From a14a4233734d9c7f388dd8b03b6438d03701d2f7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 23:49:39 -0700 Subject: [PATCH 01/24] 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. --- .../codex/codex-background-task-frames.ts | 45 ++- src/main/codex/codex-child-work-evidence.ts | 9 +- .../codex-collab-agent-item-translation.ts | 81 ++++ .../codex/codex-collab-agent-tool-call.ts | 102 +++++ .../codex/codex-default-mode-helpers.test.ts | 363 ++++++++++++++++++ src/main/codex/codex-item-run-state.ts | 16 + .../codex-structured-item-translation.ts | 34 +- .../codex/codex-structured-journal-items.ts | 11 +- .../codex-structured-journal-settlement.ts | 7 +- ...-structured-journal-translation-writers.ts | 8 +- src/main/codex/codex-subagent-activity.ts | 51 ++- src/main/codex/codex-subagent-roster.ts | 44 ++- .../provider-frame-disposition.ts | 8 +- src/shared/native-chat-tool-icon.test.ts | 7 + src/shared/native-chat-tool-icon.ts | 2 + 15 files changed, 711 insertions(+), 77 deletions(-) create mode 100644 src/main/codex/codex-collab-agent-item-translation.ts create mode 100644 src/main/codex/codex-collab-agent-tool-call.ts create mode 100644 src/main/codex/codex-default-mode-helpers.test.ts create mode 100644 src/main/codex/codex-item-run-state.ts diff --git a/src/main/codex/codex-background-task-frames.ts b/src/main/codex/codex-background-task-frames.ts index 029cc7b50c77..d23fd7bbb3b4 100644 --- a/src/main/codex/codex-background-task-frames.ts +++ b/src/main/codex/codex-background-task-frames.ts @@ -1,9 +1,9 @@ import type { NativeChatSubagentState } from '../../shared/native-chat-types' +import { readCodexSubagentAnnouncement } from './codex-subagent-activity' import { - codexSubagentLabel, - isCodexRootAgentActivity, - readCodexSubagentActivity -} from './codex-subagent-activity' + codexCollabClosedThread, + readCodexCollabAgentToolCall +} from './codex-collab-agent-tool-call' import { codexChildTurnState } from './codex-subagent-executions' import { readRecord } from './codex-item-field-readers' import { readCodexThreadItem } from './codex-structured-item-translation' @@ -15,7 +15,7 @@ export type CodexBackgroundTaskFrame = agentThreadId: string label: string | null parentTurnId: string | null | undefined - /** The reporting thread, for a `started` activity: the agent that spawned the child. */ + /** The reporting thread, for a spawn: the agent that spawned the child. */ spawnerThreadId: string | undefined } | { @@ -25,14 +25,15 @@ export type CodexBackgroundTaskFrame = state: NativeChatSubagentState } | { - /** A child turn that ended with no `turn/completed`. No `turnId`: the one it is running. */ + /** A child turn that ended with no `turn/completed`. No `turnId`: the one it is running. + * `threadId` is the child's, which for a `closeAgent` is not the thread that sent it. */ kind: 'turn-ended' threadId: string turnId: string | null state: CodexChildTurnEnding } -type CodexChildTurnEnding = Extract +type CodexChildTurnEnding = Extract export type CodexBackgroundTaskEvent = { method: string @@ -90,23 +91,25 @@ export function readCodexBackgroundTaskFrame( return null } const item = readCodexThreadItem(readRecord(event.params).item) - const activity = item && readCodexSubagentActivity(item) - if ( - !activity || - activity.agentThreadId === primaryThreadId || - isCodexRootAgentActivity(activity) - ) { + if (!item) { + return null + } + const call = readCodexCollabAgentToolCall(item) + const closed = call && codexCollabClosedThread(call) + if (closed && closed !== primaryThreadId) { + // The caller shut the helper down: whatever turn it was running is over, stopped by its caller. + return { kind: 'turn-ended', threadId: closed, turnId: null, state: 'stopped' } + } + const announcement = readCodexSubagentAnnouncement(item) + if (!announcement || announcement.agentThreadId === primaryThreadId) { return null } return { kind: 'subagent', - agentThreadId: activity.agentThreadId, - label: codexSubagentLabel(activity), - parentTurnId: - activity.kind === 'started' || activity.kind === 'interacted' - ? readCodexTurnId(event.params) - : undefined, - // Only `started` names the spawner: other kinds ride whichever agent acted. - spawnerThreadId: activity.kind === 'started' ? event.threadId : undefined + agentThreadId: announcement.agentThreadId, + label: announcement.label, + parentTurnId: announcement.namesParentTurn ? readCodexTurnId(event.params) : undefined, + // Only a spawn names the spawner: other announcements ride whichever agent acted. + spawnerThreadId: announcement.spawned ? event.threadId : undefined } } diff --git a/src/main/codex/codex-child-work-evidence.ts b/src/main/codex/codex-child-work-evidence.ts index 93bda7a54a1f..e7d26de45610 100644 --- a/src/main/codex/codex-child-work-evidence.ts +++ b/src/main/codex/codex-child-work-evidence.ts @@ -129,12 +129,15 @@ export class CodexChildWorkEvidence { event: CodexBackgroundTaskEvent, frame: CodexBackgroundTaskFrame | null ): string | null { + // A frame names its child, which a caller's spawn or close is not sent on. const threadId = frame?.kind === 'subagent' ? frame.agentThreadId - : frame || CHILD_FRAME_METHODS.has(event.method) - ? event.threadId - : null + : frame + ? frame.threadId + : CHILD_FRAME_METHODS.has(event.method) + ? event.threadId + : null return threadId === this.primaryThreadId ? null : threadId } diff --git a/src/main/codex/codex-collab-agent-item-translation.ts b/src/main/codex/codex-collab-agent-item-translation.ts new file mode 100644 index 000000000000..1d63333d36c4 --- /dev/null +++ b/src/main/codex/codex-collab-agent-item-translation.ts @@ -0,0 +1,81 @@ +// A Codex `collabAgentToolCall` item → a tool row that names the helper it acted on. +// +// The row is the call the agent made (`spawn_agent`, `wait_agent`, `close_agent`, …); the helper +// itself is the subagent roster's row. A helper is named the way the roster names it, so the two +// rows read as the same child. + +import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' +import { + boundInlineText, + boundToolInput, + DEFAULT_JOURNAL_PAYLOAD_LIMITS +} from '../native-chat/agent-session-journal/journal-payload-bounds' +import { + codexCollabHelperLabel, + codexCollabToolName, + readCodexCollabAgentToolCall, + type CodexCollabAgentToolCall +} from './codex-collab-agent-tool-call' +import { readString } from './codex-item-field-readers' +import { codexItemRunState } from './codex-item-run-state' +import type { CodexThreadItem } from './codex-thread-item-identity' + +/** The roster's name for a helper thread, or null for one it never registered. */ +export type CodexHelperName = (threadId: string) => string | null + +/** Who the call acted on. A spawn names its helper-to-be by prompt until Codex says which thread + * it became; a helper the roster never registered (a restored thread) is named by its thread id. */ +function helperNames(call: CodexCollabAgentToolCall, helperName?: CodexHelperName): string { + const named = call.receiverThreadIds.map((threadId) => helperName?.(threadId) ?? threadId) + if (call.tool === 'spawnAgent') { + return named[0] ?? codexCollabHelperLabel(call.prompt) ?? '' + } + return named.join(', ') +} + +/** What the helpers said back. One reply reads as itself; several are each put under their name. */ +function replyText(call: CodexCollabAgentToolCall, helperName?: CodexHelperName): string | null { + if (call.replies.length === 0) { + return null + } + if (call.replies.length === 1 && call.receiverThreadIds.length === 1) { + return call.replies[0].message + } + return call.replies + .map(({ threadId, message }) => `${helperName?.(threadId) ?? threadId}: ${message}`) + .join('\n') +} + +export function codexCollabAgentToolCallBody( + item: CodexThreadItem, + helperName?: CodexHelperName +): AgentJournalItemBody | null { + const call = readCodexCollabAgentToolCall(item) + if (!call) { + return null + } + const description = helperNames(call, helperName) + const model = readString(item, 'model') + const reasoningEffort = readString(item, 'reasoningEffort') + const fields = { + // `description` is the key the row label reads, so the helper's name leads the row. + ...(description ? { description } : {}), + ...(call.prompt ? { prompt: call.prompt } : {}), + ...(model ? { model } : {}), + ...(reasoningEffort ? { reasoningEffort } : {}), + ...(call.receiverThreadIds.length > 0 ? { agents: call.receiverThreadIds } : {}) + } + const reply = replyText(call, helperName) + const output = reply === null ? null : boundInlineText(reply, DEFAULT_JOURNAL_PAYLOAD_LIMITS) + return { + kind: 'tool-call', + name: codexCollabToolName(call), + callId: call.id, + input: boundToolInput( + Object.keys(fields).length > 0 ? fields : null, + DEFAULT_JOURNAL_PAYLOAD_LIMITS + ), + state: codexItemRunState(item), + ...(output === null ? {} : { output: output.bounded }) + } +} diff --git a/src/main/codex/codex-collab-agent-tool-call.ts b/src/main/codex/codex-collab-agent-tool-call.ts new file mode 100644 index 000000000000..aa6376260b10 --- /dev/null +++ b/src/main/codex/codex-collab-agent-tool-call.ts @@ -0,0 +1,102 @@ +// Reading Codex's `collabAgentToolCall` items: the calls an agent makes to spawn, message, wait on +// and close its helpers. +// +// Codex's default multi-agent mode reports a helper ONLY this way; it emits no `subAgentActivity`. +// Shapes are from the app-server's generated schema (0.155) and a live default-mode session: +// * `spawnAgent` starts with `receiverThreadIds: []`. The helper's thread id first appears on the +// call's completion, beside the `prompt` it was given. +// * The item names no nickname or task path, so the prompt is the only text that tells one +// helper from another. +// * `agentsStates` is the caller's last-known snapshot of each receiver. The helper's own turn +// frames own its execution, so nothing here reads it as state; its `message` is what the +// helper said back, which the call's row shows as output. + +import { collapsedToolInputPrefix } from '../../shared/native-chat-tool-preview-prefix' +import { readRecord, readString } from './codex-item-field-readers' +import type { CodexThreadItem } from './codex-thread-item-identity' + +export const CODEX_COLLAB_AGENT_TOOL_CALL_ITEM_TYPE = 'collabAgentToolCall' + +/** Row length for a helper named by its prompt: long enough to tell two helpers apart. */ +const MAX_HELPER_LABEL_CHARS = 80 + +export type CodexCollabAgentToolCall = { + id: string + /** The schema's `CollabAgentTool`: `spawnAgent`, `sendInput`, `wait`, `closeAgent`, … */ + tool: string + /** `inProgress`, `completed`, `failed` or `interrupted`. */ + status: string | null + receiverThreadIds: string[] + prompt: string | null + /** What each receiver said back, in `receiverThreadIds` order. */ + replies: { threadId: string; message: string }[] +} + +export function readCodexCollabAgentToolCall( + item: CodexThreadItem +): CodexCollabAgentToolCall | null { + const tool = readString(item, 'tool') + if (item.type !== CODEX_COLLAB_AGENT_TOOL_CALL_ITEM_TYPE || tool === null) { + return null + } + const receiverThreadIds = Array.isArray(item.receiverThreadIds) + ? item.receiverThreadIds.filter((id): id is string => typeof id === 'string' && id.length > 0) + : [] + const states = readRecord(item.agentsStates) + const replies = receiverThreadIds.flatMap((threadId) => { + const message = readString(readRecord(states[threadId]), 'message') + return message === null ? [] : [{ threadId, message }] + }) + return { + id: item.id, + tool, + status: readString(item, 'status'), + receiverThreadIds, + prompt: readString(item, 'prompt'), + replies + } +} + +/** The helper a finished `spawnAgent` created. A spawn Codex refused names none. */ +export function codexCollabSpawnedThread(call: CodexCollabAgentToolCall): string | null { + return call.tool === 'spawnAgent' && call.status === 'completed' + ? (call.receiverThreadIds[0] ?? null) + : null +} + +/** The helper a finished `closeAgent` shut down. */ +export function codexCollabClosedThread(call: CodexCollabAgentToolCall): string | null { + return call.tool === 'closeAgent' && call.status === 'completed' + ? (call.receiverThreadIds[0] ?? null) + : null +} + +/** A helper's row label: the head of the prompt it was spawned with, on one line. */ +export function codexCollabHelperLabel(prompt: string | null): string | null { + const collapsed = prompt === null ? '' : collapsedToolInputPrefix(prompt) + if (collapsed.length <= MAX_HELPER_LABEL_CHARS) { + return collapsed.length > 0 ? collapsed : null + } + const keep = MAX_HELPER_LABEL_CHARS - 1 + // Never end on half a surrogate pair: the label lands in a durable row. + const last = collapsed.charCodeAt(keep - 1) + const end = last >= 0xd800 && last <= 0xdbff ? keep - 1 : keep + return `${collapsed.slice(0, end)}…` +} + +/** The name the model called the tool by, which is what its row shows. */ +const CODEX_COLLAB_TOOL_NAMES = new Map([ + ['spawnAgent', 'spawn_agent'], + ['sendInput', 'send_input'], + ['resumeAgent', 'resume_agent'], + ['wait', 'wait_agent'], + ['closeAgent', 'close_agent'], + ['sendMessage', 'send_message'], + ['followupTask', 'followup_task'], + ['interruptAgent', 'interrupt_agent'], + ['listAgents', 'list_agents'] +]) + +export function codexCollabToolName(call: CodexCollabAgentToolCall): string { + return CODEX_COLLAB_TOOL_NAMES.get(call.tool) ?? call.tool +} diff --git a/src/main/codex/codex-default-mode-helpers.test.ts b/src/main/codex/codex-default-mode-helpers.test.ts new file mode 100644 index 000000000000..f08832745de0 --- /dev/null +++ b/src/main/codex/codex-default-mode-helpers.test.ts @@ -0,0 +1,363 @@ +// Codex's default multi-agent mode announces a helper only by the `collabAgentToolCall` that +// spawned it. These frames, through the real adapter, must register that helper the same way a +// `subAgentActivity` does: one child in the strip, the host's records and the roster row. + +import { describe, expect, it } from 'vitest' +import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' +import { createAgentChildWorkAdmission } from '../../shared/agent-status-child-work-admission' +import type { AgentChildWorkRecord } from '../../shared/agent-status-child-work' +import { reconcileAgentChildWorkEvidence } from '../../shared/agent-status-child-work-reconciliation' +import { createAgentStatusStore } from '../../shared/agent-status-store' +import { makeStructuredAgentStatusSubject } from '../../shared/agent-status-subject' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { fakeCodex, identityFor, THREAD_ID } from './codex-structured-session-adapter-fixture' +import { CodexStructuredSessionAdapter } from './codex-structured-session-adapter' + +const parent = makeStructuredAgentStatusSubject( + { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'folder' + }, + 'session-1' +) + +// The spawn and wait items below are verbatim from a live default-mode session (codex-cli +// 0.155.0-alpha.9.2, `multi_agent` on, `multi_agent_v2` off); only the sender is the fixture's +// thread. That session sent no `subAgentActivity` at all. +const HELPER = '01a0d114-2b8e-73e2-a69b-8e86df067ae9' +const PARENT_TURN = '01a0d112-6f86-7092-8c87-b8618f259efa' +const HELPER_TURN = 'helper-turn-1' +const PROMPT = + 'Run the shell command exactly: `sleep 45; echo CHILD_DONE`. After it completes, reply with the single word `CHILD_REPLY` and nothing else.' +/** The prompt's head, as the helper's row names it. */ +const LABEL = 'Run the shell command exactly: `sleep 45; echo CHILD_DONE`. After it completes,…' +const COMMAND = "/bin/zsh -lc 'sleep 45; echo CHILD_DONE'" + +type Frame = { method: string; params: Record } + +const turn = ( + method: 'turn/started' | 'turn/completed', + threadId: string, + id: string, + status = 'completed' +): Frame => ({ method, params: { threadId, turn: { id, status } } }) +const item = ( + method: 'item/started' | 'item/completed', + threadId: string, + turnId: string, + fields: Record +): Frame => ({ method, params: { threadId, turnId, item: fields } }) +const collab = ( + method: 'item/started' | 'item/completed', + fields: Record +): Frame => + item(method, THREAD_ID, PARENT_TURN, { + type: 'collabAgentToolCall', + senderThreadId: THREAD_ID, + prompt: null, + model: null, + reasoningEffort: null, + agentsStates: {}, + ...fields + }) + +const spawnStarted = collab('item/started', { + id: 'call_dxoSnQY1cHVswlN8MGFJBxHt', + tool: 'spawnAgent', + status: 'inProgress', + receiverThreadIds: [], + prompt: PROMPT, + model: '', + reasoningEffort: 'medium' +}) +const spawnCompleted = collab('item/completed', { + id: 'call_dxoSnQY1cHVswlN8MGFJBxHt', + tool: 'spawnAgent', + status: 'completed', + receiverThreadIds: [HELPER], + prompt: PROMPT, + model: 'gpt-5.5', + reasoningEffort: 'medium', + agentsStates: { [HELPER]: { status: 'pendingInit', message: null } } +}) +const waitStarted = collab('item/started', { + id: 'call_rMn9MIAPavyhOD6ifjH38A8E', + tool: 'wait', + status: 'inProgress', + receiverThreadIds: [HELPER] +}) +const waitCompleted = collab('item/completed', { + id: 'call_rMn9MIAPavyhOD6ifjH38A8E', + tool: 'wait', + status: 'completed', + receiverThreadIds: [HELPER], + agentsStates: { [HELPER]: { status: 'completed', message: 'CHILD_REPLY' } } +}) +const closeCompleted = collab('item/completed', { + id: 'call-close', + tool: 'closeAgent', + status: 'completed', + receiverThreadIds: [HELPER], + agentsStates: { [HELPER]: { status: 'shutdown', message: null } } +}) +const helperShell = (method: 'item/started' | 'item/completed'): Frame => + item(method, HELPER, HELPER_TURN, { + type: 'commandExecution', + id: 'call_3iUPkSEtwkUYstngZm5CNJ52', + command: COMMAND, + cwd: '/work/repo', + // Persistent exec: the only command source the strip lists. + source: 'unifiedExecStartup', + status: method === 'item/started' ? 'inProgress' : 'completed', + ...(method === 'item/completed' ? { exitCode: 0, aggregatedOutput: 'CHILD_DONE\n' } : {}) + }) +const helperReply = item('item/completed', HELPER, HELPER_TURN, { + type: 'agentMessage', + id: 'msg-child', + text: 'CHILD_REPLY' +}) +const activityStarted: Frame = item('item/started', THREAD_ID, PARENT_TURN, { + type: 'subAgentActivity', + id: 'activity-helper', + kind: 'started', + agentThreadId: HELPER, + agentPath: '/root/sleeper' +}) + +async function session() { + const codex = fakeCodex() + const store = createAgentStatusStore({ epoch: 'epoch-1', mode: 'authority' }) + expect(store.applyMutation({ parent: { subject: parent } })).not.toBeNull() + let minted = 0 + const admission = createAgentChildWorkAdmission(store, { + mintChildWorkId: () => `child-${++minted}` + }) + const adapter = new CodexStructuredSessionAdapter({ + resolveLaunch: async () => ({ + command: 'codex', + args: ['app-server'], + cwd: '/work/repo', + codexHome: null, + resumeThreadId: null + }), + openConnection: codex.openConnection, + readProcessStartTime: async () => 1_700_000_000_000, + now: () => 1_700_000_000_500, + onChildWorkEvidence: (_sessionId, evidence) => + reconcileAgentChildWorkEvidence({ store, admission, parent, provider: 'codex', evidence }) + }) + // The latest revision of each journal row, in first-written order. + const rows = new Map() + const journal: StructuredAgentSessionEventSink = { + appendItem: (identity, body) => rows.set(JSON.stringify(identity), body), + appendTombstone: () => {}, + publish: () => {} + } + await adapter.acquire({ + identity: identityFor('session-1'), + fence: 7, + spawnToken: 'spawn-9', + events: journal + }) + const send = (...frames: Frame[]): void => { + for (const frame of frames) { + codex.connections[0]!.handlers.onNotification?.(frame.method, frame.params) + } + } + const agents = (): AgentChildWorkRecord[] => + store.getChildren(parent).filter((record) => record.kind === 'agent') + const commands = (): AgentChildWorkRecord[] => + store.getChildren(parent).filter((record) => record.kind === 'command') + const strip = () => adapter.backgroundTaskState('session-1')?.tasks ?? [] + const toolRow = (name: string) => + [...rows.values()].find((body) => body.kind === 'tool-call' && body.name === name) + const rosterRows = () => + [...rows.values()].flatMap((body) => + body.kind === 'message' + ? body.blocks.flatMap((block) => (block.type === 'subagent-group' ? [block] : [])) + : [] + ) + const rawCollabRows = () => + [...rows.values()].filter( + (body) => body.kind === 'status' && body.providerFrame?.kind === 'item:collabAgentToolCall' + ) + return { send, agents, commands, strip, toolRow, rosterRows, rawCollabRows } +} + +describe('Codex default-mode helpers', () => { + it('registers a helper announced only by its spawn call, in the strip, the records and the roster row', async () => { + const run = await session() + run.send(turn('turn/started', THREAD_ID, PARENT_TURN), spawnStarted) + // The call has not said which thread the helper is yet. + expect(run.agents()).toEqual([]) + expect(run.strip()).toEqual([]) + + run.send(spawnCompleted, turn('turn/started', HELPER, HELPER_TURN), waitStarted) + expect(run.agents()).toEqual([ + expect.objectContaining({ + membership: 'live', + state: 'working', + description: LABEL, + invocation: { invocationId: HELPER_TURN, generation: 1 } + }) + ]) + expect(run.strip()).toEqual([ + { id: `codex-agent:${HELPER}`, kind: 'agent', description: LABEL } + ]) + expect(run.rosterRows()).toEqual([ + expect.objectContaining({ + agents: [expect.objectContaining({ id: HELPER, label: LABEL, state: 'working' })] + }) + ]) + + // While the helper runs, its command is the helper's work, not the session's own. + run.send(helperShell('item/started')) + expect(run.strip()).toEqual([ + { id: `codex-agent:${HELPER}`, kind: 'agent', description: LABEL } + ]) + expect(run.commands()).toEqual([ + expect.objectContaining({ + description: COMMAND, + parentChildWorkId: run.agents()[0]?.childWorkId + }) + ]) + + run.send( + helperShell('item/completed'), + helperReply, + turn('turn/completed', HELPER, HELPER_TURN) + ) + expect(run.agents()).toEqual([ + expect.objectContaining({ + membership: 'settled', + outcome: 'succeeded', + lastMessage: 'CHILD_REPLY' + }) + ]) + expect(run.strip()).toEqual([]) + expect(run.rosterRows().at(-1)?.agents).toEqual([ + expect.objectContaining({ id: HELPER, state: 'completed' }) + ]) + }) + + it('labels a command the helper leaves running with the helper, once its turn is over', async () => { + const run = await session() + run.send( + turn('turn/started', THREAD_ID, PARENT_TURN), + spawnCompleted, + turn('turn/started', HELPER, HELPER_TURN), + helperShell('item/started'), + turn('turn/completed', HELPER, HELPER_TURN) + ) + expect(run.strip()).toEqual([ + expect.objectContaining({ kind: 'command', description: `${LABEL} — ${COMMAND}` }) + ]) + }) + + it('renders each collab call as a tool row naming its helper, never as a raw provider row', async () => { + const run = await session() + run.send( + turn('turn/started', THREAD_ID, PARENT_TURN), + spawnStarted, + spawnCompleted, + turn('turn/started', HELPER, HELPER_TURN), + waitStarted + ) + expect(run.toolRow('wait_agent')).toMatchObject({ + state: 'running', + input: { description: LABEL, agents: [HELPER] } + }) + run.send(turn('turn/completed', HELPER, HELPER_TURN), waitCompleted) + expect(run.toolRow('spawn_agent')).toMatchObject({ + state: 'completed', + input: { description: LABEL, prompt: PROMPT, model: 'gpt-5.5', agents: [HELPER] } + }) + expect(run.toolRow('wait_agent')).toMatchObject({ + state: 'completed', + input: { description: LABEL }, + output: expect.objectContaining({ head: 'CHILD_REPLY' }) + }) + expect(run.rawCollabRows()).toEqual([]) + }) + + it('keeps naming the helper when the turn ends with its wait still open', async () => { + const run = await session() + run.send( + turn('turn/started', THREAD_ID, PARENT_TURN), + spawnCompleted, + turn('turn/started', HELPER, HELPER_TURN), + waitStarted, + turn('turn/completed', THREAD_ID, PARENT_TURN, 'interrupted') + ) + expect(run.toolRow('wait_agent')).toMatchObject({ + state: 'failed', + input: { description: LABEL } + }) + }) + + it('registers the helper whichever order its first turn and its spawn arrive in', async () => { + const run = await session() + run.send( + turn('turn/started', THREAD_ID, PARENT_TURN), + turn('turn/started', HELPER, HELPER_TURN), + spawnCompleted + ) + expect(run.agents()).toEqual([ + expect.objectContaining({ membership: 'live', state: 'working', description: LABEL }) + ]) + expect(run.strip()).toHaveLength(1) + }) + + it('ends a running helper its caller closed as cancelled, in the strip and the record together', async () => { + const run = await session() + run.send( + turn('turn/started', THREAD_ID, PARENT_TURN), + spawnCompleted, + turn('turn/started', HELPER, HELPER_TURN), + closeCompleted + ) + expect(run.agents()).toEqual([ + expect.objectContaining({ membership: 'settled', outcome: 'cancelled' }) + ]) + expect(run.strip()).toEqual([]) + expect(run.toolRow('close_agent')).toMatchObject({ + state: 'completed', + input: { description: LABEL } + }) + }) + + it('leaves a finished helper finished when its caller closes it', async () => { + const run = await session() + run.send( + turn('turn/started', THREAD_ID, PARENT_TURN), + spawnCompleted, + turn('turn/started', HELPER, HELPER_TURN), + turn('turn/completed', HELPER, HELPER_TURN), + closeCompleted + ) + expect(run.agents()).toEqual([ + expect.objectContaining({ membership: 'settled', outcome: 'succeeded' }) + ]) + }) + + it('keeps one child for a session that announces the helper both ways', async () => { + const run = await session() + run.send( + turn('turn/started', THREAD_ID, PARENT_TURN), + activityStarted, + spawnCompleted, + turn('turn/started', HELPER, HELPER_TURN), + { ...activityStarted, method: 'item/completed' } + ) + // The activity landed first, so its task name labels the one child. + expect(run.agents()).toEqual([expect.objectContaining({ description: 'sleeper' })]) + expect(run.strip()).toEqual([ + { id: `codex-agent:${HELPER}`, kind: 'agent', description: 'sleeper' } + ]) + expect(run.rosterRows().at(-1)?.agents).toEqual([ + expect.objectContaining({ id: HELPER, label: 'sleeper' }) + ]) + }) +}) diff --git a/src/main/codex/codex-item-run-state.ts b/src/main/codex/codex-item-run-state.ts new file mode 100644 index 000000000000..a3b22eff8aed --- /dev/null +++ b/src/main/codex/codex-item-run-state.ts @@ -0,0 +1,16 @@ +import { readString } from './codex-item-field-readers' +import type { CodexThreadItem } from './codex-thread-item-identity' + +/** Codex reports `inProgress` then a terminal status; a zero exit code is the + * only thing that makes a finished command a success. */ +export function codexItemRunState(item: CodexThreadItem): 'running' | 'completed' | 'failed' { + const status = readString(item, 'status') + if (status === null || status === 'inProgress') { + return 'running' + } + if (status !== 'completed') { + return 'failed' + } + const exitCode = item.exitCode + return typeof exitCode === 'number' && exitCode !== 0 ? 'failed' : 'completed' +} diff --git a/src/main/codex/codex-structured-item-translation.ts b/src/main/codex/codex-structured-item-translation.ts index 34f07eefda7e..361487d9fde5 100644 --- a/src/main/codex/codex-structured-item-translation.ts +++ b/src/main/codex/codex-structured-item-translation.ts @@ -9,6 +9,11 @@ import { import { unhandledProviderFrameJournalItem } from '../native-chat/agent-session-wire/unhandled-provider-frame' import { codexImageItemBody } from './codex-image-item-translation' import { commandActionFacts } from './codex-command-action-class' +import { + codexCollabAgentToolCallBody, + type CodexHelperName +} from './codex-collab-agent-item-translation' +import { codexItemRunState } from './codex-item-run-state' import { readFirstString, readRecord, @@ -66,20 +71,6 @@ export function codexMessageBlocks(item: CodexThreadItem): NativeChatBlock[] { return blocks } -/** Codex reports `inProgress` then a terminal status; a zero exit code is the - * only thing that makes a finished command a success. */ -function commandState(item: CodexThreadItem): 'running' | 'completed' | 'failed' { - const status = readString(item, 'status') - if (status === null || status === 'inProgress') { - return 'running' - } - if (status !== 'completed') { - return 'failed' - } - const exitCode = item.exitCode - return typeof exitCode === 'number' && exitCode !== 0 ? 'failed' : 'completed' -} - export type CodexJournalItem = { body: AgentJournalItemBody | null handled: boolean @@ -103,7 +94,7 @@ function commandItem(item: CodexThreadItem): CodexJournalItem { { command: item.command ?? null, cwd: item.cwd ?? null, ...parsed?.fields }, DEFAULT_JOURNAL_PAYLOAD_LIMITS ), - state: commandState(item), + state: codexItemRunState(item), ...toolExecutionMetadata(item), ...(bounded === null ? {} : { output: bounded.bounded }) }, @@ -127,7 +118,7 @@ function fileChangeItem(item: CodexThreadItem): CodexJournalItem { name: 'apply_patch', callId: item.id, input: boundToolInput({ changes: item.changes ?? null }, DEFAULT_JOURNAL_PAYLOAD_LIMITS), - state: commandState(item) + state: codexItemRunState(item) }, handled: true } @@ -180,7 +171,7 @@ function mcpToolCallItem(item: CodexThreadItem): CodexJournalItem { callId: item.id, ...(server && tool ? { mcpIdentity: { server, tool } } : {}), input: boundToolInput(mcpToolArguments(item.arguments), DEFAULT_JOURNAL_PAYLOAD_LIMITS), - state: failure === null ? commandState(item) : 'failed', + state: failure === null ? codexItemRunState(item) : 'failed', ...(bounded === null ? {} : { output: bounded.bounded }) }, handled: true @@ -236,7 +227,10 @@ function webSearchItem(item: CodexThreadItem): CodexJournalItem { * Known empty items wait for later deltas. Unknown types become bounded status * rows so a provider release cannot make new activity invisible. */ -export function codexJournalItem(item: CodexThreadItem): CodexJournalItem { +export function codexJournalItem( + item: CodexThreadItem, + helperName?: CodexHelperName +): CodexJournalItem { if (item.type === 'userMessage' || item.type === 'agentMessage') { const blocks = codexMessageBlocks(item) return { @@ -262,6 +256,10 @@ export function codexJournalItem(item: CodexThreadItem): CodexJournalItem { if (item.type === 'imageView' || item.type === 'imageGeneration') { return { body: codexImageItemBody(item), handled: true } } + const collab = codexCollabAgentToolCallBody(item, helperName) + if (collab) { + return { body: collab, handled: true } + } if (item.type === 'plan') { const text = readTextContent(item, 'text') return { diff --git a/src/main/codex/codex-structured-journal-items.ts b/src/main/codex/codex-structured-journal-items.ts index 6717f3edabe3..1e8fcf113322 100644 --- a/src/main/codex/codex-structured-journal-items.ts +++ b/src/main/codex/codex-structured-journal-items.ts @@ -12,6 +12,7 @@ import { type CodexThreadItem } from './codex-structured-item-translation' import { createCodexStructuredItemStreams } from './codex-structured-item-streams' +import type { CodexHelperName } from './codex-collab-agent-item-translation' import { boundStreamItem, codexStructuredItemKey } from './codex-structured-item-stream-bounds' import { codexCommandOutlivesTurn } from './codex-command-lifecycle' import type { @@ -46,7 +47,8 @@ export class CodexJournalItems { 'sink' | 'coalesceMs' | 'maxRetainedBytes' | 'schedule' > & { maxMetadataBytes?: number; linkageFor: CodexRowLinkage }, private readonly activeTurn: (threadId: string) => string | null, - private readonly suppress: (threadId: string, turnId: string) => void + private readonly suppress: (threadId: string, turnId: string) => void, + private readonly helperName?: CodexHelperName ) { this.streams = createCodexStructuredItemStreams({ sink: deps.sink, @@ -96,7 +98,7 @@ export class CodexJournalItems { ) { return { handled: true, admission: { accepted: false, reason: 'failed' } } } - const translated = codexJournalItem(item) + const translated = codexJournalItem(item, this.helperName) const command = readCodexJournalString(item, 'command') if (command) { const boundedCommand = Buffer.from(command, 'utf8') @@ -188,7 +190,8 @@ export class CodexJournalItems { threadId, turnId, identity, - item: retainedItem + item: retainedItem, + ...(this.helperName ? { helperName: this.helperName } : {}) }) } @@ -229,7 +232,7 @@ export class CodexJournalItems { } const evicted = this.activeItems.get(oldest) if (evicted) { - const translated = codexJournalItem(evicted.item).body + const translated = codexJournalItem(evicted.item, this.helperName).body if (translated) { const admission = appendCodexLifecycleItem( this.deps.sink, diff --git a/src/main/codex/codex-structured-journal-settlement.ts b/src/main/codex/codex-structured-journal-settlement.ts index 39d52e6b0475..7e30f744364f 100644 --- a/src/main/codex/codex-structured-journal-settlement.ts +++ b/src/main/codex/codex-structured-journal-settlement.ts @@ -19,6 +19,7 @@ import { type CodexTurnOrdinals } from './codex-structured-item-translation' import type { CodexStructuredItemStreams } from './codex-structured-item-streams' +import type { CodexHelperName } from './codex-collab-agent-item-translation' import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' import { codexCommandOutlivesTurn } from './codex-command-lifecycle' import { @@ -33,6 +34,8 @@ export type CodexActiveJournalItem = { turnId: string | null identity: AgentJournalItemIdentity item: CodexThreadItem + /** Names the helpers a collab call acted on, so a settled revision keeps naming them. */ + helperName?: CodexHelperName } export type CodexPendingJournalPrompt = { @@ -64,7 +67,7 @@ export function settleCodexJournalSession(input: { const streamed = input.streams.snapshot(active.threadId, active.item.id) const translated = streamed ? codexStreamingJournalItem(active.item, streamed.text) - : codexJournalItem(active.item) + : codexJournalItem(active.item, active.helperName) const body = interruptedBody(translated.body) if (body) { mutations.push(settledRow(input.linkageFor, active, body)) @@ -130,7 +133,7 @@ export function settleCodexJournalTurn(input: { const streamed = input.streams.snapshot(active.threadId, active.item.id) const translated = streamed ? codexStreamingJournalItem(active.item, streamed.text) - : codexJournalItem(active.item) + : codexJournalItem(active.item, active.helperName) const body = interruptedBody(translated.body) if (body) { mutations.push(settledRow(input.linkageFor, active, body)) diff --git a/src/main/codex/codex-structured-journal-translation-writers.ts b/src/main/codex/codex-structured-journal-translation-writers.ts index 72c1026452d8..605702bbee9d 100644 --- a/src/main/codex/codex-structured-journal-translation-writers.ts +++ b/src/main/codex/codex-structured-journal-translation-writers.ts @@ -23,8 +23,12 @@ export function createCodexJournalTranslatorWriters(deps: CodexJournalTranslator const { linkageFor } = subagents.linkage const producerDeps = { ...deps, linkageFor } const genericFrames = new CodexJournalGenericFrames(producerDeps, activeTurn) - const items = new CodexJournalItems(producerDeps, activeTurn, (threadId, turnId) => - genericFrames.suppress(threadId, turnId) + const items = new CodexJournalItems( + producerDeps, + activeTurn, + (threadId, turnId) => genericFrames.suppress(threadId, turnId), + // A collab call's row names its helpers the way the roster does. + (threadId) => subagents.helperLabel(threadId) ) return { activeTurns, diff --git a/src/main/codex/codex-subagent-activity.ts b/src/main/codex/codex-subagent-activity.ts index 9761f8634f8c..a3f3d1b6fc22 100644 --- a/src/main/codex/codex-subagent-activity.ts +++ b/src/main/codex/codex-subagent-activity.ts @@ -6,12 +6,19 @@ // * `agentPath` is a tree path (`/root`, `/root/list_directory`); the trailing // segment is a semantic task name and the only label available. There is no // `thread/started` for a child, so nickname/role/depth do not exist. -// * `agentsStates` on `collabAgentToolCall` arrived empty (`{}`) throughout the -// probe, so nothing here reads it; child turn events own execution state. +// * Codex's DEFAULT multi-agent mode sends no `subAgentActivity` at all; a +// helper appears only as the `collabAgentToolCall` that spawned it (read in +// `codex-collab-agent-tool-call.ts`). Either item announces the same child, +// keyed by its thread id, and child turn events own its execution state. // * `thread/tokenUsage/updated` reports a per-thread RUNNING TOTAL, so the // latest frame replaces the previous one — it is never accumulated. -import type { CodexThreadItem } from './codex-structured-item-translation' +import { + codexCollabHelperLabel, + codexCollabSpawnedThread, + readCodexCollabAgentToolCall +} from './codex-collab-agent-tool-call' +import type { CodexThreadItem } from './codex-thread-item-identity' export const CODEX_SUBAGENT_ITEM_TYPE = 'subAgentActivity' export const CODEX_TOKEN_USAGE_METHOD = 'thread/tokenUsage/updated' @@ -85,6 +92,44 @@ export function codexSubagentLabel(activity: CodexSubagentActivity): string | nu return trailing !== undefined && trailing.length > 0 ? trailing : null } +/** A child the item says exists, from either wire shape Codex announces one with. */ +export type CodexSubagentAnnouncement = { + agentThreadId: string + label: string | null + /** The item's turn is the one the child was spawned or messaged from. */ + namesParentTurn: boolean + /** The item is the spawn itself, so the thread that carried it spawned the child. */ + spawned: boolean +} + +/** The child a `subAgentActivity` item (the tree root excluded) or a finished `spawnAgent` call + * announces. Both name the child by its thread id, so a session sending both announces one. */ +export function readCodexSubagentAnnouncement( + item: CodexThreadItem +): CodexSubagentAnnouncement | null { + const activity = readCodexSubagentActivity(item) + if (activity) { + return isCodexRootAgentActivity(activity) + ? null + : { + agentThreadId: activity.agentThreadId, + label: codexSubagentLabel(activity), + namesParentTurn: activity.kind === 'started' || activity.kind === 'interacted', + spawned: activity.kind === 'started' + } + } + const call = readCodexCollabAgentToolCall(item) + const spawned = call && codexCollabSpawnedThread(call) + return call && spawned + ? { + agentThreadId: spawned, + label: codexCollabHelperLabel(call.prompt), + namesParentTurn: true, + spawned: true + } + : null +} + export type CodexThreadTokenTotal = { threadId: string; totalTokens: number } /** `{threadId, tokenUsage: {total: {totalTokens}}}`. Older builds put the total diff --git a/src/main/codex/codex-subagent-roster.ts b/src/main/codex/codex-subagent-roster.ts index 99b37e9c7723..e84c06c12def 100644 --- a/src/main/codex/codex-subagent-roster.ts +++ b/src/main/codex/codex-subagent-roster.ts @@ -1,6 +1,8 @@ // The Codex subagent roster: one journal row per spawn group, revised in place. // -// Activity supplies membership; child turn events supply execution state. +// A spawn announcement supplies membership — a `subAgentActivity` item, or in +// Codex's default multi-agent mode the finished `spawnAgent` call — and child +// turn events supply execution state. // // KNOWN LIMITATION: `groups` is process-local and is never seeded from the // journal, while the row's identity is keyed on the group id alone. So once a @@ -24,9 +26,8 @@ import type { StructuredAgentSessionSinkAdmission } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { - codexSubagentLabel, - isCodexRootAgentActivity, readCodexSubagentActivity, + readCodexSubagentAnnouncement, readCodexThreadTokenTotal } from './codex-subagent-activity' import { @@ -106,32 +107,34 @@ export class CodexSubagentRoster { }) } - /** Consume a `subAgentActivity` item. Returns null when the item is not one. */ + /** The label a registered helper's row carries. */ + helperLabel(agentThreadId: string): string | null { + return this.executions.label(agentThreadId) + } + + /** Consume an item that announces a child. Null means the item is not this roster's to render: + * a `subAgentActivity` item renders as the roster row alone, while a spawn call keeps its own + * row, so it is claimed only to hand back a refused write. */ handleItem(input: { threadId: string turnId: string | null item: CodexThreadItem }): StructuredAgentSessionSinkAdmission | null { - const activity = readCodexSubagentActivity(input.item) - if (!activity) { - return null - } + const claimed = readCodexSubagentActivity(input.item) ? ADMITTED : null + const announcement = readCodexSubagentAnnouncement(input.item) // The root node is the parent turn itself, not a child it spawned. - if ( - activity.agentThreadId === this.deps.primaryThreadId() || - isCodexRootAgentActivity(activity) - ) { - return ADMITTED + if (!announcement || announcement.agentThreadId === this.deps.primaryThreadId()) { + return claimed } const child = this.executions.register( - activity.agentThreadId, - codexSubagentLabel(activity), - activity.kind === 'started' || activity.kind === 'interacted' ? input.turnId : undefined, - // Only `started` names the spawner: other kinds ride whichever agent acted. - activity.kind === 'started' ? input.threadId : undefined + announcement.agentThreadId, + announcement.label, + announcement.namesParentTurn ? input.turnId : undefined, + // Only a spawn names the spawner: other announcements ride whichever agent acted. + announcement.spawned ? input.threadId : undefined ) if (!child?.execution) { - return ADMITTED + return claimed } const group = this.executionGroup(child.agentThreadId, child.execution.turnId) ?? @@ -139,7 +142,8 @@ export class CodexSubagentRoster { if (!group.entries.has(child.agentThreadId)) { this.recordExecution(group, child, child.execution) } - return this.write(group) + const admission = this.write(group) + return claimed || !admission.accepted ? admission : null } handleTurnEvent(event: { diff --git a/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts b/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts index 22b3b26a453c..b4d2286145b0 100644 --- a/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts +++ b/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts @@ -238,10 +238,10 @@ const CODEX_ITEM_CLASSIFICATIONS: Record = // `restoreThread` replays them straight through `items.handle`, which is where // the classification earns its keep. // - // `collabAgentToolCall` is deliberately NOT suppressed with it. Nothing - // guarantees a session reports subagent work as `subAgentActivity` at all; one - // that only ever emits the collab tool call gets no roster row, and suppressing - // that too would leave its fan-out showing nothing. + // `collabAgentToolCall` is deliberately NOT suppressed with it. Codex's default + // multi-agent mode reports subagent work ONLY as that call, which renders as its + // own tool row and never reaches this catalog; were it ever to, suppressing it + // would leave that session's fan-out showing nothing. [CODEX_SUBAGENT_ITEM_TYPE]: 'status-chrome', // `{id, durationMs}` and nothing else — Codex's own transcript renders it as // nothing at all. Every other item type this build does not model carries text diff --git a/src/shared/native-chat-tool-icon.test.ts b/src/shared/native-chat-tool-icon.test.ts index f8cabed950cb..1b1a66532fa2 100644 --- a/src/shared/native-chat-tool-icon.test.ts +++ b/src/shared/native-chat-tool-icon.test.ts @@ -56,6 +56,13 @@ describe('native chat tool icons', () => { expect(nativeChatToolCategory('web search')).toBe('webSearch') }) + it('counts only the Codex call that starts a helper as running an agent', () => { + expect(nativeChatToolCategory('spawn_agent')).toBe('subAgentActivity') + expect(nativeChatToolIconName('spawn_agent')).toBe('bot') + 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') diff --git a/src/shared/native-chat-tool-icon.ts b/src/shared/native-chat-tool-icon.ts index 1bdc5736c62f..3732fd3dbf75 100644 --- a/src/shared/native-chat-tool-icon.ts +++ b/src/shared/native-chat-tool-icon.ts @@ -83,6 +83,8 @@ const CATEGORY_BY_ROW_WORD = new Map([ ['grep', 'search'], ['glob', 'search'], ['task', 'subAgentActivity'], + // Codex's call that starts a helper. Its other agent calls (wait, message, close) run none. + ['spawn_agent', 'subAgentActivity'], ['webfetch', 'webSearch'], ['todowrite', 'todoList'], ['update_plan', 'todoList'], From 24e96c62fd8bf8820fbf1d331e765a651382a849 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 23:50:27 -0700 Subject: [PATCH 02/24] 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. --- src/main/codex/codex-collab-agent-item-translation.ts | 11 ++++++----- src/main/codex/codex-default-mode-helpers.test.ts | 5 +++++ 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/main/codex/codex-collab-agent-item-translation.ts b/src/main/codex/codex-collab-agent-item-translation.ts index 1d63333d36c4..80f921181a9f 100644 --- a/src/main/codex/codex-collab-agent-item-translation.ts +++ b/src/main/codex/codex-collab-agent-item-translation.ts @@ -23,14 +23,15 @@ import type { CodexThreadItem } from './codex-thread-item-identity' /** The roster's name for a helper thread, or null for one it never registered. */ export type CodexHelperName = (threadId: string) => string | null -/** Who the call acted on. A spawn names its helper-to-be by prompt until Codex says which thread - * it became; a helper the roster never registered (a restored thread) is named by its thread id. */ +/** Who the call acted on. A spawn names its helper by its prompt until the roster holds the + * thread it became; a helper the roster never registered (a restored thread) is named by its + * thread id. */ function helperNames(call: CodexCollabAgentToolCall, helperName?: CodexHelperName): string { - const named = call.receiverThreadIds.map((threadId) => helperName?.(threadId) ?? threadId) if (call.tool === 'spawnAgent') { - return named[0] ?? codexCollabHelperLabel(call.prompt) ?? '' + const spawned = call.receiverThreadIds[0] + return (spawned && helperName?.(spawned)) || codexCollabHelperLabel(call.prompt) || '' } - return named.join(', ') + return call.receiverThreadIds.map((threadId) => helperName?.(threadId) ?? threadId).join(', ') } /** What the helpers said back. One reply reads as itself; several are each put under their name. */ diff --git a/src/main/codex/codex-default-mode-helpers.test.ts b/src/main/codex/codex-default-mode-helpers.test.ts index f08832745de0..07ee2dda7631 100644 --- a/src/main/codex/codex-default-mode-helpers.test.ts +++ b/src/main/codex/codex-default-mode-helpers.test.ts @@ -308,6 +308,11 @@ describe('Codex default-mode helpers', () => { expect.objectContaining({ membership: 'live', state: 'working', description: LABEL }) ]) expect(run.strip()).toHaveLength(1) + expect(run.rosterRows()).toEqual([ + expect.objectContaining({ + agents: [expect.objectContaining({ id: HELPER, label: LABEL, state: 'working' })] + }) + ]) }) it('ends a running helper its caller closed as cancelled, in the strip and the record together', async () => { From 1a2d030aaa499373bc16dce472266df2b7d196e2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 23:51:55 -0700 Subject: [PATCH 03/24] test(native-chat): a spawn call keeps its own row beside the roster row it starts --- src/main/codex/codex-default-mode-helpers.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/codex/codex-default-mode-helpers.test.ts b/src/main/codex/codex-default-mode-helpers.test.ts index 07ee2dda7631..d83314c907c1 100644 --- a/src/main/codex/codex-default-mode-helpers.test.ts +++ b/src/main/codex/codex-default-mode-helpers.test.ts @@ -313,6 +313,11 @@ describe('Codex default-mode helpers', () => { agents: [expect.objectContaining({ id: HELPER, label: LABEL, state: 'working' })] }) ]) + // The roster row does not stand in for the call's own row. + expect(run.toolRow('spawn_agent')).toMatchObject({ + state: 'completed', + input: { description: LABEL } + }) }) it('ends a running helper its caller closed as cancelled, in the strip and the record together', async () => { From 221cef85ae3daadaa7ad18e103a5d2970ff9ec14 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 00:19:15 -0700 Subject: [PATCH 04/24] 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. --- src/shared/native-chat-tool-pairing.test.ts | 17 +++++++++++++++++ src/shared/native-chat-tool-pairing.ts | 16 ++++++++-------- src/shared/native-chat-types.ts | 2 ++ .../structured-agent-session-projection.test.ts | 17 +++++++++++++++++ .../structured-agent-session-projection.ts | 4 +++- 5 files changed, 47 insertions(+), 9 deletions(-) diff --git a/src/shared/native-chat-tool-pairing.test.ts b/src/shared/native-chat-tool-pairing.test.ts index 3d2b5f37a974..4b710f328129 100644 --- a/src/shared/native-chat-tool-pairing.test.ts +++ b/src/shared/native-chat-tool-pairing.test.ts @@ -43,6 +43,23 @@ describe('pairNativeChatToolResults', () => { expect(pairedResults.has(orphan)).toBe(false) }) + it('gives a result that names its call to that call, past one that finished with no output', () => { + const named = (name: string, callId: string): NativeChatToolCallBlock => ({ + ...call(name), + callId + }) + const [spawn, wait, shell] = [named('spawn', 's'), named('wait', 'w'), named('shell', 'x')] + const [waited, ran]: NativeChatToolResultBlock[] = [ + { ...result('CHILD_REPLY'), callId: 'w' }, + { ...result('CHILD_DONE'), callId: 'x' } + ] + const { resultByCall } = pairNativeChatToolResults([spawn, wait, waited, shell, ran]) + + expect(resultByCall.has(spawn)).toBe(false) + expect(resultByCall.get(wait)).toBe(waited) + expect(resultByCall.get(shell)).toBe(ran) + }) + it('ignores blocks that are neither a call nor a result', () => { const text: NativeChatBlock = { type: 'text', text: 'hi' } const [a, ra] = [call('a'), result('r')] diff --git a/src/shared/native-chat-tool-pairing.ts b/src/shared/native-chat-tool-pairing.ts index f4dd3db9d399..fe816ee79530 100644 --- a/src/shared/native-chat-tool-pairing.ts +++ b/src/shared/native-chat-tool-pairing.ts @@ -7,9 +7,11 @@ // as the command that produced it. Pairing lets the call own its output, so the // run reads as the work it did. // -// Pairing is positional, the same FIFO rule `dropUnattributableToolResults` -// already uses to decide a result is attributable at all: a result answers the -// oldest call that has not been answered yet. +// A result that names its call answers that call. One that does not is paired +// positionally, the same FIFO rule `dropUnattributableToolResults` already uses to +// decide a result is attributable at all: it answers the oldest call that has not +// been answered yet. Position alone misattributes every later result once one call +// finishes with no output, which is why a producer that knows the call names it. import { isToolCallBlock, @@ -37,7 +39,6 @@ export function pairNativeChatToolResults( const resultByCall = new Map() const pairedResults = new Set() const unanswered: NativeChatToolCallBlock[] = [] - let answered = 0 for (const block of blocks) { if (isToolCallBlock(block)) { unanswered.push(block) @@ -46,13 +47,12 @@ export function pairNativeChatToolResults( if (!isToolResultBlock(block)) { continue } - // Results carry no call id, so the journal's FIFO order is the only stable - // attribution available when calls are interleaved. - const call = unanswered[answered] + const named = + block.callId === undefined ? -1 : unanswered.findIndex((call) => call.callId === block.callId) + const [call] = unanswered.splice(named === -1 ? 0 : named, 1) if (call === undefined) { continue } - answered += 1 resultByCall.set(call, block) pairedResults.add(block) } diff --git a/src/shared/native-chat-types.ts b/src/shared/native-chat-types.ts index 392ebb5b8f6a..03ec29fa1835 100644 --- a/src/shared/native-chat-types.ts +++ b/src/shared/native-chat-types.ts @@ -96,6 +96,8 @@ export type NativeChatToolResultBlock = { type: 'tool-result' output: string isError?: boolean + /** The call this result answers, when the producer knows it; otherwise pairing is positional. */ + callId?: string /** Present only for edit tools whose result reported resolved hunks. */ editPatch?: NativeChatEditPatch } diff --git a/src/shared/structured-agent-session-projection.test.ts b/src/shared/structured-agent-session-projection.test.ts index 691872c3587d..d672184b17f2 100644 --- a/src/shared/structured-agent-session-projection.test.ts +++ b/src/shared/structured-agent-session-projection.test.ts @@ -124,6 +124,23 @@ describe('structured agent session status projection', () => { }) }) + it('names the call a tool row output answers, so a run pairs it by id', () => { + const projected = projectStructuredItemToNativeChat( + item('wait', 1, { + kind: 'tool-call', + name: 'wait_agent', + callId: 'call-wait', + input: null, + state: 'completed', + output: { head: 'CHILD_REPLY', digest: 'd', byteLength: 11, truncated: false } + }) + ) + expect(projected?.blocks).toEqual([ + expect.objectContaining({ type: 'tool-call', callId: 'call-wait' }), + { type: 'tool-result', output: 'CHILD_REPLY', isError: false, callId: 'call-wait' } + ]) + }) + it('projects running, attention, and completed lifecycle states', () => { const running = item('running', 1, { kind: 'status', diff --git a/src/shared/structured-agent-session-projection.ts b/src/shared/structured-agent-session-projection.ts index f35af81b9325..4df5bb751d25 100644 --- a/src/shared/structured-agent-session-projection.ts +++ b/src/shared/structured-agent-session-projection.ts @@ -87,7 +87,9 @@ function itemBlocks(item: AgentJournalRenderItem): { { type: 'tool-result' as const, output: boundedText(body.output), - isError: body.state === 'failed' + isError: body.state === 'failed', + // The call and its output are one journal row, so the result names its call. + ...(body.callId !== undefined ? { callId: body.callId } : {}) } ] : []) From 0bba1da3a5fe60ddbc58781bd6983d8ab82931e9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 00:33:41 -0700 Subject: [PATCH 05/24] fix(native-chat): the Codex roster row follows the executions, so every child ending settles it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../codex/codex-default-mode-helpers.test.ts | 45 +++++++++++++++++++ src/main/codex/codex-subagent-executions.ts | 18 ++++++++ src/main/codex/codex-subagent-roster.ts | 25 ++++++++--- 3 files changed, 83 insertions(+), 5 deletions(-) diff --git a/src/main/codex/codex-default-mode-helpers.test.ts b/src/main/codex/codex-default-mode-helpers.test.ts index d83314c907c1..bed36a19ca13 100644 --- a/src/main/codex/codex-default-mode-helpers.test.ts +++ b/src/main/codex/codex-default-mode-helpers.test.ts @@ -371,3 +371,48 @@ describe('Codex default-mode helpers', () => { ]) }) }) + +describe('the roster row follows a helper whose turn ends with no turn/completed', () => { + const lastRow = (run: Awaited>) => run.rosterRows().at(-1)?.agents + const fatal: Frame = { + method: 'error', + params: { + threadId: HELPER, + turnId: HELPER_TURN, + willRetry: false, + error: { message: 'Selected model is at capacity.', codexErrorInfo: 'serverOverloaded' } + } + } + const closed: Frame = { method: 'thread/closed', params: { threadId: HELPER } } + + it.each([ + ['a fatal error', fatal, 'failed', 'failed'], + ['its thread closing', closed, 'unverifiable', 'unknown'] + ] as const)( + 'settles the row with the strip and the record on %s', + async (_name, ending, rowState, outcome) => { + const run = await session() + run.send( + turn('turn/started', THREAD_ID, PARENT_TURN), + activityStarted, + turn('turn/started', HELPER, HELPER_TURN) + ) + expect(lastRow(run)).toEqual([expect.objectContaining({ id: HELPER, state: 'working' })]) + run.send(ending) + expect(run.strip()).toEqual([]) + expect(run.agents()).toEqual([expect.objectContaining({ membership: 'settled', outcome })]) + expect(lastRow(run)).toEqual([expect.objectContaining({ id: HELPER, state: rowState })]) + } + ) + + it('settles the row when its caller closes the helper', async () => { + const run = await session() + run.send( + turn('turn/started', THREAD_ID, PARENT_TURN), + spawnCompleted, + turn('turn/started', HELPER, HELPER_TURN), + closeCompleted + ) + expect(lastRow(run)).toEqual([expect.objectContaining({ id: HELPER, state: 'stopped' })]) + }) +}) diff --git a/src/main/codex/codex-subagent-executions.ts b/src/main/codex/codex-subagent-executions.ts index 939d1b903317..45b503c774d3 100644 --- a/src/main/codex/codex-subagent-executions.ts +++ b/src/main/codex/codex-subagent-executions.ts @@ -25,10 +25,20 @@ export type CodexExecutionChild = { turnCount: number } +/** Told when a child's current execution changes, whichever frame changed it. */ +export type CodexExecutionListener = (child: Readonly) => void + /** Child turn events own execution; activity items only identify the child. */ export class CodexSubagentExecutions { private readonly children = new Map() private readonly settledTurns = new Map() + private readonly listeners = new Set() + + /** Every reader of a child's execution follows it here, so none needs the frames that end it. */ + onExecutionChanged(listener: CodexExecutionListener): () => void { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } register( agentThreadId: string, @@ -90,6 +100,7 @@ export class CodexSubagentExecutions { } if (state === 'working' || !child.execution || child.execution.turnId === turnId) { child.execution = execution + this.changed(child) } return { child, execution } } @@ -138,6 +149,7 @@ export class CodexSubagentExecutions { for (const child of this.children.values()) { if (child.execution?.state === 'working') { child.execution = { ...child.execution, state: 'unverifiable' } + this.changed(child) } } } @@ -152,6 +164,12 @@ export class CodexSubagentExecutions { return { children: this.children.size, settledTurns: this.settledTurns.size } } + private changed(child: CodexExecutionChild): void { + for (const listener of this.listeners) { + listener(child) + } + } + private child(agentThreadId: string): CodexExecutionChild | undefined { const existing = this.children.get(agentThreadId) if (existing) { diff --git a/src/main/codex/codex-subagent-roster.ts b/src/main/codex/codex-subagent-roster.ts index e84c06c12def..263f92ffb9a8 100644 --- a/src/main/codex/codex-subagent-roster.ts +++ b/src/main/codex/codex-subagent-roster.ts @@ -95,12 +95,19 @@ export class CodexSubagentRoster { private readonly tokensByThread = new Map() private readonly now: () => number private readonly executions: CodexSubagentExecutions + private readonly unfollow: () => void /** Who produced a row, from what this roster learned about each child thread. */ readonly linkage: CodexSubagentLinkage constructor(private readonly deps: CodexSubagentRosterDeps) { this.now = deps.now ?? (() => Date.now()) this.executions = deps.executions ?? new CodexSubagentExecutions() + // The row follows the executions, so every frame that ends a child's turn — its own + // `turn/completed`, a fatal error, its thread closing, its caller closing it — settles it. + // A refused write clears `lastSerialized`, so the next write of the group retries it. + this.unfollow = this.executions.onExecutionChanged( + (child) => child.execution && this.follow(child, child.execution) + ) this.linkage = new CodexSubagentLinkage({ primaryThreadId: deps.primaryThreadId, executions: this.executions @@ -173,12 +180,19 @@ export class CodexSubagentRoster { return ADMITTED } const observed = this.executions.observeTurn(input.threadId, input.turnId, input.state) - if (!observed || !observed.child.registered) { + // Followed already if the execution changed; re-derived (idempotently) for its admission. + return observed ? this.follow(observed.child, observed.execution) : ADMITTED + } + + private follow( + child: Readonly, + execution: CodexChildExecution + ): StructuredAgentSessionSinkAdmission { + if (!child.registered) { return ADMITTED } - const { child, execution } = observed - if (input.state === 'working') { - const parent = this.deps.primaryThreadId() ?? input.threadId + if (execution.state === 'working') { + const parent = this.deps.primaryThreadId() ?? child.agentThreadId const group = this.executionGroup(child.agentThreadId, execution.turnId) ?? this.groupFor(parent, this.deps.activeTurn(parent) ?? child.parentTurnId) @@ -186,7 +200,7 @@ export class CodexSubagentRoster { return this.write(group) } for (const group of this.groups.values()) { - if (group.executionTurns.get(input.threadId) !== input.turnId) { + if (group.executionTurns.get(child.agentThreadId) !== execution.turnId) { continue } this.recordExecution(group, child, execution) @@ -249,6 +263,7 @@ export class CodexSubagentRoster { } dispose(): void { + this.unfollow() this.groups.clear() this.tokensByThread.clear() } From 0892906dfa9eff8a522504b44c84d6add12cdf71 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 00:59:37 -0700 Subject: [PATCH 06/24] 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. --- .../codex/codex-collab-agent-tool-call.ts | 3 +- .../codex/codex-default-mode-helpers.test.ts | 61 +++++++++++++++++++ ...-structured-journal-translation-restore.ts | 42 +++++++++++-- ...-structured-journal-translation-writers.ts | 2 +- .../codex-structured-journal-translation.ts | 22 +++---- src/main/codex/codex-subagent-roster.ts | 13 ++-- 6 files changed, 116 insertions(+), 27 deletions(-) diff --git a/src/main/codex/codex-collab-agent-tool-call.ts b/src/main/codex/codex-collab-agent-tool-call.ts index aa6376260b10..6cc0f2188b85 100644 --- a/src/main/codex/codex-collab-agent-tool-call.ts +++ b/src/main/codex/codex-collab-agent-tool-call.ts @@ -84,7 +84,8 @@ export function codexCollabHelperLabel(prompt: string | null): string | null { return `${collapsed.slice(0, end)}…` } -/** The name the model called the tool by, which is what its row shows. */ +/** The name the model called the tool by, which is what its row shows. The wire item spells the + * tool in camelCase; the model-facing name is snake_case, so this map is not a spelling fix. */ const CODEX_COLLAB_TOOL_NAMES = new Map([ ['spawnAgent', 'spawn_agent'], ['sendInput', 'send_input'], diff --git a/src/main/codex/codex-default-mode-helpers.test.ts b/src/main/codex/codex-default-mode-helpers.test.ts index bed36a19ca13..e8ea6b1e3ac2 100644 --- a/src/main/codex/codex-default-mode-helpers.test.ts +++ b/src/main/codex/codex-default-mode-helpers.test.ts @@ -12,6 +12,9 @@ import { makeStructuredAgentStatusSubject } from '../../shared/agent-status-subj import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { fakeCodex, identityFor, THREAD_ID } from './codex-structured-session-adapter-fixture' import { CodexStructuredSessionAdapter } from './codex-structured-session-adapter' +import { CodexBackgroundTaskTracker } from './codex-background-task-tracker' +import { createCodexJournalTranslator } from './codex-structured-journal-translation' +import { CodexSubagentExecutions } from './codex-subagent-executions' const parent = makeStructuredAgentStatusSubject( { @@ -416,3 +419,61 @@ describe('the roster row follows a helper whose turn ends with no turn/completed expect(lastRow(run)).toEqual([expect.objectContaining({ id: HELPER, state: 'stopped' })]) }) }) + +describe('a restored thread', () => { + function restored() { + const executions = new CodexSubagentExecutions() + const rows = new Map() + const translator = createCodexJournalTranslator({ + sink: { + appendItem: (identity, body) => rows.set(JSON.stringify(identity), body), + appendTombstone: () => {}, + publish: () => {} + }, + primaryThreadId: () => THREAD_ID, + subagentExecutions: executions, + schedule: (run: () => void) => { + run() + return () => {} + } + }) + const tracker = new CodexBackgroundTaskTracker(THREAD_ID, executions) + const replayed = [spawnCompleted, waitCompleted].map((frame) => frame.params.item) + const admission = translator.restoreThread(THREAD_ID, { + turns: [{ id: PARENT_TURN, items: replayed }] + }) + expect(admission).toEqual({ accepted: true }) + const bodies = () => [...rows.values()] + return { executions, tracker, bodies } + } + + it('names the helper on a replayed call row, as the live row did', () => { + const { bodies } = restored() + expect(bodies()).toContainEqual( + expect.objectContaining({ + kind: 'tool-call', + name: 'wait_agent', + input: expect.objectContaining({ description: LABEL }) + }) + ) + }) + + it('claims no running helper for a name it learned from history', () => { + const { executions, tracker, bodies } = restored() + expect(executions.workingChildren()).toEqual([]) + // A live frame republishes the strip and drains evidence: neither holds the helper. + tracker.observe({ + method: 'turn/started', + threadId: THREAD_ID, + params: { threadId: THREAD_ID, turn: { id: 'next-turn', status: 'inProgress' } } + }) + expect(tracker.state).toBeNull() + expect(tracker.drainChildWorkEvidence(1)).toEqual([]) + expect( + bodies().filter( + (body) => + body.kind === 'message' && body.blocks.some((block) => block.type === 'subagent-group') + ) + ).toEqual([]) + }) +}) diff --git a/src/main/codex/codex-structured-journal-translation-restore.ts b/src/main/codex/codex-structured-journal-translation-restore.ts index 7005c10a43b8..b5339ccde509 100644 --- a/src/main/codex/codex-structured-journal-translation-restore.ts +++ b/src/main/codex/codex-structured-journal-translation-restore.ts @@ -1,5 +1,12 @@ import type { AgentJournalTurnLifecycle } from '../../shared/agent-session-journal-types' -import type { CodexTurnOrdinals } from './codex-structured-item-translation' +import { readCodexThreadItem, type CodexTurnOrdinals } from './codex-structured-item-translation' +import type { CodexJournalCompactions } from './codex-structured-journal-compactions' +import type { CodexJournalItems } from './codex-structured-journal-items' +import { + readCodexNotificationThreadItem, + readCodexSubagentAnnouncement +} from './codex-subagent-activity' +import type { CodexSubagentExecutions } from './codex-subagent-executions' import { readCodexJournalRecord, readCodexJournalString @@ -12,6 +19,33 @@ import { } from './codex-structured-journal-translation-turns' import { readCodexTurnDurationMs, readCodexTurnStatus } from './codex-structured-thread-facts' +type CodexHistoryItemEvent = { threadId: string; method: string; params: unknown } + +/** One replayed item. History never runs the live item router, so a helper the replay announces + * is registered here for its name and membership only: `register` starts no execution, so no + * strip entry, record or roster row claims it runs until a live turn of its own says so. */ +export function restoreCodexHistoryItem( + event: CodexHistoryItemEvent, + input: { + primaryThreadId: string | null + compactions: Pick + items: Pick + executions: Pick + } +): CodexJournalTranslationAdmission { + const item = readCodexNotificationThreadItem(event.params, readCodexThreadItem) + const announcement = item && readCodexSubagentAnnouncement(item) + if (announcement && announcement.agentThreadId !== input.primaryThreadId) { + input.executions.register(announcement.agentThreadId, announcement.label, undefined) + } + const compaction = input.compactions.handle(event) + if (compaction) { + return compaction + } + const translated = input.items.handle(event, 'history') + return translated.handled ? translated.admission : { accepted: false, reason: 'untranslated' } +} + /** Old providers may return the complete thread from resume. Keep that fallback * bounded before admitting any rows to the asynchronous sink. */ export const CODEX_RESTORE_MAX_OPERATIONS = 1_024 @@ -22,11 +56,7 @@ export function restoreCodexJournalThread(input: { thread: Record currentTurnIds: Map> ordinals: CodexTurnOrdinals - handleItem: (event: { - threadId: string - method: string - params: unknown - }) => CodexJournalTranslationAdmission + handleItem: (event: CodexHistoryItemEvent) => CodexJournalTranslationAdmission /** Absent when the caller has no session identity to key lifecycle rows by. */ restoreTurnLifecycle?: ( turnLifecycle: AgentJournalTurnLifecycle diff --git a/src/main/codex/codex-structured-journal-translation-writers.ts b/src/main/codex/codex-structured-journal-translation-writers.ts index 605702bbee9d..80b12509ed08 100644 --- a/src/main/codex/codex-structured-journal-translation-writers.ts +++ b/src/main/codex/codex-structured-journal-translation-writers.ts @@ -28,7 +28,7 @@ export function createCodexJournalTranslatorWriters(deps: CodexJournalTranslator activeTurn, (threadId, turnId) => genericFrames.suppress(threadId, turnId), // A collab call's row names its helpers the way the roster does. - (threadId) => subagents.helperLabel(threadId) + (threadId) => subagents.executions.label(threadId) ) return { activeTurns, diff --git a/src/main/codex/codex-structured-journal-translation.ts b/src/main/codex/codex-structured-journal-translation.ts index fadab8b4589c..0401684a390d 100644 --- a/src/main/codex/codex-structured-journal-translation.ts +++ b/src/main/codex/codex-structured-journal-translation.ts @@ -7,7 +7,10 @@ import { type CodexJournalTranslatorDeps } from './codex-structured-journal-contracts' import { settleCodexJournalSession } from './codex-structured-journal-settlement' -import { restoreCodexJournalThread } from './codex-structured-journal-translation-restore' +import { + restoreCodexHistoryItem, + restoreCodexJournalThread +} from './codex-structured-journal-translation-restore' import { CodexJournalTurnBoundaries } from './codex-structured-journal-translation-turn-boundaries' import { createCodexJournalTranslatorWriters } from './codex-structured-journal-translation-writers' import { publishCodexTurnLifecycle } from './codex-structured-journal-translation-turns' @@ -103,16 +106,13 @@ export function createCodexJournalTranslator( thread, currentTurnIds: activeTurns.byThread, ordinals: items.ordinals, - handleItem: (event) => { - const compaction = compactions.handle(event) - if (compaction) { - return compaction - } - const translated = items.handle(event, 'history') - return translated.handled - ? translated.admission - : { accepted: false, reason: 'untranslated' } - }, + handleItem: (event) => + restoreCodexHistoryItem(event, { + primaryThreadId: deps.primaryThreadId?.() ?? null, + compactions, + items, + executions: subagents.executions + }), ...(deps.sessionId !== undefined ? { restoreTurnLifecycle: (turnLifecycle) => diff --git a/src/main/codex/codex-subagent-roster.ts b/src/main/codex/codex-subagent-roster.ts index 263f92ffb9a8..09d157052227 100644 --- a/src/main/codex/codex-subagent-roster.ts +++ b/src/main/codex/codex-subagent-roster.ts @@ -94,7 +94,8 @@ export class CodexSubagentRoster { * the map itself is LRU-capped in `handleTokenUsage`. */ private readonly tokensByThread = new Map() private readonly now: () => number - private readonly executions: CodexSubagentExecutions + /** The one owner of child membership and turn state; the rows of calls on a helper read it too. */ + readonly executions: CodexSubagentExecutions private readonly unfollow: () => void /** Who produced a row, from what this roster learned about each child thread. */ readonly linkage: CodexSubagentLinkage @@ -114,11 +115,6 @@ export class CodexSubagentRoster { }) } - /** The label a registered helper's row carries. */ - helperLabel(agentThreadId: string): string | null { - return this.executions.label(agentThreadId) - } - /** Consume an item that announces a child. Null means the item is not this roster's to render: * a `subAgentActivity` item renders as the roster row alone, while a spawn call keeps its own * row, so it is claimed only to hand back a refused write. */ @@ -127,7 +123,8 @@ export class CodexSubagentRoster { turnId: string | null item: CodexThreadItem }): StructuredAgentSessionSinkAdmission | null { - const claimed = readCodexSubagentActivity(input.item) ? ADMITTED : null + const renderedByRoster = readCodexSubagentActivity(input.item) !== null + const claimed = renderedByRoster ? ADMITTED : null const announcement = readCodexSubagentAnnouncement(input.item) // The root node is the parent turn itself, not a child it spawned. if (!announcement || announcement.agentThreadId === this.deps.primaryThreadId()) { @@ -150,7 +147,7 @@ export class CodexSubagentRoster { this.recordExecution(group, child, child.execution) } const admission = this.write(group) - return claimed || !admission.accepted ? admission : null + return renderedByRoster || !admission.accepted ? admission : null } handleTurnEvent(event: { From 13d58ab9fee717ed94e6d600da6956c2c553baac Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:36:57 -0700 Subject: [PATCH 07/24] 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. --- .../codex/codex-collab-agent-tool-call.ts | 16 +++-- .../codex/codex-default-mode-helpers.test.ts | 69 +++++++++++++++++++ src/main/codex/codex-subagent-activity.ts | 2 +- 3 files changed, 79 insertions(+), 8 deletions(-) diff --git a/src/main/codex/codex-collab-agent-tool-call.ts b/src/main/codex/codex-collab-agent-tool-call.ts index 6cc0f2188b85..c255228345b5 100644 --- a/src/main/codex/codex-collab-agent-tool-call.ts +++ b/src/main/codex/codex-collab-agent-tool-call.ts @@ -3,8 +3,10 @@ // // Codex's default multi-agent mode reports a helper ONLY this way; it emits no `subAgentActivity`. // Shapes are from the app-server's generated schema (0.155) and a live default-mode session: -// * `spawnAgent` starts with `receiverThreadIds: []`. The helper's thread id first appears on the -// call's completion, beside the `prompt` it was given. +// * `spawnAgent` starts with `receiverThreadIds: []`. The helper's thread id first appears when +// the call ends, beside the `prompt` it was given — even when the call ends `failed`. +// * Codex core also knows each receiver's nickname and role, but the app-server item does not +// carry them yet; `readCodexSubagentAnnouncement` is where they would be adopted. // * The item names no nickname or task path, so the prompt is the only text that tells one // helper from another. // * `agentsStates` is the caller's last-known snapshot of each receiver. The helper's own turn @@ -57,14 +59,14 @@ export function readCodexCollabAgentToolCall( } } -/** The helper a finished `spawnAgent` created. A spawn Codex refused names none. */ +/** The helper a `spawnAgent` created, whatever the call's status: Codex reports `failed` for a + * helper that errored at birth, yet names the thread it created, and that thread can still run. + * A spawn in progress, or one that created nothing, names no receiver. */ export function codexCollabSpawnedThread(call: CodexCollabAgentToolCall): string | null { - return call.tool === 'spawnAgent' && call.status === 'completed' - ? (call.receiverThreadIds[0] ?? null) - : null + return call.tool === 'spawnAgent' ? (call.receiverThreadIds[0] ?? null) : null } -/** The helper a finished `closeAgent` shut down. */ +/** The helper a finished `closeAgent` shut down. A failed close left it running. */ export function codexCollabClosedThread(call: CodexCollabAgentToolCall): string | null { return call.tool === 'closeAgent' && call.status === 'completed' ? (call.receiverThreadIds[0] ?? null) diff --git a/src/main/codex/codex-default-mode-helpers.test.ts b/src/main/codex/codex-default-mode-helpers.test.ts index e8ea6b1e3ac2..dd98bf8bea0f 100644 --- a/src/main/codex/codex-default-mode-helpers.test.ts +++ b/src/main/codex/codex-default-mode-helpers.test.ts @@ -323,6 +323,75 @@ describe('Codex default-mode helpers', () => { }) }) + it('registers a helper whose spawn call failed but created its thread, as Codex reports an errored one', async () => { + const run = await session() + // The shape Codex builds for a helper that errored at birth: the call fails, yet names the + // thread it created, and that thread can still run. + const spawnFailedWithThread = collab('item/completed', { + id: 'call-failed-spawn', + tool: 'spawnAgent', + status: 'failed', + receiverThreadIds: [HELPER], + prompt: PROMPT, + model: 'gpt-5.5', + reasoningEffort: 'medium', + agentsStates: { [HELPER]: { status: 'errored', message: null } } + }) + run.send( + turn('turn/started', THREAD_ID, PARENT_TURN), + spawnFailedWithThread, + turn('turn/started', HELPER, HELPER_TURN), + helperShell('item/started') + ) + expect(run.agents()).toEqual([ + expect.objectContaining({ membership: 'live', state: 'working', description: LABEL }) + ]) + // Its command is the helper's, not a bare command of the session's own. + expect(run.strip()).toEqual([ + { id: `codex-agent:${HELPER}`, kind: 'agent', description: LABEL } + ]) + expect(run.rosterRows()).toEqual([ + expect.objectContaining({ + agents: [expect.objectContaining({ id: HELPER, state: 'working' })] + }) + ]) + }) + + it('registers nothing for a spawn that created no thread', async () => { + const run = await session() + run.send( + turn('turn/started', THREAD_ID, PARENT_TURN), + collab('item/completed', { + id: 'call-refused-spawn', + tool: 'spawnAgent', + status: 'failed', + receiverThreadIds: [], + prompt: PROMPT + }) + ) + expect(run.agents()).toEqual([]) + expect(run.strip()).toEqual([]) + expect(run.rosterRows()).toEqual([]) + }) + + it('leaves a helper running when its caller failed to close it', async () => { + const run = await session() + run.send( + turn('turn/started', THREAD_ID, PARENT_TURN), + spawnCompleted, + turn('turn/started', HELPER, HELPER_TURN), + collab('item/completed', { + id: 'call-close-failed', + tool: 'closeAgent', + status: 'failed', + receiverThreadIds: [HELPER], + agentsStates: { [HELPER]: { status: 'notFound', message: null } } + }) + ) + expect(run.agents()).toEqual([expect.objectContaining({ membership: 'live' })]) + expect(run.strip()).toHaveLength(1) + }) + it('ends a running helper its caller closed as cancelled, in the strip and the record together', async () => { const run = await session() run.send( diff --git a/src/main/codex/codex-subagent-activity.ts b/src/main/codex/codex-subagent-activity.ts index a3f3d1b6fc22..343f7e82be2e 100644 --- a/src/main/codex/codex-subagent-activity.ts +++ b/src/main/codex/codex-subagent-activity.ts @@ -102,7 +102,7 @@ export type CodexSubagentAnnouncement = { spawned: boolean } -/** The child a `subAgentActivity` item (the tree root excluded) or a finished `spawnAgent` call +/** The child a `subAgentActivity` item (the tree root excluded) or an ended `spawnAgent` call * announces. Both name the child by its thread id, so a session sending both announces one. */ export function readCodexSubagentAnnouncement( item: CodexThreadItem From c89bb9522267fcd061f76378142c2592f781c034 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:18:26 -0700 Subject: [PATCH 08/24] 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. --- src/main/codex/codex-subagent-roster.ts | 25 +++------------ .../codex/codex-thread-token-totals.test.ts | 24 ++++++++++++++ src/main/codex/codex-thread-token-totals.ts | 32 +++++++++++++++++++ 3 files changed, 61 insertions(+), 20 deletions(-) create mode 100644 src/main/codex/codex-thread-token-totals.test.ts create mode 100644 src/main/codex/codex-thread-token-totals.ts diff --git a/src/main/codex/codex-subagent-roster.ts b/src/main/codex/codex-subagent-roster.ts index 09d157052227..09bef7e1f6c0 100644 --- a/src/main/codex/codex-subagent-roster.ts +++ b/src/main/codex/codex-subagent-roster.ts @@ -44,9 +44,9 @@ export { codexSubagentGroupBody } from './codex-subagent-group-body' import type { CodexThreadItem } from './codex-structured-item-translation' import { MAX_CODEX_SUBAGENT_GROUPS, - MAX_CODEX_SUBAGENTS_PER_GROUP, - MAX_CODEX_TOKEN_USAGE_THREADS + MAX_CODEX_SUBAGENTS_PER_GROUP } from './codex-structured-journal-limits' +import { CodexThreadTokenTotals } from './codex-thread-token-totals' const ADMITTED: StructuredAgentSessionSinkAdmission = { accepted: true } @@ -88,11 +88,8 @@ export type CodexSubagentRosterDeps = { export class CodexSubagentRoster { private readonly groups = new Map() - /** Latest reported total per thread, kept regardless of roster membership: a - * usage frame can arrive before the child's first activity item, and filtering - * at receipt would lose it permanently. Children are selected at write time; - * the map itself is LRU-capped in `handleTokenUsage`. */ - private readonly tokensByThread = new Map() + /** Every thread's total, members or not; children are selected at write time. */ + private readonly tokensByThread = new CodexThreadTokenTotals() private readonly now: () => number /** The one owner of child membership and turn state; the rows of calls on a helper read it too. */ readonly executions: CodexSubagentExecutions @@ -215,19 +212,7 @@ export class CodexSubagentRoster { if (!usage) { return null } - // A running total: the newest frame REPLACES the previous one. Summing - // updates would multiply a single child's usage by its frame count. - // Re-insert so the eviction scan below sees recency: `set` on an existing - // key keeps its original position, which would age out an active thread. - this.tokensByThread.delete(usage.threadId) - this.tokensByThread.set(usage.threadId, usage.totalTokens) - while (this.tokensByThread.size > MAX_CODEX_TOKEN_USAGE_THREADS) { - const oldest = this.tokensByThread.keys().next().value - if (typeof oldest !== 'string') { - break - } - this.tokensByThread.delete(oldest) - } + this.tokensByThread.record(usage.threadId, usage.totalTokens) for (const group of this.groups.values()) { if (!group.entries.has(usage.threadId)) { continue diff --git a/src/main/codex/codex-thread-token-totals.test.ts b/src/main/codex/codex-thread-token-totals.test.ts new file mode 100644 index 000000000000..1dab7dc6f6e7 --- /dev/null +++ b/src/main/codex/codex-thread-token-totals.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { MAX_CODEX_TOKEN_USAGE_THREADS } from './codex-structured-journal-limits' +import { CodexThreadTokenTotals } from './codex-thread-token-totals' + +describe('CodexThreadTokenTotals', () => { + it("replaces a thread's total with its newest report instead of summing reports", () => { + const totals = new CodexThreadTokenTotals() + totals.record('child-1', 100) + totals.record('child-1', 250) + expect(totals.get('child-1')).toBe(250) + }) + + it('evicts by recency of report, so a thread still reporting outlives quieter ones', () => { + const totals = new CodexThreadTokenTotals() + totals.record('child-1', 1) + for (let index = 0; index < MAX_CODEX_TOKEN_USAGE_THREADS - 1; index++) { + totals.record(`other-${index}`, index) + } + totals.record('child-1', 2) + totals.record('newest', 3) + expect(totals.get('child-1')).toBe(2) + expect(totals.get('other-0')).toBeUndefined() + }) +}) diff --git a/src/main/codex/codex-thread-token-totals.ts b/src/main/codex/codex-thread-token-totals.ts new file mode 100644 index 000000000000..23bc6a9d1995 --- /dev/null +++ b/src/main/codex/codex-thread-token-totals.ts @@ -0,0 +1,32 @@ +import { MAX_CODEX_TOKEN_USAGE_THREADS } from './codex-structured-journal-limits' + +/** Latest reported token total per thread, kept regardless of roster membership: a + * usage frame can arrive before the child's first activity item, and filtering at + * receipt would lose it permanently. LRU-capped at MAX_CODEX_TOKEN_USAGE_THREADS. */ +export class CodexThreadTokenTotals { + private readonly totals = new Map() + + /** A running total: the newest frame REPLACES the previous one. Summing updates + * would multiply a single child's usage by its frame count. */ + record(threadId: string, totalTokens: number): void { + // Re-insert so eviction sees recency: `set` on an existing key keeps its + // original position, which would age out an active thread. + this.totals.delete(threadId) + this.totals.set(threadId, totalTokens) + while (this.totals.size > MAX_CODEX_TOKEN_USAGE_THREADS) { + const oldest = this.totals.keys().next().value + if (typeof oldest !== 'string') { + break + } + this.totals.delete(oldest) + } + } + + get(threadId: string): number | undefined { + return this.totals.get(threadId) + } + + clear(): void { + this.totals.clear() + } +} From 36a0ce33c881c8f18797f88e144a6e785ce62ac7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 16:43:00 -0700 Subject: [PATCH 09/24] 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. --- ...tructured-agent-session-latest-messages.ts | 63 ++++++++++++++++ .../structured-agent-session-projection.ts | 72 ++++--------------- 2 files changed, 75 insertions(+), 60 deletions(-) create mode 100644 src/shared/structured-agent-session-latest-messages.ts diff --git a/src/shared/structured-agent-session-latest-messages.ts b/src/shared/structured-agent-session-latest-messages.ts new file mode 100644 index 000000000000..b43241060b8b --- /dev/null +++ b/src/shared/structured-agent-session-latest-messages.ts @@ -0,0 +1,63 @@ +// The newest messages a session list quotes for a session: its own user's prompt and its own +// agent's prose, never a subagent's rows that share the journal. + +import type { AgentJournalRenderItem } from './agent-session-journal-types' +import { isRootAgentJournalItem } from './agent-session-journal-producer' +import type { NativeChatBlock } from './native-chat-types' + +function messageProse(blocks: readonly NativeChatBlock[]): string { + return blocks.flatMap((block) => (block.type === 'text' ? [block.text] : [])).join('\n') +} + +/** The newest prompt the session's own user turn carries, as the sidebar quotes + * it. Scoped to root rows for the same reason the assistant line is: a provider + * that journals a subagent's own prompt would otherwise requote it as the + * session's. */ +export function latestStructuredAgentSessionPrompt( + items: readonly AgentJournalRenderItem[] +): string { + const body = latestStructuredAgentSessionUserItem(items)?.body + return body?.kind === 'message' ? messageProse(body.blocks) : '' +} + +export function latestStructuredAgentSessionUserItem( + items: readonly AgentJournalRenderItem[] +): AgentJournalRenderItem | null { + for (let index = items.length - 1; index >= 0; index -= 1) { + const item = items[index] + if ( + item?.body.kind === 'message' && + item.body.role === 'user' && + isRootAgentJournalItem(item) + ) { + return item + } + } + return null +} + +/** The newest prose THE SESSION'S OWN AGENT wrote in the latest user turn — not a + * subagent's, whose rows share this journal and are usually the newer ones while + * a child runs. Tool-only assistant items are skipped; the user boundary clears + * prose from the preceding turn. */ +export function latestStructuredAgentSessionAssistantMessage( + items: readonly AgentJournalRenderItem[] +): string { + for (let index = items.length - 1; index >= 0; index -= 1) { + const item = items[index] + const body = item?.body + if (!isRootAgentJournalItem(item)) { + continue + } + if (body?.kind === 'message' && body.role === 'user') { + return '' + } + if (body?.kind === 'message' && body.role === 'assistant') { + const prose = messageProse(body.blocks) + if (prose.trim()) { + return prose + } + } + } + return '' +} diff --git a/src/shared/structured-agent-session-projection.ts b/src/shared/structured-agent-session-projection.ts index 4df5bb751d25..f57e53bdfbdb 100644 --- a/src/shared/structured-agent-session-projection.ts +++ b/src/shared/structured-agent-session-projection.ts @@ -10,7 +10,7 @@ import { type AgentJournalSubmission, type AgentJournalTurnOutcome } from './agent-session-journal-types' -import { agentJournalLinkageFields, isRootAgentJournalItem } from './agent-session-journal-producer' +import { agentJournalLinkageFields } from './agent-session-journal-producer' import { structuredAgentSessionStatusBlock } from './structured-agent-session-status-block' import { agentJournalItemRowOrigin } from './agent-session-journal-position' import { @@ -30,17 +30,26 @@ import { } from './structured-agent-session-tool-call-block' import type { NativeChatBlock, NativeChatMessage } from './native-chat-types' +import { + latestStructuredAgentSessionAssistantMessage, + latestStructuredAgentSessionPrompt +} from './structured-agent-session-latest-messages' import { sha256 } from './sha256' import { structuredAgentSessionStatusStartedAt } from './structured-agent-session-status-started-at' import { owesStructuredAgentSessionWork } from './structured-agent-session-owed-work' -// Re-exported so the live-turn readers' and the unanswered-send rule's existing consumers keep one -// import site. +// Re-exported so the live-turn readers', the latest-message readers' and the unanswered-send rule's +// existing consumers keep one import site. export { activeStructuredAgentSessionTurnId, newestStructuredAgentSessionTurn } from './structured-agent-session-live-turn' export { hasUnansweredStructuredAgentSessionDispatch } from './structured-agent-session-unanswered-dispatch' +export { + latestStructuredAgentSessionAssistantMessage, + latestStructuredAgentSessionPrompt, + latestStructuredAgentSessionUserItem +} from './structured-agent-session-latest-messages' function boundedText(payload: { head: string; truncated: boolean; byteLength: number }): string { return payload.truncated ? `${payload.head}\n… (${payload.byteLength} bytes)` : payload.head @@ -198,63 +207,6 @@ export function projectStructuredAgentSessionStatus( return owesStructuredAgentSessionWork(items, submissions, currentFence) ? 'working' : 'idle' } -function messageProse(blocks: readonly NativeChatBlock[]): string { - return blocks.flatMap((block) => (block.type === 'text' ? [block.text] : [])).join('\n') -} - -/** The newest prompt the session's own user turn carries, as the sidebar quotes - * it. Scoped to root rows for the same reason the assistant line is: a provider - * that journals a subagent's own prompt would otherwise requote it as the - * session's. */ -export function latestStructuredAgentSessionPrompt( - items: readonly AgentJournalRenderItem[] -): string { - const body = latestStructuredAgentSessionUserItem(items)?.body - return body?.kind === 'message' ? messageProse(body.blocks) : '' -} - -export function latestStructuredAgentSessionUserItem( - items: readonly AgentJournalRenderItem[] -): AgentJournalRenderItem | null { - for (let index = items.length - 1; index >= 0; index -= 1) { - const item = items[index] - if ( - item?.body.kind === 'message' && - item.body.role === 'user' && - isRootAgentJournalItem(item) - ) { - return item - } - } - return null -} - -/** The newest prose THE SESSION'S OWN AGENT wrote in the latest user turn — not a - * subagent's, whose rows share this journal and are usually the newer ones while - * a child runs. Tool-only assistant items are skipped; the user boundary clears - * prose from the preceding turn. */ -export function latestStructuredAgentSessionAssistantMessage( - items: readonly AgentJournalRenderItem[] -): string { - for (let index = items.length - 1; index >= 0; index -= 1) { - const item = items[index] - const body = item?.body - if (!isRootAgentJournalItem(item)) { - continue - } - if (body?.kind === 'message' && body.role === 'user') { - return '' - } - if (body?.kind === 'message' && body.role === 'assistant') { - const prose = messageProse(body.blocks) - if (prose.trim()) { - return prose - } - } - } - return '' -} - /** The activity fields a sidebar row shows beside the prompt, named as the agent-status * entry names them so the client can hand them straight to a row. */ export type StructuredAgentSessionStatusProjection = { From d9f9f980818c7975e7b2f0e65ba65c2d40624996 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:20:42 -0700 Subject: [PATCH 10/24] 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. --- .../codex/codex-default-mode-helpers.test.ts | 32 +++++++++++++++---- 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/src/main/codex/codex-default-mode-helpers.test.ts b/src/main/codex/codex-default-mode-helpers.test.ts index dd98bf8bea0f..e038e844a3be 100644 --- a/src/main/codex/codex-default-mode-helpers.test.ts +++ b/src/main/codex/codex-default-mode-helpers.test.ts @@ -111,7 +111,7 @@ const helperShell = (method: 'item/started' | 'item/completed'): Frame => id: 'call_3iUPkSEtwkUYstngZm5CNJ52', command: COMMAND, cwd: '/work/repo', - // Persistent exec: the only command source the strip lists. + // As the live session reported it; a command is tracked the same whatever its source. source: 'unifiedExecStartup', status: method === 'item/started' ? 'inProgress' : 'completed', ...(method === 'item/completed' ? { exitCode: 0, aggregatedOutput: 'CHILD_DONE\n' } : {}) @@ -222,16 +222,18 @@ describe('Codex default-mode helpers', () => { ]) expect(run.commands()).toEqual([ expect.objectContaining({ + membership: 'live', description: COMMAND, parentChildWorkId: run.agents()[0]?.childWorkId }) ]) + expect(run.agents()[0]?.operation).toMatchObject({ toolName: 'Bash', input: COMMAND }) - run.send( - helperShell('item/completed'), - helperReply, - turn('turn/completed', HELPER, HELPER_TURN) - ) + run.send(helperShell('item/completed')) + // A finished command leaves no record behind. + expect(run.commands()).toEqual([]) + + run.send(helperReply, turn('turn/completed', HELPER, HELPER_TURN)) expect(run.agents()).toEqual([ expect.objectContaining({ membership: 'settled', @@ -410,6 +412,24 @@ describe('Codex default-mode helpers', () => { }) }) + it("keeps a closed helper's running command until Codex reports the killed process's exit", async () => { + const run = await session() + run.send( + turn('turn/started', THREAD_ID, PARENT_TURN), + spawnCompleted, + turn('turn/started', HELPER, HELPER_TURN), + helperShell('item/started'), + turn('turn/completed', HELPER, HELPER_TURN), + closeCompleted + ) + // The close shuts the helper's thread down and kills its processes; each still reports its + // exit on the helper's thread, and that exit is what ends the command. + expect(run.commands()).toEqual([expect.objectContaining({ membership: 'live' })]) + run.send(helperShell('item/completed')) + expect(run.commands()).toEqual([]) + expect(run.strip()).toEqual([]) + }) + it('leaves a finished helper finished when its caller closes it', async () => { const run = await session() run.send( From 8781c4106c7d0cae053733a4f3ca131f3d0d7ad0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:05:10 -0700 Subject: [PATCH 11/24] 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. --- src/shared/native-chat-tool-fold.ts | 14 ++++++++----- .../native-chat-tool-pair-limit.test.ts | 21 +++++++++++++++++++ 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/src/shared/native-chat-tool-fold.ts b/src/shared/native-chat-tool-fold.ts index f3c72a45a6b2..40d8b95e57fc 100644 --- a/src/shared/native-chat-tool-fold.ts +++ b/src/shared/native-chat-tool-fold.ts @@ -153,16 +153,17 @@ export type NativeChatToolPair = { result?: NativeChatToolResultBlock } -/** Pair calls and results by FIFO ordinal because transcript blocks carry no tool ids. */ +/** Pair each result with the call it names, else with the oldest unanswered call: the rule + * `pairNativeChatToolResults` uses, so every reader of a run agrees on who owns an output. */ export function pairToolBlocks( blocks: readonly NativeChatBlock[], limit = Infinity ): NativeChatToolPair[] { const pairs: NativeChatToolPair[] = [] + /** Slots of retained calls not yet answered, oldest first. */ const callSlots: number[] = [] - let resultOrdinal = 0 for (const block of blocks) { - if (pairs.length >= limit && resultOrdinal >= callSlots.length) { + if (pairs.length >= limit && callSlots.length === 0) { break } if (block.type === 'tool-call') { @@ -175,13 +176,16 @@ export function pairToolBlocks( if (block.type !== 'tool-result') { continue } - const slot = callSlots[resultOrdinal] + const named = + block.callId === undefined + ? -1 + : callSlots.findIndex((slot) => pairs[slot]?.call?.callId === block.callId) + const [slot] = callSlots.splice(named === -1 ? 0 : named, 1) if (slot === undefined) { if (pairs.length < limit) { pairs.push({ result: block }) } } else { - resultOrdinal += 1 pairs[slot]!.result = block } } diff --git a/src/shared/native-chat-tool-pair-limit.test.ts b/src/shared/native-chat-tool-pair-limit.test.ts index f279b70c996f..5fd7795bb337 100644 --- a/src/shared/native-chat-tool-pair-limit.test.ts +++ b/src/shared/native-chat-tool-pair-limit.test.ts @@ -77,6 +77,27 @@ describe('tool pair limits', () => { expect(pairToolBlocks(blocks, 2)).toEqual(original(blocks, 2)) }) + it('gives a result that names its call to that call, past one that finished with no output', () => { + const named = (name: string, callId: string): NativeChatBlock => ({ + type: 'tool-call', + name, + input: {}, + callId + }) + const [spawn, wait, shell] = [ + named('spawn_agent', 's'), + named('wait_agent', 'w'), + named('Bash', 'x') + ] + const waited: NativeChatBlock = { type: 'tool-result', output: 'CHILD_REPLY', callId: 'w' } + const ran: NativeChatBlock = { type: 'tool-result', output: 'CHILD_DONE', callId: 'x' } + expect(pairToolBlocks([spawn, wait, waited, shell, ran])).toEqual([ + { call: spawn }, + { call: wait, result: waited }, + { call: shell, result: ran } + ]) + }) + it('keeps a leading stray result and then stops at the limit', () => { const blocks = [result, call, result, call, result] expect(pairToolBlocks(blocks, 1)).toEqual(original(blocks, 1)) From 69277a679750a00093bad27179dea007186773e3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:17:11 -0700 Subject: [PATCH 12/24] 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. --- .../codex/codex-background-task-frames.ts | 20 ++--------- src/main/codex/codex-child-work-evidence.ts | 9 ++--- .../codex/codex-collab-agent-tool-call.ts | 7 ---- .../codex/codex-default-mode-helpers.test.ts | 34 ++++++++++++------- src/main/codex/codex-subagent-roster.ts | 2 +- 5 files changed, 28 insertions(+), 44 deletions(-) diff --git a/src/main/codex/codex-background-task-frames.ts b/src/main/codex/codex-background-task-frames.ts index d23fd7bbb3b4..4659e8767fa4 100644 --- a/src/main/codex/codex-background-task-frames.ts +++ b/src/main/codex/codex-background-task-frames.ts @@ -1,9 +1,5 @@ import type { NativeChatSubagentState } from '../../shared/native-chat-types' import { readCodexSubagentAnnouncement } from './codex-subagent-activity' -import { - codexCollabClosedThread, - readCodexCollabAgentToolCall -} from './codex-collab-agent-tool-call' import { codexChildTurnState } from './codex-subagent-executions' import { readRecord } from './codex-item-field-readers' import { readCodexThreadItem } from './codex-structured-item-translation' @@ -25,15 +21,14 @@ export type CodexBackgroundTaskFrame = state: NativeChatSubagentState } | { - /** A child turn that ended with no `turn/completed`. No `turnId`: the one it is running. - * `threadId` is the child's, which for a `closeAgent` is not the thread that sent it. */ + /** A child turn that ended with no `turn/completed`. No `turnId`: the one it is running. */ kind: 'turn-ended' threadId: string turnId: string | null state: CodexChildTurnEnding } -type CodexChildTurnEnding = Extract +type CodexChildTurnEnding = Extract export type CodexBackgroundTaskEvent = { method: string @@ -91,16 +86,7 @@ export function readCodexBackgroundTaskFrame( return null } const item = readCodexThreadItem(readRecord(event.params).item) - if (!item) { - return null - } - const call = readCodexCollabAgentToolCall(item) - const closed = call && codexCollabClosedThread(call) - if (closed && closed !== primaryThreadId) { - // The caller shut the helper down: whatever turn it was running is over, stopped by its caller. - return { kind: 'turn-ended', threadId: closed, turnId: null, state: 'stopped' } - } - const announcement = readCodexSubagentAnnouncement(item) + const announcement = item && readCodexSubagentAnnouncement(item) if (!announcement || announcement.agentThreadId === primaryThreadId) { return null } diff --git a/src/main/codex/codex-child-work-evidence.ts b/src/main/codex/codex-child-work-evidence.ts index e7d26de45610..93bda7a54a1f 100644 --- a/src/main/codex/codex-child-work-evidence.ts +++ b/src/main/codex/codex-child-work-evidence.ts @@ -129,15 +129,12 @@ export class CodexChildWorkEvidence { event: CodexBackgroundTaskEvent, frame: CodexBackgroundTaskFrame | null ): string | null { - // A frame names its child, which a caller's spawn or close is not sent on. const threadId = frame?.kind === 'subagent' ? frame.agentThreadId - : frame - ? frame.threadId - : CHILD_FRAME_METHODS.has(event.method) - ? event.threadId - : null + : frame || CHILD_FRAME_METHODS.has(event.method) + ? event.threadId + : null return threadId === this.primaryThreadId ? null : threadId } diff --git a/src/main/codex/codex-collab-agent-tool-call.ts b/src/main/codex/codex-collab-agent-tool-call.ts index c255228345b5..65525e9b6ca2 100644 --- a/src/main/codex/codex-collab-agent-tool-call.ts +++ b/src/main/codex/codex-collab-agent-tool-call.ts @@ -66,13 +66,6 @@ export function codexCollabSpawnedThread(call: CodexCollabAgentToolCall): string return call.tool === 'spawnAgent' ? (call.receiverThreadIds[0] ?? null) : null } -/** The helper a finished `closeAgent` shut down. A failed close left it running. */ -export function codexCollabClosedThread(call: CodexCollabAgentToolCall): string | null { - return call.tool === 'closeAgent' && call.status === 'completed' - ? (call.receiverThreadIds[0] ?? null) - : null -} - /** A helper's row label: the head of the prompt it was spawned with, on one line. */ export function codexCollabHelperLabel(prompt: string | null): string | null { const collapsed = prompt === null ? '' : collapsedToolInputPrefix(prompt) diff --git a/src/main/codex/codex-default-mode-helpers.test.ts b/src/main/codex/codex-default-mode-helpers.test.ts index e038e844a3be..28e09b5d46e8 100644 --- a/src/main/codex/codex-default-mode-helpers.test.ts +++ b/src/main/codex/codex-default-mode-helpers.test.ts @@ -394,18 +394,37 @@ describe('Codex default-mode helpers', () => { expect(run.strip()).toHaveLength(1) }) - it('ends a running helper its caller closed as cancelled, in the strip and the record together', async () => { + it("ends a closed helper only on its own interrupted turn, never on the caller's close item", async () => { const run = await session() run.send( turn('turn/started', THREAD_ID, PARENT_TURN), spawnCompleted, turn('turn/started', HELPER, HELPER_TURN), - closeCompleted + // What Codex reports for a close that failed on a running helper: the status is the + // helper's, not the close's, so `completed` says nothing about whether it stopped. + collab('item/completed', { + id: 'call-close-errored', + tool: 'closeAgent', + status: 'completed', + receiverThreadIds: [HELPER], + agentsStates: { [HELPER]: { status: 'running', message: null } } + }) ) + expect(run.agents()).toEqual([expect.objectContaining({ membership: 'live' })]) + expect(run.strip()).toHaveLength(1) + expect(run.rosterRows().at(-1)?.agents).toEqual([ + expect.objectContaining({ id: HELPER, state: 'working' }) + ]) + + // A close that works aborts the helper's turn, which Codex reports on the helper's thread. + run.send(closeCompleted, turn('turn/completed', HELPER, HELPER_TURN, 'interrupted')) expect(run.agents()).toEqual([ expect.objectContaining({ membership: 'settled', outcome: 'cancelled' }) ]) expect(run.strip()).toEqual([]) + expect(run.rosterRows().at(-1)?.agents).toEqual([ + expect.objectContaining({ id: HELPER, state: 'stopped' }) + ]) expect(run.toolRow('close_agent')).toMatchObject({ state: 'completed', input: { description: LABEL } @@ -496,17 +515,6 @@ describe('the roster row follows a helper whose turn ends with no turn/completed expect(lastRow(run)).toEqual([expect.objectContaining({ id: HELPER, state: rowState })]) } ) - - it('settles the row when its caller closes the helper', async () => { - const run = await session() - run.send( - turn('turn/started', THREAD_ID, PARENT_TURN), - spawnCompleted, - turn('turn/started', HELPER, HELPER_TURN), - closeCompleted - ) - expect(lastRow(run)).toEqual([expect.objectContaining({ id: HELPER, state: 'stopped' })]) - }) }) describe('a restored thread', () => { diff --git a/src/main/codex/codex-subagent-roster.ts b/src/main/codex/codex-subagent-roster.ts index 09bef7e1f6c0..a641eb9f5584 100644 --- a/src/main/codex/codex-subagent-roster.ts +++ b/src/main/codex/codex-subagent-roster.ts @@ -101,7 +101,7 @@ export class CodexSubagentRoster { this.now = deps.now ?? (() => Date.now()) this.executions = deps.executions ?? new CodexSubagentExecutions() // The row follows the executions, so every frame that ends a child's turn — its own - // `turn/completed`, a fatal error, its thread closing, its caller closing it — settles it. + // `turn/completed`, a fatal error, its thread closing — settles it. // A refused write clears `lastSerialized`, so the next write of the group retries it. this.unfollow = this.executions.onExecutionChanged( (child) => child.execution && this.follow(child, child.execution) From d5737e65fcfbcc920f4592282da9d1cdafe9bcf0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:18:39 -0700 Subject: [PATCH 13/24] 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`. --- src/shared/native-chat-tool-fold.ts | 12 ++++---- .../native-chat-tool-pair-limit.test.ts | 16 +++++++++++ src/shared/native-chat-tool-pairing.test.ts | 9 ++++++ src/shared/native-chat-tool-pairing.ts | 28 +++++++++++++------ 4 files changed, 50 insertions(+), 15 deletions(-) diff --git a/src/shared/native-chat-tool-fold.ts b/src/shared/native-chat-tool-fold.ts index 40d8b95e57fc..8b8f7b941cd6 100644 --- a/src/shared/native-chat-tool-fold.ts +++ b/src/shared/native-chat-tool-fold.ts @@ -11,6 +11,7 @@ import { import { agentJournalItemSubagentId } from './agent-session-journal-producer' import { isKnownHarnessInjectedUserTurnText } from './harness-injected-user-turns' import { isNoiseMessage } from './native-chat-noise' +import { answeredToolCallIndex } from './native-chat-tool-pairing' function isToolOnlyMessage(message: NativeChatMessage): boolean { return ( @@ -153,8 +154,8 @@ export type NativeChatToolPair = { result?: NativeChatToolResultBlock } -/** Pair each result with the call it names, else with the oldest unanswered call: the rule - * `pairNativeChatToolResults` uses, so every reader of a run agrees on who owns an output. */ +/** Pair results to calls by `answeredToolCallIndex`, the rule `pairNativeChatToolResults` uses, + * so every reader of a run agrees on who owns an output. */ export function pairToolBlocks( blocks: readonly NativeChatBlock[], limit = Infinity @@ -176,11 +177,8 @@ export function pairToolBlocks( if (block.type !== 'tool-result') { continue } - const named = - block.callId === undefined - ? -1 - : callSlots.findIndex((slot) => pairs[slot]?.call?.callId === block.callId) - const [slot] = callSlots.splice(named === -1 ? 0 : named, 1) + const answered = answeredToolCallIndex(callSlots, block, (slot) => pairs[slot]?.call?.callId) + const [slot] = answered === -1 ? [] : callSlots.splice(answered, 1) if (slot === undefined) { if (pairs.length < limit) { pairs.push({ result: block }) diff --git a/src/shared/native-chat-tool-pair-limit.test.ts b/src/shared/native-chat-tool-pair-limit.test.ts index 5fd7795bb337..d54bfc9b89ba 100644 --- a/src/shared/native-chat-tool-pair-limit.test.ts +++ b/src/shared/native-chat-tool-pair-limit.test.ts @@ -98,6 +98,22 @@ describe('tool pair limits', () => { ]) }) + it('gives no retained call the output of a call past the limit', () => { + const named = (callId: string): NativeChatBlock => ({ + type: 'tool-call', + name: 'Bash', + input: {}, + callId + }) + const [spawn, shell, later] = [named('s'), named('x'), named('y')] + const ran: NativeChatBlock = { type: 'tool-result', output: 'x', callId: 'x' } + const laterRan: NativeChatBlock = { type: 'tool-result', output: 'y', callId: 'y' } + expect(pairToolBlocks([spawn, shell, ran, later, laterRan], 2)).toEqual([ + { call: spawn }, + { call: shell, result: ran } + ]) + }) + it('keeps a leading stray result and then stops at the limit', () => { const blocks = [result, call, result, call, result] expect(pairToolBlocks(blocks, 1)).toEqual(original(blocks, 1)) diff --git a/src/shared/native-chat-tool-pairing.test.ts b/src/shared/native-chat-tool-pairing.test.ts index 4b710f328129..72d7c46d656a 100644 --- a/src/shared/native-chat-tool-pairing.test.ts +++ b/src/shared/native-chat-tool-pairing.test.ts @@ -60,6 +60,15 @@ describe('pairNativeChatToolResults', () => { expect(resultByCall.get(shell)).toBe(ran) }) + it('leaves a result that names a call not in the run unpaired, rather than give it to another', () => { + const waiting: NativeChatToolCallBlock = { ...call('spawn'), callId: 's' } + const stray: NativeChatToolResultBlock = { ...result('elsewhere'), callId: 'missing' } + const { resultByCall, pairedResults } = pairNativeChatToolResults([waiting, stray]) + + expect(resultByCall.has(waiting)).toBe(false) + expect(pairedResults.has(stray)).toBe(false) + }) + it('ignores blocks that are neither a call nor a result', () => { const text: NativeChatBlock = { type: 'text', text: 'hi' } const [a, ra] = [call('a'), result('r')] diff --git a/src/shared/native-chat-tool-pairing.ts b/src/shared/native-chat-tool-pairing.ts index fe816ee79530..70acdb918111 100644 --- a/src/shared/native-chat-tool-pairing.ts +++ b/src/shared/native-chat-tool-pairing.ts @@ -7,11 +7,12 @@ // as the command that produced it. Pairing lets the call own its output, so the // run reads as the work it did. // -// A result that names its call answers that call. One that does not is paired -// positionally, the same FIFO rule `dropUnattributableToolResults` already uses to -// decide a result is attributable at all: it answers the oldest call that has not -// been answered yet. Position alone misattributes every later result once one call -// finishes with no output, which is why a producer that knows the call names it. +// A result that names its call answers that call, and no other: one naming a call +// that is not waiting (outside a bounded window, say) stays unpaired. One that names +// none is paired positionally, the same FIFO rule `dropUnattributableToolResults` +// already uses to decide a result is attributable at all: it answers the oldest call +// that has not been answered yet. Position alone misattributes every later result once +// one call finishes with no output, which is why a producer that knows the call names it. import { isToolCallBlock, @@ -33,6 +34,18 @@ export const NO_NATIVE_CHAT_TOOL_PAIRING: NativeChatToolPairing = { pairedResults: new Set() } +/** Where in `unanswered` (oldest first) the call `result` answers is, or -1 for none. */ +export function answeredToolCallIndex( + unanswered: readonly T[], + result: NativeChatToolResultBlock, + callIdOf: (entry: T) => string | undefined +): number { + if (result.callId === undefined) { + return unanswered.length > 0 ? 0 : -1 + } + return unanswered.findIndex((entry) => callIdOf(entry) === result.callId) +} + export function pairNativeChatToolResults( blocks: readonly NativeChatBlock[] ): NativeChatToolPairing { @@ -47,9 +60,8 @@ export function pairNativeChatToolResults( if (!isToolResultBlock(block)) { continue } - const named = - block.callId === undefined ? -1 : unanswered.findIndex((call) => call.callId === block.callId) - const [call] = unanswered.splice(named === -1 ? 0 : named, 1) + const answered = answeredToolCallIndex(unanswered, block, (call) => call.callId) + const [call] = answered === -1 ? [] : unanswered.splice(answered, 1) if (call === undefined) { continue } From e387b161a1fd4b9e2f9a9a175d8dbbe40b1c9b85 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:19:10 -0700 Subject: [PATCH 14/24] test(native-chat): name the Codex default-mode test file after the subagents it covers --- ...-mode-helpers.test.ts => codex-default-mode-subagents.test.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/main/codex/{codex-default-mode-helpers.test.ts => codex-default-mode-subagents.test.ts} (100%) diff --git a/src/main/codex/codex-default-mode-helpers.test.ts b/src/main/codex/codex-default-mode-subagents.test.ts similarity index 100% rename from src/main/codex/codex-default-mode-helpers.test.ts rename to src/main/codex/codex-default-mode-subagents.test.ts From 3b74022f324b25f32e5685382fd8c21531b6e7e1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:08:01 -0700 Subject: [PATCH 15/24] 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. --- .../codex/codex-background-task-frames.ts | 41 +++--- .../codex-background-task-tracker.test.ts | 6 +- .../codex/codex-background-task-tracker.ts | 16 ++- src/main/codex/codex-child-work-evidence.ts | 30 ++--- .../codex-collab-agent-item-translation.ts | 5 +- .../codex/codex-collab-agent-tool-call.ts | 21 +-- .../codex-default-mode-subagents.test.ts | 126 +++++++++++++++++- ...-structured-journal-translation-restore.ts | 6 +- src/main/codex/codex-subagent-activity.ts | 72 ++++++---- src/main/codex/codex-subagent-roster.ts | 35 ++--- 10 files changed, 249 insertions(+), 109 deletions(-) diff --git a/src/main/codex/codex-background-task-frames.ts b/src/main/codex/codex-background-task-frames.ts index 4659e8767fa4..26f30f9ad4d2 100644 --- a/src/main/codex/codex-background-task-frames.ts +++ b/src/main/codex/codex-background-task-frames.ts @@ -1,18 +1,23 @@ import type { NativeChatSubagentState } from '../../shared/native-chat-types' -import { readCodexSubagentAnnouncement } from './codex-subagent-activity' +import { readCodexSubagentAnnouncements } from './codex-subagent-activity' import { codexChildTurnState } from './codex-subagent-executions' import { readRecord } from './codex-item-field-readers' import { readCodexThreadItem } from './codex-structured-item-translation' import { readCodexErrorWillRetry, readCodexTurnId } from './codex-structured-thread-facts' +export type CodexAnnouncedChild = { + agentThreadId: string + label: string | null + parentTurnId: string | null | undefined + /** The reporting thread, for a spawn: the agent that spawned the child. */ + spawnerThreadId: string | undefined +} + export type CodexBackgroundTaskFrame = | { - kind: 'subagent' - agentThreadId: string - label: string | null - parentTurnId: string | null | undefined - /** The reporting thread, for a spawn: the agent that spawned the child. */ - spawnerThreadId: string | undefined + /** An item naming children, several for a call on more than one helper. */ + kind: 'subagents' + children: CodexAnnouncedChild[] } | { kind: 'turn' @@ -86,16 +91,14 @@ export function readCodexBackgroundTaskFrame( return null } const item = readCodexThreadItem(readRecord(event.params).item) - const announcement = item && readCodexSubagentAnnouncement(item) - if (!announcement || announcement.agentThreadId === primaryThreadId) { - return null - } - return { - kind: 'subagent', - agentThreadId: announcement.agentThreadId, - label: announcement.label, - parentTurnId: announcement.namesParentTurn ? readCodexTurnId(event.params) : undefined, - // Only a spawn names the spawner: other announcements ride whichever agent acted. - spawnerThreadId: announcement.spawned ? event.threadId : undefined - } + const children = (item ? readCodexSubagentAnnouncements(item, primaryThreadId) : []).map( + (announcement) => ({ + agentThreadId: announcement.agentThreadId, + label: announcement.label, + parentTurnId: announcement.namesParentTurn ? readCodexTurnId(event.params) : undefined, + // Only a spawn names the spawner: other announcements ride whichever agent acted. + spawnerThreadId: announcement.spawned ? event.threadId : undefined + }) + ) + return children.length > 0 ? { kind: 'subagents', children } : null } diff --git a/src/main/codex/codex-background-task-tracker.test.ts b/src/main/codex/codex-background-task-tracker.test.ts index 0a70442a0b37..6c32896140b3 100644 --- a/src/main/codex/codex-background-task-tracker.test.ts +++ b/src/main/codex/codex-background-task-tracker.test.ts @@ -76,10 +76,8 @@ function command( describe('readCodexBackgroundTaskFrame', () => { it('reads activity as child metadata without inferring execution state', () => { expect(readCodexBackgroundTaskFrame(activity('interacted'), PRIMARY)).toEqual({ - kind: 'subagent', - agentThreadId: CHILD, - label: 'count_a', - parentTurnId: PARENT_TURN + kind: 'subagents', + children: [{ agentThreadId: CHILD, label: 'count_a', parentTurnId: PARENT_TURN }] }) }) diff --git a/src/main/codex/codex-background-task-tracker.ts b/src/main/codex/codex-background-task-tracker.ts index 351b35e6b84a..22214159eb5d 100644 --- a/src/main/codex/codex-background-task-tracker.ts +++ b/src/main/codex/codex-background-task-tracker.ts @@ -73,13 +73,15 @@ export class CodexBackgroundTaskTracker { : []) ] const frame = readCodexBackgroundTaskFrame(event, this.primaryThreadId) - if (frame?.kind === 'subagent') { - this.executions.register( - frame.agentThreadId, - frame.label, - frame.parentTurnId, - frame.spawnerThreadId - ) + if (frame?.kind === 'subagents') { + for (const child of frame.children) { + this.executions.register( + child.agentThreadId, + child.label, + child.parentTurnId, + child.spawnerThreadId + ) + } } else if (frame?.kind === 'turn-ended') { this.executions.endTurn(frame.threadId, frame.turnId, frame.state) } else if (frame && frame.threadId !== this.primaryThreadId) { diff --git a/src/main/codex/codex-child-work-evidence.ts b/src/main/codex/codex-child-work-evidence.ts index 93bda7a54a1f..af946afeedc0 100644 --- a/src/main/codex/codex-child-work-evidence.ts +++ b/src/main/codex/codex-child-work-evidence.ts @@ -100,15 +100,13 @@ export class CodexChildWorkEvidence { commands: readonly CodexBackgroundCommandChange[] ): void { this.queueCommands(commands) - const threadId = this.childThread(event, frame) - if (threadId === null) { - return - } - const facts = this.factsFor(threadId) - if (facts && event.threadId === threadId) { - this.record(facts, event) + for (const threadId of this.childThreads(event, frame)) { + const facts = this.factsFor(threadId) + if (facts && event.threadId === threadId) { + this.record(facts, event) + } + this.queueChild(threadId) } - this.queueChild(threadId) } /** The provider session is gone, with the commands it ended: no child it still ran can report @@ -125,17 +123,17 @@ export class CodexChildWorkEvidence { return pending.map((edge) => edge(observedAt)) } - private childThread( + private childThreads( event: CodexBackgroundTaskEvent, frame: CodexBackgroundTaskFrame | null - ): string | null { - const threadId = - frame?.kind === 'subagent' - ? frame.agentThreadId + ): string[] { + const threadIds = + frame?.kind === 'subagents' + ? frame.children.map((child) => child.agentThreadId) : frame || CHILD_FRAME_METHODS.has(event.method) - ? event.threadId - : null - return threadId === this.primaryThreadId ? null : threadId + ? [event.threadId] + : [] + return threadIds.filter((threadId) => threadId !== this.primaryThreadId) } /** A command belongs to the child thread that launched it; the session's own agent is no owner. diff --git a/src/main/codex/codex-collab-agent-item-translation.ts b/src/main/codex/codex-collab-agent-item-translation.ts index 80f921181a9f..a940d2fb18fe 100644 --- a/src/main/codex/codex-collab-agent-item-translation.ts +++ b/src/main/codex/codex-collab-agent-item-translation.ts @@ -20,12 +20,11 @@ import { readString } from './codex-item-field-readers' import { codexItemRunState } from './codex-item-run-state' import type { CodexThreadItem } from './codex-thread-item-identity' -/** The roster's name for a helper thread, or null for one it never registered. */ +/** The roster's name for a helper thread, or null for one it holds no name for. */ export type CodexHelperName = (threadId: string) => string | null /** Who the call acted on. A spawn names its helper by its prompt until the roster holds the - * thread it became; a helper the roster never registered (a restored thread) is named by its - * thread id. */ + * thread it became; a helper with no name (its spawn was never seen) is named by its thread id. */ function helperNames(call: CodexCollabAgentToolCall, helperName?: CodexHelperName): string { if (call.tool === 'spawnAgent') { const spawned = call.receiverThreadIds[0] diff --git a/src/main/codex/codex-collab-agent-tool-call.ts b/src/main/codex/codex-collab-agent-tool-call.ts index 65525e9b6ca2..46f398cf7e6d 100644 --- a/src/main/codex/codex-collab-agent-tool-call.ts +++ b/src/main/codex/codex-collab-agent-tool-call.ts @@ -5,13 +5,16 @@ // Shapes are from the app-server's generated schema (0.155) and a live default-mode session: // * `spawnAgent` starts with `receiverThreadIds: []`. The helper's thread id first appears when // the call ends, beside the `prompt` it was given — even when the call ends `failed`. +// * Every other call names its helpers from its start. A finished call reports a thread id it +// does not know as `notFound`; any other state says the helper exists. // * Codex core also knows each receiver's nickname and role, but the app-server item does not -// carry them yet; `readCodexSubagentAnnouncement` is where they would be adopted. +// carry them yet; `readCodexSubagentAnnouncements` is where they would be adopted. // * The item names no nickname or task path, so the prompt is the only text that tells one // helper from another. // * `agentsStates` is the caller's last-known snapshot of each receiver. The helper's own turn -// frames own its execution, so nothing here reads it as state; its `message` is what the -// helper said back, which the call's row shows as output. +// frames own its execution, so nothing here reads it as execution state: only `notFound`, +// which says the receiver is no helper, and `message`, what the helper said back, which the +// call's row shows as output. import { collapsedToolInputPrefix } from '../../shared/native-chat-tool-preview-prefix' import { readRecord, readString } from './codex-item-field-readers' @@ -29,6 +32,8 @@ export type CodexCollabAgentToolCall = { /** `inProgress`, `completed`, `failed` or `interrupted`. */ status: string | null receiverThreadIds: string[] + /** The receivers that are helpers: every one but those the call reports `notFound`. */ + helperThreadIds: string[] prompt: string | null /** What each receiver said back, in `receiverThreadIds` order. */ replies: { threadId: string; message: string }[] @@ -54,18 +59,14 @@ export function readCodexCollabAgentToolCall( tool, status: readString(item, 'status'), receiverThreadIds, + helperThreadIds: receiverThreadIds.filter( + (threadId) => readString(readRecord(states[threadId]), 'status') !== 'notFound' + ), prompt: readString(item, 'prompt'), replies } } -/** The helper a `spawnAgent` created, whatever the call's status: Codex reports `failed` for a - * helper that errored at birth, yet names the thread it created, and that thread can still run. - * A spawn in progress, or one that created nothing, names no receiver. */ -export function codexCollabSpawnedThread(call: CodexCollabAgentToolCall): string | null { - return call.tool === 'spawnAgent' ? (call.receiverThreadIds[0] ?? null) : null -} - /** A helper's row label: the head of the prompt it was spawned with, on one line. */ export function codexCollabHelperLabel(prompt: string | null): string | null { const collapsed = prompt === null ? '' : collapsedToolInputPrefix(prompt) diff --git a/src/main/codex/codex-default-mode-subagents.test.ts b/src/main/codex/codex-default-mode-subagents.test.ts index 28e09b5d46e8..27c9cb37de17 100644 --- a/src/main/codex/codex-default-mode-subagents.test.ts +++ b/src/main/codex/codex-default-mode-subagents.test.ts @@ -1,6 +1,7 @@ -// Codex's default multi-agent mode announces a helper only by the `collabAgentToolCall` that -// spawned it. These frames, through the real adapter, must register that helper the same way a -// `subAgentActivity` does: one child in the strip, the host's records and the roster row. +// Codex's default multi-agent mode announces a helper only by the `collabAgentToolCall` items that +// spawn, message, wait on or close it. These frames, through the real adapter, must register that +// helper the same way a `subAgentActivity` does: one child in the strip, the host's records and +// the roster row. import { describe, expect, it } from 'vitest' import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' @@ -15,6 +16,8 @@ import { CodexStructuredSessionAdapter } from './codex-structured-session-adapte import { CodexBackgroundTaskTracker } from './codex-background-task-tracker' import { createCodexJournalTranslator } from './codex-structured-journal-translation' import { CodexSubagentExecutions } from './codex-subagent-executions' +import { readCodexSubagentAnnouncements } from './codex-subagent-activity' +import type { CodexThreadItem } from './codex-thread-item-identity' const parent = makeStructuredAgentStatusSubject( { @@ -483,6 +486,112 @@ describe('Codex default-mode helpers', () => { }) }) +describe('a helper whose spawn was never seen', () => { + const sendInput = collab('item/completed', { + id: 'call-send-input', + tool: 'sendInput', + status: 'completed', + receiverThreadIds: [HELPER], + prompt: 'Now run it again.', + agentsStates: { [HELPER]: { status: 'running', message: null } } + }) + + it("registers from any call that names it, so its shell is no longer the session's own", async () => { + const run = await session() + run.send( + turn('turn/started', THREAD_ID, PARENT_TURN), + turn('turn/started', HELPER, HELPER_TURN), + helperShell('item/started') + ) + expect(run.strip()).toEqual([expect.objectContaining({ kind: 'command' })]) + + run.send(sendInput) + // No spawn named it, so it reads as any unnamed subagent does. + expect(run.strip()).toEqual([{ id: `codex-agent:${HELPER}`, kind: 'agent' }]) + expect(run.agents()).toEqual([ + expect.objectContaining({ membership: 'live', state: 'working' }) + ]) + expect(run.commands()).toEqual([ + expect.objectContaining({ + membership: 'live', + parentChildWorkId: run.agents()[0]?.childWorkId + }) + ]) + expect(run.rosterRows().at(-1)?.agents).toEqual([ + expect.objectContaining({ id: HELPER, label: 'subagent', state: 'working' }) + ]) + expect(run.toolRow('send_input')).toMatchObject({ input: { description: HELPER } }) + }) + + it('registers each helper a call on several names', async () => { + const second = 'helper-two' + const run = await session() + run.send( + turn('turn/started', THREAD_ID, PARENT_TURN), + turn('turn/started', HELPER, HELPER_TURN), + turn('turn/started', second, 'helper-two-turn'), + collab('item/started', { + id: 'call-wait-both', + tool: 'wait', + status: 'inProgress', + receiverThreadIds: [HELPER, second] + }) + ) + expect(run.strip()).toEqual([ + { id: `codex-agent:${HELPER}`, kind: 'agent' }, + { id: `codex-agent:${second}`, kind: 'agent' } + ]) + expect(run.agents()).toEqual([ + expect.objectContaining({ membership: 'live' }), + expect.objectContaining({ membership: 'live' }) + ]) + expect(run.rosterRows().at(-1)?.agents).toEqual([ + expect.objectContaining({ id: HELPER, label: 'subagent' }), + expect.objectContaining({ id: second, label: 'subagent 2' }) + ]) + }) + + it("puts a helper's run in the turn that sent it work, even after that turn ended", async () => { + const run = await session() + run.send( + turn('turn/started', THREAD_ID, PARENT_TURN), + spawnCompleted, + turn('turn/started', HELPER, HELPER_TURN), + turn('turn/completed', HELPER, HELPER_TURN), + turn('turn/completed', THREAD_ID, PARENT_TURN), + turn('turn/started', THREAD_ID, 'parent-turn-2'), + { ...sendInput, params: { ...sendInput.params, turnId: 'parent-turn-2' } }, + turn('turn/completed', THREAD_ID, 'parent-turn-2'), + turn('turn/started', HELPER, 'helper-turn-2') + ) + const groups = new Set(run.rosterRows().map((row) => row.groupId)) + expect(groups.size).toBe(2) + expect(run.rosterRows().at(-1)?.agents).toEqual([ + expect.objectContaining({ id: HELPER, state: 'working' }) + ]) + }) + + it('registers every helper a call names, and none it reports not found', () => { + const [second, missing] = ['helper-two', 'helper-missing'] + const waited: CodexThreadItem = { + type: 'collabAgentToolCall', + id: 'call-wait-many', + tool: 'wait', + status: 'completed', + receiverThreadIds: [HELPER, second, missing], + agentsStates: { + [HELPER]: { status: 'completed', message: 'done' }, + [second]: { status: 'running', message: null }, + [missing]: { status: 'notFound', message: null } + } + } + expect(readCodexSubagentAnnouncements(waited, THREAD_ID)).toEqual([ + { agentThreadId: HELPER, label: null, namesParentTurn: false, spawned: false }, + { agentThreadId: second, label: null, namesParentTurn: false, spawned: false } + ]) + }) +}) + describe('the roster row follows a helper whose turn ends with no turn/completed', () => { const lastRow = (run: Awaited>) => run.rosterRows().at(-1)?.agents const fatal: Frame = { @@ -518,7 +627,7 @@ describe('the roster row follows a helper whose turn ends with no turn/completed }) describe('a restored thread', () => { - function restored() { + function restored(replayed = [spawnCompleted, waitCompleted]) { const executions = new CodexSubagentExecutions() const rows = new Map() const translator = createCodexJournalTranslator({ @@ -535,9 +644,8 @@ describe('a restored thread', () => { } }) const tracker = new CodexBackgroundTaskTracker(THREAD_ID, executions) - const replayed = [spawnCompleted, waitCompleted].map((frame) => frame.params.item) const admission = translator.restoreThread(THREAD_ID, { - turns: [{ id: PARENT_TURN, items: replayed }] + turns: [{ id: PARENT_TURN, items: replayed.map((frame) => frame.params.item) }] }) expect(admission).toEqual({ accepted: true }) const bodies = () => [...rows.values()] @@ -573,4 +681,10 @@ describe('a restored thread', () => { ) ).toEqual([]) }) + + it('knows a helper whose spawn history compacted away, from a call on it', () => { + const { tracker } = restored([waitCompleted]) + tracker.observe({ ...turn('turn/started', HELPER, HELPER_TURN), threadId: HELPER }) + expect(tracker.state?.tasks).toEqual([{ id: `codex-agent:${HELPER}`, kind: 'agent' }]) + }) }) diff --git a/src/main/codex/codex-structured-journal-translation-restore.ts b/src/main/codex/codex-structured-journal-translation-restore.ts index b5339ccde509..f2a104315180 100644 --- a/src/main/codex/codex-structured-journal-translation-restore.ts +++ b/src/main/codex/codex-structured-journal-translation-restore.ts @@ -4,7 +4,7 @@ import type { CodexJournalCompactions } from './codex-structured-journal-compact import type { CodexJournalItems } from './codex-structured-journal-items' import { readCodexNotificationThreadItem, - readCodexSubagentAnnouncement + readCodexSubagentAnnouncements } from './codex-subagent-activity' import type { CodexSubagentExecutions } from './codex-subagent-executions' import { @@ -34,8 +34,8 @@ export function restoreCodexHistoryItem( } ): CodexJournalTranslationAdmission { const item = readCodexNotificationThreadItem(event.params, readCodexThreadItem) - const announcement = item && readCodexSubagentAnnouncement(item) - if (announcement && announcement.agentThreadId !== input.primaryThreadId) { + const announcements = item ? readCodexSubagentAnnouncements(item, input.primaryThreadId) : [] + for (const announcement of announcements) { input.executions.register(announcement.agentThreadId, announcement.label, undefined) } const compaction = input.compactions.handle(event) diff --git a/src/main/codex/codex-subagent-activity.ts b/src/main/codex/codex-subagent-activity.ts index 343f7e82be2e..0a00297dff8a 100644 --- a/src/main/codex/codex-subagent-activity.ts +++ b/src/main/codex/codex-subagent-activity.ts @@ -7,15 +7,15 @@ // segment is a semantic task name and the only label available. There is no // `thread/started` for a child, so nickname/role/depth do not exist. // * Codex's DEFAULT multi-agent mode sends no `subAgentActivity` at all; a -// helper appears only as the `collabAgentToolCall` that spawned it (read in -// `codex-collab-agent-tool-call.ts`). Either item announces the same child, -// keyed by its thread id, and child turn events own its execution state. +// helper appears only in the `collabAgentToolCall` items that spawn, message, +// wait on or close it (read in `codex-collab-agent-tool-call.ts`). Either item +// announces the same child, keyed by its thread id, and child turn events own +// its execution state. // * `thread/tokenUsage/updated` reports a per-thread RUNNING TOTAL, so the // latest frame replaces the previous one — it is never accumulated. import { codexCollabHelperLabel, - codexCollabSpawnedThread, readCodexCollabAgentToolCall } from './codex-collab-agent-tool-call' import type { CodexThreadItem } from './codex-thread-item-identity' @@ -102,32 +102,54 @@ export type CodexSubagentAnnouncement = { spawned: boolean } -/** The child a `subAgentActivity` item (the tree root excluded) or an ended `spawnAgent` call - * announces. Both name the child by its thread id, so a session sending both announces one. */ -export function readCodexSubagentAnnouncement( - item: CodexThreadItem -): CodexSubagentAnnouncement | null { +/** Collab calls that hand a helper work from the caller's turn, as an `interacted` activity does. */ +const CODEX_COLLAB_TOOLS_THAT_TASK_A_HELPER: ReadonlySet = new Set([ + 'spawnAgent', + 'sendInput' +]) + +/** The children an item announces: a `subAgentActivity`'s, or every helper a collab call names, + * keyed by thread id so repeats announce one child. Neither the tree root nor the session's own + * thread is a child. Any call counts, since a helper whose spawn was never seen (compacted out of + * history) is known only from the calls on it. A spawn counts whatever its status: Codex reports + * `failed` for a helper that errored at birth, yet names the thread it created, and that thread + * can still run. */ +export function readCodexSubagentAnnouncements( + item: CodexThreadItem, + primaryThreadId: string | null +): CodexSubagentAnnouncement[] { + return readAnnouncements(item).filter(({ agentThreadId }) => agentThreadId !== primaryThreadId) +} + +function readAnnouncements(item: CodexThreadItem): CodexSubagentAnnouncement[] { const activity = readCodexSubagentActivity(item) if (activity) { return isCodexRootAgentActivity(activity) - ? null - : { - agentThreadId: activity.agentThreadId, - label: codexSubagentLabel(activity), - namesParentTurn: activity.kind === 'started' || activity.kind === 'interacted', - spawned: activity.kind === 'started' - } + ? [] + : [ + { + agentThreadId: activity.agentThreadId, + label: codexSubagentLabel(activity), + namesParentTurn: activity.kind === 'started' || activity.kind === 'interacted', + spawned: activity.kind === 'started' + } + ] } const call = readCodexCollabAgentToolCall(item) - const spawned = call && codexCollabSpawnedThread(call) - return call && spawned - ? { - agentThreadId: spawned, - label: codexCollabHelperLabel(call.prompt), - namesParentTurn: true, - spawned: true - } - : null + if (!call) { + return [] + } + const spawned = call.tool === 'spawnAgent' + // Only a spawn's prompt describes its helper; another call's prompt is a later message to it, + // so a helper no spawn named reads as any unnamed subagent does. + const label = spawned ? codexCollabHelperLabel(call.prompt) : null + const namesParentTurn = CODEX_COLLAB_TOOLS_THAT_TASK_A_HELPER.has(call.tool) + return call.helperThreadIds.map((agentThreadId) => ({ + agentThreadId, + label, + namesParentTurn, + spawned + })) } export type CodexThreadTokenTotal = { threadId: string; totalTokens: number } diff --git a/src/main/codex/codex-subagent-roster.ts b/src/main/codex/codex-subagent-roster.ts index a641eb9f5584..255190afa475 100644 --- a/src/main/codex/codex-subagent-roster.ts +++ b/src/main/codex/codex-subagent-roster.ts @@ -1,8 +1,8 @@ // The Codex subagent roster: one journal row per spawn group, revised in place. // -// A spawn announcement supplies membership — a `subAgentActivity` item, or in -// Codex's default multi-agent mode the finished `spawnAgent` call — and child -// turn events supply execution state. +// An announcement supplies membership — a `subAgentActivity` item, or in +// Codex's default multi-agent mode any collab call naming the helper — and +// child turn events supply execution state. // // KNOWN LIMITATION: `groups` is process-local and is never seeded from the // journal, while the row's identity is keyed on the group id alone. So once a @@ -27,7 +27,8 @@ import type { } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { readCodexSubagentActivity, - readCodexSubagentAnnouncement, + readCodexSubagentAnnouncements, + type CodexSubagentAnnouncement, readCodexThreadTokenTotal } from './codex-subagent-activity' import { @@ -112,21 +113,24 @@ export class CodexSubagentRoster { }) } - /** Consume an item that announces a child. Null means the item is not this roster's to render: - * a `subAgentActivity` item renders as the roster row alone, while a spawn call keeps its own + /** Consume an item that announces children. Null means the item is not this roster's to render: + * a `subAgentActivity` item renders as the roster row alone, while a collab call keeps its own * row, so it is claimed only to hand back a refused write. */ handleItem(input: { threadId: string turnId: string | null item: CodexThreadItem }): StructuredAgentSessionSinkAdmission | null { - const renderedByRoster = readCodexSubagentActivity(input.item) !== null - const claimed = renderedByRoster ? ADMITTED : null - const announcement = readCodexSubagentAnnouncement(input.item) - // The root node is the parent turn itself, not a child it spawned. - if (!announcement || announcement.agentThreadId === this.deps.primaryThreadId()) { - return claimed - } + const refused = readCodexSubagentAnnouncements(input.item, this.deps.primaryThreadId()) + .map((announcement) => this.announce(input, announcement)) + .find((admission) => !admission.accepted) + return refused ?? (readCodexSubagentActivity(input.item) !== null ? ADMITTED : null) + } + + private announce( + input: { threadId: string; turnId: string | null }, + announcement: CodexSubagentAnnouncement + ): StructuredAgentSessionSinkAdmission { const child = this.executions.register( announcement.agentThreadId, announcement.label, @@ -135,7 +139,7 @@ export class CodexSubagentRoster { announcement.spawned ? input.threadId : undefined ) if (!child?.execution) { - return claimed + return ADMITTED } const group = this.executionGroup(child.agentThreadId, child.execution.turnId) ?? @@ -143,8 +147,7 @@ export class CodexSubagentRoster { if (!group.entries.has(child.agentThreadId)) { this.recordExecution(group, child, child.execution) } - const admission = this.write(group) - return renderedByRoster || !admission.accepted ? admission : null + return this.write(group) } handleTurnEvent(event: { From 3ed945dcfc2a8cf5088f163a3dd68eaa433348f2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:27:12 -0700 Subject: [PATCH 16/24] 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. --- .../codex-collab-agent-item-translation.ts | 66 +++- .../codex/codex-collab-agent-tool-call.ts | 44 ++- ...ab-call-rows-on-positional-clients.test.ts | 351 ++++++++++++++++++ .../codex-structured-item-translation.ts | 8 +- .../codex/codex-structured-journal-items.ts | 8 +- 5 files changed, 446 insertions(+), 31 deletions(-) create mode 100644 src/main/codex/codex-collab-call-rows-on-positional-clients.test.ts diff --git a/src/main/codex/codex-collab-agent-item-translation.ts b/src/main/codex/codex-collab-agent-item-translation.ts index a940d2fb18fe..6bcf1cc196ee 100644 --- a/src/main/codex/codex-collab-agent-item-translation.ts +++ b/src/main/codex/codex-collab-agent-item-translation.ts @@ -33,24 +33,68 @@ function helperNames(call: CodexCollabAgentToolCall, helperName?: CodexHelperNam return call.receiverThreadIds.map((threadId) => helperName?.(threadId) ?? threadId).join(', ') } -/** What the helpers said back. One reply reads as itself; several are each put under their name. */ -function replyText(call: CodexCollabAgentToolCall, helperName?: CodexHelperName): string | null { - if (call.replies.length === 0) { +/** Codex's own words for a helper's `CollabAgentStatus`, as its UI shows them. */ +const HELPER_STATUS_TEXT = new Map([ + ['pendingInit', 'Pending init'], + ['running', 'Running'], + ['interrupted', 'Interrupted'], + ['completed', 'Completed'], + ['errored', 'Error'], + ['shutdown', 'Shutdown'], + ['notFound', 'Not found'] +]) + +const CALL_STATUS_TEXT = new Map([ + ['completed', 'Completed'], + ['failed', 'Failed'], + ['interrupted', 'Interrupted'] +]) + +/** What the call reports about one helper. Only a wait returns what its helper said; any other + * call reports the helper's status (a close's snapshot is the status it closed it in), and an + * errored helper's message is its error. */ +function helperStateText( + call: CodexCollabAgentToolCall, + state: CodexCollabAgentToolCall['states'][number] +): string | null { + if (state.message && (call.tool === 'wait' || state.status === 'errored')) { + return state.message + } + return state.status === null ? null : (HELPER_STATUS_TEXT.get(state.status) ?? state.status) +} + +/** A finished call's output, taken from the item. Every finished call has one: a client that pairs + * results by position (one predating result call ids) would otherwise draw each later output in + * the run under the call before its own. */ +function outputText(call: CodexCollabAgentToolCall, helperName?: CodexHelperName): string | null { + if (call.status === null || call.status === 'inProgress') { return null } - if (call.replies.length === 1 && call.receiverThreadIds.length === 1) { - return call.replies[0].message + const reports = call.states.flatMap((state) => { + const text = helperStateText(call, state) + return text === null ? [] : [{ threadId: state.threadId, text }] + }) + if (reports.length === 0) { + // A wait's end names only helpers that finished (none when it timed out; v2's never names any). + return call.tool === 'wait' + ? 'Finished waiting' + : (CALL_STATUS_TEXT.get(call.status) ?? call.status) + } + if (reports.length === 1 && call.receiverThreadIds.length === 1) { + return reports[0].text } - return call.replies - .map(({ threadId, message }) => `${helperName?.(threadId) ?? threadId}: ${message}`) + return reports + .map(({ threadId, text }) => `${helperName?.(threadId) ?? threadId}: ${text}`) .join('\n') } +/** `started` is the call's started item, when the caller still holds it. */ export function codexCollabAgentToolCallBody( item: CodexThreadItem, - helperName?: CodexHelperName + helperName?: CodexHelperName, + started?: CodexThreadItem ): AgentJournalItemBody | null { - const call = readCodexCollabAgentToolCall(item) + const call = readCodexCollabAgentToolCall(item, started) if (!call) { return null } @@ -65,8 +109,8 @@ export function codexCollabAgentToolCallBody( ...(reasoningEffort ? { reasoningEffort } : {}), ...(call.receiverThreadIds.length > 0 ? { agents: call.receiverThreadIds } : {}) } - const reply = replyText(call, helperName) - const output = reply === null ? null : boundInlineText(reply, DEFAULT_JOURNAL_PAYLOAD_LIMITS) + const text = outputText(call, helperName) + const output = text === null ? null : boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS) return { kind: 'tool-call', name: codexCollabToolName(call), diff --git a/src/main/codex/codex-collab-agent-tool-call.ts b/src/main/codex/codex-collab-agent-tool-call.ts index 46f398cf7e6d..fbea0f67006a 100644 --- a/src/main/codex/codex-collab-agent-tool-call.ts +++ b/src/main/codex/codex-collab-agent-tool-call.ts @@ -11,10 +11,11 @@ // carry them yet; `readCodexSubagentAnnouncements` is where they would be adopted. // * The item names no nickname or task path, so the prompt is the only text that tells one // helper from another. +// * A wait that times out ends naming no receiver and no state. // * `agentsStates` is the caller's last-known snapshot of each receiver. The helper's own turn // frames own its execution, so nothing here reads it as execution state: only `notFound`, -// which says the receiver is no helper, and `message`, what the helper said back, which the -// call's row shows as output. +// which says the receiver is no helper, and the snapshot itself, which is what the call +// reports and so what its row shows as output. import { collapsedToolInputPrefix } from '../../shared/native-chat-tool-preview-prefix' import { readRecord, readString } from './codex-item-field-readers' @@ -35,24 +36,39 @@ export type CodexCollabAgentToolCall = { /** The receivers that are helpers: every one but those the call reports `notFound`. */ helperThreadIds: string[] prompt: string | null - /** What each receiver said back, in `receiverThreadIds` order. */ - replies: { threadId: string; message: string }[] + /** Each receiver's reported snapshot, in `receiverThreadIds` order: its `CollabAgentStatus` + * (`pendingInit`, `running`, `completed`, `errored`, `shutdown`, …) and its message. */ + states: { threadId: string; status: string | null; message: string | null }[] } +function readReceiverThreadIds(item: CodexThreadItem | undefined): string[] { + return Array.isArray(item?.receiverThreadIds) + ? item.receiverThreadIds.filter((id): id is string => typeof id === 'string' && id.length > 0) + : [] +} + +/** `started` is the same call's started item, when it was seen: a call keeps naming the helpers + * it started on even when it ends naming fewer, as a timed-out wait ends naming none. */ export function readCodexCollabAgentToolCall( - item: CodexThreadItem + item: CodexThreadItem, + started?: CodexThreadItem ): CodexCollabAgentToolCall | null { const tool = readString(item, 'tool') if (item.type !== CODEX_COLLAB_AGENT_TOOL_CALL_ITEM_TYPE || tool === null) { return null } - const receiverThreadIds = Array.isArray(item.receiverThreadIds) - ? item.receiverThreadIds.filter((id): id is string => typeof id === 'string' && id.length > 0) - : [] - const states = readRecord(item.agentsStates) - const replies = receiverThreadIds.flatMap((threadId) => { - const message = readString(readRecord(states[threadId]), 'message') - return message === null ? [] : [{ threadId, message }] + const receiverThreadIds = [ + ...new Set([...readReceiverThreadIds(started), ...readReceiverThreadIds(item)]) + ] + const snapshots = readRecord(item.agentsStates) + const states = receiverThreadIds.flatMap((threadId) => { + if (!Object.hasOwn(snapshots, threadId)) { + return [] + } + const snapshot = readRecord(snapshots[threadId]) + return [ + { threadId, status: readString(snapshot, 'status'), message: readString(snapshot, 'message') } + ] }) return { id: item.id, @@ -60,10 +76,10 @@ export function readCodexCollabAgentToolCall( status: readString(item, 'status'), receiverThreadIds, helperThreadIds: receiverThreadIds.filter( - (threadId) => readString(readRecord(states[threadId]), 'status') !== 'notFound' + (threadId) => readString(readRecord(snapshots[threadId]), 'status') !== 'notFound' ), prompt: readString(item, 'prompt'), - replies + states } } diff --git a/src/main/codex/codex-collab-call-rows-on-positional-clients.test.ts b/src/main/codex/codex-collab-call-rows-on-positional-clients.test.ts new file mode 100644 index 000000000000..eb55d688b76b --- /dev/null +++ b/src/main/codex/codex-collab-call-rows-on-positional-clients.test.ts @@ -0,0 +1,351 @@ +// 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. +// So every finished collab call row must carry an output, or each later output in its run is drawn +// under the call before its own. + +import { describe, expect, it } from 'vitest' +import type { + AgentJournalItemBody, + AgentJournalRenderItem +} from '../../shared/agent-session-journal-types' +import { foldToolMessages, pairToolBlocks } from '../../shared/native-chat-tool-fold' +import { pairNativeChatToolResults } from '../../shared/native-chat-tool-pairing' +import type { NativeChatBlock, NativeChatMessage } from '../../shared/native-chat-types' +import { projectStructuredAgentSessionMessages } from '../../shared/structured-agent-session-message-projection' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { fakeCodex, identityFor, THREAD_ID } from './codex-structured-session-adapter-fixture' +import { CodexStructuredSessionAdapter } from './codex-structured-session-adapter' + +// Shapes as a live default-mode session sent them (codex-cli 0.157); only the sender is remapped. +const HELPER = '01a0ea72-80bc-7632-a97f-4a9a0d50d08f' +const PARENT_TURN = 'parent-turn-1' +const HELPER_TURN = 'helper-turn-1' +const PROMPT = 'Run exactly one foreground shell command: `sleep 150; echo LATE`.' + +type Frame = { method: string; params: Record } + +const turn = (method: 'turn/started' | 'turn/completed', threadId: string, id: string): Frame => ({ + method, + params: { threadId, turn: { id, status: 'completed' } } +}) +const item = ( + method: 'item/started' | 'item/completed', + threadId: string, + turnId: string, + fields: Record +): Frame => ({ method, params: { threadId, turnId, item: fields } }) +const collab = ( + method: 'item/started' | 'item/completed', + fields: Record +): Frame => + item(method, THREAD_ID, PARENT_TURN, { + type: 'collabAgentToolCall', + senderThreadId: THREAD_ID, + prompt: null, + model: null, + reasoningEffort: null, + agentsStates: {}, + ...fields + }) +const shell = ( + method: 'item/started' | 'item/completed', + threadId: string, + turnId: string, + id: string, + output: string +): Frame => + item(method, threadId, turnId, { + type: 'commandExecution', + id, + command: `/bin/zsh -lc 'echo ${output}'`, + cwd: '/work/repo', + status: method === 'item/started' ? 'inProgress' : 'completed', + ...(method === 'item/completed' ? { exitCode: 0, aggregatedOutput: `${output}\n` } : {}) + }) + +const spawn = (method: 'item/started' | 'item/completed'): Frame => + collab(method, { + id: 'call-spawn', + tool: 'spawnAgent', + status: method === 'item/started' ? 'inProgress' : 'completed', + receiverThreadIds: method === 'item/started' ? [] : [HELPER], + prompt: PROMPT, + model: 'gpt-5.5', + reasoningEffort: 'medium', + ...(method === 'item/completed' + ? { agentsStates: { [HELPER]: { status: 'pendingInit', message: null } } } + : {}) + }) +const waitStarted = collab('item/started', { + id: 'call-wait', + tool: 'wait', + status: 'inProgress', + receiverThreadIds: [HELPER] +}) + +async function publishedRows(frames: Frame[]): Promise { + const codex = fakeCodex() + const adapter = new CodexStructuredSessionAdapter({ + resolveLaunch: async () => ({ + command: 'codex', + args: ['app-server'], + cwd: '/work/repo', + codexHome: null, + resumeThreadId: null + }), + openConnection: codex.openConnection, + readProcessStartTime: async () => 1_700_000_000_000, + now: () => 1_700_000_000_500 + }) + // The latest revision of each row, in first-written order, as a client's journal holds it. + const rows = new Map() + const journal: StructuredAgentSessionEventSink = { + appendItem: (identity, body, options = {}) => { + const itemId = JSON.stringify(identity) + const previous = rows.get(itemId) + const sequence = previous?.sequence ?? rows.size + 1 + const { agentId, parentAgentId, producerKind } = options + rows.set(itemId, { + ...previous, + ...(agentId === undefined ? {} : { agentId }), + ...(parentAgentId === undefined ? {} : { parentAgentId }), + ...(producerKind === undefined ? {} : { producerKind }), + itemId, + sequence, + revision: (previous?.revision ?? 0) + 1, + observedAt: sequence, + body + }) + }, + appendTombstone: () => {}, + publish: () => {} + } + await adapter.acquire({ + identity: identityFor('session-1'), + fence: 7, + spawnToken: 'spawn-9', + events: journal + }) + for (const frame of frames) { + codex.connections[0]!.handlers.onNotification?.(frame.method, frame.params) + } + return [...rows.values()] +} + +/** What a client without result call ids draws: each call with the output its pairing gives it. */ +function positionalRuns(rows: AgentJournalRenderItem[]): { + mobile: [string, string | undefined][][] + desktop: [string, string | undefined][][] +} { + const withoutCallIds = (message: NativeChatMessage): NativeChatMessage => ({ + ...message, + blocks: message.blocks.map((block): NativeChatBlock => { + if (block.type !== 'tool-result') { + return block + } + const { callId: _callId, ...legacy } = block + return legacy + }) + }) + const runs = foldToolMessages( + projectStructuredAgentSessionMessages(rows, [], []).map(withoutCallIds) + ).filter((message) => message.blocks.some((block) => block.type === 'tool-call')) + return { + mobile: runs.map((message) => + pairToolBlocks(message.blocks).map( + (pair) => + [pair.call?.name ?? '(none)', pair.result?.output.trim()] as [string, string | undefined] + ) + ), + desktop: runs.map((message) => { + const pairing = pairNativeChatToolResults(message.blocks) + return message.blocks.flatMap((block) => + block.type === 'tool-call' + ? [ + [block.name, pairing.resultByCall.get(block)?.output.trim()] as [ + string, + string | undefined + ] + ] + : [] + ) + }) + } +} + +const outputOf = (rows: AgentJournalRenderItem[], name: string): AgentJournalItemBody | undefined => + rows.map((row) => row.body).find((body) => body.kind === 'tool-call' && body.name === name) + +describe('Codex collab call rows on a client that pairs results by position', () => { + it('draws each output under its own call through spawn, shell, wait and close', async () => { + const rows = await publishedRows([ + turn('turn/started', THREAD_ID, PARENT_TURN), + spawn('item/started'), + spawn('item/completed'), + turn('turn/started', HELPER, HELPER_TURN), + shell('item/started', THREAD_ID, PARENT_TURN, 'call-parent-shell', 'PARENT_DONE'), + shell('item/completed', THREAD_ID, PARENT_TURN, 'call-parent-shell', 'PARENT_DONE'), + shell('item/started', HELPER, HELPER_TURN, 'call-helper-shell', 'CHILD_DONE'), + shell('item/completed', HELPER, HELPER_TURN, 'call-helper-shell', 'CHILD_DONE'), + item('item/completed', HELPER, HELPER_TURN, { + type: 'agentMessage', + id: 'msg-child', + text: 'CHILD_REPLY' + }), + turn('turn/completed', HELPER, HELPER_TURN), + waitStarted, + collab('item/completed', { + id: 'call-wait', + tool: 'wait', + status: 'completed', + receiverThreadIds: [HELPER], + agentsStates: { [HELPER]: { status: 'completed', message: 'CHILD_REPLY' } } + }), + collab('item/started', { + id: 'call-close', + tool: 'closeAgent', + status: 'inProgress', + receiverThreadIds: [HELPER] + }), + collab('item/completed', { + id: 'call-close', + tool: 'closeAgent', + status: 'completed', + receiverThreadIds: [HELPER], + agentsStates: { [HELPER]: { status: 'completed', message: 'CHILD_REPLY' } } + }), + shell('item/started', THREAD_ID, PARENT_TURN, 'call-parent-shell-2', 'AFTER_CLOSE'), + shell('item/completed', THREAD_ID, PARENT_TURN, 'call-parent-shell-2', 'AFTER_CLOSE') + ]) + // The helper's own shell is its own run, between the parent's two. + const runs = [ + [ + ['spawn_agent', 'Pending init'], + ['shell', 'PARENT_DONE'] + ], + [['shell', 'CHILD_DONE']], + [ + ['wait_agent', 'CHILD_REPLY'], + // A close reports the status it closed the helper in, not the reply the wait showed. + ['close_agent', 'Completed'], + ['shell', 'AFTER_CLOSE'] + ] + ] + const { mobile, desktop } = positionalRuns(rows) + expect(mobile).toEqual(runs) + expect(desktop).toEqual(runs) + }) + + it('gives a wait that timed out an output, and keeps naming the helpers it waited on', async () => { + const rows = await publishedRows([ + turn('turn/started', THREAD_ID, PARENT_TURN), + spawn('item/completed'), + turn('turn/started', HELPER, HELPER_TURN), + waitStarted, + // Codex ends a timed-out wait naming no receiver and no state. + collab('item/completed', { + id: 'call-wait', + tool: 'wait', + status: 'completed', + receiverThreadIds: [], + agentsStates: {} + }), + collab('item/started', { + id: 'call-close', + tool: 'closeAgent', + status: 'inProgress', + receiverThreadIds: [HELPER] + }), + collab('item/completed', { + id: 'call-close', + tool: 'closeAgent', + status: 'completed', + receiverThreadIds: [HELPER], + agentsStates: { [HELPER]: { status: 'running', message: null } } + }), + shell('item/started', THREAD_ID, PARENT_TURN, 'call-parent-shell', 'PARENT_DONE'), + shell('item/completed', THREAD_ID, PARENT_TURN, 'call-parent-shell', 'PARENT_DONE') + ]) + expect(outputOf(rows, 'wait_agent')).toMatchObject({ + state: 'completed', + input: { description: PROMPT, agents: [HELPER] } + }) + const runs = [ + [ + ['spawn_agent', 'Pending init'], + ['wait_agent', 'Finished waiting'], + ['close_agent', 'Running'], + ['shell', 'PARENT_DONE'] + ] + ] + const { mobile, desktop } = positionalRuns(rows) + expect(mobile).toEqual(runs) + expect(desktop).toEqual(runs) + }) + + it('gives every other finished call what it reports, even on a running helper', async () => { + const OTHER = '01a0ea72-0000-7000-8000-000000000002' + const finished = (id: string, tool: string, fields: Record): Frame[] => [ + collab('item/started', { id, tool, status: 'inProgress', receiverThreadIds: [HELPER] }), + collab('item/completed', { + id, + tool, + status: 'completed', + receiverThreadIds: [HELPER], + ...fields + }) + ] + const rows = await publishedRows([ + turn('turn/started', THREAD_ID, PARENT_TURN), + spawn('item/completed'), + turn('turn/started', HELPER, HELPER_TURN), + // A spawn that created no thread names nothing. + collab('item/completed', { + id: 'call-spawn-failed', + tool: 'spawnAgent', + status: 'failed', + receiverThreadIds: [], + prompt: 'second helper' + }), + ...finished('call-send', 'sendInput', { + prompt: 'keep going', + agentsStates: { [HELPER]: { status: 'running', message: null } } + }), + // A status's message is the helper's last reply; only a wait reports it. + ...finished('call-resume', 'resumeAgent', { + agentsStates: { [HELPER]: { status: 'completed', message: 'OLD_REPLY' } } + }), + collab('item/started', { + id: 'call-wait', + tool: 'wait', + status: 'inProgress', + receiverThreadIds: [HELPER, OTHER] + }), + // A wait's end names only the helpers that finished; an errored one's message is its error. + collab('item/completed', { + id: 'call-wait', + tool: 'wait', + status: 'failed', + receiverThreadIds: [OTHER], + agentsStates: { [OTHER]: { status: 'errored', message: 'boom' } } + }), + shell('item/started', THREAD_ID, PARENT_TURN, 'call-parent-shell', 'PARENT_DONE'), + shell('item/completed', THREAD_ID, PARENT_TURN, 'call-parent-shell', 'PARENT_DONE') + ]) + const runs = [ + [ + ['spawn_agent', 'Pending init'], + ['spawn_agent', 'Failed'], + ['send_input', 'Running'], + ['resume_agent', 'Completed'], + ['wait_agent', `${OTHER}: boom`], + ['shell', 'PARENT_DONE'] + ] + ] + const { mobile, desktop } = positionalRuns(rows) + expect(mobile).toEqual(runs) + expect(desktop).toEqual(runs) + // The wait keeps naming both helpers it waited on. + expect(outputOf(rows, 'wait_agent')).toMatchObject({ input: { agents: [HELPER, OTHER] } }) + }) +}) diff --git a/src/main/codex/codex-structured-item-translation.ts b/src/main/codex/codex-structured-item-translation.ts index 361487d9fde5..25b821d90d9f 100644 --- a/src/main/codex/codex-structured-item-translation.ts +++ b/src/main/codex/codex-structured-item-translation.ts @@ -225,11 +225,13 @@ function webSearchItem(item: CodexThreadItem): CodexJournalItem { * Journal body for a Codex item, or null for one with nothing to render. * * Known empty items wait for later deltas. Unknown types become bounded status - * rows so a provider release cannot make new activity invisible. + * rows so a provider release cannot make new activity invisible. `started` is a + * finished item's own started revision, when the caller still holds it. */ export function codexJournalItem( item: CodexThreadItem, - helperName?: CodexHelperName + helperName?: CodexHelperName, + started?: CodexThreadItem ): CodexJournalItem { if (item.type === 'userMessage' || item.type === 'agentMessage') { const blocks = codexMessageBlocks(item) @@ -256,7 +258,7 @@ export function codexJournalItem( if (item.type === 'imageView' || item.type === 'imageGeneration') { return { body: codexImageItemBody(item), handled: true } } - const collab = codexCollabAgentToolCallBody(item, helperName) + const collab = codexCollabAgentToolCallBody(item, helperName, started) if (collab) { return { body: collab, handled: true } } diff --git a/src/main/codex/codex-structured-journal-items.ts b/src/main/codex/codex-structured-journal-items.ts index 1e8fcf113322..e5ba6cd9720c 100644 --- a/src/main/codex/codex-structured-journal-items.ts +++ b/src/main/codex/codex-structured-journal-items.ts @@ -98,15 +98,17 @@ export class CodexJournalItems { ) { return { handled: true, admission: { accepted: false, reason: 'failed' } } } - const translated = codexJournalItem(item, this.helperName) + const itemKey = codexStructuredItemKey(event.threadId, item.id) + const started = + event.method === 'item/completed' ? this.activeItems.get(itemKey)?.item : undefined + const translated = codexJournalItem(item, this.helperName, started) const command = readCodexJournalString(item, 'command') if (command) { const boundedCommand = Buffer.from(command, 'utf8') .subarray(0, MAX_CODEX_DETAIL_BYTES) .toString('utf8') - this.details.set(codexStructuredItemKey(event.threadId, item.id), boundedCommand) + this.details.set(itemKey, boundedCommand) } - const itemKey = codexStructuredItemKey(event.threadId, item.id) if (!translated.body) { if (event.method === 'item/completed') { this.streams.forget(event.threadId, item.id) From bbb86afe959c69d4469bf863fe4711af2b14c875 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:30:15 -0700 Subject: [PATCH 17/24] 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. --- src/shared/native-chat-tool-fold.ts | 17 ++++++++-- src/shared/native-chat-tool-pairing.ts | 43 ++++++-------------------- 2 files changed, 24 insertions(+), 36 deletions(-) diff --git a/src/shared/native-chat-tool-fold.ts b/src/shared/native-chat-tool-fold.ts index 8b8f7b941cd6..4938ae3a2fdc 100644 --- a/src/shared/native-chat-tool-fold.ts +++ b/src/shared/native-chat-tool-fold.ts @@ -11,7 +11,6 @@ import { import { agentJournalItemSubagentId } from './agent-session-journal-producer' import { isKnownHarnessInjectedUserTurnText } from './harness-injected-user-turns' import { isNoiseMessage } from './native-chat-noise' -import { answeredToolCallIndex } from './native-chat-tool-pairing' function isToolOnlyMessage(message: NativeChatMessage): boolean { return ( @@ -154,8 +153,20 @@ export type NativeChatToolPair = { result?: NativeChatToolResultBlock } -/** Pair results to calls by `answeredToolCallIndex`, the rule `pairNativeChatToolResults` uses, - * so every reader of a run agrees on who owns an output. */ +/** Where in `unanswered` (oldest first) the call `result` answers is, or -1 for none. */ +function answeredToolCallIndex( + unanswered: readonly T[], + result: NativeChatToolResultBlock, + callIdOf: (entry: T) => string | undefined +): number { + if (result.callId === undefined) { + return unanswered.length > 0 ? 0 : -1 + } + return unanswered.findIndex((entry) => callIdOf(entry) === result.callId) +} + +/** Pair results to calls by `answeredToolCallIndex`. Every reader of a run pairs through this + * (`pairNativeChatToolResults` included), so they all agree on who owns an output. */ export function pairToolBlocks( blocks: readonly NativeChatBlock[], limit = Infinity diff --git a/src/shared/native-chat-tool-pairing.ts b/src/shared/native-chat-tool-pairing.ts index 70acdb918111..490008927c13 100644 --- a/src/shared/native-chat-tool-pairing.ts +++ b/src/shared/native-chat-tool-pairing.ts @@ -14,12 +14,11 @@ // that has not been answered yet. Position alone misattributes every later result once // one call finishes with no output, which is why a producer that knows the call names it. -import { - isToolCallBlock, - isToolResultBlock, - type NativeChatBlock, - type NativeChatToolCallBlock, - type NativeChatToolResultBlock +import { pairToolBlocks } from './native-chat-tool-fold' +import type { + NativeChatBlock, + NativeChatToolCallBlock, + NativeChatToolResultBlock } from './native-chat-types' export type NativeChatToolPairing = { @@ -34,39 +33,17 @@ export const NO_NATIVE_CHAT_TOOL_PAIRING: NativeChatToolPairing = { pairedResults: new Set() } -/** Where in `unanswered` (oldest first) the call `result` answers is, or -1 for none. */ -export function answeredToolCallIndex( - unanswered: readonly T[], - result: NativeChatToolResultBlock, - callIdOf: (entry: T) => string | undefined -): number { - if (result.callId === undefined) { - return unanswered.length > 0 ? 0 : -1 - } - return unanswered.findIndex((entry) => callIdOf(entry) === result.callId) -} - +/** The desktop run's view of `pairToolBlocks`, so every reader of a run pairs with one loop. */ export function pairNativeChatToolResults( blocks: readonly NativeChatBlock[] ): NativeChatToolPairing { const resultByCall = new Map() const pairedResults = new Set() - const unanswered: NativeChatToolCallBlock[] = [] - for (const block of blocks) { - if (isToolCallBlock(block)) { - unanswered.push(block) - continue - } - if (!isToolResultBlock(block)) { - continue - } - const answered = answeredToolCallIndex(unanswered, block, (call) => call.callId) - const [call] = answered === -1 ? [] : unanswered.splice(answered, 1) - if (call === undefined) { - continue + for (const { call, result } of pairToolBlocks(blocks)) { + if (call && result) { + resultByCall.set(call, result) + pairedResults.add(result) } - resultByCall.set(call, block) - pairedResults.add(block) } return { resultByCall, pairedResults } } From 2fd7d3c18de89238861ca66447d5636a16a6bc68 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:37:53 -0700 Subject: [PATCH 18/24] test(native-chat): type the positional-client pairs without assertions --- ...ab-call-rows-on-positional-clients.test.ts | 23 ++++++++----------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/src/main/codex/codex-collab-call-rows-on-positional-clients.test.ts b/src/main/codex/codex-collab-call-rows-on-positional-clients.test.ts index eb55d688b76b..732dc3178f52 100644 --- a/src/main/codex/codex-collab-call-rows-on-positional-clients.test.ts +++ b/src/main/codex/codex-collab-call-rows-on-positional-clients.test.ts @@ -132,10 +132,12 @@ async function publishedRows(frames: Frame[]): Promise return [...rows.values()] } +type PairedOutput = [call: string, output: string | undefined] + /** What a client without result call ids draws: each call with the output its pairing gives it. */ function positionalRuns(rows: AgentJournalRenderItem[]): { - mobile: [string, string | undefined][][] - desktop: [string, string | undefined][][] + mobile: PairedOutput[][] + desktop: PairedOutput[][] } { const withoutCallIds = (message: NativeChatMessage): NativeChatMessage => ({ ...message, @@ -152,21 +154,16 @@ function positionalRuns(rows: AgentJournalRenderItem[]): { ).filter((message) => message.blocks.some((block) => block.type === 'tool-call')) return { mobile: runs.map((message) => - pairToolBlocks(message.blocks).map( - (pair) => - [pair.call?.name ?? '(none)', pair.result?.output.trim()] as [string, string | undefined] - ) + pairToolBlocks(message.blocks).map((pair): PairedOutput => [ + pair.call?.name ?? '(none)', + pair.result?.output.trim() + ]) ), desktop: runs.map((message) => { const pairing = pairNativeChatToolResults(message.blocks) - return message.blocks.flatMap((block) => + return message.blocks.flatMap((block): PairedOutput[] => block.type === 'tool-call' - ? [ - [block.name, pairing.resultByCall.get(block)?.output.trim()] as [ - string, - string | undefined - ] - ] + ? [[block.name, pairing.resultByCall.get(block)?.output.trim()]] : [] ) }) From 97d36dc3f92bd97b8ad7f03e4d3db5f7a91f8334 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:12:17 -0700 Subject: [PATCH 19/24] 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 - `). The row label already names the helper, so the output does not repeat it. --- .../codex-collab-agent-item-translation.ts | 47 +++++++++++++------ ...ab-call-rows-on-positional-clients.test.ts | 23 +++++---- 2 files changed, 43 insertions(+), 27 deletions(-) diff --git a/src/main/codex/codex-collab-agent-item-translation.ts b/src/main/codex/codex-collab-agent-item-translation.ts index 6bcf1cc196ee..f26ac4daa51a 100644 --- a/src/main/codex/codex-collab-agent-item-translation.ts +++ b/src/main/codex/codex-collab-agent-item-translation.ts @@ -33,13 +33,12 @@ function helperNames(call: CodexCollabAgentToolCall, helperName?: CodexHelperNam return call.receiverThreadIds.map((threadId) => helperName?.(threadId) ?? threadId).join(', ') } -/** Codex's own words for a helper's `CollabAgentStatus`, as its UI shows them. */ +/** Codex's own words for a helper's `CollabAgentStatus`, as its client shows them. */ const HELPER_STATUS_TEXT = new Map([ ['pendingInit', 'Pending init'], ['running', 'Running'], ['interrupted', 'Interrupted'], ['completed', 'Completed'], - ['errored', 'Error'], ['shutdown', 'Shutdown'], ['notFound', 'Not found'] ]) @@ -50,28 +49,46 @@ const CALL_STATUS_TEXT = new Map([ ['interrupted', 'Interrupted'] ]) -/** What the call reports about one helper. Only a wait returns what its helper said; any other - * call reports the helper's status (a close's snapshot is the status it closed it in), and an - * errored helper's message is its error. */ -function helperStateText( - call: CodexCollabAgentToolCall, - state: CodexCollabAgentToolCall['states'][number] -): string | null { - if (state.message && (call.tool === 'wait' || state.status === 'errored')) { - return state.message +type HelperState = CodexCollabAgentToolCall['states'][number] + +/** A helper's status summarised as Codex's own client does: a completed helper with its last + * reply, an errored one with its error. */ +function statusSummary(state: HelperState): string | null { + if (state.status === 'errored') { + return `Error - ${state.message ?? 'Agent errored'}` + } + if (state.status === 'completed' && state.message) { + return `Completed - ${state.message}` } return state.status === null ? null : (HELPER_STATUS_TEXT.get(state.status) ?? state.status) } -/** A finished call's output, taken from the item. Every finished call has one: a client that pairs - * results by position (one predating result call ids) would otherwise draw each later output in - * the run under the call before its own. */ +/** A finished call's output, worded as Codex's own client words the call. Every finished call has + * one: a client that pairs results by position (one predating result call ids) would otherwise + * draw each later output in the run under the call before its own. The row label already names + * the helper, so the output does not. */ function outputText(call: CodexCollabAgentToolCall, helperName?: CodexHelperName): string | null { if (call.status === null || call.status === 'inProgress') { return null } + // The status a spawn or close reports predates what it did (a closed helper reads `running`), + // so these say what the call did. + switch (call.tool) { + case 'spawnAgent': + return call.receiverThreadIds.length > 0 ? 'Spawned' : 'Agent spawn failed' + case 'sendInput': + return 'Sent input' + case 'closeAgent': + return 'Closed' + case 'resumeAgent': + return (call.states[0] && statusSummary(call.states[0])) ?? 'Error - Agent resume failed' + } const reports = call.states.flatMap((state) => { - const text = helperStateText(call, state) + // A wait returns what each finished helper said. + const text = + call.tool === 'wait' && state.status === 'completed' && state.message + ? state.message + : statusSummary(state) return text === null ? [] : [{ threadId: state.threadId, text }] }) if (reports.length === 0) { diff --git a/src/main/codex/codex-collab-call-rows-on-positional-clients.test.ts b/src/main/codex/codex-collab-call-rows-on-positional-clients.test.ts index 732dc3178f52..227e157b5bdc 100644 --- a/src/main/codex/codex-collab-call-rows-on-positional-clients.test.ts +++ b/src/main/codex/codex-collab-call-rows-on-positional-clients.test.ts @@ -217,14 +217,13 @@ describe('Codex collab call rows on a client that pairs results by position', () // The helper's own shell is its own run, between the parent's two. const runs = [ [ - ['spawn_agent', 'Pending init'], + ['spawn_agent', 'Spawned'], ['shell', 'PARENT_DONE'] ], [['shell', 'CHILD_DONE']], [ ['wait_agent', 'CHILD_REPLY'], - // A close reports the status it closed the helper in, not the reply the wait showed. - ['close_agent', 'Completed'], + ['close_agent', 'Closed'], ['shell', 'AFTER_CLOSE'] ] ] @@ -269,9 +268,10 @@ describe('Codex collab call rows on a client that pairs results by position', () }) const runs = [ [ - ['spawn_agent', 'Pending init'], + ['spawn_agent', 'Spawned'], ['wait_agent', 'Finished waiting'], - ['close_agent', 'Running'], + // Its snapshot says `running`: the status from before the close. + ['close_agent', 'Closed'], ['shell', 'PARENT_DONE'] ] ] @@ -280,7 +280,7 @@ describe('Codex collab call rows on a client that pairs results by position', () expect(desktop).toEqual(runs) }) - it('gives every other finished call what it reports, even on a running helper', async () => { + it('gives every other finished call what it did or what it reports', async () => { const OTHER = '01a0ea72-0000-7000-8000-000000000002' const finished = (id: string, tool: string, fields: Record): Frame[] => [ collab('item/started', { id, tool, status: 'inProgress', receiverThreadIds: [HELPER] }), @@ -308,7 +308,6 @@ describe('Codex collab call rows on a client that pairs results by position', () prompt: 'keep going', agentsStates: { [HELPER]: { status: 'running', message: null } } }), - // A status's message is the helper's last reply; only a wait reports it. ...finished('call-resume', 'resumeAgent', { agentsStates: { [HELPER]: { status: 'completed', message: 'OLD_REPLY' } } }), @@ -331,11 +330,11 @@ describe('Codex collab call rows on a client that pairs results by position', () ]) const runs = [ [ - ['spawn_agent', 'Pending init'], - ['spawn_agent', 'Failed'], - ['send_input', 'Running'], - ['resume_agent', 'Completed'], - ['wait_agent', `${OTHER}: boom`], + ['spawn_agent', 'Spawned'], + ['spawn_agent', 'Agent spawn failed'], + ['send_input', 'Sent input'], + ['resume_agent', 'Completed - OLD_REPLY'], + ['wait_agent', `${OTHER}: Error - boom`], ['shell', 'PARENT_DONE'] ] ] From f1e0414c86d0213295809eda5e36502cdb6fa65e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:17:58 -0700 Subject: [PATCH 20/24] 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. --- src/main/codex/codex-collab-agent-tool-call.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/codex/codex-collab-agent-tool-call.ts b/src/main/codex/codex-collab-agent-tool-call.ts index fbea0f67006a..cb0442089a97 100644 --- a/src/main/codex/codex-collab-agent-tool-call.ts +++ b/src/main/codex/codex-collab-agent-tool-call.ts @@ -14,8 +14,8 @@ // * A wait that times out ends naming no receiver and no state. // * `agentsStates` is the caller's last-known snapshot of each receiver. The helper's own turn // frames own its execution, so nothing here reads it as execution state: only `notFound`, -// which says the receiver is no helper, and the snapshot itself, which is what the call -// reports and so what its row shows as output. +// which says the receiver is no helper, and the snapshot a wait or resume reports, which its +// row shows as output. A spawn's or close's predates what the call did, so its row does not. import { collapsedToolInputPrefix } from '../../shared/native-chat-tool-preview-prefix' import { readRecord, readString } from './codex-item-field-readers' From 6639c686311102e31d5c3d7fad8f514b8ff70452 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:26:31 -0700 Subject: [PATCH 21/24] fix(native-chat): a Codex spawn call no longer claims it ran an agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/shared/native-chat-tool-icon.test.ts | 6 +++--- src/shared/native-chat-tool-icon.ts | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/shared/native-chat-tool-icon.test.ts b/src/shared/native-chat-tool-icon.test.ts index 1b1a66532fa2..f84522443d2d 100644 --- a/src/shared/native-chat-tool-icon.test.ts +++ b/src/shared/native-chat-tool-icon.test.ts @@ -56,9 +56,9 @@ describe('native chat tool icons', () => { expect(nativeChatToolCategory('web search')).toBe('webSearch') }) - it('counts only the Codex call that starts a helper as running an agent', () => { - expect(nativeChatToolCategory('spawn_agent')).toBe('subAgentActivity') - expect(nativeChatToolIconName('spawn_agent')).toBe('bot') + 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() }) diff --git a/src/shared/native-chat-tool-icon.ts b/src/shared/native-chat-tool-icon.ts index 3732fd3dbf75..9da8672374a2 100644 --- a/src/shared/native-chat-tool-icon.ts +++ b/src/shared/native-chat-tool-icon.ts @@ -82,9 +82,9 @@ const CATEGORY_BY_ROW_WORD = new Map([ // Claude's tool names, which its lane renders verbatim. ['grep', 'search'], ['glob', 'search'], + // Claude's Task call lasts as long as its subagent. Codex's `spawn_agent` ends once the helper + // starts, so it is deliberately absent: its roster row, not the call, stands for the helper. ['task', 'subAgentActivity'], - // Codex's call that starts a helper. Its other agent calls (wait, message, close) run none. - ['spawn_agent', 'subAgentActivity'], ['webfetch', 'webSearch'], ['todowrite', 'todoList'], ['update_plan', 'todoList'], From 2654d957efea6a38f9f70d4c80fdc511dd1da6a5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:36:05 -0700 Subject: [PATCH 22/24] 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. --- .../codex-default-mode-subagents.test.ts | 74 +++++++++++-------- src/main/codex/codex-subagent-roster.ts | 2 +- 2 files changed, 45 insertions(+), 31 deletions(-) diff --git a/src/main/codex/codex-default-mode-subagents.test.ts b/src/main/codex/codex-default-mode-subagents.test.ts index 27c9cb37de17..335f903efbbf 100644 --- a/src/main/codex/codex-default-mode-subagents.test.ts +++ b/src/main/codex/codex-default-mode-subagents.test.ts @@ -592,38 +592,52 @@ describe('a helper whose spawn was never seen', () => { }) }) -describe('the roster row follows a helper whose turn ends with no turn/completed', () => { +describe('the roster row follows a helper whose turn ends', () => { const lastRow = (run: Awaited>) => run.rosterRows().at(-1)?.agents - const fatal: Frame = { - method: 'error', - params: { - threadId: HELPER, - turnId: HELPER_TURN, - willRetry: false, - error: { message: 'Selected model is at capacity.', codexErrorInfo: 'serverOverloaded' } - } + const running = async () => { + const run = await session() + run.send( + turn('turn/started', THREAD_ID, PARENT_TURN), + activityStarted, + turn('turn/started', HELPER, HELPER_TURN) + ) + expect(lastRow(run)).toEqual([expect.objectContaining({ id: HELPER, state: 'working' })]) + return run } - const closed: Frame = { method: 'thread/closed', params: { threadId: HELPER } } - - it.each([ - ['a fatal error', fatal, 'failed', 'failed'], - ['its thread closing', closed, 'unverifiable', 'unknown'] - ] as const)( - 'settles the row with the strip and the record on %s', - async (_name, ending, rowState, outcome) => { - const run = await session() - run.send( - turn('turn/started', THREAD_ID, PARENT_TURN), - activityStarted, - turn('turn/started', HELPER, HELPER_TURN) - ) - expect(lastRow(run)).toEqual([expect.objectContaining({ id: HELPER, state: 'working' })]) - run.send(ending) - expect(run.strip()).toEqual([]) - expect(run.agents()).toEqual([expect.objectContaining({ membership: 'settled', outcome })]) - expect(lastRow(run)).toEqual([expect.objectContaining({ id: HELPER, state: rowState })]) - } - ) + + it('settles the row with the strip and the record on the failed completion that follows a fatal error', async () => { + const run = await running() + // Codex sends the fatal error, then the helper's failed `turn/completed` 0-32 ms later. + run.send({ + method: 'error', + params: { + threadId: HELPER, + turnId: HELPER_TURN, + willRetry: false, + error: { message: 'Selected model is at capacity.', codexErrorInfo: 'serverOverloaded' } + } + }) + expect(run.strip()).toEqual([expect.objectContaining({ id: `codex-agent:${HELPER}` })]) + expect(run.agents()).toEqual([expect.objectContaining({ membership: 'live' })]) + expect(lastRow(run)).toEqual([expect.objectContaining({ id: HELPER, state: 'working' })]) + + run.send(turn('turn/completed', HELPER, HELPER_TURN, 'failed')) + expect(run.strip()).toEqual([]) + expect(run.agents()).toEqual([ + expect.objectContaining({ membership: 'settled', outcome: 'failed' }) + ]) + expect(lastRow(run)).toEqual([expect.objectContaining({ id: HELPER, state: 'failed' })]) + }) + + it('settles the row with the strip and the record when its thread closes with no turn/completed', async () => { + const run = await running() + run.send({ method: 'thread/closed', params: { threadId: HELPER } }) + expect(run.strip()).toEqual([]) + expect(run.agents()).toEqual([ + expect.objectContaining({ membership: 'settled', outcome: 'unknown' }) + ]) + expect(lastRow(run)).toEqual([expect.objectContaining({ id: HELPER, state: 'unverifiable' })]) + }) }) describe('a restored thread', () => { diff --git a/src/main/codex/codex-subagent-roster.ts b/src/main/codex/codex-subagent-roster.ts index 255190afa475..e173a3ada4c4 100644 --- a/src/main/codex/codex-subagent-roster.ts +++ b/src/main/codex/codex-subagent-roster.ts @@ -102,7 +102,7 @@ export class CodexSubagentRoster { this.now = deps.now ?? (() => Date.now()) this.executions = deps.executions ?? new CodexSubagentExecutions() // The row follows the executions, so every frame that ends a child's turn — its own - // `turn/completed`, a fatal error, its thread closing — settles it. + // `turn/completed` (a failed one included) or its thread closing — settles it. // A refused write clears `lastSerialized`, so the next write of the group retries it. this.unfollow = this.executions.onExecutionChanged( (child) => child.execution && this.follow(child, child.execution) From 4706d3df677c8c48c4096bb760654b3a02daf725 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:41:53 -0700 Subject: [PATCH 23/24] 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. --- config/tsconfig.node.json | 1 + .../codex-collab-agent-item-translation.ts | 5 +- .../codex/codex-collab-agent-tool-call.ts | 17 +-- .../codex-collab-call-delegation.test.ts | 89 ++++++++++++ .../codex/codex-collab-call-frames-fixture.ts | 127 +++++++++++++++++ ...ab-call-rows-on-positional-clients.test.ts | 134 ++---------------- .../native-chat-subagent-delegation.ts | 18 ++- src/shared/codex-collab-agent-tools.ts | 41 ++++++ 8 files changed, 293 insertions(+), 139 deletions(-) create mode 100644 src/main/codex/codex-collab-call-delegation.test.ts create mode 100644 src/main/codex/codex-collab-call-frames-fixture.ts create mode 100644 src/shared/codex-collab-agent-tools.ts diff --git a/config/tsconfig.node.json b/config/tsconfig.node.json index ff4e9a762764..7d4d951d2d62 100644 --- a/config/tsconfig.node.json +++ b/config/tsconfig.node.json @@ -10,6 +10,7 @@ "../src/renderer/src/lib/skill-freshness-display-status.ts", "../src/renderer/src/components/native-chat/native-chat-resolution-receipt.ts", "../src/renderer/src/components/native-chat/structured-agent-question-projection.ts", + "../src/renderer/src/components/native-chat/native-chat-subagent-delegation.ts", "../src/preload/**/*", "../src/shared/**/*", "../src/relay/**/*", diff --git a/src/main/codex/codex-collab-agent-item-translation.ts b/src/main/codex/codex-collab-agent-item-translation.ts index f26ac4daa51a..798f3bf121fc 100644 --- a/src/main/codex/codex-collab-agent-item-translation.ts +++ b/src/main/codex/codex-collab-agent-item-translation.ts @@ -5,6 +5,7 @@ // rows read as the same child. import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' +import { CODEX_COLLAB_ROW_AGENTS_KEY } from '../../shared/codex-collab-agent-tools' import { boundInlineText, boundToolInput, @@ -124,7 +125,9 @@ export function codexCollabAgentToolCallBody( ...(call.prompt ? { prompt: call.prompt } : {}), ...(model ? { model } : {}), ...(reasoningEffort ? { reasoningEffort } : {}), - ...(call.receiverThreadIds.length > 0 ? { agents: call.receiverThreadIds } : {}) + ...(call.receiverThreadIds.length > 0 + ? { [CODEX_COLLAB_ROW_AGENTS_KEY]: call.receiverThreadIds } + : {}) } const text = outputText(call, helperName) const output = text === null ? null : boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS) diff --git a/src/main/codex/codex-collab-agent-tool-call.ts b/src/main/codex/codex-collab-agent-tool-call.ts index cb0442089a97..01809a7fb51d 100644 --- a/src/main/codex/codex-collab-agent-tool-call.ts +++ b/src/main/codex/codex-collab-agent-tool-call.ts @@ -17,6 +17,7 @@ // which says the receiver is no helper, and the snapshot a wait or resume reports, which its // row shows as output. A spawn's or close's predates what the call did, so its row does not. +import { codexCollabToolRowName } from '../../shared/codex-collab-agent-tools' import { collapsedToolInputPrefix } from '../../shared/native-chat-tool-preview-prefix' import { readRecord, readString } from './codex-item-field-readers' import type { CodexThreadItem } from './codex-thread-item-identity' @@ -96,20 +97,6 @@ export function codexCollabHelperLabel(prompt: string | null): string | null { return `${collapsed.slice(0, end)}…` } -/** The name the model called the tool by, which is what its row shows. The wire item spells the - * tool in camelCase; the model-facing name is snake_case, so this map is not a spelling fix. */ -const CODEX_COLLAB_TOOL_NAMES = new Map([ - ['spawnAgent', 'spawn_agent'], - ['sendInput', 'send_input'], - ['resumeAgent', 'resume_agent'], - ['wait', 'wait_agent'], - ['closeAgent', 'close_agent'], - ['sendMessage', 'send_message'], - ['followupTask', 'followup_task'], - ['interruptAgent', 'interrupt_agent'], - ['listAgents', 'list_agents'] -]) - export function codexCollabToolName(call: CodexCollabAgentToolCall): string { - return CODEX_COLLAB_TOOL_NAMES.get(call.tool) ?? call.tool + return codexCollabToolRowName(call.tool) } diff --git a/src/main/codex/codex-collab-call-delegation.test.ts b/src/main/codex/codex-collab-call-delegation.test.ts new file mode 100644 index 000000000000..640dc942b991 --- /dev/null +++ b/src/main/codex/codex-collab-call-delegation.test.ts @@ -0,0 +1,89 @@ +// A running chat holds a subagent's section open while the parent's newest row delegates to it. +// In Codex's default multi-agent mode that row is the collab call the host writes as a tool row, +// so the rows the real adapter publishes must read as a delegation to the helper they name. + +import { describe, expect, it } from 'vitest' +import { projectNativeChatTranscript } from '../../shared/native-chat-transcript-projection' +import type { NativeChatMessage } from '../../shared/native-chat-types' +import { projectStructuredAgentSessionMessages } from '../../shared/structured-agent-session-message-projection' +import { + nativeChatSubagentDelegation, + type NativeChatSubagentDelegation +} from '../../renderer/src/components/native-chat/native-chat-subagent-delegation' +import { + collab, + HELPER, + HELPER_TURN, + PARENT_TURN, + publishedRows, + shell, + spawn, + turn, + waitStarted, + type Frame +} from './codex-collab-call-frames-fixture' +import { THREAD_ID } from './codex-structured-session-adapter-fixture' + +/** The delegation the parent's newest tool run reads as, the row a running chat's frontier judges. */ +async function newestRunDelegation(frames: Frame[]): Promise { + const { conversation } = projectNativeChatTranscript( + projectStructuredAgentSessionMessages(await publishedRows(frames), [], []) + ) + const runs = conversation.filter((message: NativeChatMessage) => + message.blocks.some((block) => block.type === 'tool-call') + ) + const newest = runs.at(-1) + return newest ? nativeChatSubagentDelegation(newest) : null +} + +const spawned = [ + turn('turn/started', THREAD_ID, PARENT_TURN), + spawn('item/started'), + spawn('item/completed'), + turn('turn/started', HELPER, HELPER_TURN), + shell('item/started', HELPER, HELPER_TURN, 'call-helper-shell', 'CHILD_DONE') +] + +describe("a Codex default-mode collab call row as the parent's delegation", () => { + it('delegates to the helper a finished spawn names', async () => { + expect(await newestRunDelegation(spawned)).toEqual({ kind: 'call', agentId: HELPER }) + }) + + it('delegates to the helper a running wait names', async () => { + expect(await newestRunDelegation([...spawned, waitStarted])).toEqual({ + kind: 'call', + agentId: HELPER + }) + }) + + it('delegates to the helper a close names, as a raw collab row did', async () => { + const close = collab('item/started', { + id: 'call-close', + tool: 'closeAgent', + status: 'inProgress', + receiverThreadIds: [HELPER] + }) + expect(await newestRunDelegation([...spawned, close])).toEqual({ + kind: 'call', + agentId: HELPER + }) + }) + + it('reads a spawn still starting, which names no helper, as ordinary output', async () => { + expect( + await newestRunDelegation([ + turn('turn/started', THREAD_ID, PARENT_TURN), + spawn('item/started') + ]) + ).toBeNull() + }) + + it("reads the parent's next call as superseding the delegation", async () => { + expect( + await newestRunDelegation([ + ...spawned, + shell('item/started', THREAD_ID, PARENT_TURN, 'call-parent-shell', 'PARENT_DONE') + ]) + ).toBeNull() + }) +}) diff --git a/src/main/codex/codex-collab-call-frames-fixture.ts b/src/main/codex/codex-collab-call-frames-fixture.ts new file mode 100644 index 000000000000..8926e46dcca9 --- /dev/null +++ b/src/main/codex/codex-collab-call-frames-fixture.ts @@ -0,0 +1,127 @@ +// Default-mode collab frames as a live session sent them, and the journal rows the real adapter +// publishes for them: what a client reads. + +import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { fakeCodex, identityFor, THREAD_ID } from './codex-structured-session-adapter-fixture' +import { CodexStructuredSessionAdapter } from './codex-structured-session-adapter' + +// Shapes as a live default-mode session sent them (codex-cli 0.157); only the sender is remapped. +export const HELPER = '01a0ea72-80bc-7632-a97f-4a9a0d50d08f' +export const PARENT_TURN = 'parent-turn-1' +export const HELPER_TURN = 'helper-turn-1' +export const PROMPT = 'Run exactly one foreground shell command: `sleep 150; echo LATE`.' + +export type Frame = { method: string; params: Record } + +export const turn = ( + method: 'turn/started' | 'turn/completed', + threadId: string, + id: string +): Frame => ({ + method, + params: { threadId, turn: { id, status: 'completed' } } +}) +export const item = ( + method: 'item/started' | 'item/completed', + threadId: string, + turnId: string, + fields: Record +): Frame => ({ method, params: { threadId, turnId, item: fields } }) +export const collab = ( + method: 'item/started' | 'item/completed', + fields: Record +): Frame => + item(method, THREAD_ID, PARENT_TURN, { + type: 'collabAgentToolCall', + senderThreadId: THREAD_ID, + prompt: null, + model: null, + reasoningEffort: null, + agentsStates: {}, + ...fields + }) +export const shell = ( + method: 'item/started' | 'item/completed', + threadId: string, + turnId: string, + id: string, + output: string +): Frame => + item(method, threadId, turnId, { + type: 'commandExecution', + id, + command: `/bin/zsh -lc 'echo ${output}'`, + cwd: '/work/repo', + status: method === 'item/started' ? 'inProgress' : 'completed', + ...(method === 'item/completed' ? { exitCode: 0, aggregatedOutput: `${output}\n` } : {}) + }) + +export const spawn = (method: 'item/started' | 'item/completed'): Frame => + collab(method, { + id: 'call-spawn', + tool: 'spawnAgent', + status: method === 'item/started' ? 'inProgress' : 'completed', + receiverThreadIds: method === 'item/started' ? [] : [HELPER], + prompt: PROMPT, + model: 'gpt-5.5', + reasoningEffort: 'medium', + ...(method === 'item/completed' + ? { agentsStates: { [HELPER]: { status: 'pendingInit', message: null } } } + : {}) + }) +export const waitStarted = collab('item/started', { + id: 'call-wait', + tool: 'wait', + status: 'inProgress', + receiverThreadIds: [HELPER] +}) + +export async function publishedRows(frames: Frame[]): Promise { + const codex = fakeCodex() + const adapter = new CodexStructuredSessionAdapter({ + resolveLaunch: async () => ({ + command: 'codex', + args: ['app-server'], + cwd: '/work/repo', + codexHome: null, + resumeThreadId: null + }), + openConnection: codex.openConnection, + readProcessStartTime: async () => 1_700_000_000_000, + now: () => 1_700_000_000_500 + }) + // The latest revision of each row, in first-written order, as a client's journal holds it. + const rows = new Map() + const journal: StructuredAgentSessionEventSink = { + appendItem: (identity, body, options) => { + const itemId = JSON.stringify(identity) + const previous = rows.get(itemId) + const sequence = previous?.sequence ?? rows.size + 1 + const { agentId, parentAgentId, producerKind } = options + rows.set(itemId, { + ...previous, + ...(agentId === undefined ? {} : { agentId }), + ...(parentAgentId === undefined ? {} : { parentAgentId }), + ...(producerKind === undefined ? {} : { producerKind }), + itemId, + sequence, + revision: (previous?.revision ?? 0) + 1, + observedAt: sequence, + body + }) + }, + appendTombstone: () => {}, + publish: () => {} + } + await adapter.acquire({ + identity: identityFor('session-1'), + fence: 7, + spawnToken: 'spawn-9', + events: journal + }) + for (const frame of frames) { + codex.connections[0]!.handlers.onNotification?.(frame.method, frame.params) + } + return [...rows.values()] +} diff --git a/src/main/codex/codex-collab-call-rows-on-positional-clients.test.ts b/src/main/codex/codex-collab-call-rows-on-positional-clients.test.ts index b027e918c890..795716ed553c 100644 --- a/src/main/codex/codex-collab-call-rows-on-positional-clients.test.ts +++ b/src/main/codex/codex-collab-call-rows-on-positional-clients.test.ts @@ -13,125 +13,21 @@ import { pairNativeChatToolResults } from '../../shared/native-chat-tool-pairing import { projectNativeChatTranscript } from '../../shared/native-chat-transcript-projection' import type { NativeChatBlock, NativeChatMessage } from '../../shared/native-chat-types' import { projectStructuredAgentSessionMessages } from '../../shared/structured-agent-session-message-projection' -import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -import { fakeCodex, identityFor, THREAD_ID } from './codex-structured-session-adapter-fixture' -import { CodexStructuredSessionAdapter } from './codex-structured-session-adapter' - -// Shapes as a live default-mode session sent them (codex-cli 0.157); only the sender is remapped. -const HELPER = '01a0ea72-80bc-7632-a97f-4a9a0d50d08f' -const PARENT_TURN = 'parent-turn-1' -const HELPER_TURN = 'helper-turn-1' -const PROMPT = 'Run exactly one foreground shell command: `sleep 150; echo LATE`.' - -type Frame = { method: string; params: Record } - -const turn = (method: 'turn/started' | 'turn/completed', threadId: string, id: string): Frame => ({ - method, - params: { threadId, turn: { id, status: 'completed' } } -}) -const item = ( - method: 'item/started' | 'item/completed', - threadId: string, - turnId: string, - fields: Record -): Frame => ({ method, params: { threadId, turnId, item: fields } }) -const collab = ( - method: 'item/started' | 'item/completed', - fields: Record -): Frame => - item(method, THREAD_ID, PARENT_TURN, { - type: 'collabAgentToolCall', - senderThreadId: THREAD_ID, - prompt: null, - model: null, - reasoningEffort: null, - agentsStates: {}, - ...fields - }) -const shell = ( - method: 'item/started' | 'item/completed', - threadId: string, - turnId: string, - id: string, - output: string -): Frame => - item(method, threadId, turnId, { - type: 'commandExecution', - id, - command: `/bin/zsh -lc 'echo ${output}'`, - cwd: '/work/repo', - status: method === 'item/started' ? 'inProgress' : 'completed', - ...(method === 'item/completed' ? { exitCode: 0, aggregatedOutput: `${output}\n` } : {}) - }) - -const spawn = (method: 'item/started' | 'item/completed'): Frame => - collab(method, { - id: 'call-spawn', - tool: 'spawnAgent', - status: method === 'item/started' ? 'inProgress' : 'completed', - receiverThreadIds: method === 'item/started' ? [] : [HELPER], - prompt: PROMPT, - model: 'gpt-5.5', - reasoningEffort: 'medium', - ...(method === 'item/completed' - ? { agentsStates: { [HELPER]: { status: 'pendingInit', message: null } } } - : {}) - }) -const waitStarted = collab('item/started', { - id: 'call-wait', - tool: 'wait', - status: 'inProgress', - receiverThreadIds: [HELPER] -}) - -async function publishedRows(frames: Frame[]): Promise { - const codex = fakeCodex() - const adapter = new CodexStructuredSessionAdapter({ - resolveLaunch: async () => ({ - command: 'codex', - args: ['app-server'], - cwd: '/work/repo', - codexHome: null, - resumeThreadId: null - }), - openConnection: codex.openConnection, - readProcessStartTime: async () => 1_700_000_000_000, - now: () => 1_700_000_000_500 - }) - // The latest revision of each row, in first-written order, as a client's journal holds it. - const rows = new Map() - const journal: StructuredAgentSessionEventSink = { - appendItem: (identity, body, options) => { - const itemId = JSON.stringify(identity) - const previous = rows.get(itemId) - const sequence = previous?.sequence ?? rows.size + 1 - const { agentId, parentAgentId, producerKind } = options - rows.set(itemId, { - ...previous, - ...(agentId === undefined ? {} : { agentId }), - ...(parentAgentId === undefined ? {} : { parentAgentId }), - ...(producerKind === undefined ? {} : { producerKind }), - itemId, - sequence, - revision: (previous?.revision ?? 0) + 1, - observedAt: sequence, - body - }) - }, - appendTombstone: () => {}, - publish: () => {} - } - await adapter.acquire({ - identity: identityFor('session-1'), - fence: 7, - spawnToken: 'spawn-9', - events: journal - }) - for (const frame of frames) { - codex.connections[0]!.handlers.onNotification?.(frame.method, frame.params) - } - return [...rows.values()] -} +import { + collab, + HELPER, + HELPER_TURN, + item, + PARENT_TURN, + PROMPT, + publishedRows, + shell, + spawn, + turn, + waitStarted, + type Frame +} from './codex-collab-call-frames-fixture' +import { THREAD_ID } from './codex-structured-session-adapter-fixture' type PairedOutput = [call: string, output: string | undefined] diff --git a/src/renderer/src/components/native-chat/native-chat-subagent-delegation.ts b/src/renderer/src/components/native-chat/native-chat-subagent-delegation.ts index 6d3012c271a1..392e858aaa85 100644 --- a/src/renderer/src/components/native-chat/native-chat-subagent-delegation.ts +++ b/src/renderer/src/components/native-chat/native-chat-subagent-delegation.ts @@ -3,10 +3,15 @@ // A roster row names the agents it announces, in the order they were added. A Codex collab // call (spawn, wait, resume, message, close) names the agents it acts on by thread id, which // is the agent id, and delegates to the first; one naming none (a spawn still starting, a -// wait on any agent) is ordinary output. A Claude spawn call names no agent — only its call id, which is re-minted on resume +// wait on any agent) is ordinary output. The host writes it as a tool call; journals from +// before that hold it as a raw status row. A Claude spawn call names no agent — only its call id, which is re-minted on resume // and never a join key — so it belongs to the roster that announces its agent. A drawn row is // a spawn when its newest part is one. +import { + codexCollabRowAgentIds, + isCodexCollabToolRowName +} from '../../../../shared/codex-collab-agent-tools' import { isSubagentGroupBlock, type NativeChatMessage } from '../../../../shared/native-chat-types' export type NativeChatSubagentDelegation = @@ -49,9 +54,14 @@ function derive(message: NativeChatMessage): NativeChatSubagentDelegation | null } // A tool run folds into the message before it, so its last call is the newest thing in it. const newest = message.blocks.findLast((block) => block.type !== 'tool-result') - return newest?.type === 'tool-call' && CLAUDE_SPAWN_TOOLS.has(newest.name) - ? { kind: 'spawn' } - : null + if (newest?.type !== 'tool-call') { + return null + } + if (isCodexCollabToolRowName(newest.name)) { + const [agentId] = codexCollabRowAgentIds(newest.input) + return agentId === undefined ? null : { kind: 'call', agentId } + } + return CLAUDE_SPAWN_TOOLS.has(newest.name) ? { kind: 'spawn' } : null } function receiverThreadIds(head: string): readonly string[] { diff --git a/src/shared/codex-collab-agent-tools.ts b/src/shared/codex-collab-agent-tools.ts new file mode 100644 index 000000000000..a19de5aff2a2 --- /dev/null +++ b/src/shared/codex-collab-agent-tools.ts @@ -0,0 +1,41 @@ +// The tool rows a Codex `collabAgentToolCall` item becomes: the calls an agent makes to spawn, +// message, wait on and close its helpers. The host names each row; readers recognise it here. + +/** The name the model called the tool by, which is what its row shows. The wire item spells the + * tool in camelCase; the model-facing name is snake_case, so this map is not a spelling fix. */ +const CODEX_COLLAB_TOOL_NAMES: ReadonlyMap = new Map([ + ['spawnAgent', 'spawn_agent'], + ['sendInput', 'send_input'], + ['resumeAgent', 'resume_agent'], + ['wait', 'wait_agent'], + ['closeAgent', 'close_agent'], + ['sendMessage', 'send_message'], + ['followupTask', 'followup_task'], + ['interruptAgent', 'interrupt_agent'], + ['listAgents', 'list_agents'] +]) + +const CODEX_COLLAB_ROW_NAMES: ReadonlySet = new Set(CODEX_COLLAB_TOOL_NAMES.values()) + +/** A tool Codex adds later keeps its wire spelling. */ +export function codexCollabToolRowName(wireTool: string): string { + return CODEX_COLLAB_TOOL_NAMES.get(wireTool) ?? wireTool +} + +export function isCodexCollabToolRowName(name: string): boolean { + return CODEX_COLLAB_ROW_NAMES.has(name) +} + +/** The row input's key for the helper thread ids a call acts on, in the order the call names them. */ +export const CODEX_COLLAB_ROW_AGENTS_KEY = 'agents' + +/** The helper thread ids a collab call row names; none when its input was clipped. */ +export function codexCollabRowAgentIds(input: unknown): string[] { + if (typeof input !== 'object' || input === null || !(CODEX_COLLAB_ROW_AGENTS_KEY in input)) { + return [] + } + const ids = input[CODEX_COLLAB_ROW_AGENTS_KEY] + return Array.isArray(ids) + ? ids.filter((id): id is string => typeof id === 'string' && id.length > 0) + : [] +} From 70b75cc4f3b40d0137e959e07e2fa3af99b1be0f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:56:41 -0700 Subject: [PATCH 24/24] 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. --- .../codex-collab-agent-item-translation.ts | 19 ++++++++++---- .../codex-collab-call-delegation.test.ts | 25 +++++++++++++++++++ 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/src/main/codex/codex-collab-agent-item-translation.ts b/src/main/codex/codex-collab-agent-item-translation.ts index 798f3bf121fc..f12d22b7339d 100644 --- a/src/main/codex/codex-collab-agent-item-translation.ts +++ b/src/main/codex/codex-collab-agent-item-translation.ts @@ -106,6 +106,19 @@ function outputText(call: CodexCollabAgentToolCall, helperName?: CodexHelperName .join('\n') } +/** The prompt is clipped on its own: bounded with the rest, a long one would clip away the + * helper's name and ids the row is read by. */ +function collabRowInput(fields: Record, prompt: string | null): unknown { + const bounded = boundToolInput(fields, DEFAULT_JOURNAL_PAYLOAD_LIMITS) + if (bounded !== fields) { + return bounded + } + const input = prompt + ? { ...fields, prompt: boundInlineText(prompt, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text } + : fields + return Object.keys(input).length > 0 ? input : null +} + /** `started` is the call's started item, when the caller still holds it. */ export function codexCollabAgentToolCallBody( item: CodexThreadItem, @@ -122,7 +135,6 @@ export function codexCollabAgentToolCallBody( const fields = { // `description` is the key the row label reads, so the helper's name leads the row. ...(description ? { description } : {}), - ...(call.prompt ? { prompt: call.prompt } : {}), ...(model ? { model } : {}), ...(reasoningEffort ? { reasoningEffort } : {}), ...(call.receiverThreadIds.length > 0 @@ -135,10 +147,7 @@ export function codexCollabAgentToolCallBody( kind: 'tool-call', name: codexCollabToolName(call), callId: call.id, - input: boundToolInput( - Object.keys(fields).length > 0 ? fields : null, - DEFAULT_JOURNAL_PAYLOAD_LIMITS - ), + input: collabRowInput(fields, call.prompt), state: codexItemRunState(item), ...(output === null ? {} : { output: output.bounded }) } diff --git a/src/main/codex/codex-collab-call-delegation.test.ts b/src/main/codex/codex-collab-call-delegation.test.ts index 640dc942b991..96131248ddc6 100644 --- a/src/main/codex/codex-collab-call-delegation.test.ts +++ b/src/main/codex/codex-collab-call-delegation.test.ts @@ -3,6 +3,7 @@ // so the rows the real adapter publishes must read as a delegation to the helper they name. import { describe, expect, it } from 'vitest' +import { codexCollabRowAgentIds } from '../../shared/codex-collab-agent-tools' import { projectNativeChatTranscript } from '../../shared/native-chat-transcript-projection' import type { NativeChatMessage } from '../../shared/native-chat-types' import { projectStructuredAgentSessionMessages } from '../../shared/structured-agent-session-message-projection' @@ -86,4 +87,28 @@ describe("a Codex default-mode collab call row as the parent's delegation", () = ]) ).toBeNull() }) + + it('keeps naming the helper when its prompt is past the journal limit', async () => { + const prompt = `Review lane a. ${'x'.repeat(20_000)}` + const longSpawn = [ + turn('turn/started', THREAD_ID, PARENT_TURN), + collab('item/completed', { + id: 'call-spawn', + tool: 'spawnAgent', + status: 'completed', + receiverThreadIds: [HELPER], + prompt + }) + ] + const row = (await publishedRows(longSpawn)) + .map(({ body }) => body) + .find((body) => body.kind === 'tool-call' && body.name === 'spawn_agent') + const input = row?.kind === 'tool-call' ? row.input : undefined + expect(input).toMatchObject({ + description: expect.stringMatching(/^Review lane a\. x+…$/), + prompt: expect.stringContaining('[Orca: output truncated') + }) + expect(codexCollabRowAgentIds(input)).toEqual([HELPER]) + expect(await newestRunDelegation(longSpawn)).toEqual({ kind: 'call', agentId: HELPER }) + }) })