mirror of
https://github.com/stablyai/orca.git
synced 2026-11-06 03:57:35 +00:00
* 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.
173 lines
7.0 KiB
TypeScript
173 lines
7.0 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import type { AgentChildWorkView } from './agent-status-child-work-view'
|
|
import {
|
|
structuredSessionBackgroundTasksView,
|
|
structuredSessionConfirmedStopsStillListed,
|
|
structuredSessionListedTaskRun
|
|
} from './structured-session-background-tasks-view'
|
|
|
|
function view(membership: 'live' | 'settled', kind: AgentChildWorkView['kind'] = 'agent') {
|
|
return {
|
|
id: `${kind}-${membership}`,
|
|
kind,
|
|
state: membership === 'live' ? 'working' : 'done',
|
|
membership,
|
|
...(membership === 'settled' ? { outcome: 'succeeded', settledAt: 2 } : {}),
|
|
firstObservedAt: 1,
|
|
observedAt: 2,
|
|
stoppable: false,
|
|
invocation: { invocationId: 'run-1', generation: 1 }
|
|
} satisfies AgentChildWorkView
|
|
}
|
|
|
|
describe('structuredSessionBackgroundTasksView', () => {
|
|
it('does not mount the strip for a foreground command from an older host', () => {
|
|
const id = 'codex-command:primary:pwd'
|
|
const foreground = new Set([id])
|
|
expect(
|
|
structuredSessionBackgroundTasksView(
|
|
{ state: 'monitoring', tasks: [{ id, kind: 'command' }] },
|
|
'turn-1',
|
|
foreground
|
|
)
|
|
).toMatchObject({ show: false, isMonitoring: false, tasks: [] })
|
|
})
|
|
|
|
it('removes the foreground command from child views and legacy tasks together', () => {
|
|
const id = 'codex-command:primary:pwd'
|
|
const shell = { ...view('live', 'command'), providerId: id }
|
|
const agent = view('live')
|
|
const tasks = [
|
|
{ id, kind: 'command' as const },
|
|
{ id: 'child-agent', kind: 'agent' as const }
|
|
]
|
|
const shown = structuredSessionBackgroundTasksView(
|
|
{ state: 'monitoring', tasks, children: [shell, agent] },
|
|
'turn-1',
|
|
new Set([id])
|
|
)
|
|
expect(shown).toMatchObject({ show: true, tasks: [tasks[1]], children: [agent] })
|
|
})
|
|
|
|
it('keeps unrelated rosters and commands from earlier turns during the current turn', () => {
|
|
const tasks = [
|
|
{ id: 'codex-command:primary:server', kind: 'command' as const },
|
|
{ id: 'claude-shell', kind: 'command' as const },
|
|
{ id: 'codex-command:thread:child:pwd', kind: 'command' as const }
|
|
]
|
|
const shown = structuredSessionBackgroundTasksView(
|
|
{ state: 'monitoring', tasks },
|
|
'turn-2',
|
|
new Set(['codex-command:primary:pwd'])
|
|
)
|
|
expect(shown.show).toBe(true)
|
|
expect(shown.tasks).toBe(tasks)
|
|
})
|
|
|
|
it('shows finished children until the next turn, but they hold nothing open', () => {
|
|
const finished = structuredSessionBackgroundTasksView(
|
|
{ state: 'monitoring', children: [view('settled')] },
|
|
null
|
|
)
|
|
expect(finished).toMatchObject({ show: true, isMonitoring: false, children: [view('settled')] })
|
|
})
|
|
|
|
it('hands the strip the decoded roster it was given, so its grouped rows stay memoized', () => {
|
|
const roster = { state: 'monitoring' as const, children: [view('live')] }
|
|
expect(structuredSessionBackgroundTasksView(roster, null).children).toBe(roster.children)
|
|
expect(structuredSessionBackgroundTasksView(roster, 'turn-1').children).toBe(roster.children)
|
|
})
|
|
|
|
it('keeps the same empty lists for a roster that omits one, so the strip memo holds', () => {
|
|
for (const roster of [
|
|
{ state: 'monitoring' as const, tasks: [{ id: 'task-1', kind: 'agent' as const }] },
|
|
{ state: 'monitoring' as const, settledTasks: [{ id: 'task-2', kind: 'agent' as const }] },
|
|
null
|
|
]) {
|
|
const first = structuredSessionBackgroundTasksView(roster, null)
|
|
const again = structuredSessionBackgroundTasksView(roster, null)
|
|
expect(again.tasks).toBe(first.tasks)
|
|
expect(again.settledTasks).toBe(first.settledTasks)
|
|
}
|
|
})
|
|
|
|
it('reads live work beneath an idle session as monitoring, and a running turn as its own', () => {
|
|
const roster = {
|
|
state: 'monitoring' as const,
|
|
children: [view('settled'), view('live', 'command')]
|
|
}
|
|
expect(structuredSessionBackgroundTasksView(roster, null).isMonitoring).toBe(true)
|
|
expect(structuredSessionBackgroundTasksView(roster, 'turn-1').isMonitoring).toBe(false)
|
|
})
|
|
|
|
it("shows an older host's running tasks only, and nothing once only finished ones are left", () => {
|
|
const tasks = [{ id: 'task-1', kind: 'agent' as const }]
|
|
const settledTasks = [{ id: 'task-2', kind: 'agent' as const }]
|
|
expect(
|
|
structuredSessionBackgroundTasksView({ state: 'monitoring', tasks, settledTasks }, null)
|
|
).toMatchObject({ show: true, isMonitoring: true, tasks, settledTasks: [] })
|
|
expect(
|
|
structuredSessionBackgroundTasksView({ state: 'monitoring', settledTasks }, null)
|
|
).toMatchObject({ show: false, isMonitoring: false })
|
|
// A host that reports state only still says something runs.
|
|
expect(structuredSessionBackgroundTasksView({ state: 'monitoring' }, null).show).toBe(true)
|
|
})
|
|
|
|
it('reads an older host that publishes no views exactly as before', () => {
|
|
const tasks = [{ id: 'task-1', kind: 'agent' as const }]
|
|
expect(structuredSessionBackgroundTasksView({ state: 'monitoring', tasks }, null)).toEqual({
|
|
show: true,
|
|
isMonitoring: true,
|
|
tasks,
|
|
settledTasks: [],
|
|
supportsStop: false,
|
|
supportsStopAll: true
|
|
})
|
|
expect(structuredSessionBackgroundTasksView(null, null)).toMatchObject({
|
|
show: false,
|
|
isMonitoring: false
|
|
})
|
|
})
|
|
})
|
|
|
|
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()
|
|
})
|
|
})
|