From 4615f9d43530f35e56e57d816bcdd56b4fe0cde1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Thu, 24 Sep 2026 01:18:51 +0800 Subject: [PATCH] fix(desktop): preserve stopped chat across incomplete history reloads --- desktop/src/api/sessions.test.ts | 107 +++++++++++++++++++++++++++ desktop/src/api/sessions.ts | 49 ++++++++++-- desktop/src/stores/chatStore.test.ts | 54 ++++++++++++++ desktop/src/stores/chatStore.ts | 14 +++- 4 files changed, 218 insertions(+), 6 deletions(-) diff --git a/desktop/src/api/sessions.test.ts b/desktop/src/api/sessions.test.ts index 29e9e5ea..7e674d28 100644 --- a/desktop/src/api/sessions.test.ts +++ b/desktop/src/api/sessions.test.ts @@ -277,3 +277,110 @@ describe('sessionsApi', () => { }) }) }) + +describe('full session history assembly', () => { + afterEach(() => vi.restoreAllMocks()) + + const page = (id: string, cursor: string | null, extra = {}) => ({ + messages: [{ id, type: 'assistant', content: id }], + taskNotifications: [{ taskId: id }], + page: { + nextCursor: cursor, + hasMore: cursor !== null, + historyComplete: false, + sourceVersion: 'fixture-version', + scannedBytes: 10, + omittedOversizedEntries: 0, + ...extra, + }, + }) + const response = (body: unknown, status = 200) => new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }) + + it('joins every raw page chronologically before returning and clears transport cursors', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(response(page('result', 'middle'))) + .mockResolvedValueOnce(response(page('call', 'oldest', { previousCursor: 'newer' }))) + .mockResolvedValueOnce(response(page('user', null, { previousCursor: 'middle' }))) + const result = await sessionsApi.getFullHistory('fixture') + expect(result.messages.map(message => message.id)).toEqual(['user', 'call', 'result']) + expect(result.taskNotifications?.map(notice => notice.taskId)).toEqual(['user', 'call', 'result']) + expect(result.page).toMatchObject({ nextCursor: null, hasMore: false, historyComplete: true, scannedBytes: 30 }) + expect(fetchMock.mock.calls.map(call => call[0])).toEqual([ + 'http://127.0.0.1:3456/api/sessions/fixture/messages?mode=full', + 'http://127.0.0.1:3456/api/sessions/fixture/messages?cursor=middle', + 'http://127.0.0.1:3456/api/sessions/fixture/messages?cursor=oldest', + ]) + }) + + it('keeps the single-request fast path', async () => { + const original = page('all', null, { historyComplete: true }) + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(response(original)) + expect(await sessionsApi.getFullHistory('fixture')).toEqual(original) + expect(fetchMock).toHaveBeenCalledOnce() + }) + + it.each([ + [{ omittedOversizedEntries: 2 }, { omittedOversizedEntries: 3 }, 5, false], + [{}, { contentTruncated: true }, 0, true], + ])('retains incomplete-content evidence after exhausting cursors', async (newestExtra, oldestExtra, omitted, truncated) => { + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(response(page('new', 'older', newestExtra))) + .mockResolvedValueOnce(response(page('old', null, oldestExtra))) + expect((await sessionsApi.getFullHistory('fixture')).page).toMatchObject({ + hasMore: false, historyComplete: false, omittedOversizedEntries: omitted, contentTruncated: truncated, + }) + }) + + it('rejects a changed source instead of returning a mixed transcript', async () => { + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(response(page('new', 'older'))) + .mockResolvedValueOnce(response(page('old', null, { sourceVersion: 'changed' }))) + await expect(sessionsApi.getFullHistory('fixture')).rejects.toThrow('Session history changed') + }) + + it('rejects a cursor cycle instead of looping forever', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(response(page('new', 'older'))) + .mockResolvedValueOnce(response(page('old', 'older'))) + await expect(sessionsApi.getFullHistory('fixture')).rejects.toThrow('cursor did not advance') + expect(fetchMock).toHaveBeenCalledTimes(2) + }) + + it('rejects a failed continuation without exposing its successfully loaded tail', async () => { + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(response(page('new', 'older'))) + .mockResolvedValueOnce(response({ error: 'HISTORY_CHANGED', message: 'Rewritten' }, 409)) + await expect(sessionsApi.getFullHistory('fixture')).rejects.toThrow() + }) + + it('forwards cancellation into an in-flight continuation', async () => { + const controller = new AbortController() + let continuationStarted!: () => void + const started = new Promise(resolve => { continuationStarted = resolve }) + const fetchMock = vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(response(page('new', 'older'))) + .mockImplementationOnce((_url, init) => new Promise((_resolve, reject) => { + expect(init?.signal).toBeDefined() + init?.signal?.addEventListener('abort', () => reject(controller.signal.reason), { once: true }) + continuationStarted() + })) + const request = sessionsApi.getFullHistory('fixture', { signal: controller.signal }) + await started + controller.abort() + await expect(request).rejects.toThrow() + expect(fetchMock).toHaveBeenCalledTimes(2) + }) + + it('honors cancellation before starting a continuation', async () => { + const controller = new AbortController() + const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementationOnce(async () => { + controller.abort() + return response(page('new', 'older')) + }) + await expect(sessionsApi.getFullHistory('fixture', { signal: controller.signal })).rejects.toThrow() + expect(fetchMock).toHaveBeenCalledOnce() + }) +}) diff --git a/desktop/src/api/sessions.ts b/desktop/src/api/sessions.ts index 2b44ff5b..8d7ee9a7 100644 --- a/desktop/src/api/sessions.ts +++ b/desktop/src/api/sessions.ts @@ -441,11 +441,50 @@ export const sessionsApi = { return api.get(`/api/sessions/project-history?${query.toString()}`, options) }, - // The timeline loads the whole transcript in one call. `mode=full` keeps the - // server's own byte budget but returns a single newest-first slice plus a - // `historyComplete` flag, so the UI never stitches page boundaries together. - getFullHistory(sessionId: string, options?: ApiRequestOptions) { - return api.get(`/api/sessions/${sessionId}/messages?mode=full`, options) + // The timeline receives one assembled transcript. Ordinary sessions fit in + // `mode=full`; larger sessions continue within the server's request budgets. + async getFullHistory(sessionId: string, options?: ApiRequestOptions): Promise { + const newest = await api.get(`/api/sessions/${sessionId}/messages?mode=full`, options) + if (!newest.page?.nextCursor) return newest + + // Transport budgets are page boundaries, not timeline boundaries. Join raw + // records before mapping so tool calls and results can pair across pages. + const pages = [newest] + const seen = new Set() + let cursor: string | null = newest.page.nextCursor + let omitted = newest.page.omittedOversizedEntries + let truncated = Boolean(newest.page.contentTruncated) + let scannedBytes = newest.page.scannedBytes + while (cursor) { + if (options?.signal?.aborted) throw options.signal.reason ?? new DOMException('Aborted', 'AbortError') + if (seen.has(cursor)) throw new Error('Session history cursor did not advance') + seen.add(cursor) + const query: URLSearchParams = new URLSearchParams({ cursor }) + const older: SessionHistoryPage = await api.get(`/api/sessions/${sessionId}/messages?${query}`, options) + if (options?.signal?.aborted) throw options.signal.reason ?? new DOMException('Aborted', 'AbortError') + if (!older.page || older.page.sourceVersion !== newest.page.sourceVersion) { + throw new Error('Session history changed while loading') + } + pages.push(older) + omitted += older.page.omittedOversizedEntries + truncated ||= Boolean(older.page.contentTruncated) + scannedBytes += older.page.scannedBytes + cursor = older.page.nextCursor + } + pages.reverse() + return { + messages: pages.flatMap(page => page.messages), + taskNotifications: pages.flatMap(page => page.taskNotifications ?? []), + page: { + ...newest.page, + nextCursor: null, + hasMore: false, + historyComplete: omitted === 0 && !truncated && !newest.page.previousCursor, + contentTruncated: truncated, + omittedOversizedEntries: omitted, + scannedBytes, + }, + } }, getHistoryPage(sessionId: string, page?: { cursor?: string }, options?: ApiRequestOptions) { diff --git a/desktop/src/stores/chatStore.test.ts b/desktop/src/stores/chatStore.test.ts index 57aa2938..64cfb243 100644 --- a/desktop/src/stores/chatStore.test.ts +++ b/desktop/src/stores/chatStore.test.ts @@ -15279,6 +15279,60 @@ describe('chatStore activity state survival across reload paths', () => { expect(session?.agentTaskNotifications).toEqual(notifications) }) + it.each(['loadHistory', 'reloadHistory'] as const)('keeps stopped live output and tool context when %s omits oversized records', async (method) => { + useChatStore.getState().disconnectSession(TEST_SESSION_ID) + const original: UIMessage[] = [ + { id: 'user', type: 'user_text', content: 'Read the file', timestamp: 1 }, + { id: 'tool', type: 'tool_use', toolUseId: 'read-1', toolName: 'Read', input: { file_path: '/fixture/large.txt' }, isPending: false, timestamp: 2 }, + { id: 'result', type: 'tool_result', toolUseId: 'read-1', content: 'file contents', isError: false, timestamp: 3 }, + ] + useChatStore.setState({ sessions: { [TEST_SESSION_ID]: makeSession({ + messages: original, historyHydrated: true, historyStatus: 'ready', + chatState: 'thinking', streamingText: 'Partial answer before stop', + }) } }) + useChatStore.getState().stopGeneration(TEST_SESSION_ID) + const stopped = useChatStore.getState().sessions[TEST_SESSION_ID]!.messages + vi.mocked(sessionsApi.getFullHistory).mockResolvedValueOnce({ + messages: [{ id: 'result', type: 'user', timestamp: new Date(3).toISOString(), content: [{ type: 'tool_result', tool_use_id: 'read-1', content: 'file contents' }] }], + page: { nextCursor: null, hasMore: false, historyComplete: false, sourceVersion: 'oversized-fixture', scannedBytes: 10_000_000, omittedOversizedEntries: 1 }, + }) + + await useChatStore.getState()[method](TEST_SESSION_ID) + + const session = useChatStore.getState().sessions[TEST_SESSION_ID]! + expect(session.chatState).toBe('idle') + expect(session.messages.map(message => message.type)).toEqual(stopped.map(message => message.type)) + expect(session.messages).toEqual(expect.arrayContaining([ + expect.objectContaining({ type: 'user_text', content: 'Read the file' }), + expect.objectContaining({ type: 'tool_use', toolUseId: 'read-1', toolName: 'Read' }), + expect.objectContaining({ type: 'assistant_text', content: 'Partial answer before stop' }), + ])) + expect(session.historyPage?.nextCursor).toBeNull() + expect(session.historyWindowed).toBe(true) + }) + + it('does not duplicate stopped text when repeatedly reloading incomplete history', async () => { + useChatStore.getState().disconnectSession(TEST_SESSION_ID) + useChatStore.setState({ sessions: { [TEST_SESSION_ID]: makeSession({ + historyHydrated: true, historyStatus: 'ready', chatState: 'thinking', streamingText: 'Stopped answer', + messages: [{ id: 'user-live', type: 'user_text', content: 'Continue', timestamp: 1 }], + }) } }) + useChatStore.getState().stopGeneration(TEST_SESSION_ID) + vi.mocked(sessionsApi.getFullHistory).mockResolvedValue({ + messages: [ + { id: 'user-disk', type: 'user', content: 'Continue', timestamp: new Date(1).toISOString() }, + { id: 'answer-disk', type: 'assistant', content: 'Stopped answer', timestamp: new Date(2).toISOString() }, + ], + page: { nextCursor: null, hasMore: false, historyComplete: false, sourceVersion: 'omitted-earlier-tool', scannedBytes: 10, omittedOversizedEntries: 1 }, + }) + await useChatStore.getState().reloadHistory(TEST_SESSION_ID) + await useChatStore.getState().reloadHistory(TEST_SESSION_ID) + expect(useChatStore.getState().sessions[TEST_SESSION_ID]!.messages).toMatchObject([ + { type: 'user_text', content: 'Continue', transcriptMessageId: 'user-disk' }, + { type: 'assistant_text', content: 'Stopped answer', transcriptMessageId: 'answer-disk' }, + ]) + }) + it('shows a bounded page immediately and restores state independently without treating the tail as authoritative', async () => { const page = { nextCursor: 'older', hasMore: true, historyComplete: false, sourceVersion: 'v1', scannedBytes: 1024, omittedOversizedEntries: 0 } vi.mocked(sessionsApi.getFullHistory).mockResolvedValueOnce({ messages: [{ id: 'tail', type: 'assistant', content: 'recent', timestamp: '2026-01-01T00:00:00Z' }], page }) diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts index 1aa4bb24..fcebfcef 100644 --- a/desktop/src/stores/chatStore.ts +++ b/desktop/src/stores/chatStore.ts @@ -4102,7 +4102,19 @@ export const useChatStore = create((setState, get) => { !requestedGoalEventIds.has(message.id)) const tokenUsageChangedWhileLoading = session.tokenUsage !== requestedTokenUsage - const reloadedMessages = liveGoalEventsWhileLoading.length > 0 + // A bounded read may omit oversized records (including tool calls). + // It cannot authoritatively delete rows already received over the live + // connection when a stopped turn is reconciled with its transcript. + const reloadedMessages = !historyComplete + ? mergeColdRestoredHistoryIntoLiveMessages( + uiMessages, + session.messages === sessionAtFetchStart?.messages + ? dropDuplicateTranscriptTextMessages( + mergeRestoredTranscriptMessageIds(session.messages, uiMessages), + ) + : session.messages, + ) + : liveGoalEventsWhileLoading.length > 0 ? mergeColdRestoredHistoryIntoLiveMessages( uiMessages, liveGoalEventsWhileLoading,