feat(native-chat): register Codex default-mode helpers as subagents - #22619
brennanb2025 wants to merge 9 commits into
Conversation
There was a problem hiding this comment.
Important
One gap worth addressing before merge: a helper the caller closes updates the strip and the host record, but the durable subagent roster row keeps reporting working.
Reviewed changes
- Default-mode helper membership —
readCodexSubagentAnnouncementnow treats a finishedspawnAgentcall as a second way to announce a child, and both the roster and the background-task tracker register through it into one sharedCodexSubagentExecutions. - Collab tool rows — a new
codex-collab-agent-item-translationrenders eachcollabAgentToolCallas a namedtool-callrow (spawn_agent,wait_agent, …) instead of a rawcodex · item:collabAgentToolCallstatus row, and forwards the roster's label so an interrupted turn keeps its helper names. - Close lifecycle — a finished
closeAgentbecomes aturn-ended/stoppedframe;CodexChildWorkEvidencenow keys on the frame's child thread rather than the event's. - Output pairing — the projection attaches its journal row's
callIdto the tool-result block, andpairNativeChatToolResultsresolves a named result to its call before falling back to positional pairing. - Run vocabulary —
spawn_agentcounts as running an agent and takes the bot glyph.
ℹ️ Nitpicks
- The roster row appends a collision ordinal when two helpers share a label (
claimLabel), but the new tool row and the strip both name a helper by the rawexecutions.label. Two default-mode helpers whose prompt heads coincide will therefore readX/X 2on the roster butX/Xon theirspawn_agent/wait_agentrows, so the "two rows read as the same child" invariant incodex-collab-agent-item-translation.tsdoes not hold for colliding labels. Theagentsarray still disambiguates them. - The
diffbranch ofitemBlocksstill emits an unnamedtool-result, so an edit patch can still paint under an earlier output-less call (for example aspawn_agent).AgentJournalDiffItemcarries nocallId, so closing this is a schema addition rather than a projection tweak — worth a follow-up, not this PR.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes — delta since the prior pullfrog review (80b2032040 → d09c8b2286)
- The roster row now follows the executions —
CodexSubagentExecutionsfires a newonExecutionChangedlistener fromobserveTurnandsettleSession, andCodexSubagentRostersubscribes in its constructor and rewrites the group row from the changed execution. A fatalerror, athread/closedand acloseAgenttherefore settle the durable subagent row, not only the strip and the host record.handleTurnstill returns the write's admission via an idempotent re-derive, anddispose()unsubscribes. - Three tests added — the roster row settles with the strip and the record on a fatal error (
failed), onthread/closed(unverifiable), and oncloseAgent(stopped), each asserting the lastsubagent-grouprevision.
This closes the closeAgent gap flagged in the prior review. The listener propagation reads correct: it fires only when an execution is actually assigned, duplicate writes collapse on lastSerialized, and it cannot touch the primary thread because that thread is never registered as a child.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes — delta since the prior pullfrog review (d09c8b2286 → 6214d76206)
- Restored call rows name their helper — replayed-item handling moved into
restoreCodexHistoryItem, which registers each announced helper inCodexSubagentExecutions(registerstarts no execution, so no strip entry, record or roster row claims it runs). A restoredwait_agent/close_agent/send_inputrow now names its helper instead of the raw thread id. - Roster exposes its executions —
CodexSubagentRoster.executionsis now public andhelperLabelis gone; the collab row's name callback readssubagents.executions.labeldirectly. - Render contract named —
handleItem's claim flag is renamedrenderedByRoster, documenting that asubAgentActivityrenders as the roster row alone while a spawn call keeps its own row. - Comment clarification — the collab tool-name map notes it maps the wire's camelCase to the model-facing snake_case, not a spelling fix.
- Tests —
a restored thread(2) through the real translator; the naming test reddens without the registration (verified by ablation).
The registration runs after primaryThreadId is set (codex-structured-session-acquire.ts:197), so it cannot register the primary thread as a child; register's idempotence and the MAX_CHILDREN cap bound a restored thread's historical helpers. Both existing tests and the new file pass at this head.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
f098d8d to
c99160f
Compare
6214d76 to
98978fa
Compare
c99160f to
8e22ed0
Compare
98978fa to
bd540b2
Compare
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes — delta since the prior pullfrog review (bd540b299f → c915a9cf3e)
- A failed spawn now registers its helper —
codexCollabSpawnedThread(codex-collab-agent-tool-call.ts:65) drops thestatus === 'completed'gate, so aspawnAgentcall that endedfailedbut named a receiver registers that helper in the one sharedCodexSubagentExecutions. Codex reportsfailedfor a helper that errored at birth while still naming the thread it created, and that thread can run a turn. - Three tests — a failed spawn with a receiver registers the helper (strip, records and roster row); a failed spawn with no receiver registers nothing; a failed
closeAgentleaves the helper running (the close gate is preserved). Each reads the real adapter path.
The removal is safe against the shapes it must not touch. An in-progress spawnAgent names no receiver, and the first test still asserts that the inProgress/empty-receiver call registers nothing. Registration is idempotent and starts no execution, so a failed spawn whose thread never runs a turn yields no strip entry, record or roster row — the child stays registered only for its name, bounded by MAX_CHILDREN. The closeAgent gate is unchanged and now guarded by its own test.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
8e22ed0 to
08ca92b
Compare
c915a9c to
d0d1de8
Compare
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes — delta since the prior pullfrog review (c915a9c → d0d1de8)
- Token totals moved to their own module — the roster's per-thread token map became
CodexThreadTokenTotalsincodex-thread-token-totals.ts, which owns the newest-report-replaces rule and the recency-ordered LRU cap (MAX_CODEX_TOKEN_USAGE_THREADS). The roster now callsrecord/get/clearand still selects its children's totals at write time, so behavior is unchanged. - Test —
codex-thread-token-totals.test.tscovers newest-report-replaces and that a thread which keeps reporting outlives quieter ones under eviction.
Verified at d0d1de85: pnpm tc:node, pnpm exec oxlint on the changed files, and pnpm run check:max-lines-ratchet all exit 0; codex-thread-token-totals.test.ts and codex-subagent-roster.test.ts pass (37). The branch was also rebased onto c4-codex-producer 08ca92b2; reading the full diff against that base confirms the producer-linkage plumbing is content-preserving for this PR.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
08ca92b to
358d1a9
Compare
d0d1de8 to
c77bad1
Compare
…wn 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, scoping its status to the session's own asks put it past the size limit. The module is byte-identical to the one #22619 extracts for the same reason, so the two branches carry one change.
358d1a9 to
1db34d1
Compare
c77bad1 to
de9ea37
Compare
…wn 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, scoping its status to the session's own asks put it past the size limit. The module is byte-identical to the one #22619 extracts for the same reason, so the two branches carry one change.
1db34d1 to
36242df
Compare
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.
…ds its helper Also pin that the roster row appears when the helper's first turn arrives before the spawn call finishes.
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.
…ry 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.
… 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.
… 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.
…s 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.
…wn 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.
de9ea37 to
748ef6e
Compare
…wn 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, scoping its status to the session's own asks put it past the size limit. The module is byte-identical to the one #22619 extracts for the same reason, so the two branches carry one change.

ELI5
When a Codex chat runs in its default multi-agent mode and starts a helper agent, Orca did not know the helper existed. The helper got no row in the transcript and no entry in the status dock, and the dock showed the helper's
sleep 45command as if the main agent were running it. Now Orca picks the helper up from the call that started it. The helper gets the same subagent row, dock entry and host record as any other Codex subagent. The calls the main agent makes to it (start it, wait for it, close it) show as readable tool rows that name the helper, not ascodex · item:collabAgentToolCall.Stacked on #22553 (the Codex child-record producer; this branch was rebased onto its head
8e22ed0ad2). The live test that found this gap is #22532. #22614 (the strip and sidebar switch) depends on this one to show anything for Codex in default mode.What Changed
The mechanism
Codex 0.155 has two multi-agent modes, and the model decides which one runs. In a live run, GPT-5.5 used the default mode and GPT-5.6-Terra used v2.
subAgentActivityitems.subAgentActivityat all. A helper appears only as the parent'scollabAgentToolCallitem:spawnAgentstarts withreceiverThreadIds: [], and the finished call names the helper's thread.Before this PR, the journal roster and the background-task tracker registered children only from
subAgentActivity. They share oneCodexSubagentExecutions, so in default mode nothing registered: no child in the executions, no strip row, no host record and no roster row.One membership model, two wire sources
readCodexSubagentAnnouncement(codex-subagent-activity.ts) returns the child an item announces, from either shape. That is asubAgentActivity(never the tree root), or the receiver an endedspawnAgentcall names, whatever the call's status. Both are keyed by the child's thread id.spawnAgentstarts withreceiverThreadIds: [](in the schema and in the live journal), so the helper's thread id does not exist until the call ends. Codex reports the call asfailedwhen the helper it created errored at birth, yet still names the thread it created, and that thread can run a turn. (Codex'scollab_tool_call_statusmapsAgentStatus::ErroredtoFailedeven with a receiver, and the spawn handler fillsreceiverThreadIdsfrom the created thread.) Gating oncompletedwould leave that helper nowhere and put its shell back in the dock as the session's own command, which is the bug this PR fixes. A spawn that created nothing names no receiver and registers nothing.closeAgentkeeps itscompletedgate: a failed close means the helper was not closed, so it ends nothing.handleItem) and the tracker's frame reader (readCodexBackgroundTaskFrame) both register through it into the same executions. There is no second roster and no second state source.agentsStatesis the caller's snapshot, not a state source, so it is never read as state. Itsmessageis used only as the call row's output.closeAgentends the helper's running turn asstopped, which maps tocancelled. It goes through the sameendTurnpath feat(native-chat): Codex sessions write their subagents into the host status store #22553 uses for a fatalerrororthread/closed, and the first ending a turn gets stands. Closing a helper that already finished changes nothing. There are no timers.closeAgentcounts as an ending: in every observed case the child's own frames end it. But in default mode a closed helper may send no further frames at all, and a caller deliberately shutting a helper down is a cancellation, not a failure. If Codex also sendsthread/closedfor the closed helper, whichever ending arrives first stands, so the two cannot conflict.CodexChildWorkEvidence.childThreadnow keys a frame on the frame's own child thread, not the event's. Before, acloseAgent(sent on the parent's thread) would have settled the strip but never queued the record's ending.v2 sessions change too. Their collab calls (for example
wait, which in v2 has empty receivers) also switch from raw status rows to typed rows. Helper registration in v2 is unchanged: it still comes fromsubAgentActivity.The dock
This PR adds no new labeling path. Once the helper is registered, the existing tracker path applies:
<helper> — <command>. The unit test covers this; it was not seen live.The transcript
collabAgentToolCallnow renders as atool-callrow (codex-collab-agent-item-translation.ts), named by the tool the model called:spawn_agent,send_input,resume_agent,wait_agent,close_agent, and so on.description, the key the row label reads, so the row names the helper the way the roster does.agentsStates[].message) is the row's output.spawn_agentrow counts as running an agent in the run sentence and gets the bot glyph. Wait, message and close calls run none, so they stay generic tools.Kicked off 1 subagent) now appears in default mode too, beside the spawn call's own row, as it does for other lanes.This PR closes a gap in #22553: the transcript row now settles with the strip and the record
#22553 said a child's strip row and host record settle together when its turn ends by a fatal
errororthread/closed. That held for those two, but not for the transcript's subagent row.CodexSubagentRosterrewrote that row only on a child'sturn/startedandturn/completed, while the other endings reach the executions through the tracker. So after a fatal error, a closed thread or acloseAgent, the strip dropped the child and the record settled, but the row read working until the session ended.The fix does not add a new list of frames.
CodexSubagentExecutions(the one owner of child turn state) now reports when a child's current execution changes (onExecutionChanged), and the roster rewrites the group row from that, whichever frame caused it.handleTurnstill recomputes the row so it can return the write's admission; the second write is a no-op when nothing changed. If a write is refused while following, the group'slastSerializedis cleared, so the next write for that group retries it.Which mode runs is the model's choice
On Codex 0.155, the model decides which multi-agent mode runs. In the live runs GPT-5.5 used the default mode (collab calls only, no
subAgentActivity), and GPT-5.6-Terra used v2 (subAgentActivityfor the spawn). Both announcement shapes have to register, and a session can switch shapes when the user switches models.A renderer fix this exposed
A folded run paired tool results to calls by position. So once one call finished with no output (
spawn_agent), every later output drew under the call before its own: the helper's reply underspawn_agent, and the shell's output underwait_agent. This was visible live.A structured row carries its call and its output together, so the projected result now names its call id (
NativeChatToolResultBlock.callId), andpairNativeChatToolResultspairs by it. A result that names no call is still paired by position.callIdis produced wherever the journal is projected:worker-readRPC,orchestration-structured-worker-lifecycle.ts);structured-worker-journal-archive.ts, read back byworker-output-archive.ts).It is optional everywhere and copies the journal row's own
callId. Mixed versions and old archives fall back to the previous positional pairing, and old readers ignore the field.This also changes pairing for Claude folded runs: Claude tool rows already carry call ids (
claude-structured-item-translation.ts), and the same misattribution existed there. On main it could also happen for Codex whenever a call finished with no output (aweb_searchwith no results, for example). The non-structured transcript path sets nocallId, so legacy native chat is unchanged.Why
In Codex's default mode, helpers were invisible to every surface that reads the executions, and the dock blamed the main agent for a helper's command. Registering the helper at the executions (the one owner of Codex child identity) fixes the strip, the host records and the transcript roster together. The records then feed the next PR's strip and sidebar.
Alternatives considered:
agentsStatesas the helper's state. Rejected: it is the caller's last-known snapshot, only refreshed when the caller makes a call. The helper's own turn frames already give its state (feat(native-chat): Codex sessions write their subagents into the host status store #22553).Linked Issue
None — part of the structured chat status/orchestration program. Found by the live QA on #22532. Base: #22553.
Visual Proof
Codex
codex-cli 0.155.0-alpha.9.2, GPT-5.5 (default multi-agent mode), dev builds withORCA_BACKGROUND_LAUNCH=1, CDP screenshots of the hidden window. Prompt: "Spawn exactly one subagent. Tell it to run the shell commandsleep 45; echo CHILD_DONEand then reply with the single word CHILD_REPLY…"BEFORE (#22553 head
f098d8db9e), while the helper sleeps: the transcript shows two rawcodex item:collabAgentToolCallrows, there is no subagent row, and the dock reads 1 shell command — working. The dock is naming the helper's command as the session's own.AFTER, same moment: a Kicked off 1 subagent · working roster row, and the dock reads 1 agent — working.
AFTER, dock expanded: the agent row names the helper by its prompt.
AFTER, settled turn expanded:
spawn_agentandwait_agentrows name the helper.wait_agentownsCHILD_REPLYand the shell ownsCHILD_DONE(pairing fix), and the roster row reads Ran 1 subagent · completed · 52s · 66.4k tokens.The AFTER journal holds no
statusrow with provider frameitem:collabAgentToolCall. The BEFORE journal holds four (revisions of three calls). Both journals are preserved as~/orca-qa/c8/journals/{before,after}-gpt55.db.Testing
pnpm tc:node,pnpm tc:webandpnpm tc:cliall exit 0.pnpm run check:code-quality:changedshows 0 findings (React Doctor included).pnpm exec oxlinton every changed file exits 0, andpnpm run audit:anti-slopexits 0.env -u ORCA_STRUCTURED_SESSION -u ORCA_AGENT_SESSION_IDat the final head:src/main/codex,src/main/native-chat,src/sharedandsrc/renderer/src/components/native-chattogether: 1355 files, 14,531 passed, 0 failed. Fullpnpm exec oxlint: exit 0 (2 existing warnings in mobile test files, none in this PR's files).src/main/native-chat/agent-session-wirealso passes. In an earlier run under load, one lease-renewer temp-dirENOTEMPTYfailure (a suite this PR does not touch) passed 7/7 alone.codex-default-mode-helpers.test.ts(16), through the realCodexStructuredSessionAdapter. The spawn and wait items are verbatim from the fix(native-chat): record which Codex agent produced each journal row #22532 live journal; only the sender is remapped to the fixture thread. It covers:closeAgenton a running helper givingcancelled, and on a finished one changing nothing;closeAgentleaves the helper running (3);error, onthread/closed, and oncloseAgent(3). At the feat(native-chat): Codex sessions write their subagents into the host status store #22553 head all three fail; the row stays working;wait_agentrow names its helper as the live row did, and a name learned from history leaves no strip entry, no record evidence and no roster row.spawn_agent(1).8e22ed0ad2after the stack rebase), 14 of the 17 new Codex and glyph tests fail, including all three roster-row tests, the restored-row test and the failed-spawn test. The three that pass are guards by design: "keeps one child for a session that announces the helper both ways", "registers nothing for a spawn that created no thread" and "claims no running helper for a name it learned from history".HEAD:closeAgentends the helper's running turnspawn_agentcounts as running an agentobserveTurn)The roster and the tracker share one executions instance. When the helper's first turn arrives after its spawn, either registration alone reaches every surface. Each one is needed only when the turn arrives first, and that is the one test each arm reddens.
Review
What I did NOT verify:
closeAgentlive. The model never closed its helper in these runs. It is covered only by the unit tests, which were built from the schema and the provider's own item mapping.waitwithreceiverThreadIds: []and no spawn call, so on this branch its wait row reads a barewait_agent(no helper to name). That comes from reading the captured journal shape, not a live AFTER run.wait_agent,close_agentorsend_inputrow showed the raw thread UUID as its label. Replay now registers each announced helper for its name and membership only (restoreCodexHistoryItem).registerstarts no execution, so the helper gets no strip entry, record or roster row until a live turn of its own. The spawn row keeps its prompt label either way. The roster's group rows are still not re-seeded on restore (the known limit incodex-subagent-roster.ts, from feat(native-chat): Codex sessions write their subagents into the host status store #22553), so there are no live Codex child records to render after a restart until new frames arrive. Restore is unit-tested only, not run live.receiver_agents) on the collab call, but the app-server item does not forward them yet (absent from the 0.155 schema). Once it does,readCodexSubagentAnnouncementis the one place that should adopt them.Notes
tool-callrows, and a roster row appears. Both are existing body kinds that older clients already render.NativeChatToolResultBlock.callIdcrosses the worker-read RPC and is persisted in released-worker archives (see "A renderer fix this exposed"). It is optional, so mixed versions degrade to positional pairing.codex app-server generate-json-schema, 0.155):spawnAgent,sendInput,resumeAgent,wait,closeAgent,sendMessage,followupTask,interruptAgent,listAgents.descriptionis its prompt head, not a task name.Merge order
git merge-tree, checked at this head), in either order. Codex default-mode helpers appear in the sidebar and the strip only once both are in: feat(native-chat): the chat strip and the sidebar read the host's child records #22614 reads the host records, and this PR is what writes them for default mode. Once feat(native-chat): the chat strip and the sidebar read the host's child records #22614 is in, default-mode helpers appear in both surfaces with no further work, in either merge order: this PR's default-mode records flow through the sameonChildWorkEvidence→ store path feat(native-chat): the chat strip and the sidebar read the host's child records #22614's surfaces read (the real adapter-to-store path is covered bycodex-default-mode-helpers.test.ts).codex-subagent-executions.ts,codex-subagent-roster.tsandcodex-structured-journal-translation.ts. Whoever rebases the feat(native-chat): Codex sessions write their subagents into the host status store #22553 → feat(native-chat): register Codex default-mode helpers as subagents #22619 line onto merged fix(native-chat): record which Codex agent produced each journal row #22532 resolves them. I resolved them in a scratch merge againstorigin/brennanb2025/c0-codex-linkage(edd10f6f8b) and did not keep it:find()and fix(native-chat): record which Codex agent produced each journal row #22532'sspawnerOf()andturnOrdinal(). The child keeps bothexecutionand fix(native-chat): record which Codex agent produced each journal row #22532'sturnOrdinals/turnCount. Keep this PR'sonExecutionChangedlistener.executionsand its follow subscription, and fix(native-chat): record which Codex agent produced each journal row #22532'slinkage.handleItemregisters from the announcement and passes fix(native-chat): record which Codex agent produced each journal row #22532's spawner argument asannouncement.spawned ? input.threadId : undefined, so collab spawns name their spawner too.createCodexJournalTranslatorWritersextraction, and move this PR's helper-name lookup into it as the fourthCodexJournalItemsargument,(threadId) => subagents.executions.label(threadId). KeeprestoreCodexHistoryIteminrestoreThread.src/main/codex+src/main/native-chatall pass: 365 files, 3,550 tests, 0 failed. One issue remains: the mergedcodex-subagent-roster.tsis 305 lines against the 300 limit, so the rebase also owes a small extraction from the roster (for example the group lookup andfollow).turnOrdinalagainst the record'sgeneration. It becomes the rebaser's job, since fix(native-chat): record which Codex agent produced each journal row #22532 lands first.