Files
orca/mobile/src/session/MobileNativeChatMessage.test.ts
T
Brennan Benson bb44f74d00 Keep native chat /clear in the same conversation (#26579)
* Keep clear in the same chat and reset its provider context

* Derive clear pauses only from journal membership and cover older rewind clients

* Point queued command rewind test at the structured hook

* Group queue consume errors and rewind replay assertions
2026-10-08 16:00:29 -07:00

514 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createElement } from 'react'
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { AgentJournalToolCallItem } from '../../../src/shared/agent-session-journal-types'
import { MAX_TOOL_DETAIL_LENGTH } from '../../../src/shared/native-chat-tool-summary'
import { projectStructuredItemToNativeChat } from '../../../src/shared/structured-agent-session-projection'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { AGENT_SESSION_HOST_STATUS_COPY } from '../../../src/shared/agent-session-host-status-rows'
import { colors } from '../theme/mobile-theme'
import { styles } from './mobile-native-chat-message-styles'
vi.mock('react-native', async () => {
const React = await import('react')
const Text = ({ children, ...props }: { children?: unknown }): unknown =>
React.createElement('Text', props, children)
return {
ActivityIndicator: 'ActivityIndicator',
Animated: {
Text,
Value: class {
setValue(): void {}
},
loop: (animation: unknown) => animation,
sequence: () => ({ start: vi.fn(), stop: vi.fn() }),
timing: () => ({ start: vi.fn(), stop: vi.fn() })
},
Image: 'Image',
Platform: { OS: 'ios' },
Pressable: 'Pressable',
ScrollView: ({ children, ...props }: { children?: unknown }) =>
React.createElement('ScrollView', props, children),
Text,
View: ({ children, ...props }: { children?: unknown }) =>
React.createElement('View', props, children),
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }
}
})
vi.mock('expo-clipboard', () => ({ setStringAsync: vi.fn() }))
vi.mock('lucide-react-native', () => ({
ArrowUp: 'ArrowUp',
Brain: 'Brain',
ChevronDown: 'ChevronDown',
Copy: 'Copy',
SquareChevronRight: 'SquareChevronRight',
SquareTerminal: 'SquareTerminal',
Wrench: 'Wrench',
ChevronRight: 'ChevronRight'
}))
vi.mock('../components/MobileMarkdown', () => ({ MobileMarkdown: 'MobileMarkdown' }))
vi.mock('./MobileNativeChatMessageActionsSheet', () => ({
MobileNativeChatMessageActionsSheet: 'MessageActionsSheet'
}))
import { MobileNativeChatMessage } from './MobileNativeChatMessage'
function userMessage(blocks: NativeChatMessage['blocks']): NativeChatMessage {
return { id: 'u1', role: 'user', blocks, timestamp: null, source: 'transcript' }
}
function toolMessage(blocks: NativeChatMessage['blocks']): NativeChatMessage {
return { id: 'a1', role: 'assistant', blocks, timestamp: null, source: 'transcript' }
}
describe('MobileNativeChatMessage', () => {
let renderer: ReactTestRenderer | null = null
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
})
function render(
message: NativeChatMessage,
props: {
fontScale?: number
toolsExpanded?: boolean
structuredActivityUi?: boolean
activeTurnIsWorking?: boolean
turnExpanded?: boolean
turnStatus?: {
startedAt: number | null
thinking: boolean
workedSeconds: number | null
} | null
onToggleTurn?: () => void
reasoningIsLive?: boolean
} = {}
): ReactTestRenderer {
act(() => {
renderer = create(createElement(MobileNativeChatMessage, { message, ...props }))
})
return renderer!
}
const textIn = (node: ReactTestInstance): string[] =>
node.findAllByType('Text' as never).map((text) => String(text.children.join('')))
it('renders the shared clear boundary as one muted divider with prior history retained', () => {
const message = projectStructuredItemToNativeChat({
itemId: 'clear',
revision: 1,
sequence: 5,
observedAt: 5,
body: {
kind: 'status',
text: 'Context cleared',
presentation: 'context-cleared',
contextClear: { operationId: 'clear', afterFence: 3, clearedAt: 5 }
}
})!
const tree = render(message)
expect(textIn(tree.root)).toEqual(['Context cleared'])
expect(tree.root.findAll((node) => String(node.type) === 'MobileMarkdown')).toHaveLength(0)
expect(
tree.root.findAll(
(node) => String(node.type) === 'View' && node.props.style === styles.contextBoundary
)
).toHaveLength(1)
expect(styles.contextBoundary.borderTopColor).toBe(colors.borderSubtle)
})
it.each(['system', 'user'] as const)(
'renders a %s host notice as selectable muted text rather than a markdown answer',
(role) => {
const tree = render(
{
id: 'notice',
role,
timestamp: 1,
blocks: [
{ type: 'text', text: 'provider fallback', presentation: 'history-item-too-large' }
]
},
{ fontScale: 1.5 }
)
expect(tree.root.findAll((node) => String(node.type) === 'MobileMarkdown')).toHaveLength(0)
const text = tree.root.find((node) => String(node.type) === 'Text')
expect(text.props.children).toBe(AGENT_SESSION_HOST_STATUS_COPY['history-item-too-large'])
expect(text.props.selectable).toBe(true)
expect(Object.assign({}, ...text.props.style)).toMatchObject({
color: colors.textMuted,
fontSize: 25.5
})
}
)
it("names another agent's message and sets it apart from the person's bubble", () => {
const agentMessage: NativeChatMessage = {
...userMessage([{ type: 'text', text: 'You have 1 orchestration message.' }]),
from: {
kind: 'agent',
senders: [
{
party: { address: 'term_a', terminalHandle: 'term_a', orcaSessionId: null },
name: 'Coder'
}
],
orchestration: null
}
}
const tree = render(agentMessage)
expect(textIn(tree.root)[0]).toBe('Message from Coder')
// Left-aligned agent prose, not the person's inverted bubble.
expect(tree.root.findAll((node) => String(node.type) === 'MobileMarkdown')).toHaveLength(1)
const rows = tree.root.findAll(
(node) => String(node.type) === 'View' && Array.isArray(node.props.style)
)
expect(rows.some((row) => row.props.style.includes(styles.rowUser))).toBe(false)
})
it('preserves an ordinary assistant answer without interpreting its text as a host notice', () => {
const tree = render(toolMessage([{ type: 'text', text: 'provider fallback' }]))
expect(tree.root.find((node) => String(node.type) === 'MobileMarkdown').props.content).toBe(
'provider fallback'
)
})
it('renders a loadable preview URI as an image thumbnail', () => {
const tree = render(userMessage([{ type: 'image-ref', url: 'file:///a.jpg', alt: 'a photo' }]))
const image = tree.root.findByType('Image' as never)
expect(image.props.source).toEqual({ uri: 'file:///a.jpg' })
expect(image.props.accessibilityLabel).toBe('a photo')
})
it('prefers the url over the path when both are present', () => {
const tree = render(
userMessage([{ type: 'image-ref', url: 'file:///local.jpg', path: '/tmp/host.png' }])
)
expect(tree.root.findByType('Image' as never).props.source).toEqual({
uri: 'file:///local.jpg'
})
})
it('falls back to a text placeholder for a bare host path', () => {
// A host temp path (e.g. on an SSH host) is not loadable on the device.
const tree = render(userMessage([{ type: 'image-ref', path: '/tmp/host.png' }]))
expect(tree.root.findAllByType('Image' as never)).toHaveLength(0)
const texts = tree.root
.findAllByType('Text' as never)
.map((node) => String(node.children.join('')))
expect(texts.some((text) => text.includes('/tmp/host.png'))).toBe(true)
})
it('makes user message text selectable', () => {
const tree = render(userMessage([{ type: 'text', text: 'Prompt I typed' }]))
const text = tree.root
.findAllByType('Text' as never)
.find((node) => String(node.children.join('')) === 'Prompt I typed')
expect(text?.props.selectable).toBe(true)
})
it('routes assistant prose through selectable Markdown', () => {
const tree = render(toolMessage([{ type: 'text', text: 'Agent reply prose' }]))
const markdown = tree.root.findByType('MobileMarkdown' as never)
expect(markdown.props.content).toBe('Agent reply prose')
expect(markdown.props.rangeSelectable).toBe(true)
})
it('labels a tool row with the target path instead of raw input JSON', () => {
const tree = render(
toolMessage([{ type: 'tool-call', name: 'Read', input: { file_path: 'src/index.ts' } }]),
{ toolsExpanded: true }
)
const texts = textIn(tree.root)
expect(texts).toContain('src/index.ts')
expect(texts.some((text) => text.includes('"file_path":"src/index.ts"'))).toBe(false)
})
it('bounds expanded diff-less tool input before native text layout', () => {
const tree = render(
toolMessage([
{ type: 'tool-call', name: 'CustomTool', input: { payload: 'x'.repeat(100_000) } }
]),
{ toolsExpanded: true }
)
const detail = textIn(tree.root).find((text) => text.startsWith('{\n'))
expect(detail).toHaveLength(MAX_TOOL_DETAIL_LENGTH + 1)
expect(detail?.endsWith('…')).toBe(true)
})
it('expands formatted detail for a collapsed JSON-string tool input', () => {
const tree = render(
toolMessage([
{
type: 'tool-call',
name: 'CustomTool',
input: '{"cmd":"git status","description":"Inspect changes"}'
}
])
)
const pressableWith = (label: string): ReactTestInstance =>
tree.root.findAllByType('Pressable' as never).find((node) => textIn(node).includes(label))!
act(() => pressableWith('1×').props.onPress())
// The row label is the command, and the detail stays closed until tapped.
expect(textIn(tree.root)).toContain('git status')
expect(textIn(tree.root).some((text) => text.startsWith('{\n'))).toBe(false)
act(() => pressableWith('CustomTool').props.onPress())
expect(textIn(tree.root)).toContain(
'{\n "cmd": "git status",\n "description": "Inspect changes"\n}'
)
})
it('does not echo the row label as detail when a row has nothing to expand', () => {
// The Tools toggle opens every row at once, bypassing the tap guard — a row
// whose formatted input is its own label would echo itself in a panel that
// no tap can dismiss.
const tree = render(toolMessage([{ type: 'tool-call', name: 'ListTodos', input: '{}' }]), {
toolsExpanded: true
})
expect(textIn(tree.root).filter((text) => text === '{}')).toHaveLength(1)
// The chevron has to agree with the panel, or the row claims to be open over
// nothing and the tap that would close it is guarded off. Only the run header
// is open here; the row itself stays collapsed.
expect(tree.root.findAllByType('ChevronDown' as never)).toHaveLength(1)
expect(tree.root.findAllByType('SquareChevronRight' as never)).toHaveLength(1)
})
it('does not expand a plain input that already fits in the row label', () => {
const input = 'x'.repeat(60)
const tree = render(toolMessage([{ type: 'tool-call', name: 'CustomTool', input }]), {
toolsExpanded: true
})
expect(textIn(tree.root).filter((text) => text === input)).toHaveLength(1)
expect(tree.root.findAllByType('ChevronDown' as never)).toHaveLength(1)
expect(tree.root.findAllByType('SquareChevronRight' as never)).toHaveLength(1)
})
describe('output a call left when it ended early', () => {
function projectedCall(ending: Pick<AgentJournalToolCallItem, 'endedAs'>): NativeChatMessage {
const body: AgentJournalToolCallItem = {
kind: 'tool-call',
name: 'shell',
input: { command: 'sleep 20' },
state: 'failed',
...ending,
output: { head: 'partial', byteLength: 7, digest: 'd', truncated: false }
}
const projected = projectStructuredItemToNativeChat({
itemId: 'call',
sequence: 1,
revision: 1,
observedAt: 100,
body
})
if (!projected) {
throw new Error('the call projects no message')
}
return { ...projected, id: 'a1', source: 'transcript' }
}
const outputStyle = (message: NativeChatMessage): unknown => {
const tree = render(message, { toolsExpanded: true })
const output = tree.root
.findAllByType('Text' as never)
.find((node) => node.children.join('') === 'partial')
// The tint is on the result box: the nearest View around the output text.
let box = output?.parent ?? null
while (box && String(box.type) !== 'View') {
box = box.parent
}
return box?.props.style
}
it('shows the output a stop cut short without the error tint', () => {
expect(outputStyle(projectedCall({ endedAs: 'interrupted' }))).toEqual([
expect.any(Object),
false
])
})
it('keeps the error tint on a call nothing proved was cut short', () => {
expect(outputStyle(projectedCall({ endedAs: 'unverifiable' }))).toEqual([
expect.any(Object),
expect.objectContaining({ backgroundColor: expect.any(String) })
])
})
})
describe('structured activity UI', () => {
const runningCall = {
type: 'tool-call' as const,
name: 'Bash',
input: { command: 'npm test' },
state: 'running' as const
}
const settledCall = {
type: 'tool-call' as const,
name: 'Read',
input: { file_path: 'a/b.ts' },
state: 'completed' as const
}
it('shows the live tool label with a terminal glyph while a command runs', () => {
const tree = render(toolMessage([runningCall]), {
structuredActivityUi: true,
activeTurnIsWorking: true
})
expect(textIn(tree.root)).toContain('Running npm test')
expect(tree.root.findAllByType('SquareTerminal' as never)).toHaveLength(1)
expect(tree.root.findAllByType('Wrench' as never)).toHaveLength(0)
})
it('uses the wrench glyph for a non-command tool', () => {
const tree = render(
toolMessage([
{ type: 'tool-call', name: 'Read', input: { file_path: 'a/b.ts' }, state: 'running' }
]),
{ structuredActivityUi: true, activeTurnIsWorking: true }
)
expect(textIn(tree.root)).toContain('Running Read a/b.ts')
expect(tree.root.findAllByType('Wrench' as never)).toHaveLength(1)
})
it('falls back to the collapsed count row once the run settles', () => {
const tree = render(toolMessage([settledCall]), {
structuredActivityUi: true,
activeTurnIsWorking: true
})
expect(textIn(tree.root)).not.toContain('Running Read a/b.ts')
expect(textIn(tree.root)).toContain('1×')
})
it("hides a completed turn's activity until the turn caret discloses it", () => {
const collapsed = render(toolMessage([settledCall]), {
structuredActivityUi: true,
activeTurnIsWorking: false
})
expect(textIn(collapsed.root)).not.toContain('1×')
act(() => collapsed.unmount())
const disclosed = render(toolMessage([settledCall]), {
structuredActivityUi: true,
activeTurnIsWorking: false,
turnExpanded: true
})
expect(textIn(disclosed.root)).toContain('1×')
})
it('lets the global Tools toggle reveal a hidden settled run', () => {
// Otherwise the composer's Tools control is a no-op on every settled turn.
const tree = render(toolMessage([settledCall]), {
structuredActivityUi: true,
activeTurnIsWorking: false,
toolsExpanded: true
})
expect(textIn(tree.root)).toContain('1\u00d7')
})
it('keeps the bridge lane on its always-visible tool run', () => {
const tree = render(toolMessage([settledCall]), { activeTurnIsWorking: false })
expect(textIn(tree.root)).toContain('1×')
expect(tree.root.findAllByType('Wrench' as never)).toHaveLength(0)
})
it('renders the settled turn status row under a user message', () => {
const tree = render(userMessage([{ type: 'text', text: 'go' }]), {
structuredActivityUi: true,
turnStatus: { startedAt: Date.now() - 3_000, thinking: false, workedSeconds: 3 }
})
expect(textIn(tree.root)).toContain('Worked for 3s')
})
it('does not render a turn status row without one', () => {
const tree = render(userMessage([{ type: 'text', text: 'go' }]), {
structuredActivityUi: true
})
expect(textIn(tree.root)).toEqual(['go'])
})
})
describe('a reasoning row', () => {
const reasoning = (fields: Partial<NativeChatMessage> = {}): NativeChatMessage => ({
id: 'r1',
role: 'reasoning',
blocks: [{ type: 'text', text: 'Weighing two approaches' }],
timestamp: 1_000,
source: 'transcript',
state: 'completed',
completedAt: 4_000,
...fields
})
const toggleOf = (tree: ReactTestRenderer): ReactTestInstance =>
tree.root.find(
(node) => String(node.type) === 'Pressable' && node.props.accessibilityRole === 'button'
)
const markdownIn = (tree: ReactTestRenderer): ReactTestInstance[] =>
tree.root.findAll((node) => String(node.type) === 'MobileMarkdown')
it('starts collapsed to its headline, with its text unmounted', () => {
const tree = render(reasoning())
expect(textIn(tree.root)).toContain('Thought for 3s')
expect(toggleOf(tree).props.accessibilityState).toEqual({ expanded: false })
// Said with what it is, as desktop's screen-reader prefix does, on a 32 + 2 × 6 pt target.
expect(toggleOf(tree).props.accessibilityLabel).toBe('Reasoning: Thought for 3s')
expect(toggleOf(tree).props.hitSlop).toBe(6)
expect(markdownIn(tree)).toHaveLength(0)
})
it('leads its headline with the brain, as desktop does', () => {
const [first] = toggleOf(render(reasoning())).children
expect(typeof first === 'string' ? first : first?.type).toBe('Brain')
})
it('mounts its text once opened', () => {
const tree = render(reasoning())
act(() => toggleOf(tree).props.onPress())
expect(toggleOf(tree).props.accessibilityState).toEqual({ expanded: true })
expect(markdownIn(tree).map((node) => node.props.content)).toEqual([
'Weighing two approaches'
])
})
it('draws nothing while the live line discloses it, or when blank', () => {
expect(
render(reasoning({ state: 'running' }), {
activeTurnIsWorking: true,
reasoningIsLive: true
}).toJSON()
).toBeNull()
expect(render(reasoning({ blocks: [{ type: 'text', text: ' \n ' }] })).toJSON()).toBeNull()
})
// The turn's bar is not the block's: hiding the block must not hide the bar it sits on.
it("still draws its turn's bar while the live line discloses it", () => {
const tree = render(reasoning({ state: 'running' }), {
activeTurnIsWorking: true,
reasoningIsLive: true,
turnStatus: { startedAt: 1_000, thinking: true, workedSeconds: null }
})
expect(tree.root.findAll((node) => String(node.type) === 'Pressable')).toHaveLength(0)
expect(textIn(tree.root).some((text) => text.startsWith('Working for'))).toBe(true)
})
// Only the block the line discloses hides: a subagent's or a stale open block draws, unended.
it('draws any other open block in its working turn as Reasoning', () => {
const child = render(reasoning({ state: 'running', agentId: 'sub-1' }), {
activeTurnIsWorking: true
})
expect(textIn(child.root)).toContain('Reasoning')
expect(toggleOf(child).props.accessibilityLabel).toBe('Reasoning')
})
it('says only what the host saw', () => {
expect(textIn(render(reasoning({ state: 'running' })).root)).toContain('Thought')
const unknown = render(reasoning({ state: undefined, completedAt: undefined }))
expect(textIn(unknown.root)).toContain('Reasoning')
// No "Reasoning: Reasoning".
expect(toggleOf(unknown).props.accessibilityLabel).toBe('Reasoning')
expect(textIn(render(reasoning({ completedAt: 1_300 })).root)).toContain('Thought for 1s')
})
})
})