fix(desktop): honor display.interim_assistant_messages — stop collapsing mid-turn narration - #61447
lucasfdale wants to merge 4 commits into
Conversation
|
This PR also fixes #61297 (same root cause — interim narration collapsing). Mentioning here for cross-reference. |
|
Thanks for the cross-reference — confirmed, #61297 is the same root cause (Desktop's live-completion path dropping every interim segment and keeping only Worth noting the report also ticks CLI (interactive chat) as a component, but the Ink TUI's |
tonydwb
left a comment
There was a problem hiding this comment.
Code Review Summary
Verdict: LGTM
What the PR Does
Honor display.interim_assistant_messages configuration, stopping mid-turn narration from being collapsed.
Assessment
- Small config honoring fix.
Reviewed by Hermes Agent
Review note / issue linkageThis also fixes #61822 (same Desktop root cause). Why #61822 maps here
Please add Sanity check done against current main
Scope caveat (not a blocker)
I closed the accidental salvage duplicate #61997 in favor of this original PR. |
7a24421 to
627f442
Compare
|
Thanks for the thorough triage — much appreciated. Done:
Thanks also for closing the #61997 salvage dup in favor of this one. |
teknium1
left a comment
There was a problem hiding this comment.
Thanks for isolating a confirmed Desktop completion-path defect. The current main base removes every streamed text part on completion in apps/desktop/src/app/session/hooks/use-message-stream/index.ts:359-377, so preserving interim narration is the right direction.
Problems
apps/desktop/src/lib/chat-messages.ts:340-352scans backward across tool boundaries. Fortext("same") → tool-call → message.complete("same"), it replaces the pre-tool part at line 350 instead of appending the terminal final response after the tool. This can lose the final segment's position whenever final text was not streamed and happens to match earlier narration.
Suggested changes
- Limit replacement to text in the terminal segment after the latest tool/non-stream part; otherwise append. Add the matching-text-after-tool regression case.
- Add a
use-hermes-configtest for explicitdisplay.interim_assistant_messages: falseand the omitted-key default.
Automated hermes-sweeper review.
| // 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--) { |
There was a problem hiding this comment.
This reverse scan crosses tool-call boundaries. With [text('same'), tool-call] and finalText === 'same', line 350 replaces the pre-tool narration rather than appending the terminal final segment after the tool. Stop at the final segment boundary (or append when no post-tool text exists) and add that regression case.
627f442 to
6cfa043
Compare
|
Thanks for the precise review — both points fixed, and hardening the merge surfaced a related bug I fixed too. 1. Terminal-segment boundary ( 2. Config tests — added 3. Bonus fix from adversarial testing ( Kept as two commits so the requested boundary fix and the reasoning fix stay separable. Green on latest |
6cfa043 to
4ade180
Compare
…ts, Windows paths, mergeFinalAssistantText Address all remaining gaps found by auditing every superseded PR and issue: - Add interim_assistant_callback to _agent_cbs() (construction path) in tui_gateway/server.py, mirroring #63597's approach alongside our per-turn defense-in-depth wiring - Add use-hermes-config.test.ts tests for display.interim_assistant_messages atom (defaults true, explicit true/false, sibling-key default) from #61447 - Update hermes_cli/config.py + cli-config.yaml.example comments to document the Desktop behavior from #61447 - Add Windows backslash path fix to verification_evidence.py (_split_segment_tokens posix=False, _find_ad_hoc_match tries both) from #53553 - Extract mergeFinalAssistantText() as a pure function in chat-messages.ts and use it in both completeAssistantMessage and finalizeInterimAssistantMessage from #61447 — makes the reasoning dedup independently testable - Add chat-messages.test.ts tests for the reasoning dedup edge cases: short final doesn't swallow longer reasoning, full-coverage dedup works, non-restating reasoning is kept, empty final text handled - Fix interim-sealing test to not depend on delta flush timing Co-authored-by: Liam Zhang <yingliang-zhang@users.noreply.github.com> Co-authored-by: Lucas D'Alessandro <lucasfdale@users.noreply.github.com>
When the agent emits interim text (commentary alongside tool calls, or the
attempted final answer before a verify-on-stop nudge), all UI surfaces
streamed it live but then wiped it at message.complete — keeping only the
final response. The user saw text appear during inference, then disappear.
This is the complete fix across all three layers: agent core, gateway
transport, and all UI surfaces (desktop + Ink TUI).
## Agent core (verify-on-stop persistence)
The verify-on-stop and pre_verify paths flagged the assistant's attempted
final answer as _verification_stop_synthetic, suppressing it from both
state.db and the UI. The user only saw the terse post-verification reply.
Now the assistant response is real content: it's persisted to state.db and
emitted as an interim message via _emit_interim_assistant_message(force_display=True)
before the verification loop runs. Only the synthetic nudge messages keep
the synthetic flags. The turn finalizer drops nudges from live history and
compares content (not just role) to avoid duplicating a published candidate.
Message sequence repair collapses verification candidates in the
consecutive-assistant merge.
## Gateway transport (tui_gateway)
Wire agent.interim_assistant_callback both at construction (_agent_cbs())
and per-turn (defense-in-depth), emitting a new message.interim event with
{text, already_streamed}. Gated on display.interim_assistant_messages
(default true). Cleared in the finally block so a stale closure can't
fire on a later turn.
## Shared types
Add message.interim to the GatewayEventName union (apps/shared) and a
typed payload to the TUI's GatewayEvent discriminated union.
## Ink TUI support
The TUI already had the segment-anchoring machinery (flushStreamingSegment +
finalTail) but had no handler for message.interim. Added recordInterimMessage
+ interimBoundaryIndex to seal segments mid-turn, and updated
recordMessageComplete to only dedupe segments after the interim boundary.
## Desktop state machine
Replaced the fragile sealed-set approach with a proper interimBoundaryPending
state flag on ClientSessionState. finalizeInterimAssistantMessage finalizes
the streaming bubble in place (or creates a standalone one), rotates the
stream ID so next deltas create a new bubble, and sets the flag. When the
final text equals an already-sealed interim, they stay as distinct messages.
## Reasoning dedup fix (#61447)
Extracted mergeFinalAssistantText() as a pure function in chat-messages.ts,
used by both completeAssistantMessage and finalizeInterimAssistantMessage.
Split the bidirectional dedup predicate: reasoning is a restatement only when
the final FULLY covers it. A short final ("Done.") no longer swallows a
longer reasoning block that merely starts with it.
## Config gating (#61447)
Honor display.interim_assistant_messages (default true) across all layers:
the tui_gateway gates the callback, the desktop wires it to a nanostores
atom via use-hermes-config. Updated hermes_cli/config.py and
cli-config.yaml.example comments to document the Desktop behavior.
## Windows path fix (#53553)
_split_segment_tokens now accepts posix=False and _find_ad_hoc_match tries
both posix modes so ad-hoc verification scripts with Windows backslash
paths are matched correctly. (response_previewed forwarding from #53553
is not included — our emit-interim + persist approach makes it unnecessary
since the attempted answer is now surfaced before the verification loop.)
## Testing
- tsc: clean (desktop + TUI + shared)
- vitest desktop: 73/73 pass (7 interim-sealing + 5 mergeFinalAssistantText + 4 config atom)
- vitest TUI: 83/83 pass (4 new message.interim tests)
- python: 390 tests pass (340 tui_gateway + 33 verification/finalizer + 6 config gating + 3 evidence + 8 continuation budget)
Co-authored-by: Liam Zhang <yingliang-zhang@users.noreply.github.com>
Co-authored-by: Lucas D'Alessandro <lucasfdale@users.noreply.github.com>
Co-authored-by: Eric Manganaro <superposition@users.noreply.github.com>
Co-authored-by: sweetcornna <sweetcornna@users.noreply.github.com>
Co-authored-by: DECK6 <DECK6@users.noreply.github.com>
Co-authored-by: matantsevs <matantsevs@users.noreply.github.com>
Co-authored-by: gitcommit90 <gitcommit90@users.noreply.github.com>
When the agent emits interim text (commentary alongside tool calls, or the
attempted final answer before a verify-on-stop nudge), all UI surfaces
streamed it live but then wiped it at message.complete — keeping only the
final response. The user saw text appear during inference, then disappear.
This is the complete fix across all three layers: agent core, gateway
transport, and all UI surfaces (desktop + Ink TUI).
## Agent core (verify-on-stop persistence)
The verify-on-stop and pre_verify paths flagged the assistant's attempted
final answer as _verification_stop_synthetic, suppressing it from both
state.db and the UI. The user only saw the terse post-verification reply.
Now the assistant response is real content: it's persisted to state.db and
emitted as an interim message via _emit_interim_assistant_message(force_display=True)
before the verification loop runs. Only the synthetic nudge messages keep
the synthetic flags. The turn finalizer drops nudges from live history and
compares content (not just role) to avoid duplicating a published candidate.
Message sequence repair collapses verification candidates in the
consecutive-assistant merge.
## Gateway transport (tui_gateway)
Wire agent.interim_assistant_callback both at construction (_agent_cbs())
and per-turn (defense-in-depth), emitting a new message.interim event with
{text, already_streamed}. Gated on display.interim_assistant_messages
(default true). Cleared in the finally block so a stale closure can't
fire on a later turn.
## Shared types
Add message.interim to the GatewayEventName union (apps/shared) and a
typed payload to the TUI's GatewayEvent discriminated union.
## Ink TUI support
The TUI already had the segment-anchoring machinery (flushStreamingSegment +
finalTail) but had no handler for message.interim. Added recordInterimMessage
+ interimBoundaryIndex to seal segments mid-turn, and updated
recordMessageComplete to only dedupe segments after the interim boundary.
## Desktop state machine
Replaced the fragile sealed-set approach with a proper interimBoundaryPending
state flag on ClientSessionState. finalizeInterimAssistantMessage finalizes
the streaming bubble in place (or creates a standalone one), rotates the
stream ID so next deltas create a new bubble, and sets the flag. When the
final text equals an already-sealed interim, they stay as distinct messages.
## Reasoning dedup fix (#61447)
Extracted mergeFinalAssistantText() as a pure function in chat-messages.ts,
used by both completeAssistantMessage and finalizeInterimAssistantMessage.
Split the bidirectional dedup predicate: reasoning is a restatement only when
the final FULLY covers it. A short final ("Done.") no longer swallows a
longer reasoning block that merely starts with it.
## Config gating (#61447)
Honor display.interim_assistant_messages (default true) across all layers:
the tui_gateway gates the callback, the desktop wires it to a nanostores
atom via use-hermes-config. Updated hermes_cli/config.py and
cli-config.yaml.example comments to document the Desktop behavior.
## Windows path fix (#53553)
_split_segment_tokens now accepts posix=False and _find_ad_hoc_match tries
both posix modes so ad-hoc verification scripts with Windows backslash
paths are matched correctly. (response_previewed forwarding from #53553
is not included — our emit-interim + persist approach makes it unnecessary
since the attempted answer is now surfaced before the verification loop.)
## Testing
- tsc: clean (desktop + TUI + shared)
- vitest desktop: 73/73 pass (7 interim-sealing + 5 mergeFinalAssistantText + 4 config atom)
- vitest TUI: 83/83 pass (4 new message.interim tests)
- python: 390 tests pass (340 tui_gateway + 33 verification/finalizer + 6 config gating + 3 evidence + 8 continuation budget)
Co-authored-by: Liam Zhang <yingliang-zhang@users.noreply.github.com>
Co-authored-by: Lucas D'Alessandro <lucasfdale@users.noreply.github.com>
Co-authored-by: Eric Manganaro <superposition@users.noreply.github.com>
Co-authored-by: sweetcornna <sweetcornna@users.noreply.github.com>
Co-authored-by: DECK6 <DECK6@users.noreply.github.com>
Co-authored-by: matantsevs <matantsevs@users.noreply.github.com>
Co-authored-by: gitcommit90 <gitcommit90@users.noreply.github.com>
When the agent emits interim text (commentary alongside tool calls, or the
attempted final answer before a verify-on-stop nudge), all UI surfaces
streamed it live but then wiped it at message.complete — keeping only the
final response. The user saw text appear during inference, then disappear.
This is the complete fix across all three layers: agent core, gateway
transport, and all UI surfaces (desktop + Ink TUI).
## Agent core (verify-on-stop persistence)
The verify-on-stop and pre_verify paths flagged the assistant's attempted
final answer as _verification_stop_synthetic, suppressing it from both
state.db and the UI. The user only saw the terse post-verification reply.
Now the assistant response is real content: it's persisted to state.db and
emitted as an interim message via _emit_interim_assistant_message(force_display=True)
before the verification loop runs. Only the synthetic nudge messages keep
the synthetic flags. The turn finalizer drops nudges from live history and
compares content (not just role) to avoid duplicating a published candidate.
Message sequence repair collapses verification candidates in the
consecutive-assistant merge.
## Gateway transport (tui_gateway)
Wire agent.interim_assistant_callback both at construction (_agent_cbs())
and per-turn (defense-in-depth), emitting a new message.interim event with
{text, already_streamed}. Gated on display.interim_assistant_messages
(default true). Cleared in the finally block so a stale closure can't
fire on a later turn.
## Shared types
Add message.interim to the GatewayEventName union (apps/shared) and a
typed payload to the TUI's GatewayEvent discriminated union.
## Ink TUI support
The TUI already had the segment-anchoring machinery (flushStreamingSegment +
finalTail) but had no handler for message.interim. Added recordInterimMessage
+ interimBoundaryIndex to seal segments mid-turn, and updated
recordMessageComplete to only dedupe segments after the interim boundary.
## Desktop state machine
Replaced the fragile sealed-set approach with a proper interimBoundaryPending
state flag on ClientSessionState. finalizeInterimAssistantMessage finalizes
the streaming bubble in place (or creates a standalone one), rotates the
stream ID so next deltas create a new bubble, and sets the flag. When the
final text equals an already-sealed interim, they stay as distinct messages.
## Reasoning dedup fix (#61447)
Extracted mergeFinalAssistantText() as a pure function in chat-messages.ts,
used by both completeAssistantMessage and finalizeInterimAssistantMessage.
Split the bidirectional dedup predicate: reasoning is a restatement only when
the final FULLY covers it. A short final ("Done.") no longer swallows a
longer reasoning block that merely starts with it.
## Config gating (#61447)
Honor display.interim_assistant_messages (default true) across all layers:
the tui_gateway gates the callback, the desktop wires it to a nanostores
atom via use-hermes-config. Updated hermes_cli/config.py and
cli-config.yaml.example comments to document the Desktop behavior.
## Windows path fix (#53553)
_split_segment_tokens now accepts posix=False and _find_ad_hoc_match tries
both posix modes so ad-hoc verification scripts with Windows backslash
paths are matched correctly. (response_previewed forwarding from #53553
is not included — our emit-interim + persist approach makes it unnecessary
since the attempted answer is now surfaced before the verification loop.)
## Testing
- tsc: clean (desktop + TUI + shared)
- vitest desktop: 73/73 pass (7 interim-sealing + 5 mergeFinalAssistantText + 4 config atom)
- vitest TUI: 83/83 pass (4 new message.interim tests)
- python: 390 tests pass (340 tui_gateway + 33 verification/finalizer + 6 config gating + 3 evidence + 8 continuation budget)
Co-authored-by: Liam Zhang <yingliang-zhang@users.noreply.github.com>
Co-authored-by: Lucas D'Alessandro <lucasfdale@users.noreply.github.com>
Co-authored-by: Eric Manganaro <superposition@users.noreply.github.com>
Co-authored-by: sweetcornna <sweetcornna@users.noreply.github.com>
Co-authored-by: DECK6 <DECK6@users.noreply.github.com>
Co-authored-by: matantsevs <matantsevs@users.noreply.github.com>
Co-authored-by: gitcommit90 <gitcommit90@users.noreply.github.com>
…ing mid-turn narration 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). Fixes NousResearch#54905.
The completion merge scanned backward across tool boundaries for a text part
matching the final response. When the final was not streamed as a trailing part
and happened to match an EARLIER pre-tool narration, it overwrote that earlier
part in place — dropping the terminal segment and misplacing the final response
before the tool (e.g. text("x") -> tool -> complete("x") rendered [text, tool]
instead of [text, tool, text]).
Stop the scan at the first tool/non-stream boundary so the in-place upgrade is
limited to the terminal segment; otherwise append. Reasoning stays transparent
within a streamed segment. Also add use-hermes-config tests for explicit
display.interim_assistant_messages true/false and the omitted-key default, plus
regression coverage for deep multi-tool turns and reasoning-terminal shapes.
Addresses review feedback on NousResearch#61447.
…ocks
The reasoning dedup used a bidirectional prefix match, so a short or generic
final line ("Done." / "OK" / "Yes.") counted as "restating" any longer reasoning
block that merely started with it — silently dropping substantive content the
final never covered (e.g. reasoning "Done. Note: the API key is expired..." was
erased by final "Done."). This is the same information-loss class this PR fixes,
in the reasoning lane; it predates the PR but surfaced while hardening the merge.
Split the predicate: reasoning is a restatement only when the final fully covers
it (reasoning ⊆ final). The reverse direction stays for the terminal-text
in-place upgrade, where a truncated stream is legitimately extended by the final.
Found via adversarial + property-based fuzz testing of the merge (200k cases).
4ade180 to
78f0dfa
Compare
|
Rebased onto latest What changed in this push (no code behavior change):
Re-verified on the new base (macOS, Node 26):
Note: two unrelated files ( Prior review asks are already in (teknium1's terminal-segment boundary fix + the |
When the agent emits interim text (commentary alongside tool calls, or the
attempted final answer before a verify-on-stop nudge), all UI surfaces
streamed it live but then wiped it at message.complete — keeping only the
final response. The user saw text appear during inference, then disappear.
This is the complete fix across all three layers: agent core, gateway
transport, and all UI surfaces (desktop + Ink TUI).
## Agent core (verify-on-stop persistence)
The verify-on-stop and pre_verify paths flagged the assistant's attempted
final answer as _verification_stop_synthetic, suppressing it from both
state.db and the UI. The user only saw the terse post-verification reply.
Now the assistant response is real content: it's persisted to state.db and
emitted as an interim message via _emit_interim_assistant_message(force_display=True)
before the verification loop runs. Only the synthetic nudge messages keep
the synthetic flags. The turn finalizer drops nudges from live history and
compares content (not just role) to avoid duplicating a published candidate.
Message sequence repair collapses verification candidates in the
consecutive-assistant merge.
## Gateway transport (tui_gateway)
Wire agent.interim_assistant_callback both at construction (_agent_cbs())
and per-turn (defense-in-depth), emitting a new message.interim event with
{text, already_streamed}. Gated on display.interim_assistant_messages
(default true). Cleared in the finally block so a stale closure can't
fire on a later turn.
## Shared types
Add message.interim to the GatewayEventName union (apps/shared) and a
typed payload to the TUI's GatewayEvent discriminated union.
## Ink TUI support
The TUI already had the segment-anchoring machinery (flushStreamingSegment +
finalTail) but had no handler for message.interim. Added recordInterimMessage
+ interimBoundaryIndex to seal segments mid-turn, and updated
recordMessageComplete to only dedupe segments after the interim boundary.
## Desktop state machine
Replaced the fragile sealed-set approach with a proper interimBoundaryPending
state flag on ClientSessionState. finalizeInterimAssistantMessage finalizes
the streaming bubble in place (or creates a standalone one), rotates the
stream ID so next deltas create a new bubble, and sets the flag. When the
final text equals an already-sealed interim, they stay as distinct messages.
## Reasoning dedup fix (#61447)
Extracted mergeFinalAssistantText() as a pure function in chat-messages.ts,
used by both completeAssistantMessage and finalizeInterimAssistantMessage.
Split the bidirectional dedup predicate: reasoning is a restatement only when
the final FULLY covers it. A short final ("Done.") no longer swallows a
longer reasoning block that merely starts with it.
## Config gating (#61447)
Honor display.interim_assistant_messages (default true) across all layers:
the tui_gateway gates the callback, the desktop wires it to a nanostores
atom via use-hermes-config. Updated hermes_cli/config.py and
cli-config.yaml.example comments to document the Desktop behavior.
## Windows path fix (#53553)
_split_segment_tokens now accepts posix=False and _find_ad_hoc_match tries
both posix modes so ad-hoc verification scripts with Windows backslash
paths are matched correctly. (response_previewed forwarding from #53553
is not included — our emit-interim + persist approach makes it unnecessary
since the attempted answer is now surfaced before the verification loop.)
## Testing
- tsc: clean (desktop + TUI + shared)
- vitest desktop: 73/73 pass (7 interim-sealing + 5 mergeFinalAssistantText + 4 config atom)
- vitest TUI: 83/83 pass (4 new message.interim tests)
- python: 390 tests pass (340 tui_gateway + 33 verification/finalizer + 6 config gating + 3 evidence + 8 continuation budget)
Co-authored-by: Liam Zhang <yingliang-zhang@users.noreply.github.com>
Co-authored-by: Lucas D'Alessandro <lucasfdale@users.noreply.github.com>
Co-authored-by: Eric Manganaro <superposition@users.noreply.github.com>
Co-authored-by: sweetcornna <sweetcornna@users.noreply.github.com>
Co-authored-by: DECK6 <DECK6@users.noreply.github.com>
Co-authored-by: matantsevs <matantsevs@users.noreply.github.com>
Co-authored-by: gitcommit90 <gitcommit90@users.noreply.github.com>
What does this PR do?
On the Desktop app, a multi-step turn (
text → tool → text → tool → …)streams all its mid-turn narration live, then collapses to only the final
message the instant
message.completelands. Any substantive point the modelmakes between tool calls that the final message doesn't restate disappears from
the transcript. The text is persisted to
state.dband reappears on sessionreload — the loss is render-only — but Desktop is the only surface that does
this.
Root cause:
completeAssistantMessageinapps/desktop/src/app/session/hooks/use-message-stream/index.tsran areplaceTextPartthat unconditionally dropped everytextpart oncompletion and re-appended the gateway's
final_response(message.complete.text)as one trailing part.
final_responseis only the agent's last assistantsegment (
agent/conversation_loop.py:final_response = assistant_message.contentin the no-tool-calls branch), so every earlier narration segment was discarded.
git blameshows this filter is original to the desktop app's first commit —incidental, never a deliberate design decision.
This is a real divergence: the setting that governs interim commentary already
exists product-wide —
display.interim_assistant_messages(defaulttrue,documented as "signal, not noise") — and every other surface already keeps
interim text: the messaging gateway gates sending on it; the Ink TUI keeps
interleaved segments and appends only the final tail
(
ui-tui/.../turnController.ts::recordMessageComplete); and Desktop's ownreload path (
toChatMessages) reconstructs the interleaving. Desktop'slive-completion path was the sole outlier and consulted the setting nowhere.
The fix wires Desktop to honor the existing
display.interim_assistant_messagessetting (default
true, so the improved behavior is on by default; an explicitfalsepreserves the old lean collapse) and corrects the merge so keep-modematches what the TUI and reload path already produce — no new config key, no new
IPC (the renderer already fetches
config.display).Why this content is worth keeping (not filler)
Because
final_responseis only the agent's last segment, the dropped text isspecifically everything the agent worked out along the way — root-cause
findings, decisions, caveats, partial results — that the closing line doesn't
restate. The screenshot below is a debugging turn: the agent diagnoses that a
bare
catch {}is silently swallowing Stripe errors, then finishes with a terse"Fixed." BEFORE, the user is told it's fixed but never sees why it broke —
the diagnosis is gone. AFTER, the root-cause finding is preserved. That's the
case for interim being signal: it frequently carries the single most valuable
sentence in the turn.
Screenshot
Both panels are rendered by the real shipping
Threadcomponent, and the twomessage states are produced by running the actual
mergeFinalAssistantTextfunction this PR adds over one shared streamed input (built with the real
appendAssistantTextPart/upsertToolPartbuilders): left =keepInterim:false(today's collapse), right =
keepInterim:true(this PR's default). It is afaithful render of the function's output, not a hand-drawn mockup — though not a
live end-to-end gateway capture.
Related Issue
Fixes #54905. Fixes #61297. Fixes #61822. (Same Desktop root cause — the streamed
analysis / interim narration collapses to only the brief final message.) Prior
report #40903 was closed
completedwithout a fix — same bug.Scope note: this is the Desktop renderer fix. The classic-CLI path (registering
interim_assistant_callbackincli.py) is a separate, complementary changetracked in #61880 and is intentionally out of scope here.
Type of Change
Changes Made
apps/desktop/src/lib/chat-messages.ts— extract the completion-merge into apure, exported
mergeFinalAssistantText(parts, finalText, keepInterim).keepInterim: truekeeps interim narration and upgrades the trailing streamedtext segment in place (the one
final_responserestates), or appends when thefinal text is genuinely new;
keepInterim: falseis byte-identical to the oldcollapse. Reasoning-dedup behavior is unchanged.
apps/desktop/src/app/session/hooks/use-message-stream/index.ts— replace theinline
replaceTextPartwith a call tomergeFinalAssistantText, reading thenew setting atom.
apps/desktop/src/store/session.ts— add$keepInterimAssistantMessagesatom(default
true) + setter, mirroring the backend config.apps/desktop/src/app/session/hooks/use-hermes-config.ts— set the atom fromconfig.display.interim_assistant_messageson config refresh (defaulttrue;only an explicit
falseopts into lean).apps/desktop/src/types/hermes.ts— addinterim_assistant_messages?: booleanto the
HermesConfig.displaytype.apps/desktop/src/lib/chat-messages.test.ts— 7 unit tests formergeFinalAssistantText(keep + lean modes).apps/desktop/src/app/session/hooks/use-message-stream/interim-narration.test.tsx— integration test driving the real hook through
message.start → delta → tool.start → tool.complete → delta → message.complete,asserting narration survives when on and collapses when off.
cli-config.yaml.example+hermes_cli/config.py— update theinterim_assistant_messagescomments (were "Gateway-only") to document that itnow also governs Desktop transcript behavior. Comment-only; no default change.
How to Test
the model narrates between tool calls (e.g. "check the repo layout, then
summarize", where it says something substantive before the final answer).
mid-turn narration vanishes until you reload the session.
After (default): the mid-turn narration stays in the transcript alongside
the tool calls and the final message.
display.interim_assistant_messages: falsein~/.hermes/config.yamlandrepeat — the old lean collapse (final message only) is preserved.
npm run --prefix apps/desktop typecheck(clean),npm run --prefix apps/desktop build(clean),and
cd apps/desktop && ../../node_modules/.bin/vitest run --environment jsdom src/lib/chat-messages.test.ts src/app/session/hooks/use-message-stream/interim-narration.test.tsx(37 passing).Checklist
Code
fix(desktop): …)tests/hermes_cli/test_config.py145,tests/gateway/test_display_config.py52, desktop vitest 37) — all pass.Note: desktop CI is typecheck + production build (both pass here); vitest
is included as fix evidence. The Python change is comment-only.
Documentation & Housekeeping
cli-config.yaml.example+hermes_cli/config.pycomments forinterim_assistant_messagescli-config.yaml.example(comment clarified; the key alreadyexisted, no new key added)
CONTRIBUTING.md/AGENTS.md— N/A (no architecture change)platform-specific code paths
Open question for maintainers
Desktop ignored
display.interim_assistant_messageswhile the TUI, messaginggateway, and reload path all respect/implement interim text. This PR assumes that
was an oversight to bring in line, not a deliberate Desktop-only lean default. If
it was deliberate, what's the rationale — so the default can be revisited rather
than flipped. Happy to add a Settings → Display toggle that writes the key if a
GUI control is wanted; kept out of this PR to keep the diff minimal since the
config key already drives it.