fix: stabilize ai design history switching
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user