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,
code: error.code,
error: error.message,
...(error.commandOutcome ? { commandOutcome: error.commandOutcome } : {}),
});
return;
}

View File

@@ -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;
}
}

View File

@@ -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<string, number> = {
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<DesignCommandResult> {
const command = await this.requestJson<ServerAgentCommand>(
`/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<ServerAgentCommand>(
`/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,
};
}

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_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 =

View File

@@ -112,14 +112,23 @@ async function parseResponse<T>(response: Response): Promise<T> {
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<T>(response: Response): Promise<T> {
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 }
: {}),
});
}

View File

@@ -12,6 +12,7 @@ import {
designAssetDownloadPath,
type DesignAsset,
type DesignAssetSaveResult,
type DesignCommandFailureOutcome,
type DesignCommandInput,
type DesignCommandResult,
type DesignDeleteWorkspaceResult,
@@ -24,6 +25,7 @@ type ImageWorkspaceEnvelope<T> = {
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<T>(path: string, init: RequestInit = {}): Promise<T> {
let response: ImageWorkspaceEnvelope<T>;
try {
@@ -76,6 +90,7 @@ async function requestData<T>(path: string, init: RequestInit = {}): Promise<T>
status,
getErrorCode(error, status),
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.code ?? 'IMAGE_WORKSPACE_REQUEST_FAILED',
response.error ?? 'AI 设计请求失败',
response.commandOutcome,
);
}
return response.data;

View File

@@ -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<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 }) {
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));
});
};

View File

@@ -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<unknown>).data;
if (typeof data !== 'string') return null;
@@ -299,7 +310,16 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((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<ImageWorkspaceState>((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: {

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 () => {
invokeIpcMock.mockResolvedValueOnce({
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 { 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<typeof import('@/lib/image-workspace')>();
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(<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', () => {
const { actions } = prepareWorkspace();
useImageWorkspaceStore.setState({

View File

@@ -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<ImageWorkspaceApiError>));
});
});

View File

@@ -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({

View File

@@ -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 = {

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 () => {
const socket = createSocket();
let streamUrl = '';