mirror of
https://github.com/stablyai/orca.git
synced 2026-10-27 08:02:51 +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>
212 lines
7.0 KiB
TypeScript
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()
|
|
})
|
|
})
|