Files
orca/src/shared/native-chat-tool-icon.test.ts
T
Brennan Benson cb363444f3 feat(native-chat): register Codex default-mode helpers as subagents (#22619)
* feat(native-chat): register Codex default-mode helpers as subagents

Codex's default multi-agent mode announces a helper only as the
collabAgentToolCall that spawned it; it sends no subAgentActivity. The
roster and the background-task tracker registered children only from
subAgentActivity, so such a helper had no record, no strip row and no
roster row, and its commands read as the session's own.

One announcement reader now yields a child from either wire shape, and
both the journal roster and the tracker register through it into the
same executions, so a session sending both keeps one child per thread.
A finished closeAgent ends the helper's running turn as stopped through
the executions, beside the child's own turn and thread frames.

Each collab call renders as a tool row (spawn_agent, wait_agent,
close_agent, ...) naming the helper the way the roster does, with what
the helper said back as its output, instead of the raw provider row.

* fix(native-chat): name a spawn row by its prompt until the roster holds its helper

Also pin that the roster row appears when the helper's first turn arrives
before the spawn call finishes.

* test(native-chat): a spawn call keeps its own row beside the roster row it starts

* fix(native-chat): pair a structured tool row's output with its own call

A run paired results to calls by position alone, so once one call finished
with no output (a Codex spawn_agent row) every later output drew under the
call before its own. A structured row carries its call and output together,
so the projected result now names its call id and pairing honors it,
falling back to position for results that name none.

* fix(native-chat): the Codex roster row follows the executions, so every child ending settles it

The subagent-group row was rewritten only on a child's turn/started and
turn/completed. A child turn ended any other way — a fatal error, its
thread closing, its caller closing it — settled the strip and the host
record through the executions but left the transcript row reading working
until the session ended.

The executions now say when a child's execution changes, and the roster
revises its row from that, whichever frame changed it. handleTurn still
re-derives to hand back its write admission; the revision is idempotent.

* fix(native-chat): a restored Codex call row names its helper, not its thread id

History replay never runs the live item router, so the roster never learned
the helpers a restored thread had spawned, and a restored wait_agent,
close_agent or send_input row labelled its helper with the raw thread id.
Replay now registers each announced helper for its name and membership only:
register starts no execution, so no strip entry, record or roster row claims
the helper runs until a live turn of its own says so.

The replayed-item handling moves into the restore module beside the replay
that calls it. Also name the roster's render contract in handleItem, and say
why the collab tool-name map is not a spelling fix.

* fix(native-chat): register a Codex helper whose spawn call failed but created its thread

Codex reports a spawn as failed when the helper it created errored at birth,
yet the call still names the thread it created, and that thread can run. The
spawn was registered only when the call completed, so such a helper existed
nowhere and its shell read as the session's own bare command: the original
default-mode bug. A spawn now registers whenever it names a receiver; one in
progress, or one that created nothing, names none. A failed closeAgent still
ends nothing, because the helper was not closed.

* refactor(native-chat): each Codex thread's latest token total gets its own home

Every thread's running total, member or not, with the rule that the newest
frame replaces the last and the recency-ordered cap, moves out of the roster
into codex-thread-token-totals.ts. The roster still selects its children's
totals at write time. With the producer linkage the roster now builds, it
was past the size limit.

* refactor(native-chat): the messages a session list quotes get their own module

The readers of a session's newest own prompt and own assistant prose move from
the status projection into structured-agent-session-latest-messages.ts, and
are re-exported so their consumers keep one import site. With the status
clock the projection now carries, a tool result naming its call put it past
the size limit.

* test(native-chat): a Codex helper's command is a live record until its process reports its exit

A helper's in-turn shell is a live command record owned by the helper and is also its open
Bash operation; its exit removes the record. A caller's closeAgent kills the helper's
processes, and each still reports its exit on the helper's thread, so the close leaves the
command to that exit rather than ending it itself.

* fix(native-chat): every reader of a tool run pairs a result with the call it names

The folded desktop run now pairs a result with the call it names, but mobile's tool run, the desktop edit cards and the task lists still paired by position through `pairToolBlocks`. In a Codex default-mode session the new `spawn_agent` row finishes with no output, so on mobile the helper's reply drew under `spawn_agent` and `wait_agent` showed none, and a desktop edit card could take the next command's output as its own.

`pairToolBlocks` now follows the same rule as `pairNativeChatToolResults`: a result that names its call answers that call, and one that names none answers the oldest unanswered call as before.

* fix(native-chat): a Codex helper's turn ends on its own frames, not on its caller's closeAgent

A finished `closeAgent` call ended the helper's running turn as `stopped`. But the call's status is not the close's outcome: Codex sets it from the helper's own agent status, so a close that errors on a running helper still reports `completed`. Orca then marked a helper that was still running as cancelled in the strip, the host record and the roster row, and because the first ending a turn gets stands, its real ending could never correct it.

A close that works does not need the edge either. Codex's shutdown of the helper aborts its running turn, and the app-server reports that as the helper's own `turn/completed` with status `interrupted`, which Orca already maps to `stopped`. So a helper's turn now ends only on its own frames (its `turn/completed`, a fatal `error`, `thread/closed`) or the session ending, and the close is only its call row.

This also drops the child-work evidence re-keying that existed only for the close: every remaining frame names the thread that sent it.

* fix(native-chat): a tool result that names a call is never given to a different call

A result that named a call id with no unanswered match fell back to the oldest unanswered call. On mobile, a run shows at most six calls, so a command past that window whose output named its own call gave that output to an earlier call that finished with none, such as `spawn_agent`.

A result that names its call now answers only that call and otherwise stays unpaired. A result that names no call still answers the oldest unanswered one. Both pairing readers share the one rule through `answeredToolCallIndex`.

* test(native-chat): name the Codex default-mode test file after the subagents it covers

* fix(native-chat): a Codex helper is known from any call that names it, not only its spawn

A helper whose spawn Orca never saw (history replay after compaction dropped the spawn, or a resume or message to a helper from earlier) stayed unregistered, so its shell read as the session's own command: the bug this PR fixes, in another shape.

Every collab call now announces each helper it names, skipping a receiver the finished call reports notFound. The spawn's prompt still labels a helper when it was seen; otherwise the helper has no label and reads as any unnamed subagent does. A send_input, like the spawn, names the parent turn the helper's next run belongs to.

The session's own thread is excluded once, in the reader, instead of at each of its three callers.

* fix(native-chat): every finished Codex collab call row carries what the call reports

A client that predates result call ids (an older mobile app, or an older desktop reading a newer host) projects the journal itself and pairs each tool result with the oldest unanswered call. This PR publishes collab calls as tool rows, and a finished spawn_agent, send_input, close_agent or resume_agent on a running helper had no output, so such a client drew each later output in the run under the call before its own (the parent's shell output under spawn_agent, the helper's reply under the shell).

Every finished call now has an output taken from the item: a wait's reply from each helper that finished (an errored helper's error), and for every other call the helper's reported status in Codex's own words (Pending init, Running, Completed, ...). A close or resume no longer shows the helper's last reply as if the call returned it. A wait whose end names no helper (it timed out, or v2) reads Finished waiting; any other call with no state reads its own status.

A call also keeps naming the helpers its started item named: Codex ends a timed-out wait with no receivers, so its row lost the helper's name when it finished.

* refactor(native-chat): the desktop run's tool pairing is a view of pairToolBlocks

Desktop runs and every other reader (mobile, edit cards, task lists, the ask row) each had their own pairing loop sharing one index rule. The desktop's pairNativeChatToolResults now reads the pairs pairToolBlocks makes, so a run is paired by one loop and a new reader cannot add a third. No behaviour change.

* test(native-chat): type the positional-client pairs without assertions

* fix(native-chat): a finished Codex collab call row says what the call did, in Codex's own words

A close_agent row read `Running` and a spawn_agent row `Pending init`: each showed the helper's status snapshot from before the call took effect, so a close that stopped its helper read as though it had not worked.

Each finished call's output now follows Codex's own client: spawn reads `Spawned` (or `Agent spawn failed` when no helper was created), send_input `Sent input`, close `Closed`, and resume the helper's status summary. A wait keeps each finished helper's reply, with other statuses in Codex's summary wording (`Error - <message>`). The row label already names the helper, so the output does not repeat it.

* docs(native-chat): the collab call reader says which calls show the helper's snapshot as output

Since spawn, send_input and close rows say what the call did, the reader's header was wrong to claim every call row shows the reported snapshot as its output: only a wait or resume does.

* fix(native-chat): a Codex spawn call no longer claims it ran an agent

Codex's spawn_agent call ends as soon as the helper starts, so counting it as running an agent drew "Ran 1 agent" directly above "Kicked off 1 subagent · working" while the helper was still working, and "Ran 1 agent · 1 failed" when Stop cancelled a spawn before any helper existed. Claude's Task call lasts as long as its subagent, so it keeps the agent category; the Codex spawn row is now a plain tool call and the roster row alone stands for the helper.

* test(native-chat): a Codex helper's row settles on the failed completion that follows a fatal error

Codex follows every turn-ending `error` with the turn's failed `turn/completed`,
on a helper's thread as on the primary, and only that completion ends the turn.
The roster test now sends both and checks the row, strip and record stay
working through the error and settle on the completion.

* fix(native-chat): a Codex helper's section opens while the parent spawns, waits on or messages it

A running chat holds a subagent's section open while the parent's newest row delegates to that subagent. For Codex that rule only knew the raw collab status row; this PR writes each collab call as a tool row (spawn_agent, wait_agent, send_input, close_agent, ...) naming its helpers in `input.agents`, so a default-mode helper's section stayed shut for its whole run while Claude's opened.

The delegation reader now reads a Codex collab tool row as a delegation to the first helper it names, the same rule the raw row keeps for journals written before this change; a call naming no helper stays ordinary output. The row names and the `agents` key live in one shared module the host writes from and the reader reads, instead of a second list.

The new test drives the real adapter's rows through the transcript projection to the delegation; the collab frame harness moves to a fixture shared with the positional-clients test.

* fix(native-chat): a Codex collab call with a long prompt still names its helper

A collab call row bounded its whole input as one value, so a spawn or send_input whose prompt passed the 16 KB journal limit was stored as a clipped wrapper: the row lost the helper's name and thread ids, and with them the label and the delegation that opens the helper's section.

The prompt is now clipped on its own with the journal's inline-text bound, marker included; the helper's name, ids, model and effort are bounded as before and always survive.
2026-09-30 11:36:57 -07:00

289 lines
12 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import {
isShellActivityToolCall,
NATIVE_CHAT_TOOL_ICON_NAMES,
nativeChatToolCategory,
nativeChatToolIconName,
nativeChatToolRunCategory,
nativeChatToolRunIconName,
type NativeChatToolCategory
} from './native-chat-tool-icon'
const ALL_CATEGORIES: NativeChatToolCategory[] = [
'read',
'search',
'listFiles',
'unknown',
'fileChange',
'webSearch',
'mcpToolCall',
'subAgentActivity',
'todoList',
'other'
]
describe('native chat tool icons', () => {
it('names a glyph for every category in the vocabulary', () => {
expect(NATIVE_CHAT_TOOL_ICON_NAMES).toEqual({
read: 'eye',
search: 'search',
listFiles: 'folder',
unknown: 'square-terminal',
fileChange: 'pencil',
webSearch: 'globe',
mcpToolCall: 'plug',
subAgentActivity: 'bot',
todoList: 'list-checks',
other: 'wrench'
})
expect(Object.keys(NATIVE_CHAT_TOOL_ICON_NAMES).sort()).toEqual([...ALL_CATEGORIES].sort())
})
it('gives each category a distinct glyph so rows are told apart by icon', () => {
const glyphs = ALL_CATEGORIES.map((category) => NATIVE_CHAT_TOOL_ICON_NAMES[category])
expect(new Set(glyphs).size).toBe(glyphs.length)
})
it('maps the row words the Codex lane renders to their category', () => {
expect(nativeChatToolCategory('read')).toBe('read')
expect(nativeChatToolCategory('search')).toBe('search')
expect(nativeChatToolCategory('list')).toBe('listFiles')
expect(nativeChatToolCategory('shell')).toBe('unknown')
expect(nativeChatToolCategory('apply_patch')).toBe('fileChange')
expect(nativeChatToolCategory('update_plan')).toBe('todoList')
expect(nativeChatToolIconName('update_plan')).toBe('list-checks')
expect(nativeChatToolRunIconName([{ name: 'update_plan' }])).toBe('list-checks')
expect(nativeChatToolCategory('web search')).toBe('webSearch')
})
it('counts no Codex helper call as running an agent, since a spawn ends once its helper starts', () => {
expect(nativeChatToolCategory('task')).toBe('subAgentActivity')
expect(nativeChatToolCategory('spawn_agent')).toBeNull()
expect(nativeChatToolCategory('wait_agent')).toBeNull()
expect(nativeChatToolCategory('close_agent')).toBeNull()
})
it('maps the tool names the Claude lane renders verbatim', () => {
expect(nativeChatToolIconName('Read')).toBe('eye')
expect(nativeChatToolIconName('Bash')).toBe('square-terminal')
expect(nativeChatToolIconName('Grep')).toBe('search')
expect(nativeChatToolIconName('Glob')).toBe('search')
expect(nativeChatToolIconName('Task')).toBe('bot')
expect(nativeChatToolIconName('WebFetch')).toBe('globe')
expect(nativeChatToolIconName('TodoWrite')).toBe('list-checks')
})
it('reads the whole edit family from the shared set, not a parallel list', () => {
for (const name of ['Edit', 'MultiEdit', 'Write', 'str_replace', 'apply_patch']) {
expect(nativeChatToolCategory(name)).toBe('fileChange')
expect(nativeChatToolIconName(name)).toBe('pencil')
}
})
it('reads the projected `Diff` row as a file change, which is what it renders', () => {
// Every Codex fileChange item projects to a call named `Diff`, so a wrench
// here headed a run whose body is an edited-file card.
expect(nativeChatToolCategory('Diff')).toBe('fileChange')
expect(nativeChatToolIconName('Diff')).toBe('pencil')
})
it('reads an MCP tool by its prefix, since the row is named after the tool', () => {
expect(nativeChatToolCategory('mcp__linear__create_issue')).toBe('mcpToolCall')
expect(nativeChatToolIconName('mcp__playwright__browser_click')).toBe('plug')
// Not a prefix match: a tool merely mentioning mcp is not an MCP call.
expect(nativeChatToolCategory('run_mcp__thing')).toBeNull()
})
it('resolves the glyph for each classified row word', () => {
expect(nativeChatToolIconName('read')).toBe('eye')
expect(nativeChatToolIconName('search')).toBe('search')
expect(nativeChatToolIconName('list')).toBe('folder')
expect(nativeChatToolIconName('shell')).toBe('square-terminal')
expect(nativeChatToolIconName('edit')).toBe('pencil')
expect(nativeChatToolIconName('web search')).toBe('globe')
})
it('reads a row word regardless of case or surrounding space', () => {
expect(nativeChatToolIconName(' Read ')).toBe('eye')
expect(nativeChatToolIconName('WebSearch')).toBe('globe')
})
it('falls back to the generic tool glyph, not the terminal, outside the vocabulary', () => {
// Claiming a terminal here would assert a shell ran when nothing says one did.
expect(nativeChatToolCategory('AskUserQuestion')).toBeNull()
expect(nativeChatToolIconName('AskUserQuestion')).toBe('wrench')
expect(nativeChatToolIconName('')).toBe('wrench')
})
it('keeps the terminal glyph for a row that really ran a command', () => {
// `exec` and `local_shell` are how the Codex rollout transcript names a
// shell call; `native-chat-edit-normalize` already calls the three command
// tools by those words, so a wrench on one would deny a command that ran.
for (const name of ['shell', 'bash', 'run_terminal_cmd', 'exec', 'local_shell']) {
expect(nativeChatToolCategory(name)).toBe('unknown')
expect(nativeChatToolIconName(name)).toBe('square-terminal')
}
})
describe('terminal activity for a two-glyph lane', () => {
// Mobile has only a terminal and a wrench, so it asks this instead of
// `nativeChatToolIconName`. The row word alone cannot answer it: Codex's
// classified `read` and Claude's `Read` are the same word lowercased.
it('reads a classified Codex row as terminal activity, by the command it kept', () => {
for (const [name, fields] of [
['read', { path: 'src/app.ts' }],
['search', { query: 'todo', directory: 'src' }],
['list', { directory: 'src' }]
] as const) {
expect(
isShellActivityToolCall({
name,
input: { command: 'rg todo src', cwd: '/repo', ...fields }
})
).toBe(true)
}
})
it('reads an unclassified shell row as terminal activity, by its name', () => {
for (const name of ['shell', 'bash', 'Bash', 'run_terminal_cmd']) {
expect(isShellActivityToolCall({ name, input: null })).toBe(true)
}
})
it('reads a rollout-transcript shell call as terminal activity', () => {
// `exec` and `local_shell` are not command tool names, so only the argv
// command in their input says a shell ran.
expect(
isShellActivityToolCall({ name: 'exec', input: '{"command":["bash","-lc","ls"]}' })
).toBe(true)
expect(
isShellActivityToolCall({ name: 'local_shell', input: { command: ['bash', '-lc', 'ls'] } })
).toBe(true)
})
it('leaves a Claude filesystem tool a generic tool, since no command ran', () => {
expect(
isShellActivityToolCall({ name: 'Read', input: { file_path: '/repo/src/app.ts' } })
).toBe(false)
expect(
isShellActivityToolCall({ name: 'Grep', input: { pattern: 'todo', path: 'src' } })
).toBe(false)
expect(isShellActivityToolCall({ name: 'Glob', input: { pattern: '**/*.ts' } })).toBe(false)
})
it('leaves an unmodelled tool a generic tool', () => {
expect(
isShellActivityToolCall({ name: 'AskUserQuestion', input: { question: 'which?' } })
).toBe(false)
for (const name of ['Edit', 'Diff', 'Task', 'WebFetch', 'TodoWrite', '']) {
expect(isShellActivityToolCall({ name, input: { file_path: 'a.ts' } })).toBe(false)
}
})
it('answers false for an input that carries no command, whatever its shape', () => {
for (const input of [
null,
undefined,
'ls -la',
42,
['bash', '-lc', 'ls'],
{},
{ cwd: '/r' }
]) {
expect(isShellActivityToolCall({ name: 'read', input })).toBe(false)
}
// A present-but-blank command is not a command that ran.
expect(isShellActivityToolCall({ name: 'read', input: { command: ' ' } })).toBe(false)
expect(isShellActivityToolCall({ name: 'read', input: { command: null } })).toBe(false)
})
})
describe('the glyph over a whole run', () => {
// A run header stands over a summary of the run's first calls, so its glyph
// may only claim a category every call in the run shares.
it('keeps the category when every call in the run is of it', () => {
const run = [{ name: 'Read' }, { name: 'read' }, { name: ' Read ' }]
expect(nativeChatToolRunCategory(run)).toBe('read')
expect(nativeChatToolRunIconName(run)).toBe('eye')
})
it('reads a run of differently-named shell calls as one shell run', () => {
// The categories agree even though the words do not, so the run is still
// one thing and keeps the terminal.
const run = [{ name: 'shell' }, { name: 'Bash' }, { name: 'local_shell' }]
expect(nativeChatToolRunCategory(run)).toBe('unknown')
expect(nativeChatToolRunIconName(run)).toBe('square-terminal')
})
it('falls back to the generic tool glyph when the run spans categories', () => {
const run = [{ name: 'shell' }, { name: 'Read' }]
expect(nativeChatToolRunCategory(run)).toBeNull()
// Either category here would describe only part of the run.
expect(nativeChatToolRunIconName(run)).toBe('wrench')
// Order does not make one call speak for the rest.
expect(nativeChatToolRunIconName([{ name: 'Read' }, { name: 'shell' }])).toBe('wrench')
})
it('takes a single call at its own category', () => {
expect(nativeChatToolRunCategory([{ name: 'Grep' }])).toBe('search')
expect(nativeChatToolRunIconName([{ name: 'Grep' }])).toBe('search')
expect(nativeChatToolRunIconName([{ name: 'apply_patch' }])).toBe('pencil')
})
it('reads a run of unmodelled tools as the generic category, not as spanning', () => {
const run = [{ name: 'AskUserQuestion' }, { name: 'SomeOtherTool' }]
expect(nativeChatToolRunCategory(run)).toBe('other')
expect(nativeChatToolRunIconName(run)).toBe('wrench')
})
it('has no glyph to give a run with no tool calls', () => {
expect(nativeChatToolRunCategory([])).toBeNull()
// Null, not a wrench: an empty header shows no glyph rather than a false one.
expect(nativeChatToolRunIconName([])).toBeNull()
})
})
it('does not answer a prototype key with a glyph', () => {
expect(nativeChatToolCategory('__proto__')).toBeNull()
expect(nativeChatToolCategory('constructor')).toBeNull()
expect(nativeChatToolIconName('__proto__')).toBe('wrench')
})
})
describe('qualified tool identity icons', () => {
it.each(['mcp__linear__list_issues'])('uses the MCP glyph for %s', (name) =>
expect(nativeChatToolIconName(name)).toBe('plug')
)
it.each([
'setup.py',
'src/read',
'src/tool.ts',
'/usr/bin/tool',
'tools/read',
'browser.open',
'package.lock',
'linear/list_issues',
'linear.list_issues'
])('does not claim MCP for %s', (name) => expect(nativeChatToolCategory(name)).toBeNull())
it('uses confirmed MCP metadata for row and run icons', () => {
const call = {
name: 'linear/list_issues',
mcpIdentity: { server: 'linear', tool: 'list_issues' }
}
expect(nativeChatToolIconName(call.name, call.mcpIdentity)).toBe('plug')
expect(nativeChatToolRunIconName([call])).toBe('plug')
})
it('keeps classified command and web identities', () => {
expect(nativeChatToolCategory('read')).toBe('read')
expect(nativeChatToolCategory('search')).toBe('search')
expect(nativeChatToolCategory('list')).toBe('listFiles')
expect(nativeChatToolIconName('web_search')).toBe('globe')
})
})