Files
orca/src/shared/native-chat-streaming.test.ts
T
Brennan BensonandMerge Sim a9e6fb7eff 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>
2026-08-31 23:05:31 -07:00

132 lines
4.2 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import {
deriveNativeChatStreamingText,
nativeChatStreamingMessage,
NATIVE_CHAT_STREAMING_ID
} from './native-chat-streaming'
import type { NativeChatMessage } from './native-chat-types'
const assistant = (text: string): NativeChatMessage => ({
id: `a-${text.length}`,
role: 'assistant',
blocks: [{ type: 'text', text }],
timestamp: null,
source: 'transcript'
})
const user = (text: string): NativeChatMessage => ({
id: `u-${text.length}`,
role: 'user',
blocks: [{ type: 'text', text }],
timestamp: null,
source: 'transcript'
})
describe('deriveNativeChatStreamingText', () => {
it('returns null when not working (stale preview never shows)', () => {
expect(
deriveNativeChatStreamingText({ messages: [], previewText: 'Hello there', working: false })
).toBeNull()
})
it('returns null for empty / whitespace preview', () => {
expect(
deriveNativeChatStreamingText({ messages: [], previewText: '', working: true })
).toBeNull()
expect(
deriveNativeChatStreamingText({ messages: [], previewText: ' ', working: true })
).toBeNull()
})
it('shows the preview while it leads an empty/user-tailed transcript', () => {
expect(
deriveNativeChatStreamingText({
messages: [user('do the thing')],
previewText: 'Working on it',
working: true
})
).toBe('Working on it')
})
it('treats an optimistic user echo as the active streaming-turn boundary', () => {
const optimistic = {
...user('new prompt'),
id: 'pending:send-1',
timestamp: 20,
source: 'scrape' as const
}
expect(
deriveNativeChatStreamingText({
messages: [assistant('A much longer answer from the completed prior turn'), optimistic],
previewText: 'New reply',
working: true
})
).toBe('New reply')
})
it('drops the preview once the real assistant turn contains it (no duplicate)', () => {
expect(
deriveNativeChatStreamingText({
messages: [assistant('Working on it, here is the full answer.')],
previewText: 'Working on it',
working: true
})
).toBeNull()
})
it('drops the preview when it is not longer than the last assistant turn (no flicker)', () => {
expect(
deriveNativeChatStreamingText({
messages: [assistant('Same length text')],
previewText: 'Same length text',
working: true
})
).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(
deriveNativeChatStreamingText({
messages: [assistant('Partial')],
previewText: 'Partial answer that is now much longer than before',
working: true
})
).toBe('Partial answer that is now much longer than before')
})
})
describe('nativeChatStreamingMessage', () => {
it('builds a stable-id assistant hook message', () => {
const m = nativeChatStreamingMessage('hi')
expect(m.id).toBe(NATIVE_CHAT_STREAMING_ID)
expect(m.role).toBe('assistant')
expect(m.source).toBe('hook')
expect(m.blocks).toEqual([{ type: 'text', text: 'hi' }])
})
})