Files
orca/src/main/codex/codex-persistent-command-retention.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

220 lines
7.6 KiB
TypeScript

import { describe, expect, it, vi } 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 {
CodexItemStreamRetention,
MAX_CODEX_ITEM_STREAM_METADATA_BYTES
} from './codex-item-stream-retention'
import { CodexJournalItems } from './codex-structured-journal-items'
import { settleCodexJournalTurn } from './codex-structured-journal-settlement'
function command(threadId: string, id: string, method = 'item/started') {
return {
threadId,
method,
params: {
turnId: 'turn',
item: {
type: 'commandExecution',
id,
source: 'unifiedExecStartup',
command: `sleep 30 # ${threadId}/${id}`,
cwd: '/workspace',
status: method === 'item/completed' ? 'completed' : 'inProgress',
...(method === 'item/completed' ? { exitCode: 0, aggregatedOutput: 'BEFORE\nAFTER\n' } : {})
}
}
}
}
function fixture(maxMetadataBytes?: number) {
const rows = new Map<string, AgentJournalItemBody>()
const scheduled = new Set<() => void>()
const sink = {
appendItem: (
identity: Parameters<typeof agentJournalItemKey>[0],
body: AgentJournalItemBody
) => {
rows.set(agentJournalItemKey(identity), body)
},
appendTombstone() {},
publish() {}
}
const items = new CodexJournalItems(
{
sink,
maxMetadataBytes,
schedule: (run) => {
scheduled.add(run)
return () => {
scheduled.delete(run)
}
}
},
() => 'turn',
() => {}
)
return { items, sink, rows, scheduled }
}
describe('persistent command retention', () => {
it('does not rebuild unchanged persistent output on every later lifecycle flush', () => {
const { items } = fixture()
const event = command('root', 'quiet')
items.handle(event)
items.streams.handle('root', 'item/commandExecution/outputDelta', {
itemId: 'quiet',
delta: 'retained-prefix'
})
items.streams.flush()
const originalJoin = Array.prototype.join
let retainedJoins = 0
const spy = vi
.spyOn(Array.prototype, 'join')
.mockImplementation(function (this: unknown[], separator) {
if (this[0] === 'retained-prefix') {
retainedJoins += 1
}
return originalJoin.call(this, separator)
})
try {
for (let index = 0; index < 100; index += 1) {
items.streams.flush()
}
} finally {
spy.mockRestore()
items.dispose()
}
expect(retainedJoins).toBe(0)
})
it('retains 448 live commands through completed turns, late output, and process completion', () => {
const { items, sink, rows, scheduled } = fixture()
const tracker = new CodexBackgroundCommandTracker('thread-0')
const events = Array.from({ length: 7 }, (_, thread) =>
Array.from({ length: 64 }, (_, index) => command(`thread-${thread}`, `exec-${index}`))
).flat()
for (const event of events) {
expect(tracker.canObserve(event)).toBe(true)
expect(items.handle(event)).toMatchObject({ admission: { accepted: true } })
tracker.observe(event)
expect(
items.streams.handle(event.threadId, 'item/commandExecution/outputDelta', {
turnId: 'turn',
itemId: event.params.item.id,
delta: 'BEFORE\n'
}).admission
).toEqual({ accepted: true })
}
for (let thread = 0; thread < 7; thread += 1) {
expect(
settleCodexJournalTurn({
sessionId: 'session',
threadId: `thread-${thread}`,
turnId: 'turn',
sink,
streams: items.streams,
activeItems: items.activeItems
})
).toEqual({ accepted: true })
}
expect(items.activeItems.size).toBe(448)
expect(items.streams.persistentCount).toBe(448)
expect(tracker.tasks()).toHaveLength(448)
expect(tracker.retainedMetadataBytes).toBeLessThan(256 * 1024)
for (const event of events) {
items.streams.handle(event.threadId, 'item/commandExecution/outputDelta', {
turnId: 'turn',
itemId: event.params.item.id,
delta: 'AFTER\n'
})
}
expect(items.streams.flush()).toBe(true)
for (const event of events) {
const key = agentJournalItemKey({
provider: 'orca',
clientMessageId: `codex-item:${event.threadId}:${event.params.item.id}`
})
expect(rows.get(key)).toMatchObject({
state: 'running',
input: { command: event.params.item.command, cwd: '/workspace' },
output: { head: 'BEFORE\nAFTER\n' }
})
const completed = command(event.threadId, event.params.item.id, 'item/completed')
expect(items.handle(completed)).toMatchObject({ admission: { accepted: true } })
tracker.observe(completed)
expect(rows.get(key)).toMatchObject({
state: 'completed',
output: { head: 'BEFORE\nAFTER\n' }
})
expect(items.streams.snapshot(event.threadId, event.params.item.id)).toBeNull()
}
expect(items.activeItems.size).toBe(0)
expect(items.streams.persistentCount).toBe(0)
expect(tracker.tasks()).toEqual([])
expect(tracker.retainedMetadataBytes).toBeLessThan(64 * 1024)
items.dispose()
tracker.clear()
expect(tracker.retainedMetadataBytes).toBe(0)
expect(scheduled.size).toBe(0)
})
it('rejects command metadata exhaustion before appending or evicting live state and frees it on completion', () => {
const { items, rows } = fixture(800)
const first = command('root', 'first')
const second = command('root', 'second')
expect(items.handle(first)).toMatchObject({ admission: { accepted: true } })
const prior = [...rows]
expect(items.handle(second)).toMatchObject({ admission: { accepted: false, reason: 'failed' } })
expect([...rows]).toEqual(prior)
expect(items.activeItems.size).toBe(1)
expect(items.handle(command('root', 'first', 'item/completed'))).toMatchObject({
admission: { accepted: true }
})
expect(items.handle(second)).toMatchObject({ admission: { accepted: true } })
items.dispose()
})
it('accounts metadata bytes instead of interpreting the item count as liveness', () => {
const retention = new CodexItemStreamRetention()
for (let index = 0; index < 448; index += 1) {
const item = command('root', `exec-${index}`).params.item
expect(
retention.retain(item.id, {
item,
identity: { provider: 'orca', clientMessageId: item.id }
})
).toBe(true)
}
expect(retention.size).toBe(448)
expect(retention.retainedBytes).toBeLessThan(256 * 1024)
expect(retention.retainedBytes).toBeLessThan(MAX_CODEX_ITEM_STREAM_METADATA_BYTES)
expect(retention.overCapacity).toBe(false)
expect(retention.oldestEvictable()).toBeUndefined()
retention.clear()
expect(retention.retainedBytes).toBe(0)
expect(retention.persistentSize).toBe(0)
})
it('retains startup provenance when large command metadata is bounded', () => {
const { items, sink } = fixture()
const event = command('root', 'large')
event.params.item.command = 'x'.repeat(128 * 1024)
expect(items.handle(event)).toMatchObject({ admission: { accepted: true } })
expect(
settleCodexJournalTurn({
sessionId: 'session',
threadId: 'root',
turnId: 'turn',
sink,
streams: items.streams,
activeItems: items.activeItems
})
).toEqual({ accepted: true })
expect(items.activeItems.size).toBe(1)
expect(items.streams.persistentCount).toBe(1)
items.dispose()
})
})