Skip to content

Commit 06bd720

Browse files
committed
Show what a lane tool is doing, not just which tool
A fleet of shell calls all painted as "run_shell", so an operator could not tell a test suite from a build. Derive a bounded, secret-scrubbed subject from the same arguments the transcript holds, stamp it on the outstanding call, and paint it in place of the bare tool name on the agents board and dispatch trailer.
1 parent 264a365 commit 06bd720

15 files changed

Lines changed: 400 additions & 22 deletions

src/subagent/fleet-report.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ function lane(overrides: Partial<FleetLane> & { id: string }): FleetLane {
1515
startedAt: T0,
1616
lastActivityAt: T0,
1717
currentToolName: null,
18+
currentToolPreview: null,
1819
currentToolStartedAt: null,
1920
...overrides,
2021
};

src/subagent/fleet-report.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export type FleetLane = {
2323
readonly startedAt: number;
2424
readonly lastActivityAt: number;
2525
readonly currentToolName: string | null;
26+
readonly currentToolPreview: string | null;
2627
readonly currentToolStartedAt: number | null;
2728
readonly report?: string;
2829
readonly error?: string;

src/subagent/session-store.test.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,9 +120,16 @@ describe("outstanding tool clock", () => {
120120
store.appendEvent(session.id, {
121121
type: "tool.start",
122122
seq: 1,
123-
data: { call: { id: "call-1", name: "run_shell", arguments: {} } },
123+
data: {
124+
call: {
125+
id: "call-1",
126+
name: "run_shell",
127+
arguments: { command: "bun test" },
128+
},
129+
},
124130
} as unknown as ReactorEmittedEvent);
125131
expect(store.get(session.id)?.currentToolName).toBe("run_shell");
132+
expect(store.get(session.id)?.currentToolPreview).toBe("bun test");
126133
expect(store.get(session.id)?.currentToolStartedAt).toBe(5_000);
127134

128135
clock = 95_000;
@@ -132,6 +139,7 @@ describe("outstanding tool clock", () => {
132139
data: { result: { callId: "call-1", content: "ok", isError: false } },
133140
} as unknown as ReactorEmittedEvent);
134141
expect(store.get(session.id)?.currentToolName).toBeNull();
142+
expect(store.get(session.id)?.currentToolPreview).toBeNull();
135143
expect(store.get(session.id)?.currentToolStartedAt).toBeNull();
136144
});
137145

@@ -143,6 +151,38 @@ describe("outstanding tool clock", () => {
143151

144152
store.complete(session.id, "report");
145153
expect(store.get(session.id)?.currentToolStartedAt).toBeNull();
154+
expect(store.get(session.id)?.currentToolPreview).toBeNull();
155+
});
156+
157+
// CL-5765: argument streaming must refresh the preview so a partial command
158+
// does not stick on the lane after the rest of the args arrive.
159+
test("streaming arguments refresh the lane preview from the same payload the transcript holds", () => {
160+
const store = createSubAgentSessionStore();
161+
const session = store.start({ description: "d", agentId: "a", brief: "b" });
162+
163+
store.appendEvent(session.id, {
164+
type: "inference.tool_call.start",
165+
seq: 1,
166+
data: { name: "run_shell", callId: "call-1" },
167+
} as unknown as ReactorEmittedEvent);
168+
store.appendEvent(session.id, {
169+
type: "inference.tool_call.delta",
170+
seq: 2,
171+
data: { callId: "call-1", argumentFragment: '{"command":"bun te' },
172+
} as unknown as ReactorEmittedEvent);
173+
// Incomplete JSON — no preview yet.
174+
expect(store.get(session.id)?.currentToolPreview).toBeNull();
175+
176+
store.appendEvent(session.id, {
177+
type: "inference.tool_call.delta",
178+
seq: 3,
179+
data: { callId: "call-1", argumentFragment: 'st"}' },
180+
} as unknown as ReactorEmittedEvent);
181+
expect(store.get(session.id)?.currentToolPreview).toBe("bun test");
182+
expect(store.get(session.id)?.entries[0]).toMatchObject({
183+
kind: "tool",
184+
arguments: '{"command":"bun test"}',
185+
});
146186
});
147187
});
148188

src/subagent/session-store.ts

Lines changed: 68 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
// this store is the dedicated child record the enter-session UI reads.
66

77
import type { ReactorEmittedEvent } from "@intx/inference";
8+
import { toolCallPreview } from "./tool-preview.js";
89

910
export type SubAgentSessionStatus = "running" | "done" | "failed" | "cancelled";
1011

@@ -21,6 +22,13 @@ export type OutstandingToolCall = {
2122
callId: string;
2223
name: string;
2324
startedAt: number;
25+
/**
26+
* Bounded one-line subject of the call (command, path, pattern…), or null
27+
* when the args have nothing useful to show. Derived from the same raw
28+
* arguments the transcript stores so the lane and the body cannot disagree
29+
* about what is running (CL-5765).
30+
*/
31+
preview: string | null;
2432
};
2533

2634
export type SubAgentSession = {
@@ -30,14 +38,17 @@ export type SubAgentSession = {
3038
brief: string;
3139
status: SubAgentSessionStatus;
3240
toolNames: string[];
33-
// Name and start clock of the OLDEST outstanding call — the one that
34-
// explains the longest silence. Both are derived from `outstandingTools`;
35-
// never assign them directly. Null when nothing is in flight.
41+
// Name, preview, and start clock of the OLDEST outstanding call — the one
42+
// that explains the longest silence. All three are derived from
43+
// `outstandingTools`; never assign them directly. Null when nothing is in
44+
// flight.
3645
//
3746
// A worker inside one long tool emits no events for the whole execution, so
3847
// silence alone cannot tell "wedged" from "running a ten-minute test suite".
39-
// The start clock is the fact that separates them.
48+
// The start clock is the fact that separates them. The preview is what lets
49+
// an operator tell six shell commands apart on a fleet board.
4050
currentToolName: string | null;
51+
currentToolPreview: string | null;
4152
currentToolStartedAt: number | null;
4253
// Calls the reactor has started and not yet reported a result for. The
4354
// reactor runs parallel calls concurrently, so this cannot collapse to one
@@ -116,39 +127,65 @@ function defaultCreateId(): string {
116127
}
117128

118129
/**
119-
* The one place the displayed pair is produced, so a name can never be shown
120-
* beside another call's clock. Called after every change to `outstandingTools`.
130+
* The one place the displayed triple is produced, so a name / preview can never
131+
* be shown beside another call's clock. Called after every change to
132+
* `outstandingTools`.
121133
*/
122134
function syncCurrentTool(session: SubAgentSession): void {
123135
let oldest: OutstandingToolCall | undefined;
124136
for (const call of session.outstandingTools) {
125137
if (oldest === undefined || call.startedAt < oldest.startedAt) oldest = call;
126138
}
127139
session.currentToolName = oldest?.name ?? null;
140+
session.currentToolPreview = oldest?.preview ?? null;
128141
session.currentToolStartedAt = oldest?.startedAt ?? null;
129142
}
130143

131144
/**
132145
* `restartClock` marks the execution boundary: argument streaming already
133146
* registered the call, and the figure worth showing is time spent running it.
147+
* `rawArgs`, when known, refreshes the lane preview from the same payload the
148+
* transcript stores.
134149
*/
135150
function beginToolCall(
136151
session: SubAgentSession,
137152
callId: string,
138153
name: string,
139154
nowMs: number,
140155
restartClock = false,
156+
rawArgs?: string,
141157
): void {
142158
const existing = session.outstandingTools.find((c) => c.callId === callId);
159+
const preview =
160+
rawArgs !== undefined ? toolCallPreview(name, rawArgs) : (existing?.preview ?? null);
143161
if (existing !== undefined) {
144162
existing.name = name;
145163
if (restartClock) existing.startedAt = nowMs;
164+
if (rawArgs !== undefined) existing.preview = preview;
146165
} else {
147-
session.outstandingTools.push({ callId, name, startedAt: nowMs });
166+
session.outstandingTools.push({
167+
callId,
168+
name,
169+
startedAt: nowMs,
170+
preview,
171+
});
148172
}
149173
syncCurrentTool(session);
150174
}
151175

176+
/** Refresh the outstanding call's preview once more of its arguments stream in. */
177+
function refreshToolPreview(
178+
session: SubAgentSession,
179+
callId: string,
180+
name: string,
181+
rawArgs: string,
182+
): void {
183+
const existing = session.outstandingTools.find((c) => c.callId === callId);
184+
if (existing === undefined) return;
185+
existing.preview = toolCallPreview(name, rawArgs);
186+
syncCurrentTool(session);
187+
}
188+
152189
/**
153190
* Retires exactly the call that finished. A result carrying an id we never saw
154191
* start retires nothing, rather than silently clearing a live sibling's clock.
@@ -338,6 +375,7 @@ export function createSubAgentSessionStore(
338375
status: "running",
339376
toolNames: [],
340377
currentToolName: null,
378+
currentToolPreview: null,
341379
currentToolStartedAt: null,
342380
outstandingTools: [],
343381
entries: [],
@@ -398,6 +436,9 @@ export function createSubAgentSessionStore(
398436
if (entry?.kind !== "tool") continue;
399437
if (callId !== null && entry.callId !== callId) continue;
400438
entry.arguments = appendCapped(entry.arguments, fragment, maxEntryChars);
439+
// Preview tracks the same args the transcript holds so the lane
440+
// and the body never disagree about what is running.
441+
refreshToolPreview(session, entry.callId, entry.name, entry.arguments);
401442
return;
402443
}
403444
return;
@@ -418,14 +459,21 @@ export function createSubAgentSessionStore(
418459
if (args !== null && args.length > 0) entry.arguments = args;
419460
// Arguments finished streaming; the call itself is still in
420461
// flight, so this renames it rather than restarting its clock.
421-
beginToolCall(session, entry.callId, entry.name, now());
462+
beginToolCall(
463+
session,
464+
entry.callId,
465+
entry.name,
466+
now(),
467+
false,
468+
entry.arguments,
469+
);
422470
return;
423471
}
424472
// No matching start — record a complete tool entry.
425473
if (name !== null) {
426474
const idForEntry = callId ?? `${name}-${session.entries.length}`;
427475
if (!session.toolNames.includes(name)) session.toolNames.push(name);
428-
beginToolCall(session, idForEntry, name, now());
476+
beginToolCall(session, idForEntry, name, now(), false, args ?? "");
429477
pushEntry(session, {
430478
kind: "tool",
431479
callId: idForEntry,
@@ -438,14 +486,22 @@ export function createSubAgentSessionStore(
438486
case "tool.start": {
439487
// tool.start is the execution-time counterpart of inference.tool_call.
440488
// Prefer inference events for the transcript; only fill gaps.
441-
const call = (event as { data?: { call?: { name?: unknown; id?: unknown } } }).data?.call;
489+
const call = (event as {
490+
data?: { call?: { name?: unknown; id?: unknown; arguments?: unknown } };
491+
}).data?.call;
442492
const name = typeof call?.name === "string" ? call.name : null;
443493
if (name === null) return;
444494
const callId = typeof call?.id === "string" ? call.id : null;
495+
const rawArgs =
496+
call?.arguments !== undefined
497+
? capText(stringifyUnknown(call.arguments), maxEntryChars)
498+
: undefined;
445499
// Without an id there is no way to tell which of several parallel
446500
// calls this starts, and guessing would retime the wrong one. The
447501
// inference-side start already registered it, so leave it alone.
448-
if (callId !== null) beginToolCall(session, callId, name, now(), true);
502+
if (callId !== null) {
503+
beginToolCall(session, callId, name, now(), true, rawArgs);
504+
}
449505
if (!session.toolNames.includes(name)) session.toolNames.push(name);
450506
return;
451507
}
@@ -553,6 +609,7 @@ function cloneSession(session: SubAgentSession): SubAgentSession {
553609
status: session.status,
554610
toolNames: [...session.toolNames],
555611
currentToolName: session.currentToolName,
612+
currentToolPreview: session.currentToolPreview,
556613
currentToolStartedAt: session.currentToolStartedAt,
557614
outstandingTools: session.outstandingTools.map((c) => ({ ...c })),
558615
entries: session.entries.map(cloneEntry),

src/subagent/tool-preview.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { TOOL_PREVIEW_MAX, toolCallPreview } from "./tool-preview";
3+
4+
describe("toolCallPreview", () => {
5+
test("a shell call's subject is the command, not the tool name", () => {
6+
expect(
7+
toolCallPreview("run_shell", JSON.stringify({ command: "bun test ./src" })),
8+
).toBe("bun test ./src");
9+
});
10+
11+
test("a file tool's subject is the path", () => {
12+
expect(
13+
toolCallPreview("read_file", JSON.stringify({ path: "src/subagent/session-store.ts" })),
14+
).toBe("src/subagent/session-store.ts");
15+
});
16+
17+
test("grep shows the pattern", () => {
18+
expect(
19+
toolCallPreview("grep", JSON.stringify({ pattern: "currentToolPreview", path: "src" })),
20+
).toBe("currentToolPreview");
21+
});
22+
23+
test("task prefers description over prompt", () => {
24+
expect(
25+
toolCallPreview(
26+
"task",
27+
JSON.stringify({
28+
description: "map callers",
29+
prompt: "Find every call site of leaveObserve.",
30+
}),
31+
),
32+
).toBe("map callers");
33+
});
34+
35+
test("empty or unknown args degrade to null so the lane falls back to the tool name", () => {
36+
expect(toolCallPreview("run_shell", "")).toBeNull();
37+
expect(toolCallPreview("run_shell", "{}")).toBeNull();
38+
expect(toolCallPreview("unknown_tool", JSON.stringify({ foo: 1 }))).toBeNull();
39+
});
40+
41+
test("long subjects are hard-capped so they cannot shove other columns off the row", () => {
42+
// Avoid hex-like blobs (a-f0-9) — secret scrub would redact them first.
43+
const command = "z".repeat(TOOL_PREVIEW_MAX + 20);
44+
const preview = toolCallPreview("run_shell", JSON.stringify({ command }));
45+
expect(preview).not.toBeNull();
46+
expect(preview!.length).toBe(TOOL_PREVIEW_MAX);
47+
expect(preview!.endsWith("…")).toBe(true);
48+
});
49+
50+
test("newlines collapse to a single-line subject", () => {
51+
expect(
52+
toolCallPreview(
53+
"run_shell",
54+
JSON.stringify({ command: "bun test\n --filter agent" }),
55+
),
56+
).toBe("bun test --filter agent");
57+
});
58+
59+
test("secret-shaped fragments are scrubbed before the subject leaves the helper", () => {
60+
const preview = toolCallPreview(
61+
"run_shell",
62+
JSON.stringify({ command: "curl https://api.example.com/?api_key=supersecretvalue" }),
63+
);
64+
expect(preview).not.toBeNull();
65+
expect(preview).not.toContain("supersecretvalue");
66+
expect(preview).toContain("[REDACTED]");
67+
});
68+
});

0 commit comments

Comments
 (0)