From a16dfd0d6f48b99899ba84ac63bde4dcd661d067 Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Thu, 3 Sep 2026 18:25:06 +0800 Subject: [PATCH] fix(design): preserve command outcome certainty --- ...903-design-message-send-client-8d3f2a71.md | 126 ++++++++++++++++++ electron/api/routes/image-workspace.ts | 1 + electron/image-workspace/module.ts | 10 +- .../image-workspace/works-square-workspace.ts | 105 +++++++++++++-- shared/image-workspace.ts | 1 + src/lib/host-api.ts | 15 ++- src/lib/image-workspace.ts | 18 ++- .../ImageCanvas/DesignConversationPane.tsx | 31 ++++- src/stores/image-workspace.ts | 37 ++++- tests/unit/host-api.test.ts | 26 ++++ tests/unit/image-canvas-page.test.tsx | 29 +++- tests/unit/image-workspace-api.test.ts | 2 + tests/unit/image-workspace-route.test.ts | 36 ++++- tests/unit/image-workspace-store.test.ts | 91 +++++++++++++ .../works-square-design-workspace.test.ts | 71 ++++++++++ 15 files changed, 576 insertions(+), 23 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20260903-design-message-send-client-8d3f2a71.md diff --git a/.project-docs/30-worklog/tasks/20260903-design-message-send-client-8d3f2a71.md b/.project-docs/30-worklog/tasks/20260903-design-message-send-client-8d3f2a71.md new file mode 100644 index 0000000..168d110 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260903-design-message-send-client-8d3f2a71.md @@ -0,0 +1,126 @@ +# Task: Diagnose AI Design message send failure in client + +## Identity + +- Task ID: 20260903-design-message-send-client-8d3f2a71 +- Mode: Feature +- Branch: codex/20260903-design-message-send-client-8d3f2a71-design-message-send-client +- Worktree: D:\Datas\OthersProjects\makelore-design-send-failure-8d3f2a71 +- Base commit: e9875145b41a2cb1827de10d27a4fc6a352704ea +- Owner: codex-root +- Status: Ready for Integration + +## Scope + +- Diagnose the reported AI Design chat path from the Renderer composer through the + Main-owned Works Square V2 command submission, Agent run polling, canonical + Workspace refresh, and Renderer pending-operation reconciliation. +- Own only the Main-owned Works Square V2 adapter/error boundary, its local Host API + error transport, the Renderer Image Workspace API/store/chat presentation, their + shared command-outcome type, focused tests, and this task record. +- Do not modify the Works Square server while its task registry is inconsistent. + Do not touch Plugin Marketplace/My Plugins/Project Plugins pages, plugin + navigation, sidebar/router code, database/API DTOs, packaging, deployment, + publication, or paid Provider activation. + +## Intent And Constraints + +- Reproduce the exact user-visible failure with a narrow automated seam before + changing behavior, and distinguish command receipt from Agent-run success and + canonical Workspace commit. +- Preserve ADR-007: the Current Specification remains authoritative, mutation + transport ambiguity retains the original stable command/operation identity, and + no timeout or failure creates an automatic fresh request. +- Preserve the local chat draft until success is confirmed. Project terminal Agent + failures and transport-unknown results with accurate, youth-readable Chinese copy + instead of presenting every rejection as a network send failure. +- Treat the supplied trace as evidence only for request dispatch and polling: it has + no `reasoner_response` or successful canonical update event. Do not infer a + completed write from HTTP access-log `200` lines alone. + +## Concurrent Task Gate + +- Task ID: `20260903-design-message-send-client-8d3f2a71`. +- Mode: Feature. +- Branch/worktree: + `codex/20260903-design-message-send-client-8d3f2a71-design-message-send-client` / + `D:\Datas\OthersProjects\makelore-design-send-failure-8d3f2a71`. +- Base commit: `e9875145b41a2cb1827de10d27a4fc6a352704ea`. +- Ownership result: isolated and claimed by `codex-root`; the canonical root's three + existing untracked task records remain untouched. +- Peer boundary: `20260903-plugin-navigation-design-7c4e2a91` owns read-only planning + for the three Plugin entrances. This task will not touch its pages, navigation, + sidebar, or router. The integrated Youth Design task is read-only historical + context and no active peer owns the files listed above. +- Gate result: Passed. + +## Project Context Loaded + +- Read the startup memory set, active task, project positioning, current state, + decision index, system overview, business rules, success criteria, evidence index, + ADR-007, module map, data flow, and the relevant Youth Design and live-client hang + task scopes. +- Confirmed the supported V2 flow is Renderer draft -> stable command -> Main-owned + Agent command/run -> canonical Workspace refresh; access-log receipt is not the + semantic success boundary. +- Planning Gate result: Passed for the bounded client diagnosis and minimal fix above. + +## Outcome + +- The supplied production trace proves that the command reached the V2 reasoner and + that the client polled the Agent run, but it contains neither a structured reasoner + result nor the run's terminal payload. HTTP `200` polling lines therefore do not + prove that the design mutation succeeded. +- Reproduced three client defects with failing tests: terminal + `design_reasoner_invalid` was projected as HTTP 422, terminal Agent failures were + retained as transport-unknown operations, and the chat pane discarded the safe + failure and always claimed that the message was not sent. +- Added an explicit `commandOutcome` contract from Main through the local route, + Host API/AppError, Renderer API error, and store. Main now marks a failed/cancelled + Agent run as `definitive_failure`; an accepted command whose polling or canonical + refresh fails remains `unknown` with the original stable operation ID. +- Definitive failures preserve the local chat draft, remove the settled pending + operation and any partial assistant stream, and show fixed youth-readable Chinese + copy. Unknown outcomes preserve the draft, pending command identity, and partial + stream so retry cannot silently create a second mutation. Polling auth expiry is + explicitly covered by that unknown path. +- Corrected V2 run error status projection, including + `design_reasoner_invalid -> 502`, while keeping canonical 409 refresh behavior. +- No server, Plugin Marketplace/My Plugins/Project Plugins, navigation, sidebar, or + router file was changed. + +## Verification + +- Red phase: the initial focused suite failed in the three reported seams (safe chat + copy, terminal store settlement, and reasoner status/copy). The outcome-transport + extension then produced six expected failures across Main, local route, Host API, + Renderer API, and store before implementation. +- Focused and adjacent suites: 10 files / 85 tests passed after implementation; + the final stream-settlement pair passed 20/20. +- Full ordinary suite: 221 files and 1,822 tests passed, with 2 declared skips. Its + only first-run failure occurred before product code loaded because Windows denied + the Electron dependency self-installer permission to replace an in-use + `electron.exe`. Re-running that real-process file through its supported + `MAKELORE_PI_ELECTRON_EXECUTABLE` override passed 2/2 with the same 2 skips. +- The isolated pressure suite passed 1/1 with Main-to-React p95 37.94 ms. +- `pnpm exec tsc --noEmit`, scoped ESLint for every changed TypeScript file, + `pnpm run build:vite`, and `git diff --check` passed. Vite emitted only existing + Browserslist, chunk-size, and dynamic/static import warnings. +- Independent final read-only review passed with no P1/P2 findings after two found + settlement gaps were fixed: non-enumerated terminal errors and accepted-command + polling auth ambiguity, followed by partial-stream cleanup on definitive failure. + +## Follow-ups + +- Query production run `0bb3a074-aee2-43bf-af7d-ca4585b96132` read-only and inspect + `status`, `error_code`, retryability, attempts, and timestamps. The attached trace + alone cannot distinguish `design_reasoner_invalid`, provider/runtime unavailable, + or a still-running run. +- If the production run confirms a server-side reasoner failure, remediate it in a + separately owned server task after the server task registry inconsistency is + repaired; this client task intentionally made no server write. + +## Promotion Candidates + +- None. The explicit outcome transport implements the already accepted ADR-007 + requirement that ambiguous mutation results retain their stable operation identity. diff --git a/electron/api/routes/image-workspace.ts b/electron/api/routes/image-workspace.ts index 956afa8..e263fda 100644 --- a/electron/api/routes/image-workspace.ts +++ b/electron/api/routes/image-workspace.ts @@ -332,6 +332,7 @@ function sendRouteError(res: ServerResponse, error: unknown): void { status: error.status, code: error.code, error: error.message, + ...(error.commandOutcome ? { commandOutcome: error.commandOutcome } : {}), }); return; } diff --git a/electron/image-workspace/module.ts b/electron/image-workspace/module.ts index 27bca95..015aab1 100644 --- a/electron/image-workspace/module.ts +++ b/electron/image-workspace/module.ts @@ -1,6 +1,7 @@ import type { DesignAsset, DesignAssetUploadInput, + DesignCommandFailureOutcome, DesignCommandInput, DesignCommandResult, DesignCreateWorkspaceInput, @@ -20,12 +21,19 @@ export type CloseEventSessionsOptions = { export class DesignWorkspaceModuleError extends Error { readonly status: number; readonly code: string; + readonly commandOutcome?: DesignCommandFailureOutcome; - constructor(status: number, code: string, message: string) { + constructor( + status: number, + code: string, + message: string, + commandOutcome?: DesignCommandFailureOutcome, + ) { super(message); this.name = 'DesignWorkspaceModuleError'; this.status = status; this.code = code; + this.commandOutcome = commandOutcome; } } diff --git a/electron/image-workspace/works-square-workspace.ts b/electron/image-workspace/works-square-workspace.ts index 4c097ac..063a74e 100644 --- a/electron/image-workspace/works-square-workspace.ts +++ b/electron/image-workspace/works-square-workspace.ts @@ -6,6 +6,7 @@ import { type DesignAssistantDeltaEvent, type DesignCapabilities, type DesignChangeSet, + type DesignCommandFailureOutcome, type DesignCommandInput, type DesignCommandResult, type DesignCompilationIssue, @@ -628,25 +629,87 @@ function userFacingErrorMessage(code: string): string { budget_denied: '当前设计点不足,无法开始生成', policy_blocked: '当前内容不符合创作安全规则', design_reasoner_unavailable: '设计 Agent 暂时不可用,请稍后重试', + design_reasoner_invalid: 'AI 没有整理好这次想法,请再试一次', + design_agent_run_failed: 'AI 这次没有完成设计整理,请再试一次', + design_agent_run_cancelled: '这次设计整理已停止', design_runtime_unavailable: 'AI 设计服务暂时不可用', design_production_unavailable: '当前生成能力暂时不可用', agent_runtime_unavailable: 'AI 设计服务暂时不可用,请稍后重试', agent_command_invalid: '设计请求内容无效,请检查后重试', + auth_required: '请先登录后再使用 AI 设计', + auth_expired: '登录状态已失效,请重新登录', }; - return messages[code] ?? 'AI 设计请求失败,请稍后重试'; + return messages[code.toLowerCase()] ?? 'AI 设计请求失败,请稍后重试'; } function agentRunErrorStatus(code: string): number { - if (code.includes('not_found')) return 404; - if (code === 'budget_denied') return 402; - if (code.includes('conflict') || code.includes('expired') || code.includes('consumed')) return 409; - if (code.includes('invalid') || code === 'policy_blocked' || code === 'design_quote_blocked') { + const normalizedCode = code.toLowerCase(); + const exactStatuses: Record = { + design_direction_not_found: 404, + design_direction_unavailable: 409, + design_revision_conflict: 409, + design_idempotency_conflict: 409, + design_prompt_unavailable: 409, + design_reasoner_unavailable: 503, + design_reasoner_invalid: 502, + design_field_locked: 409, + design_generation_unavailable: 503, + design_quote_expired: 409, + design_quote_consumed: 409, + design_quote_invalid: 409, + design_quote_dependency_invalid: 409, + budget_denied: 402, + design_billing_unavailable: 503, + design_state_unavailable: 409, + design_command_invalid: 400, + design_input_invalid: 422, + design_agent_run_failed: 502, + design_agent_run_cancelled: 409, + }; + if (exactStatuses[normalizedCode] !== undefined) return exactStatuses[normalizedCode]; + if (normalizedCode.includes('not_found')) return 404; + if (normalizedCode.includes('conflict') + || normalizedCode.includes('expired') + || normalizedCode.includes('consumed')) return 409; + if (normalizedCode.includes('invalid') + || normalizedCode === 'policy_blocked' + || normalizedCode === 'design_quote_blocked') { return 422; } - if (code.includes('unavailable')) return 503; + if (normalizedCode.includes('unavailable')) return 503; return 502; } +function commandFailureError( + error: unknown, + outcome: DesignCommandFailureOutcome, +): DesignWorkspaceModuleError { + if (error instanceof DesignWorkspaceModuleError) { + return new DesignWorkspaceModuleError( + error.status, + error.code, + userFacingErrorMessage(error.code), + outcome, + ); + } + return new DesignWorkspaceModuleError( + 502, + 'DESIGN_WORKSPACE_REQUEST_FAILED', + 'AI 设计服务暂时无法连接', + outcome, + ); +} + +function submissionFailureOutcome(error: unknown): DesignCommandFailureOutcome { + if (error instanceof DesignWorkspaceModuleError + && error.status >= 400 + && error.status < 500 + && error.status !== 408) { + return 'definitive_failure'; + } + return 'unknown'; +} + function designRequestTimeoutError(): DesignWorkspaceModuleError { return new DesignWorkspaceModuleError( 504, @@ -716,11 +779,22 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { } async submitCommand(input: DesignCommandInput): Promise { - const command = await this.requestJson( - `/api/agents/sessions/${encodeURIComponent(input.sessionId)}/commands`, - { method: 'POST', body: JSON.stringify(agentCommand(input)) }, - ); - const run = await this.waitForRun(input.sessionId, command.run_id); + let command: ServerAgentCommand; + try { + command = await this.requestJson( + `/api/agents/sessions/${encodeURIComponent(input.sessionId)}/commands`, + { method: 'POST', body: JSON.stringify(agentCommand(input)) }, + ); + } catch (error) { + throw commandFailureError(error, submissionFailureOutcome(error)); + } + + let run: ServerAgentRun; + try { + run = await this.waitForRun(input.sessionId, command.run_id); + } catch (error) { + throw commandFailureError(error, 'unknown'); + } if (run.status !== 'succeeded') { const code = run.error?.code ?? ( run.status === 'cancelled' ? 'design_agent_run_cancelled' : 'design_agent_run_failed' @@ -729,12 +803,19 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { agentRunErrorStatus(code), code, userFacingErrorMessage(code), + 'definitive_failure', ); } + let workspace: DesignWorkspace; + try { + workspace = await this.getWorkspace(input.workspaceId); + } catch (error) { + throw commandFailureError(error, 'unknown'); + } return { clientOperationId: input.clientOperationId, runId: run.run_id, - workspace: await this.getWorkspace(input.workspaceId), + workspace, }; } diff --git a/shared/image-workspace.ts b/shared/image-workspace.ts index 3683cde..6135fed 100644 --- a/shared/image-workspace.ts +++ b/shared/image-workspace.ts @@ -2,6 +2,7 @@ export const IMAGE_WORKSPACE_UNAVAILABLE_CODE = 'IMAGE_WORKSPACE_UNAVAILABLE'; export const IMAGE_WORKSPACE_UNAVAILABLE_MESSAGE = 'AI 设计暂不可用'; export const IMAGE_WORKSPACE_API_PATH = '/api/works/image-workspace'; +export type DesignCommandFailureOutcome = 'definitive_failure' | 'unknown'; export type DesignMedium = 'image' | 'video'; export type DesignResolution = 'open' | 'proposed' | 'resolved' | 'delegated' | 'not_applicable'; export type DesignProvenance = diff --git a/src/lib/host-api.ts b/src/lib/host-api.ts index a134846..3263efa 100644 --- a/src/lib/host-api.ts +++ b/src/lib/host-api.ts @@ -112,14 +112,23 @@ async function parseResponse(response: Response): Promise { if (!response.ok) { let message = `${response.status} ${response.statusText}`; let backendCode: string | undefined; + let commandOutcome: string | undefined; try { - const payload = await response.json() as { error?: string; code?: unknown }; + const payload = await response.json() as { + error?: string; + code?: unknown; + commandOutcome?: unknown; + }; if (payload?.error) { message = payload.error; } if (typeof payload?.code === 'string' && /^[a-z][a-z0-9_]{0,63}$/u.test(payload.code)) { backendCode = payload.code; } + if (payload?.commandOutcome === 'definitive_failure' + || payload?.commandOutcome === 'unknown') { + commandOutcome = payload.commandOutcome; + } } catch { // ignore body parse failure } @@ -127,6 +136,7 @@ async function parseResponse(response: Response): Promise { source: 'browser-fallback', status: response.status, ...(backendCode ? { backendCode } : {}), + ...(commandOutcome ? { commandOutcome } : {}), }); } @@ -155,6 +165,9 @@ function createProxyHttpError(data: HostApiProxyData): Error { return normalizeAppError(new Error(message), { status: data.status, ...(payload && typeof payload.code === 'string' ? { backendCode: payload.code } : {}), + ...(payload?.commandOutcome === 'definitive_failure' || payload?.commandOutcome === 'unknown' + ? { commandOutcome: payload.commandOutcome } + : {}), }); } diff --git a/src/lib/image-workspace.ts b/src/lib/image-workspace.ts index 05d71d8..6d122f8 100644 --- a/src/lib/image-workspace.ts +++ b/src/lib/image-workspace.ts @@ -12,6 +12,7 @@ import { designAssetDownloadPath, type DesignAsset, type DesignAssetSaveResult, + type DesignCommandFailureOutcome, type DesignCommandInput, type DesignCommandResult, type DesignDeleteWorkspaceResult, @@ -24,6 +25,7 @@ type ImageWorkspaceEnvelope = { status?: number; code?: string; error?: string; + commandOutcome?: DesignCommandFailureOutcome; data?: T; }; @@ -32,12 +34,19 @@ export const IMAGE_WORKSPACE_CREATE_PROJECT_EVENT = 'niancode:image-workspace:cr export class ImageWorkspaceApiError extends Error { readonly status: number; readonly code: string; + readonly commandOutcome?: DesignCommandFailureOutcome; - constructor(status: number, code: string, message: string) { + constructor( + status: number, + code: string, + message: string, + commandOutcome?: DesignCommandFailureOutcome, + ) { super(message); this.name = 'ImageWorkspaceApiError'; this.status = status; this.code = code; + this.commandOutcome = commandOutcome; } } @@ -66,6 +75,11 @@ function getErrorCode(error: unknown, status: number): string { return 'IMAGE_WORKSPACE_REQUEST_FAILED'; } +function getCommandOutcome(error: unknown): DesignCommandFailureOutcome | undefined { + const outcome = error instanceof AppError ? error.details?.commandOutcome : undefined; + return outcome === 'definitive_failure' || outcome === 'unknown' ? outcome : undefined; +} + async function requestData(path: string, init: RequestInit = {}): Promise { let response: ImageWorkspaceEnvelope; try { @@ -76,6 +90,7 @@ async function requestData(path: string, init: RequestInit = {}): Promise status, getErrorCode(error, status), error instanceof Error ? error.message : IMAGE_WORKSPACE_UNAVAILABLE_MESSAGE, + getCommandOutcome(error), ); } @@ -84,6 +99,7 @@ async function requestData(path: string, init: RequestInit = {}): Promise response.status ?? 502, response.code ?? 'IMAGE_WORKSPACE_REQUEST_FAILED', response.error ?? 'AI 设计请求失败', + response.commandOutcome, ); } return response.data; diff --git a/src/pages/ImageCanvas/DesignConversationPane.tsx b/src/pages/ImageCanvas/DesignConversationPane.tsx index 0d14535..d5c1f80 100644 --- a/src/pages/ImageCanvas/DesignConversationPane.tsx +++ b/src/pages/ImageCanvas/DesignConversationPane.tsx @@ -3,6 +3,7 @@ import { Loader2, MessageSquareText, Send, Sparkles } from 'lucide-react'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { Textarea } from '@/components/ui/textarea'; +import { ImageWorkspaceApiError } from '@/lib/image-workspace'; import { cn } from '@/lib/utils'; import { useImageWorkspaceStore } from '@/stores/image-workspace'; import type { DesignWorkspace } from '../../../shared/image-workspace'; @@ -14,6 +15,32 @@ const STARTER_MESSAGES = [ '做一张社团活动海报', ]; +function chatFailureMessage(error: unknown): string { + if (error instanceof ImageWorkspaceApiError) { + if (error.commandOutcome === 'unknown') { + return error.status === 401 || error.status === 403 + ? '登录已过期,这条消息的结果还没确认;重新登录后请继续原操作' + : '暂时没能确认这条消息的处理结果,内容还在输入框里'; + } + const messages: Record = { + design_reasoner_invalid: 'AI 没有整理好这次想法,请再试一次', + design_reasoner_unavailable: 'AI 现在有点忙,暂时没能整理这次想法,请稍后再试', + design_agent_run_failed: 'AI 这次没有整理好,内容还在输入框里,请再试一次', + design_agent_run_cancelled: '这次整理已停止,内容还在输入框里', + design_revision_conflict: '设计内容刚刚更新了,请再发送一次', + design_direction_revision_conflict: '设计内容刚刚更新了,请再发送一次', + auth_required: '请先登录,再继续创作', + auth_expired: '登录已过期,请重新登录后继续创作', + }; + const message = messages[error.code.toLowerCase()]; + if (message) return message; + if (error.commandOutcome === 'definitive_failure') { + return 'AI 这次没有完成设计整理,内容还在输入框里,请再试一次'; + } + } + return '暂时没能确认这条消息的处理结果,内容还在输入框里'; +} + export function DesignConversationPane({ workspace }: { workspace: DesignWorkspace }) { const chatDraft = useImageWorkspaceStore((state) => state.chatDraft); const setChatDraft = useImageWorkspaceStore((state) => state.setChatDraft); @@ -43,8 +70,8 @@ export function DesignConversationPane({ workspace }: { workspace: DesignWorkspa const submit = () => { if (!chatDraft.trim() || submittingChat) return; - void sendChat().catch(() => { - toast.error('消息没有发出去,请再试一次'); + void sendChat().catch((error) => { + toast.error(chatFailureMessage(error)); }); }; diff --git a/src/stores/image-workspace.ts b/src/stores/image-workspace.ts index e4dc555..f3e0a11 100644 --- a/src/stores/image-workspace.ts +++ b/src/stores/image-workspace.ts @@ -137,9 +137,11 @@ function isUnavailableError(error: unknown): boolean { function isDirectionConflict(error: unknown): boolean { return error instanceof ImageWorkspaceApiError - && (error.status === 409 - || error.code === 'DESIGN_DIRECTION_REVISION_CONFLICT' - || error.code === 'DIRECTION_REVISION_CONFLICT'); + && [ + 'design_revision_conflict', + 'design_direction_revision_conflict', + 'direction_revision_conflict', + ].includes(error.code.toLowerCase()); } function isQuoteBlocked(error: unknown): boolean { @@ -147,6 +149,15 @@ function isQuoteBlocked(error: unknown): boolean { && error.code.toLowerCase() === 'design_quote_blocked'; } +function isDefinitiveCommandFailure(error: unknown): error is ImageWorkspaceApiError { + return error instanceof ImageWorkspaceApiError + && error.commandOutcome === 'definitive_failure'; +} + +function isUnknownCommandOutcome(error: unknown): boolean { + return error instanceof ImageWorkspaceApiError && error.commandOutcome === 'unknown'; +} + function parseWorkspaceEvent(event: Event): DesignWorkspaceEvent | null { const data = (event as MessageEvent).data; if (typeof data !== 'string') return null; @@ -299,7 +310,16 @@ export const useImageWorkspaceStore = create((set, get) => return result.workspace; } catch (error) { const message = messageForError(error); - if (isDirectionConflict(error)) { + if (isUnknownCommandOutcome(error)) { + set((state) => ({ + pendingOperations: { + ...state.pendingOperations, + [operation.id]: { ...operation, status: 'unknown', error: message }, + }, + ...(isAuthError(error) ? { status: 'auth-required' as const } : {}), + error: '操作结果尚未确认,可使用同一操作标识安全重试', + })); + } else if (isDirectionConflict(error)) { set((state) => { const pendingOperations = { ...state.pendingOperations }; delete pendingOperations[operation.id]; @@ -318,6 +338,15 @@ export const useImageWorkspaceStore = create((set, get) => delete pendingOperations[operation.id]; return { pendingOperations, error: null }; }); + } else if (isDefinitiveCommandFailure(error)) { + set((state) => { + const pendingOperations = { ...state.pendingOperations }; + delete pendingOperations[operation.id]; + const assistantStreams = { ...state.assistantStreams }; + delete assistantStreams[operation.id]; + return { pendingOperations, assistantStreams, error: message }; + }); + if (error.status === 409) await get().refreshWorkspace().catch(() => null); } else { set((state) => ({ pendingOperations: { diff --git a/tests/unit/host-api.test.ts b/tests/unit/host-api.test.ts index aa5a00e..6e556f7 100644 --- a/tests/unit/host-api.test.ts +++ b/tests/unit/host-api.test.ts @@ -54,6 +54,32 @@ describe('host-api', () => { }); }); + it('preserves command outcome certainty from the Host API error envelope', async () => { + invokeIpcMock.mockResolvedValueOnce({ + ok: true, + data: { + status: 502, + ok: false, + json: { + success: false, + code: 'design_reasoner_invalid', + error: 'AI 没有整理好这次想法,请再试一次', + commandOutcome: 'definitive_failure', + }, + }, + }); + + const { hostApiFetch } = await import('@/lib/host-api'); + + await expect(hostApiFetch('/api/works/image-workspace/workspaces/workspace-1/commands')) + .rejects.toMatchObject({ + details: expect.objectContaining({ + backendCode: 'design_reasoner_invalid', + commandOutcome: 'definitive_failure', + }), + }); + }); + it('returns undefined for a unified 204 response', async () => { invokeIpcMock.mockResolvedValueOnce({ ok: true, diff --git a/tests/unit/image-canvas-page.test.tsx b/tests/unit/image-canvas-page.test.tsx index d9a1cf6..bc4701f 100644 --- a/tests/unit/image-canvas-page.test.tsx +++ b/tests/unit/image-canvas-page.test.tsx @@ -1,5 +1,6 @@ -import { fireEvent, render, screen, within } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ImageWorkspaceApiError } from '@/lib/image-workspace'; import { ImageCanvas } from '@/pages/ImageCanvas'; import { useAuthStore } from '@/stores/auth'; import { useImagePromptMuseumStore } from '@/stores/image-prompt-museum'; @@ -11,6 +12,12 @@ import { designWorkspaceFixture, } from '../fixtures/design-workspace-v2'; +const { toastErrorMock } = vi.hoisted(() => ({ toastErrorMock: vi.fn() })); + +vi.mock('sonner', () => ({ + toast: { error: toastErrorMock, success: vi.fn() }, +})); + vi.mock('@/lib/image-workspace', async (importOriginal) => { const original = await importOriginal(); return { @@ -138,6 +145,26 @@ describe('youth AI Design Canvas page', () => { expect(actions.sendChat).not.toHaveBeenCalled(); }); + it('shows the safe reasoner failure instead of claiming the message was not sent', async () => { + const { actions } = prepareWorkspace(); + actions.sendChat.mockRejectedValueOnce(new ImageWorkspaceApiError( + 502, + 'design_reasoner_invalid', + 'AI 没有整理好这次想法,请再试一次', + )); + render(); + + fireEvent.change(screen.getByRole('textbox', { name: '告诉 AI 你想创作什么' }), { + target: { value: '画一只会做饭的机器人' }, + }); + fireEvent.click(screen.getByRole('button', { name: '发送消息' })); + + await waitFor(() => { + expect(toastErrorMock).toHaveBeenCalledWith('AI 没有整理好这次想法,请再试一次'); + }); + expect(toastErrorMock).not.toHaveBeenCalledWith('消息没有发出去,请再试一次'); + }); + it('explains an unknown accepted result without suggesting a second production', () => { const { actions } = prepareWorkspace(); useImageWorkspaceStore.setState({ diff --git a/tests/unit/image-workspace-api.test.ts b/tests/unit/image-workspace-api.test.ts index 9b91731..0ce5dc4 100644 --- a/tests/unit/image-workspace-api.test.ts +++ b/tests/unit/image-workspace-api.test.ts @@ -189,12 +189,14 @@ describe('AI design V2 Renderer API boundary', () => { hostApiFetchMock.mockRejectedValueOnce(new AppError('UNKNOWN', '后端拒绝', undefined, { status: 409, backendCode: 'design_direction_revision_conflict', + commandOutcome: 'definitive_failure', })); await expect(fetchImageWorkspace()).rejects.toEqual(expect.objectContaining({ name: 'ImageWorkspaceApiError', status: 409, code: 'design_direction_revision_conflict', + commandOutcome: 'definitive_failure', } satisfies Partial)); }); }); diff --git a/tests/unit/image-workspace-route.test.ts b/tests/unit/image-workspace-route.test.ts index 71871bc..ed5c21b 100644 --- a/tests/unit/image-workspace-route.test.ts +++ b/tests/unit/image-workspace-route.test.ts @@ -3,7 +3,10 @@ import { Readable, Writable } from 'node:stream'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { handleImageWorkspaceRoutes } from '@electron/api/routes/image-workspace'; import type { HostApiContext } from '@electron/api/context'; -import type { DesignWorkspaceModule } from '@electron/image-workspace/module'; +import { + DesignWorkspaceModuleError, + type DesignWorkspaceModule, +} from '@electron/image-workspace/module'; import { designBootstrapFixture, designWorkspaceFixture, @@ -250,6 +253,37 @@ describe('AI design V2 Main route boundary', () => { expect(module.submitCommand).not.toHaveBeenCalled(); }); + it('preserves command outcome certainty in the local error envelope', async () => { + const module = moduleFixture({ + submitCommand: vi.fn().mockRejectedValue(new DesignWorkspaceModuleError( + 502, + 'design_reasoner_invalid', + 'AI 没有整理好这次想法,请再试一次', + 'definitive_failure', + )), + }); + const response = createResponse(); + + await handleImageWorkspaceRoutes( + createRequest('POST', { + kind: 'apply_input', + sessionId: 'session-1', + expectedDirectionRevision: 4, + clientOperationId: 'operation-1', + input: { kind: 'chat', message: '画一只会做饭的机器人' }, + }), + response.res, + new URL('http://127.0.0.1/api/works/image-workspace/workspaces/workspace-1/commands'), + context(module), + ); + + expect(response.statusCode).toBe(502); + expect(response.json()).toMatchObject({ + code: 'design_reasoner_invalid', + commandOutcome: 'definitive_failure', + }); + }); + it('relays resumable normalized events over local SSE', async () => { const close = vi.fn(); const openWorkspaceEvents = vi.fn().mockResolvedValue({ diff --git a/tests/unit/image-workspace-store.test.ts b/tests/unit/image-workspace-store.test.ts index 79c38f6..059094d 100644 --- a/tests/unit/image-workspace-store.test.ts +++ b/tests/unit/image-workspace-store.test.ts @@ -134,6 +134,97 @@ describe('V2 Living Form store', () => { expect(useImageWorkspaceStore.getState().pendingOperations).toEqual({}); }); + it('settles a definitive reasoner failure without losing the chat draft', async () => { + const source = await loadedStore(); + useImageWorkspaceStore.getState().setChatDraft('画一只会做饭的机器人'); + submitCommandMock.mockImplementationOnce(async () => { + source.emit('design.assistant.delta', { + id: 'session-1:7', + type: 'design.assistant.delta', + workspaceId: 'workspace-1', + directionId: 'direction-1', + clientOperationId: 'operation-1', + directionRevision: 4, + chunkIndex: 0, + delta: '正在整理', + }); + throw new ImageWorkspaceApiError( + 502, + 'design_reasoner_invalid', + 'AI 没有整理好这次想法,请再试一次', + 'definitive_failure', + ); + }); + + await expect(useImageWorkspaceStore.getState().sendChat()).rejects.toMatchObject({ + status: 502, + code: 'design_reasoner_invalid', + }); + + expect(useImageWorkspaceStore.getState()).toMatchObject({ + chatDraft: '画一只会做饭的机器人', + error: 'AI 没有整理好这次想法,请再试一次', + }); + expect(useImageWorkspaceStore.getState().pendingOperations).toEqual({}); + expect(useImageWorkspaceStore.getState().assistantStreams).toEqual({}); + }); + + it('settles any Main-confirmed terminal run without an error-code allowlist', async () => { + await loadedStore(); + submitCommandMock.mockRejectedValueOnce(new ImageWorkspaceApiError( + 422, + 'policy_blocked', + '当前内容不符合创作安全规则', + 'definitive_failure', + )); + + await expect(useImageWorkspaceStore.getState().requestQuote()).rejects.toMatchObject({ + code: 'policy_blocked', + commandOutcome: 'definitive_failure', + }); + + expect(useImageWorkspaceStore.getState().pendingOperations).toEqual({}); + expect(useImageWorkspaceStore.getState().error).toBe('当前内容不符合创作安全规则'); + }); + + it('keeps the accepted command identity when polling auth expires', async () => { + const source = await loadedStore(); + useImageWorkspaceStore.getState().setChatDraft('画一只会做饭的机器人'); + submitCommandMock.mockImplementationOnce(async () => { + source.emit('design.assistant.delta', { + id: 'session-1:7', + type: 'design.assistant.delta', + workspaceId: 'workspace-1', + directionId: 'direction-1', + clientOperationId: 'operation-1', + directionRevision: 4, + chunkIndex: 0, + delta: '正在整理', + }); + throw new ImageWorkspaceApiError( + 401, + 'AUTH_EXPIRED', + '登录状态已失效,请重新登录', + 'unknown', + ); + }); + + await expect(useImageWorkspaceStore.getState().sendChat()).rejects.toMatchObject({ + status: 401, + code: 'AUTH_EXPIRED', + commandOutcome: 'unknown', + }); + + expect(useImageWorkspaceStore.getState().chatDraft).toBe('画一只会做饭的机器人'); + expect(useImageWorkspaceStore.getState().pendingOperations['operation-1']).toMatchObject({ + id: 'operation-1', + status: 'unknown', + }); + expect(useImageWorkspaceStore.getState().assistantStreams).toEqual({ + 'operation-1': '正在整理', + }); + }); + it('expires quote blockers only after the canonical Specification changes', async () => { await loadedStore(); const blocker = { diff --git a/tests/unit/works-square-design-workspace.test.ts b/tests/unit/works-square-design-workspace.test.ts index 2418ec1..cd1eea6 100644 --- a/tests/unit/works-square-design-workspace.test.ts +++ b/tests/unit/works-square-design-workspace.test.ts @@ -254,6 +254,77 @@ describe('Works Square V2 Design Workspace adapter', () => { ); }); + it('projects an invalid reasoner result as a safe actionable message', async () => { + const fetchMock = vi.fn(async (input: string | URL) => { + const url = String(input); + if (url.endsWith('/commands')) { + return jsonResponse({ run_id: 'run-1', status: 'queued', error: null }); + } + if (url.endsWith('/runs/run-1')) { + return jsonResponse({ + run_id: 'run-1', + status: 'failed', + error: { + code: 'design_reasoner_invalid', + message: 'Design guidance returned an invalid result', + retryable: false, + }, + }); + } + throw new Error(`Unexpected URL: ${url}`); + }); + const module = new WorksSquareDesignWorkspace({ + apiBaseUrl: 'https://works.example', + fetchImpl: fetchMock as unknown as typeof fetch, + }); + + await expect(module.submitCommand({ + kind: 'apply_input', + workspaceId: 'workspace-1', + sessionId: 'session-1', + expectedDirectionRevision: 4, + clientOperationId: 'operation-chat-1', + input: { kind: 'chat', message: '画一只会做饭的机器人' }, + })).rejects.toMatchObject({ + status: 502, + code: 'design_reasoner_invalid', + message: 'AI 没有整理好这次想法,请再试一次', + commandOutcome: 'definitive_failure', + } satisfies Partial); + }); + + it('marks polling auth failure as unknown after the command was accepted', async () => { + getTokenMock + .mockResolvedValueOnce('access-token') + .mockResolvedValueOnce('access-token') + .mockResolvedValueOnce(null); + const fetchMock = vi.fn(async (input: string | URL) => { + const url = String(input); + if (url.endsWith('/commands')) { + return jsonResponse({ run_id: 'run-1', status: 'queued', error: null }); + } + if (url.endsWith('/runs/run-1')) return jsonResponse({ detail: 'expired' }, 401); + throw new Error(`Unexpected URL: ${url}`); + }); + const module = new WorksSquareDesignWorkspace({ + apiBaseUrl: 'https://works.example', + fetchImpl: fetchMock as unknown as typeof fetch, + }); + + await expect(module.submitCommand({ + kind: 'apply_input', + workspaceId: 'workspace-1', + sessionId: 'session-1', + expectedDirectionRevision: 4, + clientOperationId: 'operation-chat-1', + input: { kind: 'chat', message: '画一只会做饭的机器人' }, + })).rejects.toMatchObject({ + status: 401, + code: 'AUTH_EXPIRED', + commandOutcome: 'unknown', + } satisfies Partial); + }); + it('resumes the V2 event stream from the supplied sequence and normalizes deltas', async () => { const socket = createSocket(); let streamUrl = '';