mirror of
https://github.com/stablyai/orca.git
synced 2026-11-03 18:47:40 +00:00
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:
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
+2
-2
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user