Files
orca/src/main/codex/codex-subagent-execution-projection.test.ts
T
Brennan BensonandMerge Sim 5868fdc9e3 feat(native-chat): report Codex background tasks in the chat strip (#19346)
* feat(native-chat): report Codex background tasks in the chat strip

The background-tasks strip works for Claude only; a structured Codex
session shows nothing in it. Feed it from the Codex app-server stream.

The strip stands for work that OUTLIVED a turn, which is what the
monitoring header, Claude's foreground suppression, and the conversation
command gate all already assume. Codex has no `is_backgrounded` flag, so
that fact is derived from the turn boundary: a `subAgentActivity` child or
a primary-thread `commandExecution` becomes visible once the turn it
belongs to completes and it is still unsettled.

`turn/completed` only reveals a task here, never settles one — measured on
`codex app-server` 0.153.4, a spawn_agent child reported `completed` 95.8s
after its parent turn ended. Only a child's own activity kind settles it.

Codex exposes no honest stop: `turn/interrupt` on a child ends its turn
without emitting a terminal activity item and leaves its shell running. So
the state carries a new optional `supportsStopAll: false`, the strip hides
a control that could not act, and the blocked-command message asks the user
to wait rather than to press a button that does not exist.

* refactor(codex): move session teardown out of the structured adapter

Merging main crossed the 300-line cap on
`codex-structured-session-adapter.ts`: the rewind backend (#19235) and this
branch's close-time strip clear both landed in it. The four close paths move
verbatim into `codex-structured-session-teardown.ts`, where they funnel
through one `settled` helper instead of repeating the notification-retry and
background-task cleanup at each call site. No ratchet bump.

Also normalize a background task's description once at receipt rather than on
every projection; the roster is re-projected on each observed frame.

* fix(codex): drop the shell row the journal already settles

A `commandExecution` still `inProgress` when its turn ends was reported as a
`command` task. But `settleCodexJournalTurn` writes exactly those items to the
journal as `state: 'failed'` on `turn/completed` and forgets them, so the strip
row would have claimed a shell was still running at the same instant Orca
recorded that it was not — two surfaces contradicting each other about the same
process.

A subagent is the opposite case and stays: the roster pointedly does not sweep
at a turn boundary, because children measurably outlive it. That leaves the
producer making exactly one claim — these spawn_agent children are still live
after their turn — which the durable roster row corroborates.

* fix(native-chat): track Codex background execution lifetimes

* fix(native-chat): keep running tool groups from claiming completion

* Fix runtime catalog and capability expectation

* fix(codex): keep a child's name on the command row that outlives it

A child agent's commands stay hidden behind its agent row while the child
works. Once the child's turn settles with a command still running, that
command surfaces as its own row labelled from the raw command string, so
'long_probe' became "/bin/zsh -lc 'ping -c 300 127.0.0.1 > /dev/null'"
at the moment that row was the only remaining signal for the work.

Qualify a child's command row with the child's label. Resolved on read,
so a label registered after the command still lands, and bounded by the
existing description cap so admission accounting stays valid. Primary-
thread commands are left unqualified: they have no child to name.

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-09 00:13:20 -07:00

212 lines
7.0 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types'
import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter'
import { createCodexJournalTranslator } from './codex-structured-journal-translation'
import { CodexBackgroundTaskTracker } from './codex-background-task-tracker'
import { CodexSubagentExecutions } from './codex-subagent-executions'
const PRIMARY = 'primary'
const CHILD = 'child'
function turn(
method: 'turn/started' | 'turn/completed',
threadId: string,
turnId: string,
status = 'completed'
): CodexStructuredSessionEvent {
return {
type: 'notification',
sessionId: 'session',
method,
threadId,
params: { threadId, turn: { id: turnId, status } }
}
}
function activity(parentTurn: string, kind = 'started'): CodexStructuredSessionEvent {
return {
type: 'notification',
sessionId: 'session',
threadId: PRIMARY,
method: 'item/started',
params: {
threadId: PRIMARY,
turnId: parentTurn,
item: {
type: 'subAgentActivity',
id: `activity-${parentTurn}-${kind}`,
kind,
agentThreadId: CHILD,
agentPath: '/root/task'
}
}
}
}
function harness() {
const executions = new CodexSubagentExecutions()
const tracker = new CodexBackgroundTaskTracker(PRIMARY, executions)
const rows = new Map<string, AgentJournalItemBody>()
let refused = false
const translator = createCodexJournalTranslator({
primaryThreadId: () => PRIMARY,
subagentExecutions: executions,
sink: {
appendItem: (identity, body) => rows.set(JSON.stringify(identity), body),
appendTombstone: () => {},
publish: () => {},
tryAppendItem: (identity, body) => {
if (refused) {
return { accepted: false, reason: 'backpressure' }
}
rows.set(JSON.stringify(identity), body)
return { accepted: true }
}
},
schedule: (run) => {
run()
return () => {}
}
})
function send(event: CodexStructuredSessionEvent) {
const admission = translator.handle(event)
if (admission.accepted && event.type === 'notification') {
tracker.observe(event)
}
return admission
}
function state(parentTurn: string): string | undefined {
for (const body of rows.values()) {
if (body.kind !== 'message') {
continue
}
for (const block of body.blocks) {
if (block.type === 'subagent-group' && block.groupId === `${PRIMARY}:${parentTurn}`) {
return block.agents[0]?.state
}
}
}
return undefined
}
return {
send,
tracker,
state,
refuse: (value: boolean) => {
refused = value
},
dispose: () => translator.dispose()
}
}
function firstRun(h: ReturnType<typeof harness>) {
h.send(turn('turn/started', PRIMARY, 'parent-1'))
h.send(turn('turn/started', CHILD, 'child-1'))
h.send(activity('parent-1'))
h.send(turn('turn/completed', CHILD, 'child-1'))
}
describe('shared child execution projection', () => {
it('creates no execution record from activity without an owner turn', () => {
const h = harness()
h.send(activity('parent-1'))
h.send(activity('parent-2', 'interacted'))
expect(h.state('parent-1')).toBeUndefined()
expect(h.state('parent-2')).toBeUndefined()
expect(h.tracker.state).toBeNull()
h.dispose()
})
it.each(['parent-1', 'parent-2'])(
'reopens a child for real follow-up in %s and fences old execution events',
(parent) => {
const h = harness()
firstRun(h)
if (parent === 'parent-2') {
h.send(turn('turn/completed', PRIMARY, 'parent-1'))
h.send(turn('turn/started', PRIMARY, parent))
}
h.send(activity(parent, 'interacted'))
expect(h.tracker.state).toBeNull()
h.send(turn('turn/started', CHILD, 'child-2'))
h.send(turn('turn/completed', PRIMARY, parent))
expect(h.state(parent)).toBe('working')
expect(h.tracker.state?.tasks).toHaveLength(1)
h.send(turn('turn/completed', CHILD, 'child-1'))
h.send(turn('turn/started', CHILD, 'child-1'))
h.send(activity('parent-1', 'completed'))
expect(h.state(parent)).toBe('working')
expect(h.tracker.state?.tasks).toHaveLength(1)
if (parent === 'parent-2') {
expect(h.state('parent-1')).toBe('completed')
}
h.send(turn('turn/completed', CHILD, 'child-2'))
expect(h.state(parent)).toBe('completed')
expect(h.tracker.state).toBeNull()
h.dispose()
}
)
it('keeps an idle message from creating execution on either surface', () => {
const h = harness()
firstRun(h)
h.send(turn('turn/completed', PRIMARY, 'parent-1'))
h.send(activity('parent-2', 'interacted'))
h.send(turn('turn/completed', PRIMARY, 'parent-2'))
expect(h.state('parent-1')).toBe('completed')
expect(h.state('parent-2')).toBeUndefined()
expect(h.tracker.state).toBeNull()
h.dispose()
})
it('keeps a late completion activity from retargeting a queued follow-up', () => {
const h = harness()
firstRun(h)
h.send(turn('turn/completed', PRIMARY, 'parent-1'))
h.send(activity('parent-2', 'interacted'))
h.send(activity('parent-1', 'completed'))
h.send(turn('turn/started', CHILD, 'child-2'))
expect(h.state('parent-1')).toBe('completed')
expect(h.state('parent-2')).toBe('working')
expect(h.tracker.state?.tasks).toHaveLength(1)
h.dispose()
})
it('keeps a message to a running child in the original execution group', () => {
const h = harness()
h.send(turn('turn/started', PRIMARY, 'parent-1'))
h.send(turn('turn/started', CHILD, 'child-1'))
h.send(activity('parent-1'))
h.send(turn('turn/completed', PRIMARY, 'parent-1'))
h.send(turn('turn/started', PRIMARY, 'parent-2'))
h.send(activity('parent-2', 'interacted'))
h.send(turn('turn/started', CHILD, 'child-1'))
h.send(turn('turn/completed', PRIMARY, 'parent-2'))
expect(h.tracker.state?.tasks).toHaveLength(1)
expect(h.state('parent-2')).toBeUndefined()
h.send(turn('turn/completed', CHILD, 'child-1'))
expect(h.state('parent-1')).toBe('completed')
expect(h.state('parent-2')).toBeUndefined()
expect(h.tracker.state).toBeNull()
h.dispose()
})
it('does not expose pending child settlement before journal admission and retries the same fact', () => {
const h = harness()
h.send(turn('turn/started', PRIMARY, 'parent-1'))
h.send(activity('parent-1'))
h.send(turn('turn/started', CHILD, 'child-1'))
h.send(turn('turn/completed', PRIMARY, 'parent-1'))
const complete = turn('turn/completed', CHILD, 'child-1')
h.refuse(true)
expect(h.send(complete)).toEqual({ accepted: false, reason: 'backpressure' })
expect(h.tracker.state?.tasks).toHaveLength(1)
expect(h.state('parent-1')).toBe('working')
h.refuse(false)
expect(h.send(complete)).toEqual({ accepted: true })
expect(h.state('parent-1')).toBe('completed')
expect(h.tracker.state).toBeNull()
h.dispose()
})
})