Files
orca/src/main/codex/codex-background-task-tracker.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

281 lines
10 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { CodexBackgroundTaskTracker } from './codex-background-task-tracker'
import {
readCodexBackgroundTaskFrame,
type CodexBackgroundTaskEvent
} from './codex-background-task-frames'
const PRIMARY = 'parent-thread'
const PARENT_TURN = 'parent-turn'
const CHILD = 'child-thread'
const CHILD_TURN = 'child-turn'
function turn(
method: 'turn/started' | 'turn/completed',
threadId: string,
turnId: string,
status = 'completed'
): CodexBackgroundTaskEvent {
return { method, threadId, params: { threadId, turn: { id: turnId, status } } }
}
function activity(
kind = 'started',
parentTurn = PARENT_TURN,
child = CHILD
): CodexBackgroundTaskEvent {
return {
method: 'item/started',
threadId: PRIMARY,
params: {
threadId: PRIMARY,
turnId: parentTurn,
item: {
type: 'subAgentActivity',
id: `activity-${kind}`,
kind,
agentThreadId: child,
agentPath: '/root/count_a'
}
}
}
}
function runningChild(): CodexBackgroundTaskTracker {
const tracker = new CodexBackgroundTaskTracker(PRIMARY)
tracker.observe(turn('turn/started', PRIMARY, PARENT_TURN))
tracker.observe(turn('turn/started', CHILD, CHILD_TURN))
tracker.observe(activity())
return tracker
}
function command(threadId = PRIMARY, method = 'item/started'): CodexBackgroundTaskEvent {
return {
method,
threadId,
params: {
threadId,
turnId: PARENT_TURN,
item: {
type: 'commandExecution',
id: 'exec-1',
processId: '71831',
source: 'unifiedExecStartup',
command: 'sleep 90',
status: method === 'item/started' ? 'inProgress' : 'completed'
}
}
}
}
describe('readCodexBackgroundTaskFrame', () => {
it('reads activity as child metadata without inferring execution state', () => {
expect(readCodexBackgroundTaskFrame(activity('interacted'), PRIMARY)).toEqual({
kind: 'subagent',
agentThreadId: CHILD,
label: 'count_a',
parentTurnId: PARENT_TURN
})
})
it('reads a child turn with its own execution identity', () => {
expect(readCodexBackgroundTaskFrame(turn('turn/started', CHILD, CHILD_TURN), PRIMARY)).toEqual({
kind: 'turn',
threadId: CHILD,
turnId: CHILD_TURN,
state: 'working'
})
})
it('does not register the primary thread even when its activity path is missing', () => {
const event = activity('interacted', PARENT_TURN, PRIMARY)
;(event.params as { item: { agentPath?: string } }).item.agentPath = undefined
expect(readCodexBackgroundTaskFrame(event, PRIMARY)).toBeNull()
})
})
describe('CodexBackgroundTaskTracker child execution ownership', () => {
it('does not claim work from an activity item without a child turn', () => {
const tracker = new CodexBackgroundTaskTracker(PRIMARY)
tracker.observe(activity())
tracker.observe(activity('interacted'))
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
expect(tracker.state).toBeNull()
})
it('reports an executing child only after the foreground turn ends', () => {
const tracker = runningChild()
expect(tracker.state).toBeNull()
expect(tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))).toBe(true)
expect(tracker.state).toEqual({
state: 'monitoring',
supportsStopAll: false,
tasks: [{ id: `codex-agent:${CHILD}`, kind: 'agent', description: 'count_a' }]
})
})
it('never settles a child when a primary turn ends', () => {
const tracker = runningChild()
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
for (let index = 0; index < 300; index++) {
expect(tracker.observe(turn('turn/completed', PRIMARY, `later-${index}`))).toBe(false)
}
expect(tracker.state?.tasks).toHaveLength(1)
})
it.each(['completed', 'interrupted', 'failed'])(
'settles on the matching child turn %s',
(status) => {
const tracker = runningChild()
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
expect(tracker.observe(turn('turn/completed', CHILD, CHILD_TURN, status))).toBe(true)
expect(tracker.state).toBeNull()
}
)
it('does not mistake late activity completion for the current child execution', () => {
const tracker = runningChild()
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
tracker.observe(activity('completed'))
expect(tracker.state?.tasks).toHaveLength(1)
})
it.each([PARENT_TURN, 'followup-parent'])(
'reports follow-up work in %s using the new child turn',
(parentTurn) => {
const tracker = runningChild()
tracker.observe(turn('turn/completed', CHILD, CHILD_TURN))
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
tracker.observe(turn('turn/started', PRIMARY, parentTurn))
tracker.observe(activity('interacted', parentTurn))
expect(tracker.state).toBeNull()
tracker.observe(turn('turn/started', CHILD, 'followup-child-turn'))
tracker.observe(turn('turn/completed', PRIMARY, parentTurn))
expect(tracker.state?.tasks).toHaveLength(1)
tracker.observe(turn('turn/completed', CHILD, CHILD_TURN))
tracker.observe(turn('turn/started', CHILD, CHILD_TURN))
tracker.observe(activity('completed'))
expect(tracker.state?.tasks).toHaveLength(1)
tracker.observe(turn('turn/completed', CHILD, 'followup-child-turn'))
expect(tracker.state).toBeNull()
}
)
it('keeps idle send_message activity out of the strip', () => {
const tracker = runningChild()
tracker.observe(turn('turn/completed', CHILD, CHILD_TURN))
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
tracker.observe(activity('interacted', 'message-parent'))
tracker.observe(turn('turn/completed', PRIMARY, 'message-parent'))
expect(tracker.state).toBeNull()
})
it('does not invent another execution for a message to a working child', () => {
const tracker = runningChild()
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
tracker.observe(activity('interacted', 'message-parent'))
tracker.observe(turn('turn/completed', PRIMARY, 'message-parent'))
expect(tracker.state?.tasks).toHaveLength(1)
tracker.observe(turn('turn/completed', CHILD, CHILD_TURN))
expect(tracker.state).toBeNull()
})
it('retains completion delivered before child registration', () => {
const tracker = new CodexBackgroundTaskTracker(PRIMARY)
tracker.observe(turn('turn/started', CHILD, CHILD_TURN))
tracker.observe(turn('turn/completed', CHILD, CHILD_TURN))
tracker.observe(activity())
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
expect(tracker.state).toBeNull()
})
it('publishes no extra state for duplicate owner or metadata events', () => {
const tracker = runningChild()
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
expect(tracker.observe(turn('turn/started', CHILD, CHILD_TURN))).toBe(false)
expect(tracker.observe({ ...activity(), method: 'item/completed' })).toBe(false)
expect(tracker.observe(turn('turn/completed', CHILD, CHILD_TURN))).toBe(true)
expect(tracker.observe(turn('turn/completed', CHILD, CHILD_TURN))).toBe(false)
})
it('bounds retained child history while allowing repeated completed runs', () => {
const tracker = new CodexBackgroundTaskTracker(PRIMARY)
tracker.observe(activity())
for (let index = 0; index < 300; index++) {
const id = `child-turn-${index}`
tracker.observe(turn('turn/started', CHILD, id))
expect(tracker.state?.tasks).toHaveLength(1)
tracker.observe(turn('turn/completed', CHILD, id))
expect(tracker.state).toBeNull()
}
})
it('clears the roster at session teardown', () => {
const tracker = runningChild()
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
expect(tracker.clear()).toBe(true)
expect(tracker.state).toBeNull()
expect(tracker.clear()).toBe(false)
})
})
describe('CodexBackgroundTaskTracker command integration', () => {
it('keeps a primary shell visible after the turn until its own completion', () => {
const tracker = new CodexBackgroundTaskTracker(PRIMARY)
tracker.observe(turn('turn/started', PRIMARY, PARENT_TURN))
tracker.observe(command())
expect(tracker.state).toBeNull()
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
expect(tracker.state?.tasks).toEqual([
{ id: 'codex-command:primary:exec-1', kind: 'command', description: 'sleep 90' }
])
tracker.observe(command(PRIMARY, 'item/completed'))
expect(tracker.state).toBeNull()
})
it('reveals a child shell only after the child execution finishes', () => {
const tracker = runningChild()
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
tracker.observe(command(CHILD))
expect(tracker.state?.tasks).toHaveLength(1)
tracker.observe(turn('turn/completed', CHILD, CHILD_TURN, 'interrupted'))
expect(tracker.state?.tasks).toEqual([
{
id: `codex-command:thread:${CHILD}:exec-1`,
kind: 'command',
description: 'count_a — sleep 90'
}
])
tracker.observe(command(CHILD, 'item/completed'))
expect(tracker.state).toBeNull()
})
it('leaves a primary shell unqualified', () => {
const tracker = runningChild()
tracker.observe(command(PRIMARY))
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
expect(tracker.state?.tasks).toContainEqual({
id: 'codex-command:primary:exec-1',
kind: 'command',
description: 'sleep 90'
})
})
it('names a child shell whose label only arrives after the command', () => {
const tracker = new CodexBackgroundTaskTracker(PRIMARY)
tracker.observe(turn('turn/started', PRIMARY, PARENT_TURN))
tracker.observe(turn('turn/started', CHILD, CHILD_TURN))
tracker.observe(command(CHILD))
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
tracker.observe(activity())
tracker.observe(turn('turn/completed', CHILD, CHILD_TURN))
expect(tracker.state?.tasks).toEqual([
{
id: `codex-command:thread:${CHILD}:exec-1`,
kind: 'command',
description: 'count_a — sleep 90'
}
])
})
})