Files
orca/src/main/codex/codex-background-command-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

129 lines
5.1 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types'
import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key'
import { CodexBackgroundCommandTracker } from './codex-background-command-tracker'
import { createCodexJournalTranslator } from './codex-structured-journal-translation'
import type { CodexStructuredSessionEvent } from './codex-structured-session-state'
function notification(
method: string,
params: Record<string, unknown>
): Extract<CodexStructuredSessionEvent, { type: 'notification' }> {
return {
type: 'notification',
sessionId: 'session',
threadId: 'root',
method,
params: { threadId: 'root', turnId: 'turn', ...params }
}
}
function command(method: string, id = 'exec', threadId = 'root') {
return {
...notification(method, {
item: {
type: 'commandExecution',
id,
command: 'sleep 30',
source: 'unifiedExecStartup',
status: method === 'item/completed' ? 'completed' : 'inProgress',
exitCode: method === 'item/completed' ? 0 : null
}
}),
threadId
}
}
describe('persistent command ownership', () => {
it('preflights finite metadata capacity and admits work again after process completion', () => {
const tracker = new CodexBackgroundCommandTracker('root', 700)
const first = command('item/started', 'first')
const second = command('item/started', 'second')
expect(tracker.canObserve(first)).toBe(true)
tracker.observe(first)
expect(tracker.canObserve(second)).toBe(false)
expect(() => tracker.observe(second)).toThrow('not admitted')
expect(tracker.tasks()).toHaveLength(1)
expect(tracker.retainedMetadataBytes).toBeLessThanOrEqual(700)
tracker.observe(command('item/completed', 'first'))
expect(tracker.canObserve(second)).toBe(true)
tracker.observe(second)
expect(tracker.tasks()).toHaveLength(1)
expect(tracker.retainedMetadataBytes).toBeLessThanOrEqual(700)
tracker.clear()
expect(tracker.retainedMetadataBytes).toBe(0)
})
it('keeps the journal running across turn completion and accepts late output and exit', () => {
const rows: { key: string; body: AgentJournalItemBody }[] = []
const translator = createCodexJournalTranslator({
primaryThreadId: () => 'root',
sink: {
appendItem: (identity, body) => rows.push({ key: agentJournalItemKey(identity), body }),
appendTombstone: () => {},
publish: () => {}
}
})
const tracker = new CodexBackgroundCommandTracker('root')
const deliver = (event: Extract<CodexStructuredSessionEvent, { type: 'notification' }>) => {
expect(translator.handle(event)).toEqual({ accepted: true })
tracker.observe(event)
}
deliver(notification('turn/started', { turn: { id: 'turn' } }))
deliver(command('item/started'))
const originalKey = rows.find(({ body }) => body.kind === 'tool-call')?.key
deliver(notification('turn/completed', { turn: { id: 'turn' } }))
expect(rows.filter(({ body }) => body.kind === 'tool-call').map(({ body }) => body)).toEqual([
expect.objectContaining({ state: 'running' })
])
expect(tracker.tasks()).toHaveLength(1)
deliver(
notification('item/commandExecution/outputDelta', { itemId: 'exec', delta: 'late output' })
)
translator.flush()
expect(rows.at(-1)).toMatchObject({ key: originalKey, body: { state: 'running' } })
deliver(command('item/completed'))
expect(rows.at(-1)).toMatchObject({ key: originalKey, body: { state: 'completed' } })
expect(tracker.tasks()).toEqual([])
translator.dispose()
})
it('counts child shells only after the child stops covering them, without resurrecting exits', () => {
const tracker = new CodexBackgroundCommandTracker('root')
tracker.observe(command('item/started', 'child-exec', 'child'))
tracker.observe(
notification('item/started', {
item: {
type: 'commandExecution',
id: 'poll',
source: 'unifiedExecInteraction',
status: 'inProgress'
}
})
)
expect(tracker.tasks(new Set(['child']))).toEqual([])
expect(tracker.tasks()).toEqual([
{ id: 'codex-command:thread:child:child-exec', kind: 'command', description: 'sleep 30' }
])
tracker.observe(command('item/completed', 'child-exec', 'child'))
tracker.observe(command('item/started'))
tracker.observe(command('item/completed'))
tracker.observe(command('item/started'))
expect(tracker.tasks()).toEqual([])
})
it('retains live commands while recycling bounded settled history', () => {
const tracker = new CodexBackgroundCommandTracker('root')
tracker.observe(command('item/started', 'long-lived'))
for (let index = 0; index < 300; index += 1) {
tracker.observe(command('item/started', `short-${index}`))
tracker.observe(command('item/completed', `short-${index}`))
}
expect(tracker.tasks()).toEqual([
{ id: 'codex-command:primary:long-lived', kind: 'command', description: 'sleep 30' }
])
tracker.clear()
expect(tracker.tasks()).toEqual([])
})
})