fix(desktop): keep thinking traces and tool calls across a mid-turn switch

preserveReasoningParts was gated on exact text equality with the cached row.
Mid-turn the authoritative text has advanced by a delta or two, so the guard
fails and the row is rebuilt from the gateway's inflight projection — which
is text-only. The renderer's cache is the sole carrier of a running turn's
structure, so switching away and back stripped the reasoning and every tool
call, leaving the turn looking inert.

Carry tool calls alongside reasoning, dedupe them on toolCallId, and match on
same-turn (identical text, or authoritative text extending the cached text)
rather than strict equality. Attachment refs and image re-appending stay on
the strict path: those reconcile a settled row, and a growing row is by
definition not settled.
This commit is contained in:
Brooklyn Nicholson 2026-07-26 18:39:11 -05:00
parent 42a30d13db
commit 6b273f419a
2 changed files with 128 additions and 9 deletions

View file

@ -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)
})
})

View file

@ -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)