mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 00:02:56 +00:00
* 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>
281 lines
10 KiB
TypeScript
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'
|
|
}
|
|
])
|
|
})
|
|
})
|