diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/resume-structural-parts.test.ts b/apps/desktop/src/app/session/hooks/use-session-actions/resume-structural-parts.test.ts new file mode 100644 index 000000000000..1ef5376d8d96 --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-session-actions/resume-structural-parts.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest' + +import type { ChatMessage } from '@/lib/chat-messages' + +import { reconcileResumeMessages } from './utils' + +const user = (id: string, text: string): ChatMessage => ({ + id, + parts: [{ type: 'text', text }], + role: 'user' +}) + +/** + * Switching away from a running session and back re-hydrates it from + * `session.activate`, whose transcript is TEXT-ONLY for the live turn (the + * gateway's `inflight` projection carries `user`/`assistant` strings, nothing + * structural). The renderer's cached state is the only carrier of the turn's + * tool calls and reasoning, so reconcile must not drop them. + */ +describe('reconcileResumeMessages — structural parts on a mid-turn switch', () => { + it('keeps tool-call parts the authoritative text-only row cannot carry', () => { + const cached: ChatMessage[] = [ + user('u1', 'read the config'), + { + id: 'a1', + parts: [ + { type: 'reasoning', text: 'I should read the file first.' }, + { type: 'tool-call', toolCallId: 'call-1', toolName: 'read_file', result: 'contents' }, + { type: 'text', text: 'Reading it now' } + ], + role: 'assistant' + } + ] + + // What activate returns mid-turn: the same rows, but flattened to text and + // one delta further along, so the text no longer matches the cached copy. + const authoritative: ChatMessage[] = [ + user('u1', 'read the config'), + { id: 'a1', parts: [{ type: 'text', text: 'Reading it now — found the key' }], role: 'assistant' } + ] + + const [, assistant] = reconcileResumeMessages(authoritative, cached) + + expect(assistant.parts.filter(p => p.type === 'tool-call')).toHaveLength(1) + expect(assistant.parts.filter(p => p.type === 'reasoning')).toHaveLength(1) + // The newer authoritative text still wins. + expect(assistant.parts.filter(p => p.type === 'text').at(-1)).toMatchObject({ + text: 'Reading it now — found the key' + }) + }) + + it('does not duplicate tool calls the authoritative row already carries', () => { + const cached: ChatMessage[] = [ + { + id: 'a1', + parts: [{ type: 'tool-call', toolCallId: 'call-1', toolName: 'read_file', result: 'contents' }], + role: 'assistant' + } + ] + + const authoritative: ChatMessage[] = [ + { + id: 'a1', + parts: [ + { type: 'tool-call', toolCallId: 'call-1', toolName: 'read_file', result: 'contents' }, + { type: 'text', text: 'done' } + ], + role: 'assistant' + } + ] + + const [assistant] = reconcileResumeMessages(authoritative, cached) + + expect(assistant.parts.filter(p => p.type === 'tool-call')).toHaveLength(1) + }) +}) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts index 39ba0bd911c6..f06069ca75fd 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts @@ -46,14 +46,40 @@ function withAppendedText(message: ChatMessage, suffix: string): ChatMessage { return appended ? { ...message, parts } : message } -function preserveReasoningParts(message: ChatMessage, previous: ChatMessage): ChatMessage { - if (message.parts.some(part => part.type === 'reasoning')) { +/** + * Carry structural parts an authoritative row cannot express. + * + * A live turn's authoritative projection is TEXT-ONLY: the gateway's `inflight` + * snapshot carries `user`/`assistant` strings, and history is not committed + * until the turn finishes. The renderer's cached state is therefore the sole + * carrier of the running turn's reasoning and tool calls, so switching threads + * mid-turn and back re-hydrated an assistant row stripped of both — the turn + * looked inert, with no thinking trace and no tool activity. + * + * Preserved only when the rows are the SAME turn: identical text, or the + * authoritative text extending the cached one (another delta landed). Anything + * else may be a different turn at the same role ordinal — compression rewrites + * history — and must not inherit foreign parts. Tool calls dedupe on + * `toolCallId` so a row that already carries them is left alone. + */ +function preserveStructuralParts(message: ChatMessage, previous: ChatMessage): ChatMessage { + const carried = previous.parts.filter(part => part.type === 'reasoning' || part.type === 'tool-call') + + if (!carried.length) { return message } - const reasoningParts = previous.parts.filter(part => part.type === 'reasoning') + const hasReasoning = message.parts.some(part => part.type === 'reasoning') - return reasoningParts.length ? { ...message, parts: [...reasoningParts, ...message.parts] } : message + const presentToolCallIds = new Set( + message.parts.flatMap(part => (part.type === 'tool-call' ? [part.toolCallId] : [])) + ) + + const missing = carried.filter(part => + part.type === 'reasoning' ? !hasReasoning : !presentToolCallIds.has(part.toolCallId) + ) + + return missing.length ? { ...message, parts: [...missing, ...message.parts] } : message } // Compile-time exhaustiveness guards. If a new field is added to ChatMessage @@ -209,12 +235,29 @@ export function reconcileResumeMessages(nextMessages: ChatMessage[], previousMes const previousVisibleText = textWithoutEmbeddedImages(previousText) let preserved = message - if (nextText === previousVisibleText || nextText === previousText.trim()) { - preserved = preserveReasoningParts(preserved, previous) + const sameText = nextText === previousVisibleText || nextText === previousText.trim() - if (message.role === 'user' && preserved.attachmentRefs === undefined && previous.attachmentRefs?.length) { - preserved = { ...preserved, attachmentRefs: [...previous.attachmentRefs] } - } + // Mid-turn, the authoritative text has advanced past the cached copy by one + // or more deltas. That is still the same turn, and the cached row holds the + // only copy of its reasoning / tool calls, so treat an extension as a match + // for structural carry-over. Attachment refs and image re-appending stay on + // the strict equality path — they reconcile a SETTLED row, and a growing + // row is by definition not settled. + const sameTurn = + sameText || + (nextText.length > 0 && previousVisibleText.length > 0 && nextText.startsWith(previousVisibleText.trim())) + + if (sameTurn) { + preserved = preserveStructuralParts(preserved, previous) + } + + if ( + sameText && + message.role === 'user' && + preserved.attachmentRefs === undefined && + previous.attachmentRefs?.length + ) { + preserved = { ...preserved, attachmentRefs: [...previous.attachmentRefs] } } const previousImages = embeddedImageUrls(previousText)