Files
orca/mobile/src/session/MobileNativeChatMessage.test.ts
T
Brennan Benson c511e51442 fix(mobile): label native-chat tool rows with a clean, expandable input summary (STA-3333) (#12498)
* fix(mobile): label tool rows with a clean summary, expand full input (STA-3333)

Mobile tool rows showed the raw input JSON (`{"file_path":…}`) as the row
label, and the expanded detail just repeated that same truncated string.

- `describeToolInput` labels a row with the target file path, else the
  primary argument (command/cmd/query/pattern/url/description), else the
  bounded JSON preview.
- Codex delivers tool arguments as a JSON string; normalize those into the
  object shape the helpers already understand, so labels, file links,
  run summaries and the expanded detail all work for Codex calls too.
- The expanded detail now renders the fully formatted input, capped at
  MAX_TOOL_RESULT_CHARS like desktop's tool detail (and like the result
  body), and a structured input makes the row expandable.

* fix(mobile): name search rows by their term and keep the filename in path labels (STA-3333)

Review follow-ups to the tool-row summary, all in the shared helper:

- A Grep/Glob row labelled itself with the directory it scanned and dropped
  the pattern entirely, because `toolFilePath` treats `path` as a file target.
  That path is a scan root, so it also rendered a tap-to-open link that asked
  the app to open a folder. `toolFilePath` now ignores the generic `path` key
  for search-shaped input, which lets the pattern win the label and drops the
  bogus link; an explicit `file_path` still wins.

- An overlong path was truncated from the head, cutting off the basename —
  the one part that tells two rows apart. Trim from the front instead, so
  the label reads `…/session/MobileNativeChatMessage.tsx`.

- The primary-argument chain used `??`, so a present-but-blank key selected
  itself and swallowed the keys ranked after it, dropping the label all the
  way back to raw JSON. Take the first key that actually yields a label.

Refs STA-3333.

* fix(mobile): don't offer an expander whose detail repeats the row (STA-3333)

An empty tool input formats back to the row label verbatim, so `{}` and `[]`
advertised an expander and then re-showed the label — the same repeat-the-JSON
problem this change set out to remove. Gate `isStructuredToolInput` on the
collection actually having contents; the lazy detail path is untouched.

Also pins the overlong-path test to the path itself: asserting only length<=80
plus a `…` passed just as well with path labelling deleted.

* fix(mobile): gate the tool detail panel on having detail (STA-3333)

The Tools toggle opens every row at once, bypassing the row's tap guard,
so a row with nothing to expand rendered its own label again underneath
itself — and the tap that would dismiss it is a no-op. Matches desktop.

* fix(mobile): keep a blank tool argument out of the run header (STA-3333)

Skipping a present-but-blank primary key let `briefToolArg` fall through
to the raw JSON preview, so a run header read `Bash {"command":""}` where
it used to read `Bash`. Also state the search-path trade-off honestly:
suppressing the link costs a file-scoped search its tap target.

* fix(mobile): only treat a blank primary key as a missing argument (STA-3333)

The previous guard tested key presence, so a populated but non-string
argument — a mixed argv like ['kill','-9',pid], or a structured query —
dropped out of the run header instead of falling back to the preview.

* test(mobile): pin the tool-row chevron to the detail panel (STA-3333)

The panel gate was covered but the chevron beside it was not: swapping
`showDetail` back to `expanded` on the icon alone left all 909 mobile
tests green, so the affordance lie this branch fixes could return
unnoticed — a down-chevron over no panel, on a row whose tap is guarded
off.

Asserts both icon counts on the fixture that test already renders. The
two halves now die for distinct reasons: the panel gate on the duplicate
label text, the chevron on the icon count.

* test(shared): pin the blank-search-key guard in the tool label (STA-3333)

Dropping `.trim()` from summarizePrimaryToolArg left all 32 tests green,
yet it leaks through isSearchToolInput: a whitespace-only `query` starts
counting as a search term, which suppresses `path`. One character takes
the row's label, its tap-to-open link and its run-header argument at
once, and puts the raw JSON label back — the bug this branch removes.

Asserts all three outputs on that shape. Kills only that mutant; the
isSearchToolInput mutant still dies on the existing search test.

* fix(native-chat): share tool input display semantics (STA-3333)

Build the tool row label, file target, detail eligibility and bounded detail from one normalized input model. Mobile no longer reparses JSON-string input across independent helpers or repeats an already-complete plain label, and desktop now uses the same clean row summary instead of retaining raw JSON.\n\nKeep full detail formatting lazy for collapsed rows and share the 4000-character detail cap across both renderers. Tests pin desktop adoption, mobile disclosure parity, one-pass JSON parsing and the shared bound.
2026-08-04 19:34:59 -07:00

170 lines
6.6 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, beforeEach, describe, expect, it, vi } from 'vitest'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { MAX_TOOL_DETAIL_LENGTH } from './mobile-native-chat-tool-summary'
vi.mock('react-native', async () => {
const React = await import('react')
return {
Image: 'Image',
Pressable: 'Pressable',
Text: ({ children, ...props }: { children?: unknown }) =>
React.createElement('Text', props, children),
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',
ChevronDown: 'ChevronDown',
Copy: 'Copy',
SquareChevronRight: 'SquareChevronRight'
}))
vi.mock('../components/MobileMarkdown', () => ({ MobileMarkdown: 'MobileMarkdown' }))
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
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
})
function render(
message: NativeChatMessage,
props: { toolsExpanded?: boolean } = {}
): ReactTestRenderer {
const original = console.error
const spy = vi.spyOn(console, 'error').mockImplementation((...a) => {
if (typeof a[0] === 'string' && a[0].includes('react-test-renderer is deprecated')) {
return
}
original(...a)
})
try {
act(() => {
renderer = create(createElement(MobileNativeChatMessage, { message, ...props }))
})
} finally {
spy.mockRestore()
}
return renderer!
}
const textIn = (node: ReactTestInstance): string[] =>
node.findAllByType('Text' as never).map((text) => String(text.children.join('')))
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('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)
})
})