1686 lines
59 KiB
TypeScript
1686 lines
59 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import {
|
|
createImageWorkspaceOperationId,
|
|
createImageWorkspaceProject,
|
|
deleteImageWorkspaceProject,
|
|
fetchImageWorkspace,
|
|
fetchImageWorkspaceProject,
|
|
ImageWorkspaceApiError,
|
|
openImageWorkspaceEvents,
|
|
renameImageWorkspaceProject,
|
|
submitImageWorkspaceCommand,
|
|
} from '@/lib/image-workspace';
|
|
import { useImageWorkspaceStore } from '@/stores/image-workspace';
|
|
import {
|
|
designBootstrapFixture,
|
|
designFormFixture,
|
|
designQuoteFixture,
|
|
designSummaryFixture,
|
|
designWorkspaceFixture,
|
|
} from '../fixtures/design-workspace-v2';
|
|
|
|
vi.mock('@/lib/image-workspace', async (importOriginal) => {
|
|
const original = await importOriginal<typeof import('@/lib/image-workspace')>();
|
|
return {
|
|
...original,
|
|
createImageWorkspaceOperationId: vi.fn(),
|
|
createImageWorkspaceProject: vi.fn(),
|
|
deleteImageWorkspaceProject: vi.fn(),
|
|
fetchImageWorkspace: vi.fn(),
|
|
fetchImageWorkspaceProject: vi.fn(),
|
|
openImageWorkspaceEvents: vi.fn(),
|
|
renameImageWorkspaceProject: vi.fn(),
|
|
submitImageWorkspaceCommand: vi.fn(),
|
|
};
|
|
});
|
|
|
|
vi.mock('@/lib/host-api', async (importOriginal) => {
|
|
const original = await importOriginal<typeof import('@/lib/host-api')>();
|
|
return { ...original, setDesktopBackgroundLease: vi.fn() };
|
|
});
|
|
|
|
vi.mock('@/stores/auth', () => ({
|
|
useAuthStore: {
|
|
getState: () => ({ isAuthenticated: () => true }),
|
|
},
|
|
}));
|
|
|
|
const operationIdMock = vi.mocked(createImageWorkspaceOperationId);
|
|
const createProjectMock = vi.mocked(createImageWorkspaceProject);
|
|
const deleteProjectMock = vi.mocked(deleteImageWorkspaceProject);
|
|
const fetchBootstrapMock = vi.mocked(fetchImageWorkspace);
|
|
const fetchProjectMock = vi.mocked(fetchImageWorkspaceProject);
|
|
const openEventsMock = vi.mocked(openImageWorkspaceEvents);
|
|
const renameProjectMock = vi.mocked(renameImageWorkspaceProject);
|
|
const submitCommandMock = vi.mocked(submitImageWorkspaceCommand);
|
|
|
|
class FakeEventSource {
|
|
onopen: ((event: Event) => void) | null = null;
|
|
onerror: ((event: Event) => void) | null = null;
|
|
readonly listeners = new Map<string, Set<(event: Event) => void>>();
|
|
close = vi.fn();
|
|
|
|
addEventListener(type: string, listener: EventListenerOrEventListenerObject): void {
|
|
const callback = typeof listener === 'function'
|
|
? listener
|
|
: (event: Event) => listener.handleEvent(event);
|
|
const listeners = this.listeners.get(type) ?? new Set<(event: Event) => void>();
|
|
listeners.add(callback);
|
|
this.listeners.set(type, listeners);
|
|
}
|
|
|
|
emit(type: string, payload: unknown): void {
|
|
const event = new MessageEvent(type, { data: JSON.stringify(payload) });
|
|
this.listeners.get(type)?.forEach((listener) => listener(event));
|
|
}
|
|
}
|
|
|
|
function deferred<T>() {
|
|
let resolve!: (value: T) => void;
|
|
let reject!: (reason?: unknown) => void;
|
|
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
|
resolve = resolvePromise;
|
|
reject = rejectPromise;
|
|
});
|
|
return { promise, reject, resolve };
|
|
}
|
|
|
|
async function loadedStore(eventSource = new FakeEventSource()) {
|
|
openEventsMock.mockResolvedValue(eventSource as unknown as EventSource);
|
|
await useImageWorkspaceStore.getState().load();
|
|
await vi.waitFor(() => expect(openEventsMock).toHaveBeenCalled());
|
|
return eventSource;
|
|
}
|
|
|
|
describe('V2 Living Form store', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
useImageWorkspaceStore.getState().reset();
|
|
const workspace = designWorkspaceFixture();
|
|
fetchBootstrapMock.mockResolvedValue(designBootstrapFixture());
|
|
fetchProjectMock.mockResolvedValue(workspace);
|
|
createProjectMock.mockResolvedValue(workspace);
|
|
renameProjectMock.mockResolvedValue(workspace);
|
|
deleteProjectMock.mockResolvedValue({ workspaceId: 'workspace-1', deleted: true });
|
|
operationIdMock.mockReturnValue('operation-1');
|
|
submitCommandMock.mockResolvedValue({
|
|
clientOperationId: 'operation-1',
|
|
runId: 'run-1',
|
|
workspace,
|
|
});
|
|
});
|
|
|
|
it('loads one canonical Workspace and opens its resumable event stream', async () => {
|
|
await loadedStore();
|
|
|
|
expect(useImageWorkspaceStore.getState()).toMatchObject({
|
|
status: 'ready',
|
|
activeWorkspaceId: 'workspace-1',
|
|
workspace: { form: { specificationRevision: 3 } },
|
|
});
|
|
expect(openEventsMock).toHaveBeenCalledWith('workspace-1', 'session-1', undefined);
|
|
});
|
|
|
|
it('closes an obsolete event source when a replacement connection wins the race', async () => {
|
|
const workspace = designWorkspaceFixture();
|
|
useImageWorkspaceStore.setState({
|
|
status: 'ready',
|
|
activeWorkspaceId: workspace.workspace.workspaceId,
|
|
workspace,
|
|
});
|
|
const firstOpen = deferred<EventSource>();
|
|
const secondOpen = deferred<EventSource>();
|
|
const firstSource = new FakeEventSource();
|
|
const secondSource = new FakeEventSource();
|
|
openEventsMock
|
|
.mockReturnValueOnce(firstOpen.promise)
|
|
.mockReturnValueOnce(secondOpen.promise);
|
|
|
|
useImageWorkspaceStore.getState().connectEvents();
|
|
useImageWorkspaceStore.getState().disconnectEvents();
|
|
useImageWorkspaceStore.getState().connectEvents();
|
|
|
|
firstOpen.resolve(firstSource as unknown as EventSource);
|
|
await Promise.resolve();
|
|
secondOpen.resolve(secondSource as unknown as EventSource);
|
|
await vi.waitFor(() => expect(openEventsMock).toHaveBeenCalledTimes(2));
|
|
|
|
expect(firstSource.close).toHaveBeenCalledOnce();
|
|
expect(secondSource.close).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('ignores late activity from a closed connection after reconnecting to the same Workspace', async () => {
|
|
const firstSource = await loadedStore();
|
|
const oldOnOpen = firstSource.onopen;
|
|
const oldOnError = firstSource.onerror;
|
|
const secondSource = new FakeEventSource();
|
|
useImageWorkspaceStore.setState({
|
|
pendingOperations: {
|
|
'operation-chat-1': {
|
|
id: 'operation-chat-1',
|
|
label: '发送创作想法',
|
|
command: {
|
|
kind: 'apply_input',
|
|
workspaceId: 'workspace-1',
|
|
sessionId: 'session-1',
|
|
expectedDirectionRevision: 4,
|
|
clientOperationId: 'operation-chat-1',
|
|
input: { kind: 'chat', message: '继续完善画面' },
|
|
},
|
|
status: 'submitting',
|
|
error: null,
|
|
clearDraftPaths: [],
|
|
clearChatDraft: true,
|
|
baseRawTurnSequence: 1,
|
|
},
|
|
},
|
|
});
|
|
openEventsMock.mockResolvedValueOnce(secondSource as unknown as EventSource);
|
|
|
|
useImageWorkspaceStore.getState().disconnectEvents();
|
|
useImageWorkspaceStore.getState().connectEvents();
|
|
await vi.waitFor(() => expect(openEventsMock).toHaveBeenCalledTimes(2));
|
|
secondSource.onopen?.(new Event('open'));
|
|
const fetchCount = fetchProjectMock.mock.calls.length;
|
|
const event = {
|
|
type: 'design.assistant.progress',
|
|
workspaceId: 'workspace-1',
|
|
directionId: 'direction-1',
|
|
clientOperationId: 'operation-chat-1',
|
|
stage: 'understanding',
|
|
message: '正在听懂你刚补充的内容…',
|
|
} as const;
|
|
|
|
firstSource.emit('design.assistant.progress', { ...event, id: 'session-1:7-old' });
|
|
oldOnOpen?.(new Event('open'));
|
|
oldOnError?.(new Event('error'));
|
|
expect(useImageWorkspaceStore.getState().assistantActivities).toEqual({});
|
|
expect(useImageWorkspaceStore.getState().eventState).toBe('connected');
|
|
expect(fetchProjectMock).toHaveBeenCalledTimes(fetchCount);
|
|
|
|
secondSource.emit('design.assistant.progress', { ...event, id: 'session-1:7' });
|
|
expect(useImageWorkspaceStore.getState().assistantActivities['operation-chat-1'])
|
|
.toMatchObject({ status: 'active', steps: [{ stage: 'understanding' }] });
|
|
});
|
|
|
|
it('allows a new event connection after opening the previous one failed', async () => {
|
|
const workspace = designWorkspaceFixture();
|
|
const recoveredSource = new FakeEventSource();
|
|
useImageWorkspaceStore.setState({
|
|
status: 'ready',
|
|
activeWorkspaceId: workspace.workspace.workspaceId,
|
|
workspace,
|
|
});
|
|
openEventsMock
|
|
.mockRejectedValueOnce(new Error('temporary event connection failure'))
|
|
.mockResolvedValueOnce(recoveredSource as unknown as EventSource);
|
|
|
|
useImageWorkspaceStore.getState().connectEvents();
|
|
await vi.waitFor(() => expect(useImageWorkspaceStore.getState().eventState).toBe('degraded'));
|
|
useImageWorkspaceStore.getState().connectEvents();
|
|
await vi.waitFor(() => expect(openEventsMock).toHaveBeenCalledTimes(2));
|
|
|
|
expect(recoveredSource.close).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('uses assistant chunk indexes to ignore replayed deltas', async () => {
|
|
const source = await loadedStore();
|
|
useImageWorkspaceStore.setState({
|
|
pendingOperations: {
|
|
'operation-chat-1': {
|
|
id: 'operation-chat-1',
|
|
label: '发送创作想法',
|
|
command: {
|
|
kind: 'apply_input',
|
|
workspaceId: 'workspace-1',
|
|
sessionId: 'session-1',
|
|
expectedDirectionRevision: 4,
|
|
clientOperationId: 'operation-chat-1',
|
|
input: { kind: 'chat', message: '继续完善画面' },
|
|
},
|
|
status: 'submitting',
|
|
error: null,
|
|
clearDraftPaths: [],
|
|
clearChatDraft: true,
|
|
baseRawTurnSequence: 1,
|
|
},
|
|
},
|
|
});
|
|
const baseEvent = {
|
|
type: 'design.assistant.delta',
|
|
workspaceId: 'workspace-1',
|
|
directionId: 'direction-1',
|
|
clientOperationId: 'operation-chat-1',
|
|
directionRevision: 4,
|
|
} as const;
|
|
source.emit('design.assistant.delta', {
|
|
...baseEvent,
|
|
id: 'session-1:7',
|
|
chunkIndex: 0,
|
|
delta: '收到',
|
|
});
|
|
source.emit('design.assistant.delta', {
|
|
...baseEvent,
|
|
id: 'session-1:7-replayed',
|
|
chunkIndex: 0,
|
|
delta: '收到',
|
|
});
|
|
source.emit('design.assistant.delta', {
|
|
...baseEvent,
|
|
id: 'session-1:8',
|
|
chunkIndex: 1,
|
|
delta: ',正在整理',
|
|
});
|
|
source.emit('design.assistant.delta', {
|
|
...baseEvent,
|
|
id: 'session-1:9',
|
|
chunkIndex: 1,
|
|
delta: ',正在整理',
|
|
});
|
|
|
|
expect(useImageWorkspaceStore.getState().assistantStreams).toEqual({
|
|
'operation-chat-1': '收到,正在整理',
|
|
});
|
|
expect(useImageWorkspaceStore.getState().lastEventId).toBe('session-1:9');
|
|
});
|
|
|
|
it('keeps a field draft separate until the direct edit succeeds', async () => {
|
|
await loadedStore();
|
|
useImageWorkspaceStore.getState().setFieldDraft('output.aspect_ratio', '16:9');
|
|
|
|
await useImageWorkspaceStore.getState().applyFieldOperations(
|
|
[{ kind: 'set', path: 'output.aspect_ratio', value: '16:9' }],
|
|
['output.aspect_ratio'],
|
|
);
|
|
|
|
expect(submitCommandMock).toHaveBeenCalledWith({
|
|
kind: 'apply_input',
|
|
workspaceId: 'workspace-1',
|
|
sessionId: 'session-1',
|
|
expectedDirectionRevision: 4,
|
|
clientOperationId: 'operation-1',
|
|
input: {
|
|
kind: 'direct_edit',
|
|
operations: [{ kind: 'set', path: 'output.aspect_ratio', value: '16:9' }],
|
|
},
|
|
});
|
|
expect(useImageWorkspaceStore.getState().fieldDrafts).not.toHaveProperty('output.aspect_ratio');
|
|
expect(useImageWorkspaceStore.getState().pendingOperations).toEqual({});
|
|
});
|
|
|
|
it('publishes a pending chat operation before the server result settles', async () => {
|
|
await loadedStore();
|
|
const message = '画一只在月球踢球的熊猫';
|
|
const submission = deferred<Awaited<ReturnType<typeof submitImageWorkspaceCommand>>>();
|
|
submitCommandMock.mockReturnValueOnce(submission.promise);
|
|
useImageWorkspaceStore.getState().setChatDraft(message);
|
|
|
|
const sendPromise = useImageWorkspaceStore.getState().sendChat();
|
|
|
|
expect(useImageWorkspaceStore.getState()).toMatchObject({
|
|
chatDraft: message,
|
|
pendingOperations: {
|
|
'operation-1': {
|
|
id: 'operation-1',
|
|
status: 'submitting',
|
|
clearChatDraft: true,
|
|
command: {
|
|
kind: 'apply_input',
|
|
workspaceId: 'workspace-1',
|
|
clientOperationId: 'operation-1',
|
|
input: { kind: 'chat', message },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const workspace = designWorkspaceFixture({
|
|
turns: [{
|
|
turnId: 'turn-2',
|
|
rawTurnSequence: 2,
|
|
userMessage: message,
|
|
assistantMessage: '你希望画面更像漫画,还是更像电影?',
|
|
}],
|
|
});
|
|
submission.resolve({ clientOperationId: 'operation-1', runId: 'run-2', workspace });
|
|
await sendPromise;
|
|
|
|
expect(useImageWorkspaceStore.getState()).toMatchObject({
|
|
workspace,
|
|
chatDraft: '',
|
|
pendingOperations: {},
|
|
});
|
|
});
|
|
|
|
it('keeps public AI activity with its chat operation and settles it into the canonical turn', async () => {
|
|
const source = await loadedStore();
|
|
const message = '画一只在月球踢球的熊猫';
|
|
const submission = deferred<Awaited<ReturnType<typeof submitImageWorkspaceCommand>>>();
|
|
submitCommandMock.mockReturnValueOnce(submission.promise);
|
|
useImageWorkspaceStore.getState().setChatDraft(message);
|
|
|
|
const sendPromise = useImageWorkspaceStore.getState().sendChat();
|
|
const progress = {
|
|
type: 'design.assistant.progress',
|
|
workspaceId: 'workspace-1',
|
|
directionId: 'direction-1',
|
|
clientOperationId: 'operation-1',
|
|
} as const;
|
|
source.emit('design.assistant.progress', {
|
|
...progress,
|
|
id: 'session-1:7',
|
|
stage: 'understanding',
|
|
message: '正在听懂你刚补充的内容…',
|
|
});
|
|
source.emit('design.assistant.progress', {
|
|
...progress,
|
|
id: 'session-1:8',
|
|
stage: 'reviewing_context',
|
|
message: '正在把它和前面的设计想法放在一起看…',
|
|
});
|
|
|
|
expect(useImageWorkspaceStore.getState().assistantActivities['operation-1'])
|
|
.toMatchObject({
|
|
workspaceId: 'workspace-1',
|
|
directionId: 'direction-1',
|
|
status: 'active',
|
|
turnId: null,
|
|
steps: [
|
|
{ stage: 'understanding', message: '正在听懂你刚补充的内容…' },
|
|
{ stage: 'reviewing_context', message: '正在把它和前面的设计想法放在一起看…' },
|
|
],
|
|
});
|
|
|
|
source.emit('design.assistant.progress', {
|
|
...progress,
|
|
id: 'session-1:9',
|
|
stage: 'understanding',
|
|
message: '迟到的旧阶段',
|
|
});
|
|
expect(useImageWorkspaceStore.getState().assistantActivities['operation-1']?.steps)
|
|
.toHaveLength(2);
|
|
|
|
source.emit('design.assistant.delta', {
|
|
id: 'session-1:10',
|
|
type: 'design.assistant.delta',
|
|
workspaceId: 'workspace-1',
|
|
directionId: 'direction-1',
|
|
clientOperationId: 'operation-1',
|
|
directionRevision: 5,
|
|
chunkIndex: 0,
|
|
delta: '这个画面很有趣。',
|
|
});
|
|
expect(useImageWorkspaceStore.getState().assistantActivities['operation-1']?.status)
|
|
.toBe('completed');
|
|
|
|
const workspace = designWorkspaceFixture({
|
|
turns: [
|
|
{
|
|
turnId: 'turn-2',
|
|
rawTurnSequence: 2,
|
|
userMessage: message,
|
|
assistantMessage: '这个画面很有趣。',
|
|
},
|
|
{
|
|
turnId: 'turn-3',
|
|
rawTurnSequence: 3,
|
|
userMessage: '后来补充的另一条消息',
|
|
assistantMessage: '这是后来的回复。',
|
|
},
|
|
],
|
|
});
|
|
submission.resolve({ clientOperationId: 'operation-1', runId: 'run-2', workspace });
|
|
await sendPromise;
|
|
|
|
expect(useImageWorkspaceStore.getState().assistantActivities['operation-1'])
|
|
.toMatchObject({ status: 'completed', turnId: 'turn-2' });
|
|
expect(useImageWorkspaceStore.getState().pendingOperations).toEqual({});
|
|
});
|
|
|
|
it('settles an unknown chat from a replayed Gateway terminal event', async () => {
|
|
const source = await loadedStore();
|
|
const message = '画一只在月球踢球的熊猫';
|
|
const canonical = designWorkspaceFixture({
|
|
form: designFormFixture({ rawTurnSequence: 2, directionRevision: 5 }),
|
|
turns: [{
|
|
turnId: 'turn-2',
|
|
rawTurnSequence: 2,
|
|
userMessage: message,
|
|
assistantMessage: '你希望它更像漫画还是电影?',
|
|
}],
|
|
});
|
|
fetchProjectMock.mockResolvedValueOnce(canonical);
|
|
useImageWorkspaceStore.setState({
|
|
chatDraft: message,
|
|
pendingOperations: {
|
|
'operation-chat-unknown': {
|
|
id: 'operation-chat-unknown',
|
|
label: '发送创作想法',
|
|
command: {
|
|
kind: 'apply_input',
|
|
workspaceId: 'workspace-1',
|
|
sessionId: 'session-1',
|
|
expectedDirectionRevision: 4,
|
|
clientOperationId: 'operation-chat-unknown',
|
|
input: { kind: 'chat', message },
|
|
},
|
|
status: 'unknown',
|
|
error: '连接暂时中断',
|
|
clearDraftPaths: [],
|
|
clearChatDraft: true,
|
|
baseRawTurnSequence: 1,
|
|
},
|
|
},
|
|
assistantActivities: {
|
|
'operation-chat-unknown': {
|
|
workspaceId: 'workspace-1',
|
|
directionId: 'direction-1',
|
|
status: 'unknown',
|
|
turnId: null,
|
|
steps: [{ stage: 'reviewing_context', message: '正在整理上下文' }],
|
|
},
|
|
},
|
|
});
|
|
|
|
source.emit('command.completed', {
|
|
id: 'session-1:20',
|
|
type: 'command.completed',
|
|
workspaceId: 'workspace-1',
|
|
clientOperationId: 'operation-chat-unknown',
|
|
outcome: 'succeeded',
|
|
errorCode: null,
|
|
});
|
|
|
|
await vi.waitFor(() => {
|
|
expect(useImageWorkspaceStore.getState().pendingOperations).toEqual({});
|
|
});
|
|
expect(useImageWorkspaceStore.getState()).toMatchObject({
|
|
chatDraft: '',
|
|
workspace: { form: { rawTurnSequence: 2 } },
|
|
assistantActivities: {
|
|
'operation-chat-unknown': { status: 'completed', turnId: 'turn-2' },
|
|
},
|
|
});
|
|
});
|
|
|
|
it('keeps a succeeded terminal event authoritative when command polling then disconnects', async () => {
|
|
const source = await loadedStore();
|
|
const message = '画一只在月球踢球的熊猫';
|
|
const canonical = designWorkspaceFixture({
|
|
form: designFormFixture({ rawTurnSequence: 2, directionRevision: 5 }),
|
|
turns: [{
|
|
turnId: 'turn-2',
|
|
rawTurnSequence: 2,
|
|
userMessage: message,
|
|
assistantMessage: '你希望它更像漫画还是电影?',
|
|
}],
|
|
});
|
|
fetchProjectMock.mockResolvedValue(canonical);
|
|
const submission = deferred<Awaited<ReturnType<typeof submitImageWorkspaceCommand>>>();
|
|
submitCommandMock.mockReturnValueOnce(submission.promise);
|
|
useImageWorkspaceStore.getState().setChatDraft(message);
|
|
|
|
const sendPromise = useImageWorkspaceStore.getState().sendChat();
|
|
source.emit('command.completed', {
|
|
id: 'session-1:20',
|
|
type: 'command.completed',
|
|
workspaceId: 'workspace-1',
|
|
clientOperationId: 'operation-1',
|
|
outcome: 'succeeded',
|
|
errorCode: null,
|
|
});
|
|
submission.reject(new ImageWorkspaceApiError(
|
|
502,
|
|
'IMAGE_WORKSPACE_REQUEST_FAILED',
|
|
'轮询连接中断',
|
|
'unknown',
|
|
));
|
|
|
|
await expect(sendPromise).resolves.toMatchObject({
|
|
form: { rawTurnSequence: 2 },
|
|
});
|
|
expect(useImageWorkspaceStore.getState()).toMatchObject({
|
|
chatDraft: '',
|
|
error: null,
|
|
pendingOperations: {},
|
|
workspace: { form: { rawTurnSequence: 2 } },
|
|
});
|
|
});
|
|
|
|
it('keeps a failed terminal event authoritative when command polling then disconnects', async () => {
|
|
const source = await loadedStore();
|
|
const submission = deferred<Awaited<ReturnType<typeof submitImageWorkspaceCommand>>>();
|
|
submitCommandMock.mockReturnValueOnce(submission.promise);
|
|
useImageWorkspaceStore.getState().setChatDraft('画一只机器人');
|
|
|
|
const sendPromise = useImageWorkspaceStore.getState().sendChat();
|
|
source.emit('command.failed', {
|
|
id: 'session-1:20',
|
|
type: 'command.failed',
|
|
workspaceId: 'workspace-1',
|
|
clientOperationId: 'operation-1',
|
|
outcome: 'failed',
|
|
errorCode: 'design_reasoner_invalid',
|
|
});
|
|
submission.reject(new ImageWorkspaceApiError(
|
|
502,
|
|
'IMAGE_WORKSPACE_REQUEST_FAILED',
|
|
'轮询连接中断',
|
|
'unknown',
|
|
));
|
|
|
|
await expect(sendPromise).rejects.toMatchObject({
|
|
code: 'design_reasoner_invalid',
|
|
commandOutcome: 'definitive_failure',
|
|
});
|
|
expect(useImageWorkspaceStore.getState()).toMatchObject({
|
|
chatDraft: '画一只机器人',
|
|
error: 'AI 这次没有完成设计整理,内容还在输入框里,请再试一次',
|
|
pendingOperations: {},
|
|
assistantActivities: {},
|
|
assistantStreams: {},
|
|
});
|
|
});
|
|
|
|
it('does not let a late polling success overwrite a Gateway terminal failure', async () => {
|
|
const source = await loadedStore();
|
|
const submission = deferred<Awaited<ReturnType<typeof submitImageWorkspaceCommand>>>();
|
|
submitCommandMock.mockReturnValueOnce(submission.promise);
|
|
useImageWorkspaceStore.getState().setChatDraft('画一只机器人');
|
|
|
|
const sendPromise = useImageWorkspaceStore.getState().sendChat();
|
|
source.emit('command.failed', {
|
|
id: 'session-1:20',
|
|
type: 'command.failed',
|
|
workspaceId: 'workspace-1',
|
|
clientOperationId: 'operation-1',
|
|
outcome: 'failed',
|
|
errorCode: 'design_reasoner_invalid',
|
|
});
|
|
submission.resolve({
|
|
clientOperationId: 'operation-1',
|
|
runId: 'run-1',
|
|
workspace: designWorkspaceFixture({
|
|
form: designFormFixture({ rawTurnSequence: 2, directionRevision: 5 }),
|
|
}),
|
|
});
|
|
|
|
await expect(sendPromise).rejects.toMatchObject({
|
|
code: 'design_reasoner_invalid',
|
|
commandOutcome: 'definitive_failure',
|
|
});
|
|
expect(useImageWorkspaceStore.getState()).toMatchObject({
|
|
chatDraft: '画一只机器人',
|
|
error: 'AI 这次没有完成设计整理,内容还在输入框里,请再试一次',
|
|
pendingOperations: {},
|
|
});
|
|
});
|
|
|
|
it('does not resurrect pending after direction success settles before polling disconnects', async () => {
|
|
const source = await loadedStore();
|
|
const message = '画一只在月球踢球的熊猫';
|
|
const canonical = designWorkspaceFixture({
|
|
form: designFormFixture({ rawTurnSequence: 2, directionRevision: 5 }),
|
|
turns: [{
|
|
turnId: 'turn-2',
|
|
rawTurnSequence: 2,
|
|
userMessage: message,
|
|
assistantMessage: '你希望它更像漫画还是电影?',
|
|
}],
|
|
});
|
|
fetchProjectMock.mockResolvedValue(canonical);
|
|
const submission = deferred<Awaited<ReturnType<typeof submitImageWorkspaceCommand>>>();
|
|
submitCommandMock.mockReturnValueOnce(submission.promise);
|
|
useImageWorkspaceStore.getState().setChatDraft(message);
|
|
const sendPromise = useImageWorkspaceStore.getState().sendChat();
|
|
|
|
source.emit('design.direction.updated', {
|
|
id: 'session-1:19',
|
|
type: 'design.direction.updated',
|
|
clientOperationId: 'operation-1',
|
|
replayed: false,
|
|
operation: {
|
|
operationKind: 'chat',
|
|
interactionId: 'interaction-2',
|
|
baseDirectionRevision: 4,
|
|
newDirectionRevision: 5,
|
|
rawTurnSequence: 2,
|
|
specificationRevision: 3,
|
|
specificationRevisionId: 'specification-revision-3',
|
|
workspaceViewRevision: 7,
|
|
specificationRevisionCreated: false,
|
|
meaningChanged: false,
|
|
changeSet: { interaction_id: 'interaction-2', changes: [] },
|
|
turnId: 'turn-2',
|
|
assistantMessage: '你希望它更像漫画还是电影?',
|
|
createdDecisionPromptIds: [],
|
|
resolvedDecisionPromptId: null,
|
|
supersededDecisionPromptIds: [],
|
|
supersededQuoteCount: 0,
|
|
quoteId: null,
|
|
generationTaskId: null,
|
|
},
|
|
form: designFormFixture({ rawTurnSequence: 2, directionRevision: 5 }),
|
|
});
|
|
await vi.waitFor(() => {
|
|
expect(useImageWorkspaceStore.getState().pendingOperations).toEqual({});
|
|
});
|
|
source.emit('command.completed', {
|
|
id: 'session-1:20',
|
|
type: 'command.completed',
|
|
workspaceId: 'workspace-1',
|
|
clientOperationId: 'operation-1',
|
|
outcome: 'succeeded',
|
|
errorCode: null,
|
|
});
|
|
submission.reject(new ImageWorkspaceApiError(
|
|
502,
|
|
'IMAGE_WORKSPACE_REQUEST_FAILED',
|
|
'轮询连接中断',
|
|
'unknown',
|
|
));
|
|
|
|
await expect(sendPromise).resolves.toMatchObject({ form: { rawTurnSequence: 2 } });
|
|
expect(useImageWorkspaceStore.getState()).toMatchObject({
|
|
chatDraft: '',
|
|
error: null,
|
|
pendingOperations: {},
|
|
workspace: { form: { rawTurnSequence: 2 } },
|
|
});
|
|
});
|
|
|
|
it('keeps the Quote created immediately after a committed generation chat', async () => {
|
|
const source = await loadedStore();
|
|
const message = '开始生成';
|
|
const quoteForm = designFormFixture({
|
|
directionRevision: 6,
|
|
rawTurnSequence: 2,
|
|
workspaceViewRevision: 8,
|
|
activeQuotes: [designQuoteFixture()],
|
|
});
|
|
const canonical = designWorkspaceFixture({
|
|
form: quoteForm,
|
|
turns: [
|
|
...designWorkspaceFixture().turns,
|
|
{
|
|
turnId: 'turn-2',
|
|
rawTurnSequence: 2,
|
|
userMessage: message,
|
|
assistantMessage: '我先检查制作方案。准备好后需要你确认一次,确认后才会开始生成。',
|
|
},
|
|
],
|
|
});
|
|
fetchProjectMock.mockResolvedValue(canonical);
|
|
useImageWorkspaceStore.setState({
|
|
chatDraft: message,
|
|
pendingOperations: {
|
|
'operation-1': {
|
|
id: 'operation-1',
|
|
label: '发送创作想法',
|
|
command: {
|
|
kind: 'apply_input',
|
|
workspaceId: 'workspace-1',
|
|
sessionId: 'session-1',
|
|
expectedDirectionRevision: 4,
|
|
clientOperationId: 'operation-1',
|
|
input: { kind: 'chat', message },
|
|
},
|
|
status: 'submitting',
|
|
error: null,
|
|
clearDraftPaths: [],
|
|
clearChatDraft: true,
|
|
baseRawTurnSequence: 1,
|
|
},
|
|
},
|
|
assistantActivities: {
|
|
'operation-1': {
|
|
workspaceId: 'workspace-1',
|
|
directionId: 'direction-1',
|
|
status: 'submitting',
|
|
turnId: null,
|
|
steps: [],
|
|
},
|
|
},
|
|
});
|
|
|
|
source.emit('design.direction.updated', {
|
|
id: 'session-1:19',
|
|
type: 'design.direction.updated',
|
|
clientOperationId: 'operation-1',
|
|
replayed: false,
|
|
operation: {
|
|
operationKind: 'chat',
|
|
interactionId: 'interaction-2',
|
|
baseDirectionRevision: 4,
|
|
newDirectionRevision: 5,
|
|
rawTurnSequence: 2,
|
|
specificationRevision: 3,
|
|
specificationRevisionId: 'specification-revision-3',
|
|
workspaceViewRevision: 7,
|
|
specificationRevisionCreated: false,
|
|
meaningChanged: false,
|
|
changeSet: { interaction_id: 'interaction-2', changes: [] },
|
|
turnId: 'turn-2',
|
|
assistantMessage: '我先检查制作方案。准备好后需要你确认一次,确认后才会开始生成。',
|
|
createdDecisionPromptIds: [],
|
|
resolvedDecisionPromptId: null,
|
|
supersededDecisionPromptIds: [],
|
|
supersededQuoteCount: 0,
|
|
quoteId: null,
|
|
generationTaskId: null,
|
|
},
|
|
form: designFormFixture({
|
|
directionRevision: 5,
|
|
rawTurnSequence: 2,
|
|
workspaceViewRevision: 7,
|
|
}),
|
|
});
|
|
source.emit('design.direction.updated', {
|
|
id: 'session-1:20',
|
|
type: 'design.direction.updated',
|
|
clientOperationId: 'operation-1',
|
|
replayed: false,
|
|
operation: {
|
|
operationKind: 'quote_request',
|
|
interactionId: 'interaction-quote-1',
|
|
baseDirectionRevision: 5,
|
|
newDirectionRevision: 6,
|
|
rawTurnSequence: 2,
|
|
specificationRevision: 3,
|
|
specificationRevisionId: 'specification-revision-3',
|
|
workspaceViewRevision: 8,
|
|
specificationRevisionCreated: false,
|
|
meaningChanged: false,
|
|
changeSet: { interaction_id: 'interaction-quote-1', changes: [] },
|
|
turnId: null,
|
|
assistantMessage: null,
|
|
createdDecisionPromptIds: [],
|
|
resolvedDecisionPromptId: null,
|
|
supersededDecisionPromptIds: [],
|
|
supersededQuoteCount: 0,
|
|
quoteId: 'quote-1',
|
|
generationTaskId: null,
|
|
},
|
|
form: quoteForm,
|
|
});
|
|
|
|
await vi.waitFor(() => {
|
|
expect(useImageWorkspaceStore.getState()).toMatchObject({
|
|
chatDraft: '',
|
|
pendingOperations: {},
|
|
workspace: { form: { directionRevision: 6 } },
|
|
});
|
|
});
|
|
expect(useImageWorkspaceStore.getState().workspace?.form.activeQuotes)
|
|
.toEqual([designQuoteFixture()]);
|
|
expect(useImageWorkspaceStore.getState().assistantActivities).toMatchObject({
|
|
'operation-1': { status: 'completed', turnId: 'turn-2' },
|
|
});
|
|
});
|
|
|
|
it('keeps the optimistic chat until a terminal success is visible in the canonical Workspace', async () => {
|
|
const source = await loadedStore();
|
|
const message = '画一只在月球踢球的熊猫';
|
|
useImageWorkspaceStore.setState({
|
|
chatDraft: message,
|
|
pendingOperations: {
|
|
'operation-chat-unknown': {
|
|
id: 'operation-chat-unknown',
|
|
label: '发送创作想法',
|
|
command: {
|
|
kind: 'apply_input',
|
|
workspaceId: 'workspace-1',
|
|
sessionId: 'session-1',
|
|
expectedDirectionRevision: 4,
|
|
clientOperationId: 'operation-chat-unknown',
|
|
input: { kind: 'chat', message },
|
|
},
|
|
status: 'unknown',
|
|
error: '连接暂时中断',
|
|
clearDraftPaths: [],
|
|
clearChatDraft: true,
|
|
baseRawTurnSequence: 1,
|
|
},
|
|
},
|
|
assistantActivities: {
|
|
'operation-chat-unknown': {
|
|
workspaceId: 'workspace-1',
|
|
directionId: 'direction-1',
|
|
status: 'unknown',
|
|
turnId: null,
|
|
steps: [{ stage: 'composing', message: '已经整理好,正在准备回复…' }],
|
|
},
|
|
},
|
|
});
|
|
fetchProjectMock.mockResolvedValueOnce(designWorkspaceFixture());
|
|
|
|
source.emit('command.completed', {
|
|
id: 'session-1:20',
|
|
type: 'command.completed',
|
|
workspaceId: 'workspace-1',
|
|
clientOperationId: 'operation-chat-unknown',
|
|
outcome: 'succeeded',
|
|
errorCode: null,
|
|
});
|
|
await vi.waitFor(() => {
|
|
expect(fetchProjectMock).toHaveBeenCalledTimes(2);
|
|
});
|
|
expect(useImageWorkspaceStore.getState()).toMatchObject({
|
|
chatDraft: message,
|
|
pendingOperations: {
|
|
'operation-chat-unknown': { status: 'unknown' },
|
|
},
|
|
});
|
|
|
|
const canonical = designWorkspaceFixture({
|
|
form: designFormFixture({ rawTurnSequence: 2, directionRevision: 5 }),
|
|
turns: [{
|
|
turnId: 'turn-2',
|
|
rawTurnSequence: 2,
|
|
userMessage: message,
|
|
assistantMessage: '你希望它更像漫画还是电影?',
|
|
}],
|
|
});
|
|
fetchProjectMock.mockResolvedValueOnce(canonical);
|
|
source.emit('design.direction.updated', {
|
|
id: 'session-1:21',
|
|
type: 'design.direction.updated',
|
|
clientOperationId: 'operation-chat-unknown',
|
|
replayed: false,
|
|
operation: {
|
|
operationKind: 'chat',
|
|
interactionId: 'interaction-2',
|
|
baseDirectionRevision: 4,
|
|
newDirectionRevision: 5,
|
|
rawTurnSequence: 2,
|
|
specificationRevision: 3,
|
|
specificationRevisionId: 'specification-revision-3',
|
|
workspaceViewRevision: 7,
|
|
specificationRevisionCreated: false,
|
|
meaningChanged: false,
|
|
changeSet: { interaction_id: 'interaction-2', changes: [] },
|
|
turnId: 'turn-2',
|
|
assistantMessage: '你希望它更像漫画还是电影?',
|
|
createdDecisionPromptIds: [],
|
|
resolvedDecisionPromptId: null,
|
|
supersededDecisionPromptIds: [],
|
|
supersededQuoteCount: 0,
|
|
quoteId: null,
|
|
generationTaskId: null,
|
|
},
|
|
form: designFormFixture({ rawTurnSequence: 2, directionRevision: 5 }),
|
|
});
|
|
|
|
await vi.waitFor(() => {
|
|
expect(useImageWorkspaceStore.getState().pendingOperations).toEqual({});
|
|
});
|
|
expect(useImageWorkspaceStore.getState()).toMatchObject({
|
|
chatDraft: '',
|
|
assistantActivities: {
|
|
'operation-chat-unknown': { status: 'completed', turnId: 'turn-2' },
|
|
},
|
|
});
|
|
});
|
|
|
|
it('uses the Gateway operation turn ID when identical chats are both pending', async () => {
|
|
const source = await loadedStore();
|
|
const message = '再画一只机器人';
|
|
const command = (operationId: string) => ({
|
|
kind: 'apply_input' as const,
|
|
workspaceId: 'workspace-1',
|
|
sessionId: 'session-1',
|
|
expectedDirectionRevision: 4,
|
|
clientOperationId: operationId,
|
|
input: { kind: 'chat' as const, message },
|
|
});
|
|
useImageWorkspaceStore.setState({
|
|
pendingOperations: {
|
|
'operation-chat-1': {
|
|
id: 'operation-chat-1',
|
|
label: '发送创作想法',
|
|
command: command('operation-chat-1'),
|
|
status: 'unknown',
|
|
error: '结果尚未确认',
|
|
clearDraftPaths: [],
|
|
clearChatDraft: true,
|
|
baseRawTurnSequence: 1,
|
|
},
|
|
'operation-chat-2': {
|
|
id: 'operation-chat-2',
|
|
label: '发送创作想法',
|
|
command: command('operation-chat-2'),
|
|
status: 'unknown',
|
|
error: '结果尚未确认',
|
|
clearDraftPaths: [],
|
|
clearChatDraft: true,
|
|
baseRawTurnSequence: 1,
|
|
},
|
|
},
|
|
assistantActivities: {
|
|
'operation-chat-1': {
|
|
workspaceId: 'workspace-1',
|
|
directionId: 'direction-1',
|
|
status: 'unknown',
|
|
turnId: null,
|
|
steps: [{ stage: 'composing', message: '已经整理好,正在准备回复…' }],
|
|
},
|
|
'operation-chat-2': {
|
|
workspaceId: 'workspace-1',
|
|
directionId: 'direction-1',
|
|
status: 'unknown',
|
|
turnId: null,
|
|
steps: [{ stage: 'composing', message: '已经整理好,正在准备回复…' }],
|
|
},
|
|
},
|
|
});
|
|
const canonical = designWorkspaceFixture({
|
|
form: designFormFixture({ rawTurnSequence: 3, directionRevision: 6 }),
|
|
turns: [
|
|
{
|
|
turnId: 'turn-2',
|
|
rawTurnSequence: 2,
|
|
userMessage: message,
|
|
assistantMessage: '第一条相同消息的回复。',
|
|
},
|
|
{
|
|
turnId: 'turn-3',
|
|
rawTurnSequence: 3,
|
|
userMessage: message,
|
|
assistantMessage: '第二条相同消息的回复。',
|
|
},
|
|
],
|
|
});
|
|
fetchProjectMock.mockResolvedValueOnce(canonical);
|
|
|
|
source.emit('design.direction.updated', {
|
|
id: 'session-1:22',
|
|
type: 'design.direction.updated',
|
|
clientOperationId: 'operation-chat-2',
|
|
replayed: false,
|
|
operation: {
|
|
operationKind: 'chat',
|
|
interactionId: 'interaction-3',
|
|
baseDirectionRevision: 5,
|
|
newDirectionRevision: 6,
|
|
rawTurnSequence: 3,
|
|
specificationRevision: 3,
|
|
specificationRevisionId: 'specification-revision-3',
|
|
workspaceViewRevision: 8,
|
|
specificationRevisionCreated: false,
|
|
meaningChanged: false,
|
|
changeSet: { interaction_id: 'interaction-3', changes: [] },
|
|
turnId: 'turn-3',
|
|
assistantMessage: '第二条相同消息的回复。',
|
|
createdDecisionPromptIds: [],
|
|
resolvedDecisionPromptId: null,
|
|
supersededDecisionPromptIds: [],
|
|
supersededQuoteCount: 0,
|
|
quoteId: null,
|
|
generationTaskId: null,
|
|
},
|
|
form: designFormFixture({ rawTurnSequence: 3, directionRevision: 6 }),
|
|
});
|
|
|
|
await vi.waitFor(() => {
|
|
expect(useImageWorkspaceStore.getState().pendingOperations['operation-chat-2'])
|
|
.toBeUndefined();
|
|
});
|
|
expect(useImageWorkspaceStore.getState().pendingOperations['operation-chat-1'])
|
|
.toBeDefined();
|
|
expect(useImageWorkspaceStore.getState().assistantActivities).toMatchObject({
|
|
'operation-chat-1': { turnId: null },
|
|
'operation-chat-2': { status: 'completed', turnId: 'turn-3' },
|
|
});
|
|
});
|
|
|
|
it('settles an unknown chat when Gateway later reports terminal failure', async () => {
|
|
const source = await loadedStore();
|
|
useImageWorkspaceStore.setState({
|
|
pendingOperations: {
|
|
'operation-chat-unknown': {
|
|
id: 'operation-chat-unknown',
|
|
label: '发送创作想法',
|
|
command: {
|
|
kind: 'apply_input',
|
|
workspaceId: 'workspace-1',
|
|
sessionId: 'session-1',
|
|
expectedDirectionRevision: 4,
|
|
clientOperationId: 'operation-chat-unknown',
|
|
input: { kind: 'chat', message: '画一只机器人' },
|
|
},
|
|
status: 'unknown',
|
|
error: '连接暂时中断',
|
|
clearDraftPaths: [],
|
|
clearChatDraft: true,
|
|
baseRawTurnSequence: 1,
|
|
},
|
|
},
|
|
assistantActivities: {
|
|
'operation-chat-unknown': {
|
|
workspaceId: 'workspace-1',
|
|
directionId: 'direction-1',
|
|
status: 'unknown',
|
|
turnId: null,
|
|
steps: [{ stage: 'reviewing_context', message: '正在整理上下文' }],
|
|
},
|
|
},
|
|
});
|
|
|
|
source.emit('command.failed', {
|
|
id: 'session-1:20',
|
|
type: 'command.failed',
|
|
workspaceId: 'workspace-1',
|
|
clientOperationId: 'operation-chat-unknown',
|
|
outcome: 'failed',
|
|
errorCode: 'design_reasoner_invalid',
|
|
});
|
|
|
|
expect(useImageWorkspaceStore.getState().pendingOperations).toEqual({});
|
|
expect(useImageWorkspaceStore.getState().assistantActivities).toEqual({});
|
|
expect(useImageWorkspaceStore.getState().error)
|
|
.toBe('AI 这次没有完成设计整理,内容还在输入框里,请再试一次');
|
|
});
|
|
|
|
it('does not replace the active Workspace when an earlier Workspace command completes late', async () => {
|
|
await loadedStore();
|
|
const submission = deferred<Awaited<ReturnType<typeof submitImageWorkspaceCommand>>>();
|
|
submitCommandMock.mockReturnValueOnce(submission.promise);
|
|
useImageWorkspaceStore.getState().setChatDraft('旧项目里的消息');
|
|
const sendPromise = useImageWorkspaceStore.getState().sendChat();
|
|
const workspaceB = designWorkspaceFixture({
|
|
workspace: designSummaryFixture({
|
|
workspaceId: 'workspace-2',
|
|
clientWorkspaceId: 'client-workspace-2',
|
|
directionId: 'direction-2',
|
|
sessionId: 'session-2',
|
|
title: '当前项目',
|
|
}),
|
|
form: designFormFixture({ workspaceId: 'workspace-2', directionId: 'direction-2' }),
|
|
});
|
|
useImageWorkspaceStore.setState({
|
|
activeWorkspaceId: 'workspace-2',
|
|
workspace: workspaceB,
|
|
assistantActivities: {},
|
|
assistantStreams: {},
|
|
});
|
|
|
|
submission.resolve({
|
|
clientOperationId: 'operation-1',
|
|
runId: 'run-old',
|
|
workspace: designWorkspaceFixture(),
|
|
});
|
|
await sendPromise;
|
|
|
|
expect(useImageWorkspaceStore.getState()).toMatchObject({
|
|
activeWorkspaceId: 'workspace-2',
|
|
workspace: { workspace: { workspaceId: 'workspace-2', title: '当前项目' } },
|
|
pendingOperations: {},
|
|
});
|
|
});
|
|
|
|
it('does not apply an old A result after the user switches A to B and back to A', async () => {
|
|
await loadedStore();
|
|
const submission = deferred<Awaited<ReturnType<typeof submitImageWorkspaceCommand>>>();
|
|
submitCommandMock.mockReturnValueOnce(submission.promise);
|
|
useImageWorkspaceStore.getState().setChatDraft('旧的 A 项目消息');
|
|
const sendPromise = useImageWorkspaceStore.getState().sendChat();
|
|
|
|
const workspaceB = designWorkspaceFixture({
|
|
workspace: designSummaryFixture({
|
|
workspaceId: 'workspace-2',
|
|
clientWorkspaceId: 'client-workspace-2',
|
|
directionId: 'direction-2',
|
|
sessionId: 'session-2',
|
|
title: '项目 B',
|
|
}),
|
|
form: designFormFixture({ workspaceId: 'workspace-2', directionId: 'direction-2' }),
|
|
});
|
|
const currentWorkspaceA = designWorkspaceFixture({
|
|
workspace: designSummaryFixture({ workspaceViewRevision: 12, title: '重新打开的项目 A' }),
|
|
form: designFormFixture({ rawTurnSequence: 4, workspaceViewRevision: 12 }),
|
|
turns: [{
|
|
turnId: 'turn-4',
|
|
rawTurnSequence: 4,
|
|
userMessage: 'A 项目中较新的消息',
|
|
assistantMessage: '这是较新的回复。',
|
|
}],
|
|
});
|
|
fetchProjectMock
|
|
.mockResolvedValueOnce(workspaceB)
|
|
.mockResolvedValueOnce(currentWorkspaceA);
|
|
await useImageWorkspaceStore.getState().selectProject('workspace-2');
|
|
await useImageWorkspaceStore.getState().selectProject('workspace-1');
|
|
|
|
submission.resolve({
|
|
clientOperationId: 'operation-1',
|
|
runId: 'run-old-a',
|
|
workspace: designWorkspaceFixture({
|
|
form: designFormFixture({ rawTurnSequence: 2, workspaceViewRevision: 7 }),
|
|
}),
|
|
});
|
|
await sendPromise;
|
|
|
|
expect(useImageWorkspaceStore.getState()).toMatchObject({
|
|
activeWorkspaceId: 'workspace-1',
|
|
pendingOperations: {},
|
|
workspace: {
|
|
workspace: { title: '重新打开的项目 A', workspaceViewRevision: 12 },
|
|
form: { rawTurnSequence: 4, workspaceViewRevision: 12 },
|
|
},
|
|
});
|
|
});
|
|
|
|
it('does not show a late B loading error after the user returns to A', async () => {
|
|
await loadedStore();
|
|
const workspaceB = deferred<Awaited<ReturnType<typeof fetchImageWorkspaceProject>>>();
|
|
const currentWorkspaceA = designWorkspaceFixture({
|
|
workspace: designSummaryFixture({ workspaceViewRevision: 12, title: '重新打开的项目 A' }),
|
|
form: designFormFixture({ rawTurnSequence: 4, workspaceViewRevision: 12 }),
|
|
});
|
|
fetchProjectMock
|
|
.mockReturnValueOnce(workspaceB.promise)
|
|
.mockResolvedValueOnce(currentWorkspaceA);
|
|
|
|
const selectBPromise = useImageWorkspaceStore.getState().selectProject('workspace-2');
|
|
await useImageWorkspaceStore.getState().selectProject('workspace-1');
|
|
workspaceB.reject(new Error('项目 B 加载失败'));
|
|
await selectBPromise;
|
|
|
|
expect(useImageWorkspaceStore.getState()).toMatchObject({
|
|
activeWorkspaceId: 'workspace-1',
|
|
error: null,
|
|
workspace: {
|
|
workspace: { title: '重新打开的项目 A', workspaceViewRevision: 12 },
|
|
form: { rawTurnSequence: 4, workspaceViewRevision: 12 },
|
|
},
|
|
});
|
|
});
|
|
|
|
it('ignores progress and reply deltas that do not belong to the active pending chat', async () => {
|
|
const source = await loadedStore();
|
|
|
|
source.emit('design.assistant.progress', {
|
|
id: 'session-1:7',
|
|
type: 'design.assistant.progress',
|
|
workspaceId: 'workspace-1',
|
|
directionId: 'direction-1',
|
|
clientOperationId: 'unrelated-operation',
|
|
stage: 'understanding',
|
|
message: '不应显示',
|
|
});
|
|
source.emit('design.assistant.delta', {
|
|
id: 'session-1:8',
|
|
type: 'design.assistant.delta',
|
|
workspaceId: 'workspace-1',
|
|
directionId: 'direction-1',
|
|
clientOperationId: 'unrelated-operation',
|
|
directionRevision: 4,
|
|
chunkIndex: 0,
|
|
delta: '不应显示',
|
|
});
|
|
|
|
expect(useImageWorkspaceStore.getState().assistantActivities).toEqual({});
|
|
expect(useImageWorkspaceStore.getState().assistantStreams).toEqual({});
|
|
expect(useImageWorkspaceStore.getState().lastEventId).toBe('session-1:8');
|
|
});
|
|
|
|
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.progress', {
|
|
id: 'session-1:6',
|
|
type: 'design.assistant.progress',
|
|
workspaceId: 'workspace-1',
|
|
directionId: 'direction-1',
|
|
clientOperationId: 'operation-1',
|
|
stage: 'reviewing_context',
|
|
message: '正在把它和前面的设计想法放在一起看…',
|
|
});
|
|
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().assistantActivities).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.progress', {
|
|
id: 'session-1:6',
|
|
type: 'design.assistant.progress',
|
|
workspaceId: 'workspace-1',
|
|
directionId: 'direction-1',
|
|
clientOperationId: 'operation-1',
|
|
stage: 'reviewing_context',
|
|
message: '正在把它和前面的设计想法放在一起看…',
|
|
});
|
|
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': '正在整理',
|
|
});
|
|
expect(useImageWorkspaceStore.getState().assistantActivities['operation-1'])
|
|
.toMatchObject({ status: 'unknown', steps: [{ stage: 'reviewing_context' }] });
|
|
});
|
|
|
|
it('preserves an earlier unknown activity when another chat starts', async () => {
|
|
await loadedStore();
|
|
const submission = deferred<Awaited<ReturnType<typeof submitImageWorkspaceCommand>>>();
|
|
operationIdMock.mockReturnValueOnce('operation-chat-2');
|
|
submitCommandMock.mockReturnValueOnce(submission.promise);
|
|
useImageWorkspaceStore.setState({
|
|
chatDraft: '再补充一只小兔子',
|
|
pendingOperations: {
|
|
'operation-chat-1': {
|
|
id: 'operation-chat-1',
|
|
label: '发送创作想法',
|
|
command: {
|
|
kind: 'apply_input',
|
|
workspaceId: 'workspace-1',
|
|
sessionId: 'session-1',
|
|
expectedDirectionRevision: 4,
|
|
clientOperationId: 'operation-chat-1',
|
|
input: { kind: 'chat', message: '先画一只小熊' },
|
|
},
|
|
status: 'unknown',
|
|
error: '结果尚未确认',
|
|
clearDraftPaths: [],
|
|
clearChatDraft: true,
|
|
baseRawTurnSequence: 1,
|
|
},
|
|
},
|
|
assistantActivities: {
|
|
'operation-chat-1': {
|
|
workspaceId: 'workspace-1',
|
|
directionId: 'direction-1',
|
|
status: 'unknown',
|
|
turnId: null,
|
|
steps: [{ stage: 'reviewing_context', message: '正在整理上下文' }],
|
|
},
|
|
},
|
|
});
|
|
|
|
const sendPromise = useImageWorkspaceStore.getState().sendChat();
|
|
|
|
expect(useImageWorkspaceStore.getState().assistantActivities).toMatchObject({
|
|
'operation-chat-1': {
|
|
status: 'unknown',
|
|
steps: [{ stage: 'reviewing_context' }],
|
|
},
|
|
});
|
|
expect(useImageWorkspaceStore.getState().pendingOperations).toMatchObject({
|
|
'operation-chat-1': { status: 'unknown' },
|
|
'operation-chat-2': { status: 'submitting' },
|
|
});
|
|
|
|
submission.resolve({
|
|
clientOperationId: 'operation-chat-2',
|
|
runId: 'run-2',
|
|
workspace: designWorkspaceFixture(),
|
|
});
|
|
await sendPromise;
|
|
});
|
|
|
|
it('expires quote blockers only after the canonical Specification changes', async () => {
|
|
await loadedStore();
|
|
const blocker = {
|
|
code: 'aspect_ratio_required',
|
|
message: 'Choose an aspect ratio supported by the frozen route.',
|
|
severity: 'blocker' as const,
|
|
path: 'output.aspect_ratio',
|
|
};
|
|
useImageWorkspaceStore.setState({ quoteBlockers: [blocker] });
|
|
|
|
await useImageWorkspaceStore.getState().refreshWorkspace();
|
|
expect(useImageWorkspaceStore.getState().quoteBlockers).toEqual([blocker]);
|
|
|
|
const revisedWorkspace = designWorkspaceFixture({
|
|
form: designFormFixture({
|
|
directionRevision: 5,
|
|
specificationRevision: 4,
|
|
specificationRevisionId: 'specification-revision-4',
|
|
}),
|
|
});
|
|
submitCommandMock.mockResolvedValueOnce({
|
|
clientOperationId: 'operation-1',
|
|
runId: 'run-2',
|
|
workspace: revisedWorkspace,
|
|
});
|
|
await useImageWorkspaceStore.getState().applyFieldOperations([
|
|
{ kind: 'set', path: 'output.aspect_ratio', value: '16:9' },
|
|
]);
|
|
|
|
expect(useImageWorkspaceStore.getState().quoteBlockers).toEqual([]);
|
|
expect(useImageWorkspaceStore.getState().workspace?.form.specificationRevision).toBe(4);
|
|
});
|
|
|
|
it('treats a terminal quote-blocked response as definitive instead of unknown', async () => {
|
|
await loadedStore();
|
|
submitCommandMock.mockRejectedValueOnce(new ImageWorkspaceApiError(
|
|
422,
|
|
'design_quote_blocked',
|
|
'The frozen compiler needs more information.',
|
|
));
|
|
|
|
await expect(useImageWorkspaceStore.getState().requestQuote()).rejects.toMatchObject({
|
|
status: 422,
|
|
code: 'design_quote_blocked',
|
|
});
|
|
|
|
expect(useImageWorkspaceStore.getState().pendingOperations).toEqual({});
|
|
expect(useImageWorkspaceStore.getState().error).toBeNull();
|
|
});
|
|
|
|
it('settles an unknown quote from its blocked event, then allows a revised Quote', async () => {
|
|
const source = await loadedStore();
|
|
submitCommandMock.mockRejectedValueOnce(new Error('network disconnected'));
|
|
await expect(useImageWorkspaceStore.getState().requestQuote()).rejects.toThrow(
|
|
'network disconnected',
|
|
);
|
|
expect(useImageWorkspaceStore.getState().pendingOperations['operation-1']?.status).toBe('unknown');
|
|
|
|
const blocker = {
|
|
code: 'aspect_ratio_required',
|
|
message: 'Choose an aspect ratio supported by the frozen route.',
|
|
severity: 'blocker' as const,
|
|
path: 'output.aspect_ratio',
|
|
};
|
|
source.emit('design.quote.blocked', {
|
|
id: 'session-1:quote-blocked',
|
|
type: 'design.quote.blocked',
|
|
clientOperationId: 'operation-1',
|
|
blockers: [blocker],
|
|
warnings: [],
|
|
form: designFormFixture(),
|
|
});
|
|
|
|
expect(useImageWorkspaceStore.getState()).toMatchObject({
|
|
pendingOperations: {},
|
|
quoteBlockers: [blocker],
|
|
error: null,
|
|
});
|
|
|
|
const revisedWorkspace = designWorkspaceFixture({
|
|
form: designFormFixture({
|
|
directionRevision: 5,
|
|
specificationRevision: 4,
|
|
specificationRevisionId: 'specification-revision-4',
|
|
}),
|
|
});
|
|
submitCommandMock.mockResolvedValueOnce({
|
|
clientOperationId: 'operation-1',
|
|
runId: 'run-edit',
|
|
workspace: revisedWorkspace,
|
|
});
|
|
await useImageWorkspaceStore.getState().applyFieldOperations([
|
|
{ kind: 'set', path: 'output.aspect_ratio', value: '16:9' },
|
|
]);
|
|
expect(useImageWorkspaceStore.getState().quoteBlockers).toEqual([]);
|
|
|
|
submitCommandMock.mockResolvedValueOnce({
|
|
clientOperationId: 'operation-1',
|
|
runId: 'run-quote-revised',
|
|
workspace: revisedWorkspace,
|
|
});
|
|
await useImageWorkspaceStore.getState().requestQuote();
|
|
|
|
expect(submitCommandMock).toHaveBeenLastCalledWith(expect.objectContaining({
|
|
kind: 'request_quote',
|
|
expectedDirectionRevision: 5,
|
|
specificationRevision: 4,
|
|
}));
|
|
expect(useImageWorkspaceStore.getState().pendingOperations).toEqual({});
|
|
});
|
|
|
|
it('refreshes on a definitive revision conflict and preserves the unsent draft', async () => {
|
|
await loadedStore();
|
|
useImageWorkspaceStore.getState().setFieldDraft('intent.purpose', '新的用途');
|
|
submitCommandMock.mockRejectedValueOnce(new ImageWorkspaceApiError(
|
|
409,
|
|
'design_direction_revision_conflict',
|
|
'conflict',
|
|
));
|
|
|
|
await expect(useImageWorkspaceStore.getState().applyFieldOperations(
|
|
[{ kind: 'set', path: 'intent.purpose', value: '新的用途' }],
|
|
['intent.purpose'],
|
|
)).rejects.toMatchObject({ status: 409 });
|
|
|
|
expect(fetchProjectMock).toHaveBeenCalledWith('workspace-1');
|
|
expect(useImageWorkspaceStore.getState().fieldDrafts).toEqual({
|
|
'intent.purpose': '新的用途',
|
|
});
|
|
expect(useImageWorkspaceStore.getState().pendingOperations).toEqual({});
|
|
});
|
|
|
|
it('retains an uncertain accepted write and retries the exact same command identity', async () => {
|
|
await loadedStore();
|
|
submitCommandMock.mockRejectedValueOnce(new Error('network disconnected'));
|
|
|
|
await expect(useImageWorkspaceStore.getState().requestQuote()).rejects.toThrow(
|
|
'network disconnected',
|
|
);
|
|
const pending = useImageWorkspaceStore.getState().pendingOperations['operation-1'];
|
|
expect(pending).toMatchObject({
|
|
id: 'operation-1',
|
|
status: 'unknown',
|
|
command: {
|
|
kind: 'request_quote',
|
|
expectedDirectionRevision: 4,
|
|
specificationRevision: 3,
|
|
},
|
|
});
|
|
|
|
submitCommandMock.mockResolvedValueOnce({
|
|
clientOperationId: 'operation-1',
|
|
runId: 'run-original',
|
|
workspace: designWorkspaceFixture(),
|
|
});
|
|
await useImageWorkspaceStore.getState().retryOperation('operation-1');
|
|
|
|
expect(submitCommandMock).toHaveBeenNthCalledWith(2, pending.command);
|
|
expect(useImageWorkspaceStore.getState().pendingOperations).toEqual({});
|
|
});
|
|
|
|
it('reconciles assistant deltas and canonical Form events without mutating drafts', async () => {
|
|
const source = await loadedStore();
|
|
useImageWorkspaceStore.getState().setFieldDraft('intent.purpose', '本地草稿');
|
|
useImageWorkspaceStore.setState({
|
|
assistantStreams: { 'operation-chat-unknown': '这段回复还没确认' },
|
|
pendingOperations: {
|
|
'operation-chat-unknown': {
|
|
id: 'operation-chat-unknown',
|
|
label: '发送创作想法',
|
|
command: {
|
|
kind: 'apply_input',
|
|
workspaceId: 'workspace-1',
|
|
sessionId: 'session-1',
|
|
expectedDirectionRevision: 4,
|
|
clientOperationId: 'operation-chat-unknown',
|
|
input: { kind: 'chat', message: '保留这条未知结果' },
|
|
},
|
|
status: 'unknown',
|
|
error: '结果尚未确认',
|
|
clearDraftPaths: [],
|
|
clearChatDraft: true,
|
|
baseRawTurnSequence: 1,
|
|
},
|
|
},
|
|
quoteBlockers: [{
|
|
code: 'missing_choice',
|
|
message: '还需要选择作品形状',
|
|
severity: 'blocker',
|
|
path: 'output.aspect_ratio',
|
|
}],
|
|
});
|
|
source.emit('design.assistant.delta', {
|
|
id: 'session-1:7',
|
|
type: 'design.assistant.delta',
|
|
workspaceId: 'workspace-1',
|
|
directionId: 'direction-1',
|
|
clientOperationId: 'operation-chat-1',
|
|
directionRevision: 4,
|
|
chunkIndex: 0,
|
|
delta: '正在整理',
|
|
});
|
|
source.emit('design.direction.updated', {
|
|
id: 'session-1:8',
|
|
type: 'design.direction.updated',
|
|
clientOperationId: 'operation-chat-1',
|
|
replayed: false,
|
|
operation: {
|
|
operationKind: 'chat',
|
|
interactionId: 'interaction-2',
|
|
baseDirectionRevision: 4,
|
|
newDirectionRevision: 5,
|
|
rawTurnSequence: 2,
|
|
specificationRevision: 4,
|
|
specificationRevisionId: 'specification-revision-4',
|
|
workspaceViewRevision: 7,
|
|
specificationRevisionCreated: true,
|
|
meaningChanged: true,
|
|
changeSet: { interaction_id: 'interaction-2', changes: [] },
|
|
turnId: 'turn-2',
|
|
assistantMessage: '已更新',
|
|
createdDecisionPromptIds: [],
|
|
resolvedDecisionPromptId: null,
|
|
supersededDecisionPromptIds: [],
|
|
supersededQuoteCount: 0,
|
|
quoteId: null,
|
|
generationTaskId: null,
|
|
},
|
|
form: designFormFixture({
|
|
directionRevision: 5,
|
|
specificationRevision: 4,
|
|
specificationRevisionId: 'specification-revision-4',
|
|
workspaceViewRevision: 7,
|
|
}),
|
|
});
|
|
|
|
expect(useImageWorkspaceStore.getState().assistantStreams).toEqual({
|
|
'operation-chat-unknown': '这段回复还没确认',
|
|
});
|
|
expect(useImageWorkspaceStore.getState()).toMatchObject({
|
|
lastEventId: 'session-1:8',
|
|
fieldDrafts: { 'intent.purpose': '本地草稿' },
|
|
quoteBlockers: [],
|
|
workspace: { form: { directionRevision: 5, specificationRevision: 4 } },
|
|
});
|
|
});
|
|
|
|
it('creates, renames and deletes only Workspace identities', async () => {
|
|
await useImageWorkspaceStore.getState().createProject('新项目');
|
|
await useImageWorkspaceStore.getState().renameProject('workspace-1', '新名称');
|
|
await useImageWorkspaceStore.getState().deleteProject('workspace-1');
|
|
|
|
expect(createProjectMock).toHaveBeenCalledWith('新项目');
|
|
expect(renameProjectMock).toHaveBeenCalledWith('workspace-1', '新名称');
|
|
expect(deleteProjectMock).toHaveBeenCalledWith('workspace-1');
|
|
expect(useImageWorkspaceStore.getState().activeWorkspaceId).toBeNull();
|
|
});
|
|
|
|
it('renames a background Workspace without changing the active Workspace', async () => {
|
|
await loadedStore();
|
|
const backgroundSummary = designSummaryFixture({
|
|
workspaceId: 'workspace-2',
|
|
clientWorkspaceId: 'client-workspace-2',
|
|
title: '背景项目',
|
|
directionId: 'direction-2',
|
|
sessionId: 'session-2',
|
|
});
|
|
useImageWorkspaceStore.setState((state) => ({
|
|
bootstrap: state.bootstrap
|
|
? { ...state.bootstrap, workspaces: [...state.bootstrap.workspaces, backgroundSummary] }
|
|
: null,
|
|
}));
|
|
renameProjectMock.mockResolvedValueOnce(designWorkspaceFixture({
|
|
workspace: { ...backgroundSummary, title: '背景项目新名称' },
|
|
}));
|
|
|
|
await useImageWorkspaceStore.getState().renameProject('workspace-2', '背景项目新名称');
|
|
|
|
expect(useImageWorkspaceStore.getState()).toMatchObject({
|
|
activeWorkspaceId: 'workspace-1',
|
|
workspace: { workspace: { workspaceId: 'workspace-1' } },
|
|
});
|
|
expect(useImageWorkspaceStore.getState().bootstrap?.workspaces).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ workspaceId: 'workspace-2', title: '背景项目新名称' }),
|
|
]),
|
|
);
|
|
});
|
|
});
|