feat(design): render assistant activity progress
This commit is contained in:
@@ -52,6 +52,7 @@ function prepareWorkspace(overrides = {}) {
|
||||
pendingOperations: {},
|
||||
fieldDrafts: {},
|
||||
chatDraft: '',
|
||||
assistantActivities: {},
|
||||
assistantStreams: {},
|
||||
quoteBlockers: [],
|
||||
error: null,
|
||||
@@ -93,6 +94,37 @@ describe('youth AI Design Canvas page', () => {
|
||||
expect(screen.queryByText(/规格版本|编译器|生成策略|字段决策/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not disable the current composer for another Workspace command', () => {
|
||||
prepareWorkspace();
|
||||
useImageWorkspaceStore.setState({
|
||||
chatDraft: '当前项目的新想法',
|
||||
pendingOperations: {
|
||||
'operation-workspace-2': {
|
||||
id: 'operation-workspace-2',
|
||||
label: '发送创作想法',
|
||||
command: {
|
||||
kind: 'apply_input',
|
||||
workspaceId: 'workspace-2',
|
||||
sessionId: 'session-2',
|
||||
expectedDirectionRevision: 2,
|
||||
clientOperationId: 'operation-workspace-2',
|
||||
input: { kind: 'chat', message: '另一个项目还在处理' },
|
||||
},
|
||||
status: 'submitting',
|
||||
error: null,
|
||||
clearDraftPaths: [],
|
||||
clearChatDraft: true,
|
||||
baseRawTurnSequence: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
render(<ImageCanvas />);
|
||||
|
||||
expect(screen.getByRole('textbox', { name: '告诉 AI 你想创作什么' })).toBeEnabled();
|
||||
expect(screen.getByRole('button', { name: '发送消息' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('shows one unfinished assistant reply incrementally and replaces it with the canonical turn', async () => {
|
||||
const { workspace } = prepareWorkspace();
|
||||
const firstChunk = '收到,我们要制作';
|
||||
@@ -115,6 +147,7 @@ describe('youth AI Design Canvas page', () => {
|
||||
error: null,
|
||||
clearDraftPaths: [],
|
||||
clearChatDraft: true,
|
||||
baseRawTurnSequence: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -165,6 +198,95 @@ describe('youth AI Design Canvas page', () => {
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows public AI activity under the submitted message and collapses it beside the final turn', async () => {
|
||||
const message = '画一只在月球踢球的熊猫';
|
||||
const { workspace } = prepareWorkspace({ turns: [] });
|
||||
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,
|
||||
},
|
||||
},
|
||||
assistantActivities: {
|
||||
'operation-chat-1': {
|
||||
workspaceId: 'workspace-1',
|
||||
directionId: 'direction-1',
|
||||
status: 'active',
|
||||
turnId: null,
|
||||
steps: [
|
||||
{ stage: 'understanding', message: '正在听懂你刚补充的内容…' },
|
||||
{ stage: 'reviewing_context', message: '正在把它和前面的设计想法放在一起看…' },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
render(<ImageCanvas />);
|
||||
|
||||
const conversation = screen.getByTestId('image-workspace-conversation');
|
||||
const pending = within(conversation).getByTestId('pending-design-chat-operation-chat-1');
|
||||
const activity = within(conversation).getByTestId('design-assistant-activity-operation-chat-1');
|
||||
expect(pending.compareDocumentPosition(activity) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(within(activity).getByRole('button', { name: /AI 处理过程/ }))
|
||||
.toHaveAttribute('aria-expanded', 'true');
|
||||
expect(within(activity).getByText('正在听懂你刚补充的内容…')).toBeInTheDocument();
|
||||
expect(within(activity).getAllByText('正在把它和前面的设计想法放在一起看…'))
|
||||
.not.toHaveLength(0);
|
||||
expect(within(activity).queryByText(/思考过程|推理内容/)).not.toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
useImageWorkspaceStore.setState({
|
||||
workspace: {
|
||||
...workspace,
|
||||
turns: [{
|
||||
turnId: 'turn-2',
|
||||
rawTurnSequence: 2,
|
||||
userMessage: message,
|
||||
assistantMessage: '这个画面很有趣。你想让它更像漫画还是电影?',
|
||||
}],
|
||||
},
|
||||
pendingOperations: {},
|
||||
assistantActivities: {
|
||||
'operation-chat-1': {
|
||||
workspaceId: 'workspace-1',
|
||||
directionId: 'direction-1',
|
||||
status: 'completed',
|
||||
turnId: 'turn-2',
|
||||
steps: [
|
||||
{ stage: 'understanding', message: '正在听懂你刚补充的内容…' },
|
||||
{ stage: 'reviewing_context', message: '正在把它和前面的设计想法放在一起看…' },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const completedActivity = within(conversation)
|
||||
.getByTestId('design-assistant-activity-operation-chat-1');
|
||||
await waitFor(() => {
|
||||
expect(within(completedActivity).getByRole('button', { name: /AI 处理过程/ }))
|
||||
.toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
expect(within(completedActivity).queryByText('正在听懂你刚补充的内容…'))
|
||||
.not.toBeInTheDocument();
|
||||
fireEvent.click(within(completedActivity).getByRole('button', { name: /AI 处理过程/ }));
|
||||
expect(within(completedActivity).getByText('正在听懂你刚补充的内容…')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens the mobile quote confirmation from the ready conversation without starting a paid task', async () => {
|
||||
const { actions } = prepareWorkspace();
|
||||
const quotedWorkspace = designWorkspaceFixture({
|
||||
@@ -208,6 +330,7 @@ describe('youth AI Design Canvas page', () => {
|
||||
error: null,
|
||||
clearDraftPaths: [],
|
||||
clearChatDraft: true,
|
||||
baseRawTurnSequence: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -390,6 +513,7 @@ describe('youth AI Design Canvas page', () => {
|
||||
error: '网络已断开',
|
||||
clearDraftPaths: [],
|
||||
clearChatDraft: false,
|
||||
baseRawTurnSequence: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -291,6 +291,15 @@ describe('AI design V2 Main route boundary', () => {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield {
|
||||
id: 'session-1:9',
|
||||
type: 'design.assistant.progress' as const,
|
||||
workspaceId: 'workspace-1',
|
||||
directionId: 'direction-1',
|
||||
clientOperationId: 'operation-1',
|
||||
stage: 'reviewing_context' as const,
|
||||
message: '正在把它和前面的设计想法放在一起看…',
|
||||
};
|
||||
yield {
|
||||
id: 'session-1:10',
|
||||
type: 'design.assistant.delta' as const,
|
||||
workspaceId: 'workspace-1',
|
||||
directionId: 'direction-1',
|
||||
@@ -316,7 +325,9 @@ describe('AI design V2 Main route boundary', () => {
|
||||
sessionId: 'session-1',
|
||||
afterEventId: 'session-1:8',
|
||||
});
|
||||
expect(Buffer.concat(response.chunks).toString('utf8')).toContain('event: design.assistant.delta');
|
||||
const body = Buffer.concat(response.chunks).toString('utf8');
|
||||
expect(body).toContain('event: design.assistant.progress');
|
||||
expect(body).toContain('event: design.assistant.delta');
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
|
||||
@@ -76,10 +76,12 @@ class FakeEventSource {
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((resolvePromise) => {
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, resolve };
|
||||
return { promise, reject, resolve };
|
||||
}
|
||||
|
||||
async function loadedStore(eventSource = new FakeEventSource()) {
|
||||
@@ -146,6 +148,60 @@ describe('V2 Living Form store', () => {
|
||||
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();
|
||||
@@ -168,6 +224,27 @@ describe('V2 Living Form store', () => {
|
||||
|
||||
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',
|
||||
@@ -193,10 +270,17 @@ describe('V2 Living Form store', () => {
|
||||
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 () => {
|
||||
@@ -267,10 +351,762 @@ describe('V2 Living Form store', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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 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',
|
||||
@@ -299,6 +1135,7 @@ describe('V2 Living Form store', () => {
|
||||
error: 'AI 没有整理好这次想法,请再试一次',
|
||||
});
|
||||
expect(useImageWorkspaceStore.getState().pendingOperations).toEqual({});
|
||||
expect(useImageWorkspaceStore.getState().assistantActivities).toEqual({});
|
||||
expect(useImageWorkspaceStore.getState().assistantStreams).toEqual({});
|
||||
});
|
||||
|
||||
@@ -324,6 +1161,15 @@ describe('V2 Living Form store', () => {
|
||||
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',
|
||||
@@ -356,6 +1202,8 @@ describe('V2 Living Form store', () => {
|
||||
expect(useImageWorkspaceStore.getState().assistantStreams).toEqual({
|
||||
'operation-1': '正在整理',
|
||||
});
|
||||
expect(useImageWorkspaceStore.getState().assistantActivities['operation-1'])
|
||||
.toMatchObject({ status: 'unknown', steps: [{ stage: 'reviewing_context' }] });
|
||||
});
|
||||
|
||||
it('expires quote blockers only after the canonical Specification changes', async () => {
|
||||
@@ -540,6 +1388,7 @@ describe('V2 Living Form store', () => {
|
||||
error: '结果尚未确认',
|
||||
clearDraftPaths: [],
|
||||
clearChatDraft: true,
|
||||
baseRawTurnSequence: 1,
|
||||
},
|
||||
},
|
||||
quoteBlockers: [{
|
||||
@@ -562,6 +1411,7 @@ describe('V2 Living Form store', () => {
|
||||
source.emit('design.direction.updated', {
|
||||
id: 'session-1:8',
|
||||
type: 'design.direction.updated',
|
||||
clientOperationId: 'operation-chat-1',
|
||||
replayed: false,
|
||||
operation: {
|
||||
operationKind: 'chat',
|
||||
@@ -594,7 +1444,6 @@ describe('V2 Living Form store', () => {
|
||||
|
||||
expect(useImageWorkspaceStore.getState().assistantStreams).toEqual({
|
||||
'operation-chat-unknown': '这段回复还没确认',
|
||||
'operation-chat-1': '正在整理',
|
||||
});
|
||||
expect(useImageWorkspaceStore.getState()).toMatchObject({
|
||||
lastEventId: 'session-1:8',
|
||||
|
||||
@@ -390,6 +390,198 @@ describe('Works Square V2 Design Workspace adapter', () => {
|
||||
subscription.close();
|
||||
});
|
||||
|
||||
it('normalizes bounded public AI activity without exposing provider details', async () => {
|
||||
const socket = createSocket();
|
||||
const fetchMock = vi.fn(async (input: string | URL) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith('/stream-tickets')) {
|
||||
return jsonResponse({ stream_url: 'https://works.example/api/agents/stream/ticket-1' });
|
||||
}
|
||||
throw new Error(`Unexpected URL: ${url}`);
|
||||
});
|
||||
const module = new WorksSquareDesignWorkspace({
|
||||
apiBaseUrl: 'https://works.example',
|
||||
fetchImpl: fetchMock as unknown as typeof fetch,
|
||||
webSocketFactory: async () => {
|
||||
setTimeout(() => socket.onopen?.(), 0);
|
||||
return { socket };
|
||||
},
|
||||
});
|
||||
const subscription = await module.openWorkspaceEvents({
|
||||
workspaceId: 'workspace-1',
|
||||
sessionId: 'session-1',
|
||||
});
|
||||
const nextEvent = subscription.events[Symbol.asyncIterator]().next();
|
||||
|
||||
socket.onmessage?.({
|
||||
data: JSON.stringify({
|
||||
type: 'event',
|
||||
event: {
|
||||
session_id: 'session-1',
|
||||
sequence: 8,
|
||||
runtime: 'design',
|
||||
type: 'design.assistant.progress',
|
||||
schema_version: 1,
|
||||
payload: {
|
||||
workspace_id: 'workspace-1',
|
||||
direction_id: 'direction-1',
|
||||
client_operation_id: 'operation-chat-1',
|
||||
stage: 'reviewing_context',
|
||||
message: 'RAW provider chain-of-thought must not cross Main',
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(nextEvent).resolves.toEqual({
|
||||
done: false,
|
||||
value: {
|
||||
id: 'session-1:8',
|
||||
type: 'design.assistant.progress',
|
||||
workspaceId: 'workspace-1',
|
||||
directionId: 'direction-1',
|
||||
clientOperationId: 'operation-chat-1',
|
||||
stage: 'reviewing_context',
|
||||
message: '正在把它和前面的设计想法放在一起看…',
|
||||
},
|
||||
});
|
||||
subscription.close();
|
||||
});
|
||||
|
||||
it('carries the Gateway operation identity with the canonical Design turn', async () => {
|
||||
const socket = createSocket();
|
||||
const fetchMock = vi.fn(async (input: string | URL) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith('/stream-tickets')) {
|
||||
return jsonResponse({ stream_url: 'https://works.example/api/agents/stream/ticket-1' });
|
||||
}
|
||||
throw new Error(`Unexpected URL: ${url}`);
|
||||
});
|
||||
const module = new WorksSquareDesignWorkspace({
|
||||
apiBaseUrl: 'https://works.example',
|
||||
fetchImpl: fetchMock as unknown as typeof fetch,
|
||||
webSocketFactory: async () => {
|
||||
setTimeout(() => socket.onopen?.(), 0);
|
||||
return { socket };
|
||||
},
|
||||
});
|
||||
const subscription = await module.openWorkspaceEvents({
|
||||
workspaceId: 'workspace-1',
|
||||
sessionId: 'session-1',
|
||||
});
|
||||
const nextEvent = subscription.events[Symbol.asyncIterator]().next();
|
||||
|
||||
socket.onmessage?.({
|
||||
data: JSON.stringify({
|
||||
type: 'event',
|
||||
event: {
|
||||
session_id: 'session-1',
|
||||
sequence: 9,
|
||||
runtime: 'design',
|
||||
type: 'design.direction.updated',
|
||||
client_command_id: 'operation-chat-1',
|
||||
schema_version: 1,
|
||||
payload: {
|
||||
replayed: false,
|
||||
operation: {
|
||||
operation_kind: 'chat',
|
||||
interaction_id: 'interaction-2',
|
||||
base_direction_revision: 4,
|
||||
new_direction_revision: 5,
|
||||
raw_turn_sequence: 2,
|
||||
specification_revision: 3,
|
||||
specification_revision_id: 'specification-revision-3',
|
||||
workspace_view_revision: 7,
|
||||
specification_revision_created: false,
|
||||
meaning_changed: false,
|
||||
change_set: { interaction_id: 'interaction-2', changes: [] },
|
||||
turn_id: 'turn-2',
|
||||
assistant_message: '你希望它更像漫画还是电影?',
|
||||
created_decision_prompt_ids: [],
|
||||
resolved_decision_prompt_id: null,
|
||||
superseded_decision_prompt_ids: [],
|
||||
superseded_quote_count: 0,
|
||||
quote_id: null,
|
||||
generation_task_id: null,
|
||||
},
|
||||
form: { ...serverForm, raw_turn_sequence: 2, direction_revision: 5 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(nextEvent).resolves.toMatchObject({
|
||||
done: false,
|
||||
value: {
|
||||
id: 'session-1:9',
|
||||
type: 'design.direction.updated',
|
||||
clientOperationId: 'operation-chat-1',
|
||||
operation: { turnId: 'turn-2' },
|
||||
},
|
||||
});
|
||||
subscription.close();
|
||||
});
|
||||
|
||||
it('normalizes Gateway command terminal events by their stable operation identity', async () => {
|
||||
const socket = createSocket();
|
||||
const fetchMock = vi.fn(async (input: string | URL) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith('/stream-tickets')) {
|
||||
return jsonResponse({ stream_url: 'https://works.example/api/agents/stream/ticket-1' });
|
||||
}
|
||||
throw new Error(`Unexpected URL: ${url}`);
|
||||
});
|
||||
const module = new WorksSquareDesignWorkspace({
|
||||
apiBaseUrl: 'https://works.example',
|
||||
fetchImpl: fetchMock as unknown as typeof fetch,
|
||||
webSocketFactory: async () => {
|
||||
setTimeout(() => socket.onopen?.(), 0);
|
||||
return { socket };
|
||||
},
|
||||
});
|
||||
const subscription = await module.openWorkspaceEvents({
|
||||
workspaceId: 'workspace-1',
|
||||
sessionId: 'session-1',
|
||||
});
|
||||
const iterator = subscription.events[Symbol.asyncIterator]();
|
||||
const nextEvent = iterator.next();
|
||||
|
||||
socket.onmessage?.({
|
||||
data: JSON.stringify({
|
||||
type: 'event',
|
||||
event: {
|
||||
session_id: 'session-1',
|
||||
sequence: 9,
|
||||
runtime: 'design',
|
||||
type: 'command.failed',
|
||||
client_command_id: 'operation-chat-1',
|
||||
schema_version: 1,
|
||||
terminal: true,
|
||||
payload: {
|
||||
error: {
|
||||
code: 'design_reasoner_invalid',
|
||||
message: 'private technical detail',
|
||||
retryable: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(nextEvent).resolves.toEqual({
|
||||
done: false,
|
||||
value: {
|
||||
id: 'session-1:9',
|
||||
type: 'command.failed',
|
||||
workspaceId: 'workspace-1',
|
||||
clientOperationId: 'operation-chat-1',
|
||||
outcome: 'failed',
|
||||
errorCode: 'design_reasoner_invalid',
|
||||
},
|
||||
});
|
||||
subscription.close();
|
||||
});
|
||||
|
||||
it('refreshes authentication once after a 401', async () => {
|
||||
getTokenMock.mockResolvedValueOnce('expired-token').mockResolvedValueOnce('fresh-token');
|
||||
const fetchMock = vi.fn(async (_input: string | URL, init?: RequestInit) => {
|
||||
|
||||
Reference in New Issue
Block a user