diff --git a/src/main/grok/grok-hook-config.ts b/src/main/grok/grok-hook-config.ts index 52ee2ecfdb2..4f8827bb0fd 100644 --- a/src/main/grok/grok-hook-config.ts +++ b/src/main/grok/grok-hook-config.ts @@ -28,7 +28,13 @@ export const GROK_EVENTS = [ eventName: 'PostToolUseFailure', definition: { matcher: GROK_TOOL_EVENT_MATCHER, hooks: [{ type: 'command', command: '' }] } }, - { eventName: 'Notification', definition: { hooks: [{ type: 'command', command: '' }] } } + { eventName: 'Notification', definition: { hooks: [{ type: 'command', command: '' }] } }, + // Why: nested subagents inherit the parent pane's ORCA_PANE_KEY, so without these the pane only + // ever hears a child through events that look like the session's own. These name the child, which + // is what lets Orca show its row and keep the pane working while a background child outlives the + // parent turn. Older grok builds ignore unregistered event names (the StopFailure precedent). + { eventName: 'SubagentStart', definition: { hooks: [{ type: 'command', command: '' }] } }, + { eventName: 'SubagentStop', definition: { hooks: [{ type: 'command', command: '' }] } } ] as const export function buildInstalledGrokConfig( diff --git a/src/main/pi/agent-status-async-subagent-source.ts b/src/main/pi/agent-status-async-subagent-source.ts new file mode 100644 index 00000000000..821016973bf --- /dev/null +++ b/src/main/pi/agent-status-async-subagent-source.ts @@ -0,0 +1,52 @@ +import type { PiAgentKind } from '../../shared/pi-agent-kind' + +/** Async child runs delegated through pi's subagent extension outlive the parent turn: the parent + * settles and goes interactive while the children keep working. Their lifecycle rides pi's process + * event bus rather than the extension API, so it is forwarded here as its own hook event and folded + * into the pane's descendant roster receiver-side — the parent's own `agent_end` stays the parent's. */ +export function getPiAgentStatusAsyncSubagentSourceLines(kind: PiAgentKind): string[] { + if (kind !== 'pi') { + return [] + } + + return [ + ' // Why: a bus this pi build does not emit on simply never fires; registering costs nothing', + ' // and keeps the parent-only path unchanged for installs without the subagent extension.', + ' try {', + ' const bus = process as unknown as { on?: (event: string, listener: (payload: unknown) => void) => void }', + ' const readRunId = (payload: unknown): string => {', + " if (!payload || typeof payload !== 'object') return ''", + ' const record = payload as Record', + " for (const key of ['runId', 'run_id', 'subagentId', 'subagent_id', 'id']) {", + ' const value = record[key]', + " if (typeof value === 'string' && value) return value", + ' }', + " return ''", + ' }', + ' const readText = (payload: unknown, keys: string[]): string | undefined => {', + " if (!payload || typeof payload !== 'object') return undefined", + ' const record = payload as Record', + ' for (const key of keys) {', + ' const value = record[key]', + " if (typeof value === 'string' && value) return value", + ' }', + ' return undefined', + ' }', + ' const postAsyncSubagent = (hookEventName: string, payload: unknown): void => {', + ' if (isOmpRuntime()) return', + ' const runId = readRunId(payload)', + ' if (!runId) return', + ' post(hookEventName, {', + ' subagent_id: runId,', + " agent_type: readText(payload, ['agentType', 'agent_type', 'subagentType']),", + " description: readText(payload, ['description', 'task', 'prompt']),", + ' })', + ' }', + " bus.on?.('subagent:async-started', (payload) => postAsyncSubagent('subagent_async_started', payload))", + " bus.on?.('subagent:async-complete', (payload) => postAsyncSubagent('subagent_async_complete', payload))", + ' } catch {', + ' // Why: status reporting must never fail the pi run; an unavailable bus just means no children.', + ' }', + '' + ] +} diff --git a/src/main/pi/agent-status-handler-source.ts b/src/main/pi/agent-status-handler-source.ts index 9d02abbd78d..f3a290bacbe 100644 --- a/src/main/pi/agent-status-handler-source.ts +++ b/src/main/pi/agent-status-handler-source.ts @@ -1,4 +1,5 @@ import type { PiAgentKind } from '../../shared/pi-agent-kind' +import { getPiAgentStatusAsyncSubagentSourceLines } from './agent-status-async-subagent-source' import { getPiAgentStatusUiPromptHandlerSourceLines } from './agent-status-ui-prompt-source' // Why: keep the generated handler registrations separate from hook transport; @@ -136,6 +137,7 @@ export function getPiAgentStatusHandlerSourceLines(kind: PiAgentKind): string[] ' })', '', ...approvalHandlers, + ...getPiAgentStatusAsyncSubagentSourceLines(kind), ...getPiAgentStatusUiPromptHandlerSourceLines(kind), " // Why: capture the assistant's final text on each completed message", ' // so the dashboard preview reflects the most recent reply even before', diff --git a/src/shared/codex-subagent-roster.test.ts b/src/shared/agent-descendant-roster.test.ts similarity index 55% rename from src/shared/codex-subagent-roster.test.ts rename to src/shared/agent-descendant-roster.test.ts index 0cbb44830c9..e9243dc30df 100644 --- a/src/shared/codex-subagent-roster.test.ts +++ b/src/shared/agent-descendant-roster.test.ts @@ -5,18 +5,18 @@ import { AGENT_TYPE_MAX_LENGTH } from './agent-status-types' import { - codexRosterToSnapshots, - finishCodexSubagent, - setCodexSubagentModel, - upsertCodexSubagent, - type CodexSubagentRoster -} from './codex-subagent-roster' + agentDescendantRosterToSnapshots, + finishAgentDescendant, + setAgentDescendantModel, + upsertAgentDescendant, + type AgentDescendantRoster +} from './agent-descendant-roster' describe('Codex subagent roster', () => { it('normalizes retained identity fields before storing them', () => { - const roster: CodexSubagentRoster = new Map() + const roster: AgentDescendantRoster = new Map() - upsertCodexSubagent( + upsertAgentDescendant( roster, ' child-1 ', { @@ -28,49 +28,49 @@ describe('Codex subagent roster', () => { 10 ) - const snapshot = codexRosterToSnapshots(roster)?.[0] + const snapshot = agentDescendantRosterToSnapshots(roster)?.[0] expect([...roster.keys()]).toEqual(['child-1']) expect(snapshot?.agentType).toHaveLength(AGENT_TYPE_MAX_LENGTH) expect(snapshot?.agentType).not.toContain('\n') expect(snapshot?.description).toBe('Review the sidebar lifecycle') expect(snapshot?.model).toHaveLength(AGENT_MODEL_MAX_LENGTH) - finishCodexSubagent(roster, ' child-1 ') + finishAgentDescendant(roster, ' child-1 ') expect(roster.size).toBe(0) }) it('rejects an id that would normalize to an invisible child', () => { - const roster: CodexSubagentRoster = new Map() + const roster: AgentDescendantRoster = new Map() - upsertCodexSubagent(roster, ' ', { state: 'waiting' }, 10) + upsertAgentDescendant(roster, ' ', { state: 'waiting' }, 10) expect(roster.size).toBe(0) }) it('bounds live storage while admitting a replacement after one child stops', () => { - const roster: CodexSubagentRoster = new Map() + const roster: AgentDescendantRoster = new Map() for (let index = 0; index <= AGENT_STATUS_MAX_SUBAGENTS; index += 1) { - upsertCodexSubagent(roster, `child-${index}`, { state: 'working' }, index) + upsertAgentDescendant(roster, `child-${index}`, { state: 'working' }, index) } expect(roster.size).toBe(AGENT_STATUS_MAX_SUBAGENTS) expect(roster.has(`child-${AGENT_STATUS_MAX_SUBAGENTS}`)).toBe(false) - finishCodexSubagent(roster, 'child-0') - upsertCodexSubagent(roster, 'replacement', { state: 'working' }, 100) + finishAgentDescendant(roster, 'child-0') + upsertAgentDescendant(roster, 'replacement', { state: 'working' }, 100) expect(roster.size).toBe(AGENT_STATUS_MAX_SUBAGENTS) expect(roster.has('replacement')).toBe(true) }) - describe('setCodexSubagentModel', () => { + describe('setAgentDescendantModel', () => { it('records the model without disturbing the child lifecycle or label', () => { - const roster: CodexSubagentRoster = new Map() - upsertCodexSubagent(roster, 'child-1', { description: '/root/audit', state: 'waiting' }, 10) + const roster: AgentDescendantRoster = new Map() + upsertAgentDescendant(roster, 'child-1', { description: '/root/audit', state: 'waiting' }, 10) - setCodexSubagentModel(roster, 'child-1', ' gpt-5.6-terra ') + setAgentDescendantModel(roster, 'child-1', ' gpt-5.6-terra ') - expect(codexRosterToSnapshots(roster)).toEqual([ + expect(agentDescendantRosterToSnapshots(roster)).toEqual([ { id: 'child-1', agentType: undefined, @@ -83,31 +83,31 @@ describe('Codex subagent roster', () => { }) it('never creates a row for a child that is no longer tracked', () => { - const roster: CodexSubagentRoster = new Map() - upsertCodexSubagent(roster, 'child-1', { state: 'working' }, 10) - finishCodexSubagent(roster, 'child-1') + const roster: AgentDescendantRoster = new Map() + upsertAgentDescendant(roster, 'child-1', { state: 'working' }, 10) + finishAgentDescendant(roster, 'child-1') // A model read racing a completed child must not resurrect its row. - setCodexSubagentModel(roster, 'child-1', 'gpt-5.6-terra') + setAgentDescendantModel(roster, 'child-1', 'gpt-5.6-terra') expect(roster.size).toBe(0) }) it('keeps a known model when the new value is empty', () => { - const roster: CodexSubagentRoster = new Map() - upsertCodexSubagent(roster, 'child-1', { model: 'gpt-5.6-sol', state: 'working' }, 10) + const roster: AgentDescendantRoster = new Map() + upsertAgentDescendant(roster, 'child-1', { model: 'gpt-5.6-sol', state: 'working' }, 10) - setCodexSubagentModel(roster, 'child-1', ' ') - setCodexSubagentModel(roster, 'child-1', undefined) + setAgentDescendantModel(roster, 'child-1', ' ') + setAgentDescendantModel(roster, 'child-1', undefined) expect(roster.get('child-1')?.model).toBe('gpt-5.6-sol') }) it('bounds an oversized model to the shared cap', () => { - const roster: CodexSubagentRoster = new Map() - upsertCodexSubagent(roster, 'child-1', { state: 'working' }, 10) + const roster: AgentDescendantRoster = new Map() + upsertAgentDescendant(roster, 'child-1', { state: 'working' }, 10) - setCodexSubagentModel(roster, 'child-1', 'x'.repeat(AGENT_MODEL_MAX_LENGTH + 50)) + setAgentDescendantModel(roster, 'child-1', 'x'.repeat(AGENT_MODEL_MAX_LENGTH + 50)) expect(roster.get('child-1')?.model).toHaveLength(AGENT_MODEL_MAX_LENGTH) }) diff --git a/src/shared/codex-subagent-roster.ts b/src/shared/agent-descendant-roster.ts similarity index 60% rename from src/shared/codex-subagent-roster.ts rename to src/shared/agent-descendant-roster.ts index 5592ed8711f..abe3aad3a32 100644 --- a/src/shared/codex-subagent-roster.ts +++ b/src/shared/agent-descendant-roster.ts @@ -3,15 +3,28 @@ import { AGENT_STATUS_MAX_SUBAGENTS, AGENT_STATUS_TOOL_INPUT_MAX_LENGTH, AGENT_TYPE_MAX_LENGTH, + type AgentStatusState, type AgentSubagentSnapshot } from './agent-status-types' import { normalizeOptionalField } from './agent-status-field-normalization' -const CODEX_SUBAGENT_ID_MAX_LENGTH = 64 +/** Live descendants (subagents, spawned threads, async child runs) of one pane's + * lead agent session, keyed by the provider-assigned child id. + * + * Provider-agnostic on purpose: the pane rule "idle only when the lead is idle + * AND no descendant is live" is one concept, so it has one implementation. A + * provider contributes only how to READ a child out of its hook events + * (`agent-hook-listener/descendant-events.ts`). Claude keeps its own roster + * because its children carry provider-specific reconciliation (teammate + * parking, `background_tasks` folding, restored-snapshot provenance) that no + * other provider has; it implements the same pane rule in + * `providers/claude-roster-state.ts`. */ -export type CodexSubagentRoster = Map +const AGENT_DESCENDANT_ID_MAX_LENGTH = 64 -type TrackedCodexSubagent = { +export type AgentDescendantRoster = Map + +type TrackedAgentDescendant = { agentType?: string description?: string model?: string @@ -19,8 +32,8 @@ type TrackedCodexSubagent = { startedAt: number } -export function upsertCodexSubagent( - roster: CodexSubagentRoster, +export function upsertAgentDescendant( + roster: AgentDescendantRoster, id: string, fields: { agentType?: string @@ -31,7 +44,7 @@ export function upsertCodexSubagent( now: number ): void { const normalizedId = id.trim() - if (normalizedId.length === 0 || normalizedId.length > CODEX_SUBAGENT_ID_MAX_LENGTH) { + if (normalizedId.length === 0 || normalizedId.length > AGENT_DESCENDANT_ID_MAX_LENGTH) { return } const agentType = normalizeOptionalField(fields.agentType, AGENT_TYPE_MAX_LENGTH) @@ -57,18 +70,18 @@ export function upsertCodexSubagent( }) } -export function finishCodexSubagent(roster: CodexSubagentRoster, id: string): void { +export function finishAgentDescendant(roster: AgentDescendantRoster, id: string): void { roster.delete(id.trim()) } /** * Record the model a already-tracked child is running. Deliberately narrower - * than `upsertCodexSubagent`: it never creates a roster entry and never touches + * than `upsertAgentDescendant`: it never creates a roster entry and never touches * `state`, so late model discovery from a child rollout cannot resurrect a * finished child nor move any child's lifecycle. */ -export function setCodexSubagentModel( - roster: CodexSubagentRoster, +export function setAgentDescendantModel( + roster: AgentDescendantRoster, id: string, model: string | undefined ): void { @@ -83,15 +96,15 @@ export function setCodexSubagentModel( existing.model = normalizedModel } -export function seedCodexSubagentRoster( - roster: CodexSubagentRoster, +export function seedAgentDescendantRoster( + roster: AgentDescendantRoster, snapshots: readonly AgentSubagentSnapshot[] ): void { for (const snapshot of snapshots) { if (snapshot.state !== 'working' && snapshot.state !== 'waiting') { continue } - upsertCodexSubagent( + upsertAgentDescendant( roster, snapshot.id, { @@ -105,8 +118,8 @@ export function seedCodexSubagentRoster( } } -export function codexRosterToSnapshots( - roster: CodexSubagentRoster | undefined +export function agentDescendantRosterToSnapshots( + roster: AgentDescendantRoster | undefined ): AgentSubagentSnapshot[] | undefined { if (!roster || roster.size === 0) { return undefined @@ -123,10 +136,14 @@ export function codexRosterToSnapshots( return snapshots } -export function codexRosterEffectiveState( - roster: CodexSubagentRoster | undefined, - leadState: 'working' | 'waiting' | 'done' -): 'working' | 'waiting' | 'done' { +/** The pane's state once its live descendants are taken into account: a lead + * `done` is only the pane's `done` when nothing is still running under it. A + * descendant blocked on a human answer outranks the lead's own working state, + * since that wait is the actionable one. */ +export function agentDescendantEffectiveState( + roster: AgentDescendantRoster | undefined, + leadState: AgentStatusState +): AgentStatusState { if (!roster || roster.size === 0) { return leadState } diff --git a/src/shared/agent-hook-listener-descendant-lifecycle.test.ts b/src/shared/agent-hook-listener-descendant-lifecycle.test.ts new file mode 100644 index 00000000000..52f297e1e5a --- /dev/null +++ b/src/shared/agent-hook-listener-descendant-lifecycle.test.ts @@ -0,0 +1,230 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { + createHookListenerState, + type HookListenerState +} from './agent-hook-listener/listener-state' +import { normalizeAndAccept, PANE_KEY } from './agent-hook-listener-test-harness' +import type { AgentHookSource } from './agent-hook-relay' + +/** Every case drives `normalizeHookPayload`, the entry both the main process and the relay + * call, so a fix that never reaches production wiring cannot pass here. */ +describe('descendant lifecycle never settles the pane', () => { + let state: HookListenerState + + beforeEach(() => { + state = createHookListenerState() + }) + + const publish = ( + source: AgentHookSource, + payload: Record + ): ReturnType => normalizeAndAccept(state, source, payload) + + const publishedState = ( + source: AgentHookSource, + payload: Record + ): string | undefined => publish(source, payload)?.payload.state + + describe('grok nested subagents (STA-6982)', () => { + const startTurn = (): void => { + expect(publishedState('grok', { hookEventName: 'UserPromptSubmit', prompt: 'ship it' })).toBe( + 'working' + ) + } + + it('keeps the pane working when a nested child session ends', () => { + startTurn() + // Why: grok remaps a child's turn gate to SubagentStop, so the child's own SessionEnd is what + // reaches the parent's pane. It carries subagentType; the session's own SessionEnd never does. + expect( + publishedState('grok', { + hookEventName: 'SessionEnd', + reason: 'clear', + subagentType: 'explore' + }) + ).toBe('working') + }) + + it('keeps the pane working when a nested child turn fails', () => { + startTurn() + expect( + publishedState('grok', { + hookEventName: 'StopFailure', + error: 'rate_limit', + subagentType: 'explore' + }) + ).toBe('working') + }) + + it('still settles the pane on the lead session own stop', () => { + startTurn() + expect(publishedState('grok', { hookEventName: 'Stop', reason: 'end_turn' })).toBe('done') + }) + + it('holds the pane working while a tracked child outlives the lead turn', () => { + startTurn() + const spawned = publish('grok', { + hookEventName: 'SubagentStart', + subagentId: 'sub-1', + subagentType: 'explore', + description: 'review the diff' + }) + expect(spawned?.payload.state).toBe('working') + expect(spawned?.payload.subagents).toEqual([ + expect.objectContaining({ id: 'sub-1', state: 'working', agentType: 'explore' }) + ]) + + // The lead's own Stop is real, but a live child means the pane is not idle yet. + expect(publishedState('grok', { hookEventName: 'Stop', reason: 'end_turn' })).toBe('working') + + const drained = publish('grok', { + hookEventName: 'SubagentStop', + subagentId: 'sub-1', + subagentType: 'explore' + }) + expect(drained?.payload.state).toBe('done') + expect(drained?.payload.subagents).toBeUndefined() + }) + + it('publishes exactly one done across a child-then-lead completion', () => { + startTurn() + publish('grok', { hookEventName: 'SubagentStart', subagentId: 'sub-1', subagentType: 'x' }) + const states = [ + publishedState('grok', { hookEventName: 'SessionEnd', reason: 'clear', subagentType: 'x' }), + publishedState('grok', { hookEventName: 'Stop', reason: 'end_turn' }), + publishedState('grok', { + hookEventName: 'SubagentStop', + subagentId: 'sub-1', + subagentType: 'x' + }) + ] + expect(states).toEqual(['working', 'working', 'done']) + expect(states.filter((value) => value === 'done')).toHaveLength(1) + }) + + it('does not relabel the pane with a child tool call', () => { + startTurn() + const parentTool = publish('grok', { + hookEventName: 'PreToolUse', + toolName: 'edit_file', + toolInput: { path: 'src/app.ts' } + }) + expect(parentTool?.payload.toolName).toBe('edit_file') + + const childTool = publish('grok', { + hookEventName: 'PreToolUse', + subagentType: 'explore', + subagentId: 'sub-1', + toolName: 'run_terminal_cmd', + toolInput: { command: 'rg TODO' } + }) + expect(childTool?.payload.state).toBe('working') + expect(childTool?.payload.toolName).toBe('edit_file') + expect(childTool?.payload.prompt).toBe('ship it') + expect(childTool?.hasExplicitPrompt).toBeUndefined() + }) + + it('drops a stale child roster when the pane starts a new agent process', () => { + startTurn() + publish('grok', { hookEventName: 'SubagentStart', subagentId: 'sub-1', subagentType: 'x' }) + expect(publishedState('grok', { hookEventName: 'Stop', reason: 'end_turn' })).toBe('working') + + // Why: a child whose stop hook was lost must not pin the pane forever; a replaced agent + // process cannot still have the old process's children. + publish('grok', { hookEventName: 'SessionStart', source: 'startup' }) + expect(publishedState('grok', { hookEventName: 'UserPromptSubmit', prompt: 'again' })).toBe( + 'working' + ) + expect(publishedState('grok', { hookEventName: 'Stop', reason: 'end_turn' })).toBe('done') + }) + }) + + describe('pi async subagent runs (STA-6378)', () => { + const startTurn = (): void => { + expect( + publishedState('pi', { hook_event_name: 'before_agent_start', prompt: 'delegate' }) + ).toBe('working') + expect(publishedState('pi', { hook_event_name: 'agent_start' })).toBe('working') + } + + it('settles the pane when the parent ends with no async children', () => { + startTurn() + expect(publishedState('pi', { hook_event_name: 'agent_end' })).toBe('done') + }) + + it('stays working when the parent settles while an async child run continues', () => { + startTurn() + const started = publish('pi', { + hook_event_name: 'subagent_async_started', + subagent_id: 'run-1', + agent_type: 'researcher' + }) + expect(started?.payload.state).toBe('working') + expect(started?.payload.subagents).toEqual([ + expect.objectContaining({ id: 'run-1', state: 'working', agentType: 'researcher' }) + ]) + + expect(publishedState('pi', { hook_event_name: 'agent_end' })).toBe('working') + + const finished = publish('pi', { + hook_event_name: 'subagent_async_complete', + subagent_id: 'run-1' + }) + expect(finished?.payload.state).toBe('done') + expect(finished?.payload.subagents).toBeUndefined() + }) + + it('completes once when the final child wakes the parent for another turn', () => { + startTurn() + publish('pi', { hook_event_name: 'subagent_async_started', subagent_id: 'run-1' }) + publish('pi', { hook_event_name: 'subagent_async_started', subagent_id: 'run-2' }) + + const states = [ + publishedState('pi', { hook_event_name: 'agent_end' }), + publishedState('pi', { hook_event_name: 'subagent_async_complete', subagent_id: 'run-1' }), + // The last child wakes the parent, which runs another turn before the pane is idle. + publishedState('pi', { hook_event_name: 'subagent_async_complete', subagent_id: 'run-2' }), + publishedState('pi', { hook_event_name: 'agent_start' }), + publishedState('pi', { hook_event_name: 'agent_end' }) + ] + expect(states).toEqual(['working', 'working', 'done', 'working', 'done']) + }) + + it('keeps the parent prompt while an async child reports', () => { + startTurn() + const started = publish('pi', { + hook_event_name: 'subagent_async_started', + subagent_id: 'run-1', + prompt: 'child task text' + }) + expect(started?.payload.prompt).toBe('delegate') + expect(started?.hasExplicitPrompt).toBeUndefined() + }) + }) + + describe('pane-scoped state bookkeeping', () => { + it('reports a descendant-only pane as holding a state claim', async () => { + const { paneHasStateClaims, clearPaneCacheState } = + await import('./agent-hook-listener/listener-state') + publish('grok', { hookEventName: 'UserPromptSubmit', prompt: 'go' }) + publish('grok', { hookEventName: 'SubagentStart', subagentId: 'sub-1', subagentType: 'x' }) + expect(paneHasStateClaims(state, PANE_KEY)).toBe(true) + + clearPaneCacheState(state, PANE_KEY) + expect(state.descendantRosterByPaneKey.has(PANE_KEY)).toBe(false) + expect(state.descendantLeadStateByPaneKey.has(PANE_KEY)).toBe(false) + }) + + it('moves descendant state with the pane when its key is remapped', async () => { + const { movePaneCacheState } = await import('./agent-hook-listener/listener-state') + publish('grok', { hookEventName: 'UserPromptSubmit', prompt: 'go' }) + publish('grok', { hookEventName: 'SubagentStart', subagentId: 'sub-1', subagentType: 'x' }) + + movePaneCacheState(state, PANE_KEY, 'tab-2:22222222-2222-4222-8222-222222222222') + expect(state.descendantRosterByPaneKey.has(PANE_KEY)).toBe(false) + expect( + state.descendantRosterByPaneKey.get('tab-2:22222222-2222-4222-8222-222222222222')?.size + ).toBe(1) + }) + }) +}) diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index f1a05ad1412..f0fdaa044dd 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -136,8 +136,12 @@ export function normalizeHookPayload( // Normalization is transport-agnostic; only ingestRemote knows the mux identity to stamp. connectionId: null, ...(restoredUnconfirmed ? { restoredUnconfirmed: true } : {}), - hasExplicitPrompt: - source === 'amp' + // Why: nothing on a descendant's event describes the pane's own turn — counting a child's + // prompt as a user submit would move turn-scoped state (telemetry, permission stickiness) + // on the parent. + hasExplicitPrompt: dispatched.descendantScoped + ? undefined + : source === 'amp' ? hasExplicitAmpPrompt(eventName, promptText, hookPayloadRecord) ? true : undefined diff --git a/src/shared/agent-hook-listener/descendant-events.ts b/src/shared/agent-hook-listener/descendant-events.ts new file mode 100644 index 00000000000..ee51c7960e6 --- /dev/null +++ b/src/shared/agent-hook-listener/descendant-events.ts @@ -0,0 +1,137 @@ +import type { AgentHookSource } from '../agent-hook-relay' +import { readFirstString } from './interactive-tool' +import { isGrokEvent, normalizeHookEventName } from './provider-event-names' + +/** What one hook event says about a DESCENDANT of the pane's lead session. + * + * A descendant's lifecycle is not the pane's: it may add or remove a child row, + * and it may never settle the pane or fire completion. `id` is optional because + * some providers only mark an event as "this fired inside a child" without + * naming which one — that is still enough to refuse the settle. */ +export type DescendantEventFacts = { + id?: string + agentType?: string + description?: string + model?: string + /** The descendant's own turn or session ended. */ + ended: boolean +} + +/** Providers whose normalizer already tracks its own descendants and derives the + * pane state from them. Claude's roster carries teammate parking, background-task + * folding and restored-snapshot provenance; Codex's carries per-child models and + * rollout reconciliation. Both already implement the pane rule, so the generic + * path must not run a second, blinder copy over the same events. */ +export function providerOwnsDescendantLifecycle(source: AgentHookSource): boolean { + return source === 'claude' || source === 'codex' +} + +/** Provider-assigned child id under the names hook payloads actually use. */ +function readDescendantId(hookPayload: Record): string | undefined { + return readFirstString(hookPayload, [ + 'subagentId', + 'subagent_id', + 'agentId', + 'agent_id', + 'runId', + 'run_id' + ]) +} + +function readGrokDescendantFacts( + eventName: unknown, + hookPayload: Record +): DescendantEventFacts | null { + const isLifecycleEvent = isGrokEvent(eventName, 'subagent_start', 'subagent_stop', 'subagent_end') + const subagentType = readFirstString(hookPayload, ['subagentType', 'subagent_type']) + // Why: grok stamps `subagentType` on every event that can fire inside a child and omits it in + // the main session, so its presence — not the event name — is what tells a child apart. A + // background child outlives the parent turn and inherits the pane key, so without this its + // SessionEnd/StopFailure lands on the parent as a completion. + if (!isLifecycleEvent && subagentType === undefined) { + return null + } + return { + id: readFirstString(hookPayload, ['subagentId', 'subagent_id']), + agentType: subagentType, + description: readFirstString(hookPayload, ['description']), + ended: + isGrokEvent(eventName, 'subagent_stop', 'subagent_end') || + (subagentType !== undefined && + isGrokEvent(eventName, 'stop', 'session_end', 'stop_failure', 'stop_cancelled')) + } +} + +function readPiDescendantFacts( + eventName: unknown, + hookPayload: Record +): DescendantEventFacts | null { + const normalized = normalizeHookEventName(eventName) + if (normalized !== 'subagent_async_started' && normalized !== 'subagent_async_complete') { + return null + } + return { + id: readDescendantId(hookPayload), + agentType: readFirstString(hookPayload, ['agentType', 'agent_type', 'subagentType']), + description: readFirstString(hookPayload, ['description', 'task', 'prompt']), + model: readFirstString(hookPayload, ['model']), + ended: normalized === 'subagent_async_complete' + } +} + +/** Read a descendant out of one provider's hook event, or null when the event + * belongs to the pane's lead session. + * + * Exhaustive on purpose: a new provider has to answer "does this CLI report + * child sessions on the parent's pane?" here rather than inherit a guess. The + * answer is one line, and `readDescendantId` covers the field names in use. */ +export function readDescendantEventFacts( + source: AgentHookSource, + eventName: unknown, + hookPayload: Record +): DescendantEventFacts | null { + switch (source) { + // Why: both own their rosters; `providerOwnsDescendantLifecycle` already routes around this, + // and answering here too would let a future caller run the generic path over their events. + case 'claude': + case 'codex': + return null + case 'grok': + return readGrokDescendantFacts(eventName, hookPayload) + case 'pi': + case 'omp': + case 'prime-agent': + return readPiDescendantFacts(eventName, hookPayload) + case 'gemini': + case 'antigravity': + case 'amp': + case 'opencode': + case 'mimo-code': + case 'cursor': + case 'droid': + case 'command-code': + case 'copilot': + case 'hermes': + case 'devin': + case 'kimi': + // No child-session hook surface reaches Orca for these today. When one gains + // one, read it here — the roster and the pane rule already exist. + return null + } +} + +/** An event that replaces the pane's agent process, so descendants of the process + * it replaced can no longer be alive. The roster's recovery from a lost child + * stop: without it a dropped stop hook would pin the pane 'working' forever. */ +export function isDescendantScopeResetEvent(source: AgentHookSource, eventName: unknown): boolean { + switch (source) { + case 'grok': + return isGrokEvent(eventName, 'session_start') + case 'pi': + case 'omp': + case 'prime-agent': + return eventName === 'session_start' + default: + return false + } +} diff --git a/src/shared/agent-hook-listener/descendant-pane-state.ts b/src/shared/agent-hook-listener/descendant-pane-state.ts new file mode 100644 index 00000000000..bfd8bbe63ca --- /dev/null +++ b/src/shared/agent-hook-listener/descendant-pane-state.ts @@ -0,0 +1,102 @@ +import type { AgentHookSource } from '../agent-hook-relay' +import { + agentDescendantEffectiveState, + agentDescendantRosterToSnapshots, + finishAgentDescendant, + upsertAgentDescendant, + type AgentDescendantRoster +} from '../agent-descendant-roster' +import { normalizeAgentStatusPayload, type ParsedAgentStatusPayload } from '../agent-status-types' +import type { DescendantEventFacts } from './descendant-events' +import type { HookListenerState } from './listener-state' + +function getOrCreateDescendantRoster( + state: HookListenerState, + paneKey: string +): AgentDescendantRoster { + let roster = state.descendantRosterByPaneKey.get(paneKey) + if (!roster) { + roster = new Map() + state.descendantRosterByPaneKey.set(paneKey, roster) + } + return roster +} + +/** Apply a descendant's lifecycle event: it updates the child list and republishes + * the pane from the LEAD's last known state, so a child finishing can never be the + * pane's completion. The lead's own tool/prompt caches are left untouched — a + * child's tool call is not the pane's, and overwriting them would relabel the row + * with the child's work. */ +export function applyDescendantEventToPane( + state: HookListenerState, + source: AgentHookSource, + paneKey: string, + facts: DescendantEventFacts +): ParsedAgentStatusPayload | null { + if (facts.id) { + if (facts.ended) { + const roster = state.descendantRosterByPaneKey.get(paneKey) + if (roster) { + finishAgentDescendant(roster, facts.id) + if (roster.size === 0) { + state.descendantRosterByPaneKey.delete(paneKey) + } + } + } else { + upsertAgentDescendant( + getOrCreateDescendantRoster(state, paneKey), + facts.id, + { + agentType: facts.agentType, + description: facts.description, + model: facts.model, + state: 'working' + }, + Date.now() + ) + } + } + + // Why: a child event before any lead event still proves the pane is working — the lead spawned it. + const leadState = state.descendantLeadStateByPaneKey.get(paneKey) ?? 'working' + const cachedTool = state.lastToolByPaneKey.get(paneKey) ?? {} + return normalizeAgentStatusPayload({ + state: agentDescendantEffectiveState(state.descendantRosterByPaneKey.get(paneKey), leadState), + prompt: state.lastPromptByPaneKey.get(paneKey) ?? '', + agentType: source, + toolName: cachedTool.toolName, + toolInput: cachedTool.toolInput, + interactivePrompt: cachedTool.interactivePrompt, + lastAssistantMessage: cachedTool.lastAssistantMessage, + lastAssistantMessageIsToolOutput: cachedTool.lastAssistantMessageIsToolOutput, + subagents: agentDescendantRosterToSnapshots(state.descendantRosterByPaneKey.get(paneKey)) + }) +} + +/** The pane is idle only when its lead session is idle AND no descendant is live. + * Records the lead's own verdict first, so draining the last descendant later + * republishes what the lead actually said instead of the gated value. */ +export function gatePaneStateOnDescendants( + state: HookListenerState, + paneKey: string, + payload: ParsedAgentStatusPayload | null +): ParsedAgentStatusPayload | null { + if (!payload) { + return payload + } + state.descendantLeadStateByPaneKey.set(paneKey, payload.state) + const roster = state.descendantRosterByPaneKey.get(paneKey) + if (!roster || roster.size === 0) { + return payload + } + return { + ...payload, + state: agentDescendantEffectiveState(roster, payload.state), + subagents: agentDescendantRosterToSnapshots(roster) + } +} + +export function clearDescendantScope(state: HookListenerState, paneKey: string): void { + state.descendantRosterByPaneKey.delete(paneKey) + state.descendantLeadStateByPaneKey.delete(paneKey) +} diff --git a/src/shared/agent-hook-listener/listener-state.ts b/src/shared/agent-hook-listener/listener-state.ts index f6f9308dc62..fc0792a5f4f 100644 --- a/src/shared/agent-hook-listener/listener-state.ts +++ b/src/shared/agent-hook-listener/listener-state.ts @@ -1,6 +1,6 @@ import type { AgentStatusState } from '../agent-status-types' import type { ClaudeSubagentRoster } from '../claude-subagent-roster' -import type { CodexSubagentRoster } from '../codex-subagent-roster' +import type { AgentDescendantRoster } from '../agent-descendant-roster' import type { CodexSubagentTranscriptState } from '../codex-subagent-transcript' import type { AgentHookEventPayload, ToolSnapshot } from './listener-event' @@ -30,11 +30,16 @@ export type HookListenerState = { * even when no SessionStart arrives — the backstop for the exits that emit no terminating hook. */ claudeSessionOwnerByPaneKey: Map /** Live thread-spawn children per Codex pane. */ - codexSubagentRosterByPaneKey: Map + codexSubagentRosterByPaneKey: Map /** Incremental parent/child rollout cursors for Codex collaboration v2. */ codexSubagentTranscriptByPaneKey: Map /** Root Codex state/model, kept separate from child hook traffic. */ codexLeadStateByPaneKey: Map + /** Live descendants for every provider that does not own a roster of its own. */ + descendantRosterByPaneKey: Map + /** What the LEAD session last said, before descendants gated it — so draining the + * last child republishes the lead's verdict instead of the gated one. */ + descendantLeadStateByPaneKey: Map } export type ClaudeLeadTurnState = { @@ -73,7 +78,9 @@ export function createHookListenerState(): HookListenerState { claudeSessionOwnerByPaneKey: new Map(), codexSubagentRosterByPaneKey: new Map(), codexSubagentTranscriptByPaneKey: new Map(), - codexLeadStateByPaneKey: new Map() + codexLeadStateByPaneKey: new Map(), + descendantRosterByPaneKey: new Map(), + descendantLeadStateByPaneKey: new Map() } } @@ -93,6 +100,8 @@ export function clearPaneCacheState(state: HookListenerState, paneKey: string): state.codexSubagentRosterByPaneKey.delete(paneKey) state.codexSubagentTranscriptByPaneKey.delete(paneKey) state.codexLeadStateByPaneKey.delete(paneKey) + state.descendantRosterByPaneKey.delete(paneKey) + state.descendantLeadStateByPaneKey.delete(paneKey) } /** Does this pane still hold anything that can ASSERT a state — a stored row, or a Claude latch that @@ -111,7 +120,9 @@ export function paneHasStateClaims(state: HookListenerState, paneKey: string): b state.claudeActiveSessionCronPaneKeys.has(paneKey) || state.claudeSessionOwnerByPaneKey.has(paneKey) || state.codexSubagentRosterByPaneKey.has(paneKey) || - state.codexLeadStateByPaneKey.has(paneKey) + state.codexLeadStateByPaneKey.has(paneKey) || + state.descendantRosterByPaneKey.has(paneKey) || + state.descendantLeadStateByPaneKey.has(paneKey) ) } @@ -166,6 +177,8 @@ export function movePaneCacheState( movePaneScopedMapEntries(state.codexSubagentRosterByPaneKey, fromPaneKey, toPaneKey) movePaneScopedMapEntries(state.codexSubagentTranscriptByPaneKey, fromPaneKey, toPaneKey) movePaneScopedMapEntries(state.codexLeadStateByPaneKey, fromPaneKey, toPaneKey) + movePaneScopedMapEntries(state.descendantRosterByPaneKey, fromPaneKey, toPaneKey) + movePaneScopedMapEntries(state.descendantLeadStateByPaneKey, fromPaneKey, toPaneKey) } export function clearPaneTurnCacheState(state: HookListenerState, paneKey: string): void { @@ -213,4 +226,6 @@ export function clearAllListenerCaches(state: HookListenerState): void { state.codexSubagentRosterByPaneKey.clear() state.codexSubagentTranscriptByPaneKey.clear() state.codexLeadStateByPaneKey.clear() + state.descendantRosterByPaneKey.clear() + state.descendantLeadStateByPaneKey.clear() } diff --git a/src/shared/agent-hook-listener/provider-dispatch.ts b/src/shared/agent-hook-listener/provider-dispatch.ts index 473bb557d09..b6860279c20 100644 --- a/src/shared/agent-hook-listener/provider-dispatch.ts +++ b/src/shared/agent-hook-listener/provider-dispatch.ts @@ -3,6 +3,16 @@ import type { AgentHookSource } from '../agent-hook-relay' import { readLastCommandCodeUserPromptEntryFromTranscript } from './command-code-transcript' import { readGrokHomeEnvelope } from './grok-result-discovery' import { readFirstString } from './interactive-tool' +import { + isDescendantScopeResetEvent, + providerOwnsDescendantLifecycle, + readDescendantEventFacts +} from './descendant-events' +import { + applyDescendantEventToPane, + clearDescendantScope, + gatePaneStateOnDescendants +} from './descendant-pane-state' import type { HookListenerState } from './listener-state' import type { ExtractedPromptText } from './prompt-fields' import { isNewTurnEvent } from './provider-event-routing' @@ -28,6 +38,8 @@ export type ProviderDispatchResult = { resolvedPromptText: string promptInteractionKey?: string hasTranscriptPromptEvidence: boolean + /** The event fired inside a descendant, so nothing on it describes the pane's own turn. */ + descendantScoped?: true } /** Exhaustive provider routing with provider-specific transcript locality and attribution. */ @@ -48,6 +60,26 @@ export function normalizeProviderEvent(input: { let hasTranscriptPromptEvidence = false let payload: ParsedAgentStatusPayload | null + // Why: the pane rule — idle only when the lead is idle AND no descendant is live — is applied + // here, once, for every provider that does not own a roster. A child's lifecycle event never + // reaches the provider normalizer, so it cannot settle the pane or relabel the row. + const ownsDescendants = providerOwnsDescendantLifecycle(source) + if (!ownsDescendants) { + if (isDescendantScopeResetEvent(source, eventName)) { + clearDescendantScope(state, paneKey) + } + const descendant = readDescendantEventFacts(source, eventName, hookPayload) + if (descendant) { + return { + payload: applyDescendantEventToPane(state, source, paneKey, descendant), + // Why: a child's prompt is not the pane's turn label, and its tool events are not a user submit. + resolvedPromptText: '', + hasTranscriptPromptEvidence: false, + descendantScoped: true + } + } + } + switch (source) { case 'claude': payload = normalizeClaudeEvent(state, eventName, promptText, paneKey, hookPayload) @@ -150,5 +182,10 @@ export function normalizeProviderEvent(input: { break } - return { payload, resolvedPromptText, promptInteractionKey, hasTranscriptPromptEvidence } + return { + payload: ownsDescendants ? payload : gatePaneStateOnDescendants(state, paneKey, payload), + resolvedPromptText, + promptInteractionKey, + hasTranscriptPromptEvidence + } } diff --git a/src/shared/agent-hook-listener/providers/codex-events.ts b/src/shared/agent-hook-listener/providers/codex-events.ts index bcd0474b940..1167712fad4 100644 --- a/src/shared/agent-hook-listener/providers/codex-events.ts +++ b/src/shared/agent-hook-listener/providers/codex-events.ts @@ -1,16 +1,17 @@ import { AGENT_MODEL_MAX_LENGTH, normalizeAgentStatusPayload, + type AgentStatusState, type ParsedAgentStatusPayload } from '../../agent-status-types' import { normalizeOptionalField } from '../../agent-status-field-normalization' import { isAskUserQuestionTool } from '../../agent-question-answered-intent' import { - codexRosterEffectiveState, - codexRosterToSnapshots, - finishCodexSubagent, - upsertCodexSubagent -} from '../../codex-subagent-roster' + agentDescendantEffectiveState, + agentDescendantRosterToSnapshots, + finishAgentDescendant, + upsertAgentDescendant +} from '../../agent-descendant-roster' import { reconcileCodexSubagentTranscript } from '../../codex-subagent-transcript' import { readFirstString } from '../interactive-tool' import type { HookListenerState } from '../listener-state' @@ -18,7 +19,7 @@ import { resolvePrompt, resolveToolState } from '../prompt-fields' import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' import { readString } from '../tool-input-preview' import { - getOrCreateCodexSubagentRoster, + getOrCreateAgentDescendantRoster, getOrCreateCodexSubagentTranscriptState, hasCodexTranscriptSubagents } from './codex-state' @@ -29,7 +30,7 @@ export function buildCodexStatusPayload( promptText: string, paneKey: string, hookPayload: Record, - options: { stateName: 'working' | 'waiting' | 'done'; updateLead: boolean } + options: { stateName: AgentStatusState; updateLead: boolean } ): ParsedAgentStatusPayload | null { const snapshot = options.updateLead ? resolveToolState(state, paneKey, extractToolFields('codex', eventName, hookPayload), { @@ -50,7 +51,7 @@ export function buildCodexStatusPayload( interactivePrompt: snapshot.interactivePrompt, lastAssistantMessage: snapshot.lastAssistantMessage, lastAssistantMessageIsToolOutput: snapshot.lastAssistantMessageIsToolOutput, - subagents: codexRosterToSnapshots(state.codexSubagentRosterByPaneKey.get(paneKey)) + subagents: agentDescendantRosterToSnapshots(state.codexSubagentRosterByPaneKey.get(paneKey)) }) } @@ -61,7 +62,7 @@ export function buildCodexChildDrivenStatusPayload( hookPayload: Record ): ParsedAgentStatusPayload | null { const leadState = state.codexLeadStateByPaneKey.get(paneKey)?.state ?? 'working' - const stateName = codexRosterEffectiveState( + const stateName = agentDescendantEffectiveState( state.codexSubagentRosterByPaneKey.get(paneKey), leadState ) @@ -81,9 +82,9 @@ export function normalizeCodexSubagentLifecycleEvent( if (!agentId) { return null } - const roster = getOrCreateCodexSubagentRoster(state, paneKey) + const roster = getOrCreateAgentDescendantRoster(state, paneKey) if (eventName === 'SubagentStart') { - upsertCodexSubagent( + upsertAgentDescendant( roster, agentId, { @@ -94,7 +95,7 @@ export function normalizeCodexSubagentLifecycleEvent( Date.now() ) } else { - finishCodexSubagent(roster, agentId) + finishAgentDescendant(roster, agentId) } return buildCodexChildDrivenStatusPayload(state, eventName, paneKey, hookPayload) } @@ -131,8 +132,8 @@ export function normalizeCodexEvent( const agentId = readString(hookPayload, 'agent_id') if (agentId) { - upsertCodexSubagent( - getOrCreateCodexSubagentRoster(state, paneKey), + upsertAgentDescendant( + getOrCreateAgentDescendantRoster(state, paneKey), agentId, { agentType: readString(hookPayload, 'agent_type'), @@ -153,7 +154,7 @@ export function normalizeCodexEvent( if (transcriptPath) { reconcileCodexSubagentTranscript( getOrCreateCodexSubagentTranscriptState(state, paneKey), - getOrCreateCodexSubagentRoster(state, paneKey), + getOrCreateAgentDescendantRoster(state, paneKey), transcriptPath ) } @@ -168,7 +169,7 @@ export function normalizeCodexEvent( normalizeOptionalField(hookPayload['model'], AGENT_MODEL_MAX_LENGTH) ?? (eventName === 'SessionStart' ? undefined : previousLead?.model) }) - const effectiveState = codexRosterEffectiveState( + const effectiveState = agentDescendantEffectiveState( state.codexSubagentRosterByPaneKey.get(paneKey), stateName ) diff --git a/src/shared/agent-hook-listener/providers/codex-state.ts b/src/shared/agent-hook-listener/providers/codex-state.ts index 1131008ca7a..5ce589d0f03 100644 --- a/src/shared/agent-hook-listener/providers/codex-state.ts +++ b/src/shared/agent-hook-listener/providers/codex-state.ts @@ -1,11 +1,11 @@ import type { ParsedAgentStatusPayload } from '../../agent-status-types' import { - codexRosterEffectiveState, - codexRosterToSnapshots, - finishCodexSubagent, - seedCodexSubagentRoster, - type CodexSubagentRoster -} from '../../codex-subagent-roster' + agentDescendantEffectiveState, + agentDescendantRosterToSnapshots, + finishAgentDescendant, + seedAgentDescendantRoster, + type AgentDescendantRoster +} from '../../agent-descendant-roster' import { createCodexSubagentTranscriptState, hasTrackedCodexTranscriptSubagents, @@ -13,10 +13,10 @@ import { } from '../../codex-subagent-transcript' import type { CodexLeadTurnState, HookListenerState } from '../listener-state' -export function getOrCreateCodexSubagentRoster( +export function getOrCreateAgentDescendantRoster( state: HookListenerState, paneKey: string -): CodexSubagentRoster { +): AgentDescendantRoster { let roster = state.codexSubagentRosterByPaneKey.get(paneKey) if (!roster) { roster = new Map() @@ -48,7 +48,7 @@ export function seedCodexStateFromSnapshot( ): void { const snapshots = payload.subagents ?? [] if (snapshots.length > 0 && !state.codexSubagentRosterByPaneKey.has(paneKey)) { - seedCodexSubagentRoster(getOrCreateCodexSubagentRoster(state, paneKey), snapshots) + seedAgentDescendantRoster(getOrCreateAgentDescendantRoster(state, paneKey), snapshots) } if (!state.codexLeadStateByPaneKey.has(paneKey)) { // Why: child hooks after restart omit the root model; seed it from durable status before they can overwrite the cache. @@ -111,13 +111,13 @@ export function reconcileRemoteCodexState( if (agentId && !payload.subagents && !state.codexSubagentRosterByPaneKey.has(paneKey)) { return payload } - const roster = getOrCreateCodexSubagentRoster(state, paneKey) + const roster = getOrCreateAgentDescendantRoster(state, paneKey) if (payload.subagents) { - seedCodexSubagentRoster(roster, payload.subagents) + seedAgentDescendantRoster(roster, payload.subagents) } if (agentId) { if (eventName === 'SubagentStop') { - finishCodexSubagent(roster, agentId) + finishAgentDescendant(roster, agentId) } } else { const leadState = codexLeadStateForHookEvent(eventName) @@ -146,8 +146,8 @@ export function reconcileRemoteCodexState( return { ...payload, prompt, - state: codexRosterEffectiveState(roster, lead.state), + state: agentDescendantEffectiveState(roster, lead.state), model: lead.model ?? payload.model, - subagents: codexRosterToSnapshots(roster) + subagents: agentDescendantRosterToSnapshots(roster) } } diff --git a/src/shared/codex-subagent-transcript.test.ts b/src/shared/codex-subagent-transcript.test.ts index fda077a1e50..a58a4f711da 100644 --- a/src/shared/codex-subagent-transcript.test.ts +++ b/src/shared/codex-subagent-transcript.test.ts @@ -23,7 +23,10 @@ import { hasTrackedCodexTranscriptSubagents, reconcileCodexSubagentTranscript } from './codex-subagent-transcript' -import { codexRosterToSnapshots, type CodexSubagentRoster } from './codex-subagent-roster' +import { + agentDescendantRosterToSnapshots, + type AgentDescendantRoster +} from './agent-descendant-roster' const CHILD_ID = '019fa65f-3144-7151-9c02-cff7a28f316f' @@ -69,12 +72,12 @@ describe('Codex subagent transcript reconciliation', () => { writeFileSync(parentPath, jsonl([activity('started')])) writeFileSync(childPath, jsonl([{ type: 'event_msg', payload: { type: 'task_started' } }])) const state = createCodexSubagentTranscriptState() - const roster: CodexSubagentRoster = new Map() + const roster: AgentDescendantRoster = new Map() reconcileCodexSubagentTranscript(state, roster, parentPath) expect(hasTrackedCodexTranscriptSubagents(state)).toBe(true) - expect(codexRosterToSnapshots(roster)).toEqual([ + expect(agentDescendantRosterToSnapshots(roster)).toEqual([ { id: CHILD_ID, description: '/root/sidebar_repro', @@ -95,7 +98,7 @@ describe('Codex subagent transcript reconciliation', () => { reconcileCodexSubagentTranscript(state, roster, parentPath) expect(hasTrackedCodexTranscriptSubagents(state)).toBe(false) - expect(codexRosterToSnapshots(roster)).toBeUndefined() + expect(agentDescendantRosterToSnapshots(roster)).toBeUndefined() }) it('resolves a child rollout filed under a later session day than the parent', () => { @@ -111,7 +114,7 @@ describe('Codex subagent transcript reconciliation', () => { writeFileSync(parentPath, jsonl([activity('started', childStartedAt)])) writeFileSync(childPath, jsonl([{ type: 'event_msg', payload: { type: 'task_started' } }])) const state = createCodexSubagentTranscriptState() - const roster: CodexSubagentRoster = new Map() + const roster: AgentDescendantRoster = new Map() reconcileCodexSubagentTranscript(state, roster, parentPath) writeFileSync( @@ -136,7 +139,7 @@ describe('Codex subagent transcript reconciliation', () => { const parentPath = join(dir, 'rollout-parent.jsonl') writeFileSync(parentPath, jsonl([activity('started')])) const state = createCodexSubagentTranscriptState() - const roster: CodexSubagentRoster = new Map() + const roster: AgentDescendantRoster = new Map() reconcileCodexSubagentTranscript(state, roster, parentPath) expect(roster.size).toBe(1) @@ -161,7 +164,7 @@ describe('Codex subagent transcript reconciliation', () => { const parentPath = join(dir, 'rollout-parent.jsonl') writeFileSync(parentPath, jsonl([activity('started')])) const state = createCodexSubagentTranscriptState() - const roster: CodexSubagentRoster = new Map() + const roster: AgentDescendantRoster = new Map() reconcileCodexSubagentTranscript(state, roster, parentPath) writeFileSync(parentPath, jsonl([activity('started'), activity('interrupted')])) @@ -197,18 +200,18 @@ describe('Codex subagent transcript reconciliation', () => { it('reports the model from the child rollout, not the parent model', () => { const { parentPath } = seedPair([started(), turnContext('gpt-5.6-terra')]) const state = createCodexSubagentTranscriptState() - const roster: CodexSubagentRoster = new Map() + const roster: AgentDescendantRoster = new Map() reconcileCodexSubagentTranscript(state, roster, parentPath) // The parent runs sol; only the child's own turn_context may set its model. - expect(codexRosterToSnapshots(roster)?.[0]?.model).toBe('gpt-5.6-terra') + expect(agentDescendantRosterToSnapshots(roster)?.[0]?.model).toBe('gpt-5.6-terra') }) it('keeps the discovered model when a later poll carries no turn_context', () => { const { parentPath, childPath } = seedPair([started(), turnContext('gpt-5.6-terra')]) const state = createCodexSubagentTranscriptState() - const roster: CodexSubagentRoster = new Map() + const roster: AgentDescendantRoster = new Map() reconcileCodexSubagentTranscript(state, roster, parentPath) // The cursor is incremental: this appended line is all the next read sees. @@ -222,7 +225,7 @@ describe('Codex subagent transcript reconciliation', () => { ) reconcileCodexSubagentTranscript(state, roster, parentPath) - expect(codexRosterToSnapshots(roster)?.[0]?.model).toBe('gpt-5.6-terra') + expect(agentDescendantRosterToSnapshots(roster)?.[0]?.model).toBe('gpt-5.6-terra') }) it('tracks the newest model when the child switches mid-session', () => { @@ -232,22 +235,22 @@ describe('Codex subagent transcript reconciliation', () => { turnContext('gpt-5.6-sol') ]) const state = createCodexSubagentTranscriptState() - const roster: CodexSubagentRoster = new Map() + const roster: AgentDescendantRoster = new Map() reconcileCodexSubagentTranscript(state, roster, parentPath) - expect(codexRosterToSnapshots(roster)?.[0]?.model).toBe('gpt-5.6-sol') + expect(agentDescendantRosterToSnapshots(roster)?.[0]?.model).toBe('gpt-5.6-sol') }) it('leaves the child working and keeps its identity while reading the model', () => { const { parentPath } = seedPair([started(), turnContext('gpt-5.6-terra')]) const state = createCodexSubagentTranscriptState() - const roster: CodexSubagentRoster = new Map() + const roster: AgentDescendantRoster = new Map() reconcileCodexSubagentTranscript(state, roster, parentPath) // Model discovery must not move lifecycle or overwrite the child's label. - expect(codexRosterToSnapshots(roster)).toEqual([ + expect(agentDescendantRosterToSnapshots(roster)).toEqual([ { id: CHILD_ID, description: '/root/sidebar_repro', @@ -266,7 +269,7 @@ describe('Codex subagent transcript reconciliation', () => { { type: 'event_msg', payload: { type: 'task_complete' } } ]) const state = createCodexSubagentTranscriptState() - const roster: CodexSubagentRoster = new Map() + const roster: AgentDescendantRoster = new Map() reconcileCodexSubagentTranscript(state, roster, parentPath) @@ -277,7 +280,7 @@ describe('Codex subagent transcript reconciliation', () => { it('reads the model without opening any file beyond the parent and child rollouts', () => { const { parentPath } = seedPair([started(), turnContext('gpt-5.6-terra')]) const state = createCodexSubagentTranscriptState() - const roster: CodexSubagentRoster = new Map() + const roster: AgentDescendantRoster = new Map() openSyncCalls.mockClear() reconcileCodexSubagentTranscript(state, roster, parentPath) @@ -285,7 +288,7 @@ describe('Codex subagent transcript reconciliation', () => { // Why: model extraction reuses the records already read for completion // detection, so it must add no file I/O of its own. expect(openSyncCalls).toHaveBeenCalledTimes(2) - expect(codexRosterToSnapshots(roster)?.[0]?.model).toBe('gpt-5.6-terra') + expect(agentDescendantRosterToSnapshots(roster)?.[0]?.model).toBe('gpt-5.6-terra') }) }) }) diff --git a/src/shared/codex-subagent-transcript.ts b/src/shared/codex-subagent-transcript.ts index b15254684ee..ffe39df1195 100644 --- a/src/shared/codex-subagent-transcript.ts +++ b/src/shared/codex-subagent-transcript.ts @@ -2,11 +2,11 @@ import { closeSync, openSync, readSync, readdirSync, statSync, type Stats } from import { basename, dirname, extname, isAbsolute, join } from 'node:path' import { - finishCodexSubagent, - setCodexSubagentModel, - upsertCodexSubagent, - type CodexSubagentRoster -} from './codex-subagent-roster' + finishAgentDescendant, + setAgentDescendantModel, + upsertAgentDescendant, + type AgentDescendantRoster +} from './agent-descendant-roster' const TRANSCRIPT_READ_MAX_BYTES = 1024 * 1024 const TRANSCRIPT_LINE_MAX_BYTES = 256 * 1024 @@ -253,7 +253,7 @@ export function hasTrackedCodexTranscriptSubagents( export function reconcileCodexSubagentTranscript( state: CodexSubagentTranscriptState, - roster: CodexSubagentRoster, + roster: AgentDescendantRoster, transcriptPath: string | undefined ): void { const normalizedPath = transcriptPath?.trim() @@ -262,7 +262,7 @@ export function reconcileCodexSubagentTranscript( } if (state.parent.filePath !== normalizedPath) { for (const id of state.subagents.keys()) { - finishCodexSubagent(roster, id) + finishAgentDescendant(roster, id) } state.parent = { filePath: normalizedPath, offset: 0, carry: '' } state.subagents.clear() @@ -273,7 +273,7 @@ export function reconcileCodexSubagentTranscript( continue } if (activity.kind === 'interrupted') { - finishCodexSubagent(roster, activity.id) + finishAgentDescendant(roster, activity.id) state.subagents.delete(activity.id) continue } @@ -284,7 +284,7 @@ export function reconcileCodexSubagentTranscript( } tracked.description = activity.description ?? tracked.description state.subagents.set(activity.id, tracked) - upsertCodexSubagent( + upsertAgentDescendant( roster, activity.id, { description: tracked.description, state: 'working' }, @@ -316,12 +316,12 @@ export function reconcileCodexSubagentTranscript( // Why: re-applied every reconcile, not just on discovery — the parent's // own activity upsert can rebuild this child's roster entry, which would // otherwise drop a model found on an earlier poll. - setCodexSubagentModel(roster, id, tracked.model) + setAgentDescendantModel(roster, id, tracked.model) if (!childIsComplete(records)) { continue } } - finishCodexSubagent(roster, id) + finishAgentDescendant(roster, id) state.subagents.delete(id) } }