merge: 集成 AI 设计生成任务修复

This commit is contained in:
2026-08-14 15:32:55 +08:00
14 changed files with 1023 additions and 218 deletions

View File

@@ -58,6 +58,13 @@ function deferred<T>() {
return { promise, resolve };
}
async function openConfigurationEditor(): Promise<void> {
const editButton = await screen.findByRole('button', { name: '编辑配置' });
await waitFor(() => expect(editButton).toBeEnabled());
fireEvent.click(editButton);
await screen.findByRole('heading', { name: '编辑智能体配置' });
}
describe('AI hardware page', () => {
beforeEach(() => {
api.getAiHardwareOverview.mockResolvedValue(activeOverview);
@@ -139,7 +146,7 @@ describe('AI hardware page', () => {
it('sends cleared nullable configuration fields through clear_fields and never sends null', async () => {
render(<AiHardware />);
fireEvent.click(await screen.findByRole('button', { name: '编辑配置' }));
await openConfigurationEditor();
fireEvent.change(screen.getByLabelText('名称'), { target: { value: '新的助手' } });
fireEvent.change(screen.getByLabelText('系统提示'), { target: { value: '' } });
fireEvent.change(screen.getByLabelText('TTS 语音 ID'), { target: { value: '' } });
@@ -229,7 +236,7 @@ describe('AI hardware page', () => {
revision: 4,
});
render(<AiHardware />);
fireEvent.click(await screen.findByRole('button', { name: '编辑配置' }));
await openConfigurationEditor();
fireEvent.click(screen.getByText('高级设置'));
const values: Record<string, string> = {
@@ -336,7 +343,7 @@ describe('AI hardware page', () => {
revision: 4,
});
render(<AiHardware />);
fireEvent.click(await screen.findByRole('button', { name: '编辑配置' }));
await openConfigurationEditor();
expect(screen.getByLabelText('聊天记录')).toHaveValue('');
fireEvent.click(screen.getByRole('button', { name: '保存' }));
@@ -381,7 +388,7 @@ describe('AI hardware page', () => {
status: 409, code: 'ai_hardware_revision_conflict', message: 'server details',
}));
render(<AiHardware />);
fireEvent.click(await screen.findByRole('button', { name: '编辑配置' }));
await openConfigurationEditor();
fireEvent.change(screen.getByLabelText('系统提示'), { target: { value: '保留的本地提示' } });
fireEvent.click(screen.getByRole('button', { name: '保存' }));

View File

@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useImageWorkspaceStore } from '@/stores/image-workspace';
import { ImageWorkspaceApiError } from '@/lib/image-workspace';
import type {
DesignAssistantDeltaEvent,
DesignConversation,
@@ -302,6 +303,196 @@ describe('AI design task event store', () => {
expect(useImageWorkspaceStore.getState().tasks).toEqual([task]);
});
it('recovers a committed generation task when the Agent Run fails afterward', async () => {
const source = new MockEventSource();
openImageWorkspaceTaskEventsMock.mockResolvedValue(source as unknown as EventSource);
fetchImageWorkspaceTasksMock
.mockResolvedValueOnce([])
.mockResolvedValueOnce([task]);
fetchImageWorkspaceConversationMock
.mockResolvedValueOnce(conversation('workspace-one', 1, 'conversation-one'))
.mockResolvedValueOnce(conversation('workspace-one', 2, 'conversation-one'));
confirmImageWorkspaceGenerationMock.mockRejectedValueOnce(
new ImageWorkspaceApiError(
503,
'agent_runtime_unavailable',
'AI 设计服务暂时不可用,请稍后重试',
),
);
await useImageWorkspaceStore.getState().load();
await expect(useImageWorkspaceStore.getState().confirmGeneration('quote-one'))
.resolves.toMatchObject({ turnRevision: 2 });
expect(confirmImageWorkspaceGenerationMock).toHaveBeenCalledOnce();
expect(fetchImageWorkspaceTasksMock).toHaveBeenCalledTimes(2);
expect(useImageWorkspaceStore.getState()).toMatchObject({
error: null,
pendingTurn: null,
conversation: { turnRevision: 2 },
tasks: [{ taskId: 'task-one', quoteId: 'quote-one' }],
});
});
it('keeps reconciling Workspace tasks after switching Conversations during confirmation', async () => {
const first = new MockEventSource();
const second = new MockEventSource();
const confirmation = deferred<DesignConversation>();
const project = workspace();
const { messages: _messages, ...secondSummary } = conversation(
'workspace-one',
1,
'conversation-two',
);
openImageWorkspaceTaskEventsMock
.mockResolvedValueOnce(first as unknown as EventSource)
.mockResolvedValueOnce(second as unknown as EventSource);
fetchImageWorkspaceProjectMock.mockResolvedValue({
...project,
conversationCount: 2,
conversations: [...project.conversations, secondSummary],
});
fetchImageWorkspaceTasksMock
.mockResolvedValueOnce([])
.mockResolvedValueOnce([task]);
fetchImageWorkspaceConversationMock
.mockResolvedValueOnce(conversation('workspace-one', 1, 'conversation-one'))
.mockResolvedValueOnce(conversation('workspace-one', 1, 'conversation-two'));
confirmImageWorkspaceGenerationMock.mockReturnValueOnce(confirmation.promise);
await useImageWorkspaceStore.getState().load();
const confirming = useImageWorkspaceStore.getState().confirmGeneration('quote-one');
await vi.waitFor(() => expect(confirmImageWorkspaceGenerationMock).toHaveBeenCalledOnce());
await useImageWorkspaceStore.getState().selectConversation('conversation-two');
confirmation.resolve(conversation('workspace-one', 2, 'conversation-one'));
await confirming;
expect(fetchImageWorkspaceTasksMock).toHaveBeenCalledTimes(2);
expect(useImageWorkspaceStore.getState()).toMatchObject({
activeWorkspaceId: 'workspace-one',
activeConversationId: 'conversation-two',
conversation: { conversationId: 'conversation-two', turnRevision: 1 },
pendingTurn: null,
error: null,
tasks: [{ taskId: 'task-one', quoteId: 'quote-one' }],
});
});
it('keeps the newer Conversation error when stale confirmation task refresh fails', async () => {
const first = new MockEventSource();
const second = new MockEventSource();
const confirmation = deferred<DesignConversation>();
const project = workspace();
const { messages: _messages, ...secondSummary } = conversation(
'workspace-one',
1,
'conversation-two',
);
const refreshError = new Error('任务刷新失败');
openImageWorkspaceTaskEventsMock
.mockResolvedValueOnce(first as unknown as EventSource)
.mockResolvedValueOnce(second as unknown as EventSource);
fetchImageWorkspaceProjectMock.mockResolvedValue({
...project,
conversationCount: 2,
conversations: [...project.conversations, secondSummary],
});
fetchImageWorkspaceTasksMock
.mockResolvedValueOnce([])
.mockRejectedValueOnce(refreshError)
.mockResolvedValueOnce([task]);
fetchImageWorkspaceConversationMock
.mockResolvedValueOnce(conversation('workspace-one', 1, 'conversation-one'))
.mockResolvedValueOnce(conversation('workspace-one', 1, 'conversation-two'));
confirmImageWorkspaceGenerationMock.mockReturnValueOnce(confirmation.promise);
await useImageWorkspaceStore.getState().load();
const confirming = useImageWorkspaceStore.getState().confirmGeneration('quote-one');
await vi.waitFor(() => expect(confirmImageWorkspaceGenerationMock).toHaveBeenCalledOnce());
await useImageWorkspaceStore.getState().selectConversation('conversation-two');
useImageWorkspaceStore.setState({ error: '新会话错误' });
confirmation.resolve(conversation('workspace-one', 2, 'conversation-one'));
await confirming;
expect(fetchImageWorkspaceTasksMock).toHaveBeenCalledTimes(3);
expect(useImageWorkspaceStore.getState()).toMatchObject({
activeConversationId: 'conversation-two',
conversation: { conversationId: 'conversation-two', turnRevision: 1 },
error: '新会话错误',
tasks: [{ taskId: 'task-one', quoteId: 'quote-one' }],
});
});
it('still reports errors from an explicit task refresh', async () => {
const refreshError = new Error('主动刷新任务失败');
fetchImageWorkspaceTasksMock
.mockResolvedValueOnce([])
.mockRejectedValueOnce(refreshError);
await useImageWorkspaceStore.getState().load();
await expect(useImageWorkspaceStore.getState().refreshTasks()).rejects.toBe(refreshError);
expect(useImageWorkspaceStore.getState().error).toBe('主动刷新任务失败');
});
it('does not let a failed ABA confirmation overwrite a newer turn in the same Workspace', async () => {
const source = new MockEventSource();
const latestResponse = deferred<DesignConversation>();
const runtimeError = new ImageWorkspaceApiError(
503,
'agent_runtime_unavailable',
'AI 设计服务暂时不可用,请稍后重试',
);
let rejectConfirmation!: (error: unknown) => void;
const staleConfirmationResponse = new Promise<DesignConversation>((_resolve, reject) => {
rejectConfirmation = reject;
});
openImageWorkspaceTaskEventsMock.mockResolvedValue(source as unknown as EventSource);
fetchImageWorkspaceMock.mockResolvedValue(bootstrap(['workspace-one', 'workspace-two']));
fetchImageWorkspaceProjectMock.mockImplementation((workspaceId: string) => (
Promise.resolve(workspace(workspaceId))
));
fetchImageWorkspaceTasksMock
.mockResolvedValueOnce([])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([task]);
fetchImageWorkspaceConversationMock
.mockResolvedValueOnce(conversation('workspace-one', 1, 'conversation-one'))
.mockResolvedValueOnce(conversation('workspace-two', 1, 'conversation-two'))
.mockResolvedValueOnce(conversation('workspace-one', 1, 'conversation-one'))
.mockResolvedValueOnce(conversation('workspace-one', 2, 'conversation-one'));
confirmImageWorkspaceGenerationMock.mockReturnValueOnce(staleConfirmationResponse);
sendImageWorkspaceMessageMock.mockReturnValueOnce(latestResponse.promise);
await useImageWorkspaceStore.getState().load();
const staleOutcome = useImageWorkspaceStore.getState().confirmGeneration('quote-one').then(
(value) => ({ value, error: null }),
(error: unknown) => ({ value: null, error }),
);
await vi.waitFor(() => expect(confirmImageWorkspaceGenerationMock).toHaveBeenCalledOnce());
await useImageWorkspaceStore.getState().selectProject('workspace-two');
await useImageWorkspaceStore.getState().selectProject('workspace-one');
const latestTurn = useImageWorkspaceStore.getState().sendMessage('继续调整最新方案');
rejectConfirmation(runtimeError);
await expect(staleOutcome).resolves.toMatchObject({
value: { conversationId: 'conversation-one', turnRevision: 1 },
error: null,
});
expect(fetchImageWorkspaceConversationMock).toHaveBeenCalledTimes(3);
expect(useImageWorkspaceStore.getState()).toMatchObject({
activeWorkspaceId: 'workspace-one',
activeConversationId: 'conversation-one',
conversation: { turnRevision: 1 },
pendingTurn: { userText: '继续调整最新方案' },
error: null,
});
latestResponse.resolve(conversation('workspace-one', 2, 'conversation-one'));
await latestTurn;
});
it('does not silently complete confirmation when the quoted task stays missing', async () => {
const source = new MockEventSource();
openImageWorkspaceTaskEventsMock.mockResolvedValue(source as unknown as EventSource);

View File

@@ -75,6 +75,7 @@ type MockSocketScript = {
open?: boolean;
closeCode?: number;
closeReason?: string;
onSend?: (socket: MockAgentWebSocket, data: string) => void;
};
class MockAgentWebSocket {
@@ -94,6 +95,7 @@ class MockAgentWebSocket {
send(data: string): void {
this.sent.push(data);
this.script.onSend?.(this, data);
}
emitFrame(frame: unknown): void {
@@ -136,6 +138,29 @@ function scriptedSockets(scripts: MockSocketScript[]) {
return { sockets, webSocketFactory };
}
function acceptCommand(
socket: MockAgentWebSocket,
rawFrame: string,
runId: string,
events: unknown[],
): void {
const frame = JSON.parse(rawFrame) as Record<string, unknown>;
if (frame.type !== 'command.submit') return;
queueMicrotask(() => {
socket.emitFrame({
type: 'command.accepted',
request_id: frame.request_id,
command: {
command_id: `command-${runId}`,
run_id: runId,
status: 'queued',
error: null,
},
});
for (const event of events) socket.emitFrame(event);
});
}
describe('Works Square AI design adapter', () => {
beforeEach(() => {
getTokenMock.mockReset();
@@ -364,22 +389,10 @@ describe('Works Square AI design adapter', () => {
});
it('completes a design turn from the connected WebSocket without polling the run endpoint', async () => {
const { sockets, webSocketFactory } = scriptedSockets([{ open: true }]);
const fetchMock = vi.fn<typeof fetch>(async (input) => {
const url = String(input);
if (url.endsWith('/conversations/conversation-one')) {
return jsonResponse({ ...serverConversation, agent_session_id: 'session-live' });
}
if (url.endsWith('/stream-tickets')) {
return jsonResponse({
ticket: 'ticket-live',
transport: 'websocket',
stream_url: '/api/agents/sessions/session-live/ws?ticket=ticket-live',
expires_at: '2026-08-03T03:00:00Z',
});
}
if (url.endsWith('/api/agents/sessions/session-live/commands')) {
queueMicrotask(() => sockets[0]?.emitFrame({
const { webSocketFactory } = scriptedSockets([{
open: true,
onSend(socket, rawFrame) {
acceptCommand(socket, rawFrame, 'run-live', [{
type: 'event',
event: {
session_id: 'session-live',
@@ -394,12 +407,21 @@ describe('Works Square AI design adapter', () => {
occurred_at: '2026-08-03T02:00:05Z',
payload: { status: 'succeeded' },
},
}));
}]);
},
}]);
const fetchMock = vi.fn<typeof fetch>(async (input) => {
const url = String(input);
if (url.endsWith('/conversations/conversation-one')) {
return jsonResponse({ ...serverConversation, agent_session_id: 'session-live' });
}
if (url.endsWith('/stream-tickets')) {
return jsonResponse({
command_id: 'command-live',
run_id: 'run-live',
status: 'accepted',
}, 202);
ticket: 'ticket-live',
transport: 'websocket',
stream_url: '/api/agents/sessions/session-live/ws?ticket=ticket-live',
expires_at: '2026-08-03T03:00:00Z',
});
}
if (url.endsWith('/conversations/conversation-one')) {
return jsonResponse(serverConversation);
@@ -427,13 +449,264 @@ describe('Works Square AI design adapter', () => {
}
});
it('does not let terminal Run completion overtake streamed design events', async () => {
it('maps a matching top-level WebSocket command error without using the HTTP fallback', async () => {
const { webSocketFactory } = scriptedSockets([{
open: true,
onSend(socket, rawFrame) {
const frame = JSON.parse(rawFrame) as Record<string, unknown>;
expect(frame).toMatchObject({
type: 'command.submit',
command: {
client_command_id: 'turn-command-error',
name: 'turn.submit',
},
});
expect(frame.request_id).toEqual(expect.any(String));
queueMicrotask(() => socket.emitFrame({
type: 'error',
request_id: frame.request_id,
error: {
code: 'agent_runtime_unavailable',
message: 'Agent Runtime is temporarily unavailable',
retryable: true,
},
}));
},
}]);
const fetchMock = vi.fn<typeof fetch>(async (input) => {
const url = String(input);
if (url.endsWith('/conversations/conversation-one')) {
return jsonResponse({ ...serverConversation, agent_session_id: 'session-command-error' });
}
if (url.endsWith('/stream-tickets')) {
return jsonResponse({
stream_url: '/api/agents/sessions/session-command-error/ws?ticket=ticket-command-error',
});
}
throw new Error(`Unexpected request: ${url}`);
});
const adapter = new WorksSquareDesignWorkspace({
apiBaseUrl: 'https://square.example',
fetchImpl: fetchMock,
webSocketFactory,
});
const subscription = await adapter.openWorkspaceEvents({
workspaceId: 'workspace-one',
conversationId: 'conversation-one',
});
try {
await expect(adapter.submitMessage({
workspaceId: 'workspace-one',
conversationId: 'conversation-one',
clientTurnId: 'turn-command-error',
expectedTurnRevision: 1,
message: '继续设计',
})).rejects.toMatchObject({
status: 503,
code: 'agent_runtime_unavailable',
message: 'AI 设计服务暂时不可用,请稍后重试',
});
expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/commands'))).toBe(false);
} finally {
subscription.close();
}
});
it('redacts an unknown top-level WebSocket command error without using the HTTP fallback', async () => {
const privateDetail = 'private upstream host and stack trace';
const { webSocketFactory } = scriptedSockets([{
open: true,
onSend(socket, rawFrame) {
const frame = JSON.parse(rawFrame) as Record<string, unknown>;
queueMicrotask(() => socket.emitFrame({
type: 'error',
request_id: frame.request_id,
error: {
code: 'gateway_private_failure',
message: privateDetail,
retryable: false,
},
}));
},
}]);
const fetchMock = vi.fn<typeof fetch>(async (input) => {
const url = String(input);
if (url.endsWith('/conversations/conversation-one')) {
return jsonResponse({ ...serverConversation, agent_session_id: 'session-private-error' });
}
if (url.endsWith('/stream-tickets')) {
return jsonResponse({
stream_url: '/api/agents/sessions/session-private-error/ws?ticket=ticket-private-error',
});
}
throw new Error(`Unexpected request: ${url}`);
});
const adapter = new WorksSquareDesignWorkspace({
apiBaseUrl: 'https://square.example',
fetchImpl: fetchMock,
webSocketFactory,
});
const subscription = await adapter.openWorkspaceEvents({
workspaceId: 'workspace-one',
conversationId: 'conversation-one',
});
try {
const error = await adapter.submitMessage({
workspaceId: 'workspace-one',
conversationId: 'conversation-one',
clientTurnId: 'turn-private-error',
expectedTurnRevision: 1,
message: '继续设计',
}).then(
() => null,
(reason: unknown) => reason,
);
expect(error).toMatchObject({
status: 502,
code: 'gateway_private_failure',
message: 'AI 设计请求失败,请稍后重试',
});
expect((error as Error).message).not.toContain(privateDetail);
expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/commands'))).toBe(false);
} finally {
subscription.close();
}
});
it('falls back to the idempotent HTTP command when the WebSocket acknowledgement times out', async () => {
vi.useFakeTimers();
const { sockets, webSocketFactory } = scriptedSockets([{ open: true }]);
let conversationReads = 0;
const fetchMock = vi.fn<typeof fetch>(async (input, init) => {
const url = String(input);
if (url.endsWith('/conversations/conversation-one')) {
conversationReads += 1;
return jsonResponse({
...serverConversation,
agent_session_id: 'session-ack-timeout',
turn_revision: conversationReads === 1 ? 1 : 2,
});
}
if (url.endsWith('/stream-tickets')) {
return jsonResponse({
stream_url: '/api/agents/sessions/session-ack-timeout/ws?ticket=ticket-timeout',
});
}
if (url.endsWith('/api/agents/sessions/session-ack-timeout/commands')) {
expect(JSON.parse(String(init?.body))).toMatchObject({
client_command_id: 'turn-ack-timeout',
name: 'turn.submit',
});
queueMicrotask(() => sockets[0]?.emitFrame({
type: 'event',
event: {
session_id: 'session-ack-timeout',
sequence: 6,
runtime: 'design',
type: 'run.completed',
run_id: 'run-ack-timeout',
schema_version: 1,
payload: { status: 'succeeded' },
},
}));
return jsonResponse({ run_id: 'run-ack-timeout', status: 'queued', error: null }, 202);
}
throw new Error(`Unexpected request: ${url}`);
});
const adapter = new WorksSquareDesignWorkspace({
apiBaseUrl: 'https://square.example',
fetchImpl: fetchMock,
webSocketFactory,
});
const subscriptionPromise = adapter.openWorkspaceEvents({
workspaceId: 'workspace-one',
conversationId: 'conversation-one',
});
await vi.advanceTimersByTimeAsync(0);
const subscription = await subscriptionPromise;
try {
const turn = adapter.submitMessage({
workspaceId: 'workspace-one',
conversationId: 'conversation-one',
clientTurnId: 'turn-ack-timeout',
expectedTurnRevision: 1,
message: '继续设计',
});
await vi.advanceTimersByTimeAsync(5_000);
await expect(turn).resolves.toMatchObject({ turnRevision: 2 });
expect(fetchMock.mock.calls.filter(([url]) => String(url).includes('/commands')))
.toHaveLength(1);
expect(fetchMock.mock.calls.filter(([url]) => String(url).includes('/runs/')))
.toHaveLength(0);
} finally {
subscription.close();
vi.useRealTimers();
}
});
it('does not let terminal Run completion overtake streamed design events', async () => {
const streamedConversation = {
...serverConversation,
agent_session_id: 'session-ordered',
turn_revision: 2,
};
const { webSocketFactory } = scriptedSockets([{
open: true,
onSend(socket, rawFrame) {
acceptCommand(socket, rawFrame, 'run-ordered', [{
type: 'event',
event: {
session_id: 'session-ordered',
sequence: 4,
runtime: 'design',
type: 'design.assistant.delta',
run_id: 'run-ordered',
schema_version: 1,
payload: {
workspace_id: 'workspace-one',
conversation_id: 'conversation-one',
client_turn_id: 'turn-ordered',
turn_revision: 2,
chunk_index: 0,
delta: '??????',
},
},
}, {
type: 'event',
event: {
session_id: 'session-ordered',
sequence: 5,
runtime: 'design',
type: 'design.conversation.updated',
run_id: 'run-ordered',
schema_version: 1,
payload: {
workspace_id: 'workspace-one',
conversation_id: 'conversation-one',
workspace_view_revision: 3,
conversation: streamedConversation,
generation_tasks: [],
},
},
}, {
type: 'event',
event: {
session_id: 'session-ordered',
sequence: 6,
runtime: 'design',
type: 'run.completed',
run_id: 'run-ordered',
schema_version: 1,
payload: { status: 'succeeded' },
},
}]);
},
}]);
const fetchMock = vi.fn<typeof fetch>(async (input) => {
const url = String(input);
if (url.endsWith('/conversations/conversation-one')) {
@@ -444,60 +717,6 @@ describe('Works Square AI design adapter', () => {
stream_url: '/api/agents/sessions/session-ordered/ws?ticket=ticket-ordered',
});
}
if (url.endsWith('/api/agents/sessions/session-ordered/commands')) {
queueMicrotask(() => {
sockets[0]?.emitFrame({
type: 'event',
event: {
session_id: 'session-ordered',
sequence: 4,
runtime: 'design',
type: 'design.assistant.delta',
run_id: 'run-ordered',
schema_version: 1,
payload: {
workspace_id: 'workspace-one',
conversation_id: 'conversation-one',
client_turn_id: 'turn-ordered',
turn_revision: 2,
chunk_index: 0,
delta: '??????',
},
},
});
sockets[0]?.emitFrame({
type: 'event',
event: {
session_id: 'session-ordered',
sequence: 5,
runtime: 'design',
type: 'design.conversation.updated',
run_id: 'run-ordered',
schema_version: 1,
payload: {
workspace_id: 'workspace-one',
conversation_id: 'conversation-one',
workspace_view_revision: 3,
conversation: streamedConversation,
generation_tasks: [],
},
},
});
sockets[0]?.emitFrame({
type: 'event',
event: {
session_id: 'session-ordered',
sequence: 6,
runtime: 'design',
type: 'run.completed',
run_id: 'run-ordered',
schema_version: 1,
payload: { status: 'succeeded' },
},
});
});
return jsonResponse({ run_id: 'run-ordered', status: 'queued', error: null }, 202);
}
throw new Error(`Unexpected request: ${url}`);
});
const adapter = new WorksSquareDesignWorkspace({
@@ -554,12 +773,45 @@ describe('Works Square AI design adapter', () => {
it('bounds the delivery barrier when an opened stream has no consumer', async () => {
vi.useFakeTimers();
const { sockets, webSocketFactory } = scriptedSockets([{ open: true }]);
const streamedConversation = {
...serverConversation,
agent_session_id: 'session-bounded',
turn_revision: 2,
};
const { webSocketFactory } = scriptedSockets([{
open: true,
onSend(socket, rawFrame) {
acceptCommand(socket, rawFrame, 'run-bounded', [{
type: 'event',
event: {
session_id: 'session-bounded',
sequence: 5,
runtime: 'design',
type: 'design.conversation.updated',
run_id: 'run-bounded',
schema_version: 1,
payload: {
workspace_id: 'workspace-one',
conversation_id: 'conversation-one',
workspace_view_revision: 3,
conversation: streamedConversation,
generation_tasks: [],
},
},
}, {
type: 'event',
event: {
session_id: 'session-bounded',
sequence: 6,
runtime: 'design',
type: 'run.completed',
run_id: 'run-bounded',
schema_version: 1,
payload: { status: 'succeeded' },
},
}]);
},
}]);
const fetchMock = vi.fn<typeof fetch>(async (input) => {
const url = String(input);
if (url.endsWith('/conversations/conversation-one')) {
@@ -570,41 +822,6 @@ describe('Works Square AI design adapter', () => {
stream_url: '/api/agents/sessions/session-bounded/ws?ticket=ticket-bounded',
});
}
if (url.endsWith('/api/agents/sessions/session-bounded/commands')) {
queueMicrotask(() => {
sockets[0]?.emitFrame({
type: 'event',
event: {
session_id: 'session-bounded',
sequence: 5,
runtime: 'design',
type: 'design.conversation.updated',
run_id: 'run-bounded',
schema_version: 1,
payload: {
workspace_id: 'workspace-one',
conversation_id: 'conversation-one',
workspace_view_revision: 3,
conversation: streamedConversation,
generation_tasks: [],
},
},
});
sockets[0]?.emitFrame({
type: 'event',
event: {
session_id: 'session-bounded',
sequence: 6,
runtime: 'design',
type: 'run.completed',
run_id: 'run-bounded',
schema_version: 1,
payload: { status: 'succeeded' },
},
});
});
return jsonResponse({ run_id: 'run-bounded', status: 'queued', error: null }, 202);
}
throw new Error(`Unexpected request: ${url}`);
});
const adapter = new WorksSquareDesignWorkspace({
@@ -641,19 +858,10 @@ describe('Works Square AI design adapter', () => {
});
it('maps a failed Run received from the connected WebSocket without polling', async () => {
const { sockets, webSocketFactory } = scriptedSockets([{ open: true }]);
const fetchMock = vi.fn<typeof fetch>(async (input) => {
const url = String(input);
if (url.endsWith('/conversations/conversation-one')) {
return jsonResponse({ ...serverConversation, agent_session_id: 'session-failed' });
}
if (url.endsWith('/stream-tickets')) {
return jsonResponse({
stream_url: '/api/agents/sessions/session-failed/ws?ticket=ticket-failed',
});
}
if (url.endsWith('/api/agents/sessions/session-failed/commands')) {
queueMicrotask(() => sockets[0]?.emitFrame({
const { webSocketFactory } = scriptedSockets([{
open: true,
onSend(socket, rawFrame) {
acceptCommand(socket, rawFrame, 'run-failed', [{
type: 'event',
event: {
session_id: 'session-failed',
@@ -670,8 +878,18 @@ describe('Works Square AI design adapter', () => {
},
},
},
}));
return jsonResponse({ run_id: 'run-failed', status: 'queued', error: null }, 202);
}]);
},
}]);
const fetchMock = vi.fn<typeof fetch>(async (input) => {
const url = String(input);
if (url.endsWith('/conversations/conversation-one')) {
return jsonResponse({ ...serverConversation, agent_session_id: 'session-failed' });
}
if (url.endsWith('/stream-tickets')) {
return jsonResponse({
stream_url: '/api/agents/sessions/session-failed/ws?ticket=ticket-failed',
});
}
throw new Error(`Unexpected request: ${url}`);
});
@@ -700,6 +918,188 @@ describe('Works Square AI design adapter', () => {
}
});
it('submits a confirmed Quote through the connected WebSocket and receives its task', async () => {
const generationTask = {
task_id: 'task-confirm-live',
workspace_id: 'workspace-one',
conversation_id: 'conversation-one',
medium: 'image',
status: 'queued',
brief_version: 1,
brief_summary: '公益海报',
quote_id: 'quote-one',
quoted_design_points: 1,
failure_code: null,
result_assets: [],
created_at: '2026-08-14T05:00:00Z',
updated_at: '2026-08-14T05:00:00Z',
};
const { sockets, webSocketFactory } = scriptedSockets([{
open: true,
onSend(socket, rawFrame) {
acceptCommand(socket, rawFrame, 'run-confirm-live', [{
type: 'event',
event: {
session_id: 'session-confirm-live',
sequence: 5,
runtime: 'design',
type: 'design.generation_task.updated',
run_id: 'run-confirm-live',
schema_version: 1,
payload: {
workspace_id: 'workspace-one',
workspace_view_revision: 3,
generation_task: generationTask,
},
},
}, {
type: 'event',
event: {
session_id: 'session-confirm-live',
sequence: 6,
runtime: 'design',
type: 'run.completed',
run_id: 'run-confirm-live',
schema_version: 1,
payload: { status: 'succeeded' },
},
}]);
},
}]);
let conversationReads = 0;
const fetchMock = vi.fn<typeof fetch>(async (input) => {
const url = String(input);
if (url.endsWith('/conversations/conversation-one')) {
conversationReads += 1;
return jsonResponse({
...serverConversation,
agent_session_id: 'session-confirm-live',
turn_revision: conversationReads === 1 ? 1 : 2,
});
}
if (url.endsWith('/stream-tickets')) {
return jsonResponse({
stream_url: '/api/agents/sessions/session-confirm-live/ws?ticket=ticket-confirm-live',
});
}
throw new Error(`Unexpected request: ${url}`);
});
const adapter = new WorksSquareDesignWorkspace({
apiBaseUrl: 'https://square.example',
fetchImpl: fetchMock,
webSocketFactory,
});
const subscription = await adapter.openWorkspaceEvents({
workspaceId: 'workspace-one',
conversationId: 'conversation-one',
});
const received: DesignWorkspaceEvent[] = [];
const consume = (async () => {
for await (const event of subscription.events) {
received.push(event);
break;
}
})();
try {
await expect(adapter.confirmGeneration({
workspaceId: 'workspace-one',
conversationId: 'conversation-one',
clientTurnId: 'turn-confirm-live',
expectedTurnRevision: 1,
quoteId: 'quote-one',
})).resolves.toMatchObject({ turnRevision: 2 });
await consume;
expect(received).toMatchObject([{
type: 'design.generation_task.updated',
generationTask: {
taskId: 'task-confirm-live',
quoteId: 'quote-one',
status: 'queued',
},
}]);
expect(JSON.parse(sockets[0].sent[0])).toMatchObject({
type: 'command.submit',
command: {
client_command_id: 'turn-confirm-live',
name: 'turn.submit',
input: {
action: { type: 'confirm_generation', quote_id: 'quote-one' },
},
},
});
expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/commands'))).toBe(false);
expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/runs/'))).toBe(false);
} finally {
subscription.close();
}
});
it('maps a generic Agent Runtime failure from the WebSocket to a safe 503', async () => {
const { webSocketFactory } = scriptedSockets([{
open: true,
onSend(socket, rawFrame) {
acceptCommand(socket, rawFrame, 'run-unavailable', [{
type: 'event',
event: {
session_id: 'session-unavailable',
sequence: 6,
runtime: 'design',
type: 'run.failed',
run_id: 'run-unavailable',
schema_version: 1,
payload: {
error: {
code: 'agent_runtime_unavailable',
message: 'Agent Runtime is temporarily unavailable',
retryable: true,
},
},
},
}]);
},
}]);
const fetchMock = vi.fn<typeof fetch>(async (input) => {
const url = String(input);
if (url.endsWith('/conversations/conversation-one')) {
return jsonResponse({ ...serverConversation, agent_session_id: 'session-unavailable' });
}
if (url.endsWith('/stream-tickets')) {
return jsonResponse({
stream_url: '/api/agents/sessions/session-unavailable/ws?ticket=ticket-unavailable',
});
}
throw new Error(`Unexpected request: ${url}`);
});
const adapter = new WorksSquareDesignWorkspace({
apiBaseUrl: 'https://square.example',
fetchImpl: fetchMock,
webSocketFactory,
});
const subscription = await adapter.openWorkspaceEvents({
workspaceId: 'workspace-one',
conversationId: 'conversation-one',
});
try {
await expect(adapter.confirmGeneration({
workspaceId: 'workspace-one',
conversationId: 'conversation-one',
clientTurnId: 'turn-unavailable',
expectedTurnRevision: 1,
quoteId: 'quote-one',
})).rejects.toMatchObject({
status: 503,
code: 'agent_runtime_unavailable',
message: 'AI 设计服务暂时不可用,请稍后重试',
});
expect(fetchMock.mock.calls.filter(([url]) => String(url).includes('/runs/'))).toHaveLength(0);
} finally {
subscription.close();
}
});
it('falls back to one Run request after the WebSocket disconnects', async () => {
const { webSocketFactory } = scriptedSockets([{ open: true, closeCode: 1006 }]);
const fetchMock = vi.fn<typeof fetch>()