fix(design): preserve command outcome certainty

This commit is contained in:
2026-09-03 18:25:06 +08:00
parent 4d8b19eeb5
commit a16dfd0d6f
15 changed files with 576 additions and 23 deletions

View File

@@ -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.

View File

@@ -332,6 +332,7 @@ function sendRouteError(res: ServerResponse, error: unknown): void {
status: error.status, status: error.status,
code: error.code, code: error.code,
error: error.message, error: error.message,
...(error.commandOutcome ? { commandOutcome: error.commandOutcome } : {}),
}); });
return; return;
} }

View File

@@ -1,6 +1,7 @@
import type { import type {
DesignAsset, DesignAsset,
DesignAssetUploadInput, DesignAssetUploadInput,
DesignCommandFailureOutcome,
DesignCommandInput, DesignCommandInput,
DesignCommandResult, DesignCommandResult,
DesignCreateWorkspaceInput, DesignCreateWorkspaceInput,
@@ -20,12 +21,19 @@ export type CloseEventSessionsOptions = {
export class DesignWorkspaceModuleError extends Error { export class DesignWorkspaceModuleError extends Error {
readonly status: number; readonly status: number;
readonly code: string; readonly code: string;
readonly commandOutcome?: DesignCommandFailureOutcome;
constructor(status: number, code: string, message: string) { constructor(
status: number,
code: string,
message: string,
commandOutcome?: DesignCommandFailureOutcome,
) {
super(message); super(message);
this.name = 'DesignWorkspaceModuleError'; this.name = 'DesignWorkspaceModuleError';
this.status = status; this.status = status;
this.code = code; this.code = code;
this.commandOutcome = commandOutcome;
} }
} }

View File

@@ -6,6 +6,7 @@ import {
type DesignAssistantDeltaEvent, type DesignAssistantDeltaEvent,
type DesignCapabilities, type DesignCapabilities,
type DesignChangeSet, type DesignChangeSet,
type DesignCommandFailureOutcome,
type DesignCommandInput, type DesignCommandInput,
type DesignCommandResult, type DesignCommandResult,
type DesignCompilationIssue, type DesignCompilationIssue,
@@ -628,25 +629,87 @@ function userFacingErrorMessage(code: string): string {
budget_denied: '当前设计点不足,无法开始生成', budget_denied: '当前设计点不足,无法开始生成',
policy_blocked: '当前内容不符合创作安全规则', policy_blocked: '当前内容不符合创作安全规则',
design_reasoner_unavailable: '设计 Agent 暂时不可用,请稍后重试', design_reasoner_unavailable: '设计 Agent 暂时不可用,请稍后重试',
design_reasoner_invalid: 'AI 没有整理好这次想法,请再试一次',
design_agent_run_failed: 'AI 这次没有完成设计整理,请再试一次',
design_agent_run_cancelled: '这次设计整理已停止',
design_runtime_unavailable: 'AI 设计服务暂时不可用', design_runtime_unavailable: 'AI 设计服务暂时不可用',
design_production_unavailable: '当前生成能力暂时不可用', design_production_unavailable: '当前生成能力暂时不可用',
agent_runtime_unavailable: 'AI 设计服务暂时不可用,请稍后重试', agent_runtime_unavailable: 'AI 设计服务暂时不可用,请稍后重试',
agent_command_invalid: '设计请求内容无效,请检查后重试', agent_command_invalid: '设计请求内容无效,请检查后重试',
auth_required: '请先登录后再使用 AI 设计',
auth_expired: '登录状态已失效,请重新登录',
}; };
return messages[code] ?? 'AI 设计请求失败,请稍后重试'; return messages[code.toLowerCase()] ?? 'AI 设计请求失败,请稍后重试';
} }
function agentRunErrorStatus(code: string): number { function agentRunErrorStatus(code: string): number {
if (code.includes('not_found')) return 404; const normalizedCode = code.toLowerCase();
if (code === 'budget_denied') return 402; const exactStatuses: Record<string, number> = {
if (code.includes('conflict') || code.includes('expired') || code.includes('consumed')) return 409; design_direction_not_found: 404,
if (code.includes('invalid') || code === 'policy_blocked' || code === 'design_quote_blocked') { 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; return 422;
} }
if (code.includes('unavailable')) return 503; if (normalizedCode.includes('unavailable')) return 503;
return 502; 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 { function designRequestTimeoutError(): DesignWorkspaceModuleError {
return new DesignWorkspaceModuleError( return new DesignWorkspaceModuleError(
504, 504,
@@ -716,11 +779,22 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule {
} }
async submitCommand(input: DesignCommandInput): Promise<DesignCommandResult> { async submitCommand(input: DesignCommandInput): Promise<DesignCommandResult> {
const command = await this.requestJson<ServerAgentCommand>( let command: ServerAgentCommand;
`/api/agents/sessions/${encodeURIComponent(input.sessionId)}/commands`, try {
{ method: 'POST', body: JSON.stringify(agentCommand(input)) }, command = await this.requestJson<ServerAgentCommand>(
); `/api/agents/sessions/${encodeURIComponent(input.sessionId)}/commands`,
const run = await this.waitForRun(input.sessionId, command.run_id); { 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') { if (run.status !== 'succeeded') {
const code = run.error?.code ?? ( const code = run.error?.code ?? (
run.status === 'cancelled' ? 'design_agent_run_cancelled' : 'design_agent_run_failed' run.status === 'cancelled' ? 'design_agent_run_cancelled' : 'design_agent_run_failed'
@@ -729,12 +803,19 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule {
agentRunErrorStatus(code), agentRunErrorStatus(code),
code, code,
userFacingErrorMessage(code), userFacingErrorMessage(code),
'definitive_failure',
); );
} }
let workspace: DesignWorkspace;
try {
workspace = await this.getWorkspace(input.workspaceId);
} catch (error) {
throw commandFailureError(error, 'unknown');
}
return { return {
clientOperationId: input.clientOperationId, clientOperationId: input.clientOperationId,
runId: run.run_id, runId: run.run_id,
workspace: await this.getWorkspace(input.workspaceId), workspace,
}; };
} }

View File

@@ -2,6 +2,7 @@ export const IMAGE_WORKSPACE_UNAVAILABLE_CODE = 'IMAGE_WORKSPACE_UNAVAILABLE';
export const IMAGE_WORKSPACE_UNAVAILABLE_MESSAGE = 'AI 设计暂不可用'; export const IMAGE_WORKSPACE_UNAVAILABLE_MESSAGE = 'AI 设计暂不可用';
export const IMAGE_WORKSPACE_API_PATH = '/api/works/image-workspace'; export const IMAGE_WORKSPACE_API_PATH = '/api/works/image-workspace';
export type DesignCommandFailureOutcome = 'definitive_failure' | 'unknown';
export type DesignMedium = 'image' | 'video'; export type DesignMedium = 'image' | 'video';
export type DesignResolution = 'open' | 'proposed' | 'resolved' | 'delegated' | 'not_applicable'; export type DesignResolution = 'open' | 'proposed' | 'resolved' | 'delegated' | 'not_applicable';
export type DesignProvenance = export type DesignProvenance =

View File

@@ -112,14 +112,23 @@ async function parseResponse<T>(response: Response): Promise<T> {
if (!response.ok) { if (!response.ok) {
let message = `${response.status} ${response.statusText}`; let message = `${response.status} ${response.statusText}`;
let backendCode: string | undefined; let backendCode: string | undefined;
let commandOutcome: string | undefined;
try { 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) { if (payload?.error) {
message = payload.error; message = payload.error;
} }
if (typeof payload?.code === 'string' && /^[a-z][a-z0-9_]{0,63}$/u.test(payload.code)) { if (typeof payload?.code === 'string' && /^[a-z][a-z0-9_]{0,63}$/u.test(payload.code)) {
backendCode = payload.code; backendCode = payload.code;
} }
if (payload?.commandOutcome === 'definitive_failure'
|| payload?.commandOutcome === 'unknown') {
commandOutcome = payload.commandOutcome;
}
} catch { } catch {
// ignore body parse failure // ignore body parse failure
} }
@@ -127,6 +136,7 @@ async function parseResponse<T>(response: Response): Promise<T> {
source: 'browser-fallback', source: 'browser-fallback',
status: response.status, status: response.status,
...(backendCode ? { backendCode } : {}), ...(backendCode ? { backendCode } : {}),
...(commandOutcome ? { commandOutcome } : {}),
}); });
} }
@@ -155,6 +165,9 @@ function createProxyHttpError(data: HostApiProxyData): Error {
return normalizeAppError(new Error(message), { return normalizeAppError(new Error(message), {
status: data.status, status: data.status,
...(payload && typeof payload.code === 'string' ? { backendCode: payload.code } : {}), ...(payload && typeof payload.code === 'string' ? { backendCode: payload.code } : {}),
...(payload?.commandOutcome === 'definitive_failure' || payload?.commandOutcome === 'unknown'
? { commandOutcome: payload.commandOutcome }
: {}),
}); });
} }

View File

@@ -12,6 +12,7 @@ import {
designAssetDownloadPath, designAssetDownloadPath,
type DesignAsset, type DesignAsset,
type DesignAssetSaveResult, type DesignAssetSaveResult,
type DesignCommandFailureOutcome,
type DesignCommandInput, type DesignCommandInput,
type DesignCommandResult, type DesignCommandResult,
type DesignDeleteWorkspaceResult, type DesignDeleteWorkspaceResult,
@@ -24,6 +25,7 @@ type ImageWorkspaceEnvelope<T> = {
status?: number; status?: number;
code?: string; code?: string;
error?: string; error?: string;
commandOutcome?: DesignCommandFailureOutcome;
data?: T; data?: T;
}; };
@@ -32,12 +34,19 @@ export const IMAGE_WORKSPACE_CREATE_PROJECT_EVENT = 'niancode:image-workspace:cr
export class ImageWorkspaceApiError extends Error { export class ImageWorkspaceApiError extends Error {
readonly status: number; readonly status: number;
readonly code: string; readonly code: string;
readonly commandOutcome?: DesignCommandFailureOutcome;
constructor(status: number, code: string, message: string) { constructor(
status: number,
code: string,
message: string,
commandOutcome?: DesignCommandFailureOutcome,
) {
super(message); super(message);
this.name = 'ImageWorkspaceApiError'; this.name = 'ImageWorkspaceApiError';
this.status = status; this.status = status;
this.code = code; this.code = code;
this.commandOutcome = commandOutcome;
} }
} }
@@ -66,6 +75,11 @@ function getErrorCode(error: unknown, status: number): string {
return 'IMAGE_WORKSPACE_REQUEST_FAILED'; 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<T>(path: string, init: RequestInit = {}): Promise<T> { async function requestData<T>(path: string, init: RequestInit = {}): Promise<T> {
let response: ImageWorkspaceEnvelope<T>; let response: ImageWorkspaceEnvelope<T>;
try { try {
@@ -76,6 +90,7 @@ async function requestData<T>(path: string, init: RequestInit = {}): Promise<T>
status, status,
getErrorCode(error, status), getErrorCode(error, status),
error instanceof Error ? error.message : IMAGE_WORKSPACE_UNAVAILABLE_MESSAGE, error instanceof Error ? error.message : IMAGE_WORKSPACE_UNAVAILABLE_MESSAGE,
getCommandOutcome(error),
); );
} }
@@ -84,6 +99,7 @@ async function requestData<T>(path: string, init: RequestInit = {}): Promise<T>
response.status ?? 502, response.status ?? 502,
response.code ?? 'IMAGE_WORKSPACE_REQUEST_FAILED', response.code ?? 'IMAGE_WORKSPACE_REQUEST_FAILED',
response.error ?? 'AI 设计请求失败', response.error ?? 'AI 设计请求失败',
response.commandOutcome,
); );
} }
return response.data; return response.data;

View File

@@ -3,6 +3,7 @@ import { Loader2, MessageSquareText, Send, Sparkles } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { ImageWorkspaceApiError } from '@/lib/image-workspace';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useImageWorkspaceStore } from '@/stores/image-workspace'; import { useImageWorkspaceStore } from '@/stores/image-workspace';
import type { DesignWorkspace } from '../../../shared/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<string, string> = {
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 }) { export function DesignConversationPane({ workspace }: { workspace: DesignWorkspace }) {
const chatDraft = useImageWorkspaceStore((state) => state.chatDraft); const chatDraft = useImageWorkspaceStore((state) => state.chatDraft);
const setChatDraft = useImageWorkspaceStore((state) => state.setChatDraft); const setChatDraft = useImageWorkspaceStore((state) => state.setChatDraft);
@@ -43,8 +70,8 @@ export function DesignConversationPane({ workspace }: { workspace: DesignWorkspa
const submit = () => { const submit = () => {
if (!chatDraft.trim() || submittingChat) return; if (!chatDraft.trim() || submittingChat) return;
void sendChat().catch(() => { void sendChat().catch((error) => {
toast.error('消息没有发出去,请再试一次'); toast.error(chatFailureMessage(error));
}); });
}; };

View File

@@ -137,9 +137,11 @@ function isUnavailableError(error: unknown): boolean {
function isDirectionConflict(error: unknown): boolean { function isDirectionConflict(error: unknown): boolean {
return error instanceof ImageWorkspaceApiError return error instanceof ImageWorkspaceApiError
&& (error.status === 409 && [
|| error.code === 'DESIGN_DIRECTION_REVISION_CONFLICT' 'design_revision_conflict',
|| error.code === 'DIRECTION_REVISION_CONFLICT'); 'design_direction_revision_conflict',
'direction_revision_conflict',
].includes(error.code.toLowerCase());
} }
function isQuoteBlocked(error: unknown): boolean { function isQuoteBlocked(error: unknown): boolean {
@@ -147,6 +149,15 @@ function isQuoteBlocked(error: unknown): boolean {
&& error.code.toLowerCase() === 'design_quote_blocked'; && 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 { function parseWorkspaceEvent(event: Event): DesignWorkspaceEvent | null {
const data = (event as MessageEvent<unknown>).data; const data = (event as MessageEvent<unknown>).data;
if (typeof data !== 'string') return null; if (typeof data !== 'string') return null;
@@ -299,7 +310,16 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
return result.workspace; return result.workspace;
} catch (error) { } catch (error) {
const message = messageForError(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) => { set((state) => {
const pendingOperations = { ...state.pendingOperations }; const pendingOperations = { ...state.pendingOperations };
delete pendingOperations[operation.id]; delete pendingOperations[operation.id];
@@ -318,6 +338,15 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
delete pendingOperations[operation.id]; delete pendingOperations[operation.id];
return { pendingOperations, error: null }; 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 { } else {
set((state) => ({ set((state) => ({
pendingOperations: { pendingOperations: {

View File

@@ -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 () => { it('returns undefined for a unified 204 response', async () => {
invokeIpcMock.mockResolvedValueOnce({ invokeIpcMock.mockResolvedValueOnce({
ok: true, ok: true,

View File

@@ -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 { beforeEach, describe, expect, it, vi } from 'vitest';
import { ImageWorkspaceApiError } from '@/lib/image-workspace';
import { ImageCanvas } from '@/pages/ImageCanvas'; import { ImageCanvas } from '@/pages/ImageCanvas';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import { useImagePromptMuseumStore } from '@/stores/image-prompt-museum'; import { useImagePromptMuseumStore } from '@/stores/image-prompt-museum';
@@ -11,6 +12,12 @@ import {
designWorkspaceFixture, designWorkspaceFixture,
} from '../fixtures/design-workspace-v2'; } 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) => { vi.mock('@/lib/image-workspace', async (importOriginal) => {
const original = await importOriginal<typeof import('@/lib/image-workspace')>(); const original = await importOriginal<typeof import('@/lib/image-workspace')>();
return { return {
@@ -138,6 +145,26 @@ describe('youth AI Design Canvas page', () => {
expect(actions.sendChat).not.toHaveBeenCalled(); 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(<ImageCanvas />);
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', () => { it('explains an unknown accepted result without suggesting a second production', () => {
const { actions } = prepareWorkspace(); const { actions } = prepareWorkspace();
useImageWorkspaceStore.setState({ useImageWorkspaceStore.setState({

View File

@@ -189,12 +189,14 @@ describe('AI design V2 Renderer API boundary', () => {
hostApiFetchMock.mockRejectedValueOnce(new AppError('UNKNOWN', '后端拒绝', undefined, { hostApiFetchMock.mockRejectedValueOnce(new AppError('UNKNOWN', '后端拒绝', undefined, {
status: 409, status: 409,
backendCode: 'design_direction_revision_conflict', backendCode: 'design_direction_revision_conflict',
commandOutcome: 'definitive_failure',
})); }));
await expect(fetchImageWorkspace()).rejects.toEqual(expect.objectContaining({ await expect(fetchImageWorkspace()).rejects.toEqual(expect.objectContaining({
name: 'ImageWorkspaceApiError', name: 'ImageWorkspaceApiError',
status: 409, status: 409,
code: 'design_direction_revision_conflict', code: 'design_direction_revision_conflict',
commandOutcome: 'definitive_failure',
} satisfies Partial<ImageWorkspaceApiError>)); } satisfies Partial<ImageWorkspaceApiError>));
}); });
}); });

View File

@@ -3,7 +3,10 @@ import { Readable, Writable } from 'node:stream';
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { handleImageWorkspaceRoutes } from '@electron/api/routes/image-workspace'; import { handleImageWorkspaceRoutes } from '@electron/api/routes/image-workspace';
import type { HostApiContext } from '@electron/api/context'; 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 { import {
designBootstrapFixture, designBootstrapFixture,
designWorkspaceFixture, designWorkspaceFixture,
@@ -250,6 +253,37 @@ describe('AI design V2 Main route boundary', () => {
expect(module.submitCommand).not.toHaveBeenCalled(); 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 () => { it('relays resumable normalized events over local SSE', async () => {
const close = vi.fn(); const close = vi.fn();
const openWorkspaceEvents = vi.fn().mockResolvedValue({ const openWorkspaceEvents = vi.fn().mockResolvedValue({

View File

@@ -134,6 +134,97 @@ describe('V2 Living Form store', () => {
expect(useImageWorkspaceStore.getState().pendingOperations).toEqual({}); 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 () => { it('expires quote blockers only after the canonical Specification changes', async () => {
await loadedStore(); await loadedStore();
const blocker = { const blocker = {

View File

@@ -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<DesignWorkspaceModuleError>);
});
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<DesignWorkspaceModuleError>);
});
it('resumes the V2 event stream from the supplied sequence and normalizes deltas', async () => { it('resumes the V2 event stream from the supplied sequence and normalizes deltas', async () => {
const socket = createSocket(); const socket = createSocket();
let streamUrl = ''; let streamUrl = '';