diff --git a/src/subagent/fleet-report.test.ts b/src/subagent/fleet-report.test.ts index 3e3605ff3..2be646abc 100644 --- a/src/subagent/fleet-report.test.ts +++ b/src/subagent/fleet-report.test.ts @@ -15,6 +15,7 @@ function lane(overrides: Partial & { id: string }): FleetLane { startedAt: T0, lastActivityAt: T0, currentToolName: null, + currentToolPreview: null, currentToolStartedAt: null, ...overrides, }; diff --git a/src/subagent/fleet-report.ts b/src/subagent/fleet-report.ts index a6f457b17..e95210882 100644 --- a/src/subagent/fleet-report.ts +++ b/src/subagent/fleet-report.ts @@ -23,6 +23,7 @@ export type FleetLane = { readonly startedAt: number; readonly lastActivityAt: number; readonly currentToolName: string | null; + readonly currentToolPreview: string | null; readonly currentToolStartedAt: number | null; readonly report?: string; readonly error?: string; diff --git a/src/subagent/session-store.test.ts b/src/subagent/session-store.test.ts index 3b5356612..d450c7b81 100644 --- a/src/subagent/session-store.test.ts +++ b/src/subagent/session-store.test.ts @@ -120,9 +120,16 @@ describe("outstanding tool clock", () => { store.appendEvent(session.id, { type: "tool.start", seq: 1, - data: { call: { id: "call-1", name: "run_shell", arguments: {} } }, + data: { + call: { + id: "call-1", + name: "run_shell", + arguments: { command: "bun test" }, + }, + }, } as unknown as ReactorEmittedEvent); expect(store.get(session.id)?.currentToolName).toBe("run_shell"); + expect(store.get(session.id)?.currentToolPreview).toBe("bun test"); expect(store.get(session.id)?.currentToolStartedAt).toBe(5_000); clock = 95_000; @@ -132,6 +139,7 @@ describe("outstanding tool clock", () => { data: { result: { callId: "call-1", content: "ok", isError: false } }, } as unknown as ReactorEmittedEvent); expect(store.get(session.id)?.currentToolName).toBeNull(); + expect(store.get(session.id)?.currentToolPreview).toBeNull(); expect(store.get(session.id)?.currentToolStartedAt).toBeNull(); }); @@ -143,6 +151,38 @@ describe("outstanding tool clock", () => { store.complete(session.id, "report"); expect(store.get(session.id)?.currentToolStartedAt).toBeNull(); + expect(store.get(session.id)?.currentToolPreview).toBeNull(); + }); + + // CL-5765: argument streaming must refresh the preview so a partial command + // does not stick on the lane after the rest of the args arrive. + test("streaming arguments refresh the lane preview from the same payload the transcript holds", () => { + const store = createSubAgentSessionStore(); + const session = store.start({ description: "d", agentId: "a", brief: "b" }); + + store.appendEvent(session.id, { + type: "inference.tool_call.start", + seq: 1, + data: { name: "run_shell", callId: "call-1" }, + } as unknown as ReactorEmittedEvent); + store.appendEvent(session.id, { + type: "inference.tool_call.delta", + seq: 2, + data: { callId: "call-1", argumentFragment: '{"command":"bun te' }, + } as unknown as ReactorEmittedEvent); + // Incomplete JSON — no preview yet. + expect(store.get(session.id)?.currentToolPreview).toBeNull(); + + store.appendEvent(session.id, { + type: "inference.tool_call.delta", + seq: 3, + data: { callId: "call-1", argumentFragment: 'st"}' }, + } as unknown as ReactorEmittedEvent); + expect(store.get(session.id)?.currentToolPreview).toBe("bun test"); + expect(store.get(session.id)?.entries[0]).toMatchObject({ + kind: "tool", + arguments: '{"command":"bun test"}', + }); }); }); diff --git a/src/subagent/session-store.ts b/src/subagent/session-store.ts index 0ffad525a..a0be3f75b 100644 --- a/src/subagent/session-store.ts +++ b/src/subagent/session-store.ts @@ -5,6 +5,7 @@ // this store is the dedicated child record the enter-session UI reads. import type { ReactorEmittedEvent } from "@intx/inference"; +import { toolCallPreview } from "./tool-preview.js"; export type SubAgentSessionStatus = "running" | "done" | "failed" | "cancelled"; @@ -21,6 +22,13 @@ export type OutstandingToolCall = { callId: string; name: string; startedAt: number; + /** + * Bounded one-line subject of the call (command, path, pattern…), or null + * when the args have nothing useful to show. Derived from the same raw + * arguments the transcript stores so the lane and the body cannot disagree + * about what is running (CL-5765). + */ + preview: string | null; }; export type SubAgentSession = { @@ -30,14 +38,17 @@ export type SubAgentSession = { brief: string; status: SubAgentSessionStatus; toolNames: string[]; - // Name and start clock of the OLDEST outstanding call — the one that - // explains the longest silence. Both are derived from `outstandingTools`; - // never assign them directly. Null when nothing is in flight. + // Name, preview, and start clock of the OLDEST outstanding call — the one + // that explains the longest silence. All three are derived from + // `outstandingTools`; never assign them directly. Null when nothing is in + // flight. // // A worker inside one long tool emits no events for the whole execution, so // silence alone cannot tell "wedged" from "running a ten-minute test suite". - // The start clock is the fact that separates them. + // The start clock is the fact that separates them. The preview is what lets + // an operator tell six shell commands apart on a fleet board. currentToolName: string | null; + currentToolPreview: string | null; currentToolStartedAt: number | null; // Calls the reactor has started and not yet reported a result for. The // reactor runs parallel calls concurrently, so this cannot collapse to one @@ -116,8 +127,9 @@ function defaultCreateId(): string { } /** - * The one place the displayed pair is produced, so a name can never be shown - * beside another call's clock. Called after every change to `outstandingTools`. + * The one place the displayed triple is produced, so a name / preview can never + * be shown beside another call's clock. Called after every change to + * `outstandingTools`. */ function syncCurrentTool(session: SubAgentSession): void { let oldest: OutstandingToolCall | undefined; @@ -125,12 +137,15 @@ function syncCurrentTool(session: SubAgentSession): void { if (oldest === undefined || call.startedAt < oldest.startedAt) oldest = call; } session.currentToolName = oldest?.name ?? null; + session.currentToolPreview = oldest?.preview ?? null; session.currentToolStartedAt = oldest?.startedAt ?? null; } /** * `restartClock` marks the execution boundary: argument streaming already * registered the call, and the figure worth showing is time spent running it. + * `rawArgs`, when known, refreshes the lane preview from the same payload the + * transcript stores. */ function beginToolCall( session: SubAgentSession, @@ -138,17 +153,39 @@ function beginToolCall( name: string, nowMs: number, restartClock = false, + rawArgs?: string, ): void { const existing = session.outstandingTools.find((c) => c.callId === callId); + const preview = + rawArgs !== undefined ? toolCallPreview(name, rawArgs) : (existing?.preview ?? null); if (existing !== undefined) { existing.name = name; if (restartClock) existing.startedAt = nowMs; + if (rawArgs !== undefined) existing.preview = preview; } else { - session.outstandingTools.push({ callId, name, startedAt: nowMs }); + session.outstandingTools.push({ + callId, + name, + startedAt: nowMs, + preview, + }); } syncCurrentTool(session); } +/** Refresh the outstanding call's preview once more of its arguments stream in. */ +function refreshToolPreview( + session: SubAgentSession, + callId: string, + name: string, + rawArgs: string, +): void { + const existing = session.outstandingTools.find((c) => c.callId === callId); + if (existing === undefined) return; + existing.preview = toolCallPreview(name, rawArgs); + syncCurrentTool(session); +} + /** * Retires exactly the call that finished. A result carrying an id we never saw * start retires nothing, rather than silently clearing a live sibling's clock. @@ -338,6 +375,7 @@ export function createSubAgentSessionStore( status: "running", toolNames: [], currentToolName: null, + currentToolPreview: null, currentToolStartedAt: null, outstandingTools: [], entries: [], @@ -398,6 +436,9 @@ export function createSubAgentSessionStore( if (entry?.kind !== "tool") continue; if (callId !== null && entry.callId !== callId) continue; entry.arguments = appendCapped(entry.arguments, fragment, maxEntryChars); + // Preview tracks the same args the transcript holds so the lane + // and the body never disagree about what is running. + refreshToolPreview(session, entry.callId, entry.name, entry.arguments); return; } return; @@ -418,14 +459,21 @@ export function createSubAgentSessionStore( if (args !== null && args.length > 0) entry.arguments = args; // Arguments finished streaming; the call itself is still in // flight, so this renames it rather than restarting its clock. - beginToolCall(session, entry.callId, entry.name, now()); + beginToolCall( + session, + entry.callId, + entry.name, + now(), + false, + entry.arguments, + ); return; } // No matching start — record a complete tool entry. if (name !== null) { const idForEntry = callId ?? `${name}-${session.entries.length}`; if (!session.toolNames.includes(name)) session.toolNames.push(name); - beginToolCall(session, idForEntry, name, now()); + beginToolCall(session, idForEntry, name, now(), false, args ?? ""); pushEntry(session, { kind: "tool", callId: idForEntry, @@ -438,14 +486,22 @@ export function createSubAgentSessionStore( case "tool.start": { // tool.start is the execution-time counterpart of inference.tool_call. // Prefer inference events for the transcript; only fill gaps. - const call = (event as { data?: { call?: { name?: unknown; id?: unknown } } }).data?.call; + const call = (event as { + data?: { call?: { name?: unknown; id?: unknown; arguments?: unknown } }; + }).data?.call; const name = typeof call?.name === "string" ? call.name : null; if (name === null) return; const callId = typeof call?.id === "string" ? call.id : null; + const rawArgs = + call?.arguments !== undefined + ? capText(stringifyUnknown(call.arguments), maxEntryChars) + : undefined; // Without an id there is no way to tell which of several parallel // calls this starts, and guessing would retime the wrong one. The // inference-side start already registered it, so leave it alone. - if (callId !== null) beginToolCall(session, callId, name, now(), true); + if (callId !== null) { + beginToolCall(session, callId, name, now(), true, rawArgs); + } if (!session.toolNames.includes(name)) session.toolNames.push(name); return; } @@ -553,6 +609,7 @@ function cloneSession(session: SubAgentSession): SubAgentSession { status: session.status, toolNames: [...session.toolNames], currentToolName: session.currentToolName, + currentToolPreview: session.currentToolPreview, currentToolStartedAt: session.currentToolStartedAt, outstandingTools: session.outstandingTools.map((c) => ({ ...c })), entries: session.entries.map(cloneEntry), diff --git a/src/subagent/tool-preview.test.ts b/src/subagent/tool-preview.test.ts new file mode 100644 index 000000000..77c764525 --- /dev/null +++ b/src/subagent/tool-preview.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from "bun:test"; +import { TOOL_PREVIEW_MAX, toolCallPreview } from "./tool-preview"; + +describe("toolCallPreview", () => { + test("a shell call's subject is the command, not the tool name", () => { + expect( + toolCallPreview("run_shell", JSON.stringify({ command: "bun test ./src" })), + ).toBe("bun test ./src"); + }); + + test("a file tool's subject is the path", () => { + expect( + toolCallPreview("read_file", JSON.stringify({ path: "src/subagent/session-store.ts" })), + ).toBe("src/subagent/session-store.ts"); + }); + + test("grep shows the pattern", () => { + expect( + toolCallPreview("grep", JSON.stringify({ pattern: "currentToolPreview", path: "src" })), + ).toBe("currentToolPreview"); + }); + + test("task prefers description over prompt", () => { + expect( + toolCallPreview( + "task", + JSON.stringify({ + description: "map callers", + prompt: "Find every call site of leaveObserve.", + }), + ), + ).toBe("map callers"); + }); + + test("empty or unknown args degrade to null so the lane falls back to the tool name", () => { + expect(toolCallPreview("run_shell", "")).toBeNull(); + expect(toolCallPreview("run_shell", "{}")).toBeNull(); + expect(toolCallPreview("unknown_tool", JSON.stringify({ foo: 1 }))).toBeNull(); + }); + + test("long subjects are hard-capped so they cannot shove other columns off the row", () => { + // Avoid hex-like blobs (a-f0-9) — secret scrub would redact them first. + const command = "z".repeat(TOOL_PREVIEW_MAX + 20); + const preview = toolCallPreview("run_shell", JSON.stringify({ command })); + expect(preview).not.toBeNull(); + expect(preview!.length).toBe(TOOL_PREVIEW_MAX); + expect(preview!.endsWith("…")).toBe(true); + }); + + test("newlines collapse to a single-line subject", () => { + expect( + toolCallPreview( + "run_shell", + JSON.stringify({ command: "bun test\n --filter agent" }), + ), + ).toBe("bun test --filter agent"); + }); + + test("secret-shaped fragments are scrubbed before the subject leaves the helper", () => { + const preview = toolCallPreview( + "run_shell", + JSON.stringify({ command: "curl https://api.example.com/?api_key=supersecretvalue" }), + ); + expect(preview).not.toBeNull(); + expect(preview).not.toContain("supersecretvalue"); + expect(preview).toContain("[REDACTED]"); + }); +}); diff --git a/src/subagent/tool-preview.ts b/src/subagent/tool-preview.ts new file mode 100644 index 000000000..ab3ff1a74 --- /dev/null +++ b/src/subagent/tool-preview.ts @@ -0,0 +1,108 @@ +/** + * One-line previews of what a live tool call is doing — the subject of a lane + * row, not a serialisation of its arguments. + * + * CL-5765: operators watching a fleet need to tell six shell commands apart; + * the bare tool name cannot. Previews are bounded, single-line, and secret- + * scrubbed so the agents strip never becomes a new leak path for credentials + * that happen to sit in a command string. + */ + +import { scrubSecrets } from "../web/secret-scrub.js"; + +/** Hard cap so a long command cannot shove the row's other columns off-screen. */ +export const TOOL_PREVIEW_MAX = 48; + +/** + * Subject of a running tool call for lane/chrome paint, or null when the args + * have nothing meaningful to show — the surface then falls back to the tool name. + */ +export function toolCallPreview(name: string, rawArgs: string): string | null { + const extracted = extractSubject(name, rawArgs); + if (extracted === null) return null; + const scrubbed = scrubSecrets(extracted); + const oneLine = scrubbed.replace(/\s+/g, " ").trim(); + if (oneLine.length === 0) return null; + if (oneLine.length <= TOOL_PREVIEW_MAX) return oneLine; + return `${oneLine.slice(0, TOOL_PREVIEW_MAX - 1)}…`; +} + +function extractSubject(name: string, rawArgs: string): string | null { + if (rawArgs.length === 0) return null; + const args = parseObject(rawArgs); + if (args === null) { + // Incomplete JSON streams through here mid-delta. Do not surface the raw + // fragment as a subject — wait for a parseable object. + const trimmed = rawArgs.trim(); + if (trimmed.startsWith("{") || trimmed.startsWith("[")) return null; + // Non-JSON payload — only useful when short enough to be the whole subject. + const oneLine = trimmed.replace(/\s+/g, " "); + return oneLine.length > 0 && oneLine.length <= TOOL_PREVIEW_MAX ? oneLine : null; + } + + const tool = name.toLowerCase(); + + // Shell: the command is the whole story. Old surface replaced the tool name + // with it entirely; we produce the same subject here. + if ( + tool === "run_shell" || + tool === "shell" || + tool === "bash" || + tool.endsWith("__run_shell") || + tool.endsWith("__shell") + ) { + return stringField(args, "command") ?? stringField(args, "cmd"); + } + + if ( + tool === "read_file" || + tool === "write_file" || + tool === "edit_file" || + tool === "delete_file" || + tool.endsWith("__read_file") || + tool.endsWith("__write_file") || + tool.endsWith("__edit_file") + ) { + return stringField(args, "path") ?? stringField(args, "file_path"); + } + + if (tool === "grep" || tool.endsWith("__grep")) { + return stringField(args, "pattern") ?? stringField(args, "query"); + } + + if (tool === "search_files" || tool.endsWith("__search_files")) { + return stringField(args, "pattern") ?? stringField(args, "glob"); + } + + if (tool === "web_search" || tool === "web_fetch") { + return stringField(args, "query") ?? stringField(args, "url"); + } + + if (tool === "task") { + return stringField(args, "description") ?? stringField(args, "prompt"); + } + + // Generic fallback: first short scalar among common subject keys. + for (const key of ["path", "command", "query", "pattern", "url", "description", "prompt"]) { + const value = stringField(args, key); + if (value !== null) return value; + } + return null; +} + +function parseObject(raw: string): Record | null { + try { + const value: unknown = JSON.parse(raw); + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + return value as Record; + } catch { + return null; + } +} + +function stringField(args: Record, key: string): string | null { + const value = args[key]; + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} diff --git a/src/tui/agent-progress.test.ts b/src/tui/agent-progress.test.ts index 30f53e554..35175648f 100644 --- a/src/tui/agent-progress.test.ts +++ b/src/tui/agent-progress.test.ts @@ -20,7 +20,8 @@ describe("agentProgress", () => { const base = { status: "running" as const, currentToolName: "grep", - currentToolStartedAt: null, + currentToolPreview: null as string | null, + currentToolStartedAt: null as number | null, startedAt: 0, lastActivityAt: 0, } @@ -41,6 +42,27 @@ describe("agentProgress", () => { }) }) + test("a tool preview replaces the bare tool name in the trailer (CL-5765)", () => { + const progress = agentProgress( + { + ...base, + currentToolName: "run_shell", + currentToolPreview: "bun test ./src", + currentToolStartedAt: 1_000, + lastActivityAt: 1_000, + }, + 91_000, + 30_000, + ) + expect(progress?.stat).toBe("1:31 · bun test ./src 1:30") + expect(progress?.stat).not.toContain("run_shell") + }) + + test("without a preview the trailer still names the tool", () => { + const progress = agentProgress({ ...base, lastActivityAt: 42_000 }, 42_000) + expect(progress?.stat).toContain("grep") + }) + test("a running session with no current tool reports elapsed time alone", () => { const progress = agentProgress( { ...base, currentToolName: null, lastActivityAt: 42_000 }, @@ -97,8 +119,9 @@ describe("agentProgress", () => { describe("laneState", () => { const running = { status: "running" as const, - currentToolName: null, - currentToolStartedAt: null, + currentToolName: null as string | null, + currentToolPreview: null as string | null, + currentToolStartedAt: null as number | null, startedAt: 0, lastActivityAt: 0, } @@ -119,8 +142,9 @@ describe("laneState", () => { describe("fleetProgress", () => { const lane = (over: Partial[0]>) => ({ status: "running" as const, - currentToolName: null, - currentToolStartedAt: null, + currentToolName: null as string | null, + currentToolPreview: null as string | null, + currentToolStartedAt: null as number | null, startedAt: 0, lastActivityAt: 0, ...over, @@ -173,6 +197,7 @@ describe("the in-tool bound", () => { const wedged = { status: "running" as const, currentToolName: "run_shell", + currentToolPreview: null as string | null, currentToolStartedAt: 0, startedAt: 0, lastActivityAt: 0, diff --git a/src/tui/agent-progress.ts b/src/tui/agent-progress.ts index 42359dce8..ba4928abd 100644 --- a/src/tui/agent-progress.ts +++ b/src/tui/agent-progress.ts @@ -17,6 +17,13 @@ export type AgentProgressSession = { readonly status: "running" | "done" | "failed" | "cancelled"; readonly currentToolName: string | null; + /** + * Bounded subject of the oldest outstanding call (command, path, pattern…), + * or null when the args have nothing meaningful to show. When set, this + * replaces the bare tool name in the row trailer so a fleet of shell + * commands is distinguishable (CL-5765). + */ + readonly currentToolPreview: string | null; /** * When the oldest outstanding tool call began, or null when none is in * flight. Required, not optional: every hop from the store to a surface is a @@ -118,11 +125,20 @@ export function agentProgress( ): AgentProgress | null { if (session.status !== "running") return null; const elapsed = clockLabel(nowMs - session.startedAt); + // Prefer the argument subject over the bare tool name — six shell commands + // on a fleet board are six different situations, not six identical labels. + const preview = session.currentToolPreview; const tool = session.currentToolName; - const hasTool = tool !== null && tool.length > 0; + const subject = + preview !== null && preview.length > 0 + ? preview + : tool !== null && tool.length > 0 + ? tool + : null; + const hasSubject = subject !== null; const state = laneState(session, nowMs, stallMs); - const base = hasTool ? `${elapsed} · ${tool}` : elapsed; + const base = hasSubject ? `${elapsed} · ${subject}` : elapsed; const stat = state === "in_tool" && session.currentToolStartedAt !== null ? `${base} ${clockLabel(nowMs - session.currentToolStartedAt)}` diff --git a/src/tui/chrome-state.test.ts b/src/tui/chrome-state.test.ts index 6bf95d80d..da862e19b 100644 --- a/src/tui/chrome-state.test.ts +++ b/src/tui/chrome-state.test.ts @@ -435,6 +435,7 @@ describe("lane state survives the mapping hops", () => { description: "sleep 150", status: "running" as const, currentToolName: "run_shell", + currentToolPreview: null as string | null, currentToolStartedAt: NOW - 90_000, startedAt: NOW - 100_000, lastActivityAt: NOW - 90_000, @@ -462,6 +463,22 @@ describe("lane state survives the mapping hops", () => { expect(agentProgress(inTool, NOW)?.stat).toContain("run_shell 1:30") }) + test("a shell preview replaces the tool name on both panel and trailer (CL-5765)", () => { + const withPreview = { + ...inTool, + currentToolPreview: "bun test ./src", + } + const rows = formatAgentsPanel( + chromeFromSession({ agents: [withPreview] }).agents, + undefined, + NOW, + ) + expect(rows?.[1]?.tail).toContain("bun test ./src") + expect(rows?.[1]?.tail).not.toContain("run_shell") + expect(agentProgress(withPreview, NOW)?.stat).toContain("bun test ./src") + expect(agentProgress(withPreview, NOW)?.stat).not.toContain("run_shell") + }) + test("a genuinely silent lane still reads stalled through the same hops", () => { const silent = { ...inTool, currentToolName: null, currentToolStartedAt: null } expect(laneState(silent, NOW)).toBe("stalled") diff --git a/src/tui/chrome-state.ts b/src/tui/chrome-state.ts index 46952d6cb..415c4e250 100644 --- a/src/tui/chrome-state.ts +++ b/src/tui/chrome-state.ts @@ -41,6 +41,11 @@ export type ChromeAgentSession = { readonly status: "running" | "done" | "failed" | "cancelled" /** Current tool while running (optional detail). */ readonly currentToolName?: string | null + /** + * Bounded subject of the outstanding call (command / path / pattern). When + * set, the agents panel paints this instead of the bare tool name (CL-5765). + */ + readonly currentToolPreview?: string | null /** Clock the worker started; feeds the panel row's elapsed time. */ readonly startedAt?: number /** Clock of the worker's last reported activity; feeds stalled detection. */ @@ -257,6 +262,7 @@ function toProgressSession(session: ChromeAgentSession): AgentProgressSession | return { status: session.status, currentToolName: session.currentToolName ?? null, + currentToolPreview: session.currentToolPreview ?? null, currentToolStartedAt: session.currentToolStartedAt, startedAt: session.startedAt, lastActivityAt: session.lastActivityAt ?? session.startedAt, @@ -400,8 +406,16 @@ function formatAgentRow( ): AgentPanelRow { const label = `${session.agentId}: ${session.description}`.trim() const stalled = state === "stalled" + // Prefer the argument subject (command / path) over the bare tool name so a + // fleet of shell calls is distinguishable at a glance (CL-5765). + const preview = session.currentToolPreview const tool = session.currentToolName - const doing = tool !== undefined && tool !== null && tool.length > 0 ? tool : null + const doing = + preview !== undefined && preview !== null && preview.length > 0 + ? preview + : tool !== undefined && tool !== null && tool.length > 0 + ? tool + : null const progressSession = toProgressSession(session) if (progressSession === null) { @@ -480,6 +494,7 @@ export type ChromeSessionAgent = { readonly description: string readonly status: "running" | "done" | "failed" | "cancelled" readonly currentToolName?: string | null + readonly currentToolPreview?: string | null readonly currentToolStartedAt: number | null readonly startedAt?: number readonly lastActivityAt?: number @@ -541,6 +556,9 @@ function mapSessionAgents( ...(a.currentToolName !== undefined ? { currentToolName: a.currentToolName } : {}), + ...(a.currentToolPreview !== undefined + ? { currentToolPreview: a.currentToolPreview } + : {}), currentToolStartedAt: a.currentToolStartedAt, ...(a.startedAt !== undefined ? { startedAt: a.startedAt } : {}), ...(a.lastActivityAt !== undefined diff --git a/src/tui/demo.ts b/src/tui/demo.ts index 08114ecfd..495280baf 100644 --- a/src/tui/demo.ts +++ b/src/tui/demo.ts @@ -108,15 +108,38 @@ const DEMO_FLEET = [ ["a15", "mcp view polish", 90, 5, "edit mcp-view.ts"], ].map(([agentId, description, ranSec, idleSec, tool]) => { const lastActivityAt = Date.now() - (idleSec as number) * 1000 + const subject = tool as string | null + // Demo subjects are already human phrases ("bash npm test", "edit zones.ts"). + // Put the phrase in the preview so the board paints what the worker is doing + // rather than a bare tool identifier (CL-5765). + let currentToolName: string | null = null + let currentToolPreview: string | null = null + if (subject !== null) { + currentToolPreview = subject + if (subject.startsWith("bash ") || subject.startsWith("approve ")) { + currentToolName = "run_shell" + } else if (subject.startsWith("edit ")) { + currentToolName = "edit_file" + } else if (subject.startsWith("write ")) { + currentToolName = "write_file" + } else if (subject.startsWith("read ")) { + currentToolName = "read_file" + } else if (subject.startsWith("grep ")) { + currentToolName = "grep" + } else { + currentToolName = "run_shell" + } + } return { agentId: agentId as string, description: description as string, status: "running" as const, - currentToolName: tool as string | null, + currentToolName, + currentToolPreview, // Hybrid chrome requires the tool clock; without it a long-running tool // would be reclassified as stalled. Align with last activity when a tool // is named so demo lanes still exercise working / in_tool / stalled. - currentToolStartedAt: tool === null ? null : lastActivityAt, + currentToolStartedAt: subject === null ? null : lastActivityAt, startedAt: Date.now() - (ranSec as number) * 1000, lastActivityAt, } diff --git a/src/tui/runner-host.test.ts b/src/tui/runner-host.test.ts index 957e90ea4..cc70d3a5b 100644 --- a/src/tui/runner-host.test.ts +++ b/src/tui/runner-host.test.ts @@ -47,6 +47,7 @@ function session(over: Partial): SubAgentSession { status: "running", toolNames: [], currentToolName: null, + currentToolPreview: null, currentToolStartedAt: null, outstandingTools: [], entries: [], diff --git a/src/tui/runner-host.ts b/src/tui/runner-host.ts index be0301b30..265496657 100644 --- a/src/tui/runner-host.ts +++ b/src/tui/runner-host.ts @@ -272,6 +272,7 @@ export async function mountRunnerHost(deps: RunnerHostDeps): Promise id: s.id, status: s.status, currentToolName: s.currentToolName, + currentToolPreview: s.currentToolPreview, currentToolStartedAt: s.currentToolStartedAt, startedAt: s.startedAt, lastActivityAt: s.lastActivityAt, diff --git a/src/tui/runner.ts b/src/tui/runner.ts index e8b9062c3..06b2468cb 100644 --- a/src/tui/runner.ts +++ b/src/tui/runner.ts @@ -2088,6 +2088,7 @@ export async function runTUI(initialConfig: Config): Promise { description: s.description, status: s.status, currentToolName: s.currentToolName, + currentToolPreview: s.currentToolPreview, currentToolStartedAt: s.currentToolStartedAt, startedAt: s.startedAt, lastActivityAt: s.lastActivityAt, diff --git a/src/tui/runtime-bridge.test.ts b/src/tui/runtime-bridge.test.ts index f72674686..065827783 100644 --- a/src/tui/runtime-bridge.test.ts +++ b/src/tui/runtime-bridge.test.ts @@ -659,6 +659,7 @@ describe("syncAgentProgress", () => { id: "task-1", status: "running", currentToolName: "grep", + currentToolPreview: null, currentToolStartedAt: null, startedAt: 0, lastActivityAt: 0,