Let a Codex chat stop a sub-agent from the background-tasks strip (#27026)

* Let a Codex chat stop a sub-agent from the background-tasks strip

A Codex sub-agent showed in the background-tasks strip with no Stop, and
the chat's own Stop interrupts only the main turn, so a helper ran on until
it finished. Codex's app-server takes `turn/interrupt` for any loaded thread,
sub-agent threads included, so a sub-agent row's Stop now interrupts the
turn Orca saw that sub-agent run, through the same background Stop route
Codex commands use.

Codex's -32600 refusal (no active turn, another turn, thread not found)
means the run the row showed is over, so the Stop is quiet; any other
refusal says the task wasn't stopped; a timeout or lost contact is
unconfirmed. A live run showed Codex holds an interrupt that lands as the
turn ends until the sub-agent's next turn ends, so a timeout on a run the
session has since seen end is quiet too. Older clients' `codex-agent:` rows
name no host record and stay unstoppable.

* Stop a Codex sub-agent's own sub-agents and commands with it, and hold Stopping per run

A sub-agent Stop interrupted only that helper's turn, so the commands it was
running (Codex keeps a turn's processes when the turn is interrupted) and the
helpers it had spawned kept going, and its row stayed in the strip. The Stop
now interrupts the helper's run and every descendant's run, then terminates
the processes those threads hold where the app-server has background
terminals. The interrupt goes through interruptCodexTurn, and a small
codex-background-task-stops module composes the sub-agent and terminal stops,
so the terminals module is terminals only again.

A Codex helper keeps one row id (its thread id) across runs, so a confirmed
Stop's held button carried over to the helper's next run. Both clients now
hold it for the run it was pressed on, read from the row's invocation.

* Test that a sub-agent Stop leaves siblings alone and skips terminates after a timeout

Adds direct tests of the per-run Stop hold (including an older host's roster),
matches the desktop hold comment to mobile's, and reuses the one 10 s bound.
This commit is contained in:
Brennan Benson
2026-10-09 18:54:15 -07:00
committed by GitHub
parent c8b8997762
commit cbc565d804
20 changed files with 867 additions and 96 deletions
@@ -324,6 +324,32 @@ describe('MobileNativeChatBackgroundTasks', () => {
expect(stopButtons(mounted)[0]!.props.disabled).toBe(false)
})
it("offers Stop again on a helper's next run, which reuses the stopped run's row id", async () => {
let finish: (result: StopResult) => void = () => {}
const stop = vi.fn(() => new Promise<StopResult>((resolve) => (finish = resolve)))
const running = { state: 'monitoring' as const, supportsTaskStop: true }
const mounted = mount(tasksFor({ ...running, children: [view('a')] }, { stop }))
expand(mounted)
act(() => stopButtons(mounted)[0]!.props.onPress())
await act(async () => finish(STOPPED))
expect(stopButtons(mounted)[0]!.props.disabled).toBe(true)
// The helper's next run, with no render between: same provider id, a new invocation.
act(() => {
mounted.update(
createElement(MobileNativeChatBackgroundTasks, {
tasks: tasksFor(
{
...running,
children: [view('a', { invocation: { invocationId: 'turn-2', generation: 2 } })]
},
{ stop }
)
})
)
})
expect(stopButtons(mounted)[0]!.props.disabled).toBe(false)
})
it('stops a backgrounded Codex command by its own id, and offers none on the subagent beside it', () => {
const stop = vi.fn(async () => REJECTED)
const mounted = mount(
@@ -1,10 +1,14 @@
import { useRef, useState } from 'react'
import { structuredSessionConfirmedStopsStillListed } from '../../../src/shared/structured-session-background-tasks-view'
import {
structuredSessionConfirmedStopsStillListed,
structuredSessionListedTaskRun
} from '../../../src/shared/structured-session-background-tasks-view'
import type { MobileStructuredBackgroundTasks } from './use-mobile-structured-background-tasks'
export type MobileBackgroundTaskStopping = { taskIds: ReadonlySet<string>; all: boolean }
const NO_TASKS: ReadonlySet<string> = new Set()
const NO_CONFIRMED_STOPS: ReadonlyMap<string, string> = new Map()
const NOT_STOPPING: MobileBackgroundTaskStopping = { taskIds: NO_TASKS, all: false }
/** The strip's Stop presses: which buttons hold, and the press that sends one. */
@@ -14,8 +18,9 @@ export function useMobileBackgroundTaskStops(
const { view, stop } = tasks
const [stopping, setStopping] = useState<MobileBackgroundTaskStopping>(NOT_STOPPING)
const stoppingRef = useRef(NOT_STOPPING)
// Stops the host confirmed: each row keeps its stopping button until it leaves the strip.
const [confirmed, setConfirmed] = useState<ReadonlySet<string>>(NO_TASKS)
// Stops the host confirmed, with the run each was pressed on: each row keeps its stopping button
// until that run leaves the strip.
const [confirmed, setConfirmed] = useState<ReadonlyMap<string, string>>(NO_CONFIRMED_STOPS)
const confirmedListed = structuredSessionConfirmedStopsStillListed(view, confirmed)
if (confirmedListed !== confirmed) {
// A row that left and came back is a task the Stop did not end, so it offers Stop again.
@@ -32,14 +37,16 @@ export function useMobileBackgroundTaskStops(
if (taskId ? current.taskIds.has(taskId) : current.all) {
return
}
// A provider may reuse the row's id for the task's next run, which this Stop did not end.
const run = taskId ? structuredSessionListedTaskRun(view, taskId) : null
updateStopping({
taskIds: taskId ? new Set([...current.taskIds, taskId]) : current.taskIds,
all: taskId ? current.all : true
})
void stop(taskId)
.then((result) => {
if (taskId && result.status === 'accepted' && result.value.cancelled) {
setConfirmed((held) => new Set([...held, taskId]))
if (taskId && run !== null && result.status === 'accepted' && result.value.cancelled) {
setConfirmed((held) => new Map([...held, [taskId, run]]))
}
})
.finally(() => {
@@ -54,7 +61,7 @@ export function useMobileBackgroundTaskStops(
return {
stopping: confirmedListed.size
? { ...stopping, taskIds: new Set([...stopping.taskIds, ...confirmedListed]) }
? { ...stopping, taskIds: new Set([...stopping.taskIds, ...confirmedListed.keys()]) }
: stopping,
onStop
}
@@ -0,0 +1,89 @@
// A Codex chat's background Stop: a sub-agent row stops by interrupting its run and its
// descendants' runs (`codex-subagent-interrupts.ts`), and a command row, or a process one of those
// sub-agents holds, by its app-server's background terminals (`codex-background-terminals.ts`).
import { CodexAppServerTimeoutError } from './codex-app-server-session'
import {
boundedTimeout,
terminateCodexBackgroundTerminals,
type CodexBackgroundTerminal
} from './codex-background-terminals'
import { requireLiveCodexSession, type CodexSession } from './codex-structured-session-state'
import { interruptCodexSubagents } from './codex-subagent-interrupts'
import type { AgentSessionBackgroundTaskStops } from '../../shared/agent-child-work-stop-targets'
type StopTally = { stopped: number; survived: boolean }
/** A sub-agent stops by its own turn's interrupt on any Codex; a command row offers its Stop only
* once the app-server proved it can terminate background terminals. */
export function codexBackgroundTaskStops(
session: CodexSession | undefined
): AgentSessionBackgroundTaskStops | undefined {
return session
? { supportsTaskStop: true, supportsStopAll: session.backgroundTasks.stopsTerminals }
: undefined
}
function distinctTerminals(
terminals: readonly CodexBackgroundTerminal[]
): CodexBackgroundTerminal[] {
const byKey = new Map(
terminals.map((terminal) => [JSON.stringify([terminal.threadId, terminal.processId]), terminal])
)
return [...byKey.values()]
}
/** Stops the sub-agents and backgrounded commands the named tasks are, while `session` is still
* the one the host asked about. Cancelled when at least one is gone; throws, after trying every
* other, when one got no answer and none was shown still running. */
export async function stopCodexBackgroundTasks(
sessions: Map<string, CodexSession>,
input: { sessionId: string; fence: number; taskIds: readonly string[] },
timeoutMs: number | undefined
): Promise<{ cancelled: boolean; stillRunning?: true }> {
const session = requireLiveCodexSession(sessions, input.sessionId)
const { backgroundTasks } = session
const subagents = backgroundTasks.subagentStopTargets(input.taskIds)
// Interrupting a turn leaves the processes it started running, so a sub-agent's go too.
const terminals = distinctTerminals([
...backgroundTasks.backgroundProcesses(input.taskIds),
...subagents.terminals
])
const { acquisitionGeneration } = session
const options = {
timeoutMs: boundedTimeout(timeoutMs),
isCurrent: () =>
sessions.get(input.sessionId) === session &&
!session.ended &&
session.fence === input.fence &&
session.acquisitionGeneration === acquisitionGeneration
}
const none: StopTally = { stopped: 0, survived: false }
let failure: { error: unknown } | undefined
const settle = (stop: Promise<StopTally>) =>
stop.catch((error: unknown) => {
failure ??= { error }
return none
})
const children = subagents.turns.length
? await settle(
interruptCodexSubagents(session, subagents.turns, {
...options,
ended: ({ threadId, turnId }) => !backgroundTasks.runsSubagentTurn(threadId, turnId)
})
)
: none
// An app-server that is not answering is not asked again within the same Stop.
const commands =
terminals.length && !(failure?.error instanceof CodexAppServerTimeoutError)
? await settle(terminateCodexBackgroundTerminals(session.connection, terminals, options))
: none
const survived = children.survived || commands.survived
if (failure && !survived) {
throw failure.error
}
return {
cancelled: children.stopped + commands.stopped > 0,
...(survived ? { stillRunning: true as const } : {})
}
}
@@ -87,6 +87,44 @@ export class CodexBackgroundTaskTracker {
return this.stopsTerminals ? this.commands.backgroundProcesses(taskIds) : []
}
/** What a Stop of the named sub-agents reaches: the run each one and each of its descendants is
* working on, and, when this app-server can terminate them, the processes their threads hold.
* A sub-agent row's providerId is its thread id. */
subagentStopTargets(taskIds: readonly string[]): {
turns: { threadId: string; turnId: string }[]
terminals: { threadId: string; processId: string }[]
} {
const named = new Set(taskIds)
const lineage = this.executions
.workingChildren()
.filter((child) => named.has(child.agentThreadId))
.flatMap((child) => this.executions.lineage(child.agentThreadId))
const threads = [...new Set(lineage.map((child) => child.agentThreadId))]
return {
turns: threads.flatMap((threadId) => {
const execution = this.executions.find(threadId)?.execution
return execution?.state === 'working' ? [{ threadId, turnId: execution.turnId }] : []
}),
terminals: this.stopsTerminals
? threads.flatMap((threadId) =>
this.commands
.threadCommands(threadId)
.flatMap((command) =>
command.type === 'started' && command.processId !== undefined
? [{ threadId, processId: command.processId }]
: []
)
)
: []
}
}
/** Whether the sub-agent is still working on this run. */
runsSubagentTurn(threadId: string, turnId: string): boolean {
const execution = this.executions.find(threadId)?.execution
return execution?.state === 'working' && execution.turnId === turnId
}
get state(): AgentSessionBackgroundTaskState | null {
// Journal admission precedes observe; readers must not see its pending facts.
return this.publishedState
@@ -162,8 +162,9 @@ describe('Codex background-command Stop', () => {
await backgroundedDevServer(codex)
// Per-row Stop stays offered for sub-agents; no command row is stoppable and no Stop all.
expect(codex.adapter.backgroundTaskStops('session-1')).toEqual({
supportsTaskStop: false,
supportsTaskStop: true,
supportsStopAll: false
})
expect(lastCommandStoppable(codex.evidence)).toBe(false)
@@ -185,8 +186,9 @@ describe('Codex background-command Stop', () => {
})
await backgroundedDevServer(codex)
expect(lastCommandStoppable(codex.evidence)).toBe(false)
// Per-row Stop stays offered for sub-agents; no command row is stoppable and no Stop all.
expect(codex.adapter.backgroundTaskStops('session-1')).toEqual({
supportsTaskStop: false,
supportsTaskStop: true,
supportsStopAll: false
})
@@ -194,7 +196,7 @@ describe('Codex background-command Stop', () => {
await new Promise((resolve) => setImmediate(resolve))
expect(lastCommandStoppable(codex.evidence)).toBe(true)
expect(codex.adapter.backgroundTaskStops('session-1')?.supportsTaskStop).toBe(true)
expect(codex.adapter.backgroundTaskStops('session-1')?.supportsStopAll).toBe(true)
})
it('probes the app-server once, not per command', async () => {
@@ -236,7 +238,7 @@ describe('Codex background-command Stop', () => {
})
await new Promise((resolve) => setImmediate(resolve))
expect(probes).toBe(1)
expect(codex.adapter.backgroundTaskStops('session-1')?.supportsTaskStop).toBe(false)
expect(codex.adapter.backgroundTaskStops('session-1')?.supportsStopAll).toBe(false)
codex.notify('turn/completed', {
threadId: THREAD_ID,
@@ -245,7 +247,7 @@ describe('Codex background-command Stop', () => {
await new Promise((resolve) => setImmediate(resolve))
expect(probes).toBe(2)
expect(codex.adapter.backgroundTaskStops('session-1')?.supportsTaskStop).toBe(true)
expect(codex.adapter.backgroundTaskStops('session-1')?.supportsStopAll).toBe(true)
expect(lastCommandStoppable(codex.evidence)).toBe(true)
})
+4 -43
View File
@@ -11,12 +11,10 @@ import {
} from './codex-app-server-session'
import type { CodexTerminalStopSupport } from './codex-background-task-tracker'
import { readRecord } from './codex-item-field-readers'
import {
requireLiveCodexSession,
type CodexSession,
type CodexStructuredSessionAdapterDeps
import type {
CodexSession,
CodexStructuredSessionAdapterDeps
} from './codex-structured-session-state'
import type { AgentSessionBackgroundTaskStops } from '../../shared/agent-child-work-stop-targets'
type CodexTerminalRpc = Pick<CodexAppServerConnection, 'request'>
@@ -27,7 +25,7 @@ export type CodexBackgroundTerminal = { threadId: string; processId: string }
const CODEX_BACKGROUND_TERMINAL_TIMEOUT_MS = 10_000
const MAX_LIST_PAGES = 32
function boundedTimeout(timeoutMs: number | undefined): number {
export function boundedTimeout(timeoutMs: number | undefined): number {
return Math.min(
timeoutMs ?? CODEX_BACKGROUND_TERMINAL_TIMEOUT_MS,
CODEX_BACKGROUND_TERMINAL_TIMEOUT_MS
@@ -171,15 +169,6 @@ export async function terminateCodexBackgroundTerminals(
return { stopped, survived }
}
/** A backgrounded command stops through its app-server's background terminals, once that
* app-server proved it has them; a child thread has no stop here. */
export function codexBackgroundTaskStops(
session: CodexSession | undefined
): AgentSessionBackgroundTaskStops | undefined {
const stops = session?.backgroundTasks.stopsTerminals
return stops === undefined ? undefined : { supportsTaskStop: stops, supportsStopAll: stops }
}
/** Probes the session's app-server off the frame path when a running command has a process a stop
* could name, until it answers; a yes restates the running commands as stoppable. */
export function startCodexTerminalStopProbe(
@@ -211,31 +200,3 @@ export function startCodexTerminalStopProbe(
}
})
}
/** Stops the backgrounded commands the named tasks run, while `session` is still the one the host
* asked about. Cancelled when at least one is gone. */
export async function stopCodexBackgroundCommands(
sessions: Map<string, CodexSession>,
input: { sessionId: string; fence: number; taskIds: readonly string[] },
timeoutMs: number | undefined
): Promise<{ cancelled: boolean; stillRunning?: true }> {
const session = requireLiveCodexSession(sessions, input.sessionId)
const terminals = session.backgroundTasks.backgroundProcesses(input.taskIds)
if (terminals.length === 0) {
return { cancelled: false }
}
const { acquisitionGeneration } = session
const { stopped, survived } = await terminateCodexBackgroundTerminals(
session.connection,
terminals,
{
timeoutMs,
isCurrent: () =>
sessions.get(input.sessionId) === session &&
!session.ended &&
session.fence === input.fence &&
session.acquisitionGeneration === acquisitionGeneration
}
)
return { cancelled: stopped > 0, ...(survived ? { stillRunning: true as const } : {}) }
}
@@ -124,7 +124,7 @@ describe('Codex child-work evidence', () => {
residency: 'background',
description: 'audit_build',
invocation: { invocationId: 'c1', generation: 1 },
stoppable: false
stoppable: true
})
])
const aliases = store.getAliasesForChild(records()[0]!.childWorkId)
+2 -1
View File
@@ -274,7 +274,8 @@ export class CodexChildWorkEvidence {
...(facts.totalTokens !== undefined ? { totalTokens: facts.totalTokens } : {}),
...(lastMessage ? { lastMessage } : {}),
...(spawner && spawner !== this.primaryThreadId ? { ownerId: spawner } : {}),
stoppable: false
// `turn/interrupt` reaches a child thread's turn on every Codex Orca runs.
stoppable: true
}
}
@@ -96,7 +96,7 @@ describe('Codex child message preview ownership', () => {
state: 'working',
description: 'report',
lastMessage: preview,
stoppable: false,
stoppable: true,
operation: null
}
}
@@ -40,11 +40,8 @@ import {
import { createCodexStructuredNotificationRetry } from './codex-structured-notification-retry'
import { acquireCodexStructuredSession } from './codex-structured-session-acquire'
import { changeCodexThreadGoal } from './codex-structured-thread-goal'
import {
codexBackgroundTaskStops,
startCodexTerminalStopProbe,
stopCodexBackgroundCommands
} from './codex-background-terminals'
import { startCodexTerminalStopProbe } from './codex-background-terminals'
import { codexBackgroundTaskStops, stopCodexBackgroundTasks } from './codex-background-task-stops'
import {
answerCodexStructuredPrompt,
cancelCodexStructuredTurn
@@ -194,7 +191,7 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap
stopBackgroundTasks: NonNullable<StructuredAgentSessionAdapter['stopBackgroundTasks']> = (
input
) => stopCodexBackgroundCommands(this.sessions, input, this.deps.requestTimeoutMs)
) => stopCodexBackgroundTasks(this.sessions, input, this.deps.requestTimeoutMs)
bindPromptItemId = (
sessionId: string,
@@ -134,6 +134,27 @@ export class CodexSubagentExecutions {
return this.children.get(agentThreadId)
}
/** The child and every child it spawned, at any depth. */
lineage(agentThreadId: string): CodexExecutionChild[] {
const lineage: CodexExecutionChild[] = []
const seen = new Set<string>()
const pending = [agentThreadId]
for (let id = pending.pop(); id !== undefined; id = pending.pop()) {
const child = this.children.get(id)
if (!child || seen.has(id)) {
continue
}
seen.add(id)
lineage.push(child)
for (const spawned of this.children.values()) {
if (spawned.spawnerThreadId === id) {
pending.push(spawned.agentThreadId)
}
}
}
return lineage
}
workingChildren(): CodexExecutionChild[] {
return [...this.children.values()].filter(
(child) => child.registered && child.execution?.state === 'working'
@@ -0,0 +1,329 @@
import { describe, expect, it } from 'vitest'
import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence'
import { codexPrimaryCommandTaskId } from '../../shared/structured-session-foreground-commands'
import { CodexAppServerRequestError } from './codex-app-server-connection'
import { CodexAppServerTimeoutError } from './codex-app-server-session'
import {
adapterFor,
fakeCodex,
identityFor,
THREAD_ID,
type Route
} from './codex-structured-session-adapter-fixture'
const HELPER = 'thread-helper'
const HELPER_TURN = 'helper-turn-1'
const identity = identityFor('session-1')
function refusal(code: number, message: string): CodexAppServerRequestError {
return new CodexAppServerRequestError(
'turn/interrupt',
code,
`codex app-server turn/interrupt failed: ${message}`,
message
)
}
/** A session whose turn spawned `HELPER`, which is still working on its own first turn. */
async function runningHelper(routes: Record<string, Route>) {
const codex = fakeCodex(routes)
const evidence: AgentChildWorkEvidence[] = []
const adapter = adapterFor(codex, {}, [], {
onChildWorkEvidence: (_sessionId, edges) => evidence.push(...edges)
})
const live = () => codex.connections.at(-1)!
const notify = (method: string, params: unknown): void =>
live().handlers.onNotification?.(method, params)
await adapter.acquire({ identity, fence: 7, spawnToken: 'spawn-1' })
notify('turn/started', { threadId: THREAD_ID, turn: { id: 'turn-1', status: 'inProgress' } })
notify('item/started', {
threadId: THREAD_ID,
turnId: 'turn-1',
item: {
type: 'subAgentActivity',
id: 'spawn-helper',
kind: 'started',
agentThreadId: HELPER,
agentPath: '/root/helper'
}
})
notify('turn/started', { threadId: HELPER, turn: { id: HELPER_TURN, status: 'inProgress' } })
notify('turn/completed', { threadId: THREAD_ID, turn: { id: 'turn-1', status: 'completed' } })
await new Promise((resolve) => setImmediate(resolve))
const interrupts = () =>
live()
.calls.filter((call) => call.method === 'turn/interrupt')
.map((call) => call.params)
const stop = (taskIds: string[], fence = 7) =>
adapter.stopBackgroundTasks({ sessionId: 'session-1', fence, taskIds })
return { adapter, evidence, notify, interrupts, stop }
}
function helperEdges(evidence: AgentChildWorkEvidence[]) {
return evidence.filter(
(edge) =>
(edge.type === 'live'
? edge.child.handle.id
: edge.type === 'ended'
? edge.handle.id
: null) === HELPER
)
}
describe('Codex sub-agent Stop', () => {
it('offers a sub-agent its own Stop and interrupts the turn it runs', async () => {
const codex = await runningHelper({ 'turn/interrupt': () => ({}) })
expect(codex.adapter.backgroundTaskStops('session-1')).toEqual({
supportsTaskStop: true,
supportsStopAll: false
})
expect(helperEdges(codex.evidence).at(-1)).toMatchObject({
type: 'live',
child: { kind: 'agent', stoppable: true }
})
await expect(codex.stop([HELPER])).resolves.toEqual({ cancelled: true })
expect(codex.interrupts()).toEqual([{ threadId: HELPER, turnId: HELPER_TURN }])
codex.notify('turn/completed', {
threadId: HELPER,
turn: { id: HELPER_TURN, status: 'interrupted' }
})
await new Promise((resolve) => setImmediate(resolve))
expect(helperEdges(codex.evidence).at(-1)).toMatchObject({
type: 'ended',
outcome: 'cancelled'
})
// Its turn is over: a second Stop has nothing to name.
await expect(codex.stop([HELPER])).resolves.toEqual({ cancelled: false })
expect(codex.interrupts()).toHaveLength(1)
})
it.each([
['has no turn running', 'no active turn to interrupt'],
[
'has moved on to another turn',
`expected active turn id ${HELPER_TURN} but found helper-turn-2`
],
['was unloaded', `thread not found: ${HELPER}`]
])('stays quiet when Codex says the child %s', async (_, message) => {
const codex = await runningHelper({
'turn/interrupt': () => {
throw refusal(-32600, message)
}
})
await expect(codex.stop([HELPER])).resolves.toEqual({ cancelled: false })
})
it('says the sub-agent still runs when Codex could not carry the interrupt out', async () => {
const codex = await runningHelper({
'turn/interrupt': () => {
throw refusal(-32603, 'failed to interrupt turn')
}
})
await expect(codex.stop([HELPER])).resolves.toEqual({ cancelled: false, stillRunning: true })
})
it.each([
['times out', new CodexAppServerTimeoutError('codex app-server turn/interrupt timed out')],
['loses the app-server', new Error('codex app-server connection closed')]
])('leaves the Stop unconfirmed when the interrupt %s', async (_, error) => {
const codex = await runningHelper({
'turn/interrupt': () => {
throw error
}
})
await expect(codex.stop([HELPER])).rejects.toBe(error)
})
it('stays quiet when the interrupt landed as the run ended and Codex held it unanswered', async () => {
let endRun = (): void => {}
const codex = await runningHelper({
'turn/interrupt': async () => {
endRun()
await new Promise((resolve) => setImmediate(resolve))
throw new CodexAppServerTimeoutError('codex app-server turn/interrupt timed out')
}
})
endRun = () =>
codex.notify('turn/completed', {
threadId: HELPER,
turn: { id: HELPER_TURN, status: 'completed' }
})
await expect(codex.stop([HELPER])).resolves.toEqual({ cancelled: false })
})
it('sends nothing for a task that is not a running sub-agent, or for a stale fence', async () => {
const codex = await runningHelper({ 'turn/interrupt': () => ({}) })
await expect(codex.stop(['thread-unknown'])).resolves.toEqual({ cancelled: false })
await expect(codex.stop([HELPER], 6)).resolves.toEqual({ cancelled: false })
expect(codex.interrupts()).toEqual([])
})
it("ends the helper's own sub-agents with it", async () => {
const codex = await runningHelper({ 'turn/interrupt': () => ({}) })
codex.notify('item/started', {
threadId: HELPER,
turnId: HELPER_TURN,
item: {
type: 'subAgentActivity',
id: 'spawn-nested',
kind: 'started',
agentThreadId: 'thread-nested',
agentPath: '/root/helper/nested'
}
})
codex.notify('turn/started', {
threadId: 'thread-nested',
turn: { id: 'nested-turn-1', status: 'inProgress' }
})
await new Promise((resolve) => setImmediate(resolve))
await expect(codex.stop([HELPER])).resolves.toEqual({ cancelled: true })
expect(codex.interrupts()).toEqual([
{ threadId: HELPER, turnId: HELPER_TURN },
{ threadId: 'thread-nested', turnId: 'nested-turn-1' }
])
})
it('terminates the command a helper is running, which its interrupt leaves running', async () => {
const running = ['4343']
const codex = await runningHelper({
'turn/interrupt': () => ({}),
'thread/backgroundTerminals/list': () => ({
data: running.map((processId) => ({ processId })),
nextCursor: null
}),
'thread/backgroundTerminals/terminate': (params) => {
running.splice(running.indexOf(String(params?.processId)), 1)
return { terminated: true }
}
})
const command = (status: 'inProgress' | 'completed') => ({
threadId: HELPER,
turnId: HELPER_TURN,
item: {
type: 'commandExecution',
id: 'helper-exec',
processId: '4343',
source: 'unifiedExecStartup',
command: 'sleep 90',
status
}
})
codex.notify('item/started', command('inProgress'))
await new Promise((resolve) => setImmediate(resolve))
await expect(codex.stop([HELPER])).resolves.toEqual({ cancelled: true })
expect(codex.interrupts()).toEqual([{ threadId: HELPER, turnId: HELPER_TURN }])
expect(running).toEqual([])
codex.notify('turn/completed', {
threadId: HELPER,
turn: { id: HELPER_TURN, status: 'interrupted' }
})
codex.notify('item/completed', command('completed'))
await new Promise((resolve) => setImmediate(resolve))
expect(helperEdges(codex.evidence).at(-1)).toMatchObject({
type: 'ended',
outcome: 'cancelled'
})
expect(codex.evidence.at(-1)).toMatchObject({ type: 'removed' })
})
/** `runningHelper`, plus a dev server the session's own turn left running. */
async function helperAndDevServer(interrupt: Route) {
const running = ['4242']
const terminated: unknown[] = []
const codex = await runningHelper({
'turn/interrupt': interrupt,
'thread/backgroundTerminals/list': () => ({
data: running.map((processId) => ({ processId })),
nextCursor: null
}),
'thread/backgroundTerminals/terminate': (params) => {
terminated.push(params)
running.splice(running.indexOf(String(params?.processId)), 1)
return { terminated: true }
}
})
codex.notify('item/started', {
threadId: THREAD_ID,
turnId: 'turn-1',
item: {
type: 'commandExecution',
id: 'exec-1',
processId: '4242',
source: 'unifiedExecStartup',
command: 'pnpm dev',
status: 'inProgress'
}
})
await new Promise((resolve) => setImmediate(resolve))
const stopBoth = () => codex.stop([HELPER, codexPrimaryCommandTaskId('exec-1')])
return { ...codex, running, terminated, stopBoth }
}
it('stops a sub-agent and a backgrounded command in one Stop', async () => {
const codex = await helperAndDevServer(() => ({}))
await expect(codex.stopBoth()).resolves.toEqual({ cancelled: true })
expect(codex.interrupts()).toEqual([{ threadId: HELPER, turnId: HELPER_TURN }])
expect(codex.running).toEqual([])
})
it('does not ask an app-server that timed out on the sub-agent to stop the command too', async () => {
const timeout = new CodexAppServerTimeoutError('codex app-server turn/interrupt timed out')
const codex = await helperAndDevServer(() => {
throw timeout
})
await expect(codex.stopBoth()).rejects.toBe(timeout)
expect(codex.terminated).toEqual([])
})
it("leaves a helper's sibling and the session's own background process alone", async () => {
const codex = await helperAndDevServer(() => ({}))
codex.notify('item/started', {
threadId: THREAD_ID,
turnId: 'turn-1',
item: {
type: 'subAgentActivity',
id: 'spawn-sibling',
kind: 'started',
agentThreadId: 'thread-sibling',
agentPath: '/root/sibling'
}
})
codex.notify('turn/started', {
threadId: 'thread-sibling',
turn: { id: 'sibling-turn-1', status: 'inProgress' }
})
await new Promise((resolve) => setImmediate(resolve))
await expect(codex.stop([HELPER])).resolves.toEqual({ cancelled: true })
expect(codex.interrupts()).toEqual([{ threadId: HELPER, turnId: HELPER_TURN }])
expect(codex.terminated).toEqual([])
expect(codex.running).toEqual(['4242'])
})
it("does not terminate a helper's processes once its interrupt timed out", async () => {
const timeout = new CodexAppServerTimeoutError('codex app-server turn/interrupt timed out')
const codex = await helperAndDevServer(() => {
throw timeout
})
codex.notify('item/started', {
threadId: HELPER,
turnId: HELPER_TURN,
item: {
type: 'commandExecution',
id: 'helper-exec',
processId: '4343',
source: 'unifiedExecStartup',
command: 'sleep 90',
status: 'inProgress'
}
})
await new Promise((resolve) => setImmediate(resolve))
await expect(codex.stop([HELPER])).rejects.toBe(timeout)
expect(codex.terminated).toEqual([])
})
})
@@ -0,0 +1,64 @@
// Stopping a Codex sub-agent: its thread is an ordinary loaded thread on the same app-server, so
// `turn/interrupt` names the child thread and the turn it runs. Codex answers when that turn ends,
// aborted or not, and the child's own `turn/completed` then removes its row.
import { CodexAppServerTimeoutError } from './codex-app-server-session'
import type { CodexSession } from './codex-structured-session-state'
import { interruptCodexTurn } from './codex-structured-turn-cancellation'
/** One child run a Stop names: the child's thread and the turn the host last saw it working on. */
export type CodexSubagentTurn = { threadId: string; turnId: string }
/**
* Interrupts each named run. A refusal saying the turn is not running means the run is over:
* nothing to stop. Any other refusal leaves the child running. Throws when a request got no answer
* and no refusal said a child survived, since the effect is then unknown; every other child is
* tried first.
*/
export async function interruptCodexSubagents(
session: CodexSession,
turns: readonly CodexSubagentTurn[],
options: {
timeoutMs: number
isCurrent: () => boolean
/** Whether the session has since seen this run end. */
ended: (turn: CodexSubagentTurn) => boolean
}
): Promise<{ stopped: number; survived: boolean }> {
let stopped = 0
let survived = false
let failure: { error: unknown } | undefined
for (const turn of turns) {
if (!options.isCurrent()) {
break
}
try {
const outcome = await interruptCodexTurn({
session,
...turn,
requestTimeoutMs: options.timeoutMs
})
if (outcome.cancelled) {
stopped += 1
} else if (!outcome.refusal?.turnNotRunning) {
survived = true
}
} catch (error) {
// Codex holds an interrupt that lands as the turn ends, answering it only when the child's
// next turn ends; that one runs on. A run seen ended had nothing left to stop.
const timedOut = error instanceof CodexAppServerTimeoutError
if (timedOut && options.isCurrent() && options.ended(turn)) {
continue
}
failure ??= { error }
// An app-server that is not answering would make every later request wait out its deadline.
if (timedOut) {
break
}
}
}
if (failure && !survived) {
throw failure.error
}
return { stopped, survived }
}
@@ -33,14 +33,14 @@ const USER_IDENTITY = {
ordinal: 1
} as const
/** The legacy row an older client reads for `childView()`, derived from it. These sessions are
* Codex's, so a subagent keeps the id its row carried before views. */
* Codex's, so a subagent keeps the id its row carried before views, which no Stop can name. */
const LEGACY_TASK = {
id: 'codex-agent:task-1',
kind: 'agent',
name: 'deep_review',
state: 'working',
startedAt: 100,
stoppable: true
stoppable: false
} as const
let root: string
@@ -109,18 +109,26 @@ describe('foreground commands in the background-task channel', () => {
status: method === 'item/completed' ? 'completed' : 'inProgress'
}
})
return { host, rosters, turn, command, fence: attached.ok ? attached.value.fence : null }
return {
host,
rosters,
notify,
turn,
command,
fence: attached.ok ? attached.value.fence : null
}
}
type StripHost = Awaited<ReturnType<typeof rig>>['host']
/** The strip's Stop on the `server` command, as a client sends it. */
function stopFromStrip(host: StripHost, fence: number | null, operation: string) {
const fields = {
turnId: 'background-tasks',
scope: 'background-tasks' as const,
taskId: 'codex-command:primary:server'
}
/** The strip's Stop on a row (the `server` command by default), as a client sends it. */
function stopFromStrip(
host: StripHost,
fence: number | null,
operation: string,
taskId = 'codex-command:primary:server'
) {
const fields = { turnId: 'background-tasks', scope: 'background-tasks' as const, taskId }
return host.cancel(
{ callerKey: 'strip-test' },
{
@@ -246,4 +254,109 @@ describe('foreground commands in the background-task channel', () => {
children: [{ providerId: 'codex-command:primary:server', stoppable: true }]
})
})
it('stops a sub-agent from the strip by interrupting its own turn', async () => {
const helper = 'thread-helper'
const interrupts: unknown[] = []
const { host, rosters, notify, turn, fence } = await rig({
'turn/interrupt': (params) => {
interrupts.push(params)
return {}
}
})
await turn('turn/started', 'turn-1')
await notify('item/started', {
threadId: THREAD_ID,
turnId: 'turn-1',
item: {
type: 'subAgentActivity',
id: 'spawn-helper',
kind: 'started',
agentThreadId: helper,
agentPath: '/root/helper'
}
})
await notify('turn/started', {
threadId: helper,
turn: { id: 'helper-turn', status: 'inProgress' }
})
await turn('turn/completed', 'turn-1')
expect(rosters.at(-1)).toMatchObject({
supportsTaskStop: true,
children: [{ providerId: helper, kind: 'agent', stoppable: true }],
// An older client's row names `codex-agent:<thread>`, which no record carries.
tasks: [{ id: `codex-agent:${helper}`, stoppable: false }]
})
await expect(stopFromStrip(host, fence, '5', helper)).resolves.toMatchObject({
ok: true,
value: { cancelled: true }
})
expect(interrupts).toEqual([{ threadId: helper, turnId: 'helper-turn' }])
// The child's own interrupted turn is what takes its row off the strip.
await notify('turn/completed', {
threadId: helper,
turn: { id: 'helper-turn', status: 'interrupted' }
})
expect(rosters.at(-1)).toBeNull()
})
it("stops a sub-agent's running command with it, and the row leaves", async () => {
const helper = 'thread-helper'
let running = ['5151']
const { host, rosters, notify, turn, fence } = await rig({
'turn/interrupt': () => ({}),
'thread/backgroundTerminals/list': () => ({
data: running.map((processId) => ({ processId })),
nextCursor: null
}),
'thread/backgroundTerminals/terminate': (params) => {
running = running.filter((processId) => processId !== params?.processId)
return { terminated: true }
}
})
const helperCommand = (status: 'inProgress' | 'completed') =>
notify(status === 'completed' ? 'item/completed' : 'item/started', {
threadId: helper,
turnId: 'helper-turn',
item: {
type: 'commandExecution',
id: 'helper-exec',
processId: '5151',
command: 'sleep 90',
source: 'unifiedExecStartup',
status
}
})
await turn('turn/started', 'turn-1')
await notify('item/started', {
threadId: THREAD_ID,
turnId: 'turn-1',
item: {
type: 'subAgentActivity',
id: 'spawn-helper',
kind: 'started',
agentThreadId: helper,
agentPath: '/root/helper'
}
})
await notify('turn/started', {
threadId: helper,
turn: { id: 'helper-turn', status: 'inProgress' }
})
await helperCommand('inProgress')
await turn('turn/completed', 'turn-1')
await expect(stopFromStrip(host, fence, '6', helper)).resolves.toMatchObject({
ok: true,
value: { cancelled: true }
})
expect(running).toEqual([])
await notify('turn/completed', {
threadId: helper,
turn: { id: 'helper-turn', status: 'interrupted' }
})
await helperCommand('completed')
expect(rosters.at(-1)).toBeNull()
})
})
@@ -0,0 +1,61 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { AgentSessionCancelResult } from '../../../../shared/agent-session-wire'
import type { AgentChildWorkView } from '../../../../shared/agent-status-child-work-view'
import { structuredSessionBackgroundTasksView } from '../../../../shared/structured-session-background-tasks-view'
import { NativeChatStructuredSessionStatus } from './NativeChatStructuredSessionStatus'
vi.mock('./use-structured-session-child-row-context', () => ({
useStructuredSessionChildRowContext: () => undefined
}))
afterEach(cleanup)
function helper(invocation: AgentChildWorkView['invocation']): AgentChildWorkView {
return {
id: 'helper',
providerId: 'thread-helper',
kind: 'agent',
description: 'audit_build',
state: 'working',
membership: 'live',
firstObservedAt: Date.now() - 65_000,
observedAt: Date.now() - 1_000,
stoppable: true,
invocation
}
}
describe('NativeChatStructuredSessionStatus', () => {
it("offers Stop again on a helper's next run, which reuses the stopped run's row id", async () => {
let finish: (result: AgentSessionCancelResult) => void = () => {}
const stopBackgroundTask = vi.fn(
() => new Promise<AgentSessionCancelResult>((resolve) => (finish = resolve))
)
const strip = (children: AgentChildWorkView[]) => (
<NativeChatStructuredSessionStatus
sessionId="session-1"
paneKey="pane-1"
isVisible
backgroundTasks={structuredSessionBackgroundTasksView(
{ state: 'monitoring', supportsTaskStop: true, children },
null
)}
stopBackgroundTask={stopBackgroundTask}
/>
)
const { rerender } = render(strip([helper({ invocationId: 'turn-1', generation: 1 })]))
fireEvent.click(screen.getByRole('button', { expanded: false }))
fireEvent.click(screen.getByLabelText('Stop audit_build'))
await act(async () => finish({ cancelled: true }))
expect(screen.getByLabelText('Stop audit_build')).toBeDisabled()
// The helper's next run, with no render between: same provider id, a new invocation.
rerender(strip([helper({ invocationId: 'turn-2', generation: 2 })]))
expect(screen.getByLabelText('Stop audit_build')).toBeEnabled()
})
})
@@ -3,6 +3,7 @@ import { NativeChatBackgroundTasksStatus } from './NativeChatBackgroundTasksStat
import type { AgentSessionCancelResult } from '../../../../shared/agent-session-wire'
import {
structuredSessionConfirmedStopsStillListed,
structuredSessionListedTaskRun,
type StructuredSessionBackgroundTasksView
} from '../../../../shared/structured-session-background-tasks-view'
import { useStructuredSessionChildRowContext } from './use-structured-session-child-row-context'
@@ -13,9 +14,11 @@ type StoppingBackgroundTasks = {
all: boolean
}
type ConfirmedStops = { sessionId: string; taskIds: ReadonlySet<string> }
/** Each confirmed Stop's task, with the run it was pressed on. */
type ConfirmedStops = { sessionId: string; taskIds: ReadonlyMap<string, string> }
const NO_STOPPING_TASKS: ReadonlySet<string> = new Set()
const NO_CONFIRMED_STOPS: ReadonlyMap<string, string> = new Map()
export function NativeChatStructuredSessionStatus(props: {
sessionId: string
@@ -26,7 +29,8 @@ export function NativeChatStructuredSessionStatus(props: {
stopBackgroundTask: (taskId?: string) => Promise<AgentSessionCancelResult | null>
}): React.JSX.Element {
const [stopping, setStopping] = useState<StoppingBackgroundTasks | null>(null)
// Stops the host confirmed: each row keeps its stopping button until it leaves the strip.
// Stops the host confirmed, with the run each was pressed on: each row keeps its stopping button
// until that run leaves the strip.
const [confirmed, setConfirmed] = useState<ConfirmedStops | null>(null)
const [expanded, setExpanded] = useState<{ sessionId: string; expanded: boolean } | null>(null)
const activeStopping = stopping?.sessionId === props.sessionId ? stopping : null
@@ -41,12 +45,14 @@ export function NativeChatStructuredSessionStatus(props: {
)
}
const stoppingTaskIds = confirmedListed?.size
? new Set([...(activeStopping?.taskIds ?? NO_STOPPING_TASKS), ...confirmedListed])
? new Set([...(activeStopping?.taskIds ?? NO_STOPPING_TASKS), ...confirmedListed.keys()])
: (activeStopping?.taskIds ?? NO_STOPPING_TASKS)
const childRowContext = useStructuredSessionChildRowContext(props.paneKey)
const onStop = (taskId?: string) => {
const sessionId = props.sessionId
// A provider may reuse the row's id for the task's next run, which this Stop did not end.
const run = taskId ? structuredSessionListedTaskRun(props.backgroundTasks, taskId) : null
setStopping((current) => {
const taskIds = new Set(
current?.sessionId === sessionId ? current.taskIds : NO_STOPPING_TASKS
@@ -63,12 +69,12 @@ export function NativeChatStructuredSessionStatus(props: {
void props
.stopBackgroundTask(taskId)
.then((result) => {
if (taskId && result?.cancelled) {
if (taskId && run !== null && result?.cancelled) {
setConfirmed((current) => ({
sessionId,
taskIds: new Set([
...(current?.sessionId === sessionId ? current.taskIds : NO_STOPPING_TASKS),
taskId
taskIds: new Map([
...(current?.sessionId === sessionId ? current.taskIds : NO_CONFIRMED_STOPS),
[taskId, run]
])
}))
}
@@ -33,7 +33,8 @@ function withLegacyIds(
): AgentChildWorkView[] {
return views.map((view) =>
provider === 'codex' && view.kind === 'agent' && view.providerId !== undefined
? { ...view, providerId: codexAgentBackgroundTaskId(view.providerId) }
? // A Stop names the row's id, which no host record carries: these rows offer none.
{ ...view, providerId: codexAgentBackgroundTaskId(view.providerId), stoppable: false }
: view
)
}
@@ -1,6 +1,10 @@
import { describe, expect, it } from 'vitest'
import type { AgentChildWorkView } from './agent-status-child-work-view'
import { structuredSessionBackgroundTasksView } from './structured-session-background-tasks-view'
import {
structuredSessionBackgroundTasksView,
structuredSessionConfirmedStopsStillListed,
structuredSessionListedTaskRun
} from './structured-session-background-tasks-view'
function view(membership: 'live' | 'settled', kind: AgentChildWorkView['kind'] = 'agent') {
return {
@@ -125,3 +129,44 @@ describe('structuredSessionBackgroundTasksView', () => {
})
})
})
describe('a confirmed Stop held per run', () => {
const helper = (invocationId: string, generation: number): AgentChildWorkView => ({
...view('live'),
providerId: 'thread-helper',
invocation: { invocationId, generation }
})
const withChildren = (children: AgentChildWorkView[]) =>
structuredSessionBackgroundTasksView({ state: 'monitoring', children }, null)
it("keys a child's run by its invocation, so the next run under the same id is another", () => {
const first = withChildren([helper('turn-1', 1)])
const run = structuredSessionListedTaskRun(first, 'thread-helper')
expect(run).not.toBeNull()
const confirmed = new Map([['thread-helper', run!]])
expect(structuredSessionConfirmedStopsStillListed(first, confirmed)).toBe(confirmed)
const next = withChildren([helper('turn-2', 2)])
expect(structuredSessionListedTaskRun(next, 'thread-helper')).not.toBe(run)
expect(structuredSessionConfirmedStopsStillListed(next, confirmed)).toEqual(new Map())
expect(structuredSessionConfirmedStopsStillListed(withChildren([]), confirmed)).toEqual(
new Map()
)
})
it("keys an older host's roster row by its id, which it has no run marker beside", () => {
const legacy = structuredSessionBackgroundTasksView(
{ state: 'monitoring', tasks: [{ id: 'task-1', kind: 'command' }] },
null
)
expect(structuredSessionListedTaskRun(legacy, 'task-1')).toBe('task-1')
expect(structuredSessionListedTaskRun(legacy, 'task-2')).toBeNull()
const confirmed = new Map([['task-1', 'task-1']])
expect(structuredSessionConfirmedStopsStillListed(legacy, confirmed)).toBe(confirmed)
})
it('lists nothing while the strip is hidden', () => {
const hidden = structuredSessionBackgroundTasksView(null, null)
expect(structuredSessionListedTaskRun(hidden, 'task-1')).toBeNull()
})
})
@@ -77,22 +77,32 @@ export function structuredSessionBackgroundTasksView(
}
}
/** Whether the strip still lists the task a Stop named, live or settled. */
function listsTask(view: StructuredSessionBackgroundTasksView, providerId: string): boolean {
return (
view.show &&
(view.children
? view.children.some((child) => child.providerId === providerId)
: view.tasks.some((task) => task.id === providerId))
)
/** The run of the task the strip lists under `providerId`, live or settled: its record's
* invocation, since a provider can reuse one id across a child's runs (a Codex sub-agent's thread),
* or the id itself on an older host's roster. Null when the strip does not list it. */
export function structuredSessionListedTaskRun(
view: StructuredSessionBackgroundTasksView,
providerId: string
): string | null {
if (!view.show) {
return null
}
if (!view.children) {
return view.tasks.some((task) => task.id === providerId) ? providerId : null
}
const child = view.children.find((candidate) => candidate.providerId === providerId)
return child ? `${child.invocation.invocationId}#${child.invocation.generation}` : null
}
/** The host-confirmed Stops whose rows the strip still lists, each holding its row's button; the
* same set when none has left, so a caller can tell when to drop the rest. */
/** The host-confirmed Stops, by provider id and the run each was pressed on, whose run the strip
* still lists, each holding its row's button; the same map when none has left, so a caller can
* tell when to drop the rest. */
export function structuredSessionConfirmedStopsStillListed(
view: StructuredSessionBackgroundTasksView,
confirmed: ReadonlySet<string>
): ReadonlySet<string> {
const listed = [...confirmed].filter((providerId) => listsTask(view, providerId))
return listed.length === confirmed.size ? confirmed : new Set(listed)
confirmed: ReadonlyMap<string, string>
): ReadonlyMap<string, string> {
const listed = [...confirmed].filter(
([providerId, run]) => structuredSessionListedTaskRun(view, providerId) === run
)
return listed.length === confirmed.size ? confirmed : new Map(listed)
}