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
1 change: 1 addition & 0 deletions src/subagent/fleet-report.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ function lane(overrides: Partial<FleetLane> & { id: string }): FleetLane {
startedAt: T0,
lastActivityAt: T0,
currentToolName: null,
currentToolPreview: null,
currentToolStartedAt: null,
...overrides,
};
Expand Down
1 change: 1 addition & 0 deletions src/subagent/fleet-report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
42 changes: 41 additions & 1 deletion src/subagent/session-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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();
});

Expand All @@ -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"}',
});
});
});

Expand Down
79 changes: 68 additions & 11 deletions src/subagent/session-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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 = {
Expand All @@ -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
Expand Down Expand Up @@ -116,39 +127,65 @@ 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;
for (const call of session.outstandingTools) {
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,
callId: string,
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.
Expand Down Expand Up @@ -338,6 +375,7 @@ export function createSubAgentSessionStore(
status: "running",
toolNames: [],
currentToolName: null,
currentToolPreview: null,
currentToolStartedAt: null,
outstandingTools: [],
entries: [],
Expand Down Expand Up @@ -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;
Expand All @@ -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,
Expand All @@ -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;
}
Expand Down Expand Up @@ -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),
Expand Down
68 changes: 68 additions & 0 deletions src/subagent/tool-preview.test.ts
Original file line number Diff line number Diff line change
@@ -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]");
});
});
Loading
Loading