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 c6829420a1de..ba79674a93f3 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' @@ -258,6 +262,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 9133fb4dfbf6..951468af985b 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 69acc6868057..8d71b6bfa971 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -1124,11 +1124,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 981533f15e7e..1178c3a3e073 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1856,7 +1856,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) diff --git a/scripts/release.py b/scripts/release.py index 0e64556640be..c3fdb2b54568 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) "humphreysun98@gmail.com": "HumphreySun98", # PR #61142 salvage (web: null web/backend config value guards) "sonxi@nous.local": "17324393074", # PR #53196 salvage (tools_config: known_plugin_toolsets null guard; commit under unlinked local identity) "lemonwan@users.noreply.github.com": "lemonwan", # PR #59430 sibling salvage (adapter reconnect contract guard)