修复: 打通设计回复流式投递与结构化确认

问题:Run 完成会抢在 SSE 流式事件之前清空 pending,导致回复整块出现;无 Quote 的确认按钮又会降级为普通聊天,因此无法创建任务。

实现:为 Main 事件队列增加有界投递屏障,严格识别生成确认短语并只调用结构化 Quote Action,接入 generation capability 提示,同时补齐竞态、误触和任务对账测试。
This commit is contained in:
2026-08-03 17:19:53 +08:00
parent fab0e4034c
commit e697853d66
4 changed files with 408 additions and 51 deletions

View File

@@ -184,11 +184,16 @@ type AgentWebSocketFactory = (
type TaskEventQueue = { type TaskEventQueue = {
events: AsyncIterable<DesignWorkspaceEvent>; events: AsyncIterable<DesignWorkspaceEvent>;
push(event: DesignWorkspaceEvent): void; push(event: DesignWorkspaceEvent): Promise<void>;
finish(): void; finish(): void;
fail(error: unknown): void; fail(error: unknown): void;
}; };
type QueuedTaskEvent = {
event: DesignWorkspaceEvent;
acknowledge(): void;
};
type AgentRunEventWaiter = (run: ServerAgentRun) => void; type AgentRunEventWaiter = (run: ServerAgentRun) => void;
const AGENT_WEBSOCKET_OPEN = 1; const AGENT_WEBSOCKET_OPEN = 1;
@@ -196,6 +201,7 @@ const AGENT_WEBSOCKET_PING_INTERVAL_MS = 20_000;
const AGENT_RUN_INITIAL_POLL_INTERVAL_MS = 1_000; const AGENT_RUN_INITIAL_POLL_INTERVAL_MS = 1_000;
const AGENT_RUN_MAX_POLL_INTERVAL_MS = 5_000; const AGENT_RUN_MAX_POLL_INTERVAL_MS = 5_000;
const AGENT_RUN_TIMEOUT_MS = 10 * 60_000; const AGENT_RUN_TIMEOUT_MS = 10 * 60_000;
const DESIGN_EVENT_DELIVERY_BARRIER_TIMEOUT_MS = 1_000;
function mapBrief(brief: ServerBrief): DesignBrief { function mapBrief(brief: ServerBrief): DesignBrief {
return { return {
@@ -475,7 +481,8 @@ function normalizeAgentRunEvent(value: unknown, sessionId: string): ServerAgentR
} }
function createTaskEventQueue(): TaskEventQueue { function createTaskEventQueue(): TaskEventQueue {
const queued: DesignWorkspaceEvent[] = []; const queued: QueuedTaskEvent[] = [];
const pendingDeliveries = new Set<QueuedTaskEvent>();
const waiters: Array<() => void> = []; const waiters: Array<() => void> = [];
let finished = false; let finished = false;
let failed = false; let failed = false;
@@ -488,9 +495,13 @@ function createTaskEventQueue(): TaskEventQueue {
events: { events: {
async *[Symbol.asyncIterator]() { async *[Symbol.asyncIterator]() {
while (true) { while (true) {
const event = queued.shift(); const entry = queued.shift();
if (event) { if (entry) {
yield event; try {
yield entry.event;
} finally {
entry.acknowledge();
}
continue; continue;
} }
if (failed) throw failure; if (failed) throw failure;
@@ -500,24 +511,52 @@ function createTaskEventQueue(): TaskEventQueue {
}, },
}, },
push(event) { push(event) {
if (finished || failed) return; if (finished || failed) return Promise.resolve();
queued.push(event); let resolveDelivery!: () => void;
const delivered = new Promise<void>((resolve) => {
resolveDelivery = resolve;
});
let acknowledged = false;
const entry: QueuedTaskEvent = {
event,
acknowledge() {
if (acknowledged) return;
acknowledged = true;
pendingDeliveries.delete(entry);
resolveDelivery();
},
};
pendingDeliveries.add(entry);
queued.push(entry);
wake(); wake();
return delivered;
}, },
finish() { finish() {
if (finished || failed) return; if (finished || failed) return;
finished = true; finished = true;
for (const entry of [...pendingDeliveries]) entry.acknowledge();
wake(); wake();
}, },
fail(error) { fail(error) {
if (finished || failed) return; if (finished || failed) return;
failed = true; failed = true;
failure = error; failure = error;
for (const entry of [...pendingDeliveries]) entry.acknowledge();
wake(); wake();
}, },
}; };
} }
function boundedTaskEventDelivery(delivered: Promise<void>): Promise<void> {
return new Promise((resolve) => {
const timeout = setTimeout(resolve, DESIGN_EVENT_DELIVERY_BARRIER_TIMEOUT_MS);
void delivered.then(() => {
clearTimeout(timeout);
resolve();
});
});
}
function webSocketCloseError(code: number): DesignWorkspaceModuleError | null { function webSocketCloseError(code: number): DesignWorkspaceModuleError | null {
if (code === 1000 || code === 1001) return null; if (code === 1000 || code === 1001) return null;
if (code === 4401) { if (code === 4401) {
@@ -924,6 +963,7 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule {
const { socket } = connection; const { socket } = connection;
const queue = createTaskEventQueue(); const queue = createTaskEventQueue();
let latestWorkspaceEventDelivery = Promise.resolve();
let didOpen = false; let didOpen = false;
let ending = false; let ending = false;
let settled = false; let settled = false;
@@ -1010,14 +1050,19 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule {
socket.onmessage = ({ data }) => { socket.onmessage = ({ data }) => {
if (ending) return; if (ending) return;
const agentEvent = agentEventFromWebSocketFrame(data); const agentEvent = agentEventFromWebSocketFrame(data);
const run = normalizeAgentRunEvent(agentEvent, session.session_id);
if (run) this.publishAgentRun(session.session_id, run);
const event = normalizeWorkspaceEvent( const event = normalizeWorkspaceEvent(
agentEvent, agentEvent,
session.session_id, session.session_id,
input.workspaceId, input.workspaceId,
); );
if (event) queue.push(event); if (event) {
latestWorkspaceEventDelivery = boundedTaskEventDelivery(queue.push(event));
}
const run = normalizeAgentRunEvent(agentEvent, session.session_id);
if (run) {
const deliveryBarrier = latestWorkspaceEventDelivery;
void deliveryBarrier.then(() => this.publishAgentRun(session.session_id, run));
}
}; };
socket.onerror = () => { socket.onerror = () => {
setTimeout(() => { setTimeout(() => {

View File

@@ -37,6 +37,21 @@ import type {
} from '../../../shared/image-workspace'; } from '../../../shared/image-workspace';
const ACTIVE_TASK_STATUSES = new Set<DesignTaskStatus>(['queued', 'running']); const ACTIVE_TASK_STATUSES = new Set<DesignTaskStatus>(['queued', 'running']);
const CONFIRMATION_UNAVAILABLE_MESSAGE = (
'当前没有可确认的生成报价,请先让设计 Agent 完成方案与报价。'
);
const GENERATION_CONFIRMATION_INTENTS = new Set([
'确认生成',
'确认开始生成',
'确认并开始生成',
'确认开始制作',
'确认并开始制作',
]);
function isGenerationConfirmationIntent(message: string): boolean {
const normalized = message.trim().replace(/[\s,。.!??、]/g, '');
return GENERATION_CONFIRMATION_INTENTS.has(normalized);
}
function taskStatusLabel(status: DesignTaskStatus): string { function taskStatusLabel(status: DesignTaskStatus): string {
if (status === 'queued') return '排队中'; if (status === 'queued') return '排队中';
@@ -211,6 +226,7 @@ export function ImageCanvas() {
() => workspace ? activeQuote(workspace.messages) : null, () => workspace ? activeQuote(workspace.messages) : null,
[workspace], [workspace],
); );
const generationAvailable = bootstrap?.capabilities.generation ?? false;
const conversationMessages = useMemo<RenderedDesignMessage[]>(() => { const conversationMessages = useMemo<RenderedDesignMessage[]>(() => {
if (!workspace) return []; if (!workspace) return [];
const messages: RenderedDesignMessage[] = [...workspace.messages]; const messages: RenderedDesignMessage[] = [...workspace.messages];
@@ -259,9 +275,37 @@ export function ImageCanvas() {
} }
}, [conversationMessages.length, pendingTurn?.assistantText]); }, [conversationMessages.length, pendingTurn?.assistantText]);
const handleConfirm = async (quoteId: string) => {
if (!workspace || confirmingQuoteId) return;
if (!generationAvailable) {
setActionError(CONFIRMATION_UNAVAILABLE_MESSAGE);
return;
}
const requestedWorkspaceId = workspace.workspaceId;
setConfirmingQuoteId(quoteId);
setActionError(null);
try {
await confirmGeneration(quoteId);
} catch (error) {
if (useImageWorkspaceStore.getState().activeWorkspaceId !== requestedWorkspaceId) return;
setActionError(error instanceof Error ? error.message : String(error));
} finally {
setConfirmingQuoteId(null);
}
};
const handleSend = async (override?: string) => { const handleSend = async (override?: string) => {
const message = (override ?? prompt).trim(); const message = (override ?? prompt).trim();
if (!workspace || !message || submitting) return; if (!workspace || !message || submitting) return;
if (isGenerationConfirmationIntent(message)) {
setPrompt('');
if (quote && generationAvailable) {
await handleConfirm(quote.quoteId);
} else {
setActionError(CONFIRMATION_UNAVAILABLE_MESSAGE);
}
return;
}
const requestedWorkspaceId = workspace.workspaceId; const requestedWorkspaceId = workspace.workspaceId;
setSubmitting(true); setSubmitting(true);
setActionError(null); setActionError(null);
@@ -277,21 +321,6 @@ export function ImageCanvas() {
} }
}; };
const handleConfirm = async (quoteId: string) => {
if (!workspace || confirmingQuoteId) return;
const requestedWorkspaceId = workspace.workspaceId;
setConfirmingQuoteId(quoteId);
setActionError(null);
try {
await confirmGeneration(quoteId);
} catch (error) {
if (useImageWorkspaceStore.getState().activeWorkspaceId !== requestedWorkspaceId) return;
setActionError(error instanceof Error ? error.message : String(error));
} finally {
setConfirmingQuoteId(null);
}
};
const handleComposerKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => { const handleComposerKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') { if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
event.preventDefault(); event.preventDefault();
@@ -469,11 +498,16 @@ export function ImageCanvas() {
size="sm" size="sm"
className="h-8 rounded-full px-3 text-xs font-semibold" className="h-8 rounded-full px-3 text-xs font-semibold"
onClick={() => { onClick={() => {
if (reply.includes('确认') && quote) { if (!isGenerationConfirmationIntent(reply)) {
void handleConfirm(quote.quoteId);
} else {
setPrompt(reply); setPrompt(reply);
return;
} }
if (quote && generationAvailable) {
void handleConfirm(quote.quoteId);
return;
}
setPrompt('');
setActionError(CONFIRMATION_UNAVAILABLE_MESSAGE);
}} }}
> >
{reply} {reply}
@@ -502,6 +536,11 @@ export function ImageCanvas() {
> >
<div className="mx-auto w-full max-w-3xl"> <div className="mx-auto w-full max-w-3xl">
<div className="chat-composer-surface rounded-2xl border border-border/70 bg-background p-3 shadow-soft focus-within:ring-1 focus-within:ring-foreground/10"> <div className="chat-composer-surface rounded-2xl border border-border/70 bg-background p-3 shadow-soft focus-within:ring-1 focus-within:ring-foreground/10">
{!generationAvailable ? (
<p className="mb-2 rounded-xl bg-amber-500/10 px-3 py-2 text-xs font-semibold text-amber-700 dark:text-amber-300">
/
</p>
) : null}
<Textarea <Textarea
aria-label="设计需求" aria-label="设计需求"
value={prompt} value={prompt}

View File

@@ -348,6 +348,14 @@ describe('ImageCanvas Workspace-first design experience', () => {
chunkIndex: 0, chunkIndex: 0,
delta: '可以先增加', delta: '可以先增加',
} satisfies DesignAssistantDeltaEvent); } satisfies DesignAssistantDeltaEvent);
});
expect(screen.getByTestId('image-workspace-conversation'))
.toHaveTextContent('可以先增加');
expect(screen.getByTestId('image-workspace-conversation'))
.not.toHaveTextContent('留白和水流节奏');
act(() => {
taskEventSource.emit('design.assistant.delta', { taskEventSource.emit('design.assistant.delta', {
id: 'session-one:3', id: 'session-one:3',
type: 'design.assistant.delta', type: 'design.assistant.delta',
@@ -369,34 +377,34 @@ describe('ImageCanvas Workspace-first design experience', () => {
await waitFor(() => expect(useImageWorkspaceStore.getState().pendingTurn).toBeNull()); await waitFor(() => expect(useImageWorkspaceStore.getState().pendingTurn).toBeNull());
}); });
it('refreshes tasks after an Agent turn creates a generation task', async () => { it('never downgrades confirmation to normal chat when no active Quote exists', async () => {
const queuedTask = { const workspaceWithoutQuote = workspaceFixture();
...taskFixture, workspaceWithoutQuote.messages[1] = {
taskId: 'task-two', ...workspaceWithoutQuote.messages[1],
status: 'queued' as const, generationQuote: null,
resultAssets: [],
}; };
fetchImageWorkspaceTasksMock fetchImageWorkspaceMock.mockResolvedValueOnce({
.mockResolvedValueOnce([]) ...bootstrapFixture,
.mockResolvedValueOnce([queuedTask]); capabilities: {
...bootstrapFixture.capabilities,
generation: false,
image: false,
video: false,
},
});
fetchImageWorkspaceProjectMock.mockResolvedValueOnce(workspaceWithoutQuote);
render(<MemoryRouter><ImageCanvas /></MemoryRouter>); render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
await screen.findByTestId('design-quote-quote-one'); const confirmReply = await screen.findByRole('button', { name: '确认生成' });
const confirmationReply = workspaceFixture().messages[1].quickReplies[0]; fireEvent.click(confirmReply);
fireEvent.change(screen.getByLabelText('设计需求'), {
target: { value: confirmationReply },
});
fireEvent.click(screen.getByRole('button', { name: '发送给设计 Agent' }));
await waitFor(() => expect(sendImageWorkspaceMessageMock).toHaveBeenCalledWith( expect(screen.getByRole('alert')).toHaveTextContent('当前没有可确认的生成报价');
'workspace-cloud', expect(screen.getByLabelText('设计需求')).toHaveValue('');
1, expect(sendImageWorkspaceMessageMock).not.toHaveBeenCalled();
confirmationReply, expect(confirmImageWorkspaceGenerationMock).not.toHaveBeenCalled();
expect.stringMatching(/^turn-/), expect(screen.getByTestId('image-workspace-composer'))
)); .toHaveTextContent('当前环境仅支持设计沟通');
await waitFor(() => expect(fetchImageWorkspaceTasksMock).toHaveBeenCalledTimes(2));
expect(await screen.findByTestId('design-task-task-two')).toBeInTheDocument();
}); });
it('creates a generation task only through explicit Quote confirmation', async () => { it('creates a generation task only through explicit Quote confirmation', async () => {
@@ -425,6 +433,55 @@ describe('ImageCanvas Workspace-first design experience', () => {
expect(await screen.findByTestId('design-task-task-two')).toBeInTheDocument(); expect(await screen.findByTestId('design-task-task-two')).toBeInTheDocument();
}); });
it('converts an exact composer confirmation into the structured Quote action', async () => {
fetchImageWorkspaceTasksMock
.mockResolvedValueOnce([taskFixture])
.mockResolvedValueOnce([{
...taskFixture,
taskId: 'task-confirmed-from-composer',
quoteId: 'quote-one',
status: 'queued',
resultAssets: [],
}]);
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
await screen.findByTestId('design-quote-quote-one');
fireEvent.change(screen.getByLabelText('设计需求'), {
target: { value: '确认生成' },
});
fireEvent.click(screen.getByRole('button', { name: '发送给设计 Agent' }));
await waitFor(() => expect(confirmImageWorkspaceGenerationMock)
.toHaveBeenCalledWith(
'workspace-cloud',
1,
'quote-one',
expect.stringMatching(/^turn-/),
));
expect(sendImageWorkspaceMessageMock).not.toHaveBeenCalled();
expect(await screen.findByTestId('design-task-task-confirmed-from-composer'))
.toBeInTheDocument();
});
it('keeps longer design questions containing confirmation words as normal chat', async () => {
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
await screen.findByTestId('design-quote-quote-one');
const question = '请确认这个生成图的构图是不是还需要调整';
fireEvent.change(screen.getByLabelText('设计需求'), {
target: { value: question },
});
fireEvent.click(screen.getByRole('button', { name: '发送给设计 Agent' }));
await waitFor(() => expect(sendImageWorkspaceMessageMock).toHaveBeenCalledWith(
'workspace-cloud',
1,
question,
expect.stringMatching(/^turn-/),
));
expect(confirmImageWorkspaceGenerationMock).not.toHaveBeenCalled();
});
it('renders a new task pushed by the design event stream without repeated polling', async () => { it('renders a new task pushed by the design event stream without repeated polling', async () => {
const queuedTask = { const queuedTask = {
...taskFixture, ...taskFixture,

View File

@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { WorksSquareDesignWorkspace } from '@electron/image-workspace/works-square-workspace'; import { WorksSquareDesignWorkspace } from '@electron/image-workspace/works-square-workspace';
import { getValidWorksSquareAccessToken } from '@electron/services/works-square-session'; import { getValidWorksSquareAccessToken } from '@electron/services/works-square-session';
import type { DesignWorkspaceEvent } from '../../shared/image-workspace';
vi.mock('@electron/services/works-square-session', () => ({ vi.mock('@electron/services/works-square-session', () => ({
getValidWorksSquareAccessToken: vi.fn(), getValidWorksSquareAccessToken: vi.fn(),
@@ -49,6 +50,14 @@ function jsonResponse(payload: unknown, status = 200): Response {
}); });
} }
function deferred<T>(): { promise: Promise<T>; resolve(value: T): void } {
let resolve!: (value: T) => void;
const promise = new Promise<T>((accept) => {
resolve = accept;
});
return { promise, resolve };
}
type MockSocketScript = { type MockSocketScript = {
frames?: unknown[]; frames?: unknown[];
open?: boolean; open?: boolean;
@@ -277,6 +286,213 @@ 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,
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('/stream-tickets')) {
return jsonResponse({
stream_url: '/api/agents/sessions/session-ordered/ws?ticket=ticket-ordered',
});
}
if (url.endsWith('/api/agents/sessions/session-ordered/commands')) {
queueMicrotask(() => {
sockets[0]?.emitFrame({
type: 'event',
event: {
session_id: 'session-ordered',
sequence: 4,
runtime: 'design',
type: 'design.assistant.delta',
run_id: 'run-ordered',
schema_version: 1,
payload: {
workspace_id: 'workspace-one',
client_turn_id: 'turn-ordered',
turn_revision: 2,
chunk_index: 0,
delta: '先看见这一段',
},
},
});
sockets[0]?.emitFrame({
type: 'event',
event: {
session_id: 'session-ordered',
sequence: 5,
runtime: 'design',
type: 'design.workspace.updated',
run_id: 'run-ordered',
schema_version: 1,
payload: {
workspace: streamedWorkspace,
generation_tasks: [],
},
},
});
sockets[0]?.emitFrame({
type: 'event',
event: {
session_id: 'session-ordered',
sequence: 6,
runtime: 'design',
type: 'run.completed',
run_id: 'run-ordered',
schema_version: 1,
payload: { status: 'succeeded' },
},
});
});
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({
apiBaseUrl: 'https://square.example',
fetchImpl: fetchMock,
webSocketFactory,
});
const subscription = await adapter.openWorkspaceEvents({ workspaceId: 'workspace-one' });
const releaseFirstWrite = deferred<void>();
const received: DesignWorkspaceEvent[] = [];
const consume = (async () => {
for await (const event of subscription.events) {
received.push(event);
if (received.length === 1) await releaseFirstWrite.promise;
if (received.length === 2) break;
}
})();
let resolved = false;
try {
const turn = adapter.submitMessage({
workspaceId: 'workspace-one',
clientTurnId: 'turn-ordered',
expectedTurnRevision: 1,
message: '让回复逐步出现',
}).finally(() => {
resolved = true;
});
await vi.waitFor(() => expect(received).toHaveLength(1));
await new Promise<void>((resolve) => setTimeout(resolve, 0));
expect(received[0]).toMatchObject({
type: 'design.assistant.delta',
delta: '先看见这一段',
});
expect(resolved).toBe(false);
expect(fetchMock.mock.calls.some(([url]) => (
String(url).endsWith('/api/design/workspaces/workspace-one')
))).toBe(false);
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',
]);
} finally {
releaseFirstWrite.resolve();
subscription.close();
}
});
it('bounds the delivery barrier when an opened stream has no consumer', async () => {
vi.useFakeTimers();
const { sockets, webSocketFactory } = scriptedSockets([{ open: true }]);
const streamedWorkspace = {
...serverWorkspace,
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('/stream-tickets')) {
return jsonResponse({
stream_url: '/api/agents/sessions/session-bounded/ws?ticket=ticket-bounded',
});
}
if (url.endsWith('/api/agents/sessions/session-bounded/commands')) {
queueMicrotask(() => {
sockets[0]?.emitFrame({
type: 'event',
event: {
session_id: 'session-bounded',
sequence: 5,
runtime: 'design',
type: 'design.workspace.updated',
run_id: 'run-bounded',
schema_version: 1,
payload: { workspace: streamedWorkspace, generation_tasks: [] },
},
});
sockets[0]?.emitFrame({
type: 'event',
event: {
session_id: 'session-bounded',
sequence: 6,
runtime: 'design',
type: 'run.completed',
run_id: 'run-bounded',
schema_version: 1,
payload: { status: 'succeeded' },
},
});
});
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({
apiBaseUrl: 'https://square.example',
fetchImpl: fetchMock,
webSocketFactory,
});
const subscriptionPromise = adapter.openWorkspaceEvents({ workspaceId: 'workspace-one' });
await vi.advanceTimersByTimeAsync(0);
const subscription = await subscriptionPromise;
let resolved = false;
try {
const turn = adapter.submitMessage({
workspaceId: 'workspace-one',
clientTurnId: 'turn-bounded',
expectedTurnRevision: 1,
message: '即使页面暂停消费也不能永久等待',
}).finally(() => {
resolved = true;
});
await vi.advanceTimersByTimeAsync(999);
expect(resolved).toBe(false);
await vi.advanceTimersByTimeAsync(1);
await expect(turn).resolves.toMatchObject({ turnRevision: 2 });
expect(fetchMock.mock.calls.filter(([url]) => String(url).includes('/runs/')))
.toHaveLength(0);
} finally {
subscription.close();
vi.useRealTimers();
}
});
it('maps a failed Run received from the connected WebSocket without polling', async () => { it('maps a failed Run received from the connected WebSocket without polling', async () => {
const { sockets, webSocketFactory } = scriptedSockets([{ open: true }]); const { sockets, webSocketFactory } = scriptedSockets([{ open: true }]);
const fetchMock = vi.fn<typeof fetch>(async (input) => { const fetchMock = vi.fn<typeof fetch>(async (input) => {