Files
orca/src/shared/native-chat-tool-summary.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

298 lines
9.8 KiB
TypeScript

import { isToolCallBlock, type NativeChatBlock } from './native-chat-types'
const MAX_PREVIEW_LENGTH = 80
const MAX_PREVIEW_STRING_INPUT = 160
const MAX_PREVIEW_COLLECTION_ITEMS = 8
const MAX_PREVIEW_DEPTH = 2
const MAX_TOOL_RUN_SUMMARY_PARTS = 3
const PRIMARY_ARG_KEYS = ['command', 'cmd', 'query', 'pattern', 'url', 'description'] as const
const BRIEF_ARG_KEYS = ['command', 'cmd', 'query', 'pattern'] as const
export const MAX_TOOL_DETAIL_LENGTH = 4000
export type ToolInputDisplay = {
label: string
filePath: string | null
hasDetail: boolean
formatDetail: () => string
}
export function summarizeToolInput(input: unknown): string {
const collapsed = toRawPreview(input).replace(/\s+/g, ' ').trim()
return collapsed.length <= MAX_PREVIEW_LENGTH
? collapsed
: `${collapsed.slice(0, MAX_PREVIEW_LENGTH - 1)}…`
}
/** Build the renderer-independent row model from one normalization pass. Detail
* formatting stays lazy because collapsed mobile rows never render it. */
export function createToolInputDisplay(input: unknown): ToolInputDisplay {
const normalized = normalizeToolInput(input)
const filePath = normalizedToolFilePath(normalized)
const label = describeNormalizedToolInput(normalized, filePath)
return {
label,
filePath,
hasDetail: normalizedToolInputHasDetail(normalized, label),
formatDetail: () => truncateToolDetail(formatNormalizedToolInput(normalized))
}
}
export function truncateToolDetail(text: string): string {
return text.length > MAX_TOOL_DETAIL_LENGTH ? `${text.slice(0, MAX_TOOL_DETAIL_LENGTH)}…` : text
}
/** Human label for a tool line: the target file path, else the primary string
* argument (command/query/…), else the bounded JSON preview. Keeps raw
* `{"file_path":…}` JSON out of the tappable row label. */
export function describeToolInput(input: unknown): string {
const normalized = normalizeToolInput(input)
return describeNormalizedToolInput(normalized, normalizedToolFilePath(normalized))
}
function describeNormalizedToolInput(input: unknown, path: string | null): string {
if (path) {
return summarizeToolPath(path)
}
if (input && typeof input === 'object') {
// Concrete target/action first; prose `description` only as a last resort.
const primary = firstPrimaryToolArg(input as Record<string, unknown>, PRIMARY_ARG_KEYS)
if (primary) {
return primary
}
}
return summarizeToolInput(input)
}
/** Full, pretty-printed tool-call input for the expanded detail view. Structured
* JSON strings and objects/arrays print as indented JSON so a diff-less call
* (e.g. a question payload) reads cleanly instead of one long minified line;
* other strings pass through as-is. */
export function formatToolInput(input: unknown): string {
return formatNormalizedToolInput(normalizeToolInput(input))
}
function formatNormalizedToolInput(input: unknown): string {
if (input === null || input === undefined) {
return ''
}
if (typeof input === 'string') {
return input
}
if (typeof input === 'number' || typeof input === 'boolean') {
return String(input)
}
try {
return JSON.stringify(input, null, 2) ?? ''
} catch {
return ''
}
}
/** Whether the expanded detail would show structured JSON rather than repeating
* the row label — i.e. whether expanding the row is worth offering. */
export function isStructuredToolInput(input: unknown): boolean {
return isStructuredNormalizedToolInput(normalizeToolInput(input))
}
function isStructuredNormalizedToolInput(input: unknown): boolean {
if (input === null || typeof input !== 'object') {
return false
}
// An empty object formats back to the row label verbatim, so offering the
// expander would promise detail and then repeat the row.
return Array.isArray(input) ? input.length > 0 : Object.keys(input).length > 0
}
function normalizedToolInputHasDetail(input: unknown, label: string): boolean {
if (isStructuredNormalizedToolInput(input)) {
return true
}
return typeof input === 'string' && input.replace(/\s+/g, ' ').trim() !== label
}
export function toolFilePath(input: unknown): string | null {
return normalizedToolFilePath(normalizeToolInput(input))
}
function normalizedToolFilePath(input: unknown): string | null {
if (!input || typeof input !== 'object') {
return null
}
const value = input as Record<string, unknown>
// A search call's `path` is usually the directory it scanned, so taking it as a
// target would label the row with the scan root and link to a folder. Costs the
// link on a file-scoped search; a dead link on every other search is worse.
const directory = isSearchToolInput(value) ? undefined : value.path
const path = value.file_path ?? value.filePath ?? directory ?? value.notebook_path
return typeof path === 'string' && path.length > 0 ? path : null
}
export function briefToolArg(input: unknown): string {
const normalized = normalizeToolInput(input)
if (normalized && typeof normalized === 'object') {
const path = toolFilePath(normalized)
if (path) {
const parts = path.split(/[\\/]/).filter(Boolean)
return parts.at(-1) ?? path
}
const value = normalized as Record<string, unknown>
const command = firstPrimaryToolArg(value, BRIEF_ARG_KEYS)
if (command) {
return command.slice(0, 28)
}
// A blank primary key means the call has no brief argument; falling through
// would stand its raw JSON in for one in the run header. Reaching here with a
// string key means it was blank — a structured one still earns the preview.
if (BRIEF_ARG_KEYS.some((key) => typeof value[key] === 'string')) {
return ''
}
}
return summarizeToolInput(normalized).slice(0, 28)
}
/** Codex delivers tool arguments as a JSON string. Parse those into the object
* shape every helper below already understands; leave prose strings alone. */
function normalizeToolInput(input: unknown): unknown {
if (typeof input !== 'string') {
return input
}
const first = input.trimStart()[0]
if (first !== '{' && first !== '[') {
return input
}
try {
const parsed: unknown = JSON.parse(input)
return parsed !== null && typeof parsed === 'object' ? parsed : input
} catch {
return input
}
}
/** A search call is named by what it looked for, so its `path` is a scan root
* rather than a file target. */
function isSearchToolInput(value: Record<string, unknown>): boolean {
return (
summarizePrimaryToolArg(value.query) !== null || summarizePrimaryToolArg(value.pattern) !== null
)
}
/** The first key that yields a label — a present-but-blank key must not swallow
* the keys ranked after it. */
function firstPrimaryToolArg(
value: Record<string, unknown>,
keys: readonly string[]
): string | null {
for (const key of keys) {
const summary = summarizePrimaryToolArg(value[key])
if (summary) {
return summary
}
}
return null
}
/** A path is identified by its tail, so trim from the front: head-truncating an
* absolute path drops the filename, the one part that tells two rows apart. */
function summarizeToolPath(path: string): string {
const collapsed = path.replace(/\s+/g, ' ').trim()
if (collapsed.length <= MAX_PREVIEW_LENGTH) {
return collapsed
}
const tail = collapsed.slice(collapsed.length - (MAX_PREVIEW_LENGTH - 1))
// Start at a segment boundary so the label doesn't open mid-name.
const boundary = tail.search(/[\\/]/)
return `…${boundary > 0 ? tail.slice(boundary) : tail}`
}
/** A label-worthy primary argument: a non-blank string, or an argv array. */
function summarizePrimaryToolArg(input: unknown): string | null {
if (typeof input === 'string' && input.trim()) {
return summarizeToolInput(input)
}
if (Array.isArray(input) && input.length > 0 && input.every((part) => typeof part === 'string')) {
return summarizeToolInput(input.join(' '))
}
return null
}
export function summarizeToolRun(blocks: readonly NativeChatBlock[]): string {
const parts: string[] = []
for (const block of blocks) {
if (!isToolCallBlock(block)) {
continue
}
const name = block.name.trim()
if (!name) {
continue
}
const detail = briefToolArg(block.input)
parts.push(detail ? `${name} ${detail}` : name)
if (parts.length >= MAX_TOOL_RUN_SUMMARY_PARTS) {
break
}
}
return parts.join(' · ')
}
export function countToolCalls(blocks: readonly NativeChatBlock[]): number {
return blocks.filter(isToolCallBlock).length
}
function toRawPreview(input: unknown): string {
if (input === null || input === undefined) {
return ''
}
if (typeof input === 'string') {
return input
}
if (typeof input !== 'object') {
return String(input)
}
try {
return JSON.stringify(boundedPreviewValue(input, 0, new WeakSet<object>())) ?? ''
} catch {
return ''
}
}
function boundedPreviewValue(value: unknown, depth: number, seen: WeakSet<object>): unknown {
if (typeof value === 'string') {
return value.length > MAX_PREVIEW_STRING_INPUT
? `${value.slice(0, MAX_PREVIEW_STRING_INPUT)}…`
: value
}
if (!value || typeof value !== 'object') {
return value
}
if (seen.has(value)) {
return '[circular]'
}
if (depth >= MAX_PREVIEW_DEPTH) {
return '[…]'
}
seen.add(value)
if (Array.isArray(value)) {
const result = value
.slice(0, MAX_PREVIEW_COLLECTION_ITEMS)
.map((item) => boundedPreviewValue(item, depth + 1, seen))
if (value.length > MAX_PREVIEW_COLLECTION_ITEMS) {
result.push('…')
}
return result
}
const result: Record<string, unknown> = {}
let count = 0
for (const key in value) {
if (!Object.prototype.hasOwnProperty.call(value, key)) {
continue
}
if (count >= MAX_PREVIEW_COLLECTION_ITEMS) {
result['…'] = '…'
break
}
result[key] = boundedPreviewValue((value as Record<string, unknown>)[key], depth + 1, seen)
count += 1
}
return result
}