mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
fix(native-chat): stop rendering tool output as the agent's streaming reply (#17782)
* fix(native-chat): stop rendering tool output as the agent's streaming reply A tool result could appear in native chat as a raw, un-collapsed "assistant" bubble that never went away for the rest of the turn — on mobile it showed up as a wall of a source file's contents, prefixed by "Exit code 1". Providers publish a tool's stdout/error as `lastAssistantMessage` so status cards and dashboard rows can preview what the agent just did. Native chat reuses that same field as its live streaming bubble, so the preview rendered as prose. For Claude the preview is *only ever* tool output mid-turn: claude-tool-fields writes real prose exclusively at Stop, so the bubble could never contain an actual streaming reply. It also could not be retired. The bubble hides once a transcript assistant block leads with the streamed text, and tool output never lands in one — so the only remaining exit was the turn ending, which is why a long tool-heavy turn pinned it on screen. Carry provenance instead of changing what the status surfaces show: mark the writes that come from a tool result/error, keep the flag in lockstep with the value it describes through the listener merge, and have both native-chat streaming paths ignore a flagged preview. Status cards, dashboard rows and automation capture are untouched. The wire field is optional, so an older host that never sends it keeps today's behavior rather than silently suppressing previews. * fix(native-chat): preserve tool output provenance through renderer sync * fix(native-chat): retain preview provenance in Claude roster state * test(native-chat): cover restored tool preview provenance --------- Co-authored-by: Merge Sim <sim@local>
This commit is contained in:
co-authored by
Merge Sim
parent
9bc564c2aa
commit
a9e6fb7eff
@@ -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.')]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<typeof session>(() => {
|
||||
if (pending.length === 0 && commandMarkers.length === 0 && !streamingText) {
|
||||
return sessionAfterCommandBoundaries
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}))
|
||||
)
|
||||
|
||||
@@ -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<AppState> {
|
||||
}
|
||||
|
||||
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: [
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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<WebSessionTabsSyncState>
|
||||
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<WebSessionTabsSyncState>
|
||||
|
||||
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([
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -2355,6 +2355,7 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
|
||||
// card; parseAgentStatusPayload clears it on tool/state change.
|
||||
interactivePrompt: payload.interactivePrompt,
|
||||
lastAssistantMessage: payload.lastAssistantMessage,
|
||||
lastAssistantMessageIsToolOutput: payload.lastAssistantMessageIsToolOutput,
|
||||
...(lastCompletedAssistantMessage ? { lastCompletedAssistantMessage } : {}),
|
||||
// Why: reused panes can start non-orchestrated work; only final done rows keep the
|
||||
// previous lineage fallback so completed children stay grouped.
|
||||
@@ -2456,6 +2457,7 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
|
||||
entry.toolName !== existing.toolName ||
|
||||
entry.toolInput !== existing.toolInput ||
|
||||
entry.lastAssistantMessage !== existing.lastAssistantMessage ||
|
||||
entry.lastAssistantMessageIsToolOutput !== existing.lastAssistantMessageIsToolOutput ||
|
||||
entry.orchestration !== existing.orchestration ||
|
||||
entry.subagents !== existing.subagents ||
|
||||
entry.providerSession !== existing.providerSession ||
|
||||
|
||||
@@ -677,6 +677,27 @@ describe('shared agent-hook-listener', () => {
|
||||
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' })
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -36,6 +36,7 @@ export function extractAmpToolFields(
|
||||
extractToolResponseText(hookPayload.result)
|
||||
if (responseText) {
|
||||
update.lastAssistantMessage = responseText
|
||||
update.lastAssistantMessageIsToolOutput = true
|
||||
}
|
||||
}
|
||||
return update
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ export function extractCommandCodeToolFields(
|
||||
extractToolResponseText(hookPayload.tool_output)
|
||||
if (responseText) {
|
||||
update.lastAssistantMessage = responseText
|
||||
update.lastAssistantMessageIsToolOutput = true
|
||||
}
|
||||
}
|
||||
return update
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -61,6 +61,7 @@ export function normalizeCursorEvent(
|
||||
toolInput: snapshot.toolInput,
|
||||
interactivePrompt: snapshot.interactivePrompt,
|
||||
lastAssistantMessage: snapshot.lastAssistantMessage,
|
||||
lastAssistantMessageIsToolOutput: snapshot.lastAssistantMessageIsToolOutput,
|
||||
interrupted
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -56,6 +56,7 @@ export function normalizeDevinEvent(
|
||||
toolInput: snapshot.toolInput,
|
||||
interactivePrompt: snapshot.interactivePrompt,
|
||||
lastAssistantMessage: snapshot.lastAssistantMessage,
|
||||
lastAssistantMessageIsToolOutput: snapshot.lastAssistantMessageIsToolOutput,
|
||||
interrupted
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -80,6 +80,7 @@ export function extractDroidToolFields(
|
||||
extractToolResponseText(hookPayload.tool_output)
|
||||
if (responseText) {
|
||||
update.lastAssistantMessage = responseText
|
||||
update.lastAssistantMessageIsToolOutput = true
|
||||
}
|
||||
}
|
||||
return update
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ export function extractGrokToolFields(
|
||||
readString(hookPayload, 'message')
|
||||
if (responseText) {
|
||||
update.lastAssistantMessage = responseText
|
||||
update.lastAssistantMessageIsToolOutput = true
|
||||
}
|
||||
}
|
||||
return update
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ export function extractHermesToolFields(
|
||||
extractToolResponseText(hookPayload.output)
|
||||
if (responseText) {
|
||||
update.lastAssistantMessage = responseText
|
||||
update.lastAssistantMessageIsToolOutput = true
|
||||
}
|
||||
}
|
||||
return update
|
||||
|
||||
@@ -68,6 +68,7 @@ export function normalizeKimiEvent(
|
||||
toolName: snapshot.toolName,
|
||||
toolInput: snapshot.toolInput,
|
||||
lastAssistantMessage: snapshot.lastAssistantMessage,
|
||||
lastAssistantMessageIsToolOutput: snapshot.lastAssistantMessageIsToolOutput,
|
||||
interrupted
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user