mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-27 17:58:07 +00:00
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:
parent
42a30d13db
commit
6b273f419a
2 changed files with 128 additions and 9 deletions
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue