Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/subagent/session-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ export type SubAgentSession = {
currentToolName: string | null;
entries: SubAgentTranscriptEntry[];
startedAt: number;
// Clock of the last event this session recorded (a stream token, a tool
// start/end, a status change). Distinct from startedAt so the strip can
// tell a worker mid-turn from one that has gone silent.
lastActivityAt: number;
finishedAt?: number;
report?: string;
error?: string;
Expand Down Expand Up @@ -159,6 +163,7 @@ export function createSubAgentSessionStore(
const markCancelled = (session: SubAgentSession, reason: string): void => {
session.status = "cancelled";
session.finishedAt = now();
session.lastActivityAt = now();
session.currentToolName = null;
session.error = reason;
pushEntry(session, {
Expand Down Expand Up @@ -223,6 +228,7 @@ export function createSubAgentSessionStore(
const session = sessions.get(id);
if (session === undefined) return;
fn(session);
session.lastActivityAt = now();
bumpRevision(id);
notify();
};
Expand Down Expand Up @@ -264,6 +270,7 @@ export function createSubAgentSessionStore(
currentToolName: null,
entries: [],
startedAt: now(),
lastActivityAt: now(),
...(input.parentSessionId !== undefined ? { parentSessionId: input.parentSessionId } : {}),
};
sessions.set(id, session);
Expand Down Expand Up @@ -470,6 +477,7 @@ function cloneSession(session: SubAgentSession): SubAgentSession {
currentToolName: session.currentToolName,
entries: session.entries.map(cloneEntry),
startedAt: session.startedAt,
lastActivityAt: session.lastActivityAt,
...(session.finishedAt !== undefined ? { finishedAt: session.finishedAt } : {}),
...(session.report !== undefined ? { report: session.report } : {}),
...(session.error !== undefined ? { error: session.error } : {}),
Expand Down
49 changes: 49 additions & 0 deletions src/tui-opentui/agent-progress.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { describe, expect, test } from "bun:test"
import { agentProgress, clockLabel } from "./agent-progress"

describe("clockLabel", () => {
test("formats sub-minute and multi-minute elapsed as m:ss", () => {
expect(clockLabel(0)).toBe("0:00")
expect(clockLabel(42_000)).toBe("0:42")
expect(clockLabel(90_000)).toBe("1:30")
})
})

describe("agentProgress", () => {
const base = {
status: "running" as const,
currentToolName: "grep",
startedAt: 0,
lastActivityAt: 0,
}

test("terminal sessions have no pending-row progress", () => {
expect(agentProgress({ ...base, status: "done" }, 1000)).toBeNull()
expect(agentProgress({ ...base, status: "failed" }, 1000)).toBeNull()
expect(agentProgress({ ...base, status: "cancelled" }, 1000)).toBeNull()
})

test("a running session reports elapsed time and its current tool", () => {
const progress = agentProgress({ ...base, lastActivityAt: 42_000 }, 42_000)
expect(progress).toEqual({ stat: "0:42 · grep", working: true, stalled: false })
})

test("a running session with no current tool reports elapsed time alone", () => {
const progress = agentProgress(
{ ...base, currentToolName: null, lastActivityAt: 42_000 },
42_000,
)
expect(progress).toEqual({ stat: "0:42", working: true, stalled: false })
})

test("silence past the stall window flips working to stalled", () => {
const progress = agentProgress({ ...base, lastActivityAt: 0 }, 31_000, 30_000)
expect(progress).toEqual({ stat: "0:31 · grep", working: false, stalled: true })
})

test("recent activity keeps a long-running session marked working", () => {
const progress = agentProgress({ ...base, lastActivityAt: 100_000 }, 100_500, 30_000)
expect(progress?.working).toBe(true)
expect(progress?.stalled).toBe(false)
})
})
57 changes: 57 additions & 0 deletions src/tui-opentui/agent-progress.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
* Live progress for a dispatched sub-agent's pending row in the transcript.
*
* A "task" tool call renders as one row for its whole lifetime (see
* `runtime-bridge.ts`'s `syncAgentProgress`). While the call is outstanding
* this fills in what a bare pending mark cannot say: how long the worker has
* been running, what it is doing right now, and whether it has gone quiet
* long enough to look hung rather than merely slow.
*/

/** Minimal session shape this module reads — avoids a hard dep on the store. */
export type AgentProgressSession = {
readonly status: "running" | "done" | "failed" | "cancelled";
readonly currentToolName: string | null;
readonly startedAt: number;
readonly lastActivityAt: number;
};

export type AgentProgress = {
/** Dim trailer painted after the row's subject, e.g. "0:42 · grep". */
readonly stat: string;
/** True while the worker has reported activity within the stall window. */
readonly working: boolean;
/** True once silence has run longer than the stall window. */
readonly stalled: boolean;
};

/** Silence after which a running worker reads as hung rather than thinking. */
export const DEFAULT_STALL_MS = 30_000;

/** "m:ss" — compact enough to sit in a row's dim trailer alongside a tool name. */
export function clockLabel(ms: number): string {
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${String(seconds).padStart(2, "0")}`;
}

/**
* Progress for a running session's pending row, or null once it has finished —
* a terminal session resolves its row through the tool-result path instead.
*/
export function agentProgress(
session: AgentProgressSession,
nowMs: number,
stallMs: number = DEFAULT_STALL_MS,
): AgentProgress | null {
if (session.status !== "running") return null;
const elapsed = clockLabel(nowMs - session.startedAt);
const tool = session.currentToolName;
const stalled = nowMs - session.lastActivityAt >= stallMs;
return {
stat: tool !== null && tool.length > 0 ? `${elapsed} · ${tool}` : elapsed,
working: !stalled,
stalled,
};
}
10 changes: 10 additions & 0 deletions src/tui-opentui/product-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { checkWidthContract, widthContractNotice } from "./width-contract.js"
import {
attachSessionBridge,
type SessionBridge,
type TaskProgressSession,
type TurnMonitorOptions,
} from "./runtime-bridge.js"
import { openModelPickerOverlay } from "./overlays.js"
Expand Down Expand Up @@ -105,6 +106,12 @@ export type ProductHostConfig = {
* this to view real subagent sessions.
*/
readonly onObserveRequest?: PaletteOnObserveRequest
/**
* Live sub-agent sessions read on the chrome poll cadence to refresh
* outstanding `task` rows with elapsed time, current tool, and stall state.
* Omitted hosts (tests, the demo shell) simply paint bare pending rows.
*/
readonly subAgentSessions?: () => readonly TaskProgressSession[]
/**
* Renderer factory override for headless mounting in tests.
* Defaults to the real `createCliRenderer`; tests inject a
Expand Down Expand Up @@ -299,6 +306,9 @@ export async function mountProductHost(
if (disposed) return
try {
paintChrome(shell)
if (config.subAgentSessions !== undefined) {
bridge.syncAgentProgress(config.subAgentSessions())
}
} catch {
clearInterval(stickyPoll)
}
Expand Down
1 change: 1 addition & 0 deletions src/tui-opentui/runner-host.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ function session(over: Partial<SubAgentSession>): SubAgentSession {
currentToolName: null,
entries: [],
startedAt: 0,
lastActivityAt: 0,
...over,
}
}
Expand Down
8 changes: 8 additions & 0 deletions src/tui-opentui/runner-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,14 @@ export async function mountRunnerHost(deps: RunnerHostDeps): Promise<RunnerHost>
onCommand: deps.onCommand,
chrome: chromeFromSession(deps.chrome()),
onObserveRequest: () => observeSessionFromSubAgents(deps.subAgentSessions()),
subAgentSessions: () =>
deps.subAgentSessions().map((s) => ({
id: s.id,
status: s.status,
currentToolName: s.currentToolName,
startedAt: s.startedAt,
lastActivityAt: s.lastActivityAt,
})),
...(deps.createRenderer !== undefined ? { createRenderer: deps.createRenderer } : {}),
...(deps.telemetryNotice !== undefined
? { telemetryNotice: deps.telemetryNotice }
Expand Down
115 changes: 113 additions & 2 deletions src/tui-opentui/runtime-bridge.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { describe, expect, test } from "bun:test"
import { describe, expect, spyOn, test } from "bun:test"
import {
FIXTURE_BUSY_SESSION,
attachSessionBridge,
createRecordingPort,
mapReactorLike,
type TaskProgressSession,
} from "./runtime-bridge"
import { createAppShell } from "./shell"
import { appendStreamRow, createAppShell, streamRowCount } from "./shell"
import { withTestRenderer } from "./harness"
import { badgeCount } from "./session-queue"

Expand Down Expand Up @@ -600,3 +601,113 @@ describe("parallel sub-agent dispatch on the live session bridge", () => {
)
})
})

describe("syncAgentProgress", () => {
function taskSession(over: Partial<TaskProgressSession>): TaskProgressSession {
return {
id: "task-1",
status: "running",
currentToolName: "grep",
startedAt: 0,
lastActivityAt: 0,
...over,
}
}

test("updates the dispatch row in place without appending or removing rows", async () => {
await withTestRenderer(
async (h) => {
const shell = createAppShell(h.renderer, {
terminal: { columns: 80, rows: 24 },
wireKeys: false,
run: "busy",
})
// Padding rows ahead of the dispatch: proves churn stays bounded by
// outstanding task calls, not by transcript length.
for (let i = 0; i < 40; i++) {
appendStreamRow(shell, { role: "assistant", text: `filler ${i}` })
}
let nowMs = 0
const bridge = attachSessionBridge(shell, createRecordingPort(), {
now: () => nowMs,
})
try {
bridge.handle({
type: "inference.tool_call.end",
data: {
name: "task",
callId: "task-1",
arguments: { description: "Review permission gate" },
},
})
await h.renderOnce()
const rowCountBefore = streamRowCount(shell)
const removeSpy = spyOn(shell.transcript, "remove")

nowMs = 42_000
bridge.syncAgentProgress([taskSession({ lastActivityAt: nowMs })])
bridge.syncAgentProgress([
taskSession({ currentToolName: "grep", lastActivityAt: nowMs }),
])

expect(streamRowCount(shell)).toBe(rowCountBefore)
// One rewrite per changed tick, never proportional to the 40 padding rows.
expect(removeSpy.mock.calls.length).toBeLessThanOrEqual(2)

const row = shell.streamLog[rowCountBefore - 1]!
expect(row.pending).toBe(true)
expect(row.agentWorking).toBe(true)
expect(row.stat).toContain("grep")

nowMs = 72_000
bridge.syncAgentProgress([
taskSession({ currentToolName: "grep", lastActivityAt: 42_000 }),
])
const stalledRow = shell.streamLog[rowCountBefore - 1]!
expect(stalledRow.agentWorking).toBe(false)

removeSpy.mockRestore()
} finally {
bridge.dispose()
shell.dispose()
}
},
{ width: 80, height: 24 },
)
})

test("a finished session's row is left to the tool-result path", async () => {
await withTestRenderer(
async (h) => {
const shell = createAppShell(h.renderer, {
terminal: { columns: 80, rows: 24 },
wireKeys: false,
run: "busy",
})
const bridge = attachSessionBridge(shell, createRecordingPort())
try {
bridge.handle({
type: "inference.tool_call.end",
data: {
name: "task",
callId: "task-1",
arguments: { description: "Review mouse/paste" },
},
})
bridge.handle({
type: "tool.done",
data: { result: { callId: "task-1", name: "task", content: "done", isError: false } },
})
const index = shell.streamLog.length - 1
bridge.syncAgentProgress([taskSession({ status: "done" })])
expect(shell.streamLog[index]!.pending).not.toBe(true)
expect(shell.streamLog[index]!.agentWorking).toBeUndefined()
} finally {
bridge.dispose()
shell.dispose()
}
},
{ width: 80, height: 24 },
)
})
})
Loading
Loading