Skip to content

fix(desktop): honor display.interim_assistant_messages — stop collapsing mid-turn narration - #61447

Closed
lucasfdale wants to merge 4 commits into
NousResearch:mainfrom
lucasfdale:fix/desktop-honor-interim-assistant-messages
Closed

lucasfdale wants to merge 4 commits into
NousResearch:mainfrom
lucasfdale:fix/desktop-honor-interim-assistant-messages

Conversation

@lucasfdale

@lucasfdale lucasfdale commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

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.complete lands. Any substantive point the model
makes between tool calls that the final message doesn't restate disappears from
the transcript. The text is persisted to state.db and reappears on session
reload — the loss is render-only — but Desktop is the only surface that does
this.

Root cause: completeAssistantMessage in
apps/desktop/src/app/session/hooks/use-message-stream/index.ts ran a
replaceTextPart that unconditionally dropped every text part on
completion and re-appended the gateway's final_response (message.complete.text)
as one trailing part. final_response is only the agent's last assistant
segment (agent/conversation_loop.py: final_response = assistant_message.content
in the no-tool-calls branch), so every earlier narration segment was discarded.
git blame shows 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 (default true,
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 own
reload path
(toChatMessages) reconstructs the interleaving. Desktop's
live-completion path was the sole outlier and consulted the setting nowhere.

The fix wires Desktop to honor the existing display.interim_assistant_messages
setting (default true, so the improved behavior is on by default; an explicit
false preserves the old lean collapse) and corrects the merge so keep-mode
matches 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_response is only the agent's last segment, the dropped text is
specifically 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

Before/after: BEFORE collapses to a terse "Fixed" line and loses the root-cause diagnosis; AFTER preserves the mid-turn root-cause finding

Both panels are rendered by the real shipping Thread component, and the two
message states are produced by running the actual mergeFinalAssistantText
function this PR adds over one shared streamed input (built with the real
appendAssistantTextPart / upsertToolPart builders): left = keepInterim:false
(today's collapse), right = keepInterim:true (this PR's default). It is a
faithful 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 completed without a fix — same bug.

Scope note: this is the Desktop renderer fix. The classic-CLI path (registering
interim_assistant_callback in cli.py) is a separate, complementary change
tracked in #61880 and is intentionally out of scope here.

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)

Changes Made

  • apps/desktop/src/lib/chat-messages.ts — extract the completion-merge into a
    pure, exported mergeFinalAssistantText(parts, finalText, keepInterim).
    keepInterim: true keeps interim narration and upgrades the trailing streamed
    text segment in place (the one final_response restates), or appends when the
    final text is genuinely new; keepInterim: false is byte-identical to the old
    collapse. Reasoning-dedup behavior is unchanged.
  • apps/desktop/src/app/session/hooks/use-message-stream/index.ts — replace the
    inline replaceTextPart with a call to mergeFinalAssistantText, reading the
    new setting atom.
  • apps/desktop/src/store/session.ts — add $keepInterimAssistantMessages atom
    (default true) + setter, mirroring the backend config.
  • apps/desktop/src/app/session/hooks/use-hermes-config.ts — set the atom from
    config.display.interim_assistant_messages on config refresh (default true;
    only an explicit false opts into lean).
  • apps/desktop/src/types/hermes.ts — add interim_assistant_messages?: boolean
    to the HermesConfig.display type.
  • apps/desktop/src/lib/chat-messages.test.ts — 7 unit tests for
    mergeFinalAssistantText (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 the
    interim_assistant_messages comments (were "Gateway-only") to document that it
    now also governs Desktop transcript behavior. Comment-only; no default change.

How to Test

  1. In the Desktop app, ask for something that produces a multi-step turn where
    the model narrates between tool calls (e.g. "check the repo layout, then
    summarize", where it says something substantive before the final answer).
  2. Before: on completion the bubble collapses to only the final message; the
    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.
  3. Set display.interim_assistant_messages: false in ~/.hermes/config.yaml and
    repeat — the old lean collapse (final message only) is preserved.
  4. Automated: from the repo root,
    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

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(desktop): …)
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix
  • I've run the relevant test suites (tests/hermes_cli/test_config.py 145,
    tests/gateway/test_display_config.py 52, 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.
  • I've added tests for my changes (7 unit + 2 integration)
  • I've tested on my platform: macOS 26.5.2 (Apple Silicon)

Documentation & Housekeeping

  • I've updated relevant documentation — cli-config.yaml.example +
    hermes_cli/config.py comments for interim_assistant_messages
  • I've updated cli-config.yaml.example (comment clarified; the key already
    existed, no new key added)
  • I've updated CONTRIBUTING.md/AGENTS.md — N/A (no architecture change)
  • I've considered cross-platform impact — renderer logic only, no
    platform-specific code paths
  • I've updated tool descriptions/schemas — N/A (no tool change)

Open question for maintainers

Desktop ignored display.interim_assistant_messages while the TUI, messaging
gateway, 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.

@alt-glitch alt-glitch added type/bug Something isn't working comp/desktop Electron desktop app (apps/desktop/*) P3 Low — cosmetic, nice to have labels Jul 9, 2026
@kyssta-exe

Copy link
Copy Markdown
Contributor

This PR also fixes #61297 (same root cause — interim narration collapsing). Mentioning here for cross-reference.

@lucasfdale

Copy link
Copy Markdown
Contributor Author

Thanks for the cross-reference — confirmed, #61297 is the same root cause (Desktop's live-completion path dropping every interim segment and keeping only final_response, the agent's last segment). I've added Fixes #61297 to the description so it closes on merge.

Worth noting the report also ticks CLI (interactive chat) as a component, but the Ink TUI's recordMessageComplete already preserves interim segments — the defect is Desktop-only, which this PR covers in full.

@tonydwb tonydwb left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@giggling-ginger

Copy link
Copy Markdown
Contributor

Review note / issue linkage

This also fixes #61822 (same Desktop root cause).

Why #61822 maps here

  • Report: intermediate thinking / tool narration flashes briefly, then is replaced by the final answer only.
  • Reporter has interim assistant messages enabled.
  • Debug dump shows Desktop + platform=tui / tui_gateway, not classic CLI-only.
  • That matches this PR's collapse-on-message.complete analysis exactly (replaceTextPart drops every text part and keeps only final_response).

Please add Fixes #61822 to the PR body (alongside #54905 / #61297) so the issue auto-closes on merge.

Sanity check done against current main

  • Cherry-picked this branch cleanly onto latest main; desktop vitest for mergeFinalAssistantText + interim narration integration: 37 passed.
  • Only real merge friction was an unrelated scripts/release.py AUTHOR_MAP collision if someone salvages later — not a problem for merging this PR as-is if it's rebased.

Scope caveat (not a blocker)

I closed the accidental salvage duplicate #61997 in favor of this original PR.

@lucasfdale

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough triage — much appreciated.

Done:

Thanks also for closing the #61997 salvage dup in favor of this one.

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-352 scans backward across tool boundaries. For text("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-config test for explicit display.interim_assistant_messages: false and 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--) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@lucasfdale
lucasfdale force-pushed the fix/desktop-honor-interim-assistant-messages branch from 627f442 to 6cfa043 Compare July 10, 2026 17:16
@lucasfdale

Copy link
Copy Markdown
Contributor Author

Thanks for the precise review — both points fixed, and hardening the merge surfaced a related bug I fixed too.

1. Terminal-segment boundary (fix(desktop): limit interim final-text merge to the terminal segment)
Confirmed and fixed exactly as you described: the backward scan now stops at the first tool/non-stream boundary, so the in-place upgrade is limited to the terminal segment and otherwise appends. Added the matching-text-after-tool regression case (text("x") → tool → complete("x") now renders [text, tool-call, text], final positioned after the tool), plus coverage for deep multi-tool turns and reasoning-terminal shapes.

2. Config tests — added use-hermes-config cases for explicit display.interim_assistant_messages: true/false and the omitted-key default (defaults to keeping interim).

3. Bonus fix from adversarial testing (fix(desktop): stop short final text from dropping longer reasoning blocks)
While battle-testing the boundary fix (adversarial cases + a property-based fuzzer, ~200k scenarios across both modes), I found a separate pre-existing bug: the reasoning dedup used a bidirectional prefix match, so a short/generic final line ("Done."/"OK") counted as "restating" any longer reasoning block that merely started with it — silently dropping substantive content (e.g. reasoning "Done. Note: the API key is expired…" erased by final "Done."). Same information-loss class this PR targets, in the reasoning lane. Split the predicate so reasoning is only dropped when the final fully covers it (reasoning ⊆ final); the reverse direction stays for the terminal-text upgrade where a truncated stream is legitimately extended by the final.

Kept as two commits so the requested boundary fix and the reasoning fix stay separable. Green on latest main: 54 desktop vitest cases, tsc --noEmit, eslint all clean; fuzzer reports zero invariant violations across 200k cases.

@teknium1 teknium1 added sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 11, 2026
@alt-glitch alt-glitch removed the sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades label Jul 11, 2026
@teknium1 teknium1 added the sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades label Jul 11, 2026
@alt-glitch alt-glitch removed the sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades label Jul 11, 2026
@teknium1 teknium1 added the sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades label Jul 11, 2026
@alt-glitch alt-glitch added comp/cli CLI entry point, hermes_cli/, setup wizard area/config Config system, migrations, profiles and removed sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades comp/cli CLI entry point, hermes_cli/, setup wizard labels Jul 11, 2026
@teknium1 teknium1 added the sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades label Jul 12, 2026
@alt-glitch alt-glitch removed the sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades label Jul 12, 2026
@teknium1 teknium1 added the sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades label Jul 12, 2026
@alt-glitch alt-glitch removed area/config Config system, migrations, profiles sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades labels Jul 12, 2026
@teknium1 teknium1 added the sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades label Jul 12, 2026
@lucasfdale
lucasfdale force-pushed the fix/desktop-honor-interim-assistant-messages branch from 6cfa043 to 4ade180 Compare July 13, 2026 14:08
ethernet8023 added a commit that referenced this pull request Jul 16, 2026
…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>
ethernet8023 added a commit that referenced this pull request Jul 16, 2026
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>
ethernet8023 added a commit that referenced this pull request Jul 16, 2026
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>
ethernet8023 added a commit that referenced this pull request Jul 17, 2026
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).
@lucasfdale
lucasfdale force-pushed the fix/desktop-honor-interim-assistant-messages branch from 4ade180 to 78f0dfa Compare July 17, 2026 18:54
@lucasfdale

Copy link
Copy Markdown
Contributor Author

Rebased onto latest main (was 729 behind) and force-pushed — the branch is green and mergeable again (MERGEABLE; the earlier CONFLICTING is cleared).

What changed in this push (no code behavior change):

  • Conflict resolved: the only conflict was in hermes_cli/config.py, where main added the show_commentary default + comment block right below interim_assistant_messages. Resolved as a union — kept main's show_commentary and this PR's clarified interim_assistant_messages comment. Every desktop source/test file auto-merged clean; the code diff is unchanged at +547/-37 across 11 files.
  • Commits re-authored to my GitHub noreply address so contributor-check auto-resolves without an AUTHOR_MAP entry. The four commits stay separable (honor-setting / screenshot / terminal-segment boundary fix / reasoning-dedup fix).

Re-verified on the new base (macOS, Node 26):

  • tsc --noEmit (both tsconfigs): clean
  • eslint src/ electron/: 0 errors
  • The PR's own suites in isolation: 56 passed (chat-messages.test.ts 44, use-hermes-config.test.ts 10, interim-narration.test.tsx 2)

Note: two unrelated files (app/skills/index.test.tsx, assistant-ui/thread/streaming.test.tsx) flake in the full run — I confirmed they fail the same way on clean upstream/main with this branch absent, and this PR touches neither. Flagging so they're not mistaken for a regression here.

Prior review asks are already in (teknium1's terminal-segment boundary fix + the use-hermes-config explicit-true/false/omitted tests landed in the limit interim final-text merge to the terminal segment commit; tonydwb LGTM). Ready for re-review / CI approval.

ethernet8023 added a commit that referenced this pull request Jul 17, 2026
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>
@alt-glitch alt-glitch added the needs-decision Awaiting maintainer decision before any implementation label Jul 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/desktop Electron desktop app (apps/desktop/*) needs-decision Awaiting maintainer decision before any implementation P3 Low — cosmetic, nice to have sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades type/bug Something isn't working

Projects

None yet

6 participants