fix: 使用 WebSocket 完成设计 Agent Run

问题:客户端虽然保持 Agent Gateway WebSocket,但 Run 生命周期事件被过滤,提交后仍持续轮询 /runs 接口。

修复:按 Session 与 Run 关联 WebSocket 终态事件,处理事件抢跑和并发隔离;仅在连接缺失或断开时退化为低频 REST 查询。
This commit is contained in:
2026-08-03 12:02:48 +08:00
parent 20c567fe5f
commit 78e4de91e4
2 changed files with 308 additions and 0 deletions

View File

@@ -75,6 +75,10 @@ class MockAgentWebSocket {
this.sent.push(data);
}
emitFrame(frame: unknown): void {
this.onmessage?.({ data: JSON.stringify(frame) });
}
close(code = 1000, reason = ''): void {
this.emitClose(code, reason);
}
@@ -210,6 +214,166 @@ describe('Works Square AI design adapter', () => {
);
});
it('completes a design turn from the connected WebSocket without polling the run endpoint', async () => {
const { sockets, webSocketFactory } = scriptedSockets([{ open: true }]);
const fetchMock = vi.fn<typeof fetch>(async (input) => {
const url = String(input);
if (url.endsWith('/api/agents/sessions')) {
return jsonResponse({ session_id: 'session-live', status: 'active' }, 201);
}
if (url.endsWith('/stream-tickets')) {
return jsonResponse({
ticket: 'ticket-live',
transport: 'websocket',
stream_url: '/api/agents/sessions/session-live/ws?ticket=ticket-live',
expires_at: '2026-08-03T03:00:00Z',
});
}
if (url.endsWith('/api/agents/sessions/session-live/commands')) {
queueMicrotask(() => sockets[0]?.emitFrame({
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' },
},
}));
return jsonResponse({
command_id: 'command-live',
run_id: 'run-live',
status: 'accepted',
}, 202);
}
if (url.endsWith('/api/design/workspaces/workspace-one')) {
return jsonResponse(serverWorkspace);
}
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' });
try {
await expect(adapter.submitMessage({
workspaceId: 'workspace-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 failed Run received from the connected WebSocket without polling', async () => {
const { sockets, webSocketFactory } = scriptedSockets([{ open: true }]);
const fetchMock = vi.fn<typeof fetch>(async (input) => {
const url = String(input);
if (url.endsWith('/api/agents/sessions')) {
return jsonResponse({ session_id: 'session-failed', status: 'active' }, 201);
}
if (url.endsWith('/stream-tickets')) {
return jsonResponse({
stream_url: '/api/agents/sessions/session-failed/ws?ticket=ticket-failed',
});
}
if (url.endsWith('/api/agents/sessions/session-failed/commands')) {
queueMicrotask(() => sockets[0]?.emitFrame({
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,
},
},
},
}));
return jsonResponse({ run_id: 'run-failed', status: 'queued', error: null }, 202);
}
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' });
try {
await expect(adapter.submitMessage({
workspaceId: 'workspace-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('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({
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(serverWorkspace));
const adapter = new WorksSquareDesignWorkspace({
apiBaseUrl: 'https://square.example',
fetchImpl: fetchMock,
webSocketFactory,
});
const subscription = await adapter.openWorkspaceEvents({ workspaceId: 'workspace-one' });
try {
await expect(adapter.submitMessage({
workspaceId: 'workspace-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'));