Skip to content

fix(native-chat): stop rendering tool output as the agent's streaming reply - #17782

Merged
brennanb2025 merged 4 commits into
mainfrom
brennanb2025/native-chat-stream-bubble-fix
Sep 1, 2026
Merged

brennanb2025 merged 4 commits into
mainfrom
brennanb2025/native-chat-stream-bubble-fix

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Sep 1, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 9 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​256 $\color{#cf222e}{\Huge{\mathbf{−}}}$​3 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​253
Prod 35 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​124 $\color{#cf222e}{\Huge{\mathbf{−}}}$​18 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​106

What was broken

A tool result could render in native chat as a raw, un-collapsed "assistant" bubble that never went away for the rest of the turn. Reported from mobile: a wall of a source file's contents, prefixed by Exit code 1, sitting in the chat while the agent kept working.

Root cause

Two independent things combine.

1. The preview is tool output. Providers publish a tool's stdout/error into lastAssistantMessage so status cards, dashboard rows and notifications can preview what the agent just did — deliberate, and pinned by server-claude-normalization.test.ts (PostToolUse Bash → lastAssistantMessage: 'tests passed'). Native chat reuses that same field as its live streaming bubble, so the preview renders as prose.

For Claude this is not an edge case. claude-tool-fields.ts is the only writer of the field, and it writes real prose exclusively at Stop:

event value written
PostToolUse tool response text
PostToolUseFailure tool error text
Stop assistant prose

So mid-turn the bubble can only ever contain tool output — it is structurally incapable of showing a streaming reply.

2. It cannot be retired. The bubble hides only once a transcript assistant block leads with the streamed text:

const tailLeadsWithStream = assistantTailText(tail).startsWith(text)
const caughtUp = tailLeadsWithStream && tailId !== baselineTailId

assistantTailText reads assistant text blocks; tool output never lands in one, so caughtUp can never fire. The only remaining exit is the turn ending — which is why a long tool-heavy turn pins it on screen for minutes.

The fix

Carry provenance rather than change what the status surfaces show.

  • Mark the writes sourced from a tool result/error (lastAssistantMessageIsToolOutput) across all 8 providers that do this — 11 sites.
  • Keep the flag in lockstep with the value it describes through resolveToolState, so a later prose turn does not inherit a previous tool result's flag.
  • Have both native-chat streaming paths ignore a flagged preview: the shared deriveNativeChatStreamingText (desktop) and a new mobileNativeChatStreamPreview (mobile).

Status cards, dashboard rows, activity threads, notifications and automation capture are untouched — they still get the preview.

Mobile treats a flagged preview as no observation rather than empty text, so it also stays out of the gate's prevText and the next real reply is not mistaken for a continuation of a tool result.

Wire compatibility

The field is optional and normalizes absent | false → undefined, so it only ever means "known tool output". An older host that never sends it keeps today's behavior instead of silently suppressing previews. Per docs/reference/remote-wire-compatibility.md, a new optional field is safe.

Verification

  • pnpm tc and mobile tsc --noEmit: clean. Verified non-blind with a positive control — a bogus field name errors as TS2339 ... on type 'AgentStatusEntry', so the new field is really being checked.
  • src/shared/agent-hook-listener, src/main/agent-hooks, src/shared/agent-status-types, src/renderer/.../native-chat: 184 files / 1955 tests pass.
  • Full mobile suite: 488 files / 3975 tests pass.
  • Ablation (tests are not vacuous): removing the 2 provider flag writes turns the new normalization tests red (2 failed / 20 passed); removing the mobile previewIsToolOutput clause turns the new gate test red (1 failed / 20 passed). Both restored and re-verified green.
  • Changed-code quality gate (oxlint + type-aware + React Doctor): 0 new findings across 33 files.

Note for review

src/shared/native-chat-streaming.ts says desktop and mobile both use it "so the show/hide rule can't drift between platforms" — but mobile actually has its own mobile-native-chat-streaming-gate.ts with a different rule. That drift is pre-existing and left alone here; both paths are fixed independently.

… reply

A tool result could appear in native chat as a raw, un-collapsed "assistant"
bubble that never went away for the rest of the turn — on mobile it showed up
as a wall of a source file's contents, prefixed by "Exit code 1".

Providers publish a tool's stdout/error as `lastAssistantMessage` so status
cards and dashboard rows can preview what the agent just did. Native chat reuses
that same field as its live streaming bubble, so the preview rendered as prose.
For Claude the preview is *only ever* tool output mid-turn: claude-tool-fields
writes real prose exclusively at Stop, so the bubble could never contain an
actual streaming reply.

It also could not be retired. The bubble hides once a transcript assistant block
leads with the streamed text, and tool output never lands in one — so the only
remaining exit was the turn ending, which is why a long tool-heavy turn pinned it
on screen.

Carry provenance instead of changing what the status surfaces show: mark the
writes that come from a tool result/error, keep the flag in lockstep with the
value it describes through the listener merge, and have both native-chat
streaming paths ignore a flagged preview. Status cards, dashboard rows and
automation capture are untouched.

The wire field is optional, so an older host that never sends it keeps today's
behavior rather than silently suppressing previews.
@greptile-apps

greptile-apps Bot commented Sep 1, 2026 •

Copy link
Copy Markdown

Greptile Summary

The PR adds explicit provenance for agent-status messages sourced from tool output and uses it to prevent desktop and mobile native chat from rendering those previews as assistant replies.

  • Propagates the optional provenance flag through provider normalization, renderer state, runtime synchronization, and mobile status data.
  • Keeps tool-result previews available to status surfaces while excluding them from native-chat streaming bubbles.
  • Adds desktop, mobile, provider, normalization, and synchronization regression coverage.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
src/shared/agent-hook-listener/prompt-fields.ts Keeps message provenance aligned with inherited, replaced, and explicitly cleared assistant-message values.
src/shared/agent-status-types.ts Adds an optional normalized provenance field whose absent and false forms remain wire-compatible.
src/shared/native-chat-streaming.ts Prevents flagged tool-output previews from becoming desktop native-chat streaming messages.
mobile/src/session/mobile-native-chat-streaming-gate.ts Filters flagged mobile previews as no observation while preserving the existing stream-segment gate.
mobile/src/session/use-mobile-native-chat-controller.ts Applies provenance filtering before throttling the mobile streaming preview.
src/renderer/src/runtime/web-session-tabs-sync.ts Preserves provenance through mirrored status reconciliation while keeping message and flag ownership consistent.
src/renderer/src/runtime/sync-runtime-graph.ts Includes provenance in mobile status change detection so flag-only transitions are synchronized.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  P[Provider hook event] --> N[Normalize agent status]
  N --> T{Tool output?}
  T -- Yes --> F[Set provenance flag]
  T -- No --> A[Assistant prose]
  F --> S[Status synchronization]
  A --> S
  S --> C[Status cards retain preview]
  S --> G{Native-chat gate}
  G -- Flagged --> H[Hide streaming bubble]
  G -- Prose --> R[Render streaming reply]
Loading

Reviews (2): Last reviewed commit: "test(native-chat): cover restored tool p..." | Re-trigger Greptile

@coderabbitai

coderabbitai Bot commented Sep 1, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Team

Run ID: 185127d2-32b7-474f-9e8a-2473da831b98

📥 Commits

Reviewing files that changed from the base of the PR and between 879f8da and c0367ce.

📒 Files selected for processing (11)
  • src/renderer/src/hooks/ipc-events/normalize-agent-status-event.test.ts
  • src/renderer/src/hooks/ipc-events/normalize-agent-status-event.ts
  • src/renderer/src/runtime/sync-runtime-graph-agent-status-projection.test.ts
  • src/renderer/src/runtime/sync-runtime-graph.test.ts
  • src/renderer/src/runtime/sync-runtime-graph.ts
  • src/renderer/src/runtime/web-session-tabs-sync-agent-status.test.ts
  • src/renderer/src/runtime/web-session-tabs-sync.ts
  • src/renderer/src/store/slices/agent-status-tool-assistant-fields.test.ts
  • src/renderer/src/store/slices/agent-status.ts
  • src/shared/agent-hook-listener-claude-subagents.test.ts
  • src/shared/agent-hook-listener/providers/claude-roster-state.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 5 remain after this review.


📝 Walkthrough

Walkthrough

The change adds lastAssistantMessageIsToolOutput to tool snapshots and normalized agent status types. Provider adapters set and forward the flag for tool results and errors. State merging clears or inherits the flag with the associated message. Native-chat rendering and mobile streaming helpers suppress flagged previews while preserving assistant prose. Tests cover provider normalization, status transitions, shared streaming behavior, and mobile preview handling.

Merge Risk: ⚪ Minimal · up to c0367

The change prevents tool output from appearing as a persistent assistant reply while preserving existing status previews; no actionable merge-blocking risk remains beyond normal checks and review.

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description clearly explains the bug, root cause, fix, compatibility impact, and verification. However, it omits required template sections and information, including ELI5, Why, Linked Issue with … Add all missing template sections. Provide the required linked issue, add before/after visual evidence or write exactly N/A with a brief reason if no visual or interaction change applies, complete the Testing and other checklists, state A…
Docstring Coverage ⚠️ Warning Docstring coverage is 31.71% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 41 functions across 44 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: preventing tool output from rendering as the native-chat streaming reply.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Full details: Description check

Explanation

The description clearly explains the bug, root cause, fix, compatibility impact, and verification. However, it omits required template sections and information, including ELI5, Why, Linked Issue with a populated issue reference, Visual Proof or an explicit N/A justification, Testing checklist, AI Disclosure, Review, Agent skill upstream boundary, Notes, and the final Checklist.

Resolution

Add all missing template sections. Provide the required linked issue, add before/after visual evidence or write exactly N/A with a brief reason if no visual or interaction change applies, complete the Testing and other checklists, state AI usage or leave the section blank only if the contributor is an internal team member, and document security, cross-platform, SSH, mobile, compatibility, and performance considerations.

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Important

The new lastAssistantMessageIsToolOutput flag never reaches the renderer store, so the fix is currently a no-op end-to-end on the live path.

Reviewed changes

  • Provenance flag — new optional lastAssistantMessageIsToolOutput on ToolSnapshot, AgentStatusEntry, and AgentStatusPayload, normalized so absent | false → undefined (wire-compatible, only ever means "known tool output").
  • Provider writers — 8 *-tool-fields.ts files flag the 11 sites that write a tool result/error into lastAssistantMessage; all *-events.ts/status-build files pass the snapshot flag through to the payload.
  • Lockstep resolution — resolveToolState inherits the flag only when the message itself is inherited, and clears both on clearLastAssistantMessage.
  • Consumers — desktop deriveNativeChatStreamingText hard-gates on previewIsToolOutput; mobile adds mobileNativeChatStreamPreview treating a flagged preview as "no observation".
  • Tests — normalization + desktop derivation + mobile gate unit tests, plus an ablation claim in the PR body.

⚠️ The flag is dropped before it reaches agentStatusByPaneKey

Both consumers read the flag off AgentStatusEntry: desktop via useAppStore((s) => s.agentStatusByPaneKey[paneKey]?.lastAssistantMessageIsToolOutput) (NativeChatResolvedView.tsx), and mobile via activeSessionTab?.agentStatus (an AgentStatusEntry). But the diff adds the flag to the payload vocabulary and never writes it into the renderer store's entries. Two unmodified sites drop it:

  1. src/renderer/src/hooks/ipc-events/normalize-agent-status-event.ts:10-24 rebuilds the payload field-by-field and omits lastAssistantMessageIsToolOutput, so the renderer ingress discards it even though main's agentStatus:set event carries it via the ...payload spread in src/main/index.ts.
  2. src/renderer/src/store/slices/agent-status.ts (setAgentStatus, entry literal ~2325-2399) copies lastAssistantMessage: payload.lastAssistantMessage but never lastAssistantMessageIsToolOutput.

Consequently hookPreviewIsToolOutput is always undefined, deriveNativeChatStreamingText never sees previewIsToolOutput: true, and desktop native chat keeps rendering tool stdout as the streaming reply — the exact symptom this PR reports. The same drop applies to mobile whenever its session-tab agentStatus originates from the desktop host's agentStatusByPaneKey (the normal paired case); only headless serve's buildPtyMobileAgentStatus spreads the raw hook payload (liveRow.payload) and would carry it. The new tests cannot catch this because they exercise the pure functions and the payload normalization directly, never the store/ingress plumbing.

Technical details
# Propagate lastAssistantMessageIsToolOutput into AgentStatusEntry

## Affected sites
- src/renderer/src/hooks/ipc-events/normalize-agent-status-event.ts:10-24 — field-by-field rebuild omits the flag; add `lastAssistantMessageIsToolOutput: data.lastAssistantMessageIsToolOutput`.
- src/renderer/src/store/slices/agent-status.ts:2357 — entry construction copies `lastAssistantMessage` but not the flag; add `lastAssistantMessageIsToolOutput: payload.lastAssistantMessageIsToolOutput`.
- Also audit `doneRetentionFieldsChanged` (~line 2458) and any other entry-field comparisons if the flag should participate in retention change detection.

## Required outcome
- A tool result/error written to `lastAssistantMessage` must land in `agentStatusByPaneKey[paneKey].lastAssistantMessageIsToolOutput === true` so both native-chat streaming paths suppress the bubble.
- A later prose `Stop` must produce `undefined` (or `false`), matching the `absent | false → undefined` normalization contract.

## Suggested approach
- Add a store-level test: feed an `AgentStatusIpcPayload` carrying the flag through `normalizeAgentStatusEvent` → `setAgentStatus`, and assert the entry carries it; mirror a test for the snapshot restore path.

## Open questions for the human
- Mobile in the desktop-hosted case: confirm the session-tab `agentStatus` path (renderer store entry → runtime graph sync → `session.tabs`) preserves the flag after the store fix; otherwise mobile needs the same treatment on that projection.

Pullfrog  | Fix it ➔ | View workflow run | Using DeepSeek Pro (free via Pullfrog for OSS) | 𝕏

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Review status: land after fixes.

The review found and fixed in-scope provenance propagation gaps that made the original desktop fix ineffective or could reintroduce stale suppression:

  • renderer IPC normalization and agent-status store propagation;
  • mirrored web-session merge/equality and mobile runtime projection;
  • Claude roster/answered-question restoration preserving tool-output provenance;
  • regression tests for IPC normalization, store clearing, mobile projection invalidation, mirrored stale-flag clearing, and Claude restoration.

Verification:

  • pnpm tc:web passes.
  • Changed-code quality gate passes with 0 new findings.
  • Targeted native-chat/agent-status/renderer sync/runtime/Claude suites pass (including the final 6-file run: 59 tests; Claude subagent regression suite: 31 tests).
  • Worktree is clean and all review commits are pushed to brennanb2025/native-chat-stream-bubble-fix.

Electron QA: an Orca-orchestrated Grok worker attempted the required Electron/CDP validation, but the dev Electron launch/CDP target remained unavailable/hung after bounded retries. No screenshot evidence was produced, so visual QA remains blocked and is not being claimed as passing. No SSH or mobile-emulator validation was run because this diff is desktop/shared status plumbing rather than an SSH/mobile UI change.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ No new issues found.

Reviewed changes

  • Renderer ingress propagation — normalizeAgentStatusEvent now passes lastAssistantMessageIsToolOutput through, and setAgentStatus writes it onto the entry (plus doneRetentionFieldsChanged), closing the gap where the flag never reached agentStatusByPaneKey.
  • Mobile projection + mirrored host path — serializeRuntimeMobileAgentStatusEntry, buildMirroredAgentStatusPatch, and agentStatusEntryEqual all carry the flag, so both mobile and the paired-client mirror keep provenance in lockstep with the message.
  • Claude roster-state restoration — the three restore sites (seedClaudeLeadTurnFromPersistedStatus, clearClaudePendingWaitForAgent, clearClaudeAnsweredQuestionWait) preserve the flag when restoring a stashed lead preview.
  • Regression tests — new tests exercise normalization → store ingress, sync-graph projection, web-tabs mirror, and subagent roster restoration; all pass (79 tests across the 5 changed test files).

Pullfrog  | View workflow run | Using DeepSeek Pro (free via Pullfrog for OSS) | 𝕏

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Attachments

native-chat-stream-bubble-fix-clean.png
native-chat-stream-bubble-fix-clean.png

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Visual QA unblocked after targeted process cleanup.

  • Root cause of the hanging CDP was the fresh Electron main thread blocking in macOS SecItemCopyMatching while reading Keychain data; the CDP listener accepted TCP but could not answer /json.
  • Stale dev Electron trees were present. I verified worktree ownership/terminal state and stopped only abandoned trees with no active terminals (including duplicate sta-5718, r1-build, beluga, sta5679-repro, and fix-run-binding-loss). The active mobile-serving Electron on port 6769 was preserved.
  • Relaunched this PR with an isolated profile and --use-mock-keychain --password-store=basic --disable-gpu; CDP attached on 9411, identity matched native-chat-stream-bubble-fix, and the app rendered successfully.
  • Screenshot evidence: https://github.com/stablyai/orca/blob/2fa4687340b572adc07f466c9dda8f15033967e9/native-chat-stream-bubble-fix-clean.png?raw=true

No source changes were made during this QA cleanup.

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Manual verification completed on the PR tip (c0367ce).

  • Root test suites: 112 assertions passed; mobile gate: 21 assertions passed.
  • In a clean Electron profile, CDP attached to the exact PR worktree and the native-chat surface rendered with its real composer.
  • Runtime checks exercised tool-output provenance normalization, renderer store propagation, and streaming derivation: tool stdout/error is suppressed from the synthetic chat bubble, real assistant prose is shown while working, and stale/caught-up previews remain hidden.
  • The worktree was restored clean after QA; no PR source files were changed.

@brennanb2025
brennanb2025 merged commit a9e6fb7 into main Sep 1, 2026
33 checks passed
enrick-dev pushed a commit to enrick-dev/orca that referenced this pull request Oct 15, 2026
… reply (stablyai#17782)

* fix(native-chat): stop rendering tool output as the agent's streaming reply

A tool result could appear in native chat as a raw, un-collapsed "assistant"
bubble that never went away for the rest of the turn — on mobile it showed up
as a wall of a source file's contents, prefixed by "Exit code 1".

Providers publish a tool's stdout/error as `lastAssistantMessage` so status
cards and dashboard rows can preview what the agent just did. Native chat reuses
that same field as its live streaming bubble, so the preview rendered as prose.
For Claude the preview is *only ever* tool output mid-turn: claude-tool-fields
writes real prose exclusively at Stop, so the bubble could never contain an
actual streaming reply.

It also could not be retired. The bubble hides once a transcript assistant block
leads with the streamed text, and tool output never lands in one — so the only
remaining exit was the turn ending, which is why a long tool-heavy turn pinned it
on screen.

Carry provenance instead of changing what the status surfaces show: mark the
writes that come from a tool result/error, keep the flag in lockstep with the
value it describes through the listener merge, and have both native-chat
streaming paths ignore a flagged preview. Status cards, dashboard rows and
automation capture are untouched.

The wire field is optional, so an older host that never sends it keeps today's
behavior rather than silently suppressing previews.

* fix(native-chat): preserve tool output provenance through renderer sync

* fix(native-chat): retain preview provenance in Claude roster state

* test(native-chat): cover restored tool preview provenance

---------

Co-authored-by: Merge Sim <sim@local>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant