修复: 打通设计回复流式投递与结构化确认
问题:Run 完成会抢在 SSE 流式事件之前清空 pending,导致回复整块出现;无 Quote 的确认按钮又会降级为普通聊天,因此无法创建任务。 实现:为 Main 事件队列增加有界投递屏障,严格识别生成确认短语并只调用结构化 Quote Action,接入 generation capability 提示,同时补齐竞态、误触和任务对账测试。
This commit is contained in:
@@ -348,6 +348,14 @@ describe('ImageCanvas Workspace-first design experience', () => {
|
||||
chunkIndex: 0,
|
||||
delta: '可以先增加',
|
||||
} satisfies DesignAssistantDeltaEvent);
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('image-workspace-conversation'))
|
||||
.toHaveTextContent('可以先增加');
|
||||
expect(screen.getByTestId('image-workspace-conversation'))
|
||||
.not.toHaveTextContent('留白和水流节奏');
|
||||
|
||||
act(() => {
|
||||
taskEventSource.emit('design.assistant.delta', {
|
||||
id: 'session-one:3',
|
||||
type: 'design.assistant.delta',
|
||||
@@ -369,34 +377,34 @@ describe('ImageCanvas Workspace-first design experience', () => {
|
||||
await waitFor(() => expect(useImageWorkspaceStore.getState().pendingTurn).toBeNull());
|
||||
});
|
||||
|
||||
it('refreshes tasks after an Agent turn creates a generation task', async () => {
|
||||
const queuedTask = {
|
||||
...taskFixture,
|
||||
taskId: 'task-two',
|
||||
status: 'queued' as const,
|
||||
resultAssets: [],
|
||||
it('never downgrades confirmation to normal chat when no active Quote exists', async () => {
|
||||
const workspaceWithoutQuote = workspaceFixture();
|
||||
workspaceWithoutQuote.messages[1] = {
|
||||
...workspaceWithoutQuote.messages[1],
|
||||
generationQuote: null,
|
||||
};
|
||||
fetchImageWorkspaceTasksMock
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([queuedTask]);
|
||||
fetchImageWorkspaceMock.mockResolvedValueOnce({
|
||||
...bootstrapFixture,
|
||||
capabilities: {
|
||||
...bootstrapFixture.capabilities,
|
||||
generation: false,
|
||||
image: false,
|
||||
video: false,
|
||||
},
|
||||
});
|
||||
fetchImageWorkspaceProjectMock.mockResolvedValueOnce(workspaceWithoutQuote);
|
||||
|
||||
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.change(screen.getByLabelText('设计需求'), {
|
||||
target: { value: confirmationReply },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送给设计 Agent' }));
|
||||
fireEvent.click(confirmReply);
|
||||
|
||||
await waitFor(() => expect(sendImageWorkspaceMessageMock).toHaveBeenCalledWith(
|
||||
'workspace-cloud',
|
||||
1,
|
||||
confirmationReply,
|
||||
expect.stringMatching(/^turn-/),
|
||||
));
|
||||
await waitFor(() => expect(fetchImageWorkspaceTasksMock).toHaveBeenCalledTimes(2));
|
||||
expect(await screen.findByTestId('design-task-task-two')).toBeInTheDocument();
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('当前没有可确认的生成报价');
|
||||
expect(screen.getByLabelText('设计需求')).toHaveValue('');
|
||||
expect(sendImageWorkspaceMessageMock).not.toHaveBeenCalled();
|
||||
expect(confirmImageWorkspaceGenerationMock).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId('image-workspace-composer'))
|
||||
.toHaveTextContent('当前环境仅支持设计沟通');
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const queuedTask = {
|
||||
...taskFixture,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { WorksSquareDesignWorkspace } from '@electron/image-workspace/works-square-workspace';
|
||||
import { getValidWorksSquareAccessToken } from '@electron/services/works-square-session';
|
||||
import type { DesignWorkspaceEvent } from '../../shared/image-workspace';
|
||||
|
||||
vi.mock('@electron/services/works-square-session', () => ({
|
||||
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 = {
|
||||
frames?: unknown[];
|
||||
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 () => {
|
||||
const { sockets, webSocketFactory } = scriptedSockets([{ open: true }]);
|
||||
const fetchMock = vi.fn<typeof fetch>(async (input) => {
|
||||
|
||||
Reference in New Issue
Block a user