fix: 修复 AI 设计确认生成任务链路
This commit is contained in:
@@ -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,139 @@ 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('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);
|
||||
|
||||
@@ -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,137 @@ describe('Works Square AI design adapter', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('does not let terminal Run completion overtake streamed design events', async () => {
|
||||
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 +590,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 +646,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 +695,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 +731,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 +751,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 +791,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>()
|
||||
|
||||
Reference in New Issue
Block a user