mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 00:02:29 +00:00
* 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>
84 lines
3.3 KiB
TypeScript
84 lines
3.3 KiB
TypeScript
// 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]))
|
|
}
|