From d8a3c72204e52e0ca5df4617df996c5d7569280a Mon Sep 17 00:00:00 2001 From: HomeLab Agent Date: Mon, 10 Aug 2026 08:17:23 -0400 Subject: [PATCH] feat(server,web): show background work that is still running, pinned above the composer (#604) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A sub-agent already got a live row above the composer while it worked. Nothing else did. A background `Bash`, a `Monitor` or a workflow could run for minutes behind a card scrolled far up the transcript, and the only hint was a static `running` chip that meant "no completion notification was found in the transcript" — not "we checked". A killed task kept that chip forever. And the chat claimed to be idle while it happened. `chat:active` reports one hub turn, and a background task outlives the turn that launched it, so the moment the reply landed every consumer of that signal — the sidebar dot, the Home in-flight badge, the running-only filter, the composer — was told the session had stopped while the work continued. That is the defect this closes. The signal already existed and we were dropping it. The SDK publishes `background_tasks_changed` (the complete live set on every membership change) plus `task_started` / `task_progress` / `task_notification` / `task_updated` for per-task detail, and `BackgroundTaskSummary` already enumerates shell | subagent | monitor | workflow. herdctl taps that stream to decide reaping; Paddock's consumption was four comments and no code. - `BackgroundRegistry` folds those messages into a per-session live set, fed from all five turn paths. The level signal is the sole authority on membership and edges may only enrich, so a missed edge cannot wedge a stale row — the #528 failure mode. Terminal edges evict ahead of the confirming level. - `chat:background` broadcasts the set and is replayed on connect, so a remount is populated on the first paint rather than after a poll. - `chat:active.running` now accounts for background work. - `RunningWork` replaces `RunningSubagents`, rendering shells, monitors and workflows alongside sub-agents. A sub-agent the transcript path already shows is not duplicated; `skip_transcript` work is hidden. `chat:active` also gains `turnRunning`. Making `running` true whenever background work exists is what the issue asks for, but QA showed the cost: after the turn finished, the working indicator still animated and the composer stayed locked to "Queue a message to send next…". An hour-long Monitor would make the chat unusable while claiming the model was thinking. Status readouts now read `running`; the composer lock and working indicator read `turnRunning`. Optional on the wire, so an older client falls back and behaves as before. Session drive mode only — the CLI runtime reads the transcript file, which these stream-only control messages never reach. A new `[[BGTASK]]` fake-claude directive drives the whole path with no API spend. Co-Authored-By: Claude --- .changeset/background-work-bar.md | 46 +++ packages/server/src/background-live.ts | 284 ++++++++++++++++++ packages/server/src/ws-protocol.ts | 63 ++++ packages/server/src/ws-turn.ts | 24 +- packages/server/src/ws.ts | 82 ++++- .../test/integration/ws-background.test.ts | 136 +++++++++ .../server/test/unit/background-live.test.ts | 205 +++++++++++++ .../unit/background-turn-cancellable.test.ts | 5 +- packages/web/src/components/ChatPane.test.tsx | 8 +- packages/web/src/components/ChatPane.tsx | 26 +- .../components/chat/RunningSubagents.test.tsx | 80 ----- .../src/components/chat/RunningSubagents.tsx | 82 ----- .../src/components/chat/RunningWork.test.tsx | 222 ++++++++++++++ .../web/src/components/chat/RunningWork.tsx | 203 +++++++++++++ .../web/src/components/chat/useChatSocket.ts | 9 +- packages/web/src/lib/types.ts | 53 ++++ packages/web/src/lib/ws.test.ts | 3 +- packages/web/src/lib/ws.ts | 65 +++- test/bin/claude | 80 +++++ test/e2e/journey-subagents.spec.ts | 4 +- 20 files changed, 1490 insertions(+), 190 deletions(-) create mode 100644 .changeset/background-work-bar.md create mode 100644 packages/server/src/background-live.ts create mode 100644 packages/server/test/integration/ws-background.test.ts create mode 100644 packages/server/test/unit/background-live.test.ts delete mode 100644 packages/web/src/components/chat/RunningSubagents.test.tsx delete mode 100644 packages/web/src/components/chat/RunningSubagents.tsx create mode 100644 packages/web/src/components/chat/RunningWork.test.tsx create mode 100644 packages/web/src/components/chat/RunningWork.tsx diff --git a/.changeset/background-work-bar.md b/.changeset/background-work-bar.md new file mode 100644 index 00000000..88b252d4 --- /dev/null +++ b/.changeset/background-work-bar.md @@ -0,0 +1,46 @@ +--- +"@paddock/server": minor +"@paddock/web": minor +--- + +Show background work that is still running, pinned above the composer (#604) + +A sub-agent already got a live row above the composer while it worked. Nothing +else did. A background `Bash`, a `Monitor`, or a workflow could run for minutes +behind a card scrolled far up the transcript, and the only hint was a static +`running` chip that meant "no completion notification was found in the +transcript" — not "we checked". A killed task kept that chip forever. + +Worse, the chat itself claimed to be idle. `chat:active` reports one hub turn, +and a background task outlives the turn that launched it, so the moment the +reply landed every consumer of that signal — the sidebar streaming dot, the Home +in-flight badge, the running-only filter, the composer's own streaming state — +was told the session had stopped while minutes of work continued. + +Both come from the same missing piece, and the Claude Agent SDK already +publishes it. `background_tasks_changed` carries the complete live task set on +every membership change, with `task_started` / `task_progress` / +`task_notification` adding per-task detail. herdctl already taps that stream to +decide session reaping; Paddock dropped it on the floor. + +- **New `BackgroundRegistry`** (`background-live.ts`) folds those signals into a + per-session live set, fed from all five turn paths. The level signal is the + sole authority on membership and edges may only enrich, so a missed edge cannot + wedge a stale row — the failure mode #528 was. +- **New `chat:background` frame**, broadcast on every change and replayed to a + newly-connected socket, so a remount or reload is populated on the first paint + instead of after a poll. +- **`chat:active.running` now accounts for background work**, which is the #604 + fix proper. Every consumer of the signal reads the truth. +- **`RunningSubagents` becomes `RunningWork`**, rendering shells, monitors and + workflows alongside sub-agents, with elapsed time and live step counts. A task + the transcript path already shows is not duplicated; ambient work the SDK marks + `skip_transcript` is hidden. + +The signal is per-process and emits nothing at startup, so after a server +restart the bar is empty until the next change. That is correct rather than a +gap: Paddock stops the fleet with `waitForJobs: false`, so those tasks are dead — +unlike the old chip, which went on claiming a killed task was alive. + +Session drive mode only; the CLI runtime reads the transcript file, which these +stream-only control messages never reach. diff --git a/packages/server/src/background-live.ts b/packages/server/src/background-live.ts new file mode 100644 index 00000000..d3b23e25 --- /dev/null +++ b/packages/server/src/background-live.ts @@ -0,0 +1,284 @@ +/** + * Live background-work registry (#604). + * + * Paddock's only notion of "this chat is busy" is `SessionHub`'s single `Turn` + * per session. That is wrong for background work in two directions: a turn ends + * while its background tasks keep running (this issue), and a background task + * outlives the `Turn` record entirely, so the task set cannot live on it. + * + * The Claude Agent SDK already publishes the set we need. This module is the + * server-side landing point for it: + * + * - `system/background_tasks_changed` — a LEVEL signal carrying every live + * task after a membership change. Consumed with REPLACE semantics, per the + * SDK's own guidance: "consumers that only need 'is background work + * running' should replace their set with each payload rather than pairing + * edges, so a missed bookend cannot wedge a stale running indicator." + * #528 was exactly such a wedge, so this is load-bearing, not stylistic. + * + * - `system/task_started` / `system/task_progress` — EDGE signals carrying the + * detail the level payload omits (`tool_use_id`, `subagent_type`, + * `workflow_name`, `last_tool_name`, step counts). Folded onto the level set + * as enrichment only: an edge NEVER adds a task the level has not shown us, + * because the edges are not guaranteed to pair and a leaked edge would + * resurrect a finished task. + * + * - `system/task_notification` / `system/task_updated` — terminal edges. These + * evict eagerly rather than waiting for the next level, so a completed task + * leaves the bar promptly; a level signal that still lists it will simply + * put it back. + * + * Per-process caveat, straight from the SDK: the level is "per-process: nothing + * is emitted at startup, so consumers must reset to the empty set whenever the + * session's CLI process (re)starts". {@link BackgroundRegistry.clear} is that + * reset. Nothing here tries to reconstruct a set from disk — an empty bar after + * a restart is correct, because Paddock stops the fleet with + * `waitForJobs: false` and the tasks are genuinely dead. + */ + +/** Terminal task statuses — a task in one of these is no longer live. */ +const TERMINAL_STATUSES = new Set(["completed", "failed", "stopped", "killed"]); + +/** + * One live background task, as broadcast to clients. + * + * `type` is the SDK's friendly label (`shell` | `subagent` | `monitor` | + * `workflow`), falling back to the raw discriminant for kinds we do not know + * about yet — deliberately a plain `string` rather than a union so a new SDK + * task type renders as an unlabelled row instead of being dropped. + */ +export interface LiveBackgroundTask { + id: string; + type: string; + description: string; + /** Epoch-ms this task was first observed. Stamped here; the SDK sends no start time. */ + startedAt: number; + /** Links the task to its launching tool card, when an edge has told us. */ + toolUseId?: string; + /** `subagent` only. */ + agentType?: string; + /** `shell` only. */ + command?: string; + /** `workflow` only. */ + workflowName?: string; + /** `monitor` / MCP-task only. */ + server?: string; + tool?: string; + /** Latest tool the task ran, from `task_progress`. */ + lastToolName?: string; + /** Steps the task has taken, from `task_progress.usage.tool_uses`. */ + toolUses?: number; + /** + * Ambient/housekeeping work. The SDK asks consumers to hide these from the + * inline transcript while still allowing a tasks panel to show them, so it is + * carried on the wire and filtered at the point of display. + */ + skipTranscript?: boolean; +} + +/** A session's task set plus the project it belongs to (needed to route frames). */ +interface SessionEntry { + projectSlug: string; + tasks: Map; +} + +/** Shape of the SDK's `background_tasks_changed` payload entries. */ +interface RawLevelTask { + task_id?: unknown; + task_type?: unknown; + description?: unknown; +} + +const str = (v: unknown): string | undefined => (typeof v === "string" && v ? v : undefined); +const num = (v: unknown): number | undefined => (typeof v === "number" && Number.isFinite(v) ? v : undefined); + +/** + * Is this an SDK system message we care about? Structural rather than a typed + * narrow, because the SDK's `SDKMessage` union gains subtypes faster than we + * pin its version, and an unknown subtype must be ignored, not crash the turn. + */ +function systemSubtype(m: unknown): string | null { + if (!m || typeof m !== "object") return null; + const o = m as Record; + if (o.type !== "system") return null; + return typeof o.subtype === "string" ? o.subtype : null; +} + +export type BackgroundChangeListener = ( + projectSlug: string, + sessionId: string, + tasks: LiveBackgroundTask[], +) => void; + +/** + * The registry. One instance per server, owned by the WS layer so it can + * broadcast, and fed by the turn engine from both the foreground and background + * message lanes. + */ +export class BackgroundRegistry { + private bySession = new Map(); + /** Fired whenever a session's live set changes. Set by the WS layer. */ + onChange: BackgroundChangeListener | null = null; + /** Injectable clock, so tests can assert `startedAt` without sleeping. */ + constructor(private now: () => number = Date.now) {} + + /** Live tasks for a session, newest last. Empty array when idle or unknown. */ + list(sessionId: string): LiveBackgroundTask[] { + const e = this.bySession.get(sessionId); + return e ? [...e.tasks.values()] : []; + } + + /** Every session with at least one live task — the connect-time snapshot. */ + snapshot(): { projectSlug: string; sessionId: string; tasks: LiveBackgroundTask[] }[] { + const out: { projectSlug: string; sessionId: string; tasks: LiveBackgroundTask[] }[] = []; + for (const [sessionId, e] of this.bySession) { + if (e.tasks.size > 0) + out.push({ projectSlug: e.projectSlug, sessionId, tasks: [...e.tasks.values()] }); + } + return out; + } + + /** Does this session have live background work? The #604 predicate. */ + isBusy(sessionId: string): boolean { + const e = this.bySession.get(sessionId); + return !!e && e.tasks.size > 0; + } + + /** + * Forget a session's set. Called when the session's process restarts or its + * stream ends — see the per-process caveat in the module header. + */ + clear(sessionId: string): void { + const e = this.bySession.get(sessionId); + if (!e || e.tasks.size === 0) return; + e.tasks.clear(); + this.emit(sessionId, e); + } + + /** + * Feed one raw SDK message. Returns true when the live set changed, so the + * caller can avoid re-broadcasting on the overwhelming majority of messages + * (assistant text, tool calls) that are not task lifecycle at all. + */ + observe(projectSlug: string, m: unknown): boolean { + const subtype = systemSubtype(m); + if (!subtype) return false; + const o = m as Record; + const sessionId = str(o.session_id); + if (!sessionId) return false; + + switch (subtype) { + case "background_tasks_changed": + return this.replace(projectSlug, sessionId, Array.isArray(o.tasks) ? o.tasks : []); + case "task_started": + return this.enrich(sessionId, str(o.task_id), { + toolUseId: str(o.tool_use_id), + agentType: str(o.subagent_type), + workflowName: str(o.workflow_name), + description: str(o.description), + skipTranscript: o.skip_transcript === true ? true : undefined, + }); + case "task_progress": { + const usage = (o.usage ?? {}) as Record; + return this.enrich(sessionId, str(o.task_id), { + toolUseId: str(o.tool_use_id), + agentType: str(o.subagent_type), + lastToolName: str(o.last_tool_name), + toolUses: num(usage.tool_uses), + }); + } + case "task_notification": + // Always terminal — the SDK only emits this on completed/failed/stopped. + return this.evict(sessionId, str(o.task_id)); + case "task_updated": { + const patch = (o.patch ?? {}) as Record; + const status = str(patch.status); + if (status && TERMINAL_STATUSES.has(status)) return this.evict(sessionId, str(o.task_id)); + // A non-terminal patch (e.g. paused) is still enrichment. + return this.enrich(sessionId, str(o.task_id), { description: str(patch.description) }); + } + default: + return false; + } + } + + /** + * REPLACE the session's set from a level signal, preserving the enrichment we + * have already folded onto tasks that survive. Without the merge, every level + * signal would blank the `tool_use_id` and step counts that only the edges + * carry, making rows flicker between detailed and bare. + */ + private replace(projectSlug: string, sessionId: string, raw: unknown[]): boolean { + const prev = this.bySession.get(sessionId); + const next = new Map(); + for (const t of raw) { + const rt = (t ?? {}) as RawLevelTask; + const id = str(rt.task_id); + if (!id) continue; + const existing = prev?.tasks.get(id); + next.set(id, { + ...existing, + id, + type: str(rt.task_type) ?? existing?.type ?? "task", + description: str(rt.description) ?? existing?.description ?? "", + startedAt: existing?.startedAt ?? this.now(), + }); + } + if (prev && sameSet(prev.tasks, next)) { + // Membership and detail unchanged — keep the projectSlug fresh but stay quiet. + prev.projectSlug = projectSlug; + return false; + } + const entry: SessionEntry = { projectSlug, tasks: next }; + this.bySession.set(sessionId, entry); + this.emit(sessionId, entry); + return true; + } + + /** + * Fold edge detail onto an EXISTING task. Deliberately a no-op for an unknown + * id: the level signal is the sole authority on membership, so an edge can + * never create a row (and a late edge can never resurrect a finished one). + */ + private enrich(sessionId: string, taskId: string | undefined, patch: Partial): boolean { + if (!taskId) return false; + const e = this.bySession.get(sessionId); + const cur = e?.tasks.get(taskId); + if (!e || !cur) return false; + const defined = Object.fromEntries( + Object.entries(patch).filter(([, v]) => v !== undefined), + ) as Partial; + const keys = Object.keys(defined) as (keyof LiveBackgroundTask)[]; + if (keys.length === 0 || keys.every((k) => cur[k] === defined[k])) return false; + e.tasks.set(taskId, { ...cur, ...defined }); + this.emit(sessionId, e); + return true; + } + + /** Drop a task on a terminal edge, ahead of the level that will confirm it. */ + private evict(sessionId: string, taskId: string | undefined): boolean { + if (!taskId) return false; + const e = this.bySession.get(sessionId); + if (!e || !e.tasks.delete(taskId)) return false; + this.emit(sessionId, e); + return true; + } + + private emit(sessionId: string, e: SessionEntry): void { + this.onChange?.(e.projectSlug, sessionId, [...e.tasks.values()]); + } +} + +/** Do two task maps carry the same ids AND the same rendered detail? */ +function sameSet( + a: Map, + b: Map, +): boolean { + if (a.size !== b.size) return false; + for (const [id, ta] of a) { + const tb = b.get(id); + if (!tb) return false; + if (ta.type !== tb.type || ta.description !== tb.description) return false; + } + return true; +} diff --git a/packages/server/src/ws-protocol.ts b/packages/server/src/ws-protocol.ts index ef389081..c246c532 100644 --- a/packages/server/src/ws-protocol.ts +++ b/packages/server/src/ws-protocol.ts @@ -558,6 +558,22 @@ export interface ChatActiveMessage { * a client built against an older server still parses the frame. */ startedAt?: number; + /** + * Whether a MODEL TURN is in flight, as distinct from `running`, which since + * #604 also counts background work the turn left behind. + * + * The two answer different questions and must not be conflated. `running` + * drives status readouts — the sidebar dot, the fleet strip, the Home badge, + * the running-only filter — where background work genuinely means "this chat + * is busy". `turnRunning` drives the composer lock and the working indicator, + * where it does not: a `Monitor` can run for an hour, and locking the + * composer for that hour would make the chat unusable while telling the user + * the model was still thinking. + * + * Optional on the wire so an older client still parses the frame; such a + * client falls back to `running` and behaves as it did before. + */ + turnRunning?: boolean; }; } @@ -682,6 +698,52 @@ export interface ChatKilledTaskMessage { }; } +/** + * One live background task on a session (#604). Mirrors the SDK's + * `BackgroundTaskSummary` plus the detail its edge signals carry. + */ +export interface LiveBackgroundTaskWire { + id: string; + /** `shell` | `subagent` | `monitor` | `workflow`, or a raw discriminant we do not know. */ + type: string; + description: string; + startedAt: number; + toolUseId?: string; + agentType?: string; + command?: string; + workflowName?: string; + server?: string; + tool?: string; + lastToolName?: string; + toolUses?: number; + skipTranscript?: boolean; +} + +/** + * A session's live background work (#604) — the tasks still running after the + * turn that launched them has returned. + * + * A LEVEL frame with REPLACE semantics: `tasks` is the complete set, and an + * empty array means "nothing running". Clients must swap their set rather than + * pair start/stop edges, so a dropped frame cannot wedge a stale indicator — + * the same contract the SDK states for `background_tasks_changed` upstream, and + * the reason #528's permanent "running" state cannot recur here. + * + * Broadcast on every membership change and replayed to a newly-connected socket, + * so a remount or reload learns what is in flight without polling. Nothing is + * reconstructed from disk: the signal is per-process, so after a server restart + * the set is empty until the next change — which is correct, because Paddock + * stops the fleet with `waitForJobs: false` and those tasks are dead. + */ +export interface ChatBackgroundMessage { + type: "chat:background"; + payload: { + projectSlug: string; + sessionId: string; + tasks: LiveBackgroundTaskWire[]; + }; +} + /** * A keeper turn dead-ended without a normal reply (issue #329): a synthetic * subscription/usage-limit hit, the max-turns cap, or an error (network / API @@ -712,6 +774,7 @@ export type ServerMessage = | ChatQueuedStateMessage | ChatQueuedReturnedMessage | ChatKilledTaskMessage + | ChatBackgroundMessage | ChatNoticeMessage | PongMessage; diff --git a/packages/server/src/ws-turn.ts b/packages/server/src/ws-turn.ts index 7ef22a8e..f826a8d9 100644 --- a/packages/server/src/ws-turn.ts +++ b/packages/server/src/ws-turn.ts @@ -32,6 +32,7 @@ import { import { resolveMaxSpawnDepth } from "./spawn-capability.js"; import { RecoveryEngine } from "./recovery.js"; import { extractSubagentLaunches, subagentLaunchFields, type SubagentLaunch } from "./subagents.js"; +import type { BackgroundRegistry } from "./background-live.js"; import { noticeFromMessage, errorNotice, @@ -135,8 +136,12 @@ export interface TurnEngine { * Build the turn-execution engine bound to the handler's deps + shared hub, wiring * the herdctl schedule/wake resolvers and the trigger event listeners. */ -export function makeTurnEngine(engine: { deps: ChatHandlerDeps; hub: SessionHub }): TurnEngine { - const { deps, hub } = engine; +export function makeTurnEngine(engine: { + deps: ChatHandlerDeps; + hub: SessionHub; + background: BackgroundRegistry; +}): TurnEngine { + const { deps, hub, background } = engine; // Trigger / schedule / event firing (ws-triggers.ts, #403). Built here so it can // close over the shared startAgentTurn engine (a hoisted declaration below) and @@ -347,6 +352,10 @@ const makeBackgroundTurnSink = ( }); }; const onMessage = async (m: SDKMessage): Promise => { + // #604: observe task lifecycle before the sidechain filter — see the note on + // the foreground path. This lane is where a background task most often + // finishes, since the turn that launched it has already returned. + background.observe(projectSlug, m); // (1) Skip sidechain sub-agent nested steps from rendering (see header). The // attribution lives on the top-level SDK message OR its nested `message`. if (isSidechainMessage(m)) return; @@ -382,6 +391,11 @@ const makeBackgroundTurnSink = ( if (bgJobId) deps.herdctl.unregisterBackgroundTurn(bgJobId); bgJobId = null; registeredSession = null; + // #604: the stream ending IS the reap — the session's process is gone, so + // its background set can no longer change and anything still listed is dead. + // The SDK's level signal is per-process and emits nothing at startup, so + // this reset is what stops a stale row surviving into the next session. + if (resolvedSession) background.clear(resolvedSession); if (!turn) return; turn.emit({ type: "chat:complete", @@ -603,6 +617,12 @@ async function startAgentTurn(opts: StartAgentTurnOpts): Promise { // SAME `session_id` as the main chat (verified against on-disk transcripts), // and the launching `tool_use` message is itself main-stream, so it always // resolves the id before any sidechain step arrives. + // + // #604: the SDK's task-lifecycle system messages are session-level rather + // than rendered transcript, so they are observed BEFORE this filter — the + // registry must see a sub-agent's own lifecycle even though its steps are + // deliberately not rendered here. + background.observe(projectSlug, m); if (isSidechainMessage(m)) return; if (m.session_id) { resolvedSession = m.session_id; diff --git a/packages/server/src/ws.ts b/packages/server/src/ws.ts index cdceee1b..a9666d11 100644 --- a/packages/server/src/ws.ts +++ b/packages/server/src/ws.ts @@ -77,6 +77,7 @@ import { type DriveMode, } from "./models.js"; import { SessionHub, type TurnHandle, type ActiveInfo, type HubSocket } from "./session-hub.js"; +import { BackgroundRegistry } from "./background-live.js"; import { hasQueuedContent, partsOf, @@ -267,27 +268,72 @@ export function makeChatHandler(deps: ChatHandlerDeps) { // broadcast to all clients — powering the per-chat sidebar streaming dots that // must update even for chats whose pane isn't mounted (issue #53). const clients = new Set(); + /** + * Live background work per session (#604). Owned here because this is the + * layer that can broadcast; fed by the turn engine from both message lanes. + */ + const background = new BackgroundRegistry(); + const broadcast = (frame: ServerMessage): void => { + const data = JSON.stringify(frame); + for (const c of clients) { + if (c.readyState === c.OPEN) { + try { + c.send(data); + } catch { + /* a socket that throws on send is effectively gone */ + } + } + } + }; + /** + * #604: a session is busy while it holds background work, even though the + * turn that launched it has returned. `SessionHub` tracks one turn and cannot + * know this, so the join happens here — the only place that sees both. Every + * consumer of `chat:active.running` (sidebar dots, the Home in-flight badge, + * the running-only filter, the client's own `streaming` gate) then reads the + * truth instead of "the primary turn ended". + */ const activeFrame = (info: ActiveInfo): ChatActiveMessage => ({ type: "chat:active", payload: { projectSlug: info.projectSlug, sessionId: info.sessionId, jobId: info.jobId, - running: info.running, + running: info.running || background.isBusy(info.sessionId), startedAt: info.startedAt, + turnRunning: info.running, }, }); + /** + * Last `running` we published per session, so a background-set change only + * re-emits `chat:active` when it actually flips the answer. Without this every + * task start/stop would broadcast a redundant frame to every socket. + */ + const lastPublishedRunning = new Map(); hub.onActive = (info) => { - const data = JSON.stringify(activeFrame(info)); - for (const c of clients) { - if (c.readyState === c.OPEN) { - try { - c.send(data); - } catch { - /* a socket that throws on send is effectively gone */ - } - } - } + const frame = activeFrame(info); + lastPublishedRunning.set(info.sessionId, frame.payload.running); + broadcast(frame); + }; + background.onChange = (projectSlug, sessionId, tasks) => { + broadcast({ type: "chat:background", payload: { projectSlug, sessionId, tasks } }); + // The set changing can flip a session between idle and busy while the hub's + // own turn state is unchanged — that transition is exactly #604. + const info = hub.activeInfo(sessionId); + const running = (info?.running ?? false) || tasks.length > 0; + if (lastPublishedRunning.get(sessionId) === running) return; + lastPublishedRunning.set(sessionId, running); + broadcast({ + type: "chat:active", + payload: { + projectSlug, + sessionId, + jobId: info?.jobId ?? null, + running, + startedAt: info?.startedAt, + turnRunning: info?.running ?? false, + }, + }); }; // Drive scheduler-fired session wakes onto the hub (Paddock#111 gap 3). When a @@ -387,6 +433,8 @@ export function makeChatHandler(deps: ChatHandlerDeps) { }; const onWakeMessage = async (m: SDKMessage): Promise => { // A sub-agent's nested steps never render top-level ({@link isSidechainMessage}). + // #604: task lifecycle is session-level — observe before the filter. + background.observe(slug, m); if (isSidechainMessage(m)) return; if (messageProducedReply(m as Parameters[0])) wakeProducedReply = true; @@ -443,7 +491,7 @@ export function makeChatHandler(deps: ChatHandlerDeps) { // wake cache, the shared startAgentTurn engine, and Layer-2/3 recovery. Relocated // as one scope so its cross-references stay intact; the socket layer below // consumes the returned surface. - const engine = makeTurnEngine({ deps, hub }); + const engine = makeTurnEngine({ deps, hub, background }); const { startAgentTurn, wakeInjection, @@ -1006,6 +1054,9 @@ export function makeChatHandler(deps: ChatHandlerDeps) { // human `chat:send` path, so it is where the duplication was actually // seen. Skipping also keeps a sub-agent's context out of the parent's // live meter (`foldTurnUsage` below would latch its max — the #398 shape). + // #604: task lifecycle is session-level, not rendered transcript — + // observe it before the sidechain filter. + background.observe(slug, m); if (isSidechainMessage(m)) return; // Capture the session id as it arrives mid-stream (the translator // only surfaces text/boundary/tool events, not routing metadata). @@ -1249,6 +1300,9 @@ export function makeChatHandler(deps: ChatHandlerDeps) { onMessage: async (m: SDKMessage) => { // A slash-command turn can spawn a Task too; its nested steps never // render top-level ({@link isSidechainMessage}). + // #604: task lifecycle is session-level, not rendered transcript — + // observe it before the sidechain filter. + background.observe(slug, m); if (isSidechainMessage(m)) return; if (m.session_id) { resolvedSession = m.session_id; @@ -1461,6 +1515,10 @@ export function makeChatHandler(deps: ChatHandlerDeps) { // returning pane's Stop button reflect reality from the first paint). clients.add(socket); for (const info of hub.runningSessions()) send(activeFrame(info)); + // #604: and on what background work is in flight, so a remounted pane's + // pinned bar is populated from the first paint rather than after a poll. + for (const s of background.snapshot()) + send({ type: "chat:background", payload: { ...s } }); // Heartbeat: browsers auto-answer protocol ping frames with a pong, so a // client whose TCP has silently died (idle drop, sleep) fails to pong and is diff --git a/packages/server/test/integration/ws-background.test.ts b/packages/server/test/integration/ws-background.test.ts new file mode 100644 index 00000000..cb4c0f74 --- /dev/null +++ b/packages/server/test/integration/ws-background.test.ts @@ -0,0 +1,136 @@ +/** + * WS live background-work signal (#604). + * + * The server tracks the SDK's task-lifecycle system messages and broadcasts the + * live set as `chat:background`. Two things must be true end to end: + * + * 1. the frame carries the tasks, with the detail the edge signals add; and + * 2. `chat:active` keeps reporting `running: true` while background work is in + * flight — the actual defect in #604, where a session with minutes of + * background work left was announced as idle the moment the turn returned. + * + * Driven by the fake claude's `[[BGTASK]]` directive, which writes the same + * system lines the real SDK streams. herdctl's CLI runtime yields every parsed + * transcript line unfiltered, so this exercises the production path. + * + * Each test uses its own project to avoid the cross-test sweep race (see + * ws-reattach.test.ts). + */ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { startTestApp, type TestApp } from "../helpers/app.js"; +import { listen, connectWs, type WsEvent } from "../helpers/ws.js"; + +describe("integration: WS background-work signal (#604)", () => { + let t: TestApp; + let port: number; + let n = 0; + + beforeAll(async () => { + t = await startTestApp({ + script: { "[[BGTASK]] go": "Kicked off some background work." }, + sweepIntervalMs: 600_000, + env: { PADDOCK_FAKE_BGTASK_MS: "1500" }, + }); + ({ port } = await listen(t.app)); + }); + afterAll(async () => { + await t.teardown(); + }); + + async function freshProject(): Promise { + const name = `Bg ${++n}`; + await t.app.inject({ method: "POST", url: "/api/projects", payload: { name } }); + return name.toLowerCase().replace(/\s+/g, "-"); + } + + const bgFrame = (slug: string) => (e: WsEvent) => + e.type === "chat:background" && e.payload?.projectSlug === slug; + + it("broadcasts the live task set, then shrinks it as work finishes", async () => { + const slug = await freshProject(); + const ws = await connectWs(port); + try { + ws.send({ type: "chat:send", payload: { projectSlug: slug, sessionId: null, message: "[[BGTASK]] go" } }); + + // The first frame carries BOTH tasks, from the level signal. + const first = await ws.waitFor( + (e) => bgFrame(slug)(e) && (e.payload?.tasks as unknown[])?.length === 2, + { timeoutMs: 20_000 }, + ); + const tasks = first.payload!.tasks as { type: string; description: string }[]; + expect(tasks.map((x) => x.type).sort()).toEqual(["monitor", "shell"]); + expect(tasks.find((x) => x.type === "monitor")!.description).toBe("errors in deploy.log"); + + // Edge enrichment lands on the existing row rather than creating a new one. + const enriched = await ws.waitFor( + (e) => + bgFrame(slug)(e) && + ((e.payload?.tasks as { lastToolName?: string }[]) ?? []).some((x) => + x.lastToolName?.startsWith("poll #"), + ), + { timeoutMs: 20_000 }, + ); + expect((enriched.payload!.tasks as unknown[]).length).toBe(2); + + // The shell completes; REPLACE leaves only the monitor. + const shrunk = await ws.waitFor( + (e) => bgFrame(slug)(e) && (e.payload?.tasks as unknown[])?.length === 1, + { timeoutMs: 20_000 }, + ); + expect((shrunk.payload!.tasks as { type: string }[])[0].type).toBe("monitor"); + } finally { + ws.close(); + } + }, 40_000); + + it("keeps chat:active running:true while background work is in flight (#604)", async () => { + const slug = await freshProject(); + const ws = await connectWs(port); + try { + ws.send({ type: "chat:send", payload: { projectSlug: slug, sessionId: null, message: "[[BGTASK]] go" } }); + + // Wait until background work is live... + await ws.waitFor( + (e) => bgFrame(slug)(e) && ((e.payload?.tasks as unknown[]) ?? []).length > 0, + { timeoutMs: 20_000 }, + ); + // ...then wait for the turn's own completion. Before this fix, the + // `chat:active` that follows carried running:false with minutes of work left. + await ws.waitFor((e) => e.type === "chat:complete" && e.payload?.projectSlug === slug, { timeoutMs: 20_000 }); + + const actives = ws + .events + .filter((e) => e.type === "chat:active" && e.payload?.projectSlug === slug); + expect(actives.length).toBeGreaterThan(0); + // No frame may announce the session idle while tasks are still listed. + expect(actives.at(-1)!.payload!.running).toBe(true); + } finally { + ws.close(); + } + }, 40_000); + + it("replays the live set to a socket that connects mid-run", async () => { + const slug = await freshProject(); + const driver = await connectWs(port); + let latecomer: Awaited> | null = null; + try { + driver.send({ type: "chat:send", payload: { projectSlug: slug, sessionId: null, message: "[[BGTASK]] go" } }); + await driver.waitFor( + (e) => bgFrame(slug)(e) && ((e.payload?.tasks as unknown[]) ?? []).length === 2, + { timeoutMs: 20_000 }, + ); + + // A pane that mounts now — a reload, or navigating back — must be told what + // is in flight without polling for it. + latecomer = await connectWs(port); + const replay = await latecomer.waitFor( + (e) => bgFrame(slug)(e) && ((e.payload?.tasks as unknown[]) ?? []).length > 0, + { timeoutMs: 20_000 }, + ); + expect((replay.payload!.tasks as { type: string }[]).length).toBeGreaterThan(0); + } finally { + driver.close(); + latecomer?.close(); + } + }, 40_000); +}); diff --git a/packages/server/test/unit/background-live.test.ts b/packages/server/test/unit/background-live.test.ts new file mode 100644 index 00000000..34ec5b75 --- /dev/null +++ b/packages/server/test/unit/background-live.test.ts @@ -0,0 +1,205 @@ +/** + * BackgroundRegistry unit coverage (#604) — the live background-work set fed + * from the SDK's task-lifecycle system messages. + * + * The behaviours under test are the ones the design leans on: the level signal + * is the sole authority on membership, edges may only enrich, and a missed + * terminal edge cannot wedge a stale row. + */ +import { describe, it, expect, vi } from "vitest"; +import { BackgroundRegistry, type LiveBackgroundTask } from "../../src/background-live.js"; + +const SESSION = "sess-1"; +const SLUG = "proj"; + +/** `system/background_tasks_changed` — the level signal. */ +const level = (tasks: { id: string; type?: string; description?: string }[]) => ({ + type: "system", + subtype: "background_tasks_changed", + session_id: SESSION, + tasks: tasks.map((t) => ({ + task_id: t.id, + task_type: t.type ?? "shell", + description: t.description ?? "", + })), +}); + +const started = (id: string, extra: Record = {}) => ({ + type: "system", + subtype: "task_started", + session_id: SESSION, + task_id: id, + ...extra, +}); + +const progress = (id: string, extra: Record = {}) => ({ + type: "system", + subtype: "task_progress", + session_id: SESSION, + task_id: id, + ...extra, +}); + +const notify = (id: string) => ({ + type: "system", + subtype: "task_notification", + session_id: SESSION, + task_id: id, + status: "completed", +}); + +describe("BackgroundRegistry", () => { + it("ignores everything that is not a task-lifecycle system message", () => { + const r = new BackgroundRegistry(); + expect(r.observe(SLUG, { type: "assistant", session_id: SESSION })).toBe(false); + expect(r.observe(SLUG, { type: "system", subtype: "init", session_id: SESSION })).toBe(false); + expect(r.observe(SLUG, null)).toBe(false); + expect(r.observe(SLUG, { type: "system", subtype: "background_tasks_changed" })).toBe(false); + expect(r.isBusy(SESSION)).toBe(false); + }); + + it("REPLACES the set from the level signal, so a dropped task disappears", () => { + const r = new BackgroundRegistry(); + r.observe(SLUG, level([{ id: "a" }, { id: "b" }])); + expect(r.list(SESSION).map((t) => t.id)).toEqual(["a", "b"]); + expect(r.isBusy(SESSION)).toBe(true); + + // 'a' finished; the level no longer lists it. No terminal edge was seen. + r.observe(SLUG, level([{ id: "b" }])); + expect(r.list(SESSION).map((t) => t.id)).toEqual(["b"]); + + r.observe(SLUG, level([])); + expect(r.list(SESSION)).toEqual([]); + expect(r.isBusy(SESSION)).toBe(false); + }); + + it("an edge NEVER creates a row — the level is the only authority on membership", () => { + const r = new BackgroundRegistry(); + // A task_started with no preceding level must not conjure a task... + expect(r.observe(SLUG, started("ghost", { description: "not real" }))).toBe(false); + expect(r.list(SESSION)).toEqual([]); + + // ...and a late edge must not resurrect one the level has already dropped. + r.observe(SLUG, level([{ id: "a" }])); + r.observe(SLUG, level([])); + expect(r.observe(SLUG, progress("a", { last_tool_name: "Bash" }))).toBe(false); + expect(r.list(SESSION)).toEqual([]); + }); + + it("folds edge detail onto the level row and preserves it across the next level", () => { + const r = new BackgroundRegistry(); + r.observe(SLUG, level([{ id: "a", type: "subagent", description: "research" }])); + r.observe(SLUG, started("a", { tool_use_id: "tu-1", subagent_type: "Explore" })); + r.observe(SLUG, progress("a", { last_tool_name: "Grep", usage: { tool_uses: 7 } })); + + const [t] = r.list(SESSION); + expect(t).toMatchObject({ + id: "a", + type: "subagent", + description: "research", + toolUseId: "tu-1", + agentType: "Explore", + lastToolName: "Grep", + toolUses: 7, + }); + + // A fresh level signal carries none of that detail; it must survive. + r.observe(SLUG, level([{ id: "a", type: "subagent", description: "research" }])); + expect(r.list(SESSION)[0]).toMatchObject({ toolUseId: "tu-1", lastToolName: "Grep", toolUses: 7 }); + }); + + it("evicts on a terminal edge without waiting for the next level", () => { + const r = new BackgroundRegistry(); + r.observe(SLUG, level([{ id: "a" }, { id: "b" }])); + expect(r.observe(SLUG, notify("a"))).toBe(true); + expect(r.list(SESSION).map((t) => t.id)).toEqual(["b"]); + }); + + it("treats a terminal task_updated patch as an eviction and a live one as enrichment", () => { + const r = new BackgroundRegistry(); + r.observe(SLUG, level([{ id: "a" }])); + + r.observe(SLUG, { + type: "system", + subtype: "task_updated", + session_id: SESSION, + task_id: "a", + patch: { description: "still going" }, + }); + expect(r.list(SESSION)[0].description).toBe("still going"); + + r.observe(SLUG, { + type: "system", + subtype: "task_updated", + session_id: SESSION, + task_id: "a", + patch: { status: "killed" }, + }); + expect(r.list(SESSION)).toEqual([]); + }); + + it("stamps startedAt once and does not restamp on later level signals", () => { + let t = 1000; + const r = new BackgroundRegistry(() => t); + r.observe(SLUG, level([{ id: "a" }])); + expect(r.list(SESSION)[0].startedAt).toBe(1000); + t = 5000; + r.observe(SLUG, level([{ id: "a" }, { id: "b" }])); + const byId = new Map(r.list(SESSION).map((x) => [x.id, x])); + expect(byId.get("a")!.startedAt).toBe(1000); + expect(byId.get("b")!.startedAt).toBe(5000); + }); + + it("stays quiet when a level signal changes nothing", () => { + const seen: LiveBackgroundTask[][] = []; + const r = new BackgroundRegistry(); + r.onChange = (_slug, _sid, tasks) => seen.push(tasks); + expect(r.observe(SLUG, level([{ id: "a" }]))).toBe(true); + expect(r.observe(SLUG, level([{ id: "a" }]))).toBe(false); + expect(seen).toHaveLength(1); + }); + + it("notifies with the project slug and session id so the WS layer can route", () => { + const r = new BackgroundRegistry(); + const onChange = vi.fn(); + r.onChange = onChange; + r.observe(SLUG, level([{ id: "a", type: "monitor", description: "watch log" }])); + expect(onChange).toHaveBeenCalledWith(SLUG, SESSION, [ + expect.objectContaining({ id: "a", type: "monitor", description: "watch log" }), + ]); + }); + + it("clear() resets a session, as the per-process level contract requires", () => { + const r = new BackgroundRegistry(); + const onChange = vi.fn(); + r.observe(SLUG, level([{ id: "a" }])); + r.onChange = onChange; + r.clear(SESSION); + expect(r.isBusy(SESSION)).toBe(false); + expect(onChange).toHaveBeenCalledWith(SLUG, SESSION, []); + // Idempotent: a second clear is silent. + onChange.mockClear(); + r.clear(SESSION); + expect(onChange).not.toHaveBeenCalled(); + }); + + it("snapshot() returns only sessions with live work, for the connect-time replay", () => { + const r = new BackgroundRegistry(); + r.observe(SLUG, level([{ id: "a" }])); + r.observe("other", { + type: "system", + subtype: "background_tasks_changed", + session_id: "sess-2", + tasks: [], + }); + expect(r.snapshot()).toEqual([ + { projectSlug: SLUG, sessionId: SESSION, tasks: [expect.objectContaining({ id: "a" })] }, + ]); + }); + + it("keeps an unknown task type rather than dropping the row", () => { + const r = new BackgroundRegistry(); + r.observe(SLUG, level([{ id: "a", type: "some_future_kind", description: "?" }])); + expect(r.list(SESSION)[0].type).toBe("some_future_kind"); + }); +}); diff --git a/packages/server/test/unit/background-turn-cancellable.test.ts b/packages/server/test/unit/background-turn-cancellable.test.ts index 4e765b29..7f8c4f5b 100644 --- a/packages/server/test/unit/background-turn-cancellable.test.ts +++ b/packages/server/test/unit/background-turn-cancellable.test.ts @@ -21,6 +21,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import type { SDKMessage } from "@herdctl/core"; import { makeTurnEngine } from "../../src/ws-turn.js"; +import { BackgroundRegistry } from "../../src/background-live.js"; import { SessionHub } from "../../src/session-hub.js"; import type { ChatHandlerDeps } from "../../src/ws-context.js"; @@ -57,7 +58,9 @@ function harness() { attachments: { save: vi.fn(async () => "") }, } as unknown as ChatHandlerDeps; - const engine = makeTurnEngine({ deps, hub }); + // #604: the engine now feeds a live background-task registry. This suite is + // about the jobId lifecycle, so a bare registry with no listener is enough. + const engine = makeTurnEngine({ deps, hub, background: new BackgroundRegistry() }); return { engine, registered, unregistered, active, herdctl }; } diff --git a/packages/web/src/components/ChatPane.test.tsx b/packages/web/src/components/ChatPane.test.tsx index 5ec954c8..3ba3919d 100644 --- a/packages/web/src/components/ChatPane.test.tsx +++ b/packages/web/src/components/ChatPane.test.tsx @@ -33,6 +33,12 @@ let stateCb: ((s: string) => void) | null = null; vi.mock("../lib/ws", () => ({ chatClient: { state: "open", + // #604: the pane subscribes to live background work. These suites are about + // other things, so the mock reports an idle session and never emits. + onBackgroundWork: (_sessionId: string, cb: (tasks: unknown[]) => void) => { + cb([]); + return () => {}; + }, onState: (cb: (s: string) => void) => { stateCb = cb; cb("open"); @@ -332,7 +338,7 @@ describe("ChatPane: cancel + errors", () => { fireEvent.click(screen.getByRole("button", { name: /Stop/ })); expect(cancels).toEqual([]); // ...it defers, and fires the instant the jobId lands (here via chat:active). - act(() => sub().handlers.onActive?.({ running: true, jobId: "job-late" })); + act(() => sub().handlers.onActive?.({ running: true, turnRunning: true, jobId: "job-late" })); expect(cancels).toEqual(["job-late"]); }); diff --git a/packages/web/src/components/ChatPane.tsx b/packages/web/src/components/ChatPane.tsx index e01b8890..109fd852 100644 --- a/packages/web/src/components/ChatPane.tsx +++ b/packages/web/src/components/ChatPane.tsx @@ -33,6 +33,7 @@ import type { RecoveryConfig, RecoveryOverride, SlashCommand, + LiveBackgroundTask, } from "../lib/types"; import { acceptAttribute } from "../lib/attachments"; import { AttachmentTrayItem } from "./MessageAttachments"; @@ -59,7 +60,7 @@ import { TurnActionsContext, type TurnActionsValue, } from "./chat/chatContexts"; -import { RunningSubagents } from "./chat/RunningSubagents"; +import { RunningWork } from "./chat/RunningWork"; import { useRunningSubagents, useSubagentActivity } from "./chat/useSubagentActivity"; import { ConnDot, @@ -379,6 +380,19 @@ export function ChatPane({ // the bar the instant the parent replied, while the work carried on. const subagentCandidates = useRunningSubagents(turns); const subagentActivity = useSubagentActivity(subagentCandidates, fetchSubagent, streaming); + // #604: live background work (shells, monitors, workflows, and any sub-agent + // the transcript path has not found), pushed from the server rather than + // polled. Keyed on the session so a remount re-subscribes and is repopulated + // from the connect-time snapshot. + const [backgroundTasks, setBackgroundTasks] = useState([]); + useEffect(() => { + const sid = initialSessionId ?? null; + if (!sid) { + setBackgroundTasks([]); + return; + } + return chatClient.onBackgroundWork(sid, setBackgroundTasks); + }, [initialSessionId]); // The bar lists only those still working. Once a sub-agent has been polled its // own transcript decides; BEFORE the first poll lands we fall back to whether // the chat is streaming — so a just-launched sub-agent appears immediately, @@ -1242,12 +1256,14 @@ export function ChatPane({ (#53) — independent of whether a bubble is currently painting, so it shows during the initial thinking gap and between tool calls, and lights up the instant you return to a still-streaming chat. */} - {/* One live line per RUNNING sub-agent, so long nested work is visible - without hunting for (and expanding) its card. Tapping a row reveals the - card in the transcript. Renders nothing when none is running. */} - diff --git a/packages/web/src/components/chat/RunningSubagents.test.tsx b/packages/web/src/components/chat/RunningSubagents.test.tsx deleted file mode 100644 index c5518b79..00000000 --- a/packages/web/src/components/chat/RunningSubagents.test.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { describe, it, expect, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { RunningSubagents } from "./RunningSubagents"; -import { isSubagentRunning } from "./toolFormatting"; -import type { RunningSubagent, SubagentActivity } from "./useSubagentActivity"; -import type { ToolCall } from "../../lib/ws"; - -const RUNNING: RunningSubagent[] = [ - { toolUseId: "toolu_a", label: "general-purpose", description: "audit the config" }, -]; - -const activity = (a?: SubagentActivity) => - new Map(a ? [["toolu_a", a]] : []); - -describe("RunningSubagents bar", () => { - it("renders nothing when no sub-agent is running (an ordinary turn is unchanged)", () => { - const { container } = render( - {}} />, - ); - expect(container).toBeEmptyDOMElement(); - }); - - it("shows the sub-agent's LATEST step, which is the whole point of the bar", () => { - render( - {}} - />, - ); - expect(screen.getByText("Bash wc -l a.txt")).toBeInTheDocument(); - expect(screen.getByText("4 steps")).toBeInTheDocument(); - expect(screen.getByText("1 sub-agent running")).toBeInTheDocument(); - }); - - it("falls back to 'starting…' before the first poll returns", () => { - render( {}} />); - expect(screen.getByText("starting…")).toBeInTheDocument(); - }); - - it("asks to reveal the sub-agent's card when its row is tapped", async () => { - const onReveal = vi.fn(); - render( - , - ); - await userEvent.click(screen.getByRole("button", { name: /general-purpose/ })); - expect(onReveal).toHaveBeenCalledWith("toolu_a"); - }); -}); - -/** - * The bar and the card must agree on what "running" means — they read the same - * predicate precisely so a sub-agent can't be listed as running by one and shown - * as finished by the other. - */ -describe("isSubagentRunning", () => { - const tool = (over: Partial = {}): ToolCall => - ({ toolName: "Task", output: "", isError: false, ...over }) as ToolCall; - - it("is true for a sub-agent with no final duration while the chat is live", () => { - expect(isSubagentRunning(tool(), true)).toBe(true); - }); - - it("is false once the final duration lands (the history join completed it)", () => { - expect(isSubagentRunning(tool({ subagentDurationMs: 31_000 }), true)).toBe(false); - }); - - it("is false when the chat is not live (a reloaded transcript)", () => { - expect(isSubagentRunning(tool(), false)).toBe(false); - }); - - it("is false for a tool that isn't a sub-agent at all", () => { - expect(isSubagentRunning(tool({ toolName: "Bash" }), true)).toBe(false); - }); -}); diff --git a/packages/web/src/components/chat/RunningSubagents.tsx b/packages/web/src/components/chat/RunningSubagents.tsx deleted file mode 100644 index a3e7fd55..00000000 --- a/packages/web/src/components/chat/RunningSubagents.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { SparkIcon } from "../icons"; -import type { RunningSubagent, SubagentActivity } from "./useSubagentActivity"; - -/** - * A live line item per RUNNING sub-agent, sitting just above the composer. - * - * The problem it solves: a sub-agent can work for minutes behind a collapsed card - * that shows a cost and a duration but gives no sense of progress — and the card - * may be scrolled far up the transcript, so there is nowhere to look. Each row - * here names the sub-agent and its LATEST step, updating as it works, and tapping - * one scrolls its card into view, expands it, and flashes it. - * - * Renders nothing when no sub-agent is running, so an ordinary turn is unchanged. - * - * Takes its data as PROPS rather than through the sub-agent contexts: it is docked - * above the composer, outside the scrolling transcript those providers wrap. - */ -export function RunningSubagents({ - running, - activity, - onReveal, -}: { - running: RunningSubagent[]; - activity: Map; - onReveal: (toolUseId: string) => void; -}) { - if (running.length === 0) return null; - - return ( -
-
-
-
-
    - {running.map((r) => { - const act = activity.get(r.toolUseId); - return ( -
  • - -
  • - ); - })} -
-
-
- ); -} diff --git a/packages/web/src/components/chat/RunningWork.test.tsx b/packages/web/src/components/chat/RunningWork.test.tsx new file mode 100644 index 00000000..e69a9749 --- /dev/null +++ b/packages/web/src/components/chat/RunningWork.test.tsx @@ -0,0 +1,222 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { RunningWork } from "./RunningWork"; +import { isSubagentRunning } from "./toolFormatting"; +import type { RunningSubagent, SubagentActivity } from "./useSubagentActivity"; +import type { ToolCall } from "../../lib/ws"; +import type { LiveBackgroundTask } from "../../lib/types"; + +const RUNNING: RunningSubagent[] = [ + { toolUseId: "toolu_a", label: "general-purpose", description: "audit the config" }, +]; + +const activity = (a?: SubagentActivity) => + new Map(a ? [["toolu_a", a]] : []); + +const task = (over: Partial = {}): LiveBackgroundTask => ({ + id: "t1", + type: "shell", + description: "", + startedAt: Date.now() - 65_000, + ...over, +}); + +describe("RunningWork bar — sub-agents (behaviour preserved from RunningSubagents)", () => { + it("renders nothing when nothing is running (an ordinary turn is unchanged)", () => { + const { container } = render( + {}} />, + ); + expect(container).toBeEmptyDOMElement(); + }); + + it("shows the sub-agent's LATEST step, which is the whole point of the bar", () => { + render( + {}} + />, + ); + expect(screen.getByText("Bash wc -l a.txt")).toBeInTheDocument(); + expect(screen.getByText("4 steps")).toBeInTheDocument(); + expect(screen.getByText("1 sub-agent running")).toBeInTheDocument(); + }); + + it("falls back to 'starting…' before the first poll returns", () => { + render( + {}} />, + ); + expect(screen.getByText("starting…")).toBeInTheDocument(); + }); + + it("asks to reveal the sub-agent's card when its row is tapped", async () => { + const onReveal = vi.fn(); + render( + , + ); + await userEvent.click(screen.getByRole("button", { name: /general-purpose/ })); + expect(onReveal).toHaveBeenCalledWith("toolu_a"); + }); +}); + +describe("RunningWork bar — background tasks (#604)", () => { + it("shows a background shell, which previously had no liveness at all", () => { + render( + {}} + />, + ); + expect(screen.getByText("npm run build")).toBeInTheDocument(); + expect(screen.getByText("1 thing running")).toBeInTheDocument(); + }); + + it("labels a monitor and a workflow by their own identity", () => { + render( + {}} + />, + ); + expect(screen.getByText("errors in deploy.log")).toBeInTheDocument(); + expect(screen.getByText("spec")).toBeInTheDocument(); + expect(screen.getByText("2 things running")).toBeInTheDocument(); + }); + + it("counts sub-agents and background tasks together", () => { + render( + {}} + />, + ); + expect(screen.getByText("2 things running")).toBeInTheDocument(); + expect(screen.getAllByTestId("running-task-row")).toHaveLength(1); + }); + + it("does NOT double-render a sub-agent the transcript path is already showing", () => { + render( + {}} + />, + ); + expect(screen.getByText("1 sub-agent running")).toBeInTheDocument(); + expect(screen.queryByTestId("running-task-row")).not.toBeInTheDocument(); + }); + + it("DOES show a sub-agent the transcript path has not found (the reload case)", () => { + render( + {}} + />, + ); + expect(screen.getByText("Explore")).toBeInTheDocument(); + }); + + it("hides ambient work the SDK marks as skip_transcript", () => { + const { container } = render( + {}} + />, + ); + expect(container).toBeEmptyDOMElement(); + }); + + it("shows elapsed time, so a stuck shell is visibly stuck", () => { + render( + {}} />, + ); + // startedAt is 65s ago. + expect(screen.getByTestId("running-task-elapsed")).toHaveTextContent("1:05"); + }); + + it("reveals a task's card when the row is tapped, and is inert without a tool id", async () => { + const onReveal = vi.fn(); + const { rerender } = render( + , + ); + await userEvent.click(screen.getByRole("button", { name: /sleep 60/ })); + expect(onReveal).toHaveBeenCalledWith("toolu_b"); + + // A task with no tool_use_id (launched turns ago) renders, but not as a button. + rerender( + , + ); + expect(screen.queryByRole("button", { name: /sleep 60/ })).not.toBeInTheDocument(); + expect(screen.getByTestId("running-task-row")).toBeInTheDocument(); + }); + + it("renders an unknown task type rather than dropping it", () => { + render( + {}} + />, + ); + expect(screen.getByText("some_future_kind")).toBeInTheDocument(); + expect(screen.getByText("who knows")).toBeInTheDocument(); + }); +}); + +/** + * The bar and the card must agree on what "running" means — they read the same + * predicate precisely so a sub-agent can't be listed as running by one and shown + * as finished by the other. + */ +describe("isSubagentRunning", () => { + const tool = (over: Partial = {}): ToolCall => + ({ toolName: "Task", output: "", isError: false, ...over }) as ToolCall; + + it("is true for a sub-agent with no final duration while the chat is live", () => { + expect(isSubagentRunning(tool(), true)).toBe(true); + }); + + it("is false once the final duration lands (the history join completed it)", () => { + expect(isSubagentRunning(tool({ subagentDurationMs: 31_000 }), true)).toBe(false); + }); + + it("is false when the chat is not live (a reloaded transcript)", () => { + expect(isSubagentRunning(tool(), false)).toBe(false); + }); + + it("is false for a tool that isn't a sub-agent at all", () => { + expect(isSubagentRunning(tool({ toolName: "Bash" }), true)).toBe(false); + }); +}); diff --git a/packages/web/src/components/chat/RunningWork.tsx b/packages/web/src/components/chat/RunningWork.tsx new file mode 100644 index 00000000..bb89644a --- /dev/null +++ b/packages/web/src/components/chat/RunningWork.tsx @@ -0,0 +1,203 @@ +import { useEffect, useState } from "react"; +import { ClockIcon, SparkIcon, TerminalIcon, TreeIcon } from "../icons"; +import { formatElapsed } from "../../lib/format"; +import type { LiveBackgroundTask } from "../../lib/types"; +import type { RunningSubagent, SubagentActivity } from "./useSubagentActivity"; + +/** + * A live line item per piece of RUNNING background work, docked just above the + * composer. + * + * The problem it solves: work can outlive the turn that started it and the card + * that launched it can be scrolled far up the transcript, so there is nowhere to + * look to find out whether anything is still happening. This bar names each + * running thing and what it is doing right now. + * + * It merges two sources, deliberately: + * + * - **Sub-agents** come from the transcript-derived path (`useRunningSubagents` + * + `useSubagentActivity`), unchanged. That path has richer per-step detail + * than the SDK's task signals expose, and it is already covered by tests, so + * this generalisation does not disturb it. + * - **Everything else** — background shells, monitors, workflows — comes from + * the server's live background-task registry (#604), which is the first time + * any of them have had liveness at all. Before this, a background `Bash` or a + * `Monitor` rendered a static "running" chip that meant only "no completion + * notification was found in the transcript". + * + * A `subagent` task from the registry is dropped when the transcript path is + * already showing it (matched on `toolUseId`), so the two sources cannot + * double-render the same sub-agent. One the transcript path has not found still + * appears, which is what makes a reload mid-run honest. + * + * Renders nothing when nothing is running, so an ordinary turn is unchanged. + * + * Takes its data as PROPS rather than through the sub-agent contexts: it is + * docked above the composer, outside the scrolling transcript those wrap. + */ + +/** A one-second tick, live only while something is actually running. */ +function useSecondsTick(live: boolean): void { + const [, setTick] = useState(0); + useEffect(() => { + if (!live) return; + const id = setInterval(() => setTick((t) => t + 1), 1000); + return () => clearInterval(id); + }, [live]); +} + +/** Icon per task kind. Unknown kinds get the neutral clock rather than nothing. */ +function TaskIcon({ type }: { type: string }) { + const cls = "shrink-0 text-info"; + if (type === "shell") return ; + if (type === "workflow") return ; + if (type === "subagent") + return ; + return ; +} + +/** The short bold label at the head of a row. */ +function labelOf(t: LiveBackgroundTask): string { + if (t.type === "subagent") return t.agentType ?? "sub-agent"; + if (t.type === "workflow") return t.workflowName ?? "workflow"; + if (t.type === "monitor") return t.tool ?? "monitor"; + return t.type; +} + +/** The wide middle column: what this task is actually doing. */ +function detailOf(t: LiveBackgroundTask): string { + if (t.type === "shell") return t.command ?? t.description; + if (t.lastToolName) return t.lastToolName; + return t.description; +} + +export function RunningWork({ + running, + activity, + tasks, + onReveal, +}: { + running: RunningSubagent[]; + activity: Map; + tasks: LiveBackgroundTask[]; + onReveal: (toolUseId: string) => void; +}) { + // Sub-agents already on screen via the transcript path — the registry's own + // row for the same sub-agent would be a duplicate. + const shownSubagents = new Set(running.map((r) => r.toolUseId)); + const extra = tasks.filter( + (t) => + // The SDK marks ambient/housekeeping work to be kept out of the inline + // transcript; honour that here rather than showing chores as user work. + !t.skipTranscript && !(t.toolUseId != null && shownSubagents.has(t.toolUseId)), + ); + const total = running.length + extra.length; + useSecondsTick(total > 0); + if (total === 0) return null; + + return ( +
+
+
+
+
    + {running.map((r) => { + const act = activity.get(r.toolUseId); + return ( +
  • + +
  • + ); + })} + {extra.map((t) => { + const detail = detailOf(t); + // A row is only tappable when we know which card it came from; a + // background shell launched several turns ago may have no tool_use_id. + const reveal = t.toolUseId; + const Row = reveal ? "button" : "div"; + return ( +
  • + onReveal(reveal), + title: "Show this task in the transcript", + } + : {})} + data-testid="running-task-row" + data-task-type={t.type} + className={`flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors ${ + reveal ? "can-hover:hover:bg-accent/10" : "" + }`} + > + + + {labelOf(t)} + + + {detail || "starting…"} + + {t.toolUses != null && t.toolUses > 0 && ( + + {t.toolUses} step{t.toolUses === 1 ? "" : "s"} + + )} + + {formatElapsed(Date.now() - t.startedAt)} + + +
  • + ); + })} +
+
+
+ ); +} diff --git a/packages/web/src/components/chat/useChatSocket.ts b/packages/web/src/components/chat/useChatSocket.ts index 626cd01e..99245cd1 100644 --- a/packages/web/src/components/chat/useChatSocket.ts +++ b/packages/web/src/components/chat/useChatSocket.ts @@ -297,11 +297,16 @@ export function useChatSocket(params: UseChatSocketParams): void { /* keep whatever we already have */ }); }, - onActive: ({ running, jobId }) => { + onActive: ({ turnRunning, jobId }) => { // The server reports this chat's live-turn status (#52). On a pane that // navigated back to a still-streaming chat this restores the Stop button // and the job id needed to cancel — state a remount would otherwise lose. - if (running) { + // #604: gate on the TURN, not on `running` — which now also counts + // background work the turn left behind. A Monitor can run for an hour; + // locking the composer and showing "working" for that hour would make + // the chat unusable and misdescribe what is happening. Status readouts + // (sidebar dot, fleet strip) still read `running` and stay lit. + if (turnRunning) { if (jobId) armJob(jobId); streamingRef.current = true; setStreaming(true); diff --git a/packages/web/src/lib/types.ts b/packages/web/src/lib/types.ts index 5a06c33a..320bab3b 100644 --- a/packages/web/src/lib/types.ts +++ b/packages/web/src/lib/types.ts @@ -1270,6 +1270,14 @@ export type ServerWsMessage = * worse than one that admits it does not know. */ startedAt?: number; + /** + * Whether a MODEL TURN is in flight, as opposed to `running`, which since + * #604 also counts background work left behind by a finished turn. Status + * readouts use `running`; the composer lock and working indicator use this, + * so an hour-long Monitor does not make the chat unusable. Optional: an + * older server omits it and the client falls back to `running`. + */ + turnRunning?: boolean; }; } | { @@ -1323,6 +1331,17 @@ export type ServerWsMessage = type: "chat:killed_task"; payload: Routing & { sessionId: string; summary: string; timestamp: string }; } + | { + /** + * A session's live background work (#604) — the tasks still running after + * the turn that launched them returned. A LEVEL frame: `tasks` is the + * complete set and an empty array means idle, so clients replace rather + * than pair edges (a dropped frame then cannot wedge a stale indicator). + * Broadcast on every change and replayed on connect. + */ + type: "chat:background"; + payload: Routing & { sessionId: string; tasks: LiveBackgroundTask[] }; + } | { /** * A keeper turn dead-ended without a normal reply (issue #329): a @@ -1335,6 +1354,40 @@ export type ServerWsMessage = } | { type: "pong" }; +/** + * One live background task on a session (#604), mirroring the server's + * `LiveBackgroundTaskWire`. + * + * `type` is the SDK's friendly label — `shell` | `subagent` | `monitor` | + * `workflow` — but is typed as a plain string on purpose: the SDK falls back to + * a raw discriminant for kinds it does not label, and an unknown kind should + * render as a generic row rather than be dropped. + */ +export interface LiveBackgroundTask { + id: string; + type: string; + description: string; + /** Epoch-ms the server first observed this task. */ + startedAt: number; + /** Links the task to its launching tool card in the transcript, when known. */ + toolUseId?: string; + /** `subagent` only. */ + agentType?: string; + /** `shell` only. */ + command?: string; + /** `workflow` only. */ + workflowName?: string; + /** `monitor` / MCP-task only. */ + server?: string; + tool?: string; + /** Latest tool this task ran. */ + lastToolName?: string; + /** Steps taken so far. */ + toolUses?: number; + /** Ambient/housekeeping work the SDK asks consumers to keep out of the transcript. */ + skipTranscript?: boolean; +} + // --- Instance-wide settings (issue #385) ------------------------------------ /** diff --git a/packages/web/src/lib/ws.test.ts b/packages/web/src/lib/ws.test.ts index 4680769c..5fcbb961 100644 --- a/packages/web/src/lib/ws.test.ts +++ b/packages/web/src/lib/ws.test.ts @@ -604,7 +604,8 @@ describe("ws: active-turn signal (issues #52/#53)", () => { const sub = chatClient.subscribe("p", "sess-1", { ...handlers(), onActive }); last().open(); emitActive(last(), "sess-1", true, "job-9"); - expect(onActive).toHaveBeenCalledWith({ running: true, jobId: "job-9" }); + // #604 added `turnRunning`; a frame without it falls back to `running`. + expect(onActive).toHaveBeenCalledWith({ running: true, turnRunning: true, jobId: "job-9" }); sub.unsubscribe(); }); diff --git a/packages/web/src/lib/ws.ts b/packages/web/src/lib/ws.ts index 3357b608..04ac42db 100644 --- a/packages/web/src/lib/ws.ts +++ b/packages/web/src/lib/ws.ts @@ -30,6 +30,7 @@ import type { ServerWsMessage, MessageSender, TurnNotice, + LiveBackgroundTask, } from "./types"; export interface ToolCall { @@ -107,7 +108,7 @@ export interface ChatHandlers { * indicator), false when it ends. Fires on (re)subscribe for an already-running * turn, so a pane the user navigated back to restores state immediately. */ - onActive?: (meta: { running: boolean; jobId: string | null }) => void; + onActive?: (meta: { running: boolean; turnRunning: boolean; jobId: string | null }) => void; /** * The server auto-sent this chat's queued message (#245). `text` is present when * it actually drained+sent it (render it as the user bubble, then clear the @@ -219,6 +220,17 @@ class ChatClient { // would re-fire them. Read through `turnStartedAt()`, which never invents a // time — an absent entry means "we do not know", not "just now". private activeStartedAt = new Map(); + // sessionId -> that session's live background tasks (#604), from `chat:background` + // LEVEL frames. A separate map for the same reason as `activeStartedAt`: this + // value changes on every task step, and folding it into `activeSessions` would + // re-fire every consumer that keys an effect on that map's identity. + // + // REPLACE semantics — a frame's `tasks` array IS the set, so an empty array + // removes the session. Never pair start/stop edges here: a dropped frame would + // then wedge a row on forever, which is the #528 failure mode the server-side + // level signal exists to make impossible. + private backgroundTasks = new Map(); + private backgroundListeners = new Set<(sessionId: string, tasks: LiveBackgroundTask[]) => void>(); private activeListeners = new Set<(s: ReadonlySet) => void>(); private activeInfoListeners = new Set<(m: ReadonlyMap) => void>(); // Every session id this client has ever attached a subscription to. Used by @@ -313,6 +325,35 @@ class ChatClient { return this.activeStartedAt.get(sessionId) ?? null; } + /** + * A session's live background work (#604) — tasks still running, including + * after the turn that launched them has returned. Empty array when idle or + * when this client has not been told about the session. + */ + backgroundWork(sessionId: string): LiveBackgroundTask[] { + return this.backgroundTasks.get(sessionId) ?? []; + } + + /** + * Subscribe to one session's background-work set. Fires immediately with the + * current value, then on every change. The server replays its whole snapshot + * on connect, so a pane that mounts mid-run is populated on the first frame + * rather than after a poll — which is what makes this survive a remount where + * the sub-agent bar's own polling loop previously could not. + */ + onBackgroundWork( + sessionId: string, + cb: (tasks: LiveBackgroundTask[]) => void, + ): () => void { + const wrapped = (sid: string, tasks: LiveBackgroundTask[]): void => { + if (sid === sessionId) cb(tasks); + }; + this.backgroundListeners.add(wrapped); + cb(this.backgroundWork(sessionId)); + this.connect(); + return () => this.backgroundListeners.delete(wrapped); + } + private setActive( sessionId: string, running: boolean, @@ -717,6 +758,21 @@ class ChatClient { return; } + if (msg.type === "chat:background") { + // App-level and LEVEL semantics: replace this session's set outright. + const { sessionId, tasks } = msg.payload as { + sessionId?: string; + tasks?: LiveBackgroundTask[]; + }; + if (sessionId) { + const next = Array.isArray(tasks) ? tasks : []; + if (next.length === 0) this.backgroundTasks.delete(sessionId); + else this.backgroundTasks.set(sessionId, next); + for (const cb of this.backgroundListeners) cb(sessionId, next); + } + return; + } + if (msg.type === "chat:active") { // App-level: update the running-sessions set that drives the sidebar dots, // even for chats with no mounted pane. @@ -727,7 +783,12 @@ class ChatClient { // which would wrongly show it as streaming another chat's turn. for (const sub of this.subs.values()) { if (sub.projectSlug === slug && sub.sessionId === msg.payload.sessionId) { - sub.handlers.onActive?.({ running: msg.payload.running, jobId: msg.payload.jobId }); + sub.handlers.onActive?.({ + running: msg.payload.running, + // #604: fall back to `running` for a server that predates the split. + turnRunning: msg.payload.turnRunning ?? msg.payload.running, + jobId: msg.payload.jobId, + }); } } return; diff --git a/test/bin/claude b/test/bin/claude index 3014a637..af18341c 100755 --- a/test/bin/claude +++ b/test/bin/claude @@ -109,6 +109,12 @@ * pair — without which the card never becomes a running * sub-agent candidate and a UI test passes vacuously — and * closes it with a terminal `end_turn` when the window ends. + * • [[BGTASK]] → the SDK's task-lifecycle system messages (#604) for work that + * OUTLIVES the turn: a background shell and a Monitor go live + * via `background_tasks_changed`, tick `task_progress` for + * PADDOCK_FAKE_BGTASK_MS (default 3000ms) after the terminal + * result, then the shell completes and the level shrinks to + * just the monitor. Drives the running-work bar end to end. * • [[BIGCONTEXT]]→ a long tool-heavy turn whose assistant context snapshot grows * to ~292k, then a terminal `result` whose usage is the * CUMULATIVE per-turn total (~828k). The live context meter must @@ -1025,6 +1031,80 @@ function main() { ? "error_during_execution" : null; + // [[BGTASK]] emission (test-only, #604) — the SDK's task-lifecycle system messages + // for work that OUTLIVES the turn: a background shell and a Monitor. + // + // These are stream-only control messages in the real SDK, but herdctl's CLI + // runtime yields every parsed transcript line unfiltered, so writing them here + // exercises exactly the path the SDK runtime drives — which is what lets the + // running-work bar be tested without spending real API credit. + // + // Emitted BEFORE the terminal result, because the result line ENDS the watcher + // loop and anything after it is dropped on this (CLI-runtime) path. That costs + // nothing for what #604 is about: the monitor is deliberately still live when + // the turn ends, so the interesting assertion — the session is NOT idle once + // the turn returns — is exactly what a test can make. + // + // The level signal (`background_tasks_changed`) is written first and last, with + // edges in between, so a test can assert REPLACE semantics rather than edge + // pairing: the shell leaves via a terminal edge AND a shrunken level. + if ((prompt || "").includes("[[BGTASK]]")) { + const windowMs = Number(process.env.PADDOCK_FAKE_BGTASK_MS || "3000"); + const shellId = `bg_${crypto.randomBytes(4).toString("hex")}`; + const monId = `mon_${crypto.randomBytes(4).toString("hex")}`; + const sys = (obj) => + append({ + ...obj, + type: "system", + uuid: crypto.randomUUID(), + session_id: sessionId, + sessionId, + timestamp: nowIso(), + cwd, + }); + + // Level: two tasks are now live. + sys({ + subtype: "background_tasks_changed", + tasks: [ + { task_id: shellId, task_type: "shell", description: "wait for CI" }, + { task_id: monId, task_type: "monitor", description: "errors in deploy.log" }, + ], + }); + // Edges: the detail the level payload omits. + sys({ subtype: "task_started", task_id: shellId, description: "wait for CI" }); + sys({ subtype: "task_started", task_id: monId, description: "errors in deploy.log" }); + + // Hold them live so a client can observe the bar, ticking progress on the shell. + const deadline = Date.now() + windowMs; + let steps = 0; + while (Date.now() < deadline) { + sleep(500); + steps += 1; + sys({ + subtype: "task_progress", + task_id: shellId, + description: "wait for CI", + last_tool_name: `poll #${steps}`, + usage: { total_tokens: 0, tool_uses: steps, duration_ms: steps * 500 }, + }); + } + + // The shell finishes; the monitor keeps running. A terminal edge PLUS the new + // level — a client that honours either one alone still converges. + sys({ + subtype: "task_notification", + task_id: shellId, + status: "completed", + output_file: "/tmp/fake.output", + summary: 'Background command "wait for CI" completed (exit code 0)', + }); + sys({ + subtype: "background_tasks_changed", + tasks: [{ task_id: monId, task_type: "monitor", description: "errors in deploy.log" }], + }); + } + // 3) result line — ends the watcher loop. A plain turn marks it successful; the // #380 directives flip the subtype to an error while the reply above still stands. append({ diff --git a/test/e2e/journey-subagents.spec.ts b/test/e2e/journey-subagents.spec.ts index 1c241cb2..085a4b02 100644 --- a/test/e2e/journey-subagents.spec.ts +++ b/test/e2e/journey-subagents.spec.ts @@ -55,7 +55,7 @@ async function latestStep(page: Page): Promise { /** The bar, the card's running chip, and a step list that keeps moving. */ async function expectStillRunning(page: Page, where: string): Promise { - await expect(page.getByTestId("running-subagents"), `bar (${where})`).toBeVisible({ + await expect(page.getByTestId("running-work"), `bar (${where})`).toBeVisible({ timeout: 15_000, }); await expect(page.getByTitle("Sub-agent is running").first(), `card (${where})`).toBeVisible({ @@ -121,7 +121,7 @@ test("a running sub-agent survives navigating away and back, and a reload (#725) // a NEW chat, which would drop this session entirely and prove nothing. await page.getByRole("button", { name: "Files", exact: true }).click(); await expect(page).toHaveURL(/\/files$/); - await expect(page.getByTestId("running-subagents")).toHaveCount(0); + await expect(page.getByTestId("running-work")).toHaveCount(0); await page.goBack(); await expect(page).toHaveURL(chatUrl); await expectStillRunning(page, "after navigating back");