fix(pi): recover from Works prompt rejection

This commit is contained in:
2026-08-25 14:50:10 +08:00
parent 4580568384
commit 5f0ef761ea
9 changed files with 439 additions and 31 deletions

View File

@@ -0,0 +1,125 @@
# Task: Fix Pi Works submit rejection and retry lock
## Identity
- Task ID: 20260825-pi-submit-rejected-hotfix-9f3c7a62
- Mode: Feature
- Branch: codex/20260825-pi-submit-rejected-hotfix-9f3c7a62-pi-submit-rejected-hotfix
- Worktree: D:\Datas\OthersProjects\makelore-pi-submit-rejected-hotfix-9f3c7a62
- Base commit: 4580568384f5391f2030bde57c63ff90891172b3
- Owner: codex-root
- Status: Verifying
## Scope
- Fix the installed Windows Pi path where the Works Square OpenAI-completions
endpoint rejects Pi's leading `developer` message, by projecting the pinned
Pi `supportsDeveloperRole: false` compatibility only for the actual Works
gateway/proxy managed-model account modes.
- Make a definite prompt submission rejection restore the target Conversation's
draft and attachments as a retryable submission error that clears when the
user edits/retries, without poisoning persistent runtime preparation state.
- Add focused provider/Renderer isolation and no-replay regressions, then build
and verify a new Windows x64 NSIS through final packaged Main/UI proof.
## Intent And Constraints
- Keep the Pi hard cutover, Pi `0.84.2`, Main-owned Provider/secret boundary,
existing Host proxy body forwarding, and one runtime path. Do not add an
OpenCode fallback, compatibility dual track, generic body rewrite, Provider
watchdog, or Pi upgrade.
- Verify the actual Provider catalog before selecting account kinds. Preserve
built-in/direct/custom OpenAI-compatible auto-detection and do not alter
OpenAI Responses, Anthropic, Google, local Provider, image, tool, session, or
child behavior.
- A definite rejection is target-scoped submission feedback. Preserve genuine
runtime/session/preparation errors and their Recover semantics; preserve
uncertain request reconciliation and never automatically replay an accepted
or uncertain prompt.
- Error text must be stable, actionable Chinese and must not expose upstream
body, prompt, headers, credentials, session content, or complete paths.
- Do not modify the read-only diagnostic worktree, dirty/occupied `main`, the
previous cumulative worktree, or the user's installed application under
`D:\Tools\泥土\niancode\Makelore`. Do not use subagents.
- Real external Provider verification remains `Explicitly Waived / Accepted
Risk` with `realTurnVerified=false`; the controlled Works-shaped endpoint is
not a full Provider-matrix Pass.
## Project Context Loaded
- Concurrent Task Gate passed: task ID, feature mode, branch, absolute worktree,
base commit, task record, ownership, and reservation match the local registry.
- Base `4580568384f5391f2030bde57c63ff90891172b3` is both the cumulative delivery
HEAD and the diagnostic HEAD, so the implementation loses no prior Pi hotfix.
- Planning Gate passed after reading entry/memory/current-state, positioning,
decisions, architecture/data-flow, domain/success, evidence/reflection,
commitments/stale records, the Pi cutover and resilience contracts, cumulative
Pi tasks, and planning peer scopes.
- Shared integrated memory predates the Pi chain and retains older OpenCode
wording. Current source, the cumulative Pi task/spec, and the read-only
diagnostic record are authoritative for this hotfix. The entry document's
shorthand paths for memory/current-state are stale; the actual files are under
`05-agent-entry` and `30-worklog`.
- The diagnostic task is the only directly overlapping peer and explicitly owns
evidence/red tests, not product implementation. Other planning tasks are
isolated, older, unrelated, or have undefined scope; no unresolved semantic
conflict changes this repair.
- Source verification confirmed exactly two managed Works credential modes:
`works_square_ai_gateway` and `works_square_ai_gateway_proxy`; both use
OpenAI Completions. Direct/custom accounts retain Pi auto-detection.
- The locked Pi `0.84.2` models schema accepts `supportsDeveloperRole`, and its
OpenAI Completions adapter selects `system` when the explicit value is false.
- Gate result: Passed.
## Plan
1. Inspect the diagnostic red-test diff and actual Provider catalog, DTO,
managed-model projection, Renderer submission state, and packaged proof seam.
2. Recreate the two red regressions in this task and add direct/custom,
cross-Conversation, second-submit, and uncertain no-replay coverage.
3. Implement the smallest Works-only compat projection and target-scoped
definite-rejection cleanup; do not modify Host proxy bodies or runtime errors.
4. Run frozen install, focused tests, typecheck, lint, full unit, production
build, and Windows Electron E2E; fix only task-caused failures.
5. Commit a clean candidate, run formal Windows packaging and artifact closure
checks, then prove final-ASAR Works-shaped reject/system acceptance and real
UI reject/edit/retry/settle/cleanup with `realTurnVerified=false`.
6. Record exact commits, changed files, verification, installer size/hash,
packaged evidence, waivers, registry status, and clean state; complete the
Task Documentation Gate.
## Outcome
- Implemented the Works-only managed-model compat override after imported model
capability merging, so both gateway paths materialize
`supportsDeveloperRole: false` without changing direct/custom or other APIs.
- Definite and uncertain submission failures remain request/submission state;
they restore the target draft without writing the Conversation entry's
persistent preparation error. Existing runtime/session recovery state remains
reserved for preparation, hydration, worker, and session failures.
- Extended the final packaged proof with a Works-shaped endpoint role canary and
a real Renderer reject/edit/retry/settle flow. The extension and proxy proof
provider modes are protected by a focused wiring regression.
- Packaging and final artifact/product proof remain pending.
## Verification
- `corepack pnpm install --frozen-lockfile` — Pass; pnpm `10.33.4`, Pi `0.84.2`.
- Initial diagnostic red command — Expected fail: 2 files / 2 tests failed,
23 passed; missing compat and permanently disabled retry reproduced.
- Focused regressions — Pass: 4 files / 49 tests, including proof wiring.
- `corepack pnpm run typecheck` — Pass (rerun after proof wiring correction).
- `corepack pnpm run lint:check` — Pass with five pre-existing warnings and no
errors.
- `corepack pnpm test` — Pass: 180 files; 1523 passed, 2 skipped.
- `corepack pnpm run build:vite` — Pass; Renderer/Main/Preload/utility built,
with existing chunk/dynamic-import warnings.
- `corepack pnpm run test:electron:windows` — Pass: 2 files / 4 tests.
## Follow-ups
- None recorded.
## Promotion Candidates
- None recorded.

View File

@@ -43,6 +43,7 @@ export interface PiProviderModelDescriptor {
compat?: {
thinkingFormat?: 'deepseek' | 'openrouter';
sessionAffinityFormat?: 'openrouter';
supportsDeveloperRole?: boolean;
requiresReasoningContentOnAssistantMessages?: boolean;
};
thinkingLevelMap?: Partial<Record<ProductModelRef['thinkingLevel'], string | null>>;
@@ -291,6 +292,7 @@ function modelDescriptor(
summaries: readonly ModelSummary[],
compat: PiProviderModelDescriptor['compat'],
backendModels: Map<string, ProviderModelEntry>,
enforcedCompat: PiProviderModelDescriptor['compat'],
): PiProviderModelDescriptor {
const summary = summaries.find((candidate) => (
candidate.id === modelId
@@ -320,8 +322,8 @@ function modelDescriptor(
|| backend?.reasoning === true,
...(contextWindow ? { contextWindow } : {}),
...(maxOutputTokens ? { maxOutputTokens } : {}),
...(compat || profile?.pi?.compat
? { compat: { ...compat, ...profile?.pi?.compat } }
...(compat || profile?.pi?.compat || enforcedCompat
? { compat: { ...compat, ...profile?.pi?.compat, ...enforcedCompat } }
: {}),
...(profile?.pi?.thinkingLevelMap
? { thinkingLevelMap: { ...profile.pi.thinkingLevelMap } }
@@ -367,12 +369,19 @@ function descriptorForAccount(
);
}
const apiKeyEnv = apiKeyEnvForAccount(account.id);
const credentialMode = account.metadata?.worksSquareCredentialMode;
const enforcedCompat: PiProviderModelDescriptor['compat'] = mapping.api === 'openai-completions'
&& (credentialMode === WORKS_SQUARE_AI_GATEWAY_CREDENTIAL_MODE
|| credentialMode === WORKS_SQUARE_AI_GATEWAY_PROXY_CREDENTIAL_MODE)
? { supportsDeveloperRole: false }
: undefined;
const models = modelIdsForAccount(account).map((modelId) => modelDescriptor(
account,
modelId,
summaries,
mapping.compat,
backendModelEntries(account),
enforcedCompat,
));
if (models.length === 0) {
throw new PiProviderConfigError(

View File

@@ -36,7 +36,7 @@ import { PiProcessBudget, PiWorkerPool, type PiWorkerPoolEvent } from './worker-
import { PiProjectWriteLeaseCoordinator, type PiProjectWriteLease } from './write-lease';
type ProofWorkerRole = 'parent' | 'child';
type ProofProviderMode = 'subagent' | 'pressure' | 'resilience';
type ProofProviderMode = 'subagent' | 'pressure' | 'resilience' | 'submit-rejection';
type ProofMilestone = PiRuntimeTelemetryEvent['milestone'] | 'agent.start' | 'provider.first_event';
type ProofMilestoneSource = 'main.telemetry' | 'pi.agent_start' | 'pi.assistant_message_start';
@@ -56,6 +56,8 @@ type ProviderRequest = {
role: ProofWorkerRole;
toolNames: string[];
hasToolResult: boolean;
firstMessageRole: string | null;
outcome: 'accepted' | 'controlled_rejection' | 'developer_role_rejection';
};
type HeldProviderResponse = {
@@ -66,6 +68,7 @@ type HeldProviderResponse = {
type LocalProofProvider = {
baseUrl: string;
requests: ProviderRequest[];
roleContract: { developerRejected: boolean; systemAccepted: boolean } | null;
activeCounts(): { parent: number; child: number };
releaseChildren(): void;
releaseParents(): void;
@@ -184,6 +187,13 @@ export interface PiReleaseProxyCompositionStatus {
logs: boolean;
diagnostics: boolean;
};
providerCompatibility: {
roleContract: { developerRejected: boolean; systemAccepted: boolean };
requestRoles: string[];
developerRoleRequests: number;
systemRoleRequests: number;
controlledDefiniteRejections: number;
};
realTurnVerified: false;
}
@@ -196,6 +206,8 @@ export interface PiReleaseProxyCompositionProof extends PiReleaseProxyCompositio
runStatus: string;
userInputProjected: boolean;
parentResponseProjected: boolean;
definiteRejectionObserved: boolean;
retryAccepted: boolean;
};
currentHostTokenUsedBy: ProofWorkerRole[];
released: { workers: number };
@@ -247,6 +259,7 @@ export interface PiReleaseResilienceProof extends PiReleaseResilienceStatus {
const PROOF_ACCOUNT_ID = 'release-proof-account';
const PROOF_AGENT_ID = 'release-proof-agent';
const PROOF_MODEL_ID = 'release-proof-model';
const PROXY_PROOF_MODEL_ID = 'deepseek-v4-pro';
const PROOF_PROVIDER_FIRST_EVENT_DELAY_MS = 75;
const EXPECTED_TURN_MILESTONES: readonly ProofMilestone[] = [
'worker.queue_wait',
@@ -327,6 +340,19 @@ function hasToolResult(body: Record<string, unknown>): boolean {
));
}
function firstMessageRole(body: Record<string, unknown>): string | null {
if (!Array.isArray(body.messages)) return null;
const first = body.messages[0];
if (!first || typeof first !== 'object' || Array.isArray(first)) return null;
const role = (first as { role?: unknown }).role;
return typeof role === 'string' ? role : null;
}
function rejectProofRequest(response: ServerResponse, message: string): void {
response.writeHead(400, { 'content-type': 'application/json' });
response.end(JSON.stringify({ error: { message } }));
}
function writeChunk(
response: ServerResponse,
model: string,
@@ -422,6 +448,7 @@ async function startLocalProofProvider(mode: ProofProviderMode): Promise<LocalPr
const held = new Set<HeldProviderResponse>();
let closed = false;
let closeFlight: Promise<void> | null = null;
let controlledSubmitRejectionIssued = false;
const server: Server = createServer(async (request, response) => {
response.once('error', () => undefined);
try {
@@ -431,10 +458,43 @@ async function startLocalProofProvider(mode: ProofProviderMode): Promise<LocalPr
}
const body = await readRequestBody(request);
const model = typeof body.model === 'string' ? body.model : PROOF_MODEL_ID;
const leadingRole = firstMessageRole(body);
if (request.headers['x-makelore-release-proof-role-canary'] === '1') {
if (leadingRole === 'developer') {
rejectProofRequest(response, 'developer role is unsupported by the controlled endpoint');
} else if (leadingRole === 'system') {
response.writeHead(204).end();
} else {
rejectProofRequest(response, 'role canary requires a leading system or developer message');
}
return;
}
const toolNames = toolNamesFrom(body);
const role: ProofWorkerRole = toolNames.includes('subagent') ? 'parent' : 'child';
const toolResult = hasToolResult(body);
requests.push({ role, toolNames, hasToolResult: toolResult });
const providerRequest: ProviderRequest = {
role,
toolNames,
hasToolResult: toolResult,
firstMessageRole: leadingRole,
outcome: 'accepted',
};
requests.push(providerRequest);
if (mode === 'submit-rejection' && leadingRole === 'developer') {
providerRequest.outcome = 'developer_role_rejection';
rejectProofRequest(response, 'developer role is unsupported by the controlled endpoint');
return;
}
if (mode === 'submit-rejection'
&& role === 'parent'
&& !toolResult
&& !controlledSubmitRejectionIssued) {
controlledSubmitRejectionIssued = true;
providerRequest.outcome = 'controlled_rejection';
rejectProofRequest(response, 'controlled definite model rejection');
return;
}
if (mode === 'resilience') {
if (role === 'child') {
@@ -470,12 +530,12 @@ async function startLocalProofProvider(mode: ProofProviderMode): Promise<LocalPr
return;
}
if (mode === 'subagent' && role === 'parent' && !toolResult) {
if ((mode === 'subagent' || mode === 'submit-rejection') && role === 'parent' && !toolResult) {
await delay(PROOF_PROVIDER_FIRST_EVENT_DELAY_MS);
respondWithSubagentCall(response, model);
return;
}
if (mode === 'subagent' && role === 'parent') {
if ((mode === 'subagent' || mode === 'submit-rejection') && role === 'parent') {
await delay(PROOF_PROVIDER_FIRST_EVENT_DELAY_MS);
respondWithText(response, model, 'REAL_PARENT_COMPLETE');
return;
@@ -506,6 +566,33 @@ async function startLocalProofProvider(mode: ProofProviderMode): Promise<LocalPr
if (!address || typeof address === 'string') {
throw new Error('Release proof Provider did not bind a loopback port');
}
const baseUrl = `http://127.0.0.1:${address.port}/v1`;
let roleContract: LocalProofProvider['roleContract'] = null;
if (mode === 'submit-rejection') {
const probe = async (role: 'developer' | 'system'): Promise<number> => {
const response = await fetch(`${baseUrl}/chat/completions`, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-makelore-release-proof-role-canary': '1',
},
body: JSON.stringify({
model: PROXY_PROOF_MODEL_ID,
messages: [{ role, content: 'role canary' }],
}),
});
await response.text();
return response.status;
};
const [developerStatus, systemStatus] = await Promise.all([
probe('developer'),
probe('system'),
]);
roleContract = {
developerRejected: developerStatus === 400,
systemAccepted: systemStatus === 204,
};
}
const release = (role?: ProofWorkerRole) => {
for (const entry of [...held]) {
if (role && entry.role !== role) continue;
@@ -514,8 +601,9 @@ async function startLocalProofProvider(mode: ProofProviderMode): Promise<LocalPr
}
};
return {
baseUrl: `http://127.0.0.1:${address.port}/v1`,
baseUrl,
requests,
roleContract,
activeCounts: () => ({
parent: [...held].filter(({ role }) => role === 'parent').length,
child: [...held].filter(({ role }) => role === 'child').length,
@@ -782,13 +870,14 @@ async function inspectWindowsPiProcesses(hostToken: string): Promise<{
return { supported: true, processes };
}
function proxyProviderAccount(baseUrl: string): ProviderAccount {
function proxyProviderAccount(baseUrl: string, modelId = PROOF_MODEL_ID): ProviderAccount {
return {
...providerAccount(baseUrl),
model: modelId,
label: 'Authenticated Host proxy release proof',
metadata: {
worksSquareCredentialMode: 'works_square_ai_gateway_proxy',
customModels: [PROOF_MODEL_ID],
customModels: [modelId],
},
};
}
@@ -811,6 +900,24 @@ async function proxyCompositionStatus(
directoryContainsValue(logsDirectory, run.hostToken),
]);
const providerRequests = providerRequestCounts(run.provider);
const providerCompatibility = {
roleContract: run.provider.roleContract ?? {
developerRejected: false,
systemAccepted: false,
},
requestRoles: run.provider.requests.flatMap(({ firstMessageRole }) => (
firstMessageRole ? [firstMessageRole] : []
)),
developerRoleRequests: run.provider.requests.filter(({ firstMessageRole }) => (
firstMessageRole === 'developer'
)).length,
systemRoleRequests: run.provider.requests.filter(({ firstMessageRole }) => (
firstMessageRole === 'system'
)).length,
controlledDefiniteRejections: run.provider.requests.filter(({ outcome }) => (
outcome === 'controlled_rejection'
)).length,
};
const parent = processInspection.processes
.filter(({ role }) => role === 'parent')
.map(({ processId }) => processId)
@@ -832,6 +939,7 @@ async function proxyCompositionStatus(
logs: !logsContainToken,
diagnostics: !JSON.stringify(diagnostics).includes(run.hostToken),
},
providerCompatibility,
realTurnVerified: false,
};
}
@@ -1256,7 +1364,7 @@ export async function startFinalAsarProxyCompositionProof(input: {
if (proxyCompositionRun) throw new Error('PI proxy composition proof is already running');
const hostToken = input.hostToken.trim();
if (!hostToken) throw new Error('Current Main Host token is unavailable');
const provider = await startLocalProofProvider('subagent');
const provider = await startLocalProofProvider('submit-rejection');
let projectId: string | null = null;
try {
seedWorksSquareAIGatewayCredential({
@@ -1264,7 +1372,10 @@ export async function startFinalAsarProxyCompositionProof(input: {
oneApiBaseUrl: provider.baseUrl,
});
const providerService = getProviderService();
await providerService.createAccount(proxyProviderAccount(input.hostProxyBaseUrl));
await providerService.createAccount(proxyProviderAccount(
input.hostProxyBaseUrl,
PROXY_PROOF_MODEL_ID,
));
await providerService.setDefaultAccount(PROOF_ACCOUNT_ID);
const project = await input.composition.projects.createProject({ projectPath: input.projectPath });
projectId = project.project.id;
@@ -1275,8 +1386,8 @@ export async function startFinalAsarProxyCompositionProof(input: {
name: 'Packaged proxy proof agent',
model: {
accountId: PROOF_ACCOUNT_ID,
modelId: PROOF_MODEL_ID,
thinkingLevel: 'medium',
modelId: PROXY_PROOF_MODEL_ID,
thinkingLevel: 'high',
},
modelResolution: 'resolved',
responsibility: {
@@ -1366,6 +1477,15 @@ export async function finishFinalAsarProxyCompositionProof(): Promise<PiReleaseP
if (!binding?.piSessionId || !binding.sessionKey) {
throw new Error('First proxy Conversation did not persist its Pi session binding');
}
if (!finalStatus.providerCompatibility.roleContract.developerRejected
|| !finalStatus.providerCompatibility.roleContract.systemAccepted
|| finalStatus.providerCompatibility.developerRoleRequests !== 0
|| finalStatus.providerCompatibility.systemRoleRequests < 2
|| finalStatus.providerCompatibility.controlledDefiniteRejections !== 1) {
throw new Error(
`Works Provider role compatibility proof failed: ${JSON.stringify(finalStatus.providerCompatibility)}`,
);
}
const tokenSafety = {
argv: activeStatus.tokenSafety.argv && finalStatus.tokenSafety.argv,
modelsJson: activeStatus.tokenSafety.modelsJson && finalStatus.tokenSafety.modelsJson,
@@ -1387,8 +1507,10 @@ export async function finishFinalAsarProxyCompositionProof(): Promise<PiReleaseP
bindingEstablished: true,
workerStatus: snapshot.worker.status,
runStatus: snapshot.run.status,
userInputProjected: serializedNodes.includes('Exercise the packaged Main proxy Conversation'),
userInputProjected: serializedNodes.includes('Retry the packaged Main proxy Conversation'),
parentResponseProjected: serializedNodes.includes('REAL_PARENT_COMPLETE'),
definiteRejectionObserved: finalStatus.providerCompatibility.controlledDefiniteRejections === 1,
retryAccepted: finalStatus.providerCompatibility.systemRoleRequests >= 2,
},
currentHostTokenUsedBy,
};

View File

@@ -143,6 +143,11 @@ function assertProxyStatus(status) {
|| status?.processes?.supported !== true
|| status?.processes?.parent?.length !== 1
|| status?.processes?.child?.length !== 1
|| status?.providerCompatibility?.roleContract?.developerRejected !== true
|| status?.providerCompatibility?.roleContract?.systemAccepted !== true
|| status?.providerCompatibility?.developerRoleRequests !== 0
|| status?.providerCompatibility?.systemRoleRequests < 2
|| status?.providerCompatibility?.controlledDefiniteRejections !== 1
|| Object.values(status?.tokenSafety ?? {}).some((safe) => safe !== true)) {
throw new Error(`Packaged Main proxy status is incomplete: ${JSON.stringify(status)}`);
}
@@ -157,6 +162,10 @@ function assertProxyProof(proof) {
|| proof?.firstConversation?.runStatus !== 'idle'
|| proof?.firstConversation?.userInputProjected !== true
|| proof?.firstConversation?.parentResponseProjected !== true
|| proof?.firstConversation?.definiteRejectionObserved !== true
|| proof?.firstConversation?.retryAccepted !== true
|| proof?.providerCompatibility?.developerRoleRequests !== 0
|| proof?.providerCompatibility?.controlledDefiniteRejections !== 1
|| proof?.currentHostTokenUsedBy?.join(',') !== 'parent,child'
|| proof?.released?.workers !== 0
|| Object.values(proof?.tokenSafety ?? {}).some((safe) => safe !== true)) {
@@ -164,19 +173,25 @@ function assertProxyProof(proof) {
}
}
async function waitForActiveProxyProof(electronApplication) {
async function waitForProxyProof(electronApplication, predicate, message) {
const deadline = Date.now() + 30_000;
let latest;
while (Date.now() < deadline) {
latest = await evaluateProof(electronApplication, 'proxy.status');
if (latest?.proxy?.activeProviderRequests?.child === 1
&& latest?.proxy?.processes?.parent?.length === 1
&& latest?.proxy?.processes?.child?.length === 1) {
return latest;
}
if (predicate(latest?.proxy)) return latest;
await new Promise((resolveWait) => setTimeout(resolveWait, 200));
}
throw new Error(`Packaged Main proxy child did not become active: ${JSON.stringify(latest?.proxy)}`);
throw new Error(`${message}: ${JSON.stringify(latest?.proxy)}`);
}
async function waitForActiveProxyProof(electronApplication) {
return await waitForProxyProof(
electronApplication,
(status) => status?.activeProviderRequests?.child === 1
&& status?.processes?.parent?.length === 1
&& status?.processes?.child?.length === 1,
'Packaged Main proxy child did not become active',
);
}
async function waitForResilienceProof(electronApplication, predicate, message) {
@@ -380,7 +395,9 @@ export async function runPackagedProductProof(options) {
await page.evaluate(() => { window.location.hash = '/chat'; });
const composer = page.getByRole('textbox');
await composer.waitFor({ state: 'visible', timeout: 30_000 });
await composer.fill('Exercise the packaged Main proxy Conversation');
const rejectedPrompt = 'Exercise the packaged Main proxy rejection';
const retryPrompt = 'Retry the packaged Main proxy Conversation';
await composer.fill(rejectedPrompt);
if (!await composer.isEnabled()) throw new Error('Packaged proxy first Conversation composer is disabled');
const beforeSubmitText = await page.locator('body').innerText();
if (beforeSubmitText.includes('本地编程运行时暂时不可用')
@@ -389,6 +406,42 @@ export async function runPackagedProductProof(options) {
}
await page.getByRole('button', { name: '发送' }).click({ timeout: 30_000 });
const rejectedProxy = await waitForProxyProof(
electronApplication,
(status) => status?.providerCompatibility?.controlledDefiniteRejections === 1,
'Packaged Main did not observe the controlled definite rejection',
);
const draftDeadline = Date.now() + 10_000;
while (Date.now() < draftDeadline && await composer.inputValue() !== rejectedPrompt) {
await new Promise((resolveWait) => setTimeout(resolveWait, 100));
}
const draftRestored = await composer.inputValue() === rejectedPrompt;
const rejectionText = await page.locator('body').innerText();
const submissionErrorSafe = !rejectionText.includes('controlled definite model rejection')
&& !rejectionText.includes('developer role is unsupported')
&& !rejectionText.includes('X-Works-Square-AI-Token')
&& !rejectionText.includes('Authorization');
if (!draftRestored || !submissionErrorSafe) {
throw new Error(
`Packaged definite rejection did not restore a safe draft: ${JSON.stringify({ draftRestored, submissionErrorSafe })}`,
);
}
if (await page.getByRole('button', { name: '重试' }).count()) {
throw new Error('Packaged definite submission rejection was exposed as a runtime recovery error');
}
await composer.fill(retryPrompt);
const sendButton = page.getByRole('button', { name: '发送' });
const retryDeadline = Date.now() + 10_000;
while (Date.now() < retryDeadline && !await sendButton.isEnabled()) {
await new Promise((resolveWait) => setTimeout(resolveWait, 100));
}
const retryAfterEditEnabled = await sendButton.isEnabled();
if (!retryAfterEditEnabled) {
const bodyText = await page.locator('body').innerText();
throw new Error(`Packaged proxy retry stayed disabled after editing: ${bodyText}`);
}
await sendButton.click({ timeout: 30_000 });
let proxyStatus;
try {
proxyStatus = await waitForActiveProxyProof(electronApplication);
@@ -590,11 +643,16 @@ export async function runPackagedProductProof(options) {
},
proxy: {
setup: proxyStart.proxy,
rejected: rejectedProxy.proxy,
active: proxyStatus.proxy,
completed: proxyFinish.proxy,
ui: {
firstConversationEditable: true,
inputSubmitted: true,
definiteRejectionObserved: true,
draftRestored,
submissionErrorSafe,
retryAfterEditEnabled,
retrySubmitted: true,
runtimeUnavailableBanner: false,
permanentRecovering: false,
},

View File

@@ -739,12 +739,10 @@ export function createCodingConversationStore(
};
});
let attachmentsPrepared = !input.prepareAttachments;
try {
if (input.prepareAttachments) {
submittedDraft.attachments = (await input.prepareAttachments())
.map((attachment) => ({ ...attachment }));
attachmentsPrepared = true;
set((state) => {
const currentEntry = state.entriesByConversationId[input.conversationId] ?? emptyEntry();
const requests = state.requestsByConversationId[input.conversationId] ?? {};
@@ -804,7 +802,6 @@ export function createCodingConversationStore(
} catch (error) {
const failure = errorDetails(error);
const uncertain = failure.code === 'CODING_REQUEST_UNCERTAIN';
const preSubmitFailure = !attachmentsPrepared;
set((state) => {
const currentEntry = state.entriesByConversationId[input.conversationId] ?? emptyEntry();
const requests = state.requestsByConversationId[input.conversationId] ?? {};
@@ -819,7 +816,6 @@ export function createCodingConversationStore(
const nextEntry = {
...currentEntry,
reducer,
error: preSubmitFailure ? currentEntry.error : failure.message,
};
return {
entriesByConversationId: {

View File

@@ -519,6 +519,49 @@ describe('CodingChatPanel first Conversation', () => {
expect(screen.getByRole('button', { name: '发送' })).toBeEnabled();
});
it('reenables the same Conversation after a definite prompt rejection', async () => {
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
projectApi.config.mockResolvedValue({ project, config });
projectApi.conversations.mockResolvedValue([conversation]);
conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource);
conversationApi.recover.mockResolvedValue(undefined);
conversationApi.submit
.mockRejectedValueOnce(new Error('所选模型当前不可用,请重新选择。'))
.mockImplementationOnce(async (input: {
conversationId: string;
clientRequestId: string;
mode: 'prompt';
}) => ({
accepted: true,
conversationId: input.conversationId,
clientRequestId: input.clientRequestId,
runId: 'run-retry',
mode: input.mode,
}));
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
const { codingConversationStore } = await import('@/stores/coding-conversations');
conversationApi.snapshot.mockResolvedValue(createLocalConversationSnapshot(project.id, conversation));
render(<CodingChatPanel />);
const textbox = await screen.findByRole('textbox');
await waitFor(() => expect(codingConversationStore.getState().selectedConversationId)
.toBe(conversation.id));
fireEvent.change(textbox, { target: { value: 'First prompt' } });
await waitFor(() => expect(screen.getByRole('button', { name: '发送' })).toBeEnabled());
fireEvent.click(screen.getByRole('button', { name: '发送' }));
await waitFor(() => expect(textbox).toHaveValue('First prompt'));
expect(await screen.findByText('所选模型当前不可用,请重新选择。')).toBeInTheDocument();
expect(codingConversationStore.getState().entriesByConversationId[conversation.id]?.error)
.toBeNull();
expect(screen.queryByRole('button', { name: '重试' })).not.toBeInTheDocument();
fireEvent.change(textbox, { target: { value: 'Retry prompt' } });
await waitFor(() => expect(screen.getByRole('button', { name: '发送' })).toBeEnabled());
fireEvent.click(screen.getByRole('button', { name: '发送' }));
await waitFor(() => expect(conversationApi.submit).toHaveBeenCalledTimes(2));
});
it('caps one message at 16 images and uploads at most four concurrently', async () => {
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
projectApi.config.mockResolvedValue({ project, config });

View File

@@ -465,11 +465,14 @@ describe('coding Conversation store', () => {
});
it('restores draft attachments and marks the optimistic node on definite rejection', async () => {
const submitPrompt = vi.fn(async () => {
throw new AppError('RUNTIME', 'Model unavailable', undefined, {
const submitPrompt = vi.fn()
.mockRejectedValueOnce(new AppError('RUNTIME', 'Model unavailable', undefined, {
backendCode: 'CODING_MODEL_UNAVAILABLE',
});
});
}))
.mockImplementationOnce(async (input) => acceptance(
input.conversationId,
input.clientRequestId,
));
const store = createCodingConversationStore({
getSnapshot: vi.fn(),
openEvents: vi.fn(),
@@ -477,11 +480,13 @@ describe('coding Conversation store', () => {
createId: ids(),
});
store.getState().applySnapshotEvent(snapshotEvent(snapshot('conversation-a')));
store.getState().applySnapshotEvent(snapshotEvent(snapshot('conversation-b')));
store.getState().setDraft('conversation-a', 'Try again', [{
attachmentId: 'attachment-1',
mime: 'image/png',
previewUrl: 'blob:preview',
}]);
store.getState().setDraft('conversation-b', 'Other conversation draft');
await expect(store.getState().submitPrompt({
conversationId: 'conversation-a',
@@ -494,8 +499,21 @@ describe('coding Conversation store', () => {
});
expect(store.getState().requestsByConversationId['conversation-a']['request-1'])
.toMatchObject({ status: 'rejected', errorCode: 'CODING_MODEL_UNAVAILABLE' });
expect(store.getState().entriesByConversationId['conversation-a'].error).toBeNull();
expect(store.getState().entriesByConversationId['conversation-b']).toMatchObject({
loadState: 'live',
error: null,
});
expect(store.getState().draftsByConversationId['conversation-b'].text)
.toBe('Other conversation draft');
expect(selectCodingConversationSnapshot('conversation-a')(store.getState())?.nodes[0])
.toMatchObject({ id: 'node-1', status: 'error' });
store.getState().setDraft('conversation-a', 'Retry now');
await store.getState().submitPrompt({ conversationId: 'conversation-a', mode: 'prompt' });
expect(submitPrompt).toHaveBeenCalledTimes(2);
expect(store.getState().requestsByConversationId['conversation-a']['request-2'])
.toMatchObject({ status: 'accepted' });
});
it('keeps attachment preparation failures out of runtime recovery and permits a direct retry', async () => {
@@ -574,6 +592,7 @@ describe('coding Conversation store', () => {
expect(store.getState().draftsByConversationId['conversation-a'].text).toBe('Do not replay');
expect(store.getState().requestsByConversationId['conversation-a']['request-1'])
.toMatchObject({ status: 'uncertain', errorCode: 'CODING_REQUEST_UNCERTAIN' });
expect(store.getState().entriesByConversationId['conversation-a'].error).toBeNull();
expect(selectCodingConversationSnapshot('conversation-a')(store.getState())?.nodes[0])
.toMatchObject({ id: 'node-1', status: 'optimistic' });
expect(submitPrompt).toHaveBeenCalledTimes(1);

View File

@@ -54,6 +54,10 @@ describe('Pi Provider catalog', () => {
'google-generative-ai',
'openai-completions',
]);
expect(catalog.descriptors.slice(0, 4).map((descriptor) => (
descriptor.models[0]?.compat?.supportsDeveloperRole
))).toEqual([undefined, undefined, undefined, undefined]);
expect(catalog.descriptors.at(-1)?.models[0]?.compat?.supportsDeveloperRole).toBeUndefined();
expect(catalog.descriptors.at(-1)?.models[0]?.compat).toEqual({
thinkingFormat: 'openrouter',
sessionAffinityFormat: 'openrouter',
@@ -105,7 +109,10 @@ describe('Pi Provider catalog', () => {
input: ['text', 'image'],
contextWindow: 1_000_000,
maxOutputTokens: 65_536,
compat: { supportsDeveloperRole: false },
});
expect(catalog.modelsFile.providers[descriptor.runtimeProviderId]?.models[0]?.compat)
.toMatchObject({ supportsDeveloperRole: false });
expect(serialized).not.toContain('private-tenant-header');
expect(serialized).not.toContain('OLD_GATEWAY_TOKEN');
@@ -146,6 +153,7 @@ describe('Pi Provider catalog', () => {
expect(resolveCredential).not.toHaveBeenCalled();
expect(Object.values(projection.env)).toContain('current-worker-host-token');
expect(Object.values(projection.env)).not.toContain('stale-stored-host-token');
expect(descriptor.models[0]?.compat?.supportsDeveloperRole).toBe(false);
});
it('uses account-scoped model capability metadata and rejects unavailable models', () => {

View File

@@ -0,0 +1,28 @@
import { readFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
describe('Pi packaged release proof wiring', () => {
it('keeps extension and proxy Provider scenarios assigned to their own proof entrypoints', async () => {
const source = await readFile(
resolve('electron/coding-runtime/pi/release-proof.ts'),
'utf8',
);
const extensionStart = source.indexOf('export async function runFinalAsarExtensionProof');
const proxyStart = source.indexOf('export async function startFinalAsarProxyCompositionProof');
const resilienceStart = source.indexOf('export async function startFinalAsarResilienceProof');
expect(extensionStart).toBeGreaterThan(-1);
expect(proxyStart).toBeGreaterThan(extensionStart);
expect(resilienceStart).toBeGreaterThan(proxyStart);
expect(source.slice(extensionStart, proxyStart))
.toContain("startLocalProofProvider('subagent')");
expect(source.slice(extensionStart, proxyStart))
.not.toContain("startLocalProofProvider('submit-rejection')");
expect(source.slice(proxyStart, resilienceStart))
.toContain("startLocalProofProvider('submit-rejection')");
expect(source.slice(proxyStart, resilienceStart))
.not.toContain("startLocalProofProvider('subagent')");
});
});