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

@@ -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 = '';