fix: stabilize ai design history switching

This commit is contained in:
2026-08-19 10:31:36 +08:00
parent abece81fdb
commit bf0b805d9f
14 changed files with 573 additions and 48 deletions

View File

@@ -104,6 +104,11 @@ describe('AI design renderer API boundary', () => {
'client-conversation-one',
);
await fetchImageWorkspaceConversation('workspace/one', 'conversation/one');
await fetchImageWorkspaceConversation(
'workspace/one',
'conversation/one',
'older page/+',
);
expect(hostApiFetchMock).toHaveBeenNthCalledWith(
1,
@@ -121,6 +126,11 @@ describe('AI design renderer API boundary', () => {
'/api/works/image-workspace/workspaces/workspace%2Fone/conversations/conversation%2Fone',
{},
);
expect(hostApiFetchMock).toHaveBeenNthCalledWith(
3,
'/api/works/image-workspace/workspaces/workspace%2Fone/conversations/conversation%2Fone?before=older%20page%2F%2B',
{},
);
});
it('sends conversation turns with the current turn revision and no provider settings', async () => {

View File

@@ -51,6 +51,14 @@ function createRequest(method: string, body?: unknown): IncomingMessage {
return request;
}
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 };
}
class MediaResponse extends Writable {
statusCode = 0;
readonly chunks: Buffer[] = [];
@@ -224,6 +232,21 @@ describe('AI design Main route boundary', () => {
data: { conversationId: 'conversation/one' },
});
const olderConversationResponse = createResponse();
await handleImageWorkspaceRoutes(
createRequest('GET'),
olderConversationResponse.res,
new URL(
'http://127.0.0.1/api/works/image-workspace/workspaces/workspace%2Fone/conversations/conversation%2Fone?before=opaque-cursor',
),
ctx,
);
expect(workspace.getConversation).toHaveBeenLastCalledWith(
'workspace/one',
'conversation/one',
'opaque-cursor',
);
const updateQuoteResponse = createResponse();
await handleImageWorkspaceRoutes(
createRequest('PATCH', {
@@ -637,4 +660,33 @@ describe('AI design Main route boundary', () => {
].join('\n'));
expect(close).toHaveBeenCalledOnce();
});
it('closes an event subscription that finishes opening after the renderer disconnects', async () => {
const opening = deferred<{
events: AsyncIterable<never>;
close(): void;
}>();
const close = vi.fn();
const openWorkspaceEvents = vi.fn().mockReturnValue(opening.promise);
const response = new MediaResponse();
const handled = handleImageWorkspaceRoutes(
createRequest('GET'),
response as unknown as ServerResponse,
new URL(
'http://127.0.0.1/api/works/image-workspace/workspaces/workspace%2Fone/conversations/conversation%2Fone/events',
),
{ imageWorkspace: { openWorkspaceEvents } } as unknown as HostApiContext,
);
await vi.waitFor(() => expect(openWorkspaceEvents).toHaveBeenCalledOnce());
response.emit('close');
opening.resolve({
events: (async function* () {})(),
close,
});
await expect(handled).resolves.toBe(true);
expect(close).toHaveBeenCalledOnce();
expect(response.statusCode).toBe(0);
});
});

View File

@@ -114,6 +114,31 @@ function conversation(
};
}
function conversationMessages(revisions: number[]): DesignConversation['messages'] {
return revisions.flatMap((turnRevision) => ([
{
id: `turn-${turnRevision}:user`,
role: 'user' as const,
kind: 'user' as const,
text: `用户消息 ${turnRevision}`,
quickReplies: [],
generationQuote: null,
turnRevision,
createdAt: `2026-08-02T10:${String(turnRevision).padStart(2, '0')}:00Z`,
},
{
id: `turn-${turnRevision}:assistant`,
role: 'assistant' as const,
kind: 'reply' as const,
text: `助手消息 ${turnRevision}`,
quickReplies: [],
generationQuote: null,
turnRevision,
createdAt: `2026-08-02T10:${String(turnRevision).padStart(2, '0')}:00Z`,
},
]));
}
function workspace(workspaceId = 'workspace-one', viewRevision = 1): DesignWorkspace {
const { messages: _messages, ...conversationSummary } = conversation(workspaceId);
return {
@@ -400,6 +425,91 @@ describe('AI design task event store', () => {
expect(openImageWorkspaceTaskEventsMock).toHaveBeenCalledOnce();
});
it('reuses an unresolved Conversation stream when switching A to B to A', async () => {
const pendingA = deferred<EventSource>();
const sourceB = new MockEventSource();
const project = workspace();
const { messages: _messages, ...secondSummary } = conversation(
'workspace-one',
0,
'conversation-two',
);
fetchImageWorkspaceProjectMock.mockResolvedValue({
...project,
conversationCount: 2,
conversations: [...project.conversations, secondSummary],
});
openImageWorkspaceTaskEventsMock
.mockReturnValueOnce(pendingA.promise)
.mockResolvedValueOnce(sourceB as unknown as EventSource);
await useImageWorkspaceStore.getState().load();
await vi.waitFor(() => expect(openImageWorkspaceTaskEventsMock)
.toHaveBeenCalledWith('workspace-one', 'conversation-one'));
await useImageWorkspaceStore.getState().selectConversation('conversation-two');
await useImageWorkspaceStore.getState().selectConversation('conversation-one');
expect(openImageWorkspaceTaskEventsMock).toHaveBeenCalledTimes(2);
expect(openImageWorkspaceTaskEventsMock).toHaveBeenLastCalledWith(
'workspace-one',
'conversation-two',
);
const sourceA = new MockEventSource();
pendingA.resolve(sourceA as unknown as EventSource);
await vi.waitFor(() => expect(sourceA.onopen).not.toBeNull());
});
it('shows the Conversation before a delayed task refresh settles', async () => {
const source = new MockEventSource();
const delayedTasks = deferred<DesignGenerationTask[]>();
openImageWorkspaceTaskEventsMock.mockResolvedValue(source as unknown as EventSource);
fetchImageWorkspaceTasksMock.mockReturnValueOnce(delayedTasks.promise);
await useImageWorkspaceStore.getState().load();
expect(useImageWorkspaceStore.getState()).toMatchObject({
status: 'ready',
conversation: { conversationId: 'conversation-one' },
tasks: [],
});
delayedTasks.resolve([task]);
await vi.waitFor(() => expect(useImageWorkspaceStore.getState().tasks).toEqual([task]));
});
it('prepends the next older page without losing the latest messages', async () => {
const source = new MockEventSource();
const latest = {
...conversation('workspace-one', 6),
messages: conversationMessages([2, 3, 4, 5, 6]),
messagePage: { hasOlder: true, nextBefore: 'cursor-before-turn-2' },
};
const older = {
...conversation('workspace-one', 6),
messages: conversationMessages([1]),
messagePage: { hasOlder: false, nextBefore: null },
};
openImageWorkspaceTaskEventsMock.mockResolvedValue(source as unknown as EventSource);
fetchImageWorkspaceConversationMock
.mockResolvedValueOnce(latest)
.mockResolvedValueOnce(older);
await useImageWorkspaceStore.getState().load();
await useImageWorkspaceStore.getState().loadOlderMessages();
expect(fetchImageWorkspaceConversationMock).toHaveBeenLastCalledWith(
'workspace-one',
'conversation-one',
'cursor-before-turn-2',
);
expect(useImageWorkspaceStore.getState().conversation).toMatchObject({
messagePage: { hasOlder: false, nextBefore: null },
});
expect(useImageWorkspaceStore.getState().conversation?.messages.map((message) => (
message.turnRevision
))).toEqual([1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6]);
});
it('recovers a committed generation task when the Agent Run fails afterward', async () => {
const source = new MockEventSource();
openImageWorkspaceTaskEventsMock.mockResolvedValue(source as unknown as EventSource);

View File

@@ -286,6 +286,39 @@ describe('local AI design workspace', () => {
})).resolves.toMatchObject({ turnRevision: 2 });
});
it('pages local Conversation history in ten-message windows', async () => {
const service = createService(createTemporaryDirectory());
const created = await service.createWorkspace({
clientWorkspaceId: 'client-history-page',
title: '分页历史',
});
const conversationId = created.conversations[0].conversationId;
for (let revision = 0; revision < 6; revision += 1) {
await service.submitMessage({
workspaceId: created.workspaceId,
conversationId,
clientTurnId: `history-page-turn-${revision + 1}`,
expectedTurnRevision: revision,
message: `${revision + 1} 条设计需求`,
});
}
const latest = await service.getConversation(created.workspaceId, conversationId);
expect(latest.messages).toHaveLength(10);
expect(latest.messages.map((message) => message.turnRevision))
.toEqual([2, 2, 3, 3, 4, 4, 5, 5, 6, 6]);
expect(latest.messagePage).toMatchObject({ hasOlder: true });
const older = await service.getConversation(
created.workspaceId,
conversationId,
latest.messagePage!.nextBefore!,
);
expect(older.messages.map((message) => message.turnRevision)).toEqual([1, 1]);
expect(older.messagePage).toEqual({ hasOlder: false, nextBefore: null });
});
it('deletes a project while a task is queued and makes its task state inaccessible', async () => {
const userDataDir = createTemporaryDirectory();
const service = createService(userDataDir);

View File

@@ -464,6 +464,41 @@ describe('Works Square AI design adapter', () => {
);
});
it('passes the opaque older-history cursor through and preserves server message ids', async () => {
const pagedConversation = {
...serverConversation,
messages: [{
...serverConversation.messages[0],
message_id: 'persisted-turn-one:assistant',
}],
message_page: {
has_older: true,
next_before: 'opaque-older-page',
},
};
const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(pagedConversation));
const adapter = new WorksSquareDesignWorkspace({
apiBaseUrl: 'https://square.example',
fetchImpl: fetchMock,
});
const result = await adapter.getConversation(
'workspace-one',
'conversation-one',
'opaque-older-page',
);
expect(fetchMock).toHaveBeenCalledWith(
'https://square.example/api/design/workspaces/workspace-one/conversations/conversation-one?before=opaque-older-page',
expect.objectContaining({ headers: expect.any(Object) }),
);
expect(result.messages[0]?.id).toBe('persisted-turn-one:assistant');
expect(result.messagePage).toEqual({
hasOlder: true,
nextBefore: 'opaque-older-page',
});
});
it('normalizes a missing Brief medium to null and rejects invalid non-null values', async () => {
const { medium: _medium, ...legacyBrief } = serverConversation.brief;
const legacyConversation = {
@@ -1148,6 +1183,46 @@ describe('Works Square AI design adapter', () => {
}
});
it('fails a WebSocket event stream that never opens instead of leaving it pending', async () => {
vi.useFakeTimers();
const { sockets, webSocketFactory } = scriptedSockets([{ open: false }]);
const fetchMock = vi.fn<typeof fetch>(async (input) => {
const url = String(input);
if (url.endsWith('/conversations/conversation-one')) {
return jsonResponse({ ...serverConversation, agent_session_id: 'session-open-timeout' });
}
if (url.endsWith('/stream-tickets')) {
return jsonResponse({
stream_url: '/api/agents/sessions/session-open-timeout/ws?ticket=ticket-open-timeout',
});
}
throw new Error(`Unexpected request: ${url}`);
});
const adapter = new WorksSquareDesignWorkspace({
apiBaseUrl: 'https://square.example',
fetchImpl: fetchMock,
webSocketFactory,
});
try {
const opening = adapter.openWorkspaceEvents({
workspaceId: 'workspace-one',
conversationId: 'conversation-one',
});
const openingAssertion = expect(opening).rejects.toMatchObject({
status: 504,
code: 'DESIGN_EVENT_STREAM_TIMEOUT',
});
await vi.advanceTimersByTimeAsync(10_000);
await openingAssertion;
expect(sockets).toHaveLength(1);
expect(sockets[0].readyState).toBe(3);
} finally {
vi.useRealTimers();
}
});
it('maps a failed Run received from the connected WebSocket without polling', async () => {
const { webSocketFactory } = scriptedSockets([{
open: true,