修复: 打通设计回复流式投递与结构化确认
问题:Run 完成会抢在 SSE 流式事件之前清空 pending,导致回复整块出现;无 Quote 的确认按钮又会降级为普通聊天,因此无法创建任务。 实现:为 Main 事件队列增加有界投递屏障,严格识别生成确认短语并只调用结构化 Quote Action,接入 generation capability 提示,同时补齐竞态、误触和任务对账测试。
This commit is contained in:
@@ -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(() => {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user