From a9e6fb7eff8a42f75ed25f6008128ccd7c526243 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 31 Aug 2026 23:05:31 -0700 Subject: [PATCH] fix(native-chat): stop rendering tool output as the agent's streaming reply (#17782) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .../mobile-native-chat-streaming-gate.test.ts | 35 +++++++++ .../mobile-native-chat-streaming-gate.ts | 21 +++++ .../use-mobile-native-chat-controller.ts | 3 +- .../server-claude-normalization.test.ts | 55 +++++++++++++ .../native-chat/NativeChatResolvedView.tsx | 15 +++- .../normalize-agent-status-event.test.ts | 20 +++++ .../normalize-agent-status-event.ts | 1 + ...time-graph-agent-status-projection.test.ts | 1 + .../src/runtime/sync-runtime-graph.test.ts | 15 ++++ .../src/runtime/sync-runtime-graph.ts | 1 + ...web-session-tabs-sync-agent-status.test.ts | 77 +++++++++++++++++++ .../src/runtime/web-session-tabs-sync.ts | 7 +- ...agent-status-tool-assistant-fields.test.ts | 10 ++- src/renderer/src/store/slices/agent-status.ts | 2 + ...ent-hook-listener-claude-subagents.test.ts | 21 +++++ .../agent-hook-listener/listener-event.ts | 4 + .../agent-hook-listener/prompt-fields.ts | 10 ++- .../providers/amp-events.ts | 1 + .../providers/amp-tool-fields.ts | 1 + .../providers/antigravity-events.ts | 3 +- .../providers/claude-roster-state.ts | 13 +++- .../providers/claude-status-build.ts | 1 + .../providers/claude-tool-fields.ts | 2 + .../providers/codex-events.ts | 1 + .../providers/command-code-events.ts | 3 +- .../providers/command-code-tool-fields.ts | 1 + .../providers/copilot-events.ts | 3 +- .../providers/copilot-tool-fields.ts | 2 + .../providers/cursor-events.ts | 1 + .../providers/cursor-tool-fields.ts | 2 + .../providers/devin-events.ts | 1 + .../providers/droid-events.ts | 3 +- .../providers/droid-tool-fields.ts | 1 + .../providers/gemini-events.ts | 3 +- .../providers/grok-events.ts | 3 +- .../providers/grok-tool-fields.ts | 1 + .../providers/hermes-events.ts | 3 +- .../providers/hermes-tool-fields.ts | 1 + .../providers/kimi-events.ts | 1 + .../providers/opencode-family-events.ts | 1 + .../providers/pi-family-events.ts | 3 +- src/shared/agent-status-types.ts | 13 ++++ src/shared/native-chat-streaming.test.ts | 25 ++++++ src/shared/native-chat-streaming.ts | 10 ++- 44 files changed, 380 insertions(+), 21 deletions(-) create mode 100644 src/renderer/src/hooks/ipc-events/normalize-agent-status-event.test.ts diff --git a/mobile/src/session/mobile-native-chat-streaming-gate.test.ts b/mobile/src/session/mobile-native-chat-streaming-gate.test.ts index 312be591ca9..401aaee14ca 100644 --- a/mobile/src/session/mobile-native-chat-streaming-gate.test.ts +++ b/mobile/src/session/mobile-native-chat-streaming-gate.test.ts @@ -3,6 +3,7 @@ import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { createMobileNativeChatStreamingGate, deriveMobileNativeChatStreaming, + mobileNativeChatStreamPreview, type MobileNativeChatStreamingGate } from './mobile-native-chat-streaming-gate' @@ -33,6 +34,40 @@ function run(ticks: { folded: NativeChatMessage[]; text?: string; live?: boolean return { gate, results } } +describe('mobileNativeChatStreamPreview', () => { + it('drops a preview the provider flagged as tool output', () => { + // Regression: a Bash result was published as `lastAssistantMessage` for the status + // card, then rendered here as an un-collapsed assistant bubble that no catch-up rule + // could retire, so it sat in the chat for the rest of the turn. + expect( + mobileNativeChatStreamPreview( + { + lastAssistantMessage: 'Exit code 1\nimport { Foo }', + lastAssistantMessageIsToolOutput: true + }, + true + ) + ).toBeUndefined() + }) + + it('passes assistant prose through while working', () => { + expect(mobileNativeChatStreamPreview({ lastAssistantMessage: 'Working on it' }, true)).toBe( + 'Working on it' + ) + }) + + it('drops any preview once the turn is not working', () => { + expect( + mobileNativeChatStreamPreview({ lastAssistantMessage: 'Working on it' }, false) + ).toBeUndefined() + }) + + it('tolerates a missing status', () => { + expect(mobileNativeChatStreamPreview(null, true)).toBeUndefined() + expect(mobileNativeChatStreamPreview(undefined, true)).toBeUndefined() + }) +}) + describe('deriveMobileNativeChatStreaming', () => { it('shows a genuine reply that repeats the previous turn as a prefix', () => { const prior = [assistant('a1', 'The tests pass.')] diff --git a/mobile/src/session/mobile-native-chat-streaming-gate.ts b/mobile/src/session/mobile-native-chat-streaming-gate.ts index a857803214c..c13a2f1a3b2 100644 --- a/mobile/src/session/mobile-native-chat-streaming-gate.ts +++ b/mobile/src/session/mobile-native-chat-streaming-gate.ts @@ -18,6 +18,27 @@ export type MobileNativeChatStreamingGate = { baselineTailId: string | null } +/** The preview text to feed the gate for one tick, or undefined for "no observation". + * + * Providers publish a tool's stdout/error as `lastAssistantMessage` so status cards can + * preview it. That text is not the reply and never lands in a transcript assistant block, + * so the catch-up rule below could never retire it — it stayed on screen as a wall of raw + * tool output until the turn ended. Treating it as no observation (rather than empty text + * that still anchors) also keeps it out of `prevText`, so the next real reply is not + * mistaken for a continuation of a tool result. */ +export function mobileNativeChatStreamPreview( + status: + | { lastAssistantMessage?: string; lastAssistantMessageIsToolOutput?: boolean } + | null + | undefined, + working: boolean +): string | undefined { + if (!working || status?.lastAssistantMessageIsToolOutput === true) { + return undefined + } + return status?.lastAssistantMessage +} + export function createMobileNativeChatStreamingGate( scopeKey: string | null = null ): MobileNativeChatStreamingGate { diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index 4d405ac5d65..109dea93ec3 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -12,6 +12,7 @@ import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts' import { useMobileNativeChatFileSearch } from './use-mobile-native-chat-file-search' import { useMobileNativeChatMessageSend } from './use-mobile-native-chat-message-send' import { mobileNativeChatScopeKey } from './mobile-native-chat-scope-key' +import { mobileNativeChatStreamPreview } from './mobile-native-chat-streaming-gate' import { useMobileNativeChatSession } from './use-mobile-native-chat-session' import { useMobileNativeChatSessionOptions } from './use-mobile-native-chat-session-options' import { useMobileNativeChatPrompts } from './use-mobile-native-chat-prompts' @@ -127,7 +128,7 @@ export function useMobileNativeChatController(args: { // Throttle the streaming bubble: OpenCode emits a status frame per streamed // part, and each one re-renders and re-parses the whole accumulated markdown. const nativeChatStreamingText = useThrottledLatestValue( - nativeChatAgentWorking ? nativeChatStatus?.lastAssistantMessage : undefined, + mobileNativeChatStreamPreview(nativeChatStatus, nativeChatAgentWorking), NATIVE_CHAT_STREAM_THROTTLE_MS ) const { diff --git a/src/main/agent-hooks/server-claude-normalization.test.ts b/src/main/agent-hooks/server-claude-normalization.test.ts index bcee48fedf8..dec8896993f 100644 --- a/src/main/agent-hooks/server-claude-normalization.test.ts +++ b/src/main/agent-hooks/server-claude-normalization.test.ts @@ -62,6 +62,61 @@ describe('Claude hook normalization', () => { expect(result?.payload.lastAssistantMessage).toBe('tests passed') }) + it('flags a PostToolUse preview as tool output so native chat will not show it as the reply', () => { + // Regression: a Bash result rendered verbatim as an immortal "assistant" bubble in + // mobile native chat. The status card still gets the preview; only its provenance is new. + const result = _internals.normalizeHookPayload( + 'claude', + buildBody({ + hook_event_name: 'PostToolUse', + tool_name: 'Bash', + tool_input: { command: 'cat lifecycle.ts' }, + tool_response: { content: [{ type: 'text', text: 'Exit code 1\nimport { Foo }' }] } + }), + 'production' + ) + expect(result?.payload.lastAssistantMessage).toBe('Exit code 1\nimport { Foo }') + expect(result?.payload.lastAssistantMessageIsToolOutput).toBe(true) + }) + + it('flags a PostToolUseFailure preview as tool output', () => { + const result = _internals.normalizeHookPayload( + 'claude', + buildBody({ + hook_event_name: 'PostToolUseFailure', + tool_name: 'Write', + error: 'file is read-only' + }), + 'production' + ) + expect(result?.payload.lastAssistantMessage).toBe('file is read-only') + expect(result?.payload.lastAssistantMessageIsToolOutput).toBe(true) + }) + + it('leaves real assistant prose at Stop unflagged', () => { + _internals.normalizeHookPayload( + 'claude', + buildBody({ + hook_event_name: 'PostToolUse', + tool_name: 'Bash', + tool_input: { command: 'ls' }, + tool_response: { content: [{ type: 'text', text: 'a.ts b.ts' }] } + }), + 'production' + ) + // The prose turn must not inherit the previous tool result's flag. + const stopped = _internals.normalizeHookPayload( + 'claude', + buildBody({ + hook_event_name: 'Stop', + last_assistant_message: 'Here are the files.' + }), + 'production' + ) + expect(stopped?.payload.lastAssistantMessage).toBe('Here are the files.') + expect(stopped?.payload.lastAssistantMessageIsToolOutput).toBeUndefined() + }) + it('PostToolUse for Grep surfaces the search pattern', () => { const result = _internals.normalizeHookPayload( 'claude', diff --git a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx index 397b524a4c4..bc170a98a2b 100644 --- a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx +++ b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx @@ -100,6 +100,10 @@ export function NativeChatResolvedView({ // The agent's in-progress reply preview (hook), shown as a live streaming // bubble while it works — before the completed turn flushes to the transcript. const hookPreview = useAppStore((s) => s.agentStatusByPaneKey[paneKey]?.lastAssistantMessage) + // Tool stdout/errors ride the same field for status-card previews; they are not the reply. + const hookPreviewIsToolOutput = useAppStore( + (s) => s.agentStatusByPaneKey[paneKey]?.lastAssistantMessageIsToolOutput === true + ) // Why: Stop suppression must clear on a newer working epoch even when status // never leaves 'working' (interrupt + immediate next turn coalesced). const hookWorkingEpoch = useAppStore( @@ -246,9 +250,16 @@ export function NativeChatResolvedView({ ? [...sessionAfterCommandBoundaries.messages, ...pendingMessages] : sessionAfterCommandBoundaries.messages, previewText: hookPreview, - working: liveWorking + working: liveWorking, + previewIsToolOutput: hookPreviewIsToolOutput }) - }, [sessionAfterCommandBoundaries.messages, pendingMessages, hookPreview, liveWorking]) + }, [ + sessionAfterCommandBoundaries.messages, + pendingMessages, + hookPreview, + liveWorking, + hookPreviewIsToolOutput + ]) const sessionWithPending = useMemo(() => { if (pending.length === 0 && commandMarkers.length === 0 && !streamingText) { return sessionAfterCommandBoundaries diff --git a/src/renderer/src/hooks/ipc-events/normalize-agent-status-event.test.ts b/src/renderer/src/hooks/ipc-events/normalize-agent-status-event.test.ts new file mode 100644 index 00000000000..b3f3c0e2540 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/normalize-agent-status-event.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import { normalizeAgentStatusEvent } from './normalize-agent-status-event' + +describe('normalizeAgentStatusEvent', () => { + it('preserves tool-output provenance for native chat gating', () => { + const normalized = normalizeAgentStatusEvent({ + paneKey: 'tab-1:1', + state: 'working', + prompt: 'inspect the failure', + agentType: 'claude', + lastAssistantMessage: 'Exit code 1\nraw output', + lastAssistantMessageIsToolOutput: true, + connectionId: null, + receivedAt: 1, + stateStartedAt: 1 + }) + + expect(normalized?.lastAssistantMessageIsToolOutput).toBe(true) + }) +}) diff --git a/src/renderer/src/hooks/ipc-events/normalize-agent-status-event.ts b/src/renderer/src/hooks/ipc-events/normalize-agent-status-event.ts index d9c92610bbd..4fb53a07eb9 100644 --- a/src/renderer/src/hooks/ipc-events/normalize-agent-status-event.ts +++ b/src/renderer/src/hooks/ipc-events/normalize-agent-status-event.ts @@ -17,6 +17,7 @@ export function normalizeAgentStatusEvent( toolInput: data.toolInput, interactivePrompt: data.interactivePrompt, lastAssistantMessage: data.lastAssistantMessage, + lastAssistantMessageIsToolOutput: data.lastAssistantMessageIsToolOutput, interrupted: data.interrupted, sessionBoundary: data.sessionBoundary, turnCompletedAt: data.turnCompletedAt, diff --git a/src/renderer/src/runtime/sync-runtime-graph-agent-status-projection.test.ts b/src/renderer/src/runtime/sync-runtime-graph-agent-status-projection.test.ts index c6cb21f9d2f..724b8743dde 100644 --- a/src/renderer/src/runtime/sync-runtime-graph-agent-status-projection.test.ts +++ b/src/renderer/src/runtime/sync-runtime-graph-agent-status-projection.test.ts @@ -34,6 +34,7 @@ function referenceProjection(map: AppState['agentStatusByPaneKey']): string { toolInput: entry.toolInput ?? null, interactivePrompt: entry.interactivePrompt ?? null, lastAssistantMessage: entry.lastAssistantMessage ?? null, + lastAssistantMessageIsToolOutput: entry.lastAssistantMessageIsToolOutput ?? null, interrupted: entry.interrupted ?? null })) ) diff --git a/src/renderer/src/runtime/sync-runtime-graph.test.ts b/src/renderer/src/runtime/sync-runtime-graph.test.ts index f3469f6b7ea..43da98340d3 100644 --- a/src/renderer/src/runtime/sync-runtime-graph.test.ts +++ b/src/renderer/src/runtime/sync-runtime-graph.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { canSkipRuntimeMobileSessionSyncKeyBuild, + buildRuntimeMobileAgentStatusProjectionForTests, getRuntimeMobileSessionSyncKey, runtimeMobileSessionSyncKeysEqual } from './sync-runtime-graph' @@ -42,6 +43,20 @@ function makeSharedOverrides(): Partial { } describe('getRuntimeMobileSessionSyncKey', () => { + it('includes assistant preview provenance in the mobile status projection', () => { + const paneKey = 'term-1:11111111-1111-4111-8111-111111111111' + const base = makeAgentStatusEntry({ paneKey, lastAssistantMessage: 'tool output' }) + const flagged = makeAgentStatusEntry({ + paneKey, + lastAssistantMessage: 'tool output', + lastAssistantMessageIsToolOutput: true + }) + + expect(buildRuntimeMobileAgentStatusProjectionForTests({ [paneKey]: base })).not.toBe( + buildRuntimeMobileAgentStatusProjectionForTests({ [paneKey]: flagged }) + ) + }) + it('changes when mobile markdown tab state changes', () => { const base = makeState({ openFiles: [ diff --git a/src/renderer/src/runtime/sync-runtime-graph.ts b/src/renderer/src/runtime/sync-runtime-graph.ts index 15b673bb107..2c84eb85667 100644 --- a/src/renderer/src/runtime/sync-runtime-graph.ts +++ b/src/renderer/src/runtime/sync-runtime-graph.ts @@ -690,6 +690,7 @@ function serializeRuntimeMobileAgentStatusEntry( // Why: include so a newly-captured AskUserQuestion prompt re-fires the mobile republish even when no other field changed. interactivePrompt: entry.interactivePrompt ?? null, lastAssistantMessage: entry.lastAssistantMessage ?? null, + lastAssistantMessageIsToolOutput: entry.lastAssistantMessageIsToolOutput ?? null, interrupted: entry.interrupted ?? null }) } diff --git a/src/renderer/src/runtime/web-session-tabs-sync-agent-status.test.ts b/src/renderer/src/runtime/web-session-tabs-sync-agent-status.test.ts index 65098c4ea6c..64a59a80aa1 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync-agent-status.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync-agent-status.test.ts @@ -71,6 +71,83 @@ describe('applyWebSessionTabsSnapshot', () => { expect(patch.sortEpoch).toBe(1) }) + it('clears stale tool-output provenance when a newer host preview is assistant prose', () => { + const hostPaneKey = makePaneKey('host-tab-1', LEAF_ID) + const initialSnapshot = makeSnapshot([ + { + type: 'terminal', + id: HOST_SURFACE_ID, + title: 'claude [working]', + parentTabId: 'host-tab-1', + leafId: LEAF_ID, + isActive: true, + status: 'ready', + terminal: 'terminal-1', + agentStatus: { + state: 'working', + prompt: 'fix web parity', + updatedAt: NOW, + stateStartedAt: NOW - 1_000, + agentType: 'claude', + paneKey: hostPaneKey, + worktreeId: WT, + terminalTitle: 'claude [working]', + lastAssistantMessage: 'same preview', + lastAssistantMessageIsToolOutput: true, + stateHistory: [] + } + } + ]) + const initialPatch = applyWebSessionTabsSnapshot( + makeState(), + initialSnapshot, + ENV, + NOW + ) as Partial + const mirroredPaneKey = Object.keys(initialPatch.agentStatusByPaneKey ?? {})[0]! + + const prosePatch = applyWebSessionTabsSnapshot( + { ...makeState(), ...initialPatch }, + makeSnapshot( + [ + { + type: 'terminal', + id: HOST_SURFACE_ID, + title: 'claude [working]', + parentTabId: 'host-tab-1', + leafId: LEAF_ID, + isActive: true, + status: 'ready', + terminal: 'terminal-1', + agentStatus: { + state: 'working', + prompt: 'fix web parity', + stateStartedAt: NOW - 1_000, + agentType: 'claude', + paneKey: hostPaneKey, + worktreeId: WT, + terminalTitle: 'claude [working]', + lastAssistantMessage: 'same preview', + stateHistory: [], + updatedAt: NOW - 100, + lastAssistantMessageIsToolOutput: undefined + } + } + ], + { snapshotVersion: 2 } + ), + ENV, + NOW + ) as Partial + + expect(prosePatch.agentStatusByPaneKey?.[mirroredPaneKey]?.lastAssistantMessage).toBe( + 'same preview' + ) + expect( + prosePatch.agentStatusByPaneKey?.[mirroredPaneKey]?.lastAssistantMessageIsToolOutput + ).toBeUndefined() + }) + it('applies a marker-only host restart degradation to mirrored agent status', () => { const hostPaneKey = makePaneKey('host-tab-1', LEAF_ID) const snapshot = makeSnapshot([ diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index 31627070ebc..fd6b3f86c50 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -1798,7 +1798,11 @@ function buildMirroredAgentStatusPatch( // (#12906). Host-first unlike providerSession: only the host can mint one. lastAssistantMessage: (hostIdentityPredatesCurrentTurn ? undefined : entry.lastAssistantMessage) ?? - existing.lastAssistantMessage + existing.lastAssistantMessage, + lastAssistantMessageIsToolOutput: + hostIdentityPredatesCurrentTurn || entry.lastAssistantMessage === undefined + ? existing.lastAssistantMessageIsToolOutput + : entry.lastAssistantMessageIsToolOutput } : entry nextByPaneKey.set(entry.paneKey, nextEntry) @@ -2643,6 +2647,7 @@ function agentStatusEntryEqual(a: AgentStatusEntry | undefined, b: AgentStatusEn a.toolInput === b.toolInput && a.interactivePrompt === b.interactivePrompt && a.lastAssistantMessage === b.lastAssistantMessage && + a.lastAssistantMessageIsToolOutput === b.lastAssistantMessageIsToolOutput && a.interrupted === b.interrupted && a.promptInteractionKey === b.promptInteractionKey && a.restoredUnconfirmed === b.restoredUnconfirmed && diff --git a/src/renderer/src/store/slices/agent-status-tool-assistant-fields.test.ts b/src/renderer/src/store/slices/agent-status-tool-assistant-fields.test.ts index 61cc02158f1..7a80fc0279e 100644 --- a/src/renderer/src/store/slices/agent-status-tool-assistant-fields.test.ts +++ b/src/renderer/src/store/slices/agent-status-tool-assistant-fields.test.ts @@ -11,7 +11,7 @@ describe('agent status tool + assistant fields', () => { vi.useRealTimers() }) - it('writes toolName, toolInput, and lastAssistantMessage straight onto the entry', () => { + it('writes toolName, toolInput, and assistant preview provenance straight onto the entry', () => { vi.useFakeTimers() const store = createTestStore() store.getState().setAgentStatus('tab-1:1', { @@ -20,12 +20,14 @@ describe('agent status tool + assistant fields', () => { agentType: 'claude', toolName: 'Edit', toolInput: '/src/config.ts', - lastAssistantMessage: 'Edited config.ts' + lastAssistantMessage: 'Edited config.ts', + lastAssistantMessageIsToolOutput: true }) const entry = store.getState().agentStatusByPaneKey['tab-1:1'] expect(entry.toolName).toBe('Edit') expect(entry.toolInput).toBe('/src/config.ts') expect(entry.lastAssistantMessage).toBe('Edited config.ts') + expect(entry.lastAssistantMessageIsToolOutput).toBe(true) }) it('clears fields to undefined when a later payload omits them', () => { @@ -37,7 +39,8 @@ describe('agent status tool + assistant fields', () => { agentType: 'claude', toolName: 'Edit', toolInput: '/src/config.ts', - lastAssistantMessage: 'Edited config.ts' + lastAssistantMessage: 'Edited config.ts', + lastAssistantMessageIsToolOutput: true }) // Why: the main-process cache is the source of truth for tool/assistant // fields — a fresh-turn reset surfaces as undefined on the payload, and @@ -49,6 +52,7 @@ describe('agent status tool + assistant fields', () => { expect(entry.toolName).toBeUndefined() expect(entry.toolInput).toBeUndefined() expect(entry.lastAssistantMessage).toBeUndefined() + expect(entry.lastAssistantMessageIsToolOutput).toBeUndefined() }) it('preserves prior agentType when payload omits it', () => { diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index 5a6293ae5a4..6cd68bdfca9 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -2355,6 +2355,7 @@ export const createAgentStatusSlice: StateCreator { expect(childDriven?.payload.interactivePrompt).toBeUndefined() }) + it('preserves tool-output provenance when restoring the lead preview after an answer', () => { + claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'inspect and ask' }) + claudeEvent({ + hook_event_name: 'PostToolUse', + tool_name: 'Bash', + tool_response: { content: [{ type: 'text', text: 'raw command output' }] } + }) + claudeEvent({ + hook_event_name: 'PreToolUse', + tool_name: 'AskUserQuestion', + tool_input: { questions: [{ question: 'Continue?' }] } + }) + + clearClaudeAnsweredQuestionWait(state, PANE_KEY) + + expect(state.lastToolByPaneKey.get(PANE_KEY)).toMatchObject({ + lastAssistantMessage: 'raw command output', + lastAssistantMessageIsToolOutput: true + }) + }) + it('restores the stashed lead state for an answered child question', () => { claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'go' }) claudeEvent({ hook_event_name: 'SubagentStart', agent_id: 'a1', agent_type: 'probe' }) diff --git a/src/shared/agent-hook-listener/listener-event.ts b/src/shared/agent-hook-listener/listener-event.ts index cfbe6394a71..3dd8291d673 100644 --- a/src/shared/agent-hook-listener/listener-event.ts +++ b/src/shared/agent-hook-listener/listener-event.ts @@ -51,5 +51,9 @@ export type ToolSnapshot = { hasToolUpdate?: boolean hasToolInputField?: boolean lastAssistantMessage?: string + /** True when `lastAssistantMessage` was taken from a tool result/error rather than + * assistant prose. Status cards still show it; the native-chat streaming bubble + * must not, or a tool's stdout renders as the agent's reply. */ + lastAssistantMessageIsToolOutput?: boolean clearLastAssistantMessage?: boolean } diff --git a/src/shared/agent-hook-listener/prompt-fields.ts b/src/shared/agent-hook-listener/prompt-fields.ts index d76f67f06a8..4c819ab2022 100644 --- a/src/shared/agent-hook-listener/prompt-fields.ts +++ b/src/shared/agent-hook-listener/prompt-fields.ts @@ -146,7 +146,15 @@ export function resolveToolState( interactivePrompt: update.interactivePrompt, lastAssistantMessage: update.clearLastAssistantMessage ? undefined - : (update.lastAssistantMessage ?? previous.lastAssistantMessage) + : (update.lastAssistantMessage ?? previous.lastAssistantMessage), + // Why: the provenance flag has to move with the value it describes — inherit it + // only when the message itself is inherited, or a later prose turn keeps the + // previous tool result's flag and stays suppressed in native chat. + lastAssistantMessageIsToolOutput: update.clearLastAssistantMessage + ? undefined + : update.lastAssistantMessage === undefined + ? previous.lastAssistantMessageIsToolOutput + : update.lastAssistantMessageIsToolOutput } state.lastToolByPaneKey.set(paneKey, merged) return merged diff --git a/src/shared/agent-hook-listener/providers/amp-events.ts b/src/shared/agent-hook-listener/providers/amp-events.ts index cbca0e4d175..4f9adb25911 100644 --- a/src/shared/agent-hook-listener/providers/amp-events.ts +++ b/src/shared/agent-hook-listener/providers/amp-events.ts @@ -78,6 +78,7 @@ export function normalizeAmpEvent( toolInput: snapshot.toolInput, interactivePrompt: snapshot.interactivePrompt, lastAssistantMessage: snapshot.lastAssistantMessage, + lastAssistantMessageIsToolOutput: snapshot.lastAssistantMessageIsToolOutput, interrupted }) if (normalized && eventName === 'agent.end') { diff --git a/src/shared/agent-hook-listener/providers/amp-tool-fields.ts b/src/shared/agent-hook-listener/providers/amp-tool-fields.ts index 96d85304ee0..2bd8d3a8781 100644 --- a/src/shared/agent-hook-listener/providers/amp-tool-fields.ts +++ b/src/shared/agent-hook-listener/providers/amp-tool-fields.ts @@ -36,6 +36,7 @@ export function extractAmpToolFields( extractToolResponseText(hookPayload.result) if (responseText) { update.lastAssistantMessage = responseText + update.lastAssistantMessageIsToolOutput = true } } return update diff --git a/src/shared/agent-hook-listener/providers/antigravity-events.ts b/src/shared/agent-hook-listener/providers/antigravity-events.ts index 8465e9b56d0..425afd73db0 100644 --- a/src/shared/agent-hook-listener/providers/antigravity-events.ts +++ b/src/shared/agent-hook-listener/providers/antigravity-events.ts @@ -74,7 +74,8 @@ export function normalizeAntigravityEvent( toolName: snapshot.toolName, toolInput: snapshot.toolInput, interactivePrompt: snapshot.interactivePrompt, - lastAssistantMessage: snapshot.lastAssistantMessage + lastAssistantMessage: snapshot.lastAssistantMessage, + lastAssistantMessageIsToolOutput: snapshot.lastAssistantMessageIsToolOutput }) // Why: Antigravity can emit Stop with fullyIdle=false between tool steps; only a fully idle Stop is terminal, else the sidebar bounces done -> working and ignores later tool updates. if (eventName === 'Stop' && !stopStillBusy && transcriptPath) { diff --git a/src/shared/agent-hook-listener/providers/claude-roster-state.ts b/src/shared/agent-hook-listener/providers/claude-roster-state.ts index 852dce162b2..49a032edf9e 100644 --- a/src/shared/agent-hook-listener/providers/claude-roster-state.ts +++ b/src/shared/agent-hook-listener/providers/claude-roster-state.ts @@ -198,7 +198,8 @@ export function seedClaudeLeadTurnFromPersistedStatus( } if (status.payload.lastAssistantMessage) { state.lastToolByPaneKey.set(paneKey, { - lastAssistantMessage: status.payload.lastAssistantMessage + lastAssistantMessage: status.payload.lastAssistantMessage, + lastAssistantMessageIsToolOutput: status.payload.lastAssistantMessageIsToolOutput }) } } @@ -238,7 +239,10 @@ export function clearClaudePendingWaitForAgent( state.lastToolByPaneKey.set( paneKey, previousTool?.lastAssistantMessage - ? { lastAssistantMessage: previousTool.lastAssistantMessage } + ? { + lastAssistantMessage: previousTool.lastAssistantMessage, + lastAssistantMessageIsToolOutput: previousTool.lastAssistantMessageIsToolOutput + } : {} ) } @@ -260,7 +264,10 @@ export function clearClaudeAnsweredQuestionWait( state.lastToolByPaneKey.set( paneKey, previousTool?.lastAssistantMessage - ? { lastAssistantMessage: previousTool.lastAssistantMessage } + ? { + lastAssistantMessage: previousTool.lastAssistantMessage, + lastAssistantMessageIsToolOutput: previousTool.lastAssistantMessageIsToolOutput + } : {} ) const resolved = resolveClaudePaneStatus(state, paneKey, restored) diff --git a/src/shared/agent-hook-listener/providers/claude-status-build.ts b/src/shared/agent-hook-listener/providers/claude-status-build.ts index aa5d356d623..e865a5c841d 100644 --- a/src/shared/agent-hook-listener/providers/claude-status-build.ts +++ b/src/shared/agent-hook-listener/providers/claude-status-build.ts @@ -45,6 +45,7 @@ export function buildClaudeStatusPayload( toolInput: snapshot.toolInput, interactivePrompt: snapshot.interactivePrompt, lastAssistantMessage: snapshot.lastAssistantMessage, + lastAssistantMessageIsToolOutput: snapshot.lastAssistantMessageIsToolOutput, interrupted: options.interrupted, sessionBoundary: options.sessionBoundary, turnCompletedAt: options.turnCompletedAt, diff --git a/src/shared/agent-hook-listener/providers/claude-tool-fields.ts b/src/shared/agent-hook-listener/providers/claude-tool-fields.ts index f980bbb34e7..f0ce6fb4297 100644 --- a/src/shared/agent-hook-listener/providers/claude-tool-fields.ts +++ b/src/shared/agent-hook-listener/providers/claude-tool-fields.ts @@ -36,6 +36,7 @@ export function extractClaudeToolFields( const responseText = extractToolResponseText(hookPayload.tool_response) if (responseText) { update.lastAssistantMessage = responseText + update.lastAssistantMessageIsToolOutput = true } } if (eventName === 'PostToolUseFailure') { @@ -45,6 +46,7 @@ export function extractClaudeToolFields( readString(hookPayload, 'message') if (errorText) { update.lastAssistantMessage = errorText + update.lastAssistantMessageIsToolOutput = true } } if (eventName === 'Stop') { diff --git a/src/shared/agent-hook-listener/providers/codex-events.ts b/src/shared/agent-hook-listener/providers/codex-events.ts index 9df00a6ea1e..bcd0474b940 100644 --- a/src/shared/agent-hook-listener/providers/codex-events.ts +++ b/src/shared/agent-hook-listener/providers/codex-events.ts @@ -49,6 +49,7 @@ export function buildCodexStatusPayload( toolInput: snapshot.toolInput, interactivePrompt: snapshot.interactivePrompt, lastAssistantMessage: snapshot.lastAssistantMessage, + lastAssistantMessageIsToolOutput: snapshot.lastAssistantMessageIsToolOutput, subagents: codexRosterToSnapshots(state.codexSubagentRosterByPaneKey.get(paneKey)) }) } diff --git a/src/shared/agent-hook-listener/providers/command-code-events.ts b/src/shared/agent-hook-listener/providers/command-code-events.ts index cfc503993ac..d9efcd51f26 100644 --- a/src/shared/agent-hook-listener/providers/command-code-events.ts +++ b/src/shared/agent-hook-listener/providers/command-code-events.ts @@ -39,6 +39,7 @@ export function normalizeCommandCodeEvent( toolName: snapshot.toolName, toolInput: snapshot.toolInput, interactivePrompt: snapshot.interactivePrompt, - lastAssistantMessage: snapshot.lastAssistantMessage + lastAssistantMessage: snapshot.lastAssistantMessage, + lastAssistantMessageIsToolOutput: snapshot.lastAssistantMessageIsToolOutput }) } diff --git a/src/shared/agent-hook-listener/providers/command-code-tool-fields.ts b/src/shared/agent-hook-listener/providers/command-code-tool-fields.ts index 35788f7330f..5d785d832c3 100644 --- a/src/shared/agent-hook-listener/providers/command-code-tool-fields.ts +++ b/src/shared/agent-hook-listener/providers/command-code-tool-fields.ts @@ -31,6 +31,7 @@ export function extractCommandCodeToolFields( extractToolResponseText(hookPayload.tool_output) if (responseText) { update.lastAssistantMessage = responseText + update.lastAssistantMessageIsToolOutput = true } } return update diff --git a/src/shared/agent-hook-listener/providers/copilot-events.ts b/src/shared/agent-hook-listener/providers/copilot-events.ts index a9c599ef3d7..a0749f19beb 100644 --- a/src/shared/agent-hook-listener/providers/copilot-events.ts +++ b/src/shared/agent-hook-listener/providers/copilot-events.ts @@ -68,6 +68,7 @@ export function normalizeCopilotEvent( toolName: snapshot.toolName, toolInput: snapshot.toolInput, interactivePrompt: snapshot.interactivePrompt, - lastAssistantMessage: snapshot.lastAssistantMessage + lastAssistantMessage: snapshot.lastAssistantMessage, + lastAssistantMessageIsToolOutput: snapshot.lastAssistantMessageIsToolOutput }) } diff --git a/src/shared/agent-hook-listener/providers/copilot-tool-fields.ts b/src/shared/agent-hook-listener/providers/copilot-tool-fields.ts index d33fcd63677..491be19a30f 100644 --- a/src/shared/agent-hook-listener/providers/copilot-tool-fields.ts +++ b/src/shared/agent-hook-listener/providers/copilot-tool-fields.ts @@ -158,6 +158,7 @@ export function extractCopilotToolFields( extractToolResponseText(hookPayload.toolResponse) if (responseText) { update.lastAssistantMessage = responseText + update.lastAssistantMessageIsToolOutput = true } } if (eventName === 'PostToolUseFailure' || eventName === 'ErrorOccurred') { @@ -169,6 +170,7 @@ export function extractCopilotToolFields( readFirstString(hookPayload, ['error_message', 'errorMessage', 'error', 'message']) if (errorText) { update.lastAssistantMessage = errorText + update.lastAssistantMessageIsToolOutput = true } } if (eventName === 'Notification') { diff --git a/src/shared/agent-hook-listener/providers/cursor-events.ts b/src/shared/agent-hook-listener/providers/cursor-events.ts index 1c933c96361..71ff5d523da 100644 --- a/src/shared/agent-hook-listener/providers/cursor-events.ts +++ b/src/shared/agent-hook-listener/providers/cursor-events.ts @@ -61,6 +61,7 @@ export function normalizeCursorEvent( toolInput: snapshot.toolInput, interactivePrompt: snapshot.interactivePrompt, lastAssistantMessage: snapshot.lastAssistantMessage, + lastAssistantMessageIsToolOutput: snapshot.lastAssistantMessageIsToolOutput, interrupted }) } diff --git a/src/shared/agent-hook-listener/providers/cursor-tool-fields.ts b/src/shared/agent-hook-listener/providers/cursor-tool-fields.ts index 648077a0f66..7e0ab173c18 100644 --- a/src/shared/agent-hook-listener/providers/cursor-tool-fields.ts +++ b/src/shared/agent-hook-listener/providers/cursor-tool-fields.ts @@ -35,6 +35,7 @@ export function extractCursorToolFields( const responseText = extractToolResponseText(hookPayload.tool_output) if (responseText) { update.lastAssistantMessage = responseText + update.lastAssistantMessageIsToolOutput = true } } if (eventName === 'postToolUseFailure') { @@ -44,6 +45,7 @@ export function extractCursorToolFields( readString(hookPayload, 'error') if (errorText) { update.lastAssistantMessage = errorText + update.lastAssistantMessageIsToolOutput = true } } return update diff --git a/src/shared/agent-hook-listener/providers/devin-events.ts b/src/shared/agent-hook-listener/providers/devin-events.ts index bcb77020a3b..9ebe16f660d 100644 --- a/src/shared/agent-hook-listener/providers/devin-events.ts +++ b/src/shared/agent-hook-listener/providers/devin-events.ts @@ -56,6 +56,7 @@ export function normalizeDevinEvent( toolInput: snapshot.toolInput, interactivePrompt: snapshot.interactivePrompt, lastAssistantMessage: snapshot.lastAssistantMessage, + lastAssistantMessageIsToolOutput: snapshot.lastAssistantMessageIsToolOutput, interrupted }) } diff --git a/src/shared/agent-hook-listener/providers/droid-events.ts b/src/shared/agent-hook-listener/providers/droid-events.ts index dbebc73cc13..bf5022dffdd 100644 --- a/src/shared/agent-hook-listener/providers/droid-events.ts +++ b/src/shared/agent-hook-listener/providers/droid-events.ts @@ -74,6 +74,7 @@ export function normalizeDroidEvent( toolName: snapshot.toolName, toolInput: snapshot.toolInput, interactivePrompt: snapshot.interactivePrompt, - lastAssistantMessage: snapshot.lastAssistantMessage + lastAssistantMessage: snapshot.lastAssistantMessage, + lastAssistantMessageIsToolOutput: snapshot.lastAssistantMessageIsToolOutput }) } diff --git a/src/shared/agent-hook-listener/providers/droid-tool-fields.ts b/src/shared/agent-hook-listener/providers/droid-tool-fields.ts index 4bcfe5e560f..eaf6df482dd 100644 --- a/src/shared/agent-hook-listener/providers/droid-tool-fields.ts +++ b/src/shared/agent-hook-listener/providers/droid-tool-fields.ts @@ -80,6 +80,7 @@ export function extractDroidToolFields( extractToolResponseText(hookPayload.tool_output) if (responseText) { update.lastAssistantMessage = responseText + update.lastAssistantMessageIsToolOutput = true } } return update diff --git a/src/shared/agent-hook-listener/providers/gemini-events.ts b/src/shared/agent-hook-listener/providers/gemini-events.ts index 0d7de6316db..189b8c7e1d6 100644 --- a/src/shared/agent-hook-listener/providers/gemini-events.ts +++ b/src/shared/agent-hook-listener/providers/gemini-events.ts @@ -45,6 +45,7 @@ export function normalizeGeminiEvent( toolName: snapshot.toolName, toolInput: snapshot.toolInput, interactivePrompt: snapshot.interactivePrompt, - lastAssistantMessage: snapshot.lastAssistantMessage + lastAssistantMessage: snapshot.lastAssistantMessage, + lastAssistantMessageIsToolOutput: snapshot.lastAssistantMessageIsToolOutput }) } diff --git a/src/shared/agent-hook-listener/providers/grok-events.ts b/src/shared/agent-hook-listener/providers/grok-events.ts index 987c13b61a5..f3d0e100b21 100644 --- a/src/shared/agent-hook-listener/providers/grok-events.ts +++ b/src/shared/agent-hook-listener/providers/grok-events.ts @@ -98,6 +98,7 @@ export function normalizeGrokEvent( toolName: snapshot.toolName, toolInput: snapshot.toolInput, interactivePrompt: snapshot.interactivePrompt, - lastAssistantMessage: snapshot.lastAssistantMessage + lastAssistantMessage: snapshot.lastAssistantMessage, + lastAssistantMessageIsToolOutput: snapshot.lastAssistantMessageIsToolOutput }) } diff --git a/src/shared/agent-hook-listener/providers/grok-tool-fields.ts b/src/shared/agent-hook-listener/providers/grok-tool-fields.ts index 32d12511a55..249d9ab8917 100644 --- a/src/shared/agent-hook-listener/providers/grok-tool-fields.ts +++ b/src/shared/agent-hook-listener/providers/grok-tool-fields.ts @@ -63,6 +63,7 @@ export function extractGrokToolFields( readString(hookPayload, 'message') if (responseText) { update.lastAssistantMessage = responseText + update.lastAssistantMessageIsToolOutput = true } } return update diff --git a/src/shared/agent-hook-listener/providers/hermes-events.ts b/src/shared/agent-hook-listener/providers/hermes-events.ts index 3757e13430a..710cde415d2 100644 --- a/src/shared/agent-hook-listener/providers/hermes-events.ts +++ b/src/shared/agent-hook-listener/providers/hermes-events.ts @@ -49,6 +49,7 @@ export function normalizeHermesEvent( toolName: snapshot.toolName, toolInput: snapshot.toolInput, interactivePrompt: snapshot.interactivePrompt, - lastAssistantMessage: snapshot.lastAssistantMessage + lastAssistantMessage: snapshot.lastAssistantMessage, + lastAssistantMessageIsToolOutput: snapshot.lastAssistantMessageIsToolOutput }) } diff --git a/src/shared/agent-hook-listener/providers/hermes-tool-fields.ts b/src/shared/agent-hook-listener/providers/hermes-tool-fields.ts index 93502b1c1d2..865e92b20ff 100644 --- a/src/shared/agent-hook-listener/providers/hermes-tool-fields.ts +++ b/src/shared/agent-hook-listener/providers/hermes-tool-fields.ts @@ -53,6 +53,7 @@ export function extractHermesToolFields( extractToolResponseText(hookPayload.output) if (responseText) { update.lastAssistantMessage = responseText + update.lastAssistantMessageIsToolOutput = true } } return update diff --git a/src/shared/agent-hook-listener/providers/kimi-events.ts b/src/shared/agent-hook-listener/providers/kimi-events.ts index c51a8be901a..c35bfef17d0 100644 --- a/src/shared/agent-hook-listener/providers/kimi-events.ts +++ b/src/shared/agent-hook-listener/providers/kimi-events.ts @@ -68,6 +68,7 @@ export function normalizeKimiEvent( toolName: snapshot.toolName, toolInput: snapshot.toolInput, lastAssistantMessage: snapshot.lastAssistantMessage, + lastAssistantMessageIsToolOutput: snapshot.lastAssistantMessageIsToolOutput, interrupted }) } diff --git a/src/shared/agent-hook-listener/providers/opencode-family-events.ts b/src/shared/agent-hook-listener/providers/opencode-family-events.ts index 913bdbf4c03..a59c7c62bc9 100644 --- a/src/shared/agent-hook-listener/providers/opencode-family-events.ts +++ b/src/shared/agent-hook-listener/providers/opencode-family-events.ts @@ -51,6 +51,7 @@ export function normalizeOpenCodeFamilyEvent( toolInput: snapshot.toolInput, interactivePrompt: snapshot.interactivePrompt, lastAssistantMessage: snapshot.lastAssistantMessage, + lastAssistantMessageIsToolOutput: snapshot.lastAssistantMessageIsToolOutput, sessionBoundary: source === 'opencode' && eventName === 'SessionStart' ? true : undefined }) } diff --git a/src/shared/agent-hook-listener/providers/pi-family-events.ts b/src/shared/agent-hook-listener/providers/pi-family-events.ts index b3d66ee1d77..a254ed985d0 100644 --- a/src/shared/agent-hook-listener/providers/pi-family-events.ts +++ b/src/shared/agent-hook-listener/providers/pi-family-events.ts @@ -66,6 +66,7 @@ export function normalizePiCompatibleEvent( toolName: snapshot.toolName, toolInput: snapshot.toolInput, interactivePrompt: snapshot.interactivePrompt, - lastAssistantMessage: snapshot.lastAssistantMessage + lastAssistantMessage: snapshot.lastAssistantMessage, + lastAssistantMessageIsToolOutput: snapshot.lastAssistantMessageIsToolOutput }) } diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index 70509ea495a..ea377ae4f99 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -136,6 +136,10 @@ export type AgentStatusEntry = { interactivePrompt?: string /** Most recent assistant message preview, when the hook carried one. */ lastAssistantMessage?: string + /** True when `lastAssistantMessage` came from a tool result/error, not assistant prose. + * Status/dashboard surfaces still render it; native chat's streaming bubble must not, + * or a tool's stdout is shown as the agent's reply. */ + lastAssistantMessageIsToolOutput?: boolean /** Output of the newest completed (non-boundary) turn, kept across the next `working`. * Why: batched publications can fold a whole done→working turn into one notification, * so `lastAssistantMessage` is already cleared by the time a subscriber observes it. */ @@ -180,6 +184,8 @@ export type AgentStatusPayload = { * AgentStatusEntry field for semantics. Not truncated like toolInput. */ interactivePrompt?: string lastAssistantMessage?: string + /** See the AgentStatusEntry field for semantics. */ + lastAssistantMessageIsToolOutput?: boolean interrupted?: boolean /** True when this `done` marks a session boundary (connect/resume/clear landing idle, * e.g. Claude SessionStart — STA-3386), not a completed turn. Consumers that react to @@ -222,6 +228,9 @@ export function pickParsedAgentStatusPayload( ...(row.lastAssistantMessage !== undefined ? { lastAssistantMessage: row.lastAssistantMessage } : {}), + ...(row.lastAssistantMessageIsToolOutput !== undefined + ? { lastAssistantMessageIsToolOutput: row.lastAssistantMessageIsToolOutput } + : {}), ...(row.interrupted !== undefined ? { interrupted: row.interrupted } : {}), ...(row.sessionBoundary !== undefined ? { sessionBoundary: row.sessionBoundary } : {}), ...(row.turnCompletedAt !== undefined ? { turnCompletedAt: row.turnCompletedAt } : {}), @@ -391,6 +400,10 @@ function normalizeAgentStatusObject(parsed: unknown): ParsedAgentStatusPayload | obj.lastAssistantMessage, AGENT_STATUS_ASSISTANT_MESSAGE_MAX_LENGTH ), + // Why: absent/false collapse to undefined so the flag only ever means "known tool output"; + // an old host that never sends it keeps today's behavior instead of silently suppressing. + lastAssistantMessageIsToolOutput: + obj.lastAssistantMessageIsToolOutput === true ? true : undefined, // Why: only meaningful on `done`; coerce to undefined elsewhere so it can't leak stale truth across transitions. interrupted: obj.interrupted === true && state === 'done' ? true : undefined, sessionBoundary: obj.sessionBoundary === true && state === 'done' ? true : undefined, diff --git a/src/shared/native-chat-streaming.test.ts b/src/shared/native-chat-streaming.test.ts index 8795b1b4430..12ff5c7dfac 100644 --- a/src/shared/native-chat-streaming.test.ts +++ b/src/shared/native-chat-streaming.test.ts @@ -83,6 +83,31 @@ describe('deriveNativeChatStreamingText', () => { ).toBeNull() }) + it('drops a preview flagged as tool output even when it leads the transcript', () => { + // Regression: providers publish a tool's stdout as `lastAssistantMessage` for status + // cards. It leads every transcript assistant turn and never appears in one, so without + // this gate it rendered as the reply and no catch-up rule could ever retire it. + expect( + deriveNativeChatStreamingText({ + messages: [assistant('Partial')], + previewText: 'Exit code 1\nimport { Foo } from "./foo"\nexport function bar() {}', + working: true, + previewIsToolOutput: true + }) + ).toBeNull() + }) + + it('still shows a leading preview when it is not tool output', () => { + expect( + deriveNativeChatStreamingText({ + messages: [assistant('Partial')], + previewText: 'Partial answer that is now much longer than before', + working: true, + previewIsToolOutput: false + }) + ).toBe('Partial answer that is now much longer than before') + }) + it('keeps showing while the preview still leads (grows past the last turn)', () => { // The transcript hasn't flushed the new content yet; preview is longer. expect( diff --git a/src/shared/native-chat-streaming.ts b/src/shared/native-chat-streaming.ts index f32eae7e2a6..eacce8dd2cb 100644 --- a/src/shared/native-chat-streaming.ts +++ b/src/shared/native-chat-streaming.ts @@ -30,14 +30,20 @@ function assistantText(message: NativeChatMessage | undefined): string { * duplicate or flicker as the transcript catches up. * * `working` gates it: a stale preview from a finished turn never shows. + * + * `previewIsToolOutput` hard-gates it: several providers publish a tool's stdout or + * error as `lastAssistantMessage` so status cards can preview it. That text is not the + * reply, and it never appears in a transcript assistant block — so the catch-up rules + * below can never retire it and it would sit in the chat until the turn ended. */ export function deriveNativeChatStreamingText(args: { messages: readonly NativeChatMessage[] previewText: string | null | undefined working: boolean + previewIsToolOutput?: boolean }): string | null { - const { messages, previewText, working } = args - if (!working) { + const { messages, previewText, working, previewIsToolOutput } = args + if (!working || previewIsToolOutput) { return null } const text = previewText?.trim()