Files
orca/mobile/src/session/MobileNativeChatView.test.ts
T
Brennan BensonandMerge Sim fab78c7669 fix(native-chat): show one live-turn indicator, and make Thinking mean reasoning (#19977)
* native-chat: render one indicator row for the live desktop turn

The turn-timing row and the spinner+activity line were two rows saying
"Working" at once. A settled turn keeps its own row; the live turn now has
only the spinner row, labelled provider activity -> Thinking -> Working for N
through the shared resolver. Reasoning is the turn's content, so it no longer
becomes the activity label, and "Thinking" now means the turn is reasoning
right now rather than that it has produced no output yet.

* mobile: give the live turn row a spinner and the shared indicator label

Mobile's per-turn row is already the only live indicator on the structured
lane, but it pulsed a bare word and never showed what the provider said it was
doing. It now renders a spinner beside the same resolved label desktop uses,
and reads reasoning from the journal instead of inferring it from missing
output. The bridge lane's four prompt/interrupt write seams move to one module
so the controller stays under its line cap.

* codex: mark streamed reasoning as reasoning too, and pin the provider markers

The settled reasoning item carried the marker but the streaming one did not,
so a live Codex turn - the only time the indicator is on screen - never read
as reasoning. Both paths now stamp it; a plan document keeps its own
presentation and must never read as reasoning.

* fix(native-chat): tighten live turn reasoning state

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-11 00:19:57 -07:00

429 lines
15 KiB
TypeScript

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 { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { MobileNativeChatView } from './MobileNativeChatView'
vi.mock('react-native', () => ({
ActivityIndicator: 'ActivityIndicator',
FlatList: 'FlatList',
Pressable: 'Pressable',
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
Text: 'Text',
View: 'View'
}))
vi.mock('react-native-safe-area-context', () => ({
useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 })
}))
vi.mock('react-native-gesture-handler', () => {
const chain = {
runOnJS: () => chain,
onStart: () => chain,
onUpdate: () => chain
}
return {
Gesture: { Simultaneous: () => ({}), Native: () => ({}), Pinch: () => chain },
GestureDetector: 'GestureDetector',
GestureHandlerRootView: 'GestureHandlerRootView'
}
})
vi.mock('lucide-react-native', () => ({
ArrowDown: 'ArrowDown',
ChevronsDownUp: 'ChevronsDownUp',
ChevronsUpDown: 'ChevronsUpDown',
Square: 'Square'
}))
vi.mock('./MobileNativeChatMessage', () => ({ MobileNativeChatMessage: 'ChatMessage' }))
vi.mock('./MobileNativeChatAsk', () => ({ MobileNativeChatAsk: 'ChatAsk' }))
vi.mock('./MobileNativeChatPermission', () => ({ MobileNativeChatPermission: 'ChatPermission' }))
vi.mock('./MobileNativeChatQuestion', () => ({ MobileNativeChatQuestion: 'ChatQuestion' }))
vi.mock('./MobileAgentWorkingIndicator', () => ({
MobileAgentWorkingIndicator: 'WorkingIndicator'
}))
// Stand-in composer: exposes the view's `handleSend` through a pressable, which is
// the only composer behaviour these banner tests exercise.
vi.mock('./MobileNativeChatComposer', async () => {
const React = await import('react')
return {
MobileNativeChatComposer: (props: {
onSend: (text: string) => Promise<boolean>
disabled?: boolean
placeholder?: string
}) =>
React.createElement('Composer', {
...props,
accessibilityLabel: 'Send message',
onPress: () => props.onSend('hi')
})
}
})
type Overrides = {
messages?: Parameters<typeof MobileNativeChatView>[0]['messages']
folded?: Parameters<typeof MobileNativeChatView>[0]['folded']
streaming?: string | null
sendErrorMessage?: string | null
onClearSendError?: () => void
inputLockReason?: 'disconnected' | 'waiting' | null
onSend?: (text: string) => Promise<boolean>
pending?: Parameters<typeof MobileNativeChatView>[0]['pending']
structuredActivityUi?: boolean
turnIndicator?: Parameters<typeof MobileNativeChatView>[0]['turnIndicator']
agentWorking?: boolean
canStop?: boolean
sendSurfaceId?: string
}
function assistantTurn(id: string, text: string): NativeChatMessage {
return { id, role: 'assistant', blocks: [{ type: 'text', text }], timestamp: 0, source: 'hook' }
}
function chatViewElement(overrides: Overrides): ReturnType<typeof createElement> {
return createElement(MobileNativeChatView, {
messages: [],
folded: [],
status: 'ready',
streaming: null,
onSend: vi.fn().mockResolvedValue(true),
sendSurfaceId: 'tab-a',
getSendCompletionGeneration: () => 0,
pending: [],
composerText: '',
onComposerTextChange: vi.fn(),
...overrides
})
}
describe('MobileNativeChatView', () => {
let renderer: ReactTestRenderer | null = null
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
})
async function render(overrides: Overrides = {}): Promise<void> {
await act(async () => {
renderer = create(chatViewElement(overrides))
})
}
async function update(overrides: Overrides = {}): Promise<void> {
await act(async () => {
renderer?.update(chatViewElement(overrides))
})
}
/** Ids of the rows the list is currently rendering. */
it('keeps Stop hidden during a structured dispatch until a provider turn can be cancelled', async () => {
const props = { structuredActivityUi: true, agentWorking: true, canStop: false }
await render(props)
const stops = () =>
renderer!.root.findAll((node) => node.props.accessibilityLabel === 'Stop the agent')
expect(stops()).toHaveLength(0)
await update({ ...props, canStop: true })
expect(stops()).toHaveLength(1)
await update({ agentWorking: true })
expect(stops()).toHaveLength(1)
})
function listIds(): string[] {
const list = renderer!.root.find((node) => node.type === 'FlatList')
return (list.props.data as { id: string }[]).map((row) => row.id)
}
function renderedRow(id: string): ReturnType<typeof createElement> {
const list = renderer!.root.find((node) => node.type === 'FlatList')
const data = list.props.data as NativeChatMessage[]
const index = data.findIndex((row) => row.id === id)
return list.props.renderItem({ item: data[index], index })
}
function banners(): ReactTestInstance[] {
return renderer!.root.findAll((node) => node.props.accessibilityRole === 'alert')
}
function composer(): ReactTestInstance {
return renderer!.root.find((node) => node.type === 'Composer')
}
function bannerText(): string {
const [alert, ...rest] = banners()
expect(rest).toHaveLength(0)
return alert
.findAll((node) => node.type === 'Text')
.map((node) => node.props.children)
.join('')
}
async function pressSend(): Promise<void> {
const composer = renderer!.root.find((node) => node.type === 'Composer') as {
props: { onPress: () => Promise<boolean> }
}
await act(async () => {
await composer.props.onPress()
})
}
it('renders the route-reported failure verbatim', async () => {
await render({ sendErrorMessage: 'Permission reply failed' })
expect(banners()).toHaveLength(1)
expect(bannerText()).toContain('Permission reply failed')
})
it('does not duplicate the route banner when the composer rejects', async () => {
const onClearSendError = vi.fn()
await render({
onSend: vi.fn().mockResolvedValue(false),
inputLockReason: 'disconnected',
sendErrorMessage: 'Stop failed',
onClearSendError
})
await pressSend()
expect(onClearSendError).not.toHaveBeenCalled()
expect(banners()).toHaveLength(1)
expect(bannerText()).toContain('Stop failed')
expect(bannerText()).toBe('Stop failed')
})
it('retires the route-owned banner once a send is accepted', async () => {
const onClearSendError = vi.fn()
await render({ sendErrorMessage: 'Stop failed', onClearSendError })
await pressSend()
expect(onClearSendError).toHaveBeenCalledOnce()
})
// The gate that decides `streaming` lives in MobileNativeChatOverlay, which
// outlives this view; see MobileNativeChatOverlay.test.ts.
it('appends the gated streaming bubble after the folded transcript', async () => {
const folded = [assistantTurn('a1', 'The tests pass.')]
await render({ folded })
expect(listIds()).toEqual(['a1'])
await update({ folded, streaming: 'The tests' })
expect(listIds()).toEqual(['a1', 'streaming'])
})
it('renders an accepted optimistic image send without a queued state', async () => {
await render({
pending: [{ id: 'pending-1', text: 'look', images: ['file:///phone-photo.jpg'] }]
})
expect(listIds()).toEqual(['pending-1'])
expect(renderedRow('pending-1').props).not.toHaveProperty('queued')
})
it('keeps a visible lock through a subscribed-end lease blip', async () => {
vi.useFakeTimers()
try {
await render({ inputLockReason: 'waiting' })
await act(async () => vi.advanceTimersByTime(600))
expect(composer().props.disabled).toBe(true)
await update({ inputLockReason: null })
expect(composer().props.disabled).toBe(true)
await act(async () => vi.advanceTimersByTime(300))
await update({ inputLockReason: 'waiting' })
await act(async () => vi.advanceTimersByTime(600))
expect(composer().props.disabled).toBe(true)
expect(composer().props.placeholder).toBe('Waiting for terminal…')
} finally {
vi.useRealTimers()
}
})
it('unlocks after the lease stays ready', async () => {
vi.useFakeTimers()
try {
await render({ inputLockReason: 'waiting' })
await act(async () => vi.advanceTimersByTime(600))
await update({ inputLockReason: null })
await act(async () => vi.advanceTimersByTime(599))
expect(composer().props.disabled).toBe(true)
await act(async () => vi.advanceTimersByTime(1))
expect(composer().props.disabled).toBe(false)
expect(composer().props.placeholder).toBe('Message, @files, /commands')
} finally {
vi.useRealTimers()
}
})
describe('structured turn status wiring', () => {
const userTurn = (id: string, text: string): NativeChatMessage => ({
id,
role: 'user',
blocks: [{ type: 'text', text }],
timestamp: 0,
source: 'transcript'
})
function rowProps(id: string): Record<string, unknown> {
return (renderedRow(id) as { props: Record<string, unknown> }).props
}
function footerProps(): Record<string, unknown> | null {
const list = renderer!.root.find((node) => node.type === 'FlatList')
const footer = list.props.ListFooterComponent as
| { props: Record<string, unknown> }
| null
| undefined
return footer?.props ?? null
}
function workingIndicators(): ReactTestInstance[] {
return renderer!.root.findAll((node) => node.type === 'WorkingIndicator')
}
it('puts the live status at the turn tail and drops the three-dot indicator', async () => {
const folded = [userTurn('u1', 'go'), assistantTurn('a1', 'still working')]
await render({ messages: folded, folded, structuredActivityUi: true, agentWorking: true })
const props = rowProps('u1')
expect(props.structuredActivityUi).toBe(true)
expect(props.turnStatus).toBeNull()
// Nothing reports reasoning, so the one live footer counts instead of guessing.
expect(footerProps()).toMatchObject({ thinking: false, workedSeconds: null })
expect(listIds().at(-1)).toBe('a1')
expect(props.activeTurnIsWorking).toBe(true)
expect(workingIndicators()).toHaveLength(0)
})
it('reports the live turn as thinking only when its journal says it is reasoning', async () => {
const folded = [userTurn('u1', 'go')]
await render({
messages: folded,
folded,
structuredActivityUi: true,
agentWorking: true,
turnIndicator: { thinking: true, activityText: null }
})
expect(rowProps('u1').turnStatus).toBeNull()
expect(footerProps()).toMatchObject({ thinking: true, workedSeconds: null })
})
it('hands the live row the provider activity copy that outranks its fallbacks', async () => {
const folded = [userTurn('u1', 'go')]
await render({
messages: folded,
folded,
structuredActivityUi: true,
agentWorking: true,
turnIndicator: { thinking: true, activityText: 'Running pnpm test' }
})
expect(footerProps()).toMatchObject({
thinking: true,
activityText: 'Running pnpm test'
})
})
it('keeps the activity copy on the live footer instead of a historical row', async () => {
const folded = [userTurn('u1', 'go'), userTurn('u2', 'again')]
await render({
messages: folded,
folded,
structuredActivityUi: true,
agentWorking: true,
turnIndicator: { thinking: false, activityText: 'Running pnpm test' }
})
expect(rowProps('u1')).not.toHaveProperty('turnActivityText')
expect(rowProps('u2')).not.toHaveProperty('turnActivityText')
expect(footerProps()).toMatchObject({ activityText: 'Running pnpm test' })
})
it('keeps the bridge lane on the three-dot indicator with no turn status', async () => {
const folded = [userTurn('u1', 'go')]
await render({ messages: folded, folded, agentWorking: true })
const props = rowProps('u1')
expect(props.structuredActivityUi).toBe(false)
expect(props.turnStatus).toBeNull()
expect(props.activeTurnIsWorking).toBe(false)
expect(footerProps()).toBeNull()
expect(workingIndicators()).toHaveLength(1)
})
it('settles the finished turn to a tappable duration', async () => {
const folded = [userTurn('u1', 'go'), assistantTurn('a1', 'done')]
await render({ messages: folded, folded, structuredActivityUi: true, agentWorking: true })
expect(rowProps('u1').turnStatus).toBeNull()
expect(footerProps()).toMatchObject({ thinking: false, workedSeconds: null })
await update({ messages: folded, folded, structuredActivityUi: true, agentWorking: false })
const settled = rowProps('u1')
expect(settled.turnStatus).toMatchObject({ thinking: false })
expect((settled.turnStatus as { workedSeconds: number | null }).workedSeconds).toBeTypeOf(
'number'
)
expect(settled.onToggleTurn).toBeTypeOf('function')
expect(settled.activeTurnIsWorking).toBe(false)
expect(footerProps()).toBeNull()
})
it('hangs no status row on an assistant row', async () => {
const folded = [userTurn('u1', 'go'), assistantTurn('a1', 'done')]
await render({ messages: folded, folded, structuredActivityUi: true, agentWorking: true })
expect(rowProps('a1').turnStatus).toBeNull()
// The assistant row still belongs to the live turn, so its tool row stays visible.
expect(rowProps('a1').activeTurnIsWorking).toBe(true)
expect(footerProps()).toMatchObject({ workedSeconds: null })
})
it('does not carry a running turn clock across chat surfaces', async () => {
vi.useFakeTimers()
try {
vi.setSystemTime(1_000)
const firstTab = [userTurn('u1', 'first')]
await render({
messages: firstTab,
folded: firstTab,
structuredActivityUi: true,
agentWorking: true,
sendSurfaceId: 'host\0worktree\0tab-a'
})
expect(footerProps()).toMatchObject({ startedAt: 1_000 })
vi.setSystemTime(12_000)
const secondTab = [userTurn('u2', 'second')]
await update({
messages: secondTab,
folded: secondTab,
structuredActivityUi: true,
agentWorking: true,
sendSurfaceId: 'host\0worktree\0tab-b'
})
expect(footerProps()).toMatchObject({ startedAt: 12_000 })
} finally {
vi.useRealTimers()
}
})
it('does not treat pre-user history as part of the live turn', async () => {
const history = [
assistantTurn('a0', 'before the first prompt'),
userTurn('u1', 'go'),
assistantTurn('a1', 'working')
]
await render({
messages: history,
folded: history,
structuredActivityUi: true,
agentWorking: true
})
expect(rowProps('a0').activeTurnIsWorking).toBe(false)
expect(rowProps('a1').activeTurnIsWorking).toBe(true)
})
})
})