feat: 支持 AI 设计项目多会话交互
需求:用户可在同一设计项目中新建和切换会话,任务列表保持项目级共享。 实现: - 增加会话选择、新建入口及本地数据迁移 - Conversation 独立消息、Brief、Quote 与流式状态 - 保留项目任务并修复 Task revision 与 ABA 异步竞态 - 保持 Enter 发送、Shift+Enter 换行及 IME 保护
This commit is contained in:
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user