feat: 对接设计 Agent Gateway WebSocket
需求:服务端统一 Agent Gateway 将设计任务状态流切换为 WebSocket,客户端需要实时展示生成任务并支持断线恢复。 实现:Electron Main 管理 Session、一次性 Ticket、WebSocket 心跳与游标续传,按关闭码回收会话;Renderer 继续通过本机 Host API 的 SSE 投影接收任务事件,并保留 REST 降级同步。 验证:typecheck、变更文件 ESLint、37 个聚焦测试及 build:vite 通过。
This commit is contained in:
@@ -272,6 +272,7 @@ describe('auth host api routes', () => {
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const stop = vi.fn(async () => undefined);
|
||||
const closeEventSessions = vi.fn(async () => undefined);
|
||||
const response = createResponse();
|
||||
|
||||
const handled = await handleAuthRoutes(
|
||||
@@ -280,6 +281,7 @@ describe('auth host api routes', () => {
|
||||
new URL('http://127.0.0.1:13210/api/auth/logout'),
|
||||
{
|
||||
opencodeManager: { stop },
|
||||
imageWorkspace: { closeEventSessions },
|
||||
} as never,
|
||||
);
|
||||
|
||||
@@ -287,6 +289,7 @@ describe('auth host api routes', () => {
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ success: true });
|
||||
expect(stop).toHaveBeenCalledOnce();
|
||||
expect(closeEventSessions).toHaveBeenCalledOnce();
|
||||
expect(providerServiceMock.deleteAccountApiKey).toHaveBeenCalledWith('niancode-user-models');
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ImageWorkspaceApiError } from '@/lib/image-workspace';
|
||||
@@ -7,6 +7,7 @@ import { useAuthStore } from '@/stores/auth';
|
||||
import { useImageWorkspaceStore } from '@/stores/image-workspace';
|
||||
import type {
|
||||
DesignGenerationTask,
|
||||
DesignGenerationTaskUpdatedEvent,
|
||||
DesignWorkspace,
|
||||
DesignWorkspaceBootstrap,
|
||||
} from '../../shared/image-workspace';
|
||||
@@ -16,8 +17,29 @@ const fetchImageWorkspaceProjectMock = vi.hoisted(() => vi.fn());
|
||||
const fetchImageWorkspaceTasksMock = vi.hoisted(() => vi.fn());
|
||||
const sendImageWorkspaceMessageMock = vi.hoisted(() => vi.fn());
|
||||
const confirmImageWorkspaceGenerationMock = vi.hoisted(() => vi.fn());
|
||||
const openImageWorkspaceTaskEventsMock = vi.hoisted(() => vi.fn());
|
||||
const resolveImageWorkspaceAssetUrlMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
type EventListener = (event: MessageEvent<string>) => void;
|
||||
|
||||
class MockEventSource {
|
||||
onopen: ((event: Event) => void) | null = null;
|
||||
onerror: ((event: Event) => void) | null = null;
|
||||
readonly close = vi.fn();
|
||||
private readonly listeners = new Map<string, Set<EventListener>>();
|
||||
|
||||
addEventListener(type: string, listener: EventListener): void {
|
||||
const listeners = this.listeners.get(type) ?? new Set<EventListener>();
|
||||
listeners.add(listener);
|
||||
this.listeners.set(type, listeners);
|
||||
}
|
||||
|
||||
emit(type: string, payload: unknown): void {
|
||||
const event = { data: JSON.stringify(payload) } as MessageEvent<string>;
|
||||
for (const listener of this.listeners.get(type) ?? []) listener(event);
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('@/lib/image-workspace', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/image-workspace')>();
|
||||
return {
|
||||
@@ -29,6 +51,9 @@ vi.mock('@/lib/image-workspace', async (importOriginal) => {
|
||||
confirmImageWorkspaceGeneration: (...args: unknown[]) => (
|
||||
confirmImageWorkspaceGenerationMock(...args)
|
||||
),
|
||||
openImageWorkspaceTaskEvents: (...args: unknown[]) => (
|
||||
openImageWorkspaceTaskEventsMock(...args)
|
||||
),
|
||||
resolveImageWorkspaceAssetUrl: (...args: unknown[]) => (
|
||||
resolveImageWorkspaceAssetUrlMock(...args)
|
||||
),
|
||||
@@ -122,6 +147,8 @@ const taskFixture: DesignGenerationTask = {
|
||||
};
|
||||
|
||||
describe('ImageCanvas Workspace-first design experience', () => {
|
||||
let taskEventSource: MockEventSource;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useImageWorkspaceStore.getState().reset();
|
||||
@@ -134,6 +161,10 @@ describe('ImageCanvas Workspace-first design experience', () => {
|
||||
turnRevision: 2,
|
||||
phase: 'shaping',
|
||||
});
|
||||
taskEventSource = new MockEventSource();
|
||||
openImageWorkspaceTaskEventsMock.mockResolvedValue(
|
||||
taskEventSource as unknown as EventSource,
|
||||
);
|
||||
resolveImageWorkspaceAssetUrlMock.mockResolvedValue(
|
||||
'http://127.0.0.1:13210/content?token=host',
|
||||
);
|
||||
@@ -330,27 +361,33 @@ describe('ImageCanvas Workspace-first design experience', () => {
|
||||
await waitFor(() => expect(fetchImageWorkspaceTasksMock).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it('keeps checking after Quote confirmation until the new task becomes visible', async () => {
|
||||
it('renders a new task pushed by the design event stream without repeated polling', async () => {
|
||||
const queuedTask = {
|
||||
...taskFixture,
|
||||
taskId: 'task-delayed',
|
||||
status: 'queued' as const,
|
||||
resultAssets: [],
|
||||
};
|
||||
fetchImageWorkspaceTasksMock
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([queuedTask]);
|
||||
fetchImageWorkspaceTasksMock.mockResolvedValue([]);
|
||||
|
||||
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
|
||||
await screen.findByTestId('design-quote-quote-one');
|
||||
await waitFor(() => expect(openImageWorkspaceTaskEventsMock)
|
||||
.toHaveBeenCalledWith('workspace-cloud'));
|
||||
await waitFor(() => expect(taskEventSource.onopen).not.toBeNull());
|
||||
act(() => {
|
||||
taskEventSource.onopen?.(new Event('open'));
|
||||
taskEventSource.emit('design.generation_task.updated', {
|
||||
id: 'session-one:2',
|
||||
type: 'design.generation_task.updated',
|
||||
workspaceId: 'workspace-cloud',
|
||||
workspaceViewRevision: 2,
|
||||
generationTask: queuedTask,
|
||||
} satisfies DesignGenerationTaskUpdatedEvent);
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认并开始生成' }));
|
||||
|
||||
await waitFor(() => expect(confirmImageWorkspaceGenerationMock)
|
||||
.toHaveBeenCalledWith('workspace-cloud', 1, 'quote-one'));
|
||||
await waitFor(() => expect(fetchImageWorkspaceTasksMock).toHaveBeenCalledTimes(3));
|
||||
expect(await screen.findByTestId('design-task-task-delayed'))
|
||||
.toBeInTheDocument();
|
||||
expect(fetchImageWorkspaceTasksMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { AppError } from '@/lib/error-model';
|
||||
import {
|
||||
createHostEventSource,
|
||||
ensureHostApiToken,
|
||||
getHostApiBase,
|
||||
hostApiFetch,
|
||||
@@ -10,17 +11,20 @@ import {
|
||||
createImageWorkspaceProject,
|
||||
fetchImageWorkspace,
|
||||
ImageWorkspaceApiError,
|
||||
openImageWorkspaceTaskEvents,
|
||||
resolveImageWorkspaceAssetUrl,
|
||||
sendImageWorkspaceMessage,
|
||||
} from '@/lib/image-workspace';
|
||||
|
||||
vi.mock('@/lib/host-api', () => ({
|
||||
hostApiFetch: vi.fn(),
|
||||
createHostEventSource: vi.fn(),
|
||||
ensureHostApiToken: vi.fn(),
|
||||
getHostApiBase: vi.fn(),
|
||||
}));
|
||||
|
||||
const hostApiFetchMock = vi.mocked(hostApiFetch);
|
||||
const createHostEventSourceMock = vi.mocked(createHostEventSource);
|
||||
const ensureHostApiTokenMock = vi.mocked(ensureHostApiToken);
|
||||
const getHostApiBaseMock = vi.mocked(getHostApiBase);
|
||||
|
||||
@@ -40,6 +44,8 @@ describe('AI design renderer API boundary', () => {
|
||||
hostApiFetchMock.mockResolvedValue({ success: true, status: 200, data: bootstrap });
|
||||
ensureHostApiTokenMock.mockReset();
|
||||
ensureHostApiTokenMock.mockResolvedValue('host-token');
|
||||
createHostEventSourceMock.mockReset();
|
||||
createHostEventSourceMock.mockReturnValue({ close: vi.fn() } as unknown as EventSource);
|
||||
getHostApiBaseMock.mockReset();
|
||||
getHostApiBaseMock.mockReturnValue('http://127.0.0.1:13210');
|
||||
});
|
||||
@@ -50,6 +56,17 @@ describe('AI design renderer API boundary', () => {
|
||||
expect(hostApiFetchMock).toHaveBeenCalledWith('/api/works/image-workspace', {});
|
||||
});
|
||||
|
||||
it('opens task events only after the local Host API token is ready', async () => {
|
||||
const source = await openImageWorkspaceTaskEvents('workspace/one');
|
||||
|
||||
expect(source).toBe(createHostEventSourceMock.mock.results[0].value);
|
||||
expect(createHostEventSourceMock).toHaveBeenCalledWith(
|
||||
'/api/works/image-workspace/workspaces/workspace%2Fone/events',
|
||||
);
|
||||
expect(ensureHostApiTokenMock.mock.invocationCallOrder[0])
|
||||
.toBeLessThan(createHostEventSourceMock.mock.invocationCallOrder[0]);
|
||||
});
|
||||
|
||||
it('creates an idempotent Workspace with a trimmed user-visible title', async () => {
|
||||
await createImageWorkspaceProject(' 角色设计 ', 'workspace-client-1');
|
||||
|
||||
|
||||
@@ -215,4 +215,60 @@ describe('AI design Main route boundary', () => {
|
||||
expect(response.headers.get('content-range')).toBe('bytes 0-6/100');
|
||||
expect(Buffer.concat(response.chunks).toString()).toBe('partial');
|
||||
});
|
||||
|
||||
it('relays normalized task events over local SSE and forwards the opaque resume cursor', async () => {
|
||||
const close = vi.fn();
|
||||
const openWorkspaceEvents = vi.fn().mockResolvedValue({
|
||||
events: (async function* () {
|
||||
yield {
|
||||
id: 'session-one:7',
|
||||
type: 'design.generation_task.updated' as const,
|
||||
workspaceId: 'workspace/one',
|
||||
workspaceViewRevision: 8,
|
||||
generationTask: {
|
||||
taskId: 'task-live',
|
||||
workspaceId: 'workspace/one',
|
||||
medium: 'image' as const,
|
||||
status: 'succeeded' as const,
|
||||
briefVersion: 1,
|
||||
briefSummary: '海洋公益海报',
|
||||
quoteId: 'quote-live',
|
||||
quotedDesignPoints: 1,
|
||||
failureCode: null,
|
||||
resultAssets: [],
|
||||
createdAt: '2026-08-02T10:00:00Z',
|
||||
updatedAt: '2026-08-02T10:02:00Z',
|
||||
},
|
||||
};
|
||||
})(),
|
||||
close,
|
||||
});
|
||||
const request = createRequest('GET');
|
||||
request.headers = { 'last-event-id': 'session-one:6' };
|
||||
const response = new MediaResponse();
|
||||
|
||||
const handled = await handleImageWorkspaceRoutes(
|
||||
request,
|
||||
response as unknown as ServerResponse,
|
||||
new URL(
|
||||
'http://127.0.0.1/api/works/image-workspace/workspaces/workspace%2Fone/events',
|
||||
),
|
||||
{ imageWorkspace: { openWorkspaceEvents } } as unknown as HostApiContext,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(openWorkspaceEvents).toHaveBeenCalledWith({
|
||||
workspaceId: 'workspace/one',
|
||||
afterEventId: 'session-one:6',
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.headers.get('content-type')).toBe('text/event-stream; charset=utf-8');
|
||||
expect(response.headers.get('x-accel-buffering')).toBe('no');
|
||||
expect(Buffer.concat(response.chunks).toString()).toContain([
|
||||
'id: session-one:7',
|
||||
'event: design.generation_task.updated',
|
||||
'data: {"id":"session-one:7"',
|
||||
].join('\n'));
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
261
tests/unit/image-workspace-store.test.ts
Normal file
261
tests/unit/image-workspace-store.test.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useImageWorkspaceStore } from '@/stores/image-workspace';
|
||||
import type {
|
||||
DesignGenerationTask,
|
||||
DesignGenerationTasksSnapshotEvent,
|
||||
DesignGenerationTaskUpdatedEvent,
|
||||
DesignWorkspace,
|
||||
DesignWorkspaceBootstrap,
|
||||
} from '../../shared/image-workspace';
|
||||
|
||||
const fetchImageWorkspaceMock = vi.hoisted(() => vi.fn());
|
||||
const fetchImageWorkspaceProjectMock = vi.hoisted(() => vi.fn());
|
||||
const fetchImageWorkspaceTasksMock = vi.hoisted(() => vi.fn());
|
||||
const openImageWorkspaceTaskEventsMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/lib/image-workspace', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/image-workspace')>();
|
||||
return {
|
||||
...actual,
|
||||
fetchImageWorkspace: (...args: unknown[]) => fetchImageWorkspaceMock(...args),
|
||||
fetchImageWorkspaceProject: (...args: unknown[]) => fetchImageWorkspaceProjectMock(...args),
|
||||
fetchImageWorkspaceTasks: (...args: unknown[]) => fetchImageWorkspaceTasksMock(...args),
|
||||
openImageWorkspaceTaskEvents: (...args: unknown[]) => openImageWorkspaceTaskEventsMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
type EventListener = (event: MessageEvent<string>) => void;
|
||||
|
||||
class MockEventSource {
|
||||
onopen: ((event: Event) => void) | null = null;
|
||||
onerror: ((event: Event) => void) | null = null;
|
||||
readonly close = vi.fn();
|
||||
private readonly listeners = new Map<string, Set<EventListener>>();
|
||||
|
||||
addEventListener(type: string, listener: EventListener): void {
|
||||
const listeners = this.listeners.get(type) ?? new Set<EventListener>();
|
||||
listeners.add(listener);
|
||||
this.listeners.set(type, listeners);
|
||||
}
|
||||
|
||||
emit(type: string, payload: unknown): void {
|
||||
const event = { data: JSON.stringify(payload) } as MessageEvent<string>;
|
||||
for (const listener of this.listeners.get(type) ?? []) listener(event);
|
||||
}
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
const task: DesignGenerationTask = {
|
||||
taskId: 'task-one',
|
||||
workspaceId: 'workspace-one',
|
||||
medium: 'image',
|
||||
status: 'queued',
|
||||
briefVersion: 1,
|
||||
briefSummary: '海洋公益海报',
|
||||
quoteId: 'quote-one',
|
||||
quotedDesignPoints: 1,
|
||||
failureCode: null,
|
||||
resultAssets: [],
|
||||
createdAt: '2026-08-02T10:00:00Z',
|
||||
updatedAt: '2026-08-02T10:00:00Z',
|
||||
};
|
||||
|
||||
function workspace(workspaceId = 'workspace-one', viewRevision = 1): DesignWorkspace {
|
||||
return {
|
||||
workspaceId,
|
||||
title: workspaceId,
|
||||
turnRevision: 1,
|
||||
viewRevision,
|
||||
phase: 'shaping',
|
||||
brief: {
|
||||
version: 1,
|
||||
status: 'ready',
|
||||
medium: 'image',
|
||||
summary: '海洋公益海报',
|
||||
ready: true,
|
||||
missingDecision: null,
|
||||
},
|
||||
updatedAt: '2026-08-02T10:00:00Z',
|
||||
messages: [],
|
||||
};
|
||||
}
|
||||
|
||||
function bootstrap(workspaceIds = ['workspace-one']): DesignWorkspaceBootstrap {
|
||||
return {
|
||||
capabilities: { conversation: true, generation: true, image: true, video: true },
|
||||
workspaces: workspaceIds.map((workspaceId) => {
|
||||
const { messages: _messages, ...summary } = workspace(workspaceId);
|
||||
return summary;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function taskEvent(
|
||||
workspaceViewRevision: number,
|
||||
status: DesignGenerationTask['status'],
|
||||
): DesignGenerationTaskUpdatedEvent {
|
||||
return {
|
||||
id: `session-one:${workspaceViewRevision}`,
|
||||
type: 'design.generation_task.updated',
|
||||
workspaceId: 'workspace-one',
|
||||
workspaceViewRevision,
|
||||
generationTask: {
|
||||
...task,
|
||||
status,
|
||||
updatedAt: `2026-08-02T10:0${workspaceViewRevision}:00Z`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function taskSnapshotEvent(
|
||||
workspaceViewRevision: number,
|
||||
status: DesignGenerationTask['status'],
|
||||
): DesignGenerationTasksSnapshotEvent {
|
||||
return {
|
||||
id: `session-one:${workspaceViewRevision}`,
|
||||
type: 'design.generation_tasks.snapshot',
|
||||
workspaceId: 'workspace-one',
|
||||
workspaceViewRevision,
|
||||
generationTasks: [{
|
||||
...task,
|
||||
status,
|
||||
updatedAt: `2026-08-02T10:0${workspaceViewRevision}:00Z`,
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
describe('AI design task event store', () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
useImageWorkspaceStore.getState().reset();
|
||||
fetchImageWorkspaceMock.mockResolvedValue(bootstrap());
|
||||
fetchImageWorkspaceProjectMock.mockResolvedValue(workspace());
|
||||
fetchImageWorkspaceTasksMock.mockResolvedValue([task]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useImageWorkspaceStore.getState().reset();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('keeps a revision-5 snapshot over revision 3, then upserts a newer task by task id', async () => {
|
||||
const source = new MockEventSource();
|
||||
openImageWorkspaceTaskEventsMock.mockResolvedValue(source as unknown as EventSource);
|
||||
fetchImageWorkspaceProjectMock.mockResolvedValue(workspace('workspace-one', 5));
|
||||
|
||||
await useImageWorkspaceStore.getState().load();
|
||||
await vi.waitFor(() => expect(openImageWorkspaceTaskEventsMock)
|
||||
.toHaveBeenCalledWith('workspace-one'));
|
||||
source.onopen?.(new Event('open'));
|
||||
source.emit('design.generation_task.updated', taskEvent(3, 'failed'));
|
||||
expect(useImageWorkspaceStore.getState().tasks[0].status).toBe('queued');
|
||||
source.emit('design.generation_tasks.snapshot', taskSnapshotEvent(6, 'running'));
|
||||
source.emit('design.generation_task.updated', taskEvent(5, 'failed'));
|
||||
|
||||
expect(useImageWorkspaceStore.getState()).toMatchObject({
|
||||
taskStreamState: 'connected',
|
||||
workspace: { viewRevision: 6 },
|
||||
tasks: [{ taskId: 'task-one', status: 'running' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('closes the previous stream when switching workspaces and on reset', async () => {
|
||||
const first = new MockEventSource();
|
||||
const second = new MockEventSource();
|
||||
openImageWorkspaceTaskEventsMock
|
||||
.mockResolvedValueOnce(first as unknown as EventSource)
|
||||
.mockResolvedValueOnce(second as unknown as EventSource);
|
||||
fetchImageWorkspaceMock.mockResolvedValue(bootstrap(['workspace-one', 'workspace-two']));
|
||||
fetchImageWorkspaceProjectMock.mockImplementation((workspaceId: string) => (
|
||||
Promise.resolve(workspace(workspaceId))
|
||||
));
|
||||
|
||||
await useImageWorkspaceStore.getState().load();
|
||||
await vi.waitFor(() => expect(openImageWorkspaceTaskEventsMock).toHaveBeenCalledTimes(1));
|
||||
await useImageWorkspaceStore.getState().selectProject('workspace-two');
|
||||
await vi.waitFor(() => expect(openImageWorkspaceTaskEventsMock).toHaveBeenCalledTimes(2));
|
||||
|
||||
expect(first.close).toHaveBeenCalledOnce();
|
||||
useImageWorkspaceStore.getState().reset();
|
||||
expect(second.close).toHaveBeenCalledOnce();
|
||||
expect(useImageWorkspaceStore.getState().taskStreamState).toBe('idle');
|
||||
});
|
||||
|
||||
it('does not let a slow previous Workspace selection overwrite the latest one', async () => {
|
||||
const rootSource = new MockEventSource();
|
||||
const latestSource = new MockEventSource();
|
||||
const slowWorkspace = deferred<DesignWorkspace>();
|
||||
openImageWorkspaceTaskEventsMock
|
||||
.mockResolvedValueOnce(rootSource as unknown as EventSource)
|
||||
.mockResolvedValueOnce(latestSource as unknown as EventSource);
|
||||
fetchImageWorkspaceMock.mockResolvedValue(bootstrap([
|
||||
'workspace-root',
|
||||
'workspace-one',
|
||||
'workspace-two',
|
||||
]));
|
||||
fetchImageWorkspaceProjectMock.mockImplementation((workspaceId: string) => (
|
||||
workspaceId === 'workspace-one'
|
||||
? slowWorkspace.promise
|
||||
: Promise.resolve(workspace(workspaceId))
|
||||
));
|
||||
fetchImageWorkspaceTasksMock.mockResolvedValue([]);
|
||||
|
||||
await useImageWorkspaceStore.getState().load();
|
||||
await vi.waitFor(() => expect(openImageWorkspaceTaskEventsMock)
|
||||
.toHaveBeenCalledWith('workspace-root'));
|
||||
const staleSelection = useImageWorkspaceStore.getState().selectProject('workspace-one');
|
||||
await vi.waitFor(() => expect(fetchImageWorkspaceProjectMock)
|
||||
.toHaveBeenCalledWith('workspace-one'));
|
||||
await useImageWorkspaceStore.getState().selectProject('workspace-two');
|
||||
slowWorkspace.resolve(workspace('workspace-one'));
|
||||
await staleSelection;
|
||||
|
||||
expect(useImageWorkspaceStore.getState().activeWorkspaceId).toBe('workspace-two');
|
||||
expect(useImageWorkspaceStore.getState().workspace?.workspaceId).toBe('workspace-two');
|
||||
expect(openImageWorkspaceTaskEventsMock).not.toHaveBeenCalledWith('workspace-one');
|
||||
});
|
||||
|
||||
it('advances each task watermark after a degraded REST snapshot', async () => {
|
||||
const source = new MockEventSource();
|
||||
openImageWorkspaceTaskEventsMock.mockResolvedValue(source as unknown as EventSource);
|
||||
|
||||
await useImageWorkspaceStore.getState().load();
|
||||
await vi.waitFor(() => expect(openImageWorkspaceTaskEventsMock).toHaveBeenCalledOnce());
|
||||
fetchImageWorkspaceProjectMock.mockResolvedValue(workspace('workspace-one', 8));
|
||||
fetchImageWorkspaceTasksMock.mockResolvedValue([{
|
||||
...task,
|
||||
status: 'succeeded',
|
||||
}]);
|
||||
await useImageWorkspaceStore.getState().refreshWorkspace();
|
||||
await useImageWorkspaceStore.getState().refreshTasks();
|
||||
source.emit('design.generation_task.updated', taskEvent(7, 'failed'));
|
||||
|
||||
expect(useImageWorkspaceStore.getState().tasks[0].status).toBe('succeeded');
|
||||
});
|
||||
|
||||
it('polls slowly only while the event stream is degraded', async () => {
|
||||
vi.useFakeTimers();
|
||||
const source = new MockEventSource();
|
||||
openImageWorkspaceTaskEventsMock.mockResolvedValue(source as unknown as EventSource);
|
||||
|
||||
await useImageWorkspaceStore.getState().load();
|
||||
await vi.waitFor(() => expect(source.onerror).not.toBeNull());
|
||||
source.onerror?.(new Event('error'));
|
||||
await vi.advanceTimersByTimeAsync(14_999);
|
||||
expect(fetchImageWorkspaceTasksMock).toHaveBeenCalledTimes(1);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(fetchImageWorkspaceTasksMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
source.onopen?.(new Event('open'));
|
||||
await vi.advanceTimersByTimeAsync(15_000);
|
||||
expect(fetchImageWorkspaceTasksMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -49,6 +49,68 @@ function jsonResponse(payload: unknown, status = 200): Response {
|
||||
});
|
||||
}
|
||||
|
||||
type MockSocketScript = {
|
||||
frames?: unknown[];
|
||||
open?: boolean;
|
||||
closeCode?: number;
|
||||
closeReason?: string;
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
describe('Works Square AI design adapter', () => {
|
||||
beforeEach(() => {
|
||||
getTokenMock.mockReset();
|
||||
@@ -193,4 +255,356 @@ describe('Works Square AI design adapter', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('reuses one design Agent Session and normalizes matching task events from fresh WebSocket tickets', async () => {
|
||||
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.workspace.updated',
|
||||
schema_version: 1,
|
||||
payload: {
|
||||
workspace: {
|
||||
workspace_id: 'workspace-one',
|
||||
view_revision: 2,
|
||||
},
|
||||
generation_tasks: [snapshotTask],
|
||||
},
|
||||
};
|
||||
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({
|
||||
session_id: 'session-one',
|
||||
status: 'active',
|
||||
}, 201))
|
||||
.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: snapshotEvent },
|
||||
{ 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' });
|
||||
const received = [];
|
||||
for await (const event of first.events) received.push(event);
|
||||
first.close();
|
||||
const second = await adapter.openWorkspaceEvents({
|
||||
workspaceId: 'workspace-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.generation_tasks.snapshot',
|
||||
workspaceId: 'workspace-one',
|
||||
workspaceViewRevision: 2,
|
||||
generationTasks: [expect.objectContaining({
|
||||
taskId: 'task-snapshot',
|
||||
medium: 'image',
|
||||
status: 'running',
|
||||
})],
|
||||
},
|
||||
{
|
||||
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/agents/sessions',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: expect.stringContaining('"runtime":"design"'),
|
||||
}),
|
||||
);
|
||||
expect(fetchMock.mock.calls.filter(([url]) => String(url).endsWith('/api/agents/sessions')))
|
||||
.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('rotates the Session and client id after an upstream event cursor expires', async () => {
|
||||
const rotate = vi.fn().mockResolvedValue('design-stream-next');
|
||||
const fetchMock = vi.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(jsonResponse({ session_id: 'session-old', status: 'active' }, 201))
|
||||
.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({
|
||||
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,
|
||||
eventSessionClientIdStore: {
|
||||
getOrCreate: vi.fn().mockResolvedValue('design-stream-current'),
|
||||
rotate,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(adapter.openWorkspaceEvents({
|
||||
workspaceId: 'workspace-one',
|
||||
afterEventId: 'session-old:99',
|
||||
})).rejects.toMatchObject({ status: 410 });
|
||||
const recovered = await adapter.openWorkspaceEvents({
|
||||
workspaceId: 'workspace-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(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 () => {
|
||||
const fetchMock = vi.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(jsonResponse({ session_id: 'session-old', status: 'active' }, 201))
|
||||
.mockResolvedValueOnce(jsonResponse({
|
||||
detail: { code: 'agent_session_closed', message: 'closed' },
|
||||
}, 409))
|
||||
.mockResolvedValueOnce(jsonResponse({ session_id: 'session-new', status: 'active' }, 201))
|
||||
.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' });
|
||||
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);
|
||||
});
|
||||
|
||||
it('closes every cached Agent Session during logout or application shutdown', async () => {
|
||||
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({ session_id: 'session-two', status: 'active' }, 201))
|
||||
.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 adapter = new WorksSquareDesignWorkspace({
|
||||
apiBaseUrl: 'https://square.example',
|
||||
fetchImpl: fetchMock,
|
||||
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.
|
||||
}
|
||||
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',
|
||||
]);
|
||||
});
|
||||
|
||||
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 }]);
|
||||
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();
|
||||
});
|
||||
|
||||
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))
|
||||
.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({
|
||||
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,
|
||||
});
|
||||
|
||||
const firstStream = await first.openWorkspaceEvents({ workspaceId: 'workspace-one' });
|
||||
for await (const _event of firstStream.events) {
|
||||
// Empty stream.
|
||||
}
|
||||
const restartedStream = await restarted.openWorkspaceEvents({
|
||||
workspaceId: 'workspace-one',
|
||||
});
|
||||
for await (const _event of restartedStream.events) {
|
||||
// Empty stream.
|
||||
}
|
||||
|
||||
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}$/);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user