Files
orca/src/main/codex/codex-command-action-class.ts
T
Brennan BensonandMerge Sim 471a5f4aa7 feat(native-chat): model Codex MCP and web-search items instead of leaking opcodes (#18763)
* feat(native-chat): model Codex MCP and web-search items instead of leaking opcodes

Codex's app-server sends 19 thread-item types; the structured translator handled
six. The rest fell through to a generic gray `codex · item:<type>` row, even
though the disposition table's own comment says it exists so a new item type
cannot leak like that — the table had one entry.

Give `mcpToolCall` and `webSearch` real tool-call bodies, and chrome `sleep`,
which carries only a duration and renders as nothing in Codex's own TUI.

`subAgentActivity` and `collabAgentToolCall` deliberately keep their generic
rows. They arrive in real sessions today and are currently the only visible
sign a subagent is running; hiding them before the subagent UI lands would
render minutes of work as an idle turn. Tests pin that they stay visible.

MCP tool names pass through verbatim when they contain `:`, `.`, `/` or `__`,
so `mcp__server__tool` survives instead of being title-cased into nonsense.

* fix(native-chat): keep Codex MCP tool identity and web-search results on the row

Four fixes to the Codex MCP / web-search item bodies:

- Drop the title-casing display name. `get_forecast` became `Get Forecast`,
  which no longer matches the raw snake_case identifiers that the diff
  renderer, question parsers, and tool-input previews dispatch on, and does not
  match how the Claude lane or the sibling `shell`/`apply_patch`/`web_search`
  bodies name a tool. The row name is now `server/tool` verbatim, the bare
  `tool` when no server is given, and `mcp` when the item names no tool at all.
  Server-qualifying also stops an MCP tool that happens to be called
  `apply_patch` from hijacking the diff renderer.

- Pass the MCP call's own `arguments` as the tool input instead of wrapping it
  in `{server, tool, arguments}`. Row-label derivation only reads top-level
  keys, so the wrapper degraded every MCP row to a truncated raw JSON blob.
  A non-object `arguments` stays addressable under a key rather than being
  dropped; an absent one becomes null, which labels as empty rather than `{}`.

- Carry a web search's `results` as the call output, bounded like every other
  inline payload and omitted when there are none. They were being dropped
  entirely, which showed less than the generic fallback row it replaced.

- No streaming branches were added for these two item types: the Codex delta
  stream is a closed set of six methods that neither can reach, so such
  branches would be unreachable.

* fix(native-chat): label Codex web searches and argument-less MCP calls

A row label is derived from top-level `input` keys only, so a webSearch
whose detail lives inside `action` — an opened page, an in-page find, or
a bare `other` — fell through to the raw JSON of the whole input, as did
the empty `query` Codex leaves on a completed search. Hoist the action's
`url`, `pattern` and `type` beside the query, keep the full `action`
object so the expanded detail loses nothing, and emit no input at all for
the start frame.

An MCP tool that takes no arguments sends `arguments: {}`, which passed
straight through and labelled the row a literal `{}`; treat it as absent
so the row reads as a bare `server/tool`.

Split the durable-identity half of the item translator into
`codex-thread-item-identity.ts`, re-exported so every existing import is
unchanged, to keep both files under the max-lines cap.

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-05 15:33:04 -07:00

72 lines
2.9 KiB
TypeScript

import { readRecord, readString } from './codex-item-field-readers'
import type { CodexThreadItem } from './codex-thread-item-identity'
/**
* Codex's own classification of a shell call: the tool name to show, and the
* fields worth lifting into `input` for the shared label helper (a file target,
* a search term, a scanned root). A `Map`, not an object — an object index
* answers `__proto__` with a truthy non-string. Every other action type stays an
* unclassified `shell` row.
*
* Nothing is invented for a field Codex sends as null: a stand-in path is a
* claim about a target, and the label helper turns any path into a file link.
*/
type CommandActionClass = {
name: string
/** Action field to the `input` key it lifts to. A scan root and a listed
* directory lift to `directory`, never `path`: the label helper reads `path`
* as a file target, which mobile turns into a tappable open-file link. */
keys: Readonly<Record<string, string>>
}
const COMMAND_ACTION_CLASSES = new Map<string, CommandActionClass>([
['read', { name: 'read', keys: { path: 'path' } }],
['search', { name: 'search', keys: { query: 'query', path: 'directory' } }],
['listFiles', { name: 'list', keys: { path: 'directory' } }]
])
/** The one class every classified `commandActions` entry agrees on, with the
* fields they all agree on; null leaves the row exactly as a Codex that sends no
* classification renders it. `cat a.txt && ls src` classifies as two different
* things, and naming that row after either would drop the other, so it stays a
* `shell` row that shows the whole command. */
export function commandActionFacts(
item: CodexThreadItem
): { name: string; fields: Record<string, string> } | null {
const actions = item.commandActions
if (!Array.isArray(actions)) {
return null
}
let matched: { class: CommandActionClass; fields: Record<string, string> } | null = null
for (const action of actions) {
const record = readRecord(action)
const type = readString(record, 'type')
const classified = type === null ? undefined : COMMAND_ACTION_CLASSES.get(type)
if (classified === undefined) {
continue
}
if (matched === null) {
const fields: Record<string, string> = {}
for (const [source, lifted] of Object.entries(classified.keys)) {
const value = readString(record, source)
if (value !== null) {
fields[lifted] = value
}
}
matched = { class: classified, fields }
continue
}
if (matched.class.name !== classified.name) {
return null
}
// The same class twice keeps the class, but only a target both entries name.
for (const [source, lifted] of Object.entries(matched.class.keys)) {
const kept = matched.fields[lifted]
if (kept !== undefined && readString(record, source) !== kept) {
delete matched.fields[lifted]
}
}
}
return matched === null ? null : { name: matched.class.name, fields: matched.fields }
}