feat: 支持 AI 设计项目多会话交互

需求:用户可在同一设计项目中新建和切换会话,任务列表保持项目级共享。

实现:
- 增加会话选择、新建入口及本地数据迁移
- Conversation 独立消息、Brief、Quote 与流式状态
- 保留项目任务并修复 Task revision 与 ABA 异步竞态
- 保持 Enter 发送、Shift+Enter 换行及 IME 保护
This commit is contained in:
2026-08-06 18:22:15 +08:00
parent 4980894017
commit 03dae62cf3
18 changed files with 1810 additions and 702 deletions

View File

@@ -9,37 +9,48 @@ vi.mock('@electron/services/works-square-session', () => ({
const getTokenMock = vi.mocked(getValidWorksSquareAccessToken);
const serverWorkspace = {
const serverConversation = {
conversation_id: 'conversation-one',
workspace_id: 'workspace-one',
title: '海洋公益海报',
agent_session_id: 'session-one',
title: '????',
turn_revision: 1,
view_revision: 2,
phase: 'awaiting_confirmation',
brief: {
version: 1,
status: 'ready',
medium: 'image',
summary: '保护海洋的竖版公益海报',
summary: '???????????',
ready: true,
missing_decision: null,
},
messages: [{
role: 'assistant',
kind: 'confirmation',
text: '方向已经明确,是否开始生成?',
quick_replies: ['确认生成'],
text: '??????????????',
quick_replies: ['????'],
generation_quote: {
quote_id: 'quote-one',
status: 'active',
medium: 'image',
brief_version: 1,
brief_summary: '保护海洋的竖版公益海报',
brief_summary: '???????????',
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',
};
@@ -148,10 +159,9 @@ describe('Works Square AI design adapter', () => {
capabilities: { conversation: true, image: true, video: false },
workspaces: [{
workspaceId: 'workspace-one',
title: '海洋公益海报',
turnRevision: 1,
title: '??????',
viewRevision: 2,
brief: { missingDecision: null },
conversationCount: 1,
}],
});
expect(fetchMock).toHaveBeenCalledTimes(2);
@@ -162,10 +172,7 @@ describe('Works Square AI design adapter', () => {
it('submits a conversation turn through the persistent Agent Gateway Session', async () => {
const fetchMock = vi.fn<typeof fetch>()
.mockResolvedValueOnce(jsonResponse({
session_id: 'session-one',
status: 'active',
}, 201))
.mockResolvedValueOnce(jsonResponse(serverConversation))
.mockResolvedValueOnce(jsonResponse({
run_id: 'run-one',
status: 'queued',
@@ -176,7 +183,7 @@ describe('Works Square AI design adapter', () => {
status: 'succeeded',
error: null,
}))
.mockResolvedValueOnce(jsonResponse(serverWorkspace));
.mockResolvedValueOnce(jsonResponse(serverConversation));
const adapter = new WorksSquareDesignWorkspace({
apiBaseUrl: 'https://square.example',
fetchImpl: fetchMock,
@@ -185,9 +192,10 @@ describe('Works Square AI design adapter', () => {
await expect(adapter.submitMessage({
workspaceId: 'workspace-one',
conversationId: 'conversation-one',
clientTurnId: 'turn-two',
expectedTurnRevision: 1,
message: '做一张保护海洋的公益海报',
message: '????????????',
attachmentAssetIds: ['asset-reference'],
})).resolves.toMatchObject({
workspaceId: 'workspace-one',
@@ -204,7 +212,7 @@ describe('Works Square AI design adapter', () => {
name: 'turn.submit',
input: {
expected_turn_revision: 1,
message: '做一张保护海洋的公益海报',
message: '????????????',
attachment_asset_ids: ['asset-reference'],
action: null,
},
@@ -218,9 +226,11 @@ describe('Works Square AI design adapter', () => {
);
expect(fetchMock).toHaveBeenNthCalledWith(
4,
'https://square.example/api/design/workspaces/workspace-one',
'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('uploads a local reference image as multipart data and maps the returned Asset', async () => {
@@ -267,8 +277,8 @@ describe('Works Square AI design adapter', () => {
const { sockets, webSocketFactory } = scriptedSockets([{ open: true }]);
const fetchMock = vi.fn<typeof fetch>(async (input) => {
const url = String(input);
if (url.endsWith('/api/agents/sessions')) {
return jsonResponse({ session_id: 'session-live', status: 'active' }, 201);
if (url.endsWith('/conversations/conversation-one')) {
return jsonResponse({ ...serverConversation, agent_session_id: 'session-live' });
}
if (url.endsWith('/stream-tickets')) {
return jsonResponse({
@@ -301,8 +311,8 @@ describe('Works Square AI design adapter', () => {
status: 'accepted',
}, 202);
}
if (url.endsWith('/api/design/workspaces/workspace-one')) {
return jsonResponse(serverWorkspace);
if (url.endsWith('/conversations/conversation-one')) {
return jsonResponse(serverConversation);
}
throw new Error(`Unexpected request: ${url}`);
});
@@ -311,14 +321,15 @@ describe('Works Square AI design adapter', () => {
fetchImpl: fetchMock,
webSocketFactory,
});
const subscription = await adapter.openWorkspaceEvents({ workspaceId: 'workspace-one' });
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: '做一张保护海洋的公益海报',
message: '????????????',
})).resolves.toMatchObject({ workspaceId: 'workspace-one' });
expect(fetchMock.mock.calls.filter(([url]) => String(url).includes('/runs/'))).toHaveLength(0);
} finally {
@@ -328,15 +339,15 @@ describe('Works Square AI design adapter', () => {
it('does not let terminal Run completion overtake streamed design events', async () => {
const { sockets, webSocketFactory } = scriptedSockets([{ open: true }]);
const streamedWorkspace = {
...serverWorkspace,
const streamedConversation = {
...serverConversation,
agent_session_id: 'session-ordered',
turn_revision: 2,
view_revision: 3,
};
const fetchMock = vi.fn<typeof fetch>(async (input) => {
const url = String(input);
if (url.endsWith('/api/agents/sessions')) {
return jsonResponse({ session_id: 'session-ordered', status: 'active' }, 201);
if (url.endsWith('/conversations/conversation-one')) {
return jsonResponse(streamedConversation);
}
if (url.endsWith('/stream-tickets')) {
return jsonResponse({
@@ -356,10 +367,11 @@ describe('Works Square AI design adapter', () => {
schema_version: 1,
payload: {
workspace_id: 'workspace-one',
conversation_id: 'conversation-one',
client_turn_id: 'turn-ordered',
turn_revision: 2,
chunk_index: 0,
delta: '先看见这一段',
delta: '??????',
},
},
});
@@ -369,11 +381,14 @@ describe('Works Square AI design adapter', () => {
session_id: 'session-ordered',
sequence: 5,
runtime: 'design',
type: 'design.workspace.updated',
type: 'design.conversation.updated',
run_id: 'run-ordered',
schema_version: 1,
payload: {
workspace: streamedWorkspace,
workspace_id: 'workspace-one',
conversation_id: 'conversation-one',
workspace_view_revision: 3,
conversation: streamedConversation,
generation_tasks: [],
},
},
@@ -393,9 +408,6 @@ describe('Works Square AI design adapter', () => {
});
return jsonResponse({ run_id: 'run-ordered', status: 'queued', error: null }, 202);
}
if (url.endsWith('/api/design/workspaces/workspace-one')) {
return jsonResponse(streamedWorkspace);
}
throw new Error(`Unexpected request: ${url}`);
});
const adapter = new WorksSquareDesignWorkspace({
@@ -403,7 +415,7 @@ describe('Works Square AI design adapter', () => {
fetchImpl: fetchMock,
webSocketFactory,
});
const subscription = await adapter.openWorkspaceEvents({ workspaceId: 'workspace-one' });
const subscription = await adapter.openWorkspaceEvents({ workspaceId: 'workspace-one', conversationId: 'conversation-one' });
const releaseFirstWrite = deferred<void>();
const received: DesignWorkspaceEvent[] = [];
const consume = (async () => {
@@ -418,9 +430,10 @@ describe('Works Square AI design adapter', () => {
try {
const turn = adapter.submitMessage({
workspaceId: 'workspace-one',
conversationId: 'conversation-one',
clientTurnId: 'turn-ordered',
expectedTurnRevision: 1,
message: '让回复逐步出现',
message: '???????',
}).finally(() => {
resolved = true;
});
@@ -429,19 +442,19 @@ describe('Works Square AI design adapter', () => {
await new Promise<void>((resolve) => setTimeout(resolve, 0));
expect(received[0]).toMatchObject({
type: 'design.assistant.delta',
delta: '先看见这一段',
delta: '??????',
});
expect(resolved).toBe(false);
expect(fetchMock.mock.calls.some(([url]) => (
String(url).endsWith('/api/design/workspaces/workspace-one')
))).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.generation_tasks.snapshot',
'design.conversation.snapshot',
]);
} finally {
releaseFirstWrite.resolve();
@@ -452,15 +465,15 @@ 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 streamedWorkspace = {
...serverWorkspace,
const streamedConversation = {
...serverConversation,
agent_session_id: 'session-bounded',
turn_revision: 2,
view_revision: 3,
};
const fetchMock = vi.fn<typeof fetch>(async (input) => {
const url = String(input);
if (url.endsWith('/api/agents/sessions')) {
return jsonResponse({ session_id: 'session-bounded', status: 'active' }, 201);
if (url.endsWith('/conversations/conversation-one')) {
return jsonResponse(streamedConversation);
}
if (url.endsWith('/stream-tickets')) {
return jsonResponse({
@@ -475,10 +488,16 @@ describe('Works Square AI design adapter', () => {
session_id: 'session-bounded',
sequence: 5,
runtime: 'design',
type: 'design.workspace.updated',
type: 'design.conversation.updated',
run_id: 'run-bounded',
schema_version: 1,
payload: { workspace: streamedWorkspace, generation_tasks: [] },
payload: {
workspace_id: 'workspace-one',
conversation_id: 'conversation-one',
workspace_view_revision: 3,
conversation: streamedConversation,
generation_tasks: [],
},
},
});
sockets[0]?.emitFrame({
@@ -496,9 +515,6 @@ describe('Works Square AI design adapter', () => {
});
return jsonResponse({ run_id: 'run-bounded', status: 'queued', error: null }, 202);
}
if (url.endsWith('/api/design/workspaces/workspace-one')) {
return jsonResponse(streamedWorkspace);
}
throw new Error(`Unexpected request: ${url}`);
});
const adapter = new WorksSquareDesignWorkspace({
@@ -506,7 +522,7 @@ describe('Works Square AI design adapter', () => {
fetchImpl: fetchMock,
webSocketFactory,
});
const subscriptionPromise = adapter.openWorkspaceEvents({ workspaceId: 'workspace-one' });
const subscriptionPromise = adapter.openWorkspaceEvents({ workspaceId: 'workspace-one', conversationId: 'conversation-one' });
await vi.advanceTimersByTimeAsync(0);
const subscription = await subscriptionPromise;
let resolved = false;
@@ -514,9 +530,10 @@ describe('Works Square AI design adapter', () => {
try {
const turn = adapter.submitMessage({
workspaceId: 'workspace-one',
conversationId: 'conversation-one',
clientTurnId: 'turn-bounded',
expectedTurnRevision: 1,
message: '即使页面暂停消费也不能永久等待',
message: '???????????????',
}).finally(() => {
resolved = true;
});
@@ -537,8 +554,8 @@ describe('Works Square AI design adapter', () => {
const { sockets, webSocketFactory } = scriptedSockets([{ open: true }]);
const fetchMock = vi.fn<typeof fetch>(async (input) => {
const url = String(input);
if (url.endsWith('/api/agents/sessions')) {
return jsonResponse({ session_id: 'session-failed', status: 'active' }, 201);
if (url.endsWith('/conversations/conversation-one')) {
return jsonResponse({ ...serverConversation, agent_session_id: 'session-failed' });
}
if (url.endsWith('/stream-tickets')) {
return jsonResponse({
@@ -573,11 +590,12 @@ describe('Works Square AI design adapter', () => {
fetchImpl: fetchMock,
webSocketFactory,
});
const subscription = await adapter.openWorkspaceEvents({ workspaceId: 'workspace-one' });
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',
@@ -595,7 +613,10 @@ describe('Works Square AI design adapter', () => {
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({ session_id: 'session-fallback', status: 'active' }, 201))
.mockResolvedValueOnce(jsonResponse({
...serverConversation,
agent_session_id: 'session-fallback',
}))
.mockResolvedValueOnce(jsonResponse({
stream_url: '/api/agents/sessions/session-fallback/ws?ticket=ticket-fallback',
}))
@@ -609,20 +630,21 @@ describe('Works Square AI design adapter', () => {
status: 'succeeded',
error: null,
}))
.mockResolvedValueOnce(jsonResponse(serverWorkspace));
.mockResolvedValueOnce(jsonResponse(serverConversation));
const adapter = new WorksSquareDesignWorkspace({
apiBaseUrl: 'https://square.example',
fetchImpl: fetchMock,
webSocketFactory,
});
const subscription = await adapter.openWorkspaceEvents({ workspaceId: 'workspace-one' });
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: '断线后继续完成',
message: '???????',
})).resolves.toMatchObject({ workspaceId: 'workspace-one' });
expect(fetchMock.mock.calls.filter(([url]) => String(url).includes('/runs/'))).toHaveLength(1);
} finally {
@@ -637,8 +659,8 @@ describe('Works Square AI design adapter', () => {
let runPolls = 0;
const fetchMock = vi.fn<typeof fetch>(async (input, init) => {
const url = String(input);
if (url.endsWith('/api/agents/sessions')) {
return jsonResponse({ session_id: 'session-slow', status: 'active' }, 201);
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);
@@ -651,8 +673,8 @@ describe('Works Square AI design adapter', () => {
error: null,
});
}
if (url.endsWith('/api/design/workspaces/workspace-one')) {
return jsonResponse(serverWorkspace);
if (url.endsWith('/conversations/conversation-one')) {
return jsonResponse(serverConversation);
}
throw new Error(`Unexpected request: ${url} ${init?.method ?? 'GET'}`);
});
@@ -664,9 +686,10 @@ describe('Works Square AI design adapter', () => {
try {
const outcomePromise = adapter.submitMessage({
workspaceId: 'workspace-one',
conversationId: 'conversation-one',
clientTurnId: 'turn-slow',
expectedTurnRevision: 1,
message: '整理一个复杂的品牌设计方向',
message: '?????????????',
}).then(
(value) => ({ value, error: null }),
(error: unknown) => ({ value: null, error }),
@@ -685,10 +708,7 @@ describe('Works Square AI design adapter', () => {
it('maps an invalid Runtime command to a user input error', async () => {
const fetchMock = vi.fn<typeof fetch>()
.mockResolvedValueOnce(jsonResponse({
session_id: 'session-one',
status: 'active',
}, 201))
.mockResolvedValueOnce(jsonResponse(serverConversation))
.mockResolvedValueOnce(jsonResponse({
run_id: 'run-invalid',
status: 'queued',
@@ -710,6 +730,7 @@ describe('Works Square AI design adapter', () => {
await expect(adapter.submitMessage({
workspaceId: 'workspace-one',
conversationId: 'conversation-one',
clientTurnId: 'turn-invalid',
expectedTurnRevision: 1,
message: 'invalid',
@@ -722,10 +743,7 @@ describe('Works Square AI design adapter', () => {
it('keeps task creation behind structured Quote confirmation', async () => {
const fetchMock = vi.fn<typeof fetch>()
.mockResolvedValueOnce(jsonResponse({
session_id: 'session-one',
status: 'active',
}, 201))
.mockResolvedValueOnce(jsonResponse(serverConversation))
.mockResolvedValueOnce(jsonResponse({
run_id: 'run-confirm',
status: 'queued',
@@ -736,7 +754,7 @@ describe('Works Square AI design adapter', () => {
status: 'succeeded',
error: null,
}))
.mockResolvedValueOnce(jsonResponse(serverWorkspace));
.mockResolvedValueOnce(jsonResponse(serverConversation));
const adapter = new WorksSquareDesignWorkspace({
apiBaseUrl: 'https://square.example/',
fetchImpl: fetchMock,
@@ -744,6 +762,7 @@ describe('Works Square AI design adapter', () => {
const workspace = await adapter.confirmGeneration({
workspaceId: 'workspace-one',
conversationId: 'conversation-one',
clientTurnId: 'turn-two',
expectedTurnRevision: 1,
quoteId: 'quote-one',
@@ -774,7 +793,7 @@ describe('Works Square AI design adapter', () => {
);
expect(fetchMock).toHaveBeenNthCalledWith(
4,
'https://square.example/api/design/workspaces/workspace-one',
'https://square.example/api/design/workspaces/workspace-one/conversations/conversation-one',
expect.objectContaining({ headers: expect.any(Object) }),
);
});
@@ -786,7 +805,7 @@ describe('Works Square AI design adapter', () => {
medium: 'image',
status: 'succeeded',
brief_version: 1,
brief_summary: '海洋公益海报',
brief_summary: '??????',
quote_id: 'quote-one',
quoted_design_points: 1,
failure_code: null,
@@ -877,10 +896,13 @@ describe('Works Square AI design adapter', () => {
session_id: 'session-one',
sequence: 1,
runtime: 'design',
type: 'design.workspace.updated',
type: 'design.conversation.updated',
schema_version: 1,
payload: {
workspace: serverWorkspace,
workspace_id: 'workspace-one',
conversation_id: 'conversation-one',
workspace_view_revision: 2,
conversation: serverConversation,
generation_tasks: [snapshotTask],
},
};
@@ -892,10 +914,11 @@ describe('Works Square AI design adapter', () => {
schema_version: 1,
payload: {
workspace_id: 'workspace-one',
conversation_id: 'conversation-one',
client_turn_id: 'turn-two',
turn_revision: 2,
chunk_index: 0,
delta: '方向已经明确',
delta: '??????',
},
};
const malformedDeltaEvent = {
@@ -926,7 +949,7 @@ describe('Works Square AI design adapter', () => {
medium: 'video',
status: 'running',
brief_version: 2,
brief_summary: '海洋公益短片',
brief_summary: '??????',
quote_id: 'quote-live',
quoted_design_points: 8,
failure_code: null,
@@ -937,10 +960,7 @@ describe('Works Square AI design adapter', () => {
},
};
const fetchMock = vi.fn<typeof fetch>()
.mockResolvedValueOnce(jsonResponse({
session_id: 'session-one',
status: 'active',
}, 201))
.mockResolvedValueOnce(jsonResponse(serverConversation))
.mockResolvedValueOnce(jsonResponse({
ticket: 'secret-ticket-one',
transport: 'websocket',
@@ -971,12 +991,13 @@ describe('Works Square AI design adapter', () => {
webSocketFactory,
});
const first = await adapter.openWorkspaceEvents({ workspaceId: 'workspace-one' });
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) {
@@ -987,14 +1008,15 @@ describe('Works Square AI design adapter', () => {
expect(received).toEqual([
{
id: 'session-one:1',
type: 'design.generation_tasks.snapshot',
type: 'design.conversation.snapshot',
workspaceId: 'workspace-one',
conversationId: 'conversation-one',
workspaceViewRevision: 2,
workspace: expect.objectContaining({
workspaceId: 'workspace-one',
title: serverWorkspace.title,
conversation: expect.objectContaining({
conversationId: 'conversation-one',
title: serverConversation.title,
messages: [expect.objectContaining({
text: serverWorkspace.messages[0].text,
text: serverConversation.messages[0].text,
})],
}),
generationTasks: [expect.objectContaining({
@@ -1007,10 +1029,11 @@ describe('Works Square AI design adapter', () => {
id: 'session-one:2',
type: 'design.assistant.delta',
workspaceId: 'workspace-one',
conversationId: 'conversation-one',
clientTurnId: 'turn-two',
turnRevision: 2,
chunkIndex: 0,
delta: '方向已经明确',
delta: '??????',
},
{
id: 'session-one:3',
@@ -1026,13 +1049,12 @@ describe('Works Square AI design adapter', () => {
]);
expect(fetchMock).toHaveBeenNthCalledWith(
1,
'https://square.example/api/agents/sessions',
expect.objectContaining({
method: 'POST',
body: expect.stringContaining('"runtime":"design"'),
}),
'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('/api/agents/sessions')))
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);
@@ -1048,17 +1070,13 @@ describe('Works Square AI design adapter', () => {
]);
});
it('rotates the Session and client id after an upstream event cursor expires', async () => {
const rotate = vi.fn().mockResolvedValue('design-stream-next');
it('reloads the persistent Conversation Session after an upstream event cursor expires', async () => {
const fetchMock = vi.fn<typeof fetch>()
.mockResolvedValueOnce(jsonResponse({ session_id: 'session-old', status: 'active' }, 201))
.mockResolvedValueOnce(jsonResponse({ ...serverConversation, agent_session_id: 'session-old' }))
.mockResolvedValueOnce(jsonResponse({
stream_url: '/api/agents/sessions/session-old/ws?ticket=ticket-old',
}))
.mockResolvedValueOnce(jsonResponse({
session_id: 'session-old', status: 'closed',
}))
.mockResolvedValueOnce(jsonResponse({ session_id: 'session-new', status: 'active' }, 201))
.mockResolvedValueOnce(jsonResponse({ ...serverConversation, agent_session_id: 'session-new' }))
.mockResolvedValueOnce(jsonResponse({
stream_url: '/api/agents/sessions/session-new/ws?ticket=ticket-new',
}));
@@ -1070,48 +1088,39 @@ describe('Works Square AI design adapter', () => {
apiBaseUrl: 'https://square.example',
fetchImpl: fetchMock,
webSocketFactory,
eventSessionClientIdStore: {
getOrCreate: vi.fn().mockResolvedValue('design-stream-current'),
rotate,
},
});
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.
}
const sessionCalls = fetchMock.mock.calls.filter(([url]) => (
String(url).endsWith('/api/agents/sessions')
));
expect(sessionCalls).toHaveLength(2);
expect(JSON.parse(String(sessionCalls[0][1]?.body)).client_session_id)
.not.toBe(JSON.parse(String(sessionCalls[1][1]?.body)).client_session_id);
expect(rotate).toHaveBeenCalledWith('workspace-one');
expect(fetchMock.mock.calls[2]).toEqual([
'https://square.example/api/agents/sessions/session-old',
expect.objectContaining({ method: 'DELETE' }),
]);
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('replaces a closed cached Session before opening the task stream', async () => {
it('refreshes a closed persistent Session before opening the task stream', async () => {
const fetchMock = vi.fn<typeof fetch>()
.mockResolvedValueOnce(jsonResponse({ session_id: 'session-old', status: 'active' }, 201))
.mockResolvedValueOnce(jsonResponse({ ...serverConversation, agent_session_id: 'session-old' }))
.mockResolvedValueOnce(jsonResponse({
detail: { code: 'agent_session_closed', message: 'closed' },
code: 'agent_session_closed', message: 'closed',
}, 409))
.mockResolvedValueOnce(jsonResponse({ session_id: 'session-new', status: 'active' }, 201))
.mockResolvedValueOnce(jsonResponse({ ...serverConversation, agent_session_id: 'session-new' }))
.mockResolvedValueOnce(jsonResponse({
stream_url: '/api/agents/sessions/session-new/ws?ticket=ticket-new',
}));
@@ -1122,35 +1131,36 @@ describe('Works Square AI design adapter', () => {
webSocketFactory,
});
const recovered = await adapter.openWorkspaceEvents({ workspaceId: 'workspace-one' });
const recovered = await adapter.openWorkspaceEvents({ workspaceId: 'workspace-one', conversationId: 'conversation-one' });
for await (const _event of recovered.events) {
// Empty recovery stream.
}
const sessionCalls = fetchMock.mock.calls.filter(([url]) => (
String(url).endsWith('/api/agents/sessions')
));
expect(sessionCalls).toHaveLength(2);
expect(JSON.parse(String(sessionCalls[0][1]?.body)).client_session_id)
.not.toBe(JSON.parse(String(sessionCalls[1][1]?.body)).client_session_id);
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 every cached Agent Session during logout or application shutdown', async () => {
it('closes active local streams without deleting persistent Agent Sessions', async () => {
const fetchMock = vi.fn<typeof fetch>()
.mockResolvedValueOnce(jsonResponse({ session_id: 'session-one', status: 'active' }, 201))
.mockResolvedValueOnce(jsonResponse(serverConversation))
.mockResolvedValueOnce(jsonResponse({
stream_url: '/api/agents/sessions/session-one/ws?ticket=ticket-one',
}))
.mockResolvedValueOnce(jsonResponse({ session_id: 'session-two', status: 'active' }, 201))
.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',
}))
.mockImplementation(() => Promise.resolve(
jsonResponse({ session_id: 'closed', status: 'closed' }),
));
const { webSocketFactory } = scriptedSockets([
{ closeCode: 1000 },
{ closeCode: 1000 },
}));
const { sockets, webSocketFactory } = scriptedSockets([
{ open: true },
{ open: true },
]);
const adapter = new WorksSquareDesignWorkspace({
apiBaseUrl: 'https://square.example',
@@ -1158,92 +1168,79 @@ describe('Works Square AI design adapter', () => {
webSocketFactory,
});
const first = await adapter.openWorkspaceEvents({ workspaceId: 'workspace-one' });
for await (const _event of first.events) {
// Empty stream.
}
const second = await adapter.openWorkspaceEvents({ workspaceId: 'workspace-two' });
for await (const _event of second.events) {
// Empty stream.
}
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.map(([url]) => String(url)).sort()).toEqual([
'https://square.example/api/agents/sessions/session-one',
'https://square.example/api/agents/sessions/session-two',
]);
expect(closeCalls).toHaveLength(0);
expect(sockets.map((socket) => socket.readyState)).toEqual([3, 3]);
first.close();
second.close();
});
it('keeps the persisted Session key when a close result is uncertain', async () => {
const rotate = vi.fn().mockResolvedValue('design-stream-next');
const fetchMock = vi.fn<typeof fetch>()
.mockResolvedValueOnce(jsonResponse({ session_id: 'session-one', status: 'active' }, 201))
.mockResolvedValueOnce(jsonResponse({
stream_url: '/api/agents/sessions/session-one/ws?ticket=ticket-one',
}))
.mockResolvedValueOnce(jsonResponse({
detail: { code: 'service_unavailable', message: 'offline' },
}, 503));
const { webSocketFactory } = scriptedSockets([{ closeCode: 1000 }]);
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,
webSocketFactory,
eventSessionClientIdStore: {
getOrCreate: vi.fn().mockResolvedValue('design-stream-current'),
rotate,
},
});
const stream = await adapter.openWorkspaceEvents({ workspaceId: 'workspace-one' });
for await (const _event of stream.events) {
// Empty stream.
}
await expect(adapter.closeEventSessions()).rejects.toThrow(
'Failed to close 1 AI design Agent Session(s)',
);
expect(rotate).not.toHaveBeenCalled();
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('reuses a stable Session idempotency key after an unclean application restart', async () => {
const createFetch = () => vi.fn<typeof fetch>()
.mockResolvedValueOnce(jsonResponse({ session_id: 'session-stable', status: 'active' }, 201))
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({
stream_url: '/api/agents/sessions/session-stable/ws?ticket=ticket-stable',
}));
const firstFetch = createFetch();
const secondFetch = createFetch();
const firstSockets = scriptedSockets([{ closeCode: 1000 }]);
const restartedSockets = scriptedSockets([{ closeCode: 1000 }]);
const first = new WorksSquareDesignWorkspace({
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: firstFetch,
clientInstanceId: 'installation-one',
webSocketFactory: firstSockets.webSocketFactory,
});
const restarted = new WorksSquareDesignWorkspace({
apiBaseUrl: 'https://square.example',
fetchImpl: secondFetch,
clientInstanceId: 'installation-one',
webSocketFactory: restartedSockets.webSocketFactory,
fetchImpl: fetchMock,
});
const firstStream = await first.openWorkspaceEvents({ workspaceId: 'workspace-one' });
for await (const _event of firstStream.events) {
// Empty stream.
}
const restartedStream = await restarted.openWorkspaceEvents({
await expect(adapter.submitMessage({
workspaceId: 'workspace-one',
});
for await (const _event of restartedStream.events) {
// Empty stream.
}
conversationId: 'conversation-one',
clientTurnId: 'turn-retry',
expectedTurnRevision: 1,
message: 'retry',
})).resolves.toMatchObject({ conversationId: 'conversation-one' });
const firstBody = JSON.parse(String(firstFetch.mock.calls[0][1]?.body));
const restartedBody = JSON.parse(String(secondFetch.mock.calls[0][1]?.body));
expect(firstBody.client_session_id).toBe(restartedBody.client_session_id);
expect(firstBody.client_session_id).toMatch(/^design-stream-[a-f0-9]{64}$/);
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',
]);
});
});