Skip to content

Commit 4d6a6cb

Browse files
Merge live sub-agent progress rows
2 parents c423b91 + 58e3b15 commit 4d6a6cb

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
@@ -299,6 +306,9 @@ export async function mountProductHost(
299306
if (disposed) return
300307
try {
301308
paintChrome(shell)
309+
if (config.subAgentSessions !== undefined) {
310+
bridge.syncAgentProgress(config.subAgentSessions())
311+
}
302312
} catch {
303313
clearInterval(stickyPoll)
304314
}

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

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

src/tui-opentui/runner-host.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,14 @@ export async function mountRunnerHost(deps: RunnerHostDeps): Promise<RunnerHost>
239239
onCommand: deps.onCommand,
240240
chrome: chromeFromSession(deps.chrome()),
241241
onObserveRequest: () => observeSessionFromSubAgents(deps.subAgentSessions()),
242+
subAgentSessions: () =>
243+
deps.subAgentSessions().map((s) => ({
244+
id: s.id,
245+
status: s.status,
246+
currentToolName: s.currentToolName,
247+
startedAt: s.startedAt,
248+
lastActivityAt: s.lastActivityAt,
249+
})),
242250
...(deps.createRenderer !== undefined ? { createRenderer: deps.createRenderer } : {}),
243251
...(deps.telemetryNotice !== undefined
244252
? { 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

@@ -600,3 +601,113 @@ describe("parallel sub-agent dispatch on the live session bridge", () => {
600601
)
601602
})
602603
})
604+
605+
describe("syncAgentProgress", () => {
606+
function taskSession(over: Partial<TaskProgressSession>): TaskProgressSession {
607+
return {
608+
id: "task-1",
609+
status: "running",
610+
currentToolName: "grep",
611+
startedAt: 0,
612+
lastActivityAt: 0,
613+
...over,
614+
}
615+
}
616+
617+
test("updates the dispatch row in place without appending or removing rows", async () => {
618+
await withTestRenderer(
619+
async (h) => {
620+
const shell = createAppShell(h.renderer, {
621+
terminal: { columns: 80, rows: 24 },
622+
wireKeys: false,
623+
run: "busy",
624+
})
625+
// Padding rows ahead of the dispatch: proves churn stays bounded by
626+
// outstanding task calls, not by transcript length.
627+
for (let i = 0; i < 40; i++) {
628+
appendStreamRow(shell, { role: "assistant", text: `filler ${i}` })
629+
}
630+
let nowMs = 0
631+
const bridge = attachSessionBridge(shell, createRecordingPort(), {
632+
now: () => nowMs,
633+
})
634+
try {
635+
bridge.handle({
636+
type: "inference.tool_call.end",
637+
data: {
638+
name: "task",
639+
callId: "task-1",
640+
arguments: { description: "Review permission gate" },
641+
},
642+
})
643+
await h.renderOnce()
644+
const rowCountBefore = streamRowCount(shell)
645+
const removeSpy = spyOn(shell.transcript, "remove")
646+
647+
nowMs = 42_000
648+
bridge.syncAgentProgress([taskSession({ lastActivityAt: nowMs })])
649+
bridge.syncAgentProgress([
650+
taskSession({ currentToolName: "grep", lastActivityAt: nowMs }),
651+
])
652+
653+
expect(streamRowCount(shell)).toBe(rowCountBefore)
654+
// One rewrite per changed tick, never proportional to the 40 padding rows.
655+
expect(removeSpy.mock.calls.length).toBeLessThanOrEqual(2)
656+
657+
const row = shell.streamLog[rowCountBefore - 1]!
658+
expect(row.pending).toBe(true)
659+
expect(row.agentWorking).toBe(true)
660+
expect(row.stat).toContain("grep")
661+
662+
nowMs = 72_000
663+
bridge.syncAgentProgress([
664+
taskSession({ currentToolName: "grep", lastActivityAt: 42_000 }),
665+
])
666+
const stalledRow = shell.streamLog[rowCountBefore - 1]!
667+
expect(stalledRow.agentWorking).toBe(false)
668+
669+
removeSpy.mockRestore()
670+
} finally {
671+
bridge.dispose()
672+
shell.dispose()
673+
}
674+
},
675+
{ width: 80, height: 24 },
676+
)
677+
})
678+
679+
test("a finished session's row is left to the tool-result path", async () => {
680+
await withTestRenderer(
681+
async (h) => {
682+
const shell = createAppShell(h.renderer, {
683+
terminal: { columns: 80, rows: 24 },
684+
wireKeys: false,
685+
run: "busy",
686+
})
687+
const bridge = attachSessionBridge(shell, createRecordingPort())
688+
try {
689+
bridge.handle({
690+
type: "inference.tool_call.end",
691+
data: {
692+
name: "task",
693+
callId: "task-1",
694+
arguments: { description: "Review mouse/paste" },
695+
},
696+
})
697+
bridge.handle({
698+
type: "tool.done",
699+
data: { result: { callId: "task-1", name: "task", content: "done", isError: false } },
700+
})
701+
const index = shell.streamLog.length - 1
702+
bridge.syncAgentProgress([taskSession({ status: "done" })])
703+
expect(shell.streamLog[index]!.pending).not.toBe(true)
704+
expect(shell.streamLog[index]!.agentWorking).toBeUndefined()
705+
} finally {
706+
bridge.dispose()
707+
shell.dispose()
708+
}
709+
},
710+
{ width: 80, height: 24 },
711+
)
712+
})
713+
})

0 commit comments

Comments
 (0)