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()