2072 lines
70 KiB
TypeScript
2072 lines
70 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||
import { WorksSquareDesignWorkspace } from '@electron/image-workspace/works-square-workspace';
|
||
import { getValidWorksSquareAccessToken } from '@electron/services/works-square-session';
|
||
import type { DesignWorkspaceEvent } from '../../shared/image-workspace';
|
||
|
||
vi.mock('@electron/services/works-square-session', () => ({
|
||
getValidWorksSquareAccessToken: vi.fn(),
|
||
}));
|
||
|
||
const getTokenMock = vi.mocked(getValidWorksSquareAccessToken);
|
||
|
||
const serverConversation = {
|
||
conversation_id: 'conversation-one',
|
||
workspace_id: 'workspace-one',
|
||
agent_session_id: 'session-one',
|
||
title: '????',
|
||
latest_message_preview: '??????????',
|
||
turn_revision: 1,
|
||
phase: 'awaiting_confirmation',
|
||
brief: {
|
||
version: 1,
|
||
status: 'ready',
|
||
medium: 'image',
|
||
summary: '???????????',
|
||
ready: true,
|
||
missing_decision: null,
|
||
},
|
||
messages: [{
|
||
role: 'assistant',
|
||
kind: 'confirmation',
|
||
text: '??????????????',
|
||
quick_replies: ['????'],
|
||
generation_quote: {
|
||
quote_id: 'quote-one',
|
||
status: 'active',
|
||
medium: 'image',
|
||
brief_version: 1,
|
||
brief_summary: '???????????',
|
||
final_prompt: '??????????????',
|
||
prompt_mode: 'guided',
|
||
generation_parameters: {
|
||
model: 'image-model-one',
|
||
resolution: '1024x1024',
|
||
aspect_ratio: '1:1',
|
||
duration_seconds: null,
|
||
},
|
||
generation_options: {
|
||
models: [{
|
||
value: 'image-model-one',
|
||
label: '??????',
|
||
default: true,
|
||
disabled: false,
|
||
multiplier: 1,
|
||
}],
|
||
resolutions: [{
|
||
value: '1024x1024',
|
||
label: '1024 × 1024',
|
||
default: true,
|
||
disabled: false,
|
||
multiplier: 1,
|
||
}],
|
||
durations: [],
|
||
aspect_ratios: [{
|
||
value: '1:1',
|
||
label: '1:1',
|
||
default: true,
|
||
disabled: false,
|
||
multiplier: 1,
|
||
}, {
|
||
value: '16:9',
|
||
label: '16:9',
|
||
default: false,
|
||
disabled: false,
|
||
multiplier: 1,
|
||
}],
|
||
},
|
||
pricing: {
|
||
schema: 'design-pricing-v2',
|
||
amount: 1,
|
||
rounding: 'ceil',
|
||
},
|
||
quoted_design_points: 1,
|
||
expires_at: '2026-07-31T11:00:00Z',
|
||
},
|
||
turn_revision: 1,
|
||
created_at: '2026-07-31T10:00:00Z',
|
||
}],
|
||
created_at: '2026-07-31T10:00:00Z',
|
||
updated_at: '2026-07-31T10:00:00Z',
|
||
};
|
||
|
||
const serverWorkspace = {
|
||
workspace_id: 'workspace-one',
|
||
title: '??????',
|
||
view_revision: 2,
|
||
conversation_count: 1,
|
||
phase: 'awaiting_confirmation',
|
||
updated_at: '2026-07-31T10:00:00Z',
|
||
};
|
||
|
||
const quoteConfirmationInput = {
|
||
finalPrompt: '??????????????',
|
||
generationParameters: {
|
||
model: 'image-model-one',
|
||
resolution: '1024x1024',
|
||
aspectRatio: '1:1',
|
||
durationSeconds: null,
|
||
},
|
||
};
|
||
|
||
function jsonResponse(payload: unknown, status = 200): Response {
|
||
return new Response(JSON.stringify(payload), {
|
||
status,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
|
||
function deferred<T>(): { promise: Promise<T>; resolve(value: T): void } {
|
||
let resolve!: (value: T) => void;
|
||
const promise = new Promise<T>((accept) => {
|
||
resolve = accept;
|
||
});
|
||
return { promise, resolve };
|
||
}
|
||
|
||
type MockSocketScript = {
|
||
frames?: unknown[];
|
||
open?: boolean;
|
||
closeCode?: number;
|
||
closeReason?: string;
|
||
onSend?: (socket: MockAgentWebSocket, data: string) => void;
|
||
};
|
||
|
||
class MockAgentWebSocket {
|
||
readonly url: string;
|
||
readonly sent: string[] = [];
|
||
readyState = 0;
|
||
onopen: (() => void) | null = null;
|
||
onmessage: ((event: { data: unknown }) => void) | null = null;
|
||
onerror: ((event: unknown) => void) | null = null;
|
||
onclose: ((event: { code: number; reason: string }) => void) | null = null;
|
||
private closed = false;
|
||
|
||
constructor(url: string, private readonly script: MockSocketScript) {
|
||
this.url = url;
|
||
setTimeout(() => this.runScript(), 0);
|
||
}
|
||
|
||
send(data: string): void {
|
||
this.sent.push(data);
|
||
this.script.onSend?.(this, data);
|
||
}
|
||
|
||
emitFrame(frame: unknown): void {
|
||
this.onmessage?.({ data: JSON.stringify(frame) });
|
||
}
|
||
|
||
close(code = 1000, reason = ''): void {
|
||
this.emitClose(code, reason);
|
||
}
|
||
|
||
private runScript(): void {
|
||
if (this.closed) return;
|
||
if (this.script.open !== false) {
|
||
this.readyState = 1;
|
||
this.onopen?.();
|
||
for (const frame of this.script.frames ?? []) {
|
||
this.onmessage?.({ data: JSON.stringify(frame) });
|
||
}
|
||
}
|
||
if (this.script.closeCode !== undefined) {
|
||
this.emitClose(this.script.closeCode, this.script.closeReason ?? '');
|
||
}
|
||
}
|
||
|
||
private emitClose(code: number, reason: string): void {
|
||
if (this.closed) return;
|
||
this.closed = true;
|
||
this.readyState = 3;
|
||
this.onclose?.({ code, reason });
|
||
}
|
||
}
|
||
|
||
function scriptedSockets(scripts: MockSocketScript[]) {
|
||
const sockets: MockAgentWebSocket[] = [];
|
||
const webSocketFactory = vi.fn((url: string) => {
|
||
const socket = new MockAgentWebSocket(url, scripts[sockets.length] ?? { closeCode: 1000 });
|
||
sockets.push(socket);
|
||
return { socket };
|
||
});
|
||
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();
|
||
getTokenMock.mockResolvedValue('access-one');
|
||
});
|
||
|
||
it('maps the server snake_case Workspace contract into the shared client model', async () => {
|
||
const fetchMock = vi.fn<typeof fetch>()
|
||
.mockResolvedValueOnce(jsonResponse({
|
||
conversation: true,
|
||
generation: true,
|
||
image: true,
|
||
video: false,
|
||
}))
|
||
.mockResolvedValueOnce(jsonResponse([serverWorkspace]));
|
||
const adapter = new WorksSquareDesignWorkspace({
|
||
apiBaseUrl: 'https://square.example',
|
||
fetchImpl: fetchMock,
|
||
});
|
||
|
||
await expect(adapter.bootstrap()).resolves.toMatchObject({
|
||
capabilities: { conversation: true, image: true, video: false },
|
||
workspaces: [{
|
||
workspaceId: 'workspace-one',
|
||
title: '??????',
|
||
viewRevision: 2,
|
||
conversationCount: 1,
|
||
}],
|
||
});
|
||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||
expect(fetchMock.mock.calls.every(([, init]) => (
|
||
(init?.headers as Record<string, string>).Authorization === 'Bearer access-one'
|
||
))).toBe(true);
|
||
});
|
||
|
||
it('returns an explicit deletion result when the Workspace API responds with JSON', async () => {
|
||
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce(jsonResponse({
|
||
workspace_id: 'workspace/one',
|
||
deleted: true,
|
||
}));
|
||
const adapter = new WorksSquareDesignWorkspace({
|
||
apiBaseUrl: 'https://square.example',
|
||
fetchImpl: fetchMock,
|
||
});
|
||
|
||
await expect(adapter.deleteWorkspace('workspace/one')).resolves.toEqual({
|
||
workspaceId: 'workspace/one',
|
||
deleted: true,
|
||
});
|
||
expect(fetchMock).toHaveBeenCalledWith(
|
||
'https://square.example/api/design/workspaces/workspace%2Fone',
|
||
expect.objectContaining({
|
||
method: 'DELETE',
|
||
headers: expect.objectContaining({
|
||
Accept: 'application/json',
|
||
Authorization: 'Bearer access-one',
|
||
}),
|
||
}),
|
||
);
|
||
});
|
||
|
||
it('accepts a 204 deletion and clears only the deleted Workspace event/session caches', async () => {
|
||
let deleted = false;
|
||
let targetConversationReads = 0;
|
||
const fetchMock = vi.fn<typeof fetch>(async (input, init) => {
|
||
const url = String(input);
|
||
if (url.endsWith('/api/design/workspaces/workspace-one') && init?.method === 'DELETE') {
|
||
deleted = true;
|
||
return new Response(null, { status: 204 });
|
||
}
|
||
if (url.endsWith('/workspaces/workspace-one/conversations/conversation-one')) {
|
||
targetConversationReads += 1;
|
||
if (deleted) {
|
||
return jsonResponse({
|
||
detail: { code: 'workspace_not_found', message: 'deleted' },
|
||
}, 404);
|
||
}
|
||
return jsonResponse(serverConversation);
|
||
}
|
||
if (url.endsWith('/sessions/session-one/stream-tickets')) {
|
||
return jsonResponse({
|
||
stream_url: '/api/agents/sessions/session-one/ws?ticket=ticket-one',
|
||
});
|
||
}
|
||
if (url.endsWith('/workspaces/workspace-two/conversations/conversation-two')) {
|
||
return jsonResponse({
|
||
...serverConversation,
|
||
workspace_id: 'workspace-two',
|
||
conversation_id: 'conversation-two',
|
||
agent_session_id: 'session-two',
|
||
});
|
||
}
|
||
if (url.endsWith('/sessions/session-two/stream-tickets')) {
|
||
return jsonResponse({
|
||
stream_url: '/api/agents/sessions/session-two/ws?ticket=ticket-two',
|
||
});
|
||
}
|
||
throw new Error(`Unexpected request: ${url}`);
|
||
});
|
||
const { sockets, webSocketFactory } = scriptedSockets([{ open: true }, { open: true }]);
|
||
const adapter = new WorksSquareDesignWorkspace({
|
||
apiBaseUrl: 'https://square.example',
|
||
fetchImpl: fetchMock,
|
||
webSocketFactory,
|
||
});
|
||
const target = await adapter.openWorkspaceEvents({
|
||
workspaceId: 'workspace-one',
|
||
conversationId: 'conversation-one',
|
||
});
|
||
const keeper = await adapter.openWorkspaceEvents({
|
||
workspaceId: 'workspace-two',
|
||
conversationId: 'conversation-two',
|
||
});
|
||
|
||
await expect(adapter.deleteWorkspace('workspace-one')).resolves.toEqual({
|
||
workspaceId: 'workspace-one',
|
||
deleted: true,
|
||
});
|
||
|
||
expect(sockets.map((socket) => socket.readyState)).toEqual([3, 1]);
|
||
await expect(adapter.openWorkspaceEvents({
|
||
workspaceId: 'workspace-one',
|
||
conversationId: 'conversation-one',
|
||
})).rejects.toMatchObject({
|
||
status: 404,
|
||
code: 'workspace_not_found',
|
||
message: '设计项目不存在或无权访问',
|
||
});
|
||
expect(targetConversationReads).toBe(2);
|
||
expect(fetchMock.mock.calls.filter(([, init]) => init?.method === 'DELETE'))
|
||
.toEqual([expect.arrayContaining([
|
||
'https://square.example/api/design/workspaces/workspace-one',
|
||
])]);
|
||
target.close();
|
||
keeper.close();
|
||
});
|
||
|
||
it('preserves Workspace event/session caches when deletion fails', async () => {
|
||
let ticketSequence = 0;
|
||
const fetchMock = vi.fn<typeof fetch>(async (input, init) => {
|
||
const url = String(input);
|
||
if (url.endsWith('/workspaces/workspace-one/conversations/conversation-one')) {
|
||
return jsonResponse(serverConversation);
|
||
}
|
||
if (url.endsWith('/sessions/session-one/stream-tickets')) {
|
||
ticketSequence += 1;
|
||
return jsonResponse({
|
||
stream_url: `/api/agents/sessions/session-one/ws?ticket=ticket-${ticketSequence}`,
|
||
});
|
||
}
|
||
if (url.endsWith('/api/design/workspaces/workspace-one') && init?.method === 'DELETE') {
|
||
return jsonResponse({
|
||
detail: { code: 'workspace_unavailable', message: 'private detail' },
|
||
}, 503);
|
||
}
|
||
throw new Error(`Unexpected request: ${url}`);
|
||
});
|
||
const { sockets, webSocketFactory } = scriptedSockets([{ open: true }, { open: true }]);
|
||
const adapter = new WorksSquareDesignWorkspace({
|
||
apiBaseUrl: 'https://square.example',
|
||
fetchImpl: fetchMock,
|
||
webSocketFactory,
|
||
});
|
||
const first = await adapter.openWorkspaceEvents({
|
||
workspaceId: 'workspace-one',
|
||
conversationId: 'conversation-one',
|
||
});
|
||
|
||
await expect(adapter.deleteWorkspace('workspace-one')).rejects.toMatchObject({
|
||
status: 503,
|
||
code: 'workspace_unavailable',
|
||
});
|
||
const second = await adapter.openWorkspaceEvents({
|
||
workspaceId: 'workspace-one',
|
||
conversationId: 'conversation-one',
|
||
});
|
||
|
||
expect(fetchMock.mock.calls.filter(([url]) => (
|
||
String(url).endsWith('/workspaces/workspace-one/conversations/conversation-one')
|
||
))).toHaveLength(1);
|
||
expect(sockets.map((socket) => socket.readyState)).toEqual([1, 1]);
|
||
first.close();
|
||
second.close();
|
||
});
|
||
|
||
it('lists, creates, and reads Conversations through the Workspace API', async () => {
|
||
const secondConversation = {
|
||
...serverConversation,
|
||
conversation_id: 'conversation-two',
|
||
agent_session_id: 'session-two',
|
||
title: '第二会话',
|
||
};
|
||
const fetchMock = vi.fn<typeof fetch>()
|
||
.mockResolvedValueOnce(jsonResponse(serverWorkspace))
|
||
.mockResolvedValueOnce(jsonResponse([serverConversation]))
|
||
.mockResolvedValueOnce(jsonResponse(secondConversation, 201))
|
||
.mockResolvedValueOnce(jsonResponse(secondConversation));
|
||
const adapter = new WorksSquareDesignWorkspace({
|
||
apiBaseUrl: 'https://square.example',
|
||
fetchImpl: fetchMock,
|
||
});
|
||
|
||
await expect(adapter.getWorkspace('workspace-one')).resolves.toMatchObject({
|
||
workspaceId: 'workspace-one',
|
||
conversationCount: 1,
|
||
conversations: [{
|
||
conversationId: 'conversation-one',
|
||
latestMessagePreview: '??????????',
|
||
}],
|
||
});
|
||
await expect(adapter.createConversation({
|
||
workspaceId: 'workspace-one',
|
||
clientConversationId: 'client-conversation-two',
|
||
title: '第二会话',
|
||
})).resolves.toMatchObject({
|
||
workspaceId: 'workspace-one',
|
||
conversationId: 'conversation-two',
|
||
title: '第二会话',
|
||
});
|
||
await expect(adapter.getConversation('workspace-one', 'conversation-two'))
|
||
.resolves.toMatchObject({ conversationId: 'conversation-two' });
|
||
|
||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||
1,
|
||
'https://square.example/api/design/workspaces/workspace-one',
|
||
expect.objectContaining({ headers: expect.any(Object) }),
|
||
);
|
||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||
2,
|
||
'https://square.example/api/design/workspaces/workspace-one/conversations?limit=100&offset=0',
|
||
expect.objectContaining({ headers: expect.any(Object) }),
|
||
);
|
||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||
3,
|
||
'https://square.example/api/design/workspaces/workspace-one/conversations',
|
||
expect.objectContaining({
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
client_conversation_id: 'client-conversation-two',
|
||
title: '第二会话',
|
||
}),
|
||
}),
|
||
);
|
||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||
4,
|
||
'https://square.example/api/design/workspaces/workspace-one/conversations/conversation-two',
|
||
expect.objectContaining({ headers: expect.any(Object) }),
|
||
);
|
||
});
|
||
|
||
it('normalizes a missing Brief medium to null and rejects invalid non-null values', async () => {
|
||
const { medium: _medium, ...legacyBrief } = serverConversation.brief;
|
||
const legacyConversation = {
|
||
...serverConversation,
|
||
brief: legacyBrief,
|
||
};
|
||
const invalidConversation = {
|
||
...serverConversation,
|
||
brief: { ...serverConversation.brief, medium: 'audio' },
|
||
};
|
||
const fetchMock = vi.fn<typeof fetch>()
|
||
.mockResolvedValueOnce(jsonResponse(legacyConversation))
|
||
.mockResolvedValueOnce(jsonResponse(invalidConversation));
|
||
const adapter = new WorksSquareDesignWorkspace({
|
||
apiBaseUrl: 'https://square.example',
|
||
fetchImpl: fetchMock,
|
||
});
|
||
|
||
await expect(adapter.getConversation('workspace-one', 'conversation-one'))
|
||
.resolves.toMatchObject({ brief: { medium: null } });
|
||
await expect(adapter.getConversation('workspace-one', 'conversation-one'))
|
||
.rejects.toMatchObject({ code: 'DESIGN_WORKSPACE_RESPONSE_INVALID' });
|
||
});
|
||
|
||
it('submits a conversation turn through the persistent Agent Gateway Session', async () => {
|
||
const fetchMock = vi.fn<typeof fetch>()
|
||
.mockResolvedValueOnce(jsonResponse(serverConversation))
|
||
.mockResolvedValueOnce(jsonResponse({
|
||
run_id: 'run-one',
|
||
status: 'queued',
|
||
error: null,
|
||
}, 202))
|
||
.mockResolvedValueOnce(jsonResponse({
|
||
run_id: 'run-one',
|
||
status: 'succeeded',
|
||
error: null,
|
||
}))
|
||
.mockResolvedValueOnce(jsonResponse(serverConversation));
|
||
const adapter = new WorksSquareDesignWorkspace({
|
||
apiBaseUrl: 'https://square.example',
|
||
fetchImpl: fetchMock,
|
||
clientInstanceId: 'installation-one',
|
||
});
|
||
|
||
await expect(adapter.submitMessage({
|
||
workspaceId: 'workspace-one',
|
||
conversationId: 'conversation-one',
|
||
clientTurnId: 'turn-two',
|
||
expectedTurnRevision: 1,
|
||
message: '????????????',
|
||
attachmentAssetIds: ['asset-reference'],
|
||
})).resolves.toMatchObject({
|
||
workspaceId: 'workspace-one',
|
||
turnRevision: 1,
|
||
});
|
||
|
||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||
2,
|
||
'https://square.example/api/agents/sessions/session-one/commands',
|
||
expect.objectContaining({
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
client_command_id: 'turn-two',
|
||
name: 'turn.submit',
|
||
input: {
|
||
expected_turn_revision: 1,
|
||
message: '????????????',
|
||
attachment_asset_ids: ['asset-reference'],
|
||
action: null,
|
||
},
|
||
}),
|
||
}),
|
||
);
|
||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||
3,
|
||
'https://square.example/api/agents/sessions/session-one/runs/run-one',
|
||
expect.objectContaining({ headers: expect.any(Object) }),
|
||
);
|
||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||
4,
|
||
'https://square.example/api/design/workspaces/workspace-one/conversations/conversation-one',
|
||
expect.objectContaining({ headers: expect.any(Object) }),
|
||
);
|
||
const commandBody = JSON.parse(String(fetchMock.mock.calls[1][1]?.body));
|
||
expect(commandBody.input).not.toHaveProperty('conversation_id');
|
||
});
|
||
|
||
it('re-quotes a generation Quote through the cloud PATCH contract and maps every option', async () => {
|
||
const quote = serverConversation.messages[0].generation_quote;
|
||
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce(jsonResponse(quote));
|
||
const adapter = new WorksSquareDesignWorkspace({
|
||
apiBaseUrl: 'https://square.example',
|
||
fetchImpl: fetchMock,
|
||
});
|
||
|
||
await expect(adapter.updateGenerationQuote({
|
||
workspaceId: 'workspace-one',
|
||
quoteId: 'quote-one',
|
||
finalPrompt: '用户最新编辑后的完整提示词',
|
||
generationParameters: {
|
||
model: 'image-model-one',
|
||
resolution: '1024x1024',
|
||
aspectRatio: '16:9',
|
||
durationSeconds: null,
|
||
},
|
||
})).resolves.toMatchObject({
|
||
quoteId: 'quote-one',
|
||
finalPrompt: '??????????????',
|
||
generationOptions: {
|
||
models: [{ value: 'image-model-one', label: '??????', disabled: false }],
|
||
},
|
||
});
|
||
|
||
expect(fetchMock).toHaveBeenCalledWith(
|
||
'https://square.example/api/design/workspaces/workspace-one/generation-quotes/quote-one',
|
||
expect.objectContaining({
|
||
method: 'PATCH',
|
||
body: JSON.stringify({
|
||
final_prompt: '用户最新编辑后的完整提示词',
|
||
model: 'image-model-one',
|
||
resolution: '1024x1024',
|
||
aspect_ratio: '16:9',
|
||
duration_seconds: null,
|
||
}),
|
||
}),
|
||
);
|
||
});
|
||
|
||
it('uploads a local reference image as multipart data and maps the returned Asset', async () => {
|
||
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce(jsonResponse({
|
||
asset_id: 'asset-uploaded',
|
||
media_type: 'image',
|
||
mime_type: 'image/png',
|
||
width: 1200,
|
||
height: 800,
|
||
duration_milliseconds: null,
|
||
created_at: '2026-08-06T10:00:00Z',
|
||
}));
|
||
const adapter = new WorksSquareDesignWorkspace({
|
||
apiBaseUrl: 'https://square.example',
|
||
fetchImpl: fetchMock,
|
||
});
|
||
|
||
await expect(adapter.uploadAsset({
|
||
workspaceId: 'workspace-one',
|
||
fileName: 'poster.webp',
|
||
mimeType: 'image/webp',
|
||
bytes: new Uint8Array([1, 2, 3]),
|
||
})).resolves.toMatchObject({
|
||
assetId: 'asset-uploaded',
|
||
workspaceId: 'workspace-one',
|
||
mimeType: 'image/png',
|
||
contentPath: '/api/works/image-workspace/workspaces/workspace-one/assets/asset-uploaded/content',
|
||
});
|
||
|
||
const [url, init] = fetchMock.mock.calls[0];
|
||
expect(url).toBe('https://square.example/api/design/workspaces/workspace-one/assets');
|
||
expect(init?.method).toBe('POST');
|
||
expect((init?.headers as Record<string, string>).Authorization).toBe('Bearer access-one');
|
||
expect((init?.headers as Record<string, string>)['Content-Type']).toBeUndefined();
|
||
const form = init?.body as FormData;
|
||
expect(form.get('file')).toBeInstanceOf(File);
|
||
expect((form.get('file') as File).name).toBe('poster.webp');
|
||
expect(await (form.get('file') as File).arrayBuffer()).toEqual(
|
||
Uint8Array.from([1, 2, 3]).buffer,
|
||
);
|
||
});
|
||
|
||
it('completes a design turn from the connected WebSocket without polling the run endpoint', async () => {
|
||
const { webSocketFactory } = scriptedSockets([{
|
||
open: true,
|
||
onSend(socket, rawFrame) {
|
||
acceptCommand(socket, rawFrame, 'run-live', [{
|
||
type: 'event',
|
||
event: {
|
||
session_id: 'session-live',
|
||
sequence: 6,
|
||
runtime: 'design',
|
||
type: 'run.completed',
|
||
command_id: 'command-live',
|
||
run_id: 'run-live',
|
||
client_command_id: 'turn-live',
|
||
schema_version: 1,
|
||
terminal: true,
|
||
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({
|
||
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);
|
||
}
|
||
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-live',
|
||
expectedTurnRevision: 1,
|
||
message: '????????????',
|
||
})).resolves.toMatchObject({ workspaceId: 'workspace-one' });
|
||
expect(fetchMock.mock.calls.filter(([url]) => String(url).includes('/runs/'))).toHaveLength(0);
|
||
} finally {
|
||
subscription.close();
|
||
}
|
||
});
|
||
|
||
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')) {
|
||
return jsonResponse(streamedConversation);
|
||
}
|
||
if (url.endsWith('/stream-tickets')) {
|
||
return jsonResponse({
|
||
stream_url: '/api/agents/sessions/session-ordered/ws?ticket=ticket-ordered',
|
||
});
|
||
}
|
||
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 releaseFirstWrite = deferred<void>();
|
||
const received: DesignWorkspaceEvent[] = [];
|
||
const consume = (async () => {
|
||
for await (const event of subscription.events) {
|
||
received.push(event);
|
||
if (received.length === 1) await releaseFirstWrite.promise;
|
||
if (received.length === 2) break;
|
||
}
|
||
})();
|
||
let resolved = false;
|
||
|
||
try {
|
||
const turn = adapter.submitMessage({
|
||
workspaceId: 'workspace-one',
|
||
conversationId: 'conversation-one',
|
||
clientTurnId: 'turn-ordered',
|
||
expectedTurnRevision: 1,
|
||
message: '???????',
|
||
}).finally(() => {
|
||
resolved = true;
|
||
});
|
||
|
||
await vi.waitFor(() => expect(received).toHaveLength(1));
|
||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||
expect(received[0]).toMatchObject({
|
||
type: 'design.assistant.delta',
|
||
delta: '??????',
|
||
});
|
||
expect(resolved).toBe(false);
|
||
expect(fetchMock.mock.calls.filter(([url]) => (
|
||
String(url).endsWith('/conversations/conversation-one')
|
||
))).toHaveLength(1);
|
||
|
||
releaseFirstWrite.resolve();
|
||
await consume;
|
||
await expect(turn).resolves.toMatchObject({ turnRevision: 2 });
|
||
expect(received.map((event) => event.type)).toEqual([
|
||
'design.assistant.delta',
|
||
'design.conversation.snapshot',
|
||
]);
|
||
} finally {
|
||
releaseFirstWrite.resolve();
|
||
subscription.close();
|
||
}
|
||
});
|
||
|
||
it('bounds the delivery barrier when an opened stream has no consumer', async () => {
|
||
vi.useFakeTimers();
|
||
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')) {
|
||
return jsonResponse(streamedConversation);
|
||
}
|
||
if (url.endsWith('/stream-tickets')) {
|
||
return jsonResponse({
|
||
stream_url: '/api/agents/sessions/session-bounded/ws?ticket=ticket-bounded',
|
||
});
|
||
}
|
||
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;
|
||
let resolved = false;
|
||
|
||
try {
|
||
const turn = adapter.submitMessage({
|
||
workspaceId: 'workspace-one',
|
||
conversationId: 'conversation-one',
|
||
clientTurnId: 'turn-bounded',
|
||
expectedTurnRevision: 1,
|
||
message: '???????????????',
|
||
}).finally(() => {
|
||
resolved = true;
|
||
});
|
||
|
||
await vi.advanceTimersByTimeAsync(999);
|
||
expect(resolved).toBe(false);
|
||
await vi.advanceTimersByTimeAsync(1);
|
||
await expect(turn).resolves.toMatchObject({ turnRevision: 2 });
|
||
expect(fetchMock.mock.calls.filter(([url]) => String(url).includes('/runs/')))
|
||
.toHaveLength(0);
|
||
} finally {
|
||
subscription.close();
|
||
vi.useRealTimers();
|
||
}
|
||
});
|
||
|
||
it('maps a failed Run received from the connected WebSocket without polling', async () => {
|
||
const { webSocketFactory } = scriptedSockets([{
|
||
open: true,
|
||
onSend(socket, rawFrame) {
|
||
acceptCommand(socket, rawFrame, 'run-failed', [{
|
||
type: 'event',
|
||
event: {
|
||
session_id: 'session-failed',
|
||
sequence: 6,
|
||
runtime: 'design',
|
||
type: 'run.failed',
|
||
run_id: 'run-failed',
|
||
schema_version: 1,
|
||
payload: {
|
||
error: {
|
||
code: 'agent_command_invalid',
|
||
message: 'private validation detail',
|
||
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-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}`);
|
||
});
|
||
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-failed',
|
||
expectedTurnRevision: 1,
|
||
message: 'invalid',
|
||
})).rejects.toMatchObject({
|
||
status: 422,
|
||
code: 'agent_command_invalid',
|
||
message: '设计请求内容无效,请检查后重试',
|
||
});
|
||
expect(fetchMock.mock.calls.filter(([url]) => String(url).includes('/runs/'))).toHaveLength(0);
|
||
} finally {
|
||
subscription.close();
|
||
}
|
||
});
|
||
|
||
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',
|
||
...quoteConfirmationInput,
|
||
})).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',
|
||
final_prompt: quoteConfirmationInput.finalPrompt,
|
||
aspect_ratio: '1:1',
|
||
generation_parameters: {
|
||
model: 'image-model-one',
|
||
resolution: '1024x1024',
|
||
duration_seconds: null,
|
||
},
|
||
},
|
||
},
|
||
},
|
||
});
|
||
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',
|
||
...quoteConfirmationInput,
|
||
})).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>()
|
||
.mockResolvedValueOnce(jsonResponse({
|
||
...serverConversation,
|
||
agent_session_id: 'session-fallback',
|
||
}))
|
||
.mockResolvedValueOnce(jsonResponse({
|
||
stream_url: '/api/agents/sessions/session-fallback/ws?ticket=ticket-fallback',
|
||
}))
|
||
.mockResolvedValueOnce(jsonResponse({
|
||
run_id: 'run-fallback',
|
||
status: 'queued',
|
||
error: null,
|
||
}, 202))
|
||
.mockResolvedValueOnce(jsonResponse({
|
||
run_id: 'run-fallback',
|
||
status: 'succeeded',
|
||
error: null,
|
||
}))
|
||
.mockResolvedValueOnce(jsonResponse(serverConversation));
|
||
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-fallback',
|
||
expectedTurnRevision: 1,
|
||
message: '???????',
|
||
})).resolves.toMatchObject({ workspaceId: 'workspace-one' });
|
||
expect(fetchMock.mock.calls.filter(([url]) => String(url).includes('/runs/'))).toHaveLength(1);
|
||
} finally {
|
||
subscription.close();
|
||
}
|
||
});
|
||
|
||
it('waits for a slow design run without flooding the run endpoint', async () => {
|
||
vi.useFakeTimers();
|
||
vi.setSystemTime(new Date('2026-08-03T02:00:00Z'));
|
||
const startedAt = Date.now();
|
||
let runPolls = 0;
|
||
const fetchMock = vi.fn<typeof fetch>(async (input, init) => {
|
||
const url = String(input);
|
||
if (url.endsWith('/conversations/conversation-one')) {
|
||
return jsonResponse({ ...serverConversation, agent_session_id: 'session-slow' });
|
||
}
|
||
if (url.endsWith('/api/agents/sessions/session-slow/commands')) {
|
||
return jsonResponse({ run_id: 'run-slow', status: 'queued', error: null }, 202);
|
||
}
|
||
if (url.endsWith('/api/agents/sessions/session-slow/runs/run-slow')) {
|
||
runPolls += 1;
|
||
return jsonResponse({
|
||
run_id: 'run-slow',
|
||
status: Date.now() - startedAt >= 150_000 ? 'succeeded' : 'running',
|
||
error: null,
|
||
});
|
||
}
|
||
if (url.endsWith('/conversations/conversation-one')) {
|
||
return jsonResponse(serverConversation);
|
||
}
|
||
throw new Error(`Unexpected request: ${url} ${init?.method ?? 'GET'}`);
|
||
});
|
||
const adapter = new WorksSquareDesignWorkspace({
|
||
apiBaseUrl: 'https://square.example',
|
||
fetchImpl: fetchMock,
|
||
});
|
||
|
||
try {
|
||
const outcomePromise = adapter.submitMessage({
|
||
workspaceId: 'workspace-one',
|
||
conversationId: 'conversation-one',
|
||
clientTurnId: 'turn-slow',
|
||
expectedTurnRevision: 1,
|
||
message: '?????????????',
|
||
}).then(
|
||
(value) => ({ value, error: null }),
|
||
(error: unknown) => ({ value: null, error }),
|
||
);
|
||
|
||
await vi.advanceTimersByTimeAsync(160_000);
|
||
const outcome = await outcomePromise;
|
||
|
||
expect(outcome.error).toBeNull();
|
||
expect(outcome.value).toMatchObject({ workspaceId: 'workspace-one' });
|
||
expect(runPolls).toBeLessThanOrEqual(40);
|
||
} finally {
|
||
vi.useRealTimers();
|
||
}
|
||
});
|
||
|
||
it('maps an invalid Runtime command to a user input error', async () => {
|
||
const fetchMock = vi.fn<typeof fetch>()
|
||
.mockResolvedValueOnce(jsonResponse(serverConversation))
|
||
.mockResolvedValueOnce(jsonResponse({
|
||
run_id: 'run-invalid',
|
||
status: 'queued',
|
||
error: null,
|
||
}, 202))
|
||
.mockResolvedValueOnce(jsonResponse({
|
||
run_id: 'run-invalid',
|
||
status: 'failed',
|
||
error: {
|
||
code: 'agent_command_invalid',
|
||
message: 'private validation detail',
|
||
retryable: false,
|
||
},
|
||
}));
|
||
const adapter = new WorksSquareDesignWorkspace({
|
||
apiBaseUrl: 'https://square.example',
|
||
fetchImpl: fetchMock,
|
||
});
|
||
|
||
await expect(adapter.submitMessage({
|
||
workspaceId: 'workspace-one',
|
||
conversationId: 'conversation-one',
|
||
clientTurnId: 'turn-invalid',
|
||
expectedTurnRevision: 1,
|
||
message: 'invalid',
|
||
})).rejects.toMatchObject({
|
||
status: 422,
|
||
code: 'agent_command_invalid',
|
||
message: '设计请求内容无效,请检查后重试',
|
||
});
|
||
});
|
||
|
||
it('keeps task creation behind structured Quote confirmation', async () => {
|
||
const fetchMock = vi.fn<typeof fetch>()
|
||
.mockResolvedValueOnce(jsonResponse(serverConversation))
|
||
.mockResolvedValueOnce(jsonResponse({
|
||
run_id: 'run-confirm',
|
||
status: 'queued',
|
||
error: null,
|
||
}, 202))
|
||
.mockResolvedValueOnce(jsonResponse({
|
||
run_id: 'run-confirm',
|
||
status: 'succeeded',
|
||
error: null,
|
||
}))
|
||
.mockResolvedValueOnce(jsonResponse(serverConversation));
|
||
const adapter = new WorksSquareDesignWorkspace({
|
||
apiBaseUrl: 'https://square.example/',
|
||
fetchImpl: fetchMock,
|
||
});
|
||
|
||
const workspace = await adapter.confirmGeneration({
|
||
workspaceId: 'workspace-one',
|
||
conversationId: 'conversation-one',
|
||
clientTurnId: 'turn-two',
|
||
expectedTurnRevision: 1,
|
||
quoteId: 'quote-one',
|
||
...quoteConfirmationInput,
|
||
});
|
||
|
||
expect(workspace.workspaceId).toBe('workspace-one');
|
||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||
2,
|
||
'https://square.example/api/agents/sessions/session-one/commands',
|
||
expect.objectContaining({
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
client_command_id: 'turn-two',
|
||
name: 'turn.submit',
|
||
input: {
|
||
expected_turn_revision: 1,
|
||
message: '确认生成',
|
||
attachment_asset_ids: [],
|
||
action: {
|
||
type: 'confirm_generation',
|
||
quote_id: 'quote-one',
|
||
final_prompt: quoteConfirmationInput.finalPrompt,
|
||
aspect_ratio: '1:1',
|
||
generation_parameters: {
|
||
model: 'image-model-one',
|
||
resolution: '1024x1024',
|
||
duration_seconds: null,
|
||
},
|
||
},
|
||
},
|
||
}),
|
||
}),
|
||
);
|
||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||
3,
|
||
'https://square.example/api/agents/sessions/session-one/runs/run-confirm',
|
||
expect.objectContaining({ headers: expect.any(Object) }),
|
||
);
|
||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||
4,
|
||
'https://square.example/api/design/workspaces/workspace-one/conversations/conversation-one',
|
||
expect.objectContaining({ headers: expect.any(Object) }),
|
||
);
|
||
});
|
||
|
||
it('maps stable generation tasks and private asset relay paths', async () => {
|
||
const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse([{
|
||
task_id: 'task-one',
|
||
workspace_id: 'workspace-one',
|
||
medium: 'image',
|
||
status: 'succeeded',
|
||
brief_version: 1,
|
||
brief_summary: '??????',
|
||
quote_id: 'quote-one',
|
||
quoted_design_points: 1,
|
||
failure_code: null,
|
||
result_assets: [{
|
||
asset_id: 'asset-one',
|
||
media_type: 'image',
|
||
mime_type: 'image/png',
|
||
width: 1024,
|
||
height: 1280,
|
||
duration_milliseconds: null,
|
||
created_at: '2026-07-31T10:01:00Z',
|
||
}],
|
||
created_at: '2026-07-31T10:00:00Z',
|
||
updated_at: '2026-07-31T10:01:00Z',
|
||
}]));
|
||
const adapter = new WorksSquareDesignWorkspace({
|
||
apiBaseUrl: 'https://square.example',
|
||
fetchImpl: fetchMock,
|
||
});
|
||
|
||
await expect(adapter.listTasks('workspace-one')).resolves.toMatchObject([{
|
||
taskId: 'task-one',
|
||
status: 'succeeded',
|
||
resultAssets: [{
|
||
assetId: 'asset-one',
|
||
contentPath: '/api/works/image-workspace/workspaces/workspace-one/assets/asset-one/content',
|
||
}],
|
||
}]);
|
||
});
|
||
|
||
it('refreshes the Main-owned session once after an upstream 401 and preserves Range', async () => {
|
||
getTokenMock
|
||
.mockResolvedValueOnce('expired-token')
|
||
.mockResolvedValueOnce('fresh-token');
|
||
const fetchMock = vi.fn<typeof fetch>()
|
||
.mockResolvedValueOnce(new Response(null, { status: 401 }))
|
||
.mockResolvedValueOnce(new Response('partial', {
|
||
status: 206,
|
||
headers: {
|
||
'Content-Type': 'video/mp4',
|
||
'Content-Range': 'bytes 0-6/100',
|
||
},
|
||
}));
|
||
const adapter = new WorksSquareDesignWorkspace({
|
||
apiBaseUrl: 'https://square.example',
|
||
fetchImpl: fetchMock,
|
||
});
|
||
|
||
const response = await adapter.openAssetContent(
|
||
'workspace-one',
|
||
'asset-one',
|
||
'bytes=0-6',
|
||
);
|
||
|
||
expect(response.status).toBe(206);
|
||
expect(getTokenMock).toHaveBeenNthCalledWith(2, {
|
||
fetchImpl: fetchMock,
|
||
forceRefresh: true,
|
||
});
|
||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||
2,
|
||
expect.stringContaining('/assets/asset-one/content'),
|
||
expect.objectContaining({
|
||
headers: {
|
||
Range: 'bytes=0-6',
|
||
Authorization: 'Bearer fresh-token',
|
||
},
|
||
}),
|
||
);
|
||
});
|
||
|
||
it('reuses one design Agent Session and normalizes matching task events from fresh WebSocket tickets', async () => {
|
||
const { medium: _medium, ...legacyBrief } = serverConversation.brief;
|
||
const legacyConversation = {
|
||
...serverConversation,
|
||
brief: legacyBrief,
|
||
};
|
||
const snapshotTask = {
|
||
task_id: 'task-snapshot',
|
||
workspace_id: 'workspace-one',
|
||
medium: 'image',
|
||
status: 'running',
|
||
brief_version: 1,
|
||
brief_summary: 'snapshot brief',
|
||
quote_id: 'quote-snapshot',
|
||
quoted_design_points: 1,
|
||
failure_code: null,
|
||
result_assets: [],
|
||
created_at: '2026-08-02T09:59:00Z',
|
||
updated_at: '2026-08-02T10:00:00Z',
|
||
};
|
||
const snapshotEvent = {
|
||
session_id: 'session-one',
|
||
sequence: 1,
|
||
runtime: 'design',
|
||
type: 'design.conversation.updated',
|
||
schema_version: 1,
|
||
payload: {
|
||
workspace_id: 'workspace-one',
|
||
conversation_id: 'conversation-one',
|
||
workspace_view_revision: 2,
|
||
conversation: legacyConversation,
|
||
generation_tasks: [snapshotTask],
|
||
},
|
||
};
|
||
const invalidSnapshotEvent = {
|
||
...snapshotEvent,
|
||
sequence: 98,
|
||
payload: {
|
||
...snapshotEvent.payload,
|
||
conversation: {
|
||
...serverConversation,
|
||
brief: { ...serverConversation.brief, medium: 'audio' },
|
||
},
|
||
},
|
||
};
|
||
const assistantDeltaEvent = {
|
||
session_id: 'session-one',
|
||
sequence: 2,
|
||
runtime: 'design',
|
||
type: 'design.assistant.delta',
|
||
schema_version: 1,
|
||
payload: {
|
||
workspace_id: 'workspace-one',
|
||
conversation_id: 'conversation-one',
|
||
client_turn_id: 'turn-two',
|
||
turn_revision: 2,
|
||
chunk_index: 0,
|
||
delta: '??????',
|
||
},
|
||
};
|
||
const malformedDeltaEvent = {
|
||
...assistantDeltaEvent,
|
||
sequence: 99,
|
||
payload: {
|
||
...assistantDeltaEvent.payload,
|
||
chunk_index: -1,
|
||
},
|
||
};
|
||
const taskEvent = {
|
||
session_id: 'session-one',
|
||
sequence: 3,
|
||
runtime: 'design',
|
||
type: 'design.generation_task.updated',
|
||
command_id: null,
|
||
run_id: null,
|
||
client_command_id: null,
|
||
schema_version: 1,
|
||
terminal: false,
|
||
occurred_at: '2026-08-02T10:01:00Z',
|
||
payload: {
|
||
workspace_id: 'workspace-one',
|
||
workspace_view_revision: 4,
|
||
generation_task: {
|
||
task_id: 'task-live',
|
||
workspace_id: 'workspace-one',
|
||
medium: 'video',
|
||
status: 'running',
|
||
brief_version: 2,
|
||
brief_summary: '??????',
|
||
quote_id: 'quote-live',
|
||
quoted_design_points: 8,
|
||
failure_code: null,
|
||
result_assets: [],
|
||
created_at: '2026-08-02T10:00:00Z',
|
||
updated_at: '2026-08-02T10:01:00Z',
|
||
},
|
||
},
|
||
};
|
||
const fetchMock = vi.fn<typeof fetch>()
|
||
.mockResolvedValueOnce(jsonResponse(serverConversation))
|
||
.mockResolvedValueOnce(jsonResponse({
|
||
ticket: 'secret-ticket-one',
|
||
transport: 'websocket',
|
||
stream_url: '/api/agents/sessions/session-one/ws?ticket=secret-ticket-one',
|
||
expires_at: '2026-08-02T10:02:00Z',
|
||
}))
|
||
.mockResolvedValueOnce(jsonResponse({
|
||
ticket: 'secret-ticket-two',
|
||
transport: 'websocket',
|
||
stream_url: '/api/agents/sessions/session-one/ws?ticket=secret-ticket-two',
|
||
expires_at: '2026-08-02T10:03:00Z',
|
||
}));
|
||
const { sockets, webSocketFactory } = scriptedSockets([
|
||
{
|
||
frames: [
|
||
{ type: 'event', event: invalidSnapshotEvent },
|
||
{ type: 'event', event: snapshotEvent },
|
||
{ type: 'event', event: malformedDeltaEvent },
|
||
{ type: 'event', event: assistantDeltaEvent },
|
||
{ type: 'event', event: taskEvent },
|
||
],
|
||
closeCode: 1000,
|
||
},
|
||
{ closeCode: 1000 },
|
||
]);
|
||
const adapter = new WorksSquareDesignWorkspace({
|
||
apiBaseUrl: 'https://square.example',
|
||
fetchImpl: fetchMock,
|
||
webSocketFactory,
|
||
});
|
||
|
||
const first = await adapter.openWorkspaceEvents({ workspaceId: 'workspace-one', conversationId: 'conversation-one' });
|
||
const received = [];
|
||
for await (const event of first.events) received.push(event);
|
||
first.close();
|
||
const second = await adapter.openWorkspaceEvents({
|
||
workspaceId: 'workspace-one',
|
||
conversationId: 'conversation-one',
|
||
afterEventId: 'session-one:3',
|
||
});
|
||
for await (const _event of second.events) {
|
||
// The second connection only proves Session reuse and a fresh ticket.
|
||
}
|
||
second.close();
|
||
|
||
expect(received).toEqual([
|
||
{
|
||
id: 'session-one:1',
|
||
type: 'design.conversation.snapshot',
|
||
workspaceId: 'workspace-one',
|
||
conversationId: 'conversation-one',
|
||
workspaceViewRevision: 2,
|
||
conversation: expect.objectContaining({
|
||
conversationId: 'conversation-one',
|
||
title: serverConversation.title,
|
||
brief: expect.objectContaining({ medium: null }),
|
||
messages: [expect.objectContaining({
|
||
text: serverConversation.messages[0].text,
|
||
})],
|
||
}),
|
||
generationTasks: [expect.objectContaining({
|
||
taskId: 'task-snapshot',
|
||
medium: 'image',
|
||
status: 'running',
|
||
})],
|
||
},
|
||
{
|
||
id: 'session-one:2',
|
||
type: 'design.assistant.delta',
|
||
workspaceId: 'workspace-one',
|
||
conversationId: 'conversation-one',
|
||
clientTurnId: 'turn-two',
|
||
turnRevision: 2,
|
||
chunkIndex: 0,
|
||
delta: '??????',
|
||
},
|
||
{
|
||
id: 'session-one:3',
|
||
type: 'design.generation_task.updated',
|
||
workspaceId: 'workspace-one',
|
||
workspaceViewRevision: 4,
|
||
generationTask: expect.objectContaining({
|
||
taskId: 'task-live',
|
||
medium: 'video',
|
||
status: 'running',
|
||
}),
|
||
},
|
||
]);
|
||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||
1,
|
||
'https://square.example/api/design/workspaces/workspace-one/conversations/conversation-one',
|
||
expect.objectContaining({ headers: expect.any(Object) }),
|
||
);
|
||
expect(fetchMock.mock.calls.filter(([url]) => (
|
||
String(url).endsWith('/conversations/conversation-one')
|
||
)))
|
||
.toHaveLength(1);
|
||
expect(fetchMock.mock.calls.filter(([url]) => String(url).endsWith('/stream-tickets')))
|
||
.toHaveLength(2);
|
||
const ticketCalls = fetchMock.mock.calls.filter(([url]) => (
|
||
String(url).endsWith('/stream-tickets')
|
||
));
|
||
expect(ticketCalls.every(([, init]) => (
|
||
JSON.parse(String(init?.body)).transport === 'websocket'
|
||
))).toBe(true);
|
||
expect(sockets.map((socket) => socket.url)).toEqual([
|
||
'wss://square.example/api/agents/sessions/session-one/ws?ticket=secret-ticket-one&after_sequence=0',
|
||
'wss://square.example/api/agents/sessions/session-one/ws?ticket=secret-ticket-two&after_sequence=3',
|
||
]);
|
||
});
|
||
|
||
it('reloads the persistent Conversation Session after an upstream event cursor expires', async () => {
|
||
const fetchMock = vi.fn<typeof fetch>()
|
||
.mockResolvedValueOnce(jsonResponse({ ...serverConversation, agent_session_id: 'session-old' }))
|
||
.mockResolvedValueOnce(jsonResponse({
|
||
stream_url: '/api/agents/sessions/session-old/ws?ticket=ticket-old',
|
||
}))
|
||
.mockResolvedValueOnce(jsonResponse({ ...serverConversation, agent_session_id: 'session-new' }))
|
||
.mockResolvedValueOnce(jsonResponse({
|
||
stream_url: '/api/agents/sessions/session-new/ws?ticket=ticket-new',
|
||
}));
|
||
const { sockets, webSocketFactory } = scriptedSockets([
|
||
{ open: false, closeCode: 4409, closeReason: 'Agent event cursor expired' },
|
||
{ closeCode: 1000 },
|
||
]);
|
||
const adapter = new WorksSquareDesignWorkspace({
|
||
apiBaseUrl: 'https://square.example',
|
||
fetchImpl: fetchMock,
|
||
webSocketFactory,
|
||
});
|
||
|
||
await expect(adapter.openWorkspaceEvents({
|
||
workspaceId: 'workspace-one',
|
||
conversationId: 'conversation-one',
|
||
afterEventId: 'session-old:99',
|
||
})).rejects.toMatchObject({ status: 410 });
|
||
const recovered = await adapter.openWorkspaceEvents({
|
||
workspaceId: 'workspace-one',
|
||
conversationId: 'conversation-one',
|
||
afterEventId: 'session-old:99',
|
||
});
|
||
for await (const _event of recovered.events) {
|
||
// Empty recovery stream.
|
||
}
|
||
|
||
expect(fetchMock.mock.calls.filter(([url]) => (
|
||
String(url).endsWith('/conversations/conversation-one')
|
||
))).toHaveLength(2);
|
||
expect(fetchMock.mock.calls.some(([, init]) => init?.method === 'DELETE')).toBe(false);
|
||
expect(sockets.map((socket) => socket.url)).toEqual([
|
||
'wss://square.example/api/agents/sessions/session-old/ws?ticket=ticket-old&after_sequence=99',
|
||
'wss://square.example/api/agents/sessions/session-new/ws?ticket=ticket-new&after_sequence=0',
|
||
]);
|
||
});
|
||
|
||
it('refreshes a closed persistent Session before opening the task stream', async () => {
|
||
const fetchMock = vi.fn<typeof fetch>()
|
||
.mockResolvedValueOnce(jsonResponse({ ...serverConversation, agent_session_id: 'session-old' }))
|
||
.mockResolvedValueOnce(jsonResponse({
|
||
code: 'agent_session_closed', message: 'closed',
|
||
}, 409))
|
||
.mockResolvedValueOnce(jsonResponse({ ...serverConversation, agent_session_id: 'session-new' }))
|
||
.mockResolvedValueOnce(jsonResponse({
|
||
stream_url: '/api/agents/sessions/session-new/ws?ticket=ticket-new',
|
||
}));
|
||
const { webSocketFactory } = scriptedSockets([{ closeCode: 1000 }]);
|
||
const adapter = new WorksSquareDesignWorkspace({
|
||
apiBaseUrl: 'https://square.example',
|
||
fetchImpl: fetchMock,
|
||
webSocketFactory,
|
||
});
|
||
|
||
const recovered = await adapter.openWorkspaceEvents({ workspaceId: 'workspace-one', conversationId: 'conversation-one' });
|
||
for await (const _event of recovered.events) {
|
||
// Empty recovery stream.
|
||
}
|
||
|
||
expect(fetchMock.mock.calls.filter(([url]) => (
|
||
String(url).endsWith('/conversations/conversation-one')
|
||
))).toHaveLength(2);
|
||
expect(fetchMock.mock.calls.filter(([url]) => String(url).endsWith('/stream-tickets')))
|
||
.toHaveLength(2);
|
||
});
|
||
|
||
it('closes active local streams without deleting persistent Agent Sessions', async () => {
|
||
const fetchMock = vi.fn<typeof fetch>()
|
||
.mockResolvedValueOnce(jsonResponse(serverConversation))
|
||
.mockResolvedValueOnce(jsonResponse({
|
||
stream_url: '/api/agents/sessions/session-one/ws?ticket=ticket-one',
|
||
}))
|
||
.mockResolvedValueOnce(jsonResponse({
|
||
...serverConversation,
|
||
workspace_id: 'workspace-two',
|
||
conversation_id: 'conversation-two',
|
||
agent_session_id: 'session-two',
|
||
}))
|
||
.mockResolvedValueOnce(jsonResponse({
|
||
stream_url: '/api/agents/sessions/session-two/ws?ticket=ticket-two',
|
||
}));
|
||
const { sockets, webSocketFactory } = scriptedSockets([
|
||
{ open: true },
|
||
{ open: true },
|
||
]);
|
||
const adapter = new WorksSquareDesignWorkspace({
|
||
apiBaseUrl: 'https://square.example',
|
||
fetchImpl: fetchMock,
|
||
webSocketFactory,
|
||
});
|
||
|
||
const first = await adapter.openWorkspaceEvents({ workspaceId: 'workspace-one', conversationId: 'conversation-one' });
|
||
const second = await adapter.openWorkspaceEvents({ workspaceId: 'workspace-two', conversationId: 'conversation-two' });
|
||
await adapter.closeEventSessions();
|
||
|
||
const closeCalls = fetchMock.mock.calls.filter(([, init]) => init?.method === 'DELETE');
|
||
expect(closeCalls).toHaveLength(0);
|
||
expect(sockets.map((socket) => socket.readyState)).toEqual([3, 3]);
|
||
first.close();
|
||
second.close();
|
||
});
|
||
|
||
it('reloads persistent Session ids after local event sessions are closed', async () => {
|
||
const fetchMock = vi.fn<typeof fetch>(async (input) => {
|
||
const url = String(input);
|
||
if (url.endsWith('/api/design/capabilities')) {
|
||
return jsonResponse({
|
||
conversation: true,
|
||
generation: true,
|
||
image: true,
|
||
video: false,
|
||
});
|
||
}
|
||
if (url.endsWith('/api/design/workspaces?limit=100&offset=0')) {
|
||
return jsonResponse([]);
|
||
}
|
||
if (url.endsWith('/conversations/conversation-one')) {
|
||
return jsonResponse(serverConversation);
|
||
}
|
||
if (url.endsWith('/stream-tickets')) {
|
||
return jsonResponse({
|
||
stream_url: '/api/agents/sessions/session-one/ws?ticket=ticket-one',
|
||
});
|
||
}
|
||
return jsonResponse({ detail: { code: 'unexpected_request', message: url } }, 500);
|
||
});
|
||
const { webSocketFactory } = scriptedSockets([{ open: true }, { open: true }]);
|
||
const adapter = new WorksSquareDesignWorkspace({
|
||
apiBaseUrl: 'https://square.example',
|
||
fetchImpl: fetchMock,
|
||
webSocketFactory,
|
||
});
|
||
|
||
const first = await adapter.openWorkspaceEvents({
|
||
workspaceId: 'workspace-one',
|
||
conversationId: 'conversation-one',
|
||
});
|
||
await adapter.closeEventSessions();
|
||
await adapter.bootstrap();
|
||
const second = await adapter.openWorkspaceEvents({
|
||
workspaceId: 'workspace-one',
|
||
conversationId: 'conversation-one',
|
||
});
|
||
|
||
expect(fetchMock.mock.calls.filter(([url]) => (
|
||
String(url).endsWith('/conversations/conversation-one')
|
||
))).toHaveLength(2);
|
||
expect(fetchMock.mock.calls.some(([, init]) => init?.method === 'DELETE')).toBe(false);
|
||
first.close();
|
||
second.close();
|
||
});
|
||
|
||
it('reports an unavailable persistent Session instead of creating a client-owned Session', async () => {
|
||
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce(jsonResponse({
|
||
...serverConversation,
|
||
agent_session_id: null,
|
||
}));
|
||
const adapter = new WorksSquareDesignWorkspace({
|
||
apiBaseUrl: 'https://square.example',
|
||
fetchImpl: fetchMock,
|
||
});
|
||
|
||
await expect(adapter.submitMessage({
|
||
workspaceId: 'workspace-one',
|
||
conversationId: 'conversation-one',
|
||
clientTurnId: 'turn-unavailable',
|
||
expectedTurnRevision: 1,
|
||
message: 'hello',
|
||
})).rejects.toMatchObject({
|
||
status: 503,
|
||
code: 'DESIGN_CONVERSATION_SESSION_UNAVAILABLE',
|
||
});
|
||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it('retries a turn once with the refreshed persistent Session when the old Session is stale', async () => {
|
||
const fetchMock = vi.fn<typeof fetch>()
|
||
.mockResolvedValueOnce(jsonResponse({ ...serverConversation, agent_session_id: 'session-old' }))
|
||
.mockResolvedValueOnce(jsonResponse({
|
||
code: 'agent_session_not_found',
|
||
message: 'stale',
|
||
}, 404))
|
||
.mockResolvedValueOnce(jsonResponse({ ...serverConversation, agent_session_id: 'session-new' }))
|
||
.mockResolvedValueOnce(jsonResponse({
|
||
run_id: 'run-new',
|
||
status: 'queued',
|
||
error: null,
|
||
}, 202))
|
||
.mockResolvedValueOnce(jsonResponse({
|
||
run_id: 'run-new',
|
||
status: 'succeeded',
|
||
error: null,
|
||
}))
|
||
.mockResolvedValueOnce(jsonResponse({ ...serverConversation, agent_session_id: 'session-new' }));
|
||
const adapter = new WorksSquareDesignWorkspace({
|
||
apiBaseUrl: 'https://square.example',
|
||
fetchImpl: fetchMock,
|
||
});
|
||
|
||
await expect(adapter.submitMessage({
|
||
workspaceId: 'workspace-one',
|
||
conversationId: 'conversation-one',
|
||
clientTurnId: 'turn-retry',
|
||
expectedTurnRevision: 1,
|
||
message: 'retry',
|
||
})).resolves.toMatchObject({ conversationId: 'conversation-one' });
|
||
|
||
expect(fetchMock.mock.calls.map(([url]) => String(url))).toEqual([
|
||
'https://square.example/api/design/workspaces/workspace-one/conversations/conversation-one',
|
||
'https://square.example/api/agents/sessions/session-old/commands',
|
||
'https://square.example/api/design/workspaces/workspace-one/conversations/conversation-one',
|
||
'https://square.example/api/agents/sessions/session-new/commands',
|
||
'https://square.example/api/agents/sessions/session-new/runs/run-new',
|
||
'https://square.example/api/design/workspaces/workspace-one/conversations/conversation-one',
|
||
]);
|
||
});
|
||
});
|