mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 00:02:56 +00:00
fix(agents): keep a pane working while its agent's descendants are live
A pane is idle only when its lead session is idle AND no descendant is still running. That rule existed twice — Claude's roster and Codex's — and nowhere else, so a Grok nested subagent's SessionEnd/StopFailure and a Pi async subagent run were read as the pane's own completion. Generalize the Codex roster into a provider-agnostic descendant roster and apply the rule once, at the provider-dispatch boundary every provider funnels through. A descendant's event now updates the child list and republishes the LEAD's state; it never settles the pane, never relabels the row with the child's tool or prompt, and never counts as a user submit. Claude keeps its own roster: its children carry teammate parking, background-task folding and restored-snapshot provenance that no other provider has. Install Grok's SubagentStart/SubagentStop so a child can be named rather than only inferred, and forward pi's async subagent run lifecycle from the managed status extension.
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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<string, unknown>',
|
||||
" 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<string, unknown>',
|
||||
' 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.',
|
||||
' }',
|
||||
''
|
||||
]
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
+32
-32
@@ -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)
|
||||
})
|
||||
@@ -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<string, TrackedCodexSubagent>
|
||||
const AGENT_DESCENDANT_ID_MAX_LENGTH = 64
|
||||
|
||||
type TrackedCodexSubagent = {
|
||||
export type AgentDescendantRoster = Map<string, TrackedAgentDescendant>
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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<string, unknown>
|
||||
): ReturnType<typeof normalizeAndAccept> => normalizeAndAccept(state, source, payload)
|
||||
|
||||
const publishedState = (
|
||||
source: AgentHookSource,
|
||||
payload: Record<string, unknown>
|
||||
): 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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, unknown>): string | undefined {
|
||||
return readFirstString(hookPayload, [
|
||||
'subagentId',
|
||||
'subagent_id',
|
||||
'agentId',
|
||||
'agent_id',
|
||||
'runId',
|
||||
'run_id'
|
||||
])
|
||||
}
|
||||
|
||||
function readGrokDescendantFacts(
|
||||
eventName: unknown,
|
||||
hookPayload: Record<string, unknown>
|
||||
): 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<string, unknown>
|
||||
): 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<string, unknown>
|
||||
): 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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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<string, string>
|
||||
/** Live thread-spawn children per Codex pane. */
|
||||
codexSubagentRosterByPaneKey: Map<string, CodexSubagentRoster>
|
||||
codexSubagentRosterByPaneKey: Map<string, AgentDescendantRoster>
|
||||
/** Incremental parent/child rollout cursors for Codex collaboration v2. */
|
||||
codexSubagentTranscriptByPaneKey: Map<string, CodexSubagentTranscriptState>
|
||||
/** Root Codex state/model, kept separate from child hook traffic. */
|
||||
codexLeadStateByPaneKey: Map<string, CodexLeadTurnState>
|
||||
/** Live descendants for every provider that does not own a roster of its own. */
|
||||
descendantRosterByPaneKey: Map<string, AgentDescendantRoster>
|
||||
/** 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<string, AgentStatusState>
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
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<string, unknown>
|
||||
): 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
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user