fix(design): separate progress from chat replies
This commit is contained in:
@@ -93,6 +93,39 @@ describe('youth AI Design Canvas page', () => {
|
||||
expect(screen.queryByText(/规格版本|编译器|生成策略|字段决策/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('presents unfinished AI output as progress instead of a chat reply', () => {
|
||||
prepareWorkspace();
|
||||
const unfinishedDraft = '收到,我们要收到,我们要制作一张社团活动海报。';
|
||||
useImageWorkspaceStore.setState({
|
||||
assistantStreams: { 'operation-chat-1': unfinishedDraft },
|
||||
pendingOperations: {
|
||||
'operation-chat-1': {
|
||||
id: 'operation-chat-1',
|
||||
label: '发送创作想法',
|
||||
command: {
|
||||
kind: 'apply_input',
|
||||
workspaceId: 'workspace-1',
|
||||
sessionId: 'session-1',
|
||||
expectedDirectionRevision: 4,
|
||||
clientOperationId: 'operation-chat-1',
|
||||
input: { kind: 'chat', message: '做一张社团活动海报' },
|
||||
},
|
||||
status: 'submitting',
|
||||
error: null,
|
||||
clearDraftPaths: [],
|
||||
clearChatDraft: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
render(<ImageCanvas />);
|
||||
|
||||
const conversation = screen.getByTestId('image-workspace-conversation');
|
||||
expect(within(conversation).getByRole('status')).toHaveTextContent('AI 正在整理你的想法');
|
||||
expect(within(conversation).getByText('我已经整理了用途、受众和初步概念,请确认右侧建议。')).toBeInTheDocument();
|
||||
expect(within(conversation).queryByText(unfinishedDraft)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('invites a free description and does not turn a legacy decision prompt into a form', () => {
|
||||
const workspace = designWorkspaceFixture({
|
||||
form: designFormFixture({
|
||||
|
||||
@@ -74,6 +74,14 @@ class FakeEventSource {
|
||||
}
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((resolvePromise) => {
|
||||
resolve = resolvePromise;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
async function loadedStore(eventSource = new FakeEventSource()) {
|
||||
openEventsMock.mockResolvedValue(eventSource as unknown as EventSource);
|
||||
await useImageWorkspaceStore.getState().load();
|
||||
@@ -110,6 +118,87 @@ describe('V2 Living Form store', () => {
|
||||
expect(openEventsMock).toHaveBeenCalledWith('workspace-1', 'session-1', undefined);
|
||||
});
|
||||
|
||||
it('closes an obsolete event source when a replacement connection wins the race', async () => {
|
||||
const workspace = designWorkspaceFixture();
|
||||
useImageWorkspaceStore.setState({
|
||||
status: 'ready',
|
||||
activeWorkspaceId: workspace.workspace.workspaceId,
|
||||
workspace,
|
||||
});
|
||||
const firstOpen = deferred<EventSource>();
|
||||
const secondOpen = deferred<EventSource>();
|
||||
const firstSource = new FakeEventSource();
|
||||
const secondSource = new FakeEventSource();
|
||||
openEventsMock
|
||||
.mockReturnValueOnce(firstOpen.promise)
|
||||
.mockReturnValueOnce(secondOpen.promise);
|
||||
|
||||
useImageWorkspaceStore.getState().connectEvents();
|
||||
useImageWorkspaceStore.getState().disconnectEvents();
|
||||
useImageWorkspaceStore.getState().connectEvents();
|
||||
|
||||
firstOpen.resolve(firstSource as unknown as EventSource);
|
||||
await Promise.resolve();
|
||||
secondOpen.resolve(secondSource as unknown as EventSource);
|
||||
await vi.waitFor(() => expect(openEventsMock).toHaveBeenCalledTimes(2));
|
||||
|
||||
expect(firstSource.close).toHaveBeenCalledOnce();
|
||||
expect(secondSource.close).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows a new event connection after opening the previous one failed', async () => {
|
||||
const workspace = designWorkspaceFixture();
|
||||
const recoveredSource = new FakeEventSource();
|
||||
useImageWorkspaceStore.setState({
|
||||
status: 'ready',
|
||||
activeWorkspaceId: workspace.workspace.workspaceId,
|
||||
workspace,
|
||||
});
|
||||
openEventsMock
|
||||
.mockRejectedValueOnce(new Error('temporary event connection failure'))
|
||||
.mockResolvedValueOnce(recoveredSource as unknown as EventSource);
|
||||
|
||||
useImageWorkspaceStore.getState().connectEvents();
|
||||
await vi.waitFor(() => expect(useImageWorkspaceStore.getState().eventState).toBe('degraded'));
|
||||
useImageWorkspaceStore.getState().connectEvents();
|
||||
await vi.waitFor(() => expect(openEventsMock).toHaveBeenCalledTimes(2));
|
||||
|
||||
expect(recoveredSource.close).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses assistant chunk indexes to ignore replayed deltas', async () => {
|
||||
const source = await loadedStore();
|
||||
const baseEvent = {
|
||||
type: 'design.assistant.delta',
|
||||
workspaceId: 'workspace-1',
|
||||
directionId: 'direction-1',
|
||||
clientOperationId: 'operation-chat-1',
|
||||
directionRevision: 4,
|
||||
} as const;
|
||||
source.emit('design.assistant.delta', {
|
||||
...baseEvent,
|
||||
id: 'session-1:7',
|
||||
chunkIndex: 0,
|
||||
delta: '收到',
|
||||
});
|
||||
source.emit('design.assistant.delta', {
|
||||
...baseEvent,
|
||||
id: 'session-1:7-replayed',
|
||||
chunkIndex: 0,
|
||||
delta: '收到',
|
||||
});
|
||||
source.emit('design.assistant.delta', {
|
||||
...baseEvent,
|
||||
id: 'session-1:8',
|
||||
chunkIndex: 1,
|
||||
delta: ',正在整理',
|
||||
});
|
||||
|
||||
expect(useImageWorkspaceStore.getState().assistantStreams).toEqual({
|
||||
'operation-chat-1': '收到,正在整理',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a field draft separate until the direct edit succeeds', async () => {
|
||||
await loadedStore();
|
||||
useImageWorkspaceStore.getState().setFieldDraft('output.aspect_ratio', '16:9');
|
||||
|
||||
Reference in New Issue
Block a user