Files
orca/mobile/src/session/MobileNativeChatMessage.test.ts
T
Kelvin Amoaba f3551dba57 fix(mobile): show skill commands you ran and draw warnings, errors and status lines as notices (#28899)
* fix(mobile): show skill commands you ran in the chat

Claude records a skill run (/simplify, /review-pr 123) as a command
envelope, which the noise filter hides. Desktop surfaces it as the typed
`/name args` before folding; the phone only normalised image markers, so
the turn vanished and the reply answered nothing.

Both apps now prepare a terminal agent's transcript through one shared
prepareNativeChatMessages: skill envelopes, reporting-command output and
image marker turns.

* fix(native-chat): keep a streamed sentence from coming back after a tool or thought row

Both chats hide the live preview once the transcript shows it, but each
only compared the preview with the last row. When a tool call or thought
landed below the sentence, the last row had no words and the earlier
sentence came back at the bottom as a live message.

latestNativeChatReplyText reads back past those rows to the newest
assistant words in the turn; desktop's preview check and the phone's
streaming gate both use it.

* fix(mobile): draw errors, warnings and status notices as notices, not agent replies

The phone drew every system row through the agent's markdown, so an API
retry warning, a sign-in failure, "Context compacted" or "Stopped manually"
read as the agent's own words. Only the clear boundary and two host
notices were special-cased.

Desktop's notice rules move to shared native-chat-notice: which block of a
system row is its notice, and the look it gets (divider, command output,
host status, plan, aside, or a toned line). Desktop's notice row switches
on it with no change in what it draws; the phone's new notice row draws
the same looks, with words in faint type when a row has no notice.

* fix(native-chat): show a new reply that follows a tool call on desktop

The earlier fix read back past a tool call or thought to the newest
assistant words, then kept desktop's length rule. Commentary written
before the tool is often longer than the reply after it, so a new,
shorter reply stayed hidden until the transcript caught up.

The last-row rule is back as it was. Reading back now only hides the
preview when the earlier words already contain it, which is the sentence
still on screen while the tool runs. The phone's gate reads only rows
that landed after the current reply began, so it was not affected.

* fix(mobile): open the actions sheet from a long press on a plan's link

A plan card's Markdown was drawn without the row's long-press handler, so
on Android holding a link or file path inside a plan no longer opened Copy
message / Select text, as it did before plans were drawn as notices. The
notice row now passes the handler to the plan's Markdown.
2026-10-15 11:04:34 +00:00

641 lines
24 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 {
AgentJournalStatusItem,
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 {
NATIVE_CHAT_INTERRUPTED_STATUS_TEXT,
type NativeChatMessage
} from '../../../src/shared/native-chat-types'
import { AGENT_SESSION_HOST_STATUS_COPY } from '../../../src/shared/agent-session-host-status-rows'
import {
agentSessionFailureFact,
readProviderRetry
} from '../../../src/shared/agent-session-failure'
import { agentSessionFailureWords } from '../../../src/shared/agent-session-failure-words'
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', () => ({
AlertCircle: 'AlertCircle',
AlertTriangle: 'AlertTriangle',
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
unsentNotice?: string
} = {}
): 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.accessibilityLabel === 'Context cleared'
)
).toHaveLength(1)
})
// Each row as its host writes it: Claude's API retry and sign-in rows use these shared words.
const statusRow = (body: AgentJournalStatusItem): NativeChatMessage =>
projectStructuredItemToNativeChat({
itemId: 'status',
revision: 1,
sequence: 2,
observedAt: 2,
body
})!
it.each([
[
'an API retry as a warning',
statusRow({
kind: 'status',
tone: 'warning',
...agentSessionFailureWords(
agentSessionFailureFact('providerRetrying', {
retry: readProviderRetry({
error: 'overloaded',
status: 529,
attempt: 2,
maxRetries: 10
})
}),
{ surface: 'row', agentName: 'Claude' }
)
}),
colors.statusAmber
],
[
'a sign-in failure as an error',
statusRow({
kind: 'status',
tone: 'error',
...agentSessionFailureWords(agentSessionFailureFact('notSignedIn'), {
agentName: 'Claude',
provider: 'claude',
surface: 'row'
})
}),
colors.statusRed
],
[
'an interruption as a faint aside',
{
id: 'interrupted',
role: 'system',
blocks: [{ type: 'text', text: NATIVE_CHAT_INTERRUPTED_STATUS_TEXT }],
timestamp: 1,
source: 'transcript'
} satisfies NativeChatMessage,
colors.textMuted
]
])('draws %s, never as the agent speaking', (_, message, color) => {
const tree = render(message)
expect(tree.root.findAll((node) => String(node.type) === 'MobileMarkdown')).toHaveLength(0)
const text = tree.root.find(
(node) => String(node.type) === 'Text' && node.props.selectable === true
)
expect(text.props.children).toBe(message.blocks.find((block) => block.type === 'text')?.text)
expect(Object.assign({}, ...text.props.style)).toMatchObject({ color })
})
it('draws a compaction as a divider, not as a reply', () => {
const tree = render(
statusRow({ kind: 'status', text: 'Context compacted', presentation: 'compaction' })
)
expect(tree.root.findAll((node) => String(node.type) === 'MobileMarkdown')).toHaveLength(0)
expect(
tree.root.findAll(
(node) =>
String(node.type) === 'View' && node.props.accessibilityLabel === 'Context compacted'
)
).toHaveLength(1)
})
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' && node.props.selectable === true
)
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)
})
describe('a message the host recorded and did not deliver', () => {
const notSent = { ...userMessage([{ type: 'text', text: 'fix it' }]), unsent: true as const }
const lineIn = (tree: ReactTestRenderer) =>
tree.root.findAll(
(node) => String(node.type) === 'Text' && node.props.style === styles.unsentNotice
)
it('says why under the bubble, and draws no line without a reason, as the desktop', () => {
expect(textIn(render(notSent, { unsentNotice: 'Claude is not signed in.' }).root)).toEqual([
'fix it',
'Claude is not signed in.'
])
expect(lineIn(render(notSent))).toHaveLength(0)
})
it("draws the line under another agent's message too", () => {
const fromAgent: NativeChatMessage = {
...notSent,
from: {
kind: 'agent',
senders: [
{
party: { address: 'term_a', terminalHandle: 'term_a', orcaSessionId: null },
name: 'Coder'
}
],
orchestration: null
}
}
expect(
lineIn(render(fromAgent, { unsentNotice: 'Your message was not sent.' }))
).toHaveLength(1)
})
})
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')
})
})
})