Files
orca/src/shared/structured-agent-session-reducer.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

283 lines
9.3 KiB
TypeScript

import type {
AgentJournalCursor,
AgentJournalRenderItem,
AgentJournalSubmission
} from './agent-session-journal-types'
import type {
AgentSessionBackgroundTaskState,
AgentSessionSlashCommand,
AgentSessionHandoffStatus,
AgentSessionHistoryPage,
AgentSessionSubscribeEvent,
AgentSessionTurnActivity
} from './agent-session-wire'
export type StructuredAgentSessionState = {
epoch: string | null
cursor: AgentJournalCursor | null
fence: number | null
items: AgentJournalRenderItem[]
submissions: AgentJournalSubmission[]
hasOlder: boolean
status: 'idle' | 'loading' | 'ready' | 'error'
error?: string
handoff: AgentSessionHandoffStatus | null
backgroundTasks?: AgentSessionBackgroundTaskState | null
commands?: AgentSessionSlashCommand[] | null
activity?: AgentSessionTurnActivity | null
}
export type StructuredAgentSessionAction =
| { type: 'loading' }
| { type: 'error'; message: string }
| { type: 'handoff'; handoff: AgentSessionHandoffStatus }
| { type: 'event'; event: AgentSessionSubscribeEvent }
| { type: 'tail-page'; page: AgentSessionHistoryPage }
| { type: 'older-page'; requestedEpoch: string; page: AgentSessionHistoryPage }
export const EMPTY_STRUCTURED_AGENT_SESSION: StructuredAgentSessionState = {
epoch: null,
cursor: null,
fence: null,
items: [],
submissions: [],
hasOlder: false,
status: 'idle',
handoff: null
}
const MAX_RETAINED_SUBMISSIONS = 256
function backgroundTaskStatesEqual(
left: AgentSessionBackgroundTaskState | null | undefined,
right: AgentSessionBackgroundTaskState | null | undefined
): boolean {
if (left === right) {
return true
}
if (
!left ||
!right ||
left.state !== right.state ||
left.supportsTaskStop !== right.supportsTaskStop ||
left.supportsStopAll !== right.supportsStopAll
) {
return false
}
if (left.tasks === right.tasks) {
return true
}
if (!left.tasks || !right.tasks || left.tasks.length !== right.tasks.length) {
return false
}
return left.tasks.every(
(task, index) =>
task.id === right.tasks?.[index]?.id &&
task.kind === right.tasks[index]?.kind &&
task.description === right.tasks[index]?.description
)
}
function replacePage(
page: AgentSessionHistoryPage,
fence: number,
handoff?: AgentSessionHandoffStatus,
backgroundTasks?: AgentSessionBackgroundTaskState | null,
activity?: AgentSessionTurnActivity | null
): StructuredAgentSessionState {
return {
epoch: page.epoch,
cursor: page.liveCursor ?? page.window.nextCursor,
fence,
items: [...page.items].sort((left, right) => left.sequence - right.sequence),
submissions: page.submissions,
hasOlder: page.hasOlder,
status: 'ready',
handoff: handoff ?? null,
activity: activity ?? null,
...(backgroundTasks !== undefined
? { backgroundTasks }
: page.backgroundTasks !== undefined
? { backgroundTasks: page.backgroundTasks }
: {})
}
}
function mergeItems(
current: readonly AgentJournalRenderItem[],
incoming: readonly AgentJournalRenderItem[],
removedIds: readonly string[]
): AgentJournalRenderItem[] {
const removed = new Set(removedIds)
const byId = new Map(
current.filter((item) => !removed.has(item.itemId)).map((item) => [item.itemId, item])
)
for (const item of incoming) {
const prior = byId.get(item.itemId)
if (!prior || item.revision >= prior.revision) {
byId.set(item.itemId, item)
}
}
return [...byId.values()].sort((left, right) => left.sequence - right.sequence)
}
function mergeSubmissions(
current: readonly AgentJournalSubmission[],
incoming: readonly AgentJournalSubmission[]
): AgentJournalSubmission[] {
const byId = new Map(current.map((submission) => [submission.clientMessageId, submission]))
for (const submission of incoming) {
byId.set(submission.clientMessageId, submission)
}
return [...byId.values()]
.sort((left, right) => left.submittedAt - right.submittedAt)
.slice(-MAX_RETAINED_SUBMISSIONS)
}
export function reduceStructuredAgentSession(
state: StructuredAgentSessionState,
action: StructuredAgentSessionAction
): StructuredAgentSessionState {
if (action.type === 'loading') {
// Keep the last transcript visible while a reconnect rehydrates the stream.
return { ...state, status: 'loading', error: undefined }
}
if (action.type === 'error') {
return { ...state, status: 'error', error: action.message }
}
if (action.type === 'handoff') {
return { ...state, handoff: action.handoff }
}
if (action.type === 'tail-page') {
const pageCursor = action.page.liveCursor ?? action.page.window.newest
// An equal cursor means the page holds nothing the stream has not already
// delivered; replacing would throw away paged-in older items mid-scroll.
if (
state.epoch === action.page.epoch &&
state.cursor &&
(!pageCursor || pageCursor.sequence <= state.cursor.sequence)
) {
const backgroundTasksChanged =
action.page.backgroundTasks !== undefined &&
!backgroundTaskStatesEqual(action.page.backgroundTasks, state.backgroundTasks)
if (
pageCursor?.sequence === state.cursor.sequence &&
((action.page.fence !== undefined && action.page.fence !== state.fence) ||
backgroundTasksChanged)
) {
return {
...state,
...(action.page.fence !== undefined ? { fence: action.page.fence } : {}),
...(action.page.backgroundTasks !== undefined
? { backgroundTasks: action.page.backgroundTasks }
: {}),
status: 'ready',
error: undefined
}
}
return state
}
const sameEpoch = state.epoch === action.page.epoch
return {
epoch: action.page.epoch,
cursor: action.page.liveCursor ?? null,
fence: action.page.fence ?? null,
items: action.page.items,
submissions: sameEpoch
? mergeSubmissions(state.submissions, action.page.submissions)
: action.page.submissions,
hasOlder: action.page.hasOlder,
status: 'ready',
handoff: state.handoff,
...(sameEpoch ? { commands: state.commands } : {}),
...(sameEpoch && state.activity !== undefined ? { activity: state.activity } : {}),
...(action.page.backgroundTasks !== undefined
? { backgroundTasks: action.page.backgroundTasks }
: state.backgroundTasks !== undefined
? { backgroundTasks: state.backgroundTasks }
: {})
}
}
if (action.type === 'older-page') {
if (state.epoch !== action.requestedEpoch || action.page.epoch !== action.requestedEpoch) {
return state
}
return {
...state,
items: mergeItems(state.items, action.page.items, action.page.removedItemIds),
submissions: mergeSubmissions(state.submissions, action.page.submissions),
hasOlder: action.page.hasOlder
}
}
const event = action.event
if (event.type === 'end') {
return state
}
if (event.type === 'snapshot' || event.type === 'reset') {
return {
...replacePage(event.page, event.fence, event.handoff, event.backgroundTasks, event.activity),
commands: event.commands
}
}
if (state.epoch !== event.batch.cursor.epoch) {
return state
}
if (state.cursor && event.batch.cursor.sequence < state.cursor.sequence) {
return state
}
const backgroundTasks =
event.backgroundTasks !== undefined ? event.backgroundTasks : state.backgroundTasks
const activity = event.activity !== undefined ? event.activity : state.activity
const journalUnchanged =
event.batch.items.length === 0 &&
event.batch.removedItemIds.length === 0 &&
event.batch.submissions.length === 0
if (
event.batch.cursor.sequence === state.cursor?.sequence &&
journalUnchanged &&
(event.fence === undefined || event.fence === state.fence) &&
(event.handoff === undefined || event.handoff === state.handoff) &&
(event.commands === undefined || event.commands === state.commands) &&
backgroundTaskStatesEqual(backgroundTasks, state.backgroundTasks) &&
activity?.turnId === state.activity?.turnId &&
activity?.text === state.activity?.text &&
state.status === 'ready' &&
state.error === undefined
) {
return state
}
return {
...state,
cursor: event.batch.cursor,
fence: event.fence ?? state.fence,
items: journalUnchanged
? state.items
: mergeItems(state.items, event.batch.items, event.batch.removedItemIds),
submissions: journalUnchanged
? state.submissions
: mergeSubmissions(state.submissions, event.batch.submissions),
status: 'ready',
error: undefined,
handoff: event.handoff ?? state.handoff,
commands: event.commands !== undefined ? event.commands : state.commands,
...(backgroundTasks !== undefined ? { backgroundTasks } : {}),
...(activity !== undefined ? { activity } : {})
}
}
export function oldestStructuredAgentSessionCursor(
state: StructuredAgentSessionState
): AgentJournalCursor | null {
const oldest = state.items[0]
return state.epoch && oldest ? { epoch: state.epoch, sequence: oldest.sequence } : null
}
export function shouldAdvanceStructuredResumeCursor(
current: AgentJournalCursor | null,
incoming: AgentJournalCursor
): boolean {
return (
current === null || (current.epoch === incoming.epoch && incoming.sequence >= current.sequence)
)
}