Skip to content

Commit 3769dd9

Browse files
committed
Show live progress on a dispatched sub-agent's transcript row
A task call's row painted a bare dot until its result landed, so a parent that dispatched several sub-agents at once had no way to tell a worker mid-turn from one that had gone silent — every unfinished dispatch looked identical. The sub-agent session store already tracks each worker's current tool and now its last activity clock; the runtime bridge polls it on the same cadence it already ticks the chrome and rewrites each outstanding task call's row in place with elapsed time, current tool, and a mark that tells working apart from stalled. The row itself never grows an extra line and the rewrite touches only that one row, regardless of how many other rows sit above it. Resolving a dispatch drops its live elapsed-time trailer for the result's own answer rather than leaving the last progress reading stuck on a finished row.
1 parent cf3bb84 commit 3769dd9

12 files changed

Lines changed: 399 additions & 7 deletions

src/subagent/session-store.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ export type SubAgentSession = {
2727
currentToolName: string | null;
2828
entries: SubAgentTranscriptEntry[];
2929
startedAt: number;
30+
// Clock of the last event this session recorded (a stream token, a tool
31+
// start/end, a status change). Distinct from startedAt so the strip can
32+
// tell a worker mid-turn from one that has gone silent.
33+
lastActivityAt: number;
3034
finishedAt?: number;
3135
report?: string;
3236
error?: string;
@@ -159,6 +163,7 @@ export function createSubAgentSessionStore(
159163
const markCancelled = (session: SubAgentSession, reason: string): void => {
160164
session.status = "cancelled";
161165
session.finishedAt = now();
166+
session.lastActivityAt = now();
162167
session.currentToolName = null;
163168
session.error = reason;
164169
pushEntry(session, {
@@ -223,6 +228,7 @@ export function createSubAgentSessionStore(
223228
const session = sessions.get(id);
224229
if (session === undefined) return;
225230
fn(session);
231+
session.lastActivityAt = now();
226232
bumpRevision(id);
227233
notify();
228234
};
@@ -264,6 +270,7 @@ export function createSubAgentSessionStore(
264270
currentToolName: null,
265271
entries: [],
266272
startedAt: now(),
273+
lastActivityAt: now(),
267274
...(input.parentSessionId !== undefined ? { parentSessionId: input.parentSessionId } : {}),
268275
};
269276
sessions.set(id, session);
@@ -470,6 +477,7 @@ function cloneSession(session: SubAgentSession): SubAgentSession {
470477
currentToolName: session.currentToolName,
471478
entries: session.entries.map(cloneEntry),
472479
startedAt: session.startedAt,
480+
lastActivityAt: session.lastActivityAt,
473481
...(session.finishedAt !== undefined ? { finishedAt: session.finishedAt } : {}),
474482
...(session.report !== undefined ? { report: session.report } : {}),
475483
...(session.error !== undefined ? { error: session.error } : {}),
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { describe, expect, test } from "bun:test"
2+
import { agentProgress, clockLabel } from "./agent-progress"
3+
4+
describe("clockLabel", () => {
5+
test("formats sub-minute and multi-minute elapsed as m:ss", () => {
6+
expect(clockLabel(0)).toBe("0:00")
7+
expect(clockLabel(42_000)).toBe("0:42")
8+
expect(clockLabel(90_000)).toBe("1:30")
9+
})
10+
})
11+
12+
describe("agentProgress", () => {
13+
const base = {
14+
status: "running" as const,
15+
currentToolName: "grep",
16+
startedAt: 0,
17+
lastActivityAt: 0,
18+
}
19+
20+
test("terminal sessions have no pending-row progress", () => {
21+
expect(agentProgress({ ...base, status: "done" }, 1000)).toBeNull()
22+
expect(agentProgress({ ...base, status: "failed" }, 1000)).toBeNull()
23+
expect(agentProgress({ ...base, status: "cancelled" }, 1000)).toBeNull()
24+
})
25+
26+
test("a running session reports elapsed time and its current tool", () => {
27+
const progress = agentProgress({ ...base, lastActivityAt: 42_000 }, 42_000)
28+
expect(progress).toEqual({ stat: "0:42 · grep", working: true, stalled: false })
29+
})
30+
31+
test("a running session with no current tool reports elapsed time alone", () => {
32+
const progress = agentProgress(
33+
{ ...base, currentToolName: null, lastActivityAt: 42_000 },
34+
42_000,
35+
)
36+
expect(progress).toEqual({ stat: "0:42", working: true, stalled: false })
37+
})
38+
39+
test("silence past the stall window flips working to stalled", () => {
40+
const progress = agentProgress({ ...base, lastActivityAt: 0 }, 31_000, 30_000)
41+
expect(progress).toEqual({ stat: "0:31 · grep", working: false, stalled: true })
42+
})
43+
44+
test("recent activity keeps a long-running session marked working", () => {
45+
const progress = agentProgress({ ...base, lastActivityAt: 100_000 }, 100_500, 30_000)
46+
expect(progress?.working).toBe(true)
47+
expect(progress?.stalled).toBe(false)
48+
})
49+
})

src/tui-opentui/agent-progress.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/**
2+
* Live progress for a dispatched sub-agent's pending row in the transcript.
3+
*
4+
* A "task" tool call renders as one row for its whole lifetime (see
5+
* `runtime-bridge.ts`'s `syncAgentProgress`). While the call is outstanding
6+
* this fills in what a bare pending mark cannot say: how long the worker has
7+
* been running, what it is doing right now, and whether it has gone quiet
8+
* long enough to look hung rather than merely slow.
9+
*/
10+
11+
/** Minimal session shape this module reads — avoids a hard dep on the store. */
12+
export type AgentProgressSession = {
13+
readonly status: "running" | "done" | "failed" | "cancelled";
14+
readonly currentToolName: string | null;
15+
readonly startedAt: number;
16+
readonly lastActivityAt: number;
17+
};
18+
19+
export type AgentProgress = {
20+
/** Dim trailer painted after the row's subject, e.g. "0:42 · grep". */
21+
readonly stat: string;
22+
/** True while the worker has reported activity within the stall window. */
23+
readonly working: boolean;
24+
/** True once silence has run longer than the stall window. */
25+
readonly stalled: boolean;
26+
};
27+
28+
/** Silence after which a running worker reads as hung rather than thinking. */
29+
export const DEFAULT_STALL_MS = 30_000;
30+
31+
/** "m:ss" — compact enough to sit in a row's dim trailer alongside a tool name. */
32+
export function clockLabel(ms: number): string {
33+
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
34+
const minutes = Math.floor(totalSeconds / 60);
35+
const seconds = totalSeconds % 60;
36+
return `${minutes}:${String(seconds).padStart(2, "0")}`;
37+
}
38+
39+
/**
40+
* Progress for a running session's pending row, or null once it has finished —
41+
* a terminal session resolves its row through the tool-result path instead.
42+
*/
43+
export function agentProgress(
44+
session: AgentProgressSession,
45+
nowMs: number,
46+
stallMs: number = DEFAULT_STALL_MS,
47+
): AgentProgress | null {
48+
if (session.status !== "running") return null;
49+
const elapsed = clockLabel(nowMs - session.startedAt);
50+
const tool = session.currentToolName;
51+
const stalled = nowMs - session.lastActivityAt >= stallMs;
52+
return {
53+
stat: tool !== null && tool.length > 0 ? `${elapsed} · ${tool}` : elapsed,
54+
working: !stalled,
55+
stalled,
56+
};
57+
}

src/tui-opentui/product-host.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { checkWidthContract, widthContractNotice } from "./width-contract.js"
1313
import {
1414
attachSessionBridge,
1515
type SessionBridge,
16+
type TaskProgressSession,
1617
type TurnMonitorOptions,
1718
} from "./runtime-bridge.js"
1819
import { openModelPickerOverlay } from "./overlays.js"
@@ -105,6 +106,12 @@ export type ProductHostConfig = {
105106
* this to view real subagent sessions.
106107
*/
107108
readonly onObserveRequest?: PaletteOnObserveRequest
109+
/**
110+
* Live sub-agent sessions read on the chrome poll cadence to refresh
111+
* outstanding `task` rows with elapsed time, current tool, and stall state.
112+
* Omitted hosts (tests, the demo shell) simply paint bare pending rows.
113+
*/
114+
readonly subAgentSessions?: () => readonly TaskProgressSession[]
108115
/**
109116
* Renderer factory override for headless mounting in tests.
110117
* Defaults to the real `createCliRenderer`; tests inject a
@@ -294,6 +301,9 @@ export async function mountProductHost(
294301
if (disposed) return
295302
try {
296303
paintChrome(shell)
304+
if (config.subAgentSessions !== undefined) {
305+
bridge.syncAgentProgress(config.subAgentSessions())
306+
}
297307
} catch {
298308
clearInterval(stickyPoll)
299309
}

src/tui-opentui/runner-host.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ function session(over: Partial<SubAgentSession>): SubAgentSession {
4848
currentToolName: null,
4949
entries: [],
5050
startedAt: 0,
51+
lastActivityAt: 0,
5152
...over,
5253
}
5354
}

src/tui-opentui/runner-host.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,14 @@ export async function mountRunnerHost(deps: RunnerHostDeps): Promise<RunnerHost>
237237
onCommand: deps.onCommand,
238238
chrome: chromeFromSession(deps.chrome()),
239239
onObserveRequest: () => observeSessionFromSubAgents(deps.subAgentSessions()),
240+
subAgentSessions: () =>
241+
deps.subAgentSessions().map((s) => ({
242+
id: s.id,
243+
status: s.status,
244+
currentToolName: s.currentToolName,
245+
startedAt: s.startedAt,
246+
lastActivityAt: s.lastActivityAt,
247+
})),
240248
...(deps.createRenderer !== undefined ? { createRenderer: deps.createRenderer } : {}),
241249
...(deps.telemetryNotice !== undefined
242250
? { telemetryNotice: deps.telemetryNotice }

src/tui-opentui/runtime-bridge.test.ts

Lines changed: 113 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
import { describe, expect, test } from "bun:test"
1+
import { describe, expect, spyOn, test } from "bun:test"
22
import {
33
FIXTURE_BUSY_SESSION,
44
attachSessionBridge,
55
createRecordingPort,
66
mapReactorLike,
7+
type TaskProgressSession,
78
} from "./runtime-bridge"
8-
import { createAppShell } from "./shell"
9+
import { appendStreamRow, createAppShell, streamRowCount } from "./shell"
910
import { withTestRenderer } from "./harness"
1011
import { badgeCount } from "./session-queue"
1112

@@ -432,3 +433,113 @@ describe("committed inference retry", () => {
432433
)
433434
})
434435
})
436+
437+
describe("syncAgentProgress", () => {
438+
function taskSession(over: Partial<TaskProgressSession>): TaskProgressSession {
439+
return {
440+
id: "task-1",
441+
status: "running",
442+
currentToolName: "grep",
443+
startedAt: 0,
444+
lastActivityAt: 0,
445+
...over,
446+
}
447+
}
448+
449+
test("updates the dispatch row in place without appending or removing rows", async () => {
450+
await withTestRenderer(
451+
async (h) => {
452+
const shell = createAppShell(h.renderer, {
453+
terminal: { columns: 80, rows: 24 },
454+
wireKeys: false,
455+
run: "busy",
456+
})
457+
// Padding rows ahead of the dispatch: proves churn stays bounded by
458+
// outstanding task calls, not by transcript length.
459+
for (let i = 0; i < 40; i++) {
460+
appendStreamRow(shell, { role: "assistant", text: `filler ${i}` })
461+
}
462+
let nowMs = 0
463+
const bridge = attachSessionBridge(shell, createRecordingPort(), {
464+
now: () => nowMs,
465+
})
466+
try {
467+
bridge.handle({
468+
type: "inference.tool_call.end",
469+
data: {
470+
name: "task",
471+
callId: "task-1",
472+
arguments: { description: "Review permission gate" },
473+
},
474+
})
475+
await h.renderOnce()
476+
const rowCountBefore = streamRowCount(shell)
477+
const removeSpy = spyOn(shell.transcript, "remove")
478+
479+
nowMs = 42_000
480+
bridge.syncAgentProgress([taskSession({ lastActivityAt: nowMs })])
481+
bridge.syncAgentProgress([
482+
taskSession({ currentToolName: "grep", lastActivityAt: nowMs }),
483+
])
484+
485+
expect(streamRowCount(shell)).toBe(rowCountBefore)
486+
// One rewrite per changed tick, never proportional to the 40 padding rows.
487+
expect(removeSpy.mock.calls.length).toBeLessThanOrEqual(2)
488+
489+
const row = shell.streamLog[rowCountBefore - 1]!
490+
expect(row.pending).toBe(true)
491+
expect(row.agentWorking).toBe(true)
492+
expect(row.stat).toContain("grep")
493+
494+
nowMs = 72_000
495+
bridge.syncAgentProgress([
496+
taskSession({ currentToolName: "grep", lastActivityAt: 42_000 }),
497+
])
498+
const stalledRow = shell.streamLog[rowCountBefore - 1]!
499+
expect(stalledRow.agentWorking).toBe(false)
500+
501+
removeSpy.mockRestore()
502+
} finally {
503+
bridge.dispose()
504+
shell.dispose()
505+
}
506+
},
507+
{ width: 80, height: 24 },
508+
)
509+
})
510+
511+
test("a finished session's row is left to the tool-result path", async () => {
512+
await withTestRenderer(
513+
async (h) => {
514+
const shell = createAppShell(h.renderer, {
515+
terminal: { columns: 80, rows: 24 },
516+
wireKeys: false,
517+
run: "busy",
518+
})
519+
const bridge = attachSessionBridge(shell, createRecordingPort())
520+
try {
521+
bridge.handle({
522+
type: "inference.tool_call.end",
523+
data: {
524+
name: "task",
525+
callId: "task-1",
526+
arguments: { description: "Review mouse/paste" },
527+
},
528+
})
529+
bridge.handle({
530+
type: "tool.done",
531+
data: { result: { callId: "task-1", name: "task", content: "done", isError: false } },
532+
})
533+
const index = shell.streamLog.length - 1
534+
bridge.syncAgentProgress([taskSession({ status: "done" })])
535+
expect(shell.streamLog[index]!.pending).not.toBe(true)
536+
expect(shell.streamLog[index]!.agentWorking).toBeUndefined()
537+
} finally {
538+
bridge.dispose()
539+
shell.dispose()
540+
}
541+
},
542+
{ width: 80, height: 24 },
543+
)
544+
})
545+
})

0 commit comments

Comments
 (0)