Files
orca/src/shared/native-chat-tool-summary.ts
T
Brennan Benson 91e6e1f355 fix(native-chat): collapse a finished turn to its answer (#22029)
* fix(native-chat): collapse a finished turn to its answer

A finished turn's "Worked for N" row hid the turn's tool runs and nothing
else. Every sentence the agent said on the way to its answer stayed in the
transcript, so the resting state of a long chat was the narration, not the
reply — one 16m 56s review turn left 21 assistant messages and roughly
seven screens of scrolling behind a control that reads as if it had put
the work away.

The fold's unit is now the turn. A settled turn draws its prompt, its
duration, and its answer; the narration and activity that produced it sit
behind the caret. The answer is the turn's last assistant row that renders
prose — derived, because the journal carries no marker saying which message
is the reply.

Collapsed stays derived rather than stored: nothing closes the disclosure
when a turn ends, it arrives closed because the turn gained a duration. A
running turn therefore folds nothing and the reader watches the work as it
happens, which is what already happened and is now stated rather than
inherited.

Rows that outlive the turn that started them stay outside the fold — a
spawn roster and a background task are often the only record of how that
work ended. So does the reader's own message, question receipts, and the
turn's diff rollup. A turn that produced no prose folds whole, its status
row standing as the anchor.

Two presentation changes come with it, both about the opened view:

- A settled run's header was a call count followed by a monospace list of
  tool names and arguments. It is now one sentence in the transcript's own
  type — "Read 7 files, ran 17 commands, and searched 4 times" — built on
  the tool-category vocabulary that already picks the row's glyph, so the
  words and the icon cannot claim different things. A run of one command
  keeps that command as its header.
- A tool call now owns its result instead of standing beside a separate
  `Result` row, so an opened run lists the work rather than twice as many
  rows half of which say `Result`. Output is one more click. Pairing is
  positional — a result answers the most recent unanswered call — because
  result blocks carry no call identifier to match on.

Command previews also lose the `/bin/zsh -lc "…"` wrapper they all opened
with. The unwrap happens inside `summarizeToolInput`, before truncation,
because the clip at 80 characters removes the closing quote that proves the
wrapper; one site fixes the header, the rows, and the running label.

Measured on a real session journal at 1200x900: the turn above goes from
6,300px across 51 rows to 452px across 2, the whole session from 8,151px
to 2,138px, and the same turn opened from 18,540px to 11,131px.

The fold derivation lives in `src/shared` so the mobile transcript can read
the same rule; wiring mobile's list to it is not part of this change.

* fix(native-chat): preserve FIFO tool result pairing

* fix(native-chat): keep tools collapsed when opening turn

* test(native-chat): clarify independent tool disclosures
2026-09-21 12:13:30 -07:00

373 lines
12 KiB
TypeScript

import {
collapsedToolInputPrefix,
unwrapLoginShellCommand,
MAX_TOOL_PREVIEW_LENGTH
} from './native-chat-tool-preview-prefix'
import type { NativeChatMcpIdentity } from './native-chat-tool-identity'
import { isToolCallBlock, type NativeChatBlock } from './native-chat-types'
const MAX_PREVIEW_STRING_INPUT = 160
const MAX_PREVIEW_COLLECTION_ITEMS = 8
const MAX_PREVIEW_DEPTH = 2
const MAX_TOOL_RUN_SUMMARY_PARTS = 3
// Search term before command: a classified search row carries both, and the
// term is what identifies it. No other tool input supplies the two together.
// `directory` is a scan root or a listed folder — it labels a row but is
// deliberately absent from the file-target keys below, because a folder reaches
// mobile as a tappable open-file link that can only fail.
const PRIMARY_ARG_KEYS = [
'query',
'pattern',
'directory',
'command',
'cmd',
'url',
'description'
] as const
const BRIEF_ARG_KEYS = ['query', 'pattern', 'directory', 'command', 'cmd'] as const
// Only the keys that hold a shell command, so a search term or a listed folder
// cannot stand in for one.
const COMMAND_ARG_KEYS = ['command', 'cmd'] 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 {
// Unwrap before clipping: the closing quote is what proves the wrapper, and an
// 80-character prefix has already dropped it. Non-shell input is untouched.
const collapsed = collapsedToolInputPrefix(unwrapLoginShellCommand(toRawPreview(input)))
return collapsed.length <= MAX_TOOL_PREVIEW_LENGTH
? collapsed
: `${collapsed.slice(0, MAX_TOOL_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' && collapsedToolInputPrefix(input) !== 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 ??
firstPatchChangePath(value)
return typeof path === 'string' && path.length > 0 ? path : null
}
function firstPatchChangePath(value: Record<string, unknown>): unknown {
if (!Array.isArray(value.changes)) {
return undefined
}
for (const change of value.changes) {
if (typeof change === 'object' && change !== null && typeof change.path === 'string') {
return change.path
}
}
return undefined
}
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)
}
/** The shell command a call carries in its input, or null when it carries none.
* Codex keeps the raw command on a classified `read`/`search`/`list` row, so
* this is what tells one apart from a Claude tool of the same lowercased word. */
export function toolInputCommand(input: unknown): string | null {
const normalized = normalizeToolInput(input)
return isToolInputRecord(normalized) ? firstPrimaryToolArg(normalized, COMMAND_ARG_KEYS) : null
}
function isToolInputRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value)
}
/** 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_TOOL_PREVIEW_LENGTH) {
return collapsed
}
const tail = collapsed.slice(collapsed.length - (MAX_TOOL_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
}
/** One named call in a run header, kept apart rather than pre-joined so a
* surface can draw the boundary between members itself. */
export type ToolRunMember = {
name: string
/** Brief argument, or '' when the call has none worth showing. */
arg: string
mcpIdentity?: NativeChatMcpIdentity
}
/** The run header's leading calls. Capped at the same limit the joined string
* has always used, so the two can never disagree about which calls speak for
* a run. */
export function toolRunSummaryMembers(blocks: readonly NativeChatBlock[]): ToolRunMember[] {
const members: ToolRunMember[] = []
for (const block of blocks) {
if (!isToolCallBlock(block)) {
continue
}
const name = block.name.trim()
if (!name) {
continue
}
members.push({ name, arg: briefToolArg(block.input), mcpIdentity: block.mcpIdentity })
if (members.length >= MAX_TOOL_RUN_SUMMARY_PARTS) {
break
}
}
return members
}
export function summarizeToolRun(blocks: readonly NativeChatBlock[]): string {
return toolRunSummaryMembers(blocks)
.map((member) => (member.arg ? `${member.name} ${member.arg}` : member.name))
.join(' · ')
}
export function countToolCalls(blocks: readonly NativeChatBlock[]): number {
let count = 0
blocks.forEach((block) => {
if (isToolCallBlock(block)) {
count += 1
}
})
return count
}
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.hasOwn(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
}