fix(design): preserve command outcome certainty
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
Reference in New Issue
Block a user