fix(desktop): preserve stopped chat across incomplete history reloads

This commit is contained in:
程序员阿江(Relakkes)
2026-09-24 01:18:51 +08:00
parent e37ad30922
commit 4615f9d435
4 changed files with 218 additions and 6 deletions
+107
View File
@@ -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<void>(resolve => { continuationStarted = resolve })
const fetchMock = vi.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(response(page('new', 'older')))
.mockImplementationOnce((_url, init) => new Promise<Response>((_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()
})
})
+44 -5
View File
@@ -441,11 +441,50 @@ export const sessionsApi = {
return api.get<ProjectSessionHistoryResponse>(`/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<SessionHistoryPage>(`/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<SessionHistoryPage> {
const newest = await api.get<SessionHistoryPage>(`/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<string>()
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<SessionHistoryPage>(`/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) {
+54
View File
@@ -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 })
+13 -1
View File
@@ -4102,7 +4102,19 @@ export const useChatStore = create<ChatStore>((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,