From 8d6be96e2863269c92a90d57cc6b6feee0c2ecc1 Mon Sep 17 00:00:00 2001 From: Lucas D'Alessandro Date: Thu, 9 Jul 2026 08:02:36 -0400 Subject: [PATCH 1/2] =?UTF-8?q?fix(desktop):=20honor=20display.interim=5Fa?= =?UTF-8?q?ssistant=5Fmessages=20=E2=80=94=20stop=20collapsing=20mid-turn?= =?UTF-8?q?=20narration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Desktop collapsed a multi-step turn to only the final message on message.complete, dropping every interim assistant text segment streamed between tool calls. The text is persisted and reappears on reload, and every other surface (messaging gateway, Ink TUI, Desktop's own reload path) keeps it — Desktop's live-completion path was the sole outlier and never consulted the existing display.interim_assistant_messages setting. Wire the setting through to completion (default true) and extract the merge into a pure, tested mergeFinalAssistantText() that keeps interim narration and upgrades the trailing segment in place, matching the TUI/reload contract. Also clarify the interim_assistant_messages comments in config.py and cli-config.yaml.example (comment-only; no default change). Salvaged from #61447 (lucasfdale). Fixes #61822. Fixes #54905. Fixes #61297. --- .../app/session/hooks/use-hermes-config.ts | 6 +- .../session/hooks/use-message-stream/index.ts | 33 +---- .../interim-narration.test.tsx | 126 ++++++++++++++++++ apps/desktop/src/lib/chat-messages.test.ts | 78 +++++++++++ apps/desktop/src/lib/chat-messages.ts | 99 +++++++++++++- apps/desktop/src/store/session.ts | 8 ++ apps/desktop/src/types/hermes.ts | 1 + cli-config.yaml.example | 13 +- hermes_cli/config.py | 2 +- 9 files changed, 330 insertions(+), 36 deletions(-) create mode 100644 apps/desktop/src/app/session/hooks/use-message-stream/interim-narration.test.tsx diff --git a/apps/desktop/src/app/session/hooks/use-hermes-config.ts b/apps/desktop/src/app/session/hooks/use-hermes-config.ts index 8c3cbac65a38..7e61ef4d6dd0 100644 --- a/apps/desktop/src/app/session/hooks/use-hermes-config.ts +++ b/apps/desktop/src/app/session/hooks/use-hermes-config.ts @@ -11,7 +11,8 @@ import { setCurrentPersonality, setCurrentReasoningEffort, setCurrentServiceTier, - setIntroPersonality + setIntroPersonality, + setKeepInterimAssistantMessages } from '@/store/session' import { applyAutoSpeakFromConfig } from '@/store/voice-prefs' @@ -87,6 +88,9 @@ export function useHermesConfig({ activeSessionIdRef, refreshProjectBranch }: He setVoiceMaxRecordingSeconds(recordingLimit(config.voice?.max_recording_seconds)) setSttEnabled(config.stt?.enabled !== false) + // Default true — only an explicit `false` opts into the lean (collapse + // interim narration) view, matching the backend display-config default. + setKeepInterimAssistantMessages(config.display?.interim_assistant_messages !== false) applyAutoSpeakFromConfig(config) } catch { // Config is nice-to-have; chat still works without it. diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/index.ts b/apps/desktop/src/app/session/hooks/use-message-stream/index.ts index 65a203a215eb..c2cd9ba73386 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/index.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/index.ts @@ -10,17 +10,15 @@ import { type ChatMessagePart, chatMessageText, type GatewayEventPayload, + mergeFinalAssistantText, reasoningPart, renderMediaTags, upsertToolPart } from '@/lib/chat-messages' -import { - dedupeGeneratedImageEchoesInParts, - generatedImageEchoSources, - stripGeneratedImageEchoes -} from '@/lib/generated-images' +import { dedupeGeneratedImageEchoesInParts } from '@/lib/generated-images' import { parseTodos } from '@/lib/todos' import { dispatchNativeNotification } from '@/store/native-notifications' +import { $keepInterimAssistantMessages } from '@/store/session' import { broadcastSessionsChanged } from '@/store/session-sync' import { upsertSubagent } from '@/store/subagents' import { setSessionTodos } from '@/store/todos' @@ -354,28 +352,7 @@ export function useMessageStream({ const streamId = state.streamId const finalText = renderMediaTags(text).trim() const completionError = completionErrorText(finalText) - const normalize = (value: string) => value.replace(/\s+/g, ' ').trim() - - const replaceTextPart = (parts: ChatMessagePart[]) => { - const visibleFinalText = stripGeneratedImageEchoes(finalText, generatedImageEchoSources(parts)).trim() - const dedupeReference = normalize(visibleFinalText) - - const kept = parts.filter(part => { - if (part.type === 'text') { - return false - } - - if (part.type !== 'reasoning' || !dedupeReference) { - return true - } - - const r = normalize(part.text) - - return !(r && (dedupeReference.startsWith(r) || r.startsWith(dedupeReference))) - }) - - return visibleFinalText ? [...kept, assistantTextPart(visibleFinalText)] : kept - } + const keepInterim = $keepInterimAssistantMessages.get() const completeMessage = (message: ChatMessage): ChatMessage => completionError @@ -387,7 +364,7 @@ export function useMessageStream({ } : { ...message, - parts: replaceTextPart(message.parts), + parts: mergeFinalAssistantText(message.parts, finalText, keepInterim), pending: false } diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/interim-narration.test.tsx b/apps/desktop/src/app/session/hooks/use-message-stream/interim-narration.test.tsx new file mode 100644 index 000000000000..15f7908095b7 --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-message-stream/interim-narration.test.tsx @@ -0,0 +1,126 @@ +import { QueryClient } from '@tanstack/react-query' +import { act, cleanup, render, waitFor } from '@testing-library/react' +import { useEffect, useRef } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { ClientSessionState } from '@/app/types' +import { createClientSessionState } from '@/lib/chat-runtime' +import { $keepInterimAssistantMessages, setKeepInterimAssistantMessages } from '@/store/session' +import type { RpcEvent } from '@/types/hermes' + +import { useMessageStream } from './index' + +// Integration test for the interim-narration fix: drive the REAL hook through a +// multi-step turn (delta → tool → delta → complete) via handleGatewayEvent and +// assert the mid-turn narration survives on the finalized message parts. This +// exercises the wiring (the $keepInterimAssistantMessages atom + its read in +// completeAssistantMessage) that the pure mergeFinalAssistantText unit tests in +// lib/chat-messages.test.ts cannot cover. + +const SID = 'session-1' + +let handleEvent: ((event: RpcEvent) => void) | null = null +let readState: (() => ClientSessionState | undefined) | null = null + +function Harness() { + const activeSessionIdRef = useRef(SID) + const sessionStateByRuntimeIdRef = useRef(new Map()) + const queryClientRef = useRef(new QueryClient()) + + const stream = useMessageStream({ + activeSessionIdRef, + hydrateFromStoredSession: vi.fn(async () => undefined), + queryClient: queryClientRef.current, + refreshHermesConfig: vi.fn(async () => undefined), + refreshSessions: vi.fn(async () => undefined), + sessionStateByRuntimeIdRef, + updateSessionState: (sessionId, updater) => { + const current = sessionStateByRuntimeIdRef.current.get(sessionId) ?? createClientSessionState() + const next = updater(current) + sessionStateByRuntimeIdRef.current.set(sessionId, next) + + return next + } + }) + + useEffect(() => { + handleEvent = stream.handleGatewayEvent + readState = () => sessionStateByRuntimeIdRef.current.get(SID) + }, [stream.handleGatewayEvent]) + + return null +} + +async function mountStream() { + render() + await waitFor(() => expect(handleEvent).not.toBeNull()) +} + +function fire(event: RpcEvent) { + act(() => handleEvent!(event)) +} + +// Deltas are queued but flushed synchronously at tool.start and message.complete +// boundaries (flushQueuedDeltas is called in those handlers), so a multi-step +// turn resolves deterministically without advancing any timer. +function runMultiStepTurn() { + fire({ payload: {}, session_id: SID, type: 'message.start' }) + fire({ payload: { text: 'Let me check the repo.' }, session_id: SID, type: 'message.delta' }) + fire({ payload: { name: 'terminal', tool_id: 'tc-1' }, session_id: SID, type: 'tool.start' }) + fire({ + payload: { name: 'terminal', tool_id: 'tc-1', result: { output: 'no ci', exit_code: 0 } }, + session_id: SID, + type: 'tool.complete' + }) + fire({ payload: { text: 'No CI here.' }, session_id: SID, type: 'message.delta' }) + fire({ payload: { text: 'No CI here.' }, session_id: SID, type: 'message.complete' }) +} + +function textPartTexts(state: ClientSessionState | undefined): string[] { + const message = state?.messages.at(-1) + + return (message?.parts ?? []) + .filter((p): p is { type: 'text'; text: string } => p.type === 'text') + .map(p => p.text) +} + +function partTypes(state: ClientSessionState | undefined): string[] { + return (state?.messages.at(-1)?.parts ?? []).map(p => p.type) +} + +describe('useMessageStream interim narration on completion', () => { + const initialKeepInterim = $keepInterimAssistantMessages.get() + + beforeEach(() => { + handleEvent = null + readState = null + }) + + afterEach(() => { + cleanup() + setKeepInterimAssistantMessages(initialKeepInterim) + vi.restoreAllMocks() + }) + + it('keeps mid-turn narration when interim_assistant_messages is on (default)', async () => { + setKeepInterimAssistantMessages(true) + await mountStream() + + runMultiStepTurn() + + expect(partTypes(readState!())).toEqual(['text', 'tool-call', 'text']) + expect(textPartTexts(readState!())).toEqual(['Let me check the repo.', 'No CI here.']) + }) + + it('collapses to the final message when interim_assistant_messages is off', async () => { + setKeepInterimAssistantMessages(false) + await mountStream() + + runMultiStepTurn() + + // Lean mode: the interim "Let me check the repo." narration is dropped; only + // the tool part and the final text survive. + expect(partTypes(readState!())).toEqual(['tool-call', 'text']) + expect(textPartTexts(readState!())).toEqual(['No CI here.']) + }) +}) diff --git a/apps/desktop/src/lib/chat-messages.test.ts b/apps/desktop/src/lib/chat-messages.test.ts index 44a6915fed7e..4f7e025d182d 100644 --- a/apps/desktop/src/lib/chat-messages.test.ts +++ b/apps/desktop/src/lib/chat-messages.test.ts @@ -5,6 +5,7 @@ import { appendAssistantTextPart, appendReasoningPart, chatMessageText, + mergeFinalAssistantText, preserveLocalAssistantErrors, renderMediaTags, toChatMessages, @@ -785,3 +786,80 @@ describe('upsertToolPart', () => { }) }) }) + +describe('mergeFinalAssistantText', () => { + const textParts = (parts: ChatMessagePart[]) => + parts.filter((p): p is Extract => p.type === 'text').map(p => p.text) + + describe('keepInterim = true (default / comprehensive)', () => { + it('keeps interim narration and upgrades the trailing segment in place', () => { + // text → tool → text, final text restates only the LAST segment. + let parts: ChatMessagePart[] = appendAssistantTextPart([], 'Let me check the repo.') + parts = upsertToolPart(parts, { name: 'terminal', tool_id: 'tc-1' }, 'running') + parts = appendAssistantTextPart(parts, 'No CI here.') + + const merged = mergeFinalAssistantText(parts, 'No CI here.', true) + + expect(merged.map(p => p.type)).toEqual(['text', 'tool-call', 'text']) + expect(textParts(merged)).toEqual(['Let me check the repo.', 'No CI here.']) + }) + + it('appends the final text as a new part when it does not restate the last streamed segment', () => { + let parts: ChatMessagePart[] = appendAssistantTextPart([], 'Working on it.') + parts = upsertToolPart(parts, { name: 'terminal', tool_id: 'tc-1' }, 'running') + + // No trailing text part after the tool; final text is genuinely new. + const merged = mergeFinalAssistantText(parts, 'All done.', true) + + expect(merged.map(p => p.type)).toEqual(['text', 'tool-call', 'text']) + expect(textParts(merged)).toEqual(['Working on it.', 'All done.']) + }) + + it('drops reasoning the final text restates but keeps interim narration', () => { + let parts: ChatMessagePart[] = appendReasoningPart([], 'The answer is 42.') + parts = appendAssistantTextPart(parts, 'Intermediate note.') + parts = upsertToolPart(parts, { name: 'terminal', tool_id: 'tc-1' }, 'running') + + const merged = mergeFinalAssistantText(parts, 'The answer is 42.', true) + + // reasoning that the final text restates is dropped; interim text stays; + // the new final text is appended. + expect(merged.some(p => p.type === 'reasoning')).toBe(false) + expect(textParts(merged)).toEqual(['Intermediate note.', 'The answer is 42.']) + }) + + it('returns kept parts unchanged when the final text is empty', () => { + let parts: ChatMessagePart[] = appendAssistantTextPart([], 'Only narration.') + parts = upsertToolPart(parts, { name: 'terminal', tool_id: 'tc-1' }, 'running') + + const merged = mergeFinalAssistantText(parts, '', true) + + expect(textParts(merged)).toEqual(['Only narration.']) + }) + }) + + describe('keepInterim = false (lean / legacy collapse)', () => { + it('drops every interim text part and re-appends only the final text', () => { + let parts: ChatMessagePart[] = appendAssistantTextPart([], 'Let me check the repo.') + parts = upsertToolPart(parts, { name: 'terminal', tool_id: 'tc-1' }, 'running') + parts = appendAssistantTextPart(parts, 'No CI here.') + + const merged = mergeFinalAssistantText(parts, 'No CI here.', false) + + // Historical behavior: tool survives, all text collapses to one trailing part. + expect(merged.map(p => p.type)).toEqual(['tool-call', 'text']) + expect(textParts(merged)).toEqual(['No CI here.']) + }) + + it('keeps tool/reasoning parts the final text does not restate', () => { + let parts: ChatMessagePart[] = appendReasoningPart([], 'unrelated thought') + parts = appendAssistantTextPart(parts, 'chatter') + parts = upsertToolPart(parts, { name: 'terminal', tool_id: 'tc-1' }, 'running') + + const merged = mergeFinalAssistantText(parts, 'Final answer.', false) + + expect(merged.map(p => p.type)).toEqual(['reasoning', 'tool-call', 'text']) + expect(textParts(merged)).toEqual(['Final answer.']) + }) + }) +}) diff --git a/apps/desktop/src/lib/chat-messages.ts b/apps/desktop/src/lib/chat-messages.ts index 47ac077cd6c4..3c127ad94ea7 100644 --- a/apps/desktop/src/lib/chat-messages.ts +++ b/apps/desktop/src/lib/chat-messages.ts @@ -1,6 +1,10 @@ import type { ThreadMessageLike } from '@assistant-ui/react' -import { dedupeGeneratedImageEchoesInParts } from '@/lib/generated-images' +import { + dedupeGeneratedImageEchoesInParts, + generatedImageEchoSources, + stripGeneratedImageEchoes +} from '@/lib/generated-images' import { mediaDisplayLabel, mediaMarkdownHref } from '@/lib/media' import { normalize } from '@/lib/text' import { parseTodos } from '@/lib/todos' @@ -257,6 +261,99 @@ export function appendAssistantTextPart(parts: ChatMessagePart[], delta: string) return next } +// Whitespace-collapse WITHOUT lowercasing. Deliberately NOT the module's +// imported `normalize` (which lowercases): this must match the streamed text +// byte-for-byte modulo whitespace so the final-vs-streamed prefix test is +// case-sensitive, matching the historical completion-merge behavior. +function normalizeWhitespace(value: string): string { + return value.replace(/\s+/g, ' ').trim() +} + +/** Merge the turn's final text (`message.complete.text` == the agent's LAST + * assistant segment) into the streamed parts. + * + * `keepInterim` mirrors `display.interim_assistant_messages` (default true). + * When true, earlier narration streamed between tool calls is preserved and + * only the trailing streamed text segment — the one the final text restates — + * is upgraded in place (re-rendered + image-echo-stripped). This matches what + * the reload path (`toChatMessages`) and the Ink TUI (`recordMessageComplete`) + * already produce. When false, every text part is dropped and the final text + * is re-appended as one trailing part (the legacy lean view). + * + * Reasoning parts that the final text restates are always dropped (unchanged + * from the original behavior); other non-text parts (tools, images) are kept. */ +export function mergeFinalAssistantText( + parts: ChatMessagePart[], + finalText: string, + keepInterim: boolean +): ChatMessagePart[] { + const visibleFinalText = stripGeneratedImageEchoes(finalText, generatedImageEchoSources(parts)).trim() + const dedupeReference = normalizeWhitespace(visibleFinalText) + + const restatesReference = (candidate: string): boolean => { + const r = normalizeWhitespace(candidate) + + return Boolean(r) && (dedupeReference.startsWith(r) || r.startsWith(dedupeReference)) + } + + // Lean mode: drop ALL text parts, drop reasoning the final text restates, + // keep everything else, then re-append the final text. Byte-identical to the + // pre-fix behavior. + if (!keepInterim) { + const kept = parts.filter(part => { + if (part.type === 'text') { + return false + } + + if (part.type !== 'reasoning' || !dedupeReference) { + return true + } + + return !restatesReference((part as { text: string }).text) + }) + + return visibleFinalText ? [...kept, assistantTextPart(visibleFinalText)] : kept + } + + // Comprehensive mode: keep interim narration. Still drop reasoning the final + // text restates, but preserve every text part. + const kept = parts.filter(part => { + if (part.type !== 'reasoning' || !dedupeReference) { + return true + } + + return !restatesReference((part as { text: string }).text) + }) + + if (!visibleFinalText) { + return kept + } + + const rendered = assistantTextPart(visibleFinalText) + + // Find the last streamed text part. The final text is the agent's last + // segment, so it either equals (or is a prefix/extension of) that trailing + // streamed part — upgrade it in place — or it's genuinely new text to append. + let lastTextIndex = -1 + + for (let i = kept.length - 1; i >= 0; i--) { + if (kept[i].type === 'text') { + lastTextIndex = i + + break + } + } + + if (lastTextIndex >= 0 && restatesReference((kept[lastTextIndex] as { text: string }).text)) { + const next = [...kept] + next[lastTextIndex] = rendered + + return next + } + + return [...kept, rendered] +} + export function hasToolPart(message: ChatMessage): boolean { return message.parts.some(part => part.type === 'tool-call') } diff --git a/apps/desktop/src/store/session.ts b/apps/desktop/src/store/session.ts index 2be408530541..945cc37c094a 100644 --- a/apps/desktop/src/store/session.ts +++ b/apps/desktop/src/store/session.ts @@ -265,6 +265,11 @@ export const $currentProvider = atom(storedString(COMPOSER_PROVIDER_KEY) ?? '') export const $currentReasoningEffort = atom(storedString(COMPOSER_EFFORT_KEY) ?? '') export const $currentServiceTier = atom('') export const $currentFastMode = atom(storedBoolean(COMPOSER_FAST_KEY, false)) +// Mirrors `display.interim_assistant_messages` (default true). When true, the +// live completion path keeps mid-turn narration streamed between tool calls +// instead of collapsing the bubble to only the final message. Reflects backend +// config (no local persistence) — set from useHermesConfig on config refresh. +export const $keepInterimAssistantMessages = atom(true) // Effective approval-bypass state mirrored from the gateway (session.info). // Persistence lives in the backend config (approvals.mode), so this is a plain // reflection of the truth the gateway reports rather than its own store. @@ -331,6 +336,9 @@ export const setCurrentFastMode = (next: Updater) => { persistBoolean(COMPOSER_FAST_KEY, $currentFastMode.get()) } +export const setKeepInterimAssistantMessages = (next: Updater) => + updateAtom($keepInterimAssistantMessages, next) + export const setYoloActive = (next: Updater) => updateAtom($yoloActive, next) export const setCurrentCwd = (next: Updater) => { diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index 63ac7f9de403..f7ec963f95f0 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -209,6 +209,7 @@ export interface HermesConfig { display?: { personality?: string skin?: string + interim_assistant_messages?: boolean } terminal?: { cwd?: string diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 8b0769ead3a7..364669d3412c 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -1105,11 +1105,14 @@ display: # cleanup_progress: true cleanup_progress: false - # Gateway-only natural mid-turn assistant updates. - # When true, completed assistant status messages are sent as separate chat - # messages. This is independent of tool_progress and gateway streaming. - # true: Send mid-turn assistant updates as separate messages (default) - # false: Only send the final response + # Natural mid-turn assistant updates. + # On gateway platforms, when true, completed assistant status messages are + # sent as separate chat messages. On the Desktop app, when true, mid-turn + # assistant narration streamed between tool calls is kept in the transcript + # instead of the bubble collapsing to only the final message on completion. + # Independent of tool_progress and gateway streaming. + # true: Keep/send mid-turn assistant updates (default) + # false: Only keep/send the final response interim_assistant_messages: true # Gateway-only long-running status heartbeats. diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 531d419ab809..fe2ef5defaea 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1758,7 +1758,7 @@ def _ensure_hermes_home_managed(home: Path): "first_lines": 2, "last_lines": 2, }, - "interim_assistant_messages": True, # Gateway: show natural mid-turn assistant status messages + "interim_assistant_messages": True, # Gateway: send natural mid-turn assistant status messages. Desktop: keep mid-turn narration between tool calls instead of collapsing to the final message. "tool_progress_command": False, # Enable /verbose command in messaging gateway "tool_progress_overrides": {}, # DEPRECATED — use display.platforms instead "tool_preview_length": 0, # Max chars for tool call previews (0 = no limit, show full paths/commands) From 5a84dc00797255860e9e10cacc6bfd9a1a672f6a Mon Sep 17 00:00:00 2001 From: giggling-ginger <110955495+giggling-ginger@users.noreply.github.com> Date: Fri, 10 Jul 2026 09:59:59 +0000 Subject: [PATCH 2/2] chore(release): map lucasfdale in AUTHOR_MAP for #61447 salvage --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index e47302a77103..af68ec92ef7a 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -45,6 +45,7 @@ # Auto-extracted from noreply emails + manual overrides AUTHOR_MAP = { + "lucasfdale@gmail.com": "lucasfdale", # PR #61447 salvage (desktop: honor display.interim_assistant_messages — stop collapsing mid-turn narration on message.complete; #61822/#54905/#61297) "jonathan@mintrx.com": "JAlmanzarMint", # PR #52688 salvage (vision: rasterize SVG / re-encode unsupported raster formats to PNG before embedding), folded into #57890 "al3060388206@gmail.com": "ooiuuii", # PR #58466/#58377 salvage (redact: fireworks fw-/fpk_ prefixes; telegram: redact bot tokens out of transport error strings). Also PR #58433 salvage (codex: accept recorded final_text when app-server omits turn/completed). "Jigoooo@users.noreply.github.com": "Jigoooo", # PR #58474 salvage (auxiliary: fall back to token resolver when anthropic pool has no usable entry)