diff --git a/.project-docs/30-worklog/tasks/20260825-pi-submit-rejected-hotfix-9f3c7a62.md b/.project-docs/30-worklog/tasks/20260825-pi-submit-rejected-hotfix-9f3c7a62.md new file mode 100644 index 0000000..7fdcf39 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260825-pi-submit-rejected-hotfix-9f3c7a62.md @@ -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. diff --git a/electron/coding-runtime/pi/provider-config.ts b/electron/coding-runtime/pi/provider-config.ts index ed199b0..1042105 100644 --- a/electron/coding-runtime/pi/provider-config.ts +++ b/electron/coding-runtime/pi/provider-config.ts @@ -43,6 +43,7 @@ export interface PiProviderModelDescriptor { compat?: { thinkingFormat?: 'deepseek' | 'openrouter'; sessionAffinityFormat?: 'openrouter'; + supportsDeveloperRole?: boolean; requiresReasoningContentOnAssistantMessages?: boolean; }; thinkingLevelMap?: Partial>; @@ -291,6 +292,7 @@ function modelDescriptor( summaries: readonly ModelSummary[], compat: PiProviderModelDescriptor['compat'], backendModels: Map, + 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( diff --git a/electron/coding-runtime/pi/release-proof.ts b/electron/coding-runtime/pi/release-proof.ts index a4716a7..01be38a 100644 --- a/electron/coding-runtime/pi/release-proof.ts +++ b/electron/coding-runtime/pi/release-proof.ts @@ -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): boolean { )); } +function firstMessageRole(body: Record): 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(); let closed = false; let closeFlight: Promise | 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 => { + 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 ({ 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= 2, }, currentHostTokenUsedBy, }; diff --git a/scripts/run-pi-subagent-packaged-smoke.mjs b/scripts/run-pi-subagent-packaged-smoke.mjs index f00857c..a9f7c79 100644 --- a/scripts/run-pi-subagent-packaged-smoke.mjs +++ b/scripts/run-pi-subagent-packaged-smoke.mjs @@ -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, }, diff --git a/src/stores/coding-conversations.ts b/src/stores/coding-conversations.ts index 03f6114..abc07de 100644 --- a/src/stores/coding-conversations.ts +++ b/src/stores/coding-conversations.ts @@ -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: { diff --git a/tests/unit/coding-chat-panel.test.tsx b/tests/unit/coding-chat-panel.test.tsx index c21bdf5..a2754fb 100644 --- a/tests/unit/coding-chat-panel.test.tsx +++ b/tests/unit/coding-chat-panel.test.tsx @@ -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(); + + 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 }); diff --git a/tests/unit/coding-conversations-store.test.tsx b/tests/unit/coding-conversations-store.test.tsx index 3748107..d61f4af 100644 --- a/tests/unit/coding-conversations-store.test.tsx +++ b/tests/unit/coding-conversations-store.test.tsx @@ -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); diff --git a/tests/unit/pi-provider-config.test.ts b/tests/unit/pi-provider-config.test.ts index 60a5011..65c055d 100644 --- a/tests/unit/pi-provider-config.test.ts +++ b/tests/unit/pi-provider-config.test.ts @@ -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', () => { diff --git a/tests/unit/pi-release-proof-wiring.test.ts b/tests/unit/pi-release-proof-wiring.test.ts new file mode 100644 index 0000000..c8a8b56 --- /dev/null +++ b/tests/unit/pi-release-proof-wiring.test.ts @@ -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')"); + }); +});