mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
feat(native-chat): show live background work and name each row by kind (#19705)
* fix(codex): reserve the label's share of a qualified command row
A child's label is raw provider text and was spliced into the command
row unbounded, then the pair clipped to the description cap. A label at
or past that cap clipped the command away entirely, leaving a row of
kind 'command' that named an agent and showed no command - the failure
qualification exists to remove, inverted. The same clip could also cut a
surrogate pair, which boundSubagentField already guards against on the
agent row two lines away.
Give the label a reserved share and clip it the way the agent row does.
* feat(native-chat): show live background work and name each row by kind
The strip suppressed itself in three places: the Claude tracker blanked
its roster for the whole of any turn, the Codex tracker returned nothing
while a primary turn was open, and the renderer view gated on
`turnId === null`. Between them, work in flight was never shown — and a
task backgrounded in an earlier turn vanished from the strip as soon as
the next prompt was sent. Claude additionally dropped every foreground
subagent, so a fan-out reported nothing at all.
Report work while it is live, in all three layers. Foreground Claude
work is turn-scoped, so `result` retires it — that is the provider's own
outcome for a task it marked foreground, not a roster sweep. Nothing
settles a Codex child on turn end: those keep reporting well past their
parent, so turn frames only prompt a republish.
Name each ROW by kind — Subagent, Shell command, Workflow, Monitor —
instead of a generic "Background <kind>", each drawing the glyph the
shared tool-icon table already uses for that category. A row that
carries a provider description still shows it unchanged. The collapsed
header summary is deliberately untouched; it is owned elsewhere.
The conversation-command gate is unchanged in effect: an open turn
already refuses first, and Claude foreground work never reaches the
backgrounded set the gate reads.
* fix(native-chat): withhold the row stop Claude foreground work cannot honour
The strip now publishes foreground rows, but `stoppableTaskIds` still filters
on `backgrounded`, so `stopClaudeBackgroundTasks` resolved an empty target list
and returned `{ cancelled: false }` that no renderer reads: the user clicked
"Stop Subagent" and nothing ever happened.
Carry stoppability per row instead of widening the stop to a target the SDK has
no way to reach. `AgentSessionBackgroundTask.stoppable` is absent-means-yes, so
hosts that predate it keep their working control, Claude emits `false` only on
foreground rows, and the strip hides that row's button the same way it already
hides the stop-all a provider cannot honour.
* fix(claude): scope aggregate-roster authority to the work it enumerates
`background_tasks_changed` lists BACKGROUNDED tasks, so a foreground subagent
can never appear in it. Treating it as the whole world meant any such frame
cleared every live foreground row mid-flight and then dropped every later
foreground `task_started` for the rest of the session, killing the in-turn
fan-out the strip exists to show in any session that ever backgrounds anything.
Decide `backgrounded` before the staleness guard and apply the guard only to a
backgrounded start, and retain live foreground entries across a roster replace.
Retained rows count against MAX_TRACKED_TASKS, so the map stays bounded, and a
stale backgrounded start the roster no longer lists is still dropped.
* test(native-chat): pin the strip's monitor amber to the constant that defines it
`MONITOR_GLYPH_COLOR`'s comment claimed a test held it and AgentStateDot's amber
together, but no test imported it — the assertions hardcoded 'text-yellow-500',
so the two could drift with every test still green. Read the colour from the
module, which is what the comment always said was happening. Drop the unused
`BackgroundTaskGlyph` export too: nothing outside the module names it.
* fix(native-chat): keep the task list open across a gap in live work
The strip is now mounted on live work, so a sequential fan-out unmounts it
between one subagent finishing and the next starting: local `useState` meant
the expanded list collapsed itself on every such gap, on top of the strip
flickering above the composer.
Hand the disclosure to the session, keyed by session id so it does not leak
across a session switch. The strip is now controlled and holds no state of its
own, which is what makes it survive its own mount churn.
* fix(codex): route every command-row cut through one surrogate-safe clip
`boundLabel` avoided splitting a pair, then `qualifiedDescription` re-cut the
COMPOSED string with a raw slice: label (<=96) plus separator plus description
(<=512) is up to 611 chars, so that second cut landed at an arbitrary index
inside the description and could publish a lone high surrogate — lossy through
any non-JSON UTF-8 hop. `parse` had the identical hazard on an unqualified
primary-thread command.
One `boundText` helper now owns all three cuts, so no path in the file can emit
a lone surrogate from well-formed input.
* fix(claude): keep terminal evidence for ids an aggregate roster never lists
Narrowing the admission guard to backgrounded starts left a finished FOREGROUND
id with no defence: `replaceAggregateRoster` wiped `terminalTaskIds` wholesale,
so after any `background_tasks_changed` a replayed `task_started` revived a task
whose completion had already been seen — and only a later `result` could settle
it again.
Scope the wipe the same way the guard was scoped: delete only the ids the
incoming roster actually enumerates. A roster still overrules terminal evidence
for the work it lists, which is what that behaviour was added for.
* fix(claude): keep retained rows in place and evict the stalest, not the newest
Re-adding retained foreground entries after the roster made a live row the user
is reading jump below the backgrounded rows on every `background_tasks_changed`,
and the cap `break` kept the STALEST retained rows while dropping the newest.
Merge in the tracked map's own order so a surviving row holds its position, and
count the overflow up front so eviction takes the oldest retained rows. Roster
entries are never starved and the map stays bounded either way.
* fix(claude): retire leftover foreground rows when the next turn starts
A foreground `task_started` arriving with no turn open has no `result` coming
to retire it, so it sat in the strip indefinitely — with no per-row stop, since
foreground rows are not stoppable — and refused conversation commands behind an
instruction nobody could follow.
Settle on turn start as well as on `result`. This is cleanup only: visibility
never consults `startsTurn`, so a missed one degrades to today's behaviour and
can never switch the feature off. It shortens the row's life to the next turn;
the case where no further turn is ever sent is filed separately.
* fix(agent-session): withhold unstoppable rows from readers that predate them
Rule 3 of remote-wire-compatibility: changing what the host publishes reaches
old clients with no wire change. The Claude host published no foreground rows
before this feature; it does now, and a client that cannot read `stoppable`
draws a per-row Stop on every one of them — Claude always sets
`supportsTaskStop` — which filters to the backgrounded ids, stops nothing, and
returns a result no renderer inspects. That is the dead button `stoppable` was
added to remove, reappearing across a version skew.
Negotiate it. A client can advertise the existing background-task-stop
capability and still predate `stoppable`, so this needs its own constant.
Readers that do not advertise it get unstoppable rows dropped, and a state whose
every row is dropped becomes no strip — exactly their pre-feature view.
RUNTIME_PROTOCOL_VERSION is not bumped: this adds an optional field and a new
negotiated capability, and changes no existing field's meaning, which is the
explicit do-not-bump case in protocol-version.ts.
* test(agent-session): name the projected rows so the fixture typechecks
An indexed lookup into the fixture's task list is possibly-undefined under
`pnpm tc`; the rows are more readable named anyway.
* test(web): advertise the row-stop capability in the e2ee auth expectation
The web e2ee handshake started sending
AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, and this test asserts the
advertised list by deep equality, so it went red on CI while every targeted
test run stayed green. Add the capability in the position the router sends it.
* test(claude): pin why the roster empties mid-turn in a sequential fan-out
The strip unmounting between two sequential subagents is truthful, not a swept
row: A leaves on the provider's own terminal frame, B does not exist yet, and
backgrounded work spanning the same gap holds the roster open — so an empty
roster is never work the strip is hiding.
Also pins the previous-turn rule against the one the subagent roster already
applies on the same frame: a still-working FOREGROUND child becomes
`unverifiable` there and a backgrounded one is left alone, so the strip drops
the first and keeps the second rather than asserting `live` for either.
---------
Co-authored-by: Merge Sim <merge-sim@users.noreply.github.com>
Co-authored-by: Merge Sim <sim@local>
This commit is contained in:
co-authored by
Merge Sim
Merge Sim
parent
4b1b7178ad
commit
f2af92b2fa
@@ -18,6 +18,7 @@ import { launchOrcaApp } from './launch'
|
||||
import { addEnvironmentFromPairingCode } from './environments'
|
||||
import { RuntimeClientError } from './types'
|
||||
import {
|
||||
AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY,
|
||||
AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY,
|
||||
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
|
||||
AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY,
|
||||
@@ -72,6 +73,7 @@ describe('CLI remote WebSocket transport', () => {
|
||||
expect.objectContaining({
|
||||
clientCapabilities: [
|
||||
AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY,
|
||||
AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY,
|
||||
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
|
||||
SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY,
|
||||
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
|
||||
|
||||
@@ -21,6 +21,24 @@ function trackerAt(times: number[]): ClaudeBackgroundTaskTracker {
|
||||
return new ClaudeBackgroundTaskTracker(() => times[Math.min(index++, times.length - 1)])
|
||||
}
|
||||
|
||||
/** The identity and stoppability of each published row, which is what the
|
||||
* foreground cases below are about; `startedAt` and `state` have their own
|
||||
* tests and would only make these brittle. */
|
||||
function rows(tracker: ClaudeBackgroundTaskTracker): { id: string; stoppable?: boolean }[] {
|
||||
return (tracker.state?.tasks ?? []).map((task) => ({
|
||||
id: task.id,
|
||||
...(task.stoppable === undefined ? {} : { stoppable: task.stoppable })
|
||||
}))
|
||||
}
|
||||
|
||||
function started(id: string, backgrounded: boolean): Record<string, unknown> {
|
||||
return system('task_started', {
|
||||
task_id: id,
|
||||
task_type: 'local_agent',
|
||||
is_backgrounded: backgrounded
|
||||
})
|
||||
}
|
||||
|
||||
describe('ClaudeBackgroundTaskTracker', () => {
|
||||
it('classifies SDK task types without inferring them from descriptions', () => {
|
||||
expect(classifyClaudeBackgroundTaskKind('local_agent')).toBe('agent')
|
||||
@@ -581,4 +599,137 @@ describe('ClaudeBackgroundTaskTracker', () => {
|
||||
expect(tracker.clear()).toBe(true)
|
||||
expect(tracker.state).toBeNull()
|
||||
})
|
||||
it('marks a foreground row not stoppable and leaves a backgrounded row alone', () => {
|
||||
// `stopTask` has no foreground target, so the row must not offer a Stop that
|
||||
// resolves to an empty list and silently reports nothing cancelled. A
|
||||
// backgrounded row stays untouched on the wire: absent means stoppable.
|
||||
const tracker = new ClaudeBackgroundTaskTracker()
|
||||
tracker.observe({ type: 'user' }, true)
|
||||
tracker.observe(started('fore-1', false))
|
||||
tracker.observe(started('back-1', true))
|
||||
|
||||
expect(rows(tracker)).toEqual([{ id: 'fore-1', stoppable: false }, { id: 'back-1' }])
|
||||
expect(tracker.stoppableTaskIds).toEqual(['back-1'])
|
||||
})
|
||||
|
||||
it('keeps live foreground work across an aggregate roster that never lists it', () => {
|
||||
// `background_tasks_changed` enumerates BACKGROUNDED work only, so it is
|
||||
// authoritative over that class alone. Treating it as the whole world wiped
|
||||
// every in-flight foreground row and then dropped every later start.
|
||||
const tracker = new ClaudeBackgroundTaskTracker()
|
||||
tracker.observe({ type: 'user' }, true)
|
||||
tracker.observe(started('fore-1', false))
|
||||
tracker.observe(
|
||||
aggregate([{ task_id: 'back-1', task_type: 'local_bash', description: 'bash' }])
|
||||
)
|
||||
|
||||
// A retained row also keeps the place the user is already reading it in.
|
||||
expect(rows(tracker)).toEqual([{ id: 'fore-1', stoppable: false }, { id: 'back-1' }])
|
||||
|
||||
// A foreground start after the roster is new work, not a stale echo.
|
||||
tracker.observe(started('fore-2', false))
|
||||
expect(rows(tracker)).toEqual([
|
||||
{ id: 'fore-1', stoppable: false },
|
||||
{ id: 'back-1' },
|
||||
{ id: 'fore-2', stoppable: false }
|
||||
])
|
||||
|
||||
// Turn end still retires the foreground rows and only those.
|
||||
tracker.observe(result())
|
||||
expect(rows(tracker)).toEqual([{ id: 'back-1' }])
|
||||
})
|
||||
|
||||
it('drops a backgrounded start the roster no longer lists but bounds what it retains', () => {
|
||||
const tracker = new ClaudeBackgroundTaskTracker()
|
||||
tracker.observe({ type: 'user' }, true)
|
||||
for (let index = 0; index < 300; index += 1) {
|
||||
tracker.observe(started(`fore-${index}`, false))
|
||||
}
|
||||
tracker.observe(
|
||||
aggregate([{ task_id: 'back-1', task_type: 'local_bash', description: 'bash' }])
|
||||
)
|
||||
|
||||
const ids = rows(tracker).map((row) => row.id)
|
||||
// 255 retained foreground rows plus the roster's own entry: retention is
|
||||
// real and still counts against the cap.
|
||||
expect(ids).toHaveLength(256)
|
||||
// When the cap bites, the STALEST retained row goes, not the newest.
|
||||
expect(ids).toContain('fore-299')
|
||||
expect(ids).not.toContain('fore-44')
|
||||
expect(ids).toContain('back-1')
|
||||
|
||||
// Aggregate authority over its OWN class is unchanged.
|
||||
tracker.observe(started('stale', true))
|
||||
expect(tracker.stoppableTaskIds).toEqual(['back-1'])
|
||||
})
|
||||
|
||||
it('keeps a finished foreground id dead across a roster that never listed it', () => {
|
||||
// The start guard only convicts BACKGROUNDED starts now, so terminal
|
||||
// evidence is the only thing left defending a finished foreground id — and
|
||||
// the roster carries no evidence about one, so it must not wipe it.
|
||||
const tracker = new ClaudeBackgroundTaskTracker()
|
||||
tracker.observe({ type: 'user' }, true)
|
||||
tracker.observe(started('fore-1', false))
|
||||
tracker.observe(system('task_notification', { task_id: 'fore-1', status: 'completed' }))
|
||||
expect(tracker.state).toBeNull()
|
||||
|
||||
tracker.observe(
|
||||
aggregate([{ task_id: 'back-1', task_type: 'local_bash', description: 'bash' }])
|
||||
)
|
||||
tracker.observe(started('fore-1', false))
|
||||
|
||||
expect(rows(tracker)).toEqual([{ id: 'back-1' }])
|
||||
})
|
||||
|
||||
it('retires a phantom foreground row when the next turn starts', () => {
|
||||
// A foreground `task_started` with no turn open has no `result` coming to
|
||||
// retire it, so it would sit in the strip — with no stop of its own — and
|
||||
// refuse a conversation command. Turn start is the same evidence `result`
|
||||
// is, and settling on it is cleanup only: nothing gates visibility on it.
|
||||
const tracker = new ClaudeBackgroundTaskTracker()
|
||||
tracker.observe(started('phantom', false))
|
||||
expect(rows(tracker)).toEqual([{ id: 'phantom', stoppable: false }])
|
||||
|
||||
tracker.observe({ type: 'user' }, true)
|
||||
expect(tracker.state).toBeNull()
|
||||
})
|
||||
|
||||
it('settles a previous turn the way the subagent roster settles it', () => {
|
||||
// On this same frame the roster's `settleTurn` moves a still-working
|
||||
// FOREGROUND child to `unverifiable` and leaves a backgrounded one alone.
|
||||
// The strip has no `unverifiable` row, so keeping one would assert `live`
|
||||
// for work Orca has already stopped vouching for.
|
||||
const tracker = new ClaudeBackgroundTaskTracker()
|
||||
tracker.observe({ type: 'user' }, true)
|
||||
tracker.observe(started('fore', false))
|
||||
tracker.observe(started('back', true))
|
||||
|
||||
// No `result` for that turn; the next one starting is its only end.
|
||||
tracker.observe({ type: 'user' }, true)
|
||||
expect(rows(tracker)).toEqual([{ id: 'back' }])
|
||||
})
|
||||
|
||||
it('empties only between one task retiring and the next starting', () => {
|
||||
// The strip's mid-turn unmount in a sequential fan-out is TRUTHFUL: A leaves
|
||||
// on the provider's own terminal frame, B does not exist yet, and nothing
|
||||
// sweeps A early. Foreground work is not retained as a settled row either,
|
||||
// so an empty roster means no task is running.
|
||||
const tracker = new ClaudeBackgroundTaskTracker()
|
||||
tracker.observe({ type: 'user' }, true)
|
||||
tracker.observe(started('A', false))
|
||||
expect(rows(tracker)).toEqual([{ id: 'A', stoppable: false }])
|
||||
tracker.observe(system('task_notification', { task_id: 'A', status: 'completed' }))
|
||||
expect(tracker.state).toBeNull()
|
||||
tracker.observe(started('B', false))
|
||||
expect(rows(tracker)).toEqual([{ id: 'B', stoppable: false }])
|
||||
|
||||
// Backgrounded work spanning the same gap holds the roster open, so an
|
||||
// empty one is never work the strip is hiding.
|
||||
const spanned = new ClaudeBackgroundTaskTracker()
|
||||
spanned.observe({ type: 'user' }, true)
|
||||
spanned.observe(started('bg', true))
|
||||
spanned.observe(started('A', false))
|
||||
spanned.observe(system('task_notification', { task_id: 'A', status: 'completed' }))
|
||||
expect(rows(spanned)).toEqual([{ id: 'bg' }])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -66,6 +66,16 @@ export class ClaudeBackgroundTaskTracker {
|
||||
// Background work publishes through a foreground turn: the strip stays
|
||||
// honest mid-fan-out and the client alone decides when the idle-only
|
||||
// monitoring label may speak.
|
||||
//
|
||||
// A new turn is the same evidence `result` is: nothing the previous turn
|
||||
// left foreground is still that turn's work. CLEANUP ONLY — a row's
|
||||
// visibility never consults `startsTurn`, which is Orca's own
|
||||
// dispatch-correlation bookkeeping and false by design for undispatched
|
||||
// turns, so a missed one degrades to the old behaviour and can never hide
|
||||
// live work.
|
||||
if (startsTurn || message.type === 'result') {
|
||||
this.settleForegroundTasks()
|
||||
}
|
||||
if (message.type === 'system') {
|
||||
if (!this.observeSystemFrame(message) && !startsTurn) {
|
||||
return false
|
||||
@@ -84,6 +94,17 @@ export class ClaudeBackgroundTaskTracker {
|
||||
return this.refreshMonitoring()
|
||||
}
|
||||
|
||||
/** `result` is the outcome of every task the provider marked foreground, so
|
||||
* they stop being live work. Backgrounded tasks outlive the turn and are
|
||||
* never swept here — only their own terminal frame retires them. */
|
||||
private settleForegroundTasks(): void {
|
||||
for (const task of this.tasks.values()) {
|
||||
if (!task.backgrounded) {
|
||||
task.liveInTurn = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private settle(
|
||||
id: string,
|
||||
state: AgentSessionBackgroundTaskRunState,
|
||||
@@ -131,12 +152,17 @@ export class ClaudeBackgroundTaskTracker {
|
||||
this.finish(id)
|
||||
return true
|
||||
}
|
||||
if (this.aggregateRosterObserved && !this.tasks.has(id)) {
|
||||
const kind = classifyClaudeBackgroundTaskKind(message.task_type)
|
||||
const backgrounded =
|
||||
message.is_backgrounded === true || kind === 'workflow' || kind === 'monitor'
|
||||
// The aggregate roster enumerates BACKGROUND work only, so it is authoritative
|
||||
// over that class alone. A foreground start it could never have listed is not
|
||||
// stale evidence, and dropping it here silently killed foreground rows.
|
||||
if (this.aggregateRosterObserved && backgrounded && !this.tasks.has(id)) {
|
||||
return false
|
||||
}
|
||||
const kind = classifyClaudeBackgroundTaskKind(message.task_type)
|
||||
this.upsert(id, {
|
||||
backgrounded: message.is_backgrounded === true || kind === 'workflow' || kind === 'monitor',
|
||||
backgrounded,
|
||||
kind,
|
||||
description: taskDescription(message.description),
|
||||
name: taskName(message),
|
||||
@@ -189,9 +215,9 @@ export class ClaudeBackgroundTaskTracker {
|
||||
const prior = new Map(this.tasks)
|
||||
this.aggregateRosterObserved = true
|
||||
this.tasks.clear()
|
||||
this.terminalTaskIds.clear()
|
||||
const roster = new Map<string, TrackedClaudeBackgroundTask>()
|
||||
for (const valueTask of value) {
|
||||
if (this.tasks.size >= MAX_TRACKED_TASKS) {
|
||||
if (roster.size >= MAX_TRACKED_TASKS) {
|
||||
break
|
||||
}
|
||||
const task = record(valueTask)
|
||||
@@ -202,12 +228,16 @@ export class ClaudeBackgroundTaskTracker {
|
||||
if (!id) {
|
||||
continue
|
||||
}
|
||||
// An authoritative live roster supersedes an earlier terminal edge.
|
||||
const retained = this.retention.resume(id)
|
||||
const existing = prior.get(id) ?? retained
|
||||
// An authoritative live roster supersedes an earlier terminal edge — for
|
||||
// the ids it actually lists. Wiping the whole set left a finished
|
||||
// FOREGROUND id undefended, since the start guard now convicts only
|
||||
// backgrounded starts.
|
||||
this.terminalTaskIds.delete(id)
|
||||
const existing = prior.get(id) ?? this.retention.resume(id)
|
||||
const kind = classifyClaudeBackgroundTaskKind(task.task_type)
|
||||
this.tasks.set(id, {
|
||||
roster.set(id, {
|
||||
backgrounded: true,
|
||||
liveInTurn: true,
|
||||
kind: kind !== 'unknown' ? kind : (existing?.kind ?? 'unknown'),
|
||||
description: taskDescription(task.description) ?? existing?.description,
|
||||
name: taskName(task) ?? existing?.name,
|
||||
@@ -216,6 +246,28 @@ export class ClaudeBackgroundTaskTracker {
|
||||
totalTokens: existing?.totalTokens
|
||||
})
|
||||
}
|
||||
// Live foreground work is not in a BACKGROUND roster and is not superseded
|
||||
// by one. Budget counted up front so eviction drops the STALEST retained
|
||||
// rows rather than the newest, and roster entries are never starved.
|
||||
const retainable = [...prior].filter(
|
||||
([id, task]) => !task.backgrounded && task.liveInTurn && !roster.has(id)
|
||||
)
|
||||
let evict = Math.max(0, roster.size + retainable.length - MAX_TRACKED_TASKS)
|
||||
// Retained rows keep their own relative order and stay ahead of the roster,
|
||||
// so a live row the user is reading does not drop below it when a roster
|
||||
// frame lands. Within the roster the PROVIDER's order wins — including for
|
||||
// a task it reports live again, which belongs where the provider lists it
|
||||
// rather than appended after the rows that outlived it.
|
||||
for (const [id, task] of retainable) {
|
||||
if (evict > 0) {
|
||||
evict -= 1
|
||||
continue
|
||||
}
|
||||
this.tasks.set(id, task)
|
||||
}
|
||||
for (const [id, task] of roster) {
|
||||
this.tasks.set(id, task)
|
||||
}
|
||||
for (const [id, task] of prior) {
|
||||
if (task.backgrounded && !this.tasks.has(id)) {
|
||||
this.retention.rememberRemoved(id, task)
|
||||
@@ -223,7 +275,7 @@ export class ClaudeBackgroundTaskTracker {
|
||||
}
|
||||
}
|
||||
|
||||
private upsert(id: string, task: TrackedClaudeBackgroundTask): void {
|
||||
private upsert(id: string, task: Omit<TrackedClaudeBackgroundTask, 'liveInTurn'>): void {
|
||||
if (!this.tasks.has(id) && this.tasks.size >= MAX_TRACKED_TASKS) {
|
||||
let foregroundId: string | undefined
|
||||
for (const [candidateId, candidate] of this.tasks) {
|
||||
@@ -242,6 +294,8 @@ export class ClaudeBackgroundTaskTracker {
|
||||
if (existing) {
|
||||
this.tasks.set(id, {
|
||||
backgrounded: existing.backgrounded || task.backgrounded,
|
||||
// A settled foreground task is not revived by a late edge frame.
|
||||
liveInTurn: existing.liveInTurn,
|
||||
kind: task.kind !== 'unknown' ? task.kind : existing.kind,
|
||||
description: task.description ?? existing.description,
|
||||
name: task.name ?? existing.name,
|
||||
@@ -251,7 +305,7 @@ export class ClaudeBackgroundTaskTracker {
|
||||
})
|
||||
return
|
||||
}
|
||||
this.tasks.set(id, task)
|
||||
this.tasks.set(id, { ...task, liveInTurn: true })
|
||||
}
|
||||
|
||||
private finish(id: string): void {
|
||||
@@ -284,7 +338,7 @@ export class ClaudeBackgroundTaskTracker {
|
||||
private backgroundTaskDetails(): AgentSessionBackgroundTask[] {
|
||||
const details: AgentSessionBackgroundTask[] = []
|
||||
for (const [id, task] of this.tasks) {
|
||||
if (!task.backgrounded) {
|
||||
if (!task.backgrounded && !task.liveInTurn) {
|
||||
continue
|
||||
}
|
||||
details.push(claudeBackgroundTaskDetail(id, task))
|
||||
|
||||
@@ -17,6 +17,10 @@ const MAX_RETAINED_TASKS = 256
|
||||
|
||||
export type TrackedClaudeBackgroundTask = {
|
||||
backgrounded: boolean
|
||||
/** Foreground work is turn-scoped: the provider's `result` (or the next turn
|
||||
* starting) is its outcome, so it stays visible only until that frame.
|
||||
* Backgrounded work ignores this and is retired only by its own edge. */
|
||||
liveInTurn: boolean
|
||||
kind: AgentSessionBackgroundTask['kind']
|
||||
description?: string
|
||||
name?: string
|
||||
@@ -38,7 +42,9 @@ export function claudeBackgroundTaskDetail(
|
||||
...(task.name ? { name: task.name } : {}),
|
||||
state: task.state ?? (task.kind === 'monitor' ? 'monitoring' : 'working'),
|
||||
startedAt: task.startedAt,
|
||||
...(task.totalTokens !== undefined ? { totalTokens: task.totalTokens } : {})
|
||||
...(task.totalTokens !== undefined ? { totalTokens: task.totalTokens } : {}),
|
||||
// Only a backgrounded row has a stop the host can target; absent means yes.
|
||||
...(task.backgrounded ? {} : { stoppable: false })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,7 +109,15 @@ export class ClaudeSettledBackgroundTasks {
|
||||
if (!source || source.startedAt === undefined) {
|
||||
return undefined
|
||||
}
|
||||
return { ...source, backgrounded: true, state: undefined, startedAt: source.startedAt }
|
||||
// Positive live evidence, so it re-enters live in this turn too; a resumed
|
||||
// task is always backgrounded, which is what actually gates its visibility.
|
||||
return {
|
||||
...source,
|
||||
backgrounded: true,
|
||||
liveInTurn: true,
|
||||
state: undefined,
|
||||
startedAt: source.startedAt
|
||||
}
|
||||
}
|
||||
|
||||
get hasSettled(): boolean {
|
||||
|
||||
@@ -10,9 +10,33 @@ const MAX_DESCRIPTION_CHARS = 512
|
||||
|
||||
type Command = { threadId: string; task: AgentSessionBackgroundTask; bytes: number }
|
||||
|
||||
/** Stays within the retained bound, so read-time qualification cannot outgrow admission. */
|
||||
/** The label's reserved share of the description. Reserved, not merely capped:
|
||||
* a label free to spend the whole budget clips away the command it qualifies,
|
||||
* leaving a command row naming an agent and no command — the failure this
|
||||
* qualification exists to remove, in the other direction. `bytes` is counted
|
||||
* before qualification, so this share is also what a published row may exceed
|
||||
* the admitted count by. */
|
||||
const MAX_LABEL_CHARS = 96
|
||||
|
||||
/** Every cut in this file goes through here, clipped the way `boundSubagentField`
|
||||
* clips the same provider string on the agent row: never mid surrogate pair,
|
||||
* since a lone surrogate is lossy through any non-JSON UTF-8 hop. A composed
|
||||
* row is cut a SECOND time, so a clip that is safe only where the label is
|
||||
* bounded is not safe. No ordinal, because a row's identity is its `id`. */
|
||||
function boundText(value: string, max: number): string {
|
||||
if (value.length <= max) {
|
||||
return value
|
||||
}
|
||||
const keep = max - 1
|
||||
const last = value.charCodeAt(keep - 1)
|
||||
const end = last >= 0xd800 && last <= 0xdbff ? keep - 1 : keep
|
||||
return `${value.slice(0, end)}…`
|
||||
}
|
||||
|
||||
/** Resolved on read, and capped at the bound the admitted description already respects. */
|
||||
function qualifiedDescription(label: string, description: string | undefined): string {
|
||||
return (description ? `${label} — ${description}` : label).slice(0, MAX_DESCRIPTION_CHARS)
|
||||
const name = boundText(label, MAX_LABEL_CHARS)
|
||||
return boundText(description ? `${name} — ${description}` : name, MAX_DESCRIPTION_CHARS)
|
||||
}
|
||||
|
||||
export class CodexBackgroundCommandTracker {
|
||||
@@ -122,8 +146,7 @@ export class CodexBackgroundCommandTracker {
|
||||
}
|
||||
const key = JSON.stringify([event.threadId, item.id])
|
||||
const completed = event.method === 'item/completed' || item.status !== 'inProgress'
|
||||
const description = readString(item, 'command')
|
||||
?.slice(0, MAX_DESCRIPTION_CHARS)
|
||||
const description = boundText(readString(item, 'command') ?? '', MAX_DESCRIPTION_CHARS)
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
const value = {
|
||||
|
||||
@@ -22,7 +22,8 @@ function turn(
|
||||
function activity(
|
||||
kind = 'started',
|
||||
parentTurn = PARENT_TURN,
|
||||
child = CHILD
|
||||
child = CHILD,
|
||||
name = 'count_a'
|
||||
): CodexBackgroundTaskEvent {
|
||||
return {
|
||||
method: 'item/started',
|
||||
@@ -35,7 +36,7 @@ function activity(
|
||||
id: `activity-${kind}`,
|
||||
kind,
|
||||
agentThreadId: child,
|
||||
agentPath: '/root/count_a'
|
||||
agentPath: `/root/${name}`
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,7 +50,11 @@ function runningChild(): CodexBackgroundTaskTracker {
|
||||
return tracker
|
||||
}
|
||||
|
||||
function command(threadId = PRIMARY, method = 'item/started'): CodexBackgroundTaskEvent {
|
||||
function command(
|
||||
threadId = PRIMARY,
|
||||
method = 'item/started',
|
||||
commandText = 'sleep 90'
|
||||
): CodexBackgroundTaskEvent {
|
||||
return {
|
||||
method,
|
||||
threadId,
|
||||
@@ -61,7 +66,7 @@ function command(threadId = PRIMARY, method = 'item/started'): CodexBackgroundTa
|
||||
id: 'exec-1',
|
||||
processId: '71831',
|
||||
source: 'unifiedExecStartup',
|
||||
command: 'sleep 90',
|
||||
command: commandText,
|
||||
status: method === 'item/started' ? 'inProgress' : 'completed'
|
||||
}
|
||||
}
|
||||
@@ -103,15 +108,19 @@ describe('CodexBackgroundTaskTracker child execution ownership', () => {
|
||||
expect(tracker.state).toBeNull()
|
||||
})
|
||||
|
||||
it('reports an executing child only after the foreground turn ends', () => {
|
||||
it('reports an executing child while the spawning turn is still open', () => {
|
||||
const tracker = runningChild()
|
||||
expect(tracker.state).toBeNull()
|
||||
expect(tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))).toBe(true)
|
||||
expect(tracker.state).toEqual({
|
||||
const running = {
|
||||
state: 'monitoring',
|
||||
supportsStopAll: false,
|
||||
tasks: [{ id: `codex-agent:${CHILD}`, kind: 'agent', description: 'count_a' }]
|
||||
})
|
||||
}
|
||||
// The strip is a live view: a fan-out is reported while it runs, not once
|
||||
// the parent turn happens to end.
|
||||
expect(tracker.state).toEqual(running)
|
||||
// Turn end reveals children, it never settles them; the child is unchanged.
|
||||
expect(tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))).toBe(false)
|
||||
expect(tracker.state).toEqual(running)
|
||||
})
|
||||
|
||||
it('never settles a child when a primary turn ends', () => {
|
||||
@@ -220,15 +229,16 @@ describe('CodexBackgroundTaskTracker child execution ownership', () => {
|
||||
})
|
||||
|
||||
describe('CodexBackgroundTaskTracker command integration', () => {
|
||||
it('keeps a primary shell visible after the turn until its own completion', () => {
|
||||
it('keeps a primary shell visible from launch until its own completion', () => {
|
||||
const tracker = new CodexBackgroundTaskTracker(PRIMARY)
|
||||
const shell = [{ id: 'codex-command:primary:exec-1', kind: 'command', description: 'sleep 90' }]
|
||||
tracker.observe(turn('turn/started', PRIMARY, PARENT_TURN))
|
||||
tracker.observe(command())
|
||||
expect(tracker.state).toBeNull()
|
||||
// Visible while the turn that launched it is still running.
|
||||
expect(tracker.state?.tasks).toEqual(shell)
|
||||
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
|
||||
expect(tracker.state?.tasks).toEqual([
|
||||
{ id: 'codex-command:primary:exec-1', kind: 'command', description: 'sleep 90' }
|
||||
])
|
||||
expect(tracker.state?.tasks).toEqual(shell)
|
||||
// Only the shell's own completion retires the row.
|
||||
tracker.observe(command(PRIMARY, 'item/completed'))
|
||||
expect(tracker.state).toBeNull()
|
||||
})
|
||||
@@ -261,6 +271,63 @@ describe('CodexBackgroundTaskTracker command integration', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the command visible under a label that would otherwise fill the row', () => {
|
||||
const tracker = new CodexBackgroundTaskTracker(PRIMARY)
|
||||
tracker.observe(turn('turn/started', PRIMARY, PARENT_TURN))
|
||||
tracker.observe(turn('turn/started', CHILD, CHILD_TURN))
|
||||
tracker.observe(activity('started', PARENT_TURN, CHILD, 'L'.repeat(600)))
|
||||
tracker.observe(command(CHILD))
|
||||
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
|
||||
tracker.observe(turn('turn/completed', CHILD, CHILD_TURN))
|
||||
const description = tracker.state?.tasks?.[0]?.description
|
||||
expect(description).toContain('sleep 90')
|
||||
expect(description).toBe(`${'L'.repeat(95)}… — sleep 90`)
|
||||
})
|
||||
|
||||
it('never cuts a label mid surrogate pair', () => {
|
||||
const tracker = new CodexBackgroundTaskTracker(PRIMARY)
|
||||
tracker.observe(turn('turn/started', PRIMARY, PARENT_TURN))
|
||||
tracker.observe(turn('turn/started', CHILD, CHILD_TURN))
|
||||
tracker.observe(activity('started', PARENT_TURN, CHILD, `${'L'.repeat(94)}\u{1F600}bad`))
|
||||
tracker.observe(command(CHILD))
|
||||
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
|
||||
tracker.observe(turn('turn/completed', CHILD, CHILD_TURN))
|
||||
const description = tracker.state?.tasks?.[0]?.description ?? ''
|
||||
expect(description.isWellFormed()).toBe(true)
|
||||
expect(description).toBe(`${'L'.repeat(94)}… — sleep 90`)
|
||||
})
|
||||
|
||||
it('never cuts a qualified command mid surrogate pair', () => {
|
||||
// The label is bounded, then the COMPOSED row is bounded again. That second
|
||||
// cut lands inside the description, so clipping only the label side leaves a
|
||||
// lone surrogate — lossy through any non-JSON UTF-8 hop.
|
||||
const tracker = new CodexBackgroundTaskTracker(PRIMARY)
|
||||
tracker.observe(turn('turn/started', PRIMARY, PARENT_TURN))
|
||||
tracker.observe(turn('turn/started', CHILD, CHILD_TURN))
|
||||
tracker.observe(activity('started', PARENT_TURN, CHILD, 'L'.repeat(96)))
|
||||
// Places the pair exactly where a raw slice of the composed row splits it.
|
||||
tracker.observe(command(CHILD, 'item/started', `${'C'.repeat(412)}\u{1F600}${'D'.repeat(200)}`))
|
||||
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
|
||||
tracker.observe(turn('turn/completed', CHILD, CHILD_TURN))
|
||||
const description = tracker.state?.tasks?.[0]?.description ?? ''
|
||||
expect(description.length).toBeLessThanOrEqual(512)
|
||||
expect(description.startsWith(`${'L'.repeat(96)} — `)).toBe(true)
|
||||
expect(description.isWellFormed()).toBe(true)
|
||||
})
|
||||
|
||||
it('never cuts an unqualified primary command mid surrogate pair', () => {
|
||||
const tracker = new CodexBackgroundTaskTracker(PRIMARY)
|
||||
tracker.observe(turn('turn/started', PRIMARY, PARENT_TURN))
|
||||
// The pair straddles the raw description bound itself.
|
||||
tracker.observe(
|
||||
command(PRIMARY, 'item/started', `${'C'.repeat(511)}\u{1F600}${'D'.repeat(50)}`)
|
||||
)
|
||||
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
|
||||
const description = tracker.state?.tasks?.[0]?.description ?? ''
|
||||
expect(description.length).toBeLessThanOrEqual(512)
|
||||
expect(description.isWellFormed()).toBe(true)
|
||||
})
|
||||
|
||||
it('names a child shell whose label only arrives after the command', () => {
|
||||
const tracker = new CodexBackgroundTaskTracker(PRIMARY)
|
||||
tracker.observe(turn('turn/started', PRIMARY, PARENT_TURN))
|
||||
|
||||
@@ -12,7 +12,6 @@ import { boundSubagentField } from './codex-subagent-group-body'
|
||||
|
||||
/** Projects the same child execution facts the durable roster consumes. */
|
||||
export class CodexBackgroundTaskTracker {
|
||||
private primaryTurnId: string | null = null
|
||||
private publishedFingerprint = '[]'
|
||||
private publishedState: AgentSessionBackgroundTaskState | null = null
|
||||
private readonly commands: CodexBackgroundCommandTracker
|
||||
@@ -44,29 +43,22 @@ export class CodexBackgroundTaskTracker {
|
||||
}
|
||||
if (frame.kind === 'subagent') {
|
||||
this.executions.register(frame.agentThreadId, frame.label, frame.parentTurnId)
|
||||
} else if (frame.threadId === this.primaryThreadId) {
|
||||
if (frame.state === 'working') {
|
||||
this.primaryTurnId = frame.turnId
|
||||
} else if (frame.turnId === this.primaryTurnId) {
|
||||
this.primaryTurnId = null
|
||||
}
|
||||
} else {
|
||||
} else if (frame.threadId !== this.primaryThreadId) {
|
||||
this.executions.observeTurn(frame.threadId, frame.turnId, frame.state)
|
||||
}
|
||||
// A primary-turn frame only prompts a republish: turn end reveals children,
|
||||
// it never settles them. Codex `spawn_agent` children keep reporting well
|
||||
// past their parent turn, so nothing here may sweep the roster.
|
||||
return this.refresh()
|
||||
}
|
||||
|
||||
clear(): boolean {
|
||||
this.executions.clear()
|
||||
this.commands.clear()
|
||||
this.primaryTurnId = null
|
||||
return this.refresh()
|
||||
}
|
||||
|
||||
private tasks(): AgentSessionBackgroundTask[] {
|
||||
if (this.primaryTurnId !== null) {
|
||||
return []
|
||||
}
|
||||
const children = this.executions.workingChildren()
|
||||
const agents: AgentSessionBackgroundTask[] = children.map((child, index) => ({
|
||||
id: `codex-agent:${child.agentThreadId}`,
|
||||
|
||||
@@ -215,29 +215,27 @@ describe('codex background tasks reach the strip', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('publishes the orphaned fan-out once the spawning turn completes', async () => {
|
||||
it('publishes the fan-out while it runs and keeps it past the spawning turn', async () => {
|
||||
const published: { sessionId: string; state: AgentSessionBackgroundTaskState | null }[] = []
|
||||
const { adapter, codex } = await adapterWithSession(published)
|
||||
const running = {
|
||||
state: 'monitoring',
|
||||
supportsStopAll: false,
|
||||
tasks: [{ id: `codex-agent:${CHILD_ID}`, kind: 'agent', description: 'count_a' }]
|
||||
}
|
||||
|
||||
const spawn = subagentNotification('started')
|
||||
codex.handlers().onNotification?.(spawn.method, spawn.params)
|
||||
// The child is still inside the turn, so the strip stays silent.
|
||||
expect(published).toEqual([])
|
||||
expect(adapter.backgroundTaskState('session-1')).toBeNull()
|
||||
// Mid-turn: the child is running, so the strip reports it now.
|
||||
expect(published).toEqual([{ sessionId: 'session-1', state: running }])
|
||||
expect(adapter.backgroundTaskState('session-1')).toEqual(running)
|
||||
|
||||
published.length = 0
|
||||
codex.handlers().onNotification?.(TURN_COMPLETED.method, TURN_COMPLETED.params)
|
||||
|
||||
expect(published).toEqual([
|
||||
{
|
||||
sessionId: 'session-1',
|
||||
state: {
|
||||
state: 'monitoring',
|
||||
supportsStopAll: false,
|
||||
tasks: [{ id: `codex-agent:${CHILD_ID}`, kind: 'agent', description: 'count_a' }]
|
||||
}
|
||||
}
|
||||
])
|
||||
expect(adapter.backgroundTaskState('session-1')).toEqual(published[0].state)
|
||||
// Turn end is not the child's outcome: no republish and no settle.
|
||||
expect(published).toEqual([])
|
||||
expect(adapter.backgroundTaskState('session-1')).toEqual(running)
|
||||
})
|
||||
|
||||
it('clears the strip when the session closes', async () => {
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope'
|
||||
import type { ClientHostedBrowserRowsEvent } from '../../shared/client-hosted-browser-rows'
|
||||
import { TERMINAL_FIT_RESTORE_DEADLINE_MS } from '../../shared/terminal-fit-restore-deadline'
|
||||
import {
|
||||
AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY,
|
||||
AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY,
|
||||
CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
|
||||
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
|
||||
@@ -82,6 +83,7 @@ export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void {
|
||||
connectionId: desktopSenders.connectionIdFor(event.sender),
|
||||
clientCapabilities: [
|
||||
AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY,
|
||||
AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY,
|
||||
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
|
||||
CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
|
||||
]
|
||||
@@ -131,6 +133,7 @@ export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void {
|
||||
connectionId,
|
||||
clientCapabilities: [
|
||||
AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY,
|
||||
AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY,
|
||||
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
|
||||
CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
|
||||
]
|
||||
|
||||
+22
@@ -45,4 +45,26 @@ describe('conversationCommandBlocked background tasks', () => {
|
||||
)
|
||||
expect(blocked).toBe('Wait for background tasks to finish before using this command.')
|
||||
})
|
||||
|
||||
it('still refuses on the open turn, not on the work the strip now shows', () => {
|
||||
// The strip reports subagents while a turn runs. That must not change which
|
||||
// refusal the user sees: an open turn already refuses, and it refuses first,
|
||||
// so a live fan-out never re-labels the reason or blocks anything new.
|
||||
const ctx = contextWith({ state: 'monitoring', supportsTaskStop: true })
|
||||
ctx.journal.snapshot = () =>
|
||||
({
|
||||
items: [
|
||||
{
|
||||
id: 'turn-1',
|
||||
body: {
|
||||
kind: 'status',
|
||||
turnLifecycle: { turnId: 'turn-1', state: 'running' }
|
||||
}
|
||||
}
|
||||
]
|
||||
}) as unknown as ReturnType<typeof ctx.journal.snapshot>
|
||||
expect(conversationCommandBlocked(ctx, RECORD)).toBe(
|
||||
'Wait for the current turn to finish before using this command.'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
+70
-1
@@ -1,6 +1,9 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentSessionBackgroundTaskState } from '../../../../shared/agent-session-wire'
|
||||
import { AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY } from '../../../../shared/protocol-version'
|
||||
import {
|
||||
AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY,
|
||||
AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY
|
||||
} from '../../../../shared/protocol-version'
|
||||
import { remoteRuntimeClientCapabilities } from '../../../../shared/remote-runtime-client-capabilities'
|
||||
import type { AgentSessionSubscribeInput } from '../../../native-chat/agent-session-wire/structured-agent-session-subscribers'
|
||||
import {
|
||||
@@ -24,6 +27,20 @@ const CURRENT_CLIENT = {
|
||||
...STRUCTURED_CLIENT,
|
||||
clientCapabilities: remoteRuntimeClientCapabilities(STRUCTURED_CLIENT.clientCapabilities)
|
||||
}
|
||||
/** Understands a stopless roster, but predates per-row stoppability. */
|
||||
const STOP_ONLY_CLIENT = {
|
||||
...STRUCTURED_CLIENT,
|
||||
clientCapabilities: remoteRuntimeClientCapabilities(STRUCTURED_CLIENT.clientCapabilities).filter(
|
||||
(capability) => capability !== AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY
|
||||
)
|
||||
}
|
||||
const FOREGROUND_ROW = { id: 'fore', kind: 'agent', stoppable: false } as const
|
||||
const BACKGROUNDED_ROW = { id: 'back', kind: 'agent' } as const
|
||||
const MIXED_ROWS: AgentSessionBackgroundTaskState = {
|
||||
state: 'monitoring',
|
||||
supportsTaskStop: true,
|
||||
tasks: [FOREGROUND_ROW, BACKGROUNDED_ROW]
|
||||
}
|
||||
|
||||
describe('background-task stop capability at the RPC boundary', () => {
|
||||
it('advertises reader support on remote requests and subscriptions', () => {
|
||||
@@ -93,6 +110,58 @@ describe('background-task stop capability at the RPC boundary', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('advertises row-stop support separately from stop support', () => {
|
||||
// A client can advertise the stop capability and still predate `stoppable`,
|
||||
// so the two must not be conflated.
|
||||
expect(CURRENT_CLIENT.clientCapabilities).toContain(
|
||||
AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY
|
||||
)
|
||||
expect(STOP_ONLY_CLIENT.clientCapabilities).not.toContain(
|
||||
AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['row-stop reader', () => CURRENT_CLIENT, MIXED_ROWS],
|
||||
[
|
||||
'stop-only reader',
|
||||
() => STOP_ONLY_CLIENT,
|
||||
{ state: 'monitoring', tasks: [BACKGROUNDED_ROW] }
|
||||
],
|
||||
['in-process reader', () => undefined, MIXED_ROWS]
|
||||
] as const)('projects unstoppable rows for a %s', async (_label, client, expected) => {
|
||||
hostCalls.history.mockReturnValue({
|
||||
ok: true,
|
||||
page: { items: [], backgroundTasks: MIXED_ROWS }
|
||||
})
|
||||
expect(
|
||||
await call('agentSession.history', { sessionId: SESSION, direction: 'tail' }, client())
|
||||
).toMatchObject({ ok: true, result: { page: { backgroundTasks: expected } } })
|
||||
})
|
||||
|
||||
it('hands a reader that predates the field no strip when every row is unstoppable', async () => {
|
||||
// Its pre-feature view exactly: the host published no foreground rows at all.
|
||||
const foregroundOnly = {
|
||||
state: 'monitoring' as const,
|
||||
supportsTaskStop: true,
|
||||
tasks: [{ id: 'fore', kind: 'agent' as const, stoppable: false }]
|
||||
}
|
||||
hostCalls.history.mockReturnValue({
|
||||
ok: true,
|
||||
page: { items: [], backgroundTasks: foregroundOnly }
|
||||
})
|
||||
expect(
|
||||
await call(
|
||||
'agentSession.history',
|
||||
{ sessionId: SESSION, direction: 'tail' },
|
||||
STOP_ONLY_CLIENT
|
||||
)
|
||||
).toMatchObject({ ok: true, result: { page: { backgroundTasks: null } } })
|
||||
expect(
|
||||
await call('agentSession.history', { sessionId: SESSION, direction: 'tail' }, CURRENT_CLIENT)
|
||||
).toMatchObject({ ok: true, result: { page: { backgroundTasks: foregroundOnly } } })
|
||||
})
|
||||
|
||||
it('preserves legacy stoppable state for both readers', async () => {
|
||||
const stoppable = { state: 'monitoring', tasks: TASKS.tasks }
|
||||
hostCalls.history.mockReturnValue({ ok: true, page: { items: [], backgroundTasks: stoppable } })
|
||||
|
||||
+29
-3
@@ -3,7 +3,10 @@ import type {
|
||||
AgentSessionHistoryResult,
|
||||
AgentSessionSubscribeEvent
|
||||
} from '../../../../shared/agent-session-wire'
|
||||
import { AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY } from '../../../../shared/protocol-version'
|
||||
import {
|
||||
AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY,
|
||||
AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY
|
||||
} from '../../../../shared/protocol-version'
|
||||
import type { RpcContext } from '../core'
|
||||
|
||||
type BackgroundTaskReader = Pick<RpcContext, 'clientKind' | 'clientCapabilities'>
|
||||
@@ -15,14 +18,37 @@ function supportsReadOnlyTasks(ctx: BackgroundTaskReader): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
function honoursRowStop(ctx: BackgroundTaskReader): boolean {
|
||||
return (
|
||||
ctx.clientKind === undefined ||
|
||||
ctx.clientCapabilities?.includes(AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY) === true
|
||||
)
|
||||
}
|
||||
|
||||
/** A reader that predates `stoppable` draws a per-row stop on every row it is
|
||||
* handed, and the host cannot honour one on a row marked unstoppable — the
|
||||
* dead button the field exists to remove. The host publishing such rows at all
|
||||
* is new, so withholding them hands that reader exactly its pre-feature view;
|
||||
* a state whose every row is withheld becomes no strip, as it was. */
|
||||
function withoutUnstoppableRows(
|
||||
state: AgentSessionBackgroundTaskState
|
||||
): AgentSessionBackgroundTaskState | null {
|
||||
if (!state.tasks?.some((task) => task.stoppable === false)) {
|
||||
return state
|
||||
}
|
||||
const tasks = state.tasks.filter((task) => task.stoppable !== false)
|
||||
return tasks.length > 0 ? { ...state, tasks } : null
|
||||
}
|
||||
|
||||
function projectState(
|
||||
state: AgentSessionBackgroundTaskState | null | undefined,
|
||||
ctx: BackgroundTaskReader
|
||||
): AgentSessionBackgroundTaskState | null | undefined {
|
||||
const rows = !state || honoursRowStop(ctx) ? state : withoutUnstoppableRows(state)
|
||||
// Legacy readers always offer a stop; retain their pre-producer empty strip.
|
||||
return state?.supportsStopAll === false && !state.supportsTaskStop && !supportsReadOnlyTasks(ctx)
|
||||
return rows?.supportsStopAll === false && !rows.supportsTaskStop && !supportsReadOnlyTasks(ctx)
|
||||
? null
|
||||
: state
|
||||
: rows
|
||||
}
|
||||
|
||||
export function projectBackgroundTaskHistory(
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
|
||||
import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { Profiler } from 'react'
|
||||
import { Profiler, useState } from 'react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentSessionBackgroundTask } from '../../../../shared/agent-session-wire'
|
||||
import { NativeChatBackgroundTasksStatus } from './NativeChatBackgroundTasksStatus'
|
||||
@@ -13,6 +13,24 @@ afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
/** The strip's disclosure is parent-owned, because the strip unmounts whenever
|
||||
* live work momentarily drops to nothing; this stands in for that owner. */
|
||||
function DisclosureHost(
|
||||
props: Omit<
|
||||
Parameters<typeof NativeChatBackgroundTasksStatus>[0],
|
||||
'expanded' | 'onExpandedChange'
|
||||
>
|
||||
): React.JSX.Element {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
return (
|
||||
<NativeChatBackgroundTasksStatus
|
||||
{...props}
|
||||
expanded={expanded}
|
||||
onExpandedChange={setExpanded}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const TASKS: AgentSessionBackgroundTask[] = [
|
||||
{ id: 'codex-agent:child-1', kind: 'agent', description: 'count_a' },
|
||||
{ id: 'codex-command:exec-1', kind: 'command', description: 'sleep 90' }
|
||||
@@ -23,7 +41,7 @@ function renderStrip(props: { supportsTaskStop: boolean; supportsStopAll: boolea
|
||||
} {
|
||||
const onStop = vi.fn()
|
||||
render(
|
||||
<NativeChatBackgroundTasksStatus
|
||||
<DisclosureHost
|
||||
isVisible
|
||||
tasks={TASKS}
|
||||
settledTasks={[]}
|
||||
@@ -51,6 +69,33 @@ describe('NativeChatBackgroundTasksStatus stop affordances', () => {
|
||||
expect(screen.getByLabelText('Stop background tasks')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('withholds a row stop the host reported it cannot act on', () => {
|
||||
// Claude publishes in-turn foreground rows with `stoppable: false`: the
|
||||
// session accepts targeted stops, but `stopTask` has no target for this row,
|
||||
// so a Stop here resolves to an empty list and reports nothing cancelled.
|
||||
render(
|
||||
<DisclosureHost
|
||||
isVisible
|
||||
tasks={[
|
||||
{ id: 'fore-1', kind: 'agent', description: 'in-turn subagent', stoppable: false },
|
||||
{ id: 'back-1', kind: 'agent', description: 'backgrounded subagent' }
|
||||
]}
|
||||
settledTasks={[]}
|
||||
indicatorActive
|
||||
supportsTaskStop
|
||||
supportsStopAll
|
||||
stoppingTaskIds={new Set()}
|
||||
stoppingAll={false}
|
||||
onStop={vi.fn()}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { expanded: false }))
|
||||
|
||||
expect(screen.getByText('in-turn subagent')).toBeInTheDocument()
|
||||
expect(screen.queryByLabelText('Stop in-turn subagent')).not.toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Stop backgrounded subagent')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('offers no stop at all when the provider exposes none', () => {
|
||||
// Codex: a Stop button here would be a control that cannot act.
|
||||
renderStrip({ supportsTaskStop: false, supportsStopAll: false })
|
||||
@@ -64,7 +109,7 @@ describe('NativeChatBackgroundTasksStatus stop affordances', () => {
|
||||
describe('background-tasks strip header', () => {
|
||||
function renderHeader(tasks: AgentSessionBackgroundTask[]): HTMLElement {
|
||||
render(
|
||||
<NativeChatBackgroundTasksStatus
|
||||
<DisclosureHost
|
||||
isVisible
|
||||
tasks={tasks}
|
||||
settledTasks={[]}
|
||||
@@ -111,7 +156,7 @@ describe('background-tasks strip header', () => {
|
||||
|
||||
it('dims the monitor amber while a turn owns the voice', () => {
|
||||
render(
|
||||
<NativeChatBackgroundTasksStatus
|
||||
<DisclosureHost
|
||||
isVisible
|
||||
tasks={[{ id: 'm1', kind: 'monitor' }]}
|
||||
settledTasks={[]}
|
||||
@@ -176,7 +221,7 @@ describe('settled rows beside their live siblings', () => {
|
||||
// usage it ended on, and stops claiming a clock or a stop control.
|
||||
it('keeps a settled row with its final usage, no clock and no stop', () => {
|
||||
render(
|
||||
<NativeChatBackgroundTasksStatus
|
||||
<DisclosureHost
|
||||
isVisible
|
||||
tasks={[
|
||||
{
|
||||
@@ -220,7 +265,7 @@ describe('settled rows beside their live siblings', () => {
|
||||
describe('background-task row reasons', () => {
|
||||
function expandedRows(tasks: AgentSessionBackgroundTask[]): HTMLElement[] {
|
||||
render(
|
||||
<NativeChatBackgroundTasksStatus
|
||||
<DisclosureHost
|
||||
isVisible
|
||||
tasks={tasks}
|
||||
settledTasks={[]}
|
||||
@@ -261,6 +306,8 @@ it('stops elapsed renders in a hidden pane and catches up on reveal', () => {
|
||||
const view = (isVisible: boolean) => (
|
||||
<Profiler id="strip" onRender={committed}>
|
||||
<NativeChatBackgroundTasksStatus
|
||||
expanded={false}
|
||||
onExpandedChange={() => {}}
|
||||
isVisible={isVisible}
|
||||
tasks={[{ id: 'shell', kind: 'command', startedAt: 1_000 }]}
|
||||
settledTasks={[]}
|
||||
|
||||
@@ -62,6 +62,15 @@ function kindIconTone(kind: AgentSessionBackgroundTask['kind'], dimmed: boolean)
|
||||
return dimmed ? `${tone}/40` : tone
|
||||
}
|
||||
|
||||
/** Absent means stoppable: a host predating the field published only rows its
|
||||
* stop could act on, so reading absence as "not stoppable" would hide a
|
||||
* working control. Only an explicit `false` withholds the button — Claude
|
||||
* marks its in-turn foreground rows that way, and a Stop on one of those
|
||||
* resolves to an empty target list and silently reports nothing cancelled. */
|
||||
function backgroundTaskStoppable(task: AgentSessionBackgroundTask): boolean {
|
||||
return task.stoppable !== false
|
||||
}
|
||||
|
||||
function BackgroundTaskRow(props: {
|
||||
entry: BackgroundRosterTask
|
||||
now: number
|
||||
@@ -100,7 +109,7 @@ function BackgroundTaskRow(props: {
|
||||
{meta}
|
||||
</span>
|
||||
) : null}
|
||||
{!entry.settled && props.supportsTaskStop ? (
|
||||
{!entry.settled && props.supportsTaskStop && backgroundTaskStoppable(entry.task) ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@@ -135,9 +144,16 @@ export function NativeChatBackgroundTasksStatus(props: {
|
||||
* animated monitoring indicator. A running turn owns the voice. */
|
||||
indicatorActive: boolean
|
||||
isVisible: boolean
|
||||
/** Owned by the parent. The strip is mounted on live work, so it disappears
|
||||
* and comes back whenever the roster momentarily empties between two pieces
|
||||
* of a sequential fan-out — settled rows are flushed at that same instant
|
||||
* and hold nothing open — and local state would collapse the list on every
|
||||
* such gap. */
|
||||
expanded: boolean
|
||||
onExpandedChange: (expanded: boolean) => void
|
||||
onStop: (taskId?: string) => void
|
||||
}): React.JSX.Element {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const expanded = props.expanded
|
||||
const taskListId = useId()
|
||||
const stripRef = useRef<HTMLDivElement>(null)
|
||||
const narrow = useNarrowStrip(stripRef)
|
||||
@@ -170,7 +186,7 @@ export function NativeChatBackgroundTasksStatus(props: {
|
||||
aria-expanded={expanded}
|
||||
aria-controls={taskListId}
|
||||
aria-label={headerText}
|
||||
onClick={() => setExpanded((current) => !current)}
|
||||
onClick={() => props.onExpandedChange(!expanded)}
|
||||
>
|
||||
<span className="min-w-0 truncate">
|
||||
{header.segments.map((segment, index) => {
|
||||
|
||||
@@ -243,7 +243,9 @@ describe('NativeChatStructuredSession', () => {
|
||||
}
|
||||
)
|
||||
|
||||
// Every background-task test mounts the same local Claude session; only the ids differ.
|
||||
// Every background-task test mounts the same local Claude session; only the ids
|
||||
// differ. A fresh element per call also matters for the rerenders below: React
|
||||
// bails out of re-rendering an identical one.
|
||||
const claudeSessionView = (tabId: string, sessionId: string) => (
|
||||
<NativeChatStructuredSession
|
||||
isVisible
|
||||
@@ -254,7 +256,7 @@ describe('NativeChatStructuredSession', () => {
|
||||
/>
|
||||
)
|
||||
|
||||
it('places background monitoring above the usable composer and stops without an active turn', async () => {
|
||||
it('places background monitoring above the usable composer, keeps its list open across a gap in live work, and stops without an active turn', async () => {
|
||||
mocks.monitoringBackgroundTasks = true
|
||||
mocks.supportsBackgroundTaskStop = true
|
||||
mocks.backgroundTasks = [
|
||||
@@ -263,7 +265,9 @@ describe('NativeChatStructuredSession', () => {
|
||||
]
|
||||
mocks.stopBackgroundTask.mockResolvedValue({ cancelled: true })
|
||||
|
||||
render(claudeSessionView('structured-tab-background', 'session-background'))
|
||||
const { rerender } = render(
|
||||
claudeSessionView('structured-tab-background', 'session-background')
|
||||
)
|
||||
|
||||
const disclosure = screen.getByRole('button', { name: '1 agent · 1 shell' })
|
||||
const status = disclosure.closest('[data-native-chat-background-tasks="true"]')
|
||||
@@ -288,6 +292,17 @@ describe('NativeChatStructuredSession', () => {
|
||||
await waitFor(() =>
|
||||
expect(mocks.stopBackgroundTask).toHaveBeenCalledWith('session-background', 'task-command')
|
||||
)
|
||||
|
||||
// The strip is mounted on live work, and settled rows are flushed the instant
|
||||
// the last live one ends, so a sequential fan-out unmounts it between one
|
||||
// subagent finishing and the next starting. The disclosure is not the
|
||||
// strip's to forget in that gap.
|
||||
mocks.monitoringBackgroundTasks = false
|
||||
rerender(claudeSessionView('structured-tab-background', 'session-background'))
|
||||
expect(document.querySelector('[data-native-chat-background-tasks="true"]')).toBeNull()
|
||||
mocks.monitoringBackgroundTasks = true
|
||||
rerender(claudeSessionView('structured-tab-background', 'session-background'))
|
||||
expect(screen.getByRole('list', { name: 'Agents' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps the strip mounted through a running turn, with the turn owning the voice', () => {
|
||||
@@ -373,6 +388,8 @@ describe('NativeChatStructuredSession', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Stop Shared task' }))
|
||||
|
||||
rerender(claudeSessionView('structured-tab-stale-background', 'session-current'))
|
||||
// The disclosure is keyed by session, so a new session opens collapsed.
|
||||
fireEvent.click(screen.getByRole('button', { name: '1 shell command — working' }))
|
||||
const currentStop = screen.getByRole('button', { name: 'Stop Shared task' })
|
||||
expect((currentStop as HTMLButtonElement).disabled).toBe(false)
|
||||
fireEvent.click(currentStop)
|
||||
|
||||
@@ -30,6 +30,8 @@ type StoppingBackgroundTasks = {
|
||||
|
||||
const NO_STOPPING_TASKS: ReadonlySet<string> = new Set()
|
||||
|
||||
type ExpandedBackgroundTasks = { sessionId: string; expanded: boolean }
|
||||
|
||||
function encodeQuestionAnswer(questionId: string, answer: string): string {
|
||||
return `${encodeURIComponent(questionId)}:${encodeURIComponent(answer)}`
|
||||
}
|
||||
@@ -41,6 +43,10 @@ export function NativeChatStructuredSession(
|
||||
const [composerError, setComposerError] = useState<string | null>(null)
|
||||
const [stoppingBackgroundTasks, setStoppingBackgroundTasks] =
|
||||
useState<StoppingBackgroundTasks | null>(null)
|
||||
// Held here, not in the strip: the strip unmounts whenever live work briefly
|
||||
// drops to nothing, and its own state would collapse the list each time.
|
||||
const [expandedBackgroundTasks, setExpandedBackgroundTasks] =
|
||||
useState<ExpandedBackgroundTasks | null>(null)
|
||||
const [optionPickerRequest, setOptionPickerRequest] = useState<{
|
||||
id: string
|
||||
sequence: number
|
||||
@@ -314,6 +320,13 @@ export function NativeChatStructuredSession(
|
||||
supportsStopAll={controller.backgroundTasks.supportsStopAll}
|
||||
stoppingTaskIds={activeStoppingBackgroundTasks?.taskIds ?? NO_STOPPING_TASKS}
|
||||
stoppingAll={activeStoppingBackgroundTasks?.all ?? false}
|
||||
expanded={
|
||||
expandedBackgroundTasks?.sessionId === props.sessionId &&
|
||||
expandedBackgroundTasks.expanded
|
||||
}
|
||||
onExpandedChange={(expanded) =>
|
||||
setExpandedBackgroundTasks({ sessionId: props.sessionId, expanded })
|
||||
}
|
||||
onStop={(taskId) => {
|
||||
const targetSessionId = props.sessionId
|
||||
setStoppingBackgroundTasks((current) => {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
// The background-tasks strip's view of one session's wire state.
|
||||
//
|
||||
// The strip stands for work that OUTLIVES a turn. It stays mounted through a
|
||||
// running turn — a fan-out's children keep reporting long after the parent
|
||||
// settles — but only an idle session lets it animate or speak for itself.
|
||||
// The strip reports work that is IN FLIGHT, whether or not it outlived a turn:
|
||||
// a fan-out's children keep reporting long after the parent settles, and a
|
||||
// foreground fan-out is running work while the turn is still open. It stays
|
||||
// mounted through a running turn — turn state is not a filter on the rows,
|
||||
// because the producers publish only tasks they still have live evidence for.
|
||||
// Only an idle session lets it animate or speak for itself.
|
||||
|
||||
import type {
|
||||
AgentSessionBackgroundTask,
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from '../../../shared/e2ee-crypto'
|
||||
import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope'
|
||||
import {
|
||||
AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY,
|
||||
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
|
||||
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
|
||||
SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY,
|
||||
@@ -86,6 +87,7 @@ describe('WebRuntimeClient', () => {
|
||||
type: 'e2ee_auth',
|
||||
deviceToken: 'token',
|
||||
clientCapabilities: [
|
||||
AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY,
|
||||
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
|
||||
SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY,
|
||||
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope'
|
||||
import { isKeepaliveFrame } from '../../../shared/runtime-rpc-envelope'
|
||||
import {
|
||||
AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY,
|
||||
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
|
||||
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
|
||||
SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY,
|
||||
@@ -58,6 +59,7 @@ export async function routeWebRuntimeConnectionFrame(
|
||||
type: 'e2ee_auth',
|
||||
deviceToken: context.pairingToken,
|
||||
clientCapabilities: [
|
||||
AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY,
|
||||
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
|
||||
SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY,
|
||||
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// The background-tasks half of the agent-session wire: the per-task row, the
|
||||
// roster state that carries it, and the field equality both the client reducer
|
||||
// and the host status feed compare with. Split out of `agent-session-wire.ts`
|
||||
// when that file reached its line budget; the definitions below are unchanged.
|
||||
|
||||
/** Per-task run state, reusing the agent-state vocabulary the dashboard already
|
||||
* renders. Optional on the wire: an old host sends none and clients fall back
|
||||
* to kind-derived defaults. */
|
||||
export type AgentSessionBackgroundTaskRunState =
|
||||
| 'working'
|
||||
| 'monitoring'
|
||||
| 'waiting'
|
||||
| 'blocked'
|
||||
| 'done'
|
||||
| 'idle'
|
||||
| 'unverifiable'
|
||||
|
||||
export type AgentSessionBackgroundTask = {
|
||||
id: string
|
||||
kind: 'agent' | 'workflow' | 'command' | 'monitor' | 'unknown'
|
||||
description?: string
|
||||
/** Provider-reported identity (e.g. a subagent type). `description` stays the
|
||||
* display name; this is the fallback when the description is absent. */
|
||||
name?: string
|
||||
state?: AgentSessionBackgroundTaskRunState
|
||||
/** Host epoch ms when the task was first observed, so clients render elapsed. */
|
||||
startedAt?: number
|
||||
/** Cumulative provider-reported token usage, where the provider supplies it. */
|
||||
totalTokens?: number
|
||||
/** Whether this row's own stop can act on it. Absent means yes: every host
|
||||
* that predates this field published only backgrounded, stoppable rows, and
|
||||
* a client that read absence as "not stoppable" would hide a working control
|
||||
* on those hosts. A row the host cannot target sends `false`. */
|
||||
stoppable?: boolean
|
||||
}
|
||||
|
||||
export type AgentSessionBackgroundTaskState = {
|
||||
state: 'monitoring'
|
||||
/** Optional so mixed-version clients can consume state-only hosts. */
|
||||
tasks?: AgentSessionBackgroundTask[]
|
||||
/** Terminal-state siblings of a still-live roster, kept apart from `tasks`
|
||||
* so old clients keep rendering exactly the live set they render today. */
|
||||
settledTasks?: AgentSessionBackgroundTask[]
|
||||
/** Optional so clients only send targeted stops to hosts that accept them. */
|
||||
supportsTaskStop?: boolean
|
||||
/** Whether an untargeted "stop everything" is available at all. Absent means
|
||||
* yes: every host that predates this field accepted one, and a client that
|
||||
* read absence as "no stop" would hide a working control on those hosts.
|
||||
* A host whose provider exposes no honest stop sends `false`. */
|
||||
supportsStopAll?: boolean
|
||||
}
|
||||
|
||||
function backgroundTaskFieldsEqual(
|
||||
left: AgentSessionBackgroundTask,
|
||||
right: AgentSessionBackgroundTask
|
||||
): boolean {
|
||||
return (
|
||||
left.id === right.id &&
|
||||
left.kind === right.kind &&
|
||||
left.description === right.description &&
|
||||
left.name === right.name &&
|
||||
left.state === right.state &&
|
||||
left.startedAt === right.startedAt &&
|
||||
left.totalTokens === right.totalTokens &&
|
||||
left.stoppable === right.stoppable
|
||||
)
|
||||
}
|
||||
|
||||
/** Field equality for task lists, shared by the client reducer and the host
|
||||
* status feed so a publish whose only change is one task's state is never
|
||||
* judged equal and dropped. */
|
||||
export function agentSessionBackgroundTasksEqual(
|
||||
left: AgentSessionBackgroundTask[] | undefined,
|
||||
right: AgentSessionBackgroundTask[] | undefined
|
||||
): boolean {
|
||||
if (left === right) {
|
||||
return true
|
||||
}
|
||||
if (!left || !right || left.length !== right.length) {
|
||||
return false
|
||||
}
|
||||
return left.every((task, index) => backgroundTaskFieldsEqual(task, right[index]))
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
import type {
|
||||
AgentSessionBackgroundTask,
|
||||
AgentSessionBackgroundTaskState
|
||||
} from './agent-session-background-task-wire'
|
||||
import type { AgentSessionRewindReason, AgentSessionRewindSupport } from './agent-session-rewind'
|
||||
import type { AgentSessionConversationCommand } from './agent-session-conversation-command'
|
||||
// ─── Structured agent-session wire contract ─────────────────────────────────
|
||||
@@ -56,78 +60,12 @@ export type AgentSessionHandoffRequest = {
|
||||
|
||||
export type AgentSessionHandoffResult = { status: AgentSessionHandoffStatus }
|
||||
|
||||
/** Per-task run state, reusing the agent-state vocabulary the dashboard already
|
||||
* renders. Optional on the wire: an old host sends none and clients fall back
|
||||
* to kind-derived defaults. */
|
||||
export type AgentSessionBackgroundTaskRunState =
|
||||
| 'working'
|
||||
| 'monitoring'
|
||||
| 'waiting'
|
||||
| 'blocked'
|
||||
| 'done'
|
||||
| 'idle'
|
||||
| 'unverifiable'
|
||||
|
||||
export type AgentSessionBackgroundTask = {
|
||||
id: string
|
||||
kind: 'agent' | 'workflow' | 'command' | 'monitor' | 'unknown'
|
||||
description?: string
|
||||
/** Provider-reported identity (e.g. a subagent type). `description` stays the
|
||||
* display name; this is the fallback when the description is absent. */
|
||||
name?: string
|
||||
state?: AgentSessionBackgroundTaskRunState
|
||||
/** Host epoch ms when the task was first observed, so clients render elapsed. */
|
||||
startedAt?: number
|
||||
/** Cumulative provider-reported token usage, where the provider supplies it. */
|
||||
totalTokens?: number
|
||||
}
|
||||
|
||||
export type AgentSessionBackgroundTaskState = {
|
||||
state: 'monitoring'
|
||||
/** Optional so mixed-version clients can consume state-only hosts. */
|
||||
tasks?: AgentSessionBackgroundTask[]
|
||||
/** Terminal-state siblings of a still-live roster, kept apart from `tasks`
|
||||
* so old clients keep rendering exactly the live set they render today. */
|
||||
settledTasks?: AgentSessionBackgroundTask[]
|
||||
/** Optional so clients only send targeted stops to hosts that accept them. */
|
||||
supportsTaskStop?: boolean
|
||||
/** Whether an untargeted "stop everything" is available at all. Absent means
|
||||
* yes: every host that predates this field accepted one, and a client that
|
||||
* read absence as "no stop" would hide a working control on those hosts.
|
||||
* A host whose provider exposes no honest stop sends `false`. */
|
||||
supportsStopAll?: boolean
|
||||
}
|
||||
|
||||
function backgroundTaskFieldsEqual(
|
||||
left: AgentSessionBackgroundTask,
|
||||
right: AgentSessionBackgroundTask
|
||||
): boolean {
|
||||
return (
|
||||
left.id === right.id &&
|
||||
left.kind === right.kind &&
|
||||
left.description === right.description &&
|
||||
left.name === right.name &&
|
||||
left.state === right.state &&
|
||||
left.startedAt === right.startedAt &&
|
||||
left.totalTokens === right.totalTokens
|
||||
)
|
||||
}
|
||||
|
||||
/** Field equality for task lists, shared by the client reducer and the host
|
||||
* status feed so a publish whose only change is one task's state is never
|
||||
* judged equal and dropped. */
|
||||
export function agentSessionBackgroundTasksEqual(
|
||||
left: AgentSessionBackgroundTask[] | undefined,
|
||||
right: AgentSessionBackgroundTask[] | undefined
|
||||
): boolean {
|
||||
if (left === right) {
|
||||
return true
|
||||
}
|
||||
if (!left || !right || left.length !== right.length) {
|
||||
return false
|
||||
}
|
||||
return left.every((task, index) => backgroundTaskFieldsEqual(task, right[index]))
|
||||
}
|
||||
export type {
|
||||
AgentSessionBackgroundTask,
|
||||
AgentSessionBackgroundTaskRunState,
|
||||
AgentSessionBackgroundTaskState
|
||||
} from './agent-session-background-task-wire'
|
||||
export { agentSessionBackgroundTasksEqual } from './agent-session-background-task-wire'
|
||||
|
||||
export type AgentSessionTurnActivity = {
|
||||
turnId: string
|
||||
|
||||
@@ -166,6 +166,13 @@ export const AGENT_SESSION_REWIND_RUNTIME_CAPABILITY = 'agent-session.rewind.v1'
|
||||
// Readers must understand a monitoring roster with no available stop control.
|
||||
export const AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY =
|
||||
'agent-session.background-task-stop.v1' as const
|
||||
// Why: the host now publishes rows for work that is live inside a turn, and such
|
||||
// a row carries `stoppable: false` because no targeted stop can reach it. A
|
||||
// reader that predates the field draws a per-row Stop on every row it is given,
|
||||
// so it must be told apart from one that honours the field — and NOT by the
|
||||
// stop capability above, which a client can advertise while predating this.
|
||||
export const AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY =
|
||||
'agent-session.background-task-row-stop.v1' as const
|
||||
// Why: adding kimi to RESUMABLE_TUI_AGENTS grows terminal.ensureAgentSession's enum, and an
|
||||
// older host answers the unknown member with invalid_argument — a code the launch fallback does
|
||||
// not retry on — so clients must probe before taking the host-authority path.
|
||||
@@ -273,6 +280,7 @@ export const RUNTIME_CAPABILITIES = [
|
||||
AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY,
|
||||
AGENT_SESSION_REWIND_RUNTIME_CAPABILITY,
|
||||
AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY,
|
||||
AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY,
|
||||
AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY,
|
||||
FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY,
|
||||
GITHUB_MARK_PR_READY_RUNTIME_CAPABILITY,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY,
|
||||
AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY,
|
||||
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
|
||||
AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY,
|
||||
@@ -18,6 +19,7 @@ export function remoteRuntimeClientCapabilities(
|
||||
return Array.from(
|
||||
new Set([
|
||||
AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY,
|
||||
AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY,
|
||||
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
|
||||
SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY,
|
||||
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
|
||||
|
||||
@@ -87,6 +87,46 @@ describe('structured agent session reducer', () => {
|
||||
expect(updated.items).toBe(initial.items)
|
||||
})
|
||||
|
||||
it("republishes when only a row's stoppability changes", () => {
|
||||
// A row losing its stop is the whole difference between an honest control
|
||||
// and a dead one, so it must not be dropped as an equal state.
|
||||
const backgroundTasks = {
|
||||
state: 'monitoring' as const,
|
||||
supportsTaskStop: true,
|
||||
tasks: [{ id: 'task-1', kind: 'agent' as const }]
|
||||
}
|
||||
const initial = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, {
|
||||
type: 'event',
|
||||
event: {
|
||||
type: 'snapshot',
|
||||
sessionId: 'session-a',
|
||||
fence: 1,
|
||||
page: { ...hydrationPage([]), backgroundTasks }
|
||||
}
|
||||
})
|
||||
const updated = reduceStructuredAgentSession(initial, {
|
||||
type: 'event',
|
||||
event: {
|
||||
type: 'batch',
|
||||
sessionId: 'session-a',
|
||||
batch: {
|
||||
cursor: { epoch: 'epoch-a', sequence: 0 },
|
||||
items: [],
|
||||
removedItemIds: [],
|
||||
submissions: []
|
||||
},
|
||||
backgroundTasks: {
|
||||
...backgroundTasks,
|
||||
tasks: [{ id: 'task-1', kind: 'agent' as const, stoppable: false }]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(updated.backgroundTasks?.tasks).toEqual([
|
||||
{ id: 'task-1', kind: 'agent', stoppable: false }
|
||||
])
|
||||
})
|
||||
|
||||
it('uses the bounded hydration page pagination boundary', () => {
|
||||
const restored = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, {
|
||||
type: 'event',
|
||||
|
||||
Reference in New Issue
Block a user