Files
orca/src/main/codex/codex-child-work-translation.ts
T
Brennan Benson 153d3fd3fa feat(native-chat): Codex sessions write their subagents into the host status store (#22553)
* refactor(native-chat): the Codex acquire names its turn-boundary methods as a set

Behavior-neutral: the same two methods stamp receipt time. Keeps the file
under the size limit once the child-work sink lands.

* feat(native-chat): Codex sessions write their subagents into the host status store

A Codex child thread and each persistent command become host child records,
fed through the same delivery, ingest and reducer the Claude lane uses. The
child's own turn decides it: turn start is live, turn completion settles it
with the outcome Codex reports, and a follow-up turn reopens the same record
as a new run. Its open tool call, last message, usage and waiting-on-user flag
come from its own thread's frames. A parent turn ending settles nothing.

* fix(native-chat): close a Codex child's tool call by its item id alone

A completion frame need not restate the tool it ran, so reading the tool name
before closing left the call open and the record naming a finished tool.

* test(native-chat): pin the Codex child-work evidence and every hop to the host's records

Child turn start/end/follow-up, open tool call, last message, usage, waiting,
the persistent command a child owns and its monitoring display, a primary
turn end settling nothing, and session end. End to end through the real
adapter: evidence after the journal and the legacy republish, and the parent
state the records imply equals today's at every frame of a scripted session.
Through the production runtime: a Codex session's child work reaches the
status sink under its own address, and a provider exit ends it there.

* test(native-chat): a Codex child's new run never inherits the last run's open call

* test(native-chat): a Codex session with no child-work sink holds no evidence

* test(native-chat): deliver a Codex child's announcement twice, as Codex does, before counting edges

* refactor(native-chat): hand the Codex producer's pending edge over directly

* fix(native-chat): name every Codex turn state in the outcome map; type the runtime test's fake opener

* fix(native-chat): a Codex child's turn ends on the error that ends it, or on its thread closing

Codex can end a child's turn with no turn/completed: an error it will not
retry is that turn's own end (the verdict the transcript already settles the
same turn on), and a closed thread ran its last turn. The executions, the one
owner of child turn state, now end the turn on both, so the strip drops the
child and its record settles (failed, or unknown for a close) together,
instead of reading working for the life of the session. A systemError status
is not an ending: Codex raises it for errors that leave the turn running.

A child fact whose frame names no turn now belongs to the turn the child is
running, instead of counting for every run.

* test(native-chat): a Codex child's turn ending by fatal error or thread close settles strip and record together

* test(native-chat): the Codex parity script reads a waiting child through the shared fold's waiting arm

* test(native-chat): a Codex child row's journal attempt is its record's generation

The journal numbers a Codex child's runs by the turns it observed on the
child's thread; the host record numbers them by the runs its evidence
opened. Both are keyed by the child's own turn id, so they must agree run for
run, including when Codex reports the child's first turn before the spawn
that announces it.

* test(native-chat): a Codex session's end settles its live children and keeps the ended ones

The host no longer erases a session's children when its provider goes away: a
child still running settles with an outcome nobody reported, and a child that
had already ended keeps what it said. The producer tests now expect exactly
that, from the close path and from an unexpected exit.

* fix(native-chat): a Codex subagent's shell is its open tool until the process exits

Codex runs every agent shell through unified exec, so every subagent shell
arrives with the source the persistent-command tracker keys on. The producer
skipped those items, so a working subagent never named its shell, and an
approved command (started on the approval path, completed from unified exec)
stayed its open tool until the turn ended. The tracker still records the
process separately, so a command that outlives the turn reads as monitoring.

* fix(native-chat): a Codex shell becomes a subagent's own work only once it outlives its turn

Codex runs every agent shell through unified exec and never says when one is
left running, so the producer turned every shell, even a millisecond `rg`, into
a command record the moment it started. Each settled into the session's pool
of 32 settled records, so a busy turn evicted a finished subagent's record
(its outcome row would vanish) and listed dozens of finished shells beside it.

A command now becomes a record at the first turn boundary of the thread that
launched it while its process still runs: until then it is the agent's open
call. A shell that exits within its turn never becomes a record.

* refactor(native-chat): child records keep every settled child and can be removed outright

Settled child records now stay until the host drops the session's row; the
32-record trim is gone. A producer can say work stopped with nothing to
report, and its record (and the handles it answered to) goes instead of
settling. Evidence stays host-internal: the producer and the store share
one process.

* fix(native-chat): a Codex command is live work from its start until its process stops

The command tracker is now the one owner of a Codex command's lifetime. It
admits every command whatever `source` Codex tags it with (the approval
path starts one as `agent`), and ends it when its process exits, when its
thread closes (Codex stops the processes first, so no exit ever arrives),
or when the session ends. The producer mirrors that one-to-one: a live
record from the start, removed when the command stops, never settled.

This removes the turn-boundary rule: a command that was only recorded at
its turn's end left the parent reading done for one publish when the main
agent's turn ended with a shell still running. The parity script now
checks the parent at every journal write, not only at frame end.

* fix(native-chat): a Codex command whose approval its turn abandoned never ran

Codex starts an approval's command item before it asks, and when the turn
ends with the question unanswered (the user stops at the approval), it drops
the question and never completes the item. The command tracker admitted that
start as a running process, so the strip kept a phantom command row and the
session row read working until the session ended.

The prompt registry, which owns which approvals are still unanswered, reports
the command approvals a turn ended without; the tracker ends those commands
with the frame that ended the turn. An answered approval keeps its command.

* test(native-chat): start the Codex child-work runtime test without the removed hold

Main no longer has host.hold: creating the session starts its child, and
nothing a viewer does keeps it running. The test attaches and asserts the
one child that attach started, then drives it as before.
2026-09-28 14:56:48 -07:00

89 lines
3.3 KiB
TypeScript

// Codex items and statuses, read in the child-work vocabulary.
//
// A tool is named the way Codex names it to its own hooks (`Bash`, `apply_patch`,
// `mcp__server__tool`), so a structured Codex child running a shell reads exactly as a Codex
// CLI agent running one does.
import type { AgentChildWorkOutcome } from '../../shared/agent-status-child-work'
import type { NativeChatSubagentState } from '../../shared/native-chat-types'
import {
deriveFallbackToolInputPreview,
deriveToolInputPreview
} from '../../shared/agent-hook-listener/tool-input-preview'
import { readRecord, readString, readTextContent } from './codex-item-field-readers'
import type { CodexThreadItem } from './codex-structured-item-translation'
/** Raw provider text kept for a record; admission folds it to its own one-line bound. */
export const CODEX_CHILD_WORK_TEXT_MAX_CHARS = 2_048
export type CodexChildToolCall = { toolName: string; input?: string }
function bounded(text: string | null | undefined): string | undefined {
return text ? text.slice(0, CODEX_CHILD_WORK_TEXT_MAX_CHARS) : undefined
}
function withInput(toolName: string, input: string | undefined): CodexChildToolCall {
const preview = bounded(input)
return preview ? { toolName, input: preview } : { toolName }
}
function firstChangePath(changes: unknown): string | undefined {
const [first] = Array.isArray(changes) ? changes : []
return readString(readRecord(first), 'path') ?? undefined
}
/** The tool a thread item runs, or null for an item that is not a tool call (a message, a
* thought, a plan). */
export function codexChildToolCall(item: CodexThreadItem): CodexChildToolCall | null {
switch (item.type) {
case 'commandExecution':
return withInput('Bash', deriveToolInputPreview('Bash', { command: item.command }))
case 'fileChange':
return withInput('apply_patch', firstChangePath(item.changes))
case 'mcpToolCall': {
const server = readString(item, 'server')
const tool = readString(item, 'tool')
if (!tool) {
return null
}
return withInput(
server ? `mcp__${server}__${tool}` : tool,
deriveFallbackToolInputPreview(item.arguments)
)
}
case 'webSearch':
return withInput('web_search', readString(item, 'query') ?? undefined)
default:
return null
}
}
/** Whether an item frame says the call is over, whatever frame carried it. */
export function codexToolCallEnded(method: string, item: CodexThreadItem): boolean {
const status = readString(item, 'status')
return method === 'item/completed' || (status !== null && status !== 'inProgress')
}
/** What a child said: an assistant message's text. */
export function codexChildMessageText(item: CodexThreadItem): string | undefined {
return item.type === 'agentMessage'
? bounded(readString(item, 'text') ?? readTextContent(item, 'content'))
: undefined
}
/** A child turn's ending. Codex states three; anything else is an ending nobody classified. */
export function codexChildTurnOutcome(state: NativeChatSubagentState): AgentChildWorkOutcome {
switch (state) {
case 'completed':
return 'succeeded'
case 'failed':
return 'failed'
case 'stopped':
return 'cancelled'
case 'unverifiable':
case 'working':
case 'idle':
return 'unknown'
}
}