feat(progress): humanize subagent updates
This commit is contained in:
150
src/progress.mjs
Normal file
150
src/progress.mjs
Normal file
@@ -0,0 +1,150 @@
|
||||
function shortenText(text, max = 100) {
|
||||
return text.length > max ? `${text.slice(0, max)}…` : text;
|
||||
}
|
||||
|
||||
function readString(value) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function readPath(args) {
|
||||
return readString(args?.path);
|
||||
}
|
||||
|
||||
function readPattern(args) {
|
||||
return readString(args?.pattern);
|
||||
}
|
||||
|
||||
function readCommand(args) {
|
||||
return readString(args?.command);
|
||||
}
|
||||
|
||||
function readQuery(args) {
|
||||
return readString(args?.query);
|
||||
}
|
||||
|
||||
function formatSearchScope(path) {
|
||||
return path && path !== "." ? shortenText(path) : "code";
|
||||
}
|
||||
|
||||
function formatToolCall(toolName, args) {
|
||||
const path = readPath(args);
|
||||
|
||||
switch (toolName) {
|
||||
case "bash": {
|
||||
const command = readCommand(args);
|
||||
return command ? `$ ${shortenText(command)}` : "Running command";
|
||||
}
|
||||
case "read":
|
||||
return path ? `Reading ${shortenText(path)}` : "Reading file";
|
||||
case "grep": {
|
||||
const pattern = readPattern(args);
|
||||
if (pattern) return `Searching ${formatSearchScope(path)} for ${shortenText(pattern)}`;
|
||||
return path ? `Searching ${shortenText(path)}` : "Searching code";
|
||||
}
|
||||
case "find": {
|
||||
const pattern = readPattern(args);
|
||||
if (path && pattern) return `Scanning ${shortenText(path)} for ${shortenText(pattern)}`;
|
||||
if (pattern) return `Scanning for ${shortenText(pattern)}`;
|
||||
return path ? `Scanning ${shortenText(path)}` : "Scanning files";
|
||||
}
|
||||
case "ls":
|
||||
return path ? `Listing ${shortenText(path)}` : "Listing current directory";
|
||||
case "edit":
|
||||
return path ? `Editing ${shortenText(path)}` : "Editing file";
|
||||
case "write":
|
||||
return path ? `Writing ${shortenText(path)}` : "Writing file";
|
||||
case "web_search": {
|
||||
const query = readQuery(args);
|
||||
return query ? `Searching web for ${shortenText(query)}` : "Searching web";
|
||||
}
|
||||
case "web_fetch":
|
||||
return "Fetching web page";
|
||||
case "question":
|
||||
return "Asking question";
|
||||
default:
|
||||
return toolName ? `Running ${toolName}` : "Running tool";
|
||||
}
|
||||
}
|
||||
|
||||
function formatToolResult(toolName, isError, args) {
|
||||
const path = readPath(args);
|
||||
|
||||
switch (toolName) {
|
||||
case "bash":
|
||||
return isError ? "Command failed" : "Command finished";
|
||||
case "read":
|
||||
return path
|
||||
? isError
|
||||
? `Read failed: ${shortenText(path)}`
|
||||
: `Finished reading ${shortenText(path)}`
|
||||
: isError
|
||||
? "Read failed"
|
||||
: "Read finished";
|
||||
case "grep":
|
||||
return isError ? "Search failed" : "Search finished";
|
||||
case "find":
|
||||
return isError ? "Scan failed" : "Scan finished";
|
||||
case "ls":
|
||||
return path
|
||||
? isError
|
||||
? `Listing failed: ${shortenText(path)}`
|
||||
: `Finished listing ${shortenText(path)}`
|
||||
: isError
|
||||
? "Listing failed"
|
||||
: "Listing finished";
|
||||
case "edit":
|
||||
return path
|
||||
? isError
|
||||
? `Edit failed: ${shortenText(path)}`
|
||||
: `Finished editing ${shortenText(path)}`
|
||||
: isError
|
||||
? "Edit failed"
|
||||
: "Edit finished";
|
||||
case "write":
|
||||
return path
|
||||
? isError
|
||||
? `Write failed: ${shortenText(path)}`
|
||||
: `Finished writing ${shortenText(path)}`
|
||||
: isError
|
||||
? "Write failed"
|
||||
: "Write finished";
|
||||
case "web_search":
|
||||
return isError ? "Web search failed" : "Web search finished";
|
||||
case "web_fetch":
|
||||
return isError ? "Fetch failed" : "Fetch finished";
|
||||
case "question":
|
||||
return isError ? "Question failed" : "Question finished";
|
||||
default:
|
||||
return toolName ? `${toolName} ${isError ? "failed" : "finished"}` : isError ? "Tool failed" : "Tool finished";
|
||||
}
|
||||
}
|
||||
|
||||
export function createProgressFormatter() {
|
||||
let lastTool = null;
|
||||
|
||||
return {
|
||||
format(event) {
|
||||
if (event?.type === "assistant_text") {
|
||||
const text = readString(event.text);
|
||||
return text || null;
|
||||
}
|
||||
|
||||
if (event?.type === "tool_call") {
|
||||
lastTool = {
|
||||
toolName: readString(event.toolName),
|
||||
args: event.args ?? {},
|
||||
};
|
||||
return formatToolCall(lastTool.toolName, lastTool.args);
|
||||
}
|
||||
|
||||
if (event?.type === "tool_result") {
|
||||
const toolName = readString(event.toolName);
|
||||
const context = lastTool?.toolName === toolName ? lastTool : { toolName, args: {} };
|
||||
if (lastTool?.toolName === toolName) lastTool = null;
|
||||
return formatToolResult(context.toolName, Boolean(event.isError), context.args);
|
||||
}
|
||||
|
||||
return JSON.stringify(event);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -2,12 +2,14 @@ import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createSubagentTool } from "./tool.ts";
|
||||
|
||||
test("single-mode subagent uses the required top-level model, emits progress, and returns final text plus metadata", async () => {
|
||||
test("single-mode subagent uses the required top-level model and emits humanized live progress", async () => {
|
||||
const updates: string[] = [];
|
||||
|
||||
const tool = createSubagentTool({
|
||||
runSingleTask: async ({ onEvent, meta }: any) => {
|
||||
onEvent?.({ type: "assistant_text", text: "Inspecting auth flow" });
|
||||
onEvent?.({ type: "tool_call", toolName: "read", args: { path: "src/auth.ts" } });
|
||||
onEvent?.({ type: "tool_result", toolName: "read", isError: false });
|
||||
return {
|
||||
runId: "run-1",
|
||||
task: "inspect auth",
|
||||
@@ -49,7 +51,49 @@ test("single-mode subagent uses the required top-level model, emits progress, an
|
||||
assert.equal(result.details.results[0]?.paneId, "%3");
|
||||
assert.equal(result.details.results[0]?.requestedModel, "anthropic/claude-sonnet-4-5");
|
||||
assert.equal("agent" in (result.details.results[0] ?? {}), false);
|
||||
assert.match(updates.join("\n"), /Running subagent/);
|
||||
assert.deepEqual(updates, ["Inspecting auth flow", "Reading src/auth.ts", "Finished reading src/auth.ts"]);
|
||||
assert.doesNotMatch(updates.join("\n"), /tool_call|tool_result/);
|
||||
});
|
||||
|
||||
test("single-mode subagent ignores blank assistant text and falls back to tool activity", async () => {
|
||||
const updates: string[] = [];
|
||||
|
||||
const tool = createSubagentTool({
|
||||
runSingleTask: async ({ onEvent, meta }: any) => {
|
||||
onEvent?.({ type: "assistant_text", text: " " });
|
||||
onEvent?.({ type: "tool_call", toolName: "grep", args: { pattern: "auth" } });
|
||||
return {
|
||||
runId: "run-1",
|
||||
task: meta.task,
|
||||
requestedModel: meta.requestedModel,
|
||||
resolvedModel: meta.resolvedModel,
|
||||
exitCode: 0,
|
||||
finalText: "Auth search done",
|
||||
};
|
||||
},
|
||||
} as any);
|
||||
|
||||
await tool.execute(
|
||||
"tool-1",
|
||||
{
|
||||
task: "inspect auth",
|
||||
model: "anthropic/claude-sonnet-4-5",
|
||||
},
|
||||
undefined,
|
||||
(partial: any) => {
|
||||
const first = partial.content?.[0];
|
||||
if (first?.type === "text") updates.push(first.text);
|
||||
},
|
||||
{
|
||||
cwd: "/repo",
|
||||
modelRegistry: {
|
||||
getAvailable: () => [{ provider: "anthropic", id: "claude-sonnet-4-5" }],
|
||||
},
|
||||
hasUI: false,
|
||||
} as any,
|
||||
);
|
||||
|
||||
assert.deepEqual(updates, ["Searching code for auth"]);
|
||||
});
|
||||
|
||||
test("single-mode subagent requires a top-level model even when execute is called directly", async () => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type SubagentRunResult,
|
||||
type SubagentToolDetails,
|
||||
} from "./schema.ts";
|
||||
import { createProgressFormatter } from "./progress.mjs";
|
||||
|
||||
const MAX_PARALLEL_TASKS = 8;
|
||||
const MAX_CONCURRENCY = 4;
|
||||
@@ -141,11 +142,15 @@ export function createSubagentTool(deps: {
|
||||
topLevelModel: params.model,
|
||||
});
|
||||
|
||||
const progressFormatter = createProgressFormatter();
|
||||
|
||||
return deps.runSingleTask?.({
|
||||
cwd: input.cwd ?? ctx.cwd,
|
||||
onEvent(event) {
|
||||
const text = progressFormatter.format(event);
|
||||
if (!text) return;
|
||||
onUpdate?.({
|
||||
content: [{ type: "text", text: `Running subagent: ${event.type}` }],
|
||||
content: [{ type: "text", text }],
|
||||
details: makeDetails(input.mode, []),
|
||||
});
|
||||
},
|
||||
|
||||
@@ -117,8 +117,10 @@ async function runWrapper(meta, startedAt) {
|
||||
|
||||
await appendJsonLine(meta.eventsPath, normalized);
|
||||
const rendered = renderEventLine(normalized);
|
||||
await appendBestEffort(meta.transcriptPath, `${rendered}\n`);
|
||||
console.log(rendered);
|
||||
if (rendered !== null) {
|
||||
await appendBestEffort(meta.transcriptPath, `${rendered}\n`);
|
||||
console.log(rendered);
|
||||
}
|
||||
|
||||
if (normalized.type === "assistant_text") {
|
||||
finalText = normalized.text;
|
||||
|
||||
@@ -250,6 +250,66 @@ test("wrapper does not exit early on non-terminal toolUse assistant messages", a
|
||||
assert.match(eventsText, /"type":"tool_call"/);
|
||||
});
|
||||
|
||||
test("wrapper skips blank assistant transcript lines before later tool activity", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "pi-subagents-wrapper-"));
|
||||
const metaPath = join(dir, "meta.json");
|
||||
const resultPath = join(dir, "result.json");
|
||||
const transcriptPath = join(dir, "transcript.log");
|
||||
const piPath = join(dir, "pi");
|
||||
|
||||
await writeFile(
|
||||
piPath,
|
||||
[
|
||||
`#!${process.execPath}`,
|
||||
"console.log(JSON.stringify({type:'message_end',message:{role:'assistant',content:[{type:'text',text:' '}],model:'openai/gpt-5',stopReason:'toolUse'}}));",
|
||||
"setTimeout(() => console.log(JSON.stringify({type:'tool_execution_start',toolName:'read',args:{path:'src/auth.ts'}})), 100);",
|
||||
"setTimeout(() => console.log(JSON.stringify({type:'message_end',message:{role:'assistant',content:[{type:'text',text:'done'}],model:'openai/gpt-5',stopReason:'stop'}})), 200);",
|
||||
"setTimeout(() => process.exit(0), 400);",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
await chmod(piPath, 0o755);
|
||||
|
||||
await writeFile(
|
||||
metaPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
runId: "run-1",
|
||||
mode: "single",
|
||||
task: "inspect auth",
|
||||
cwd: dir,
|
||||
requestedModel: "openai/gpt-5",
|
||||
resolvedModel: "openai/gpt-5",
|
||||
startedAt: "2026-04-09T00:00:00.000Z",
|
||||
sessionPath: join(dir, "child-session.jsonl"),
|
||||
eventsPath: join(dir, "events.jsonl"),
|
||||
resultPath,
|
||||
stdoutPath: join(dir, "stdout.log"),
|
||||
stderrPath: join(dir, "stderr.log"),
|
||||
transcriptPath,
|
||||
systemPromptPath: join(dir, "system-prompt.md"),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const wrapperPath = join(dirname(fileURLToPath(import.meta.url)), "cli.mjs");
|
||||
const child = spawn(process.execPath, [wrapperPath, metaPath], {
|
||||
env: { ...process.env, PATH: dir },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
const exitCode = await waitForExit(child, 2500);
|
||||
assert.equal(exitCode, 0);
|
||||
|
||||
const transcript = await readFile(transcriptPath, "utf8");
|
||||
assert.doesNotMatch(transcript, /\nnull\n/);
|
||||
assert.match(transcript, /Reading src\/auth.ts/);
|
||||
assert.match(transcript, /done/);
|
||||
});
|
||||
|
||||
test("wrapper exits and writes result.json after terminal output even if the pi child lingers", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "pi-subagents-wrapper-"));
|
||||
const metaPath = join(dir, "meta.json");
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
function shortenCommand(command) {
|
||||
return command.length > 100 ? `${command.slice(0, 100)}…` : command;
|
||||
}
|
||||
import { createProgressFormatter } from "../progress.mjs";
|
||||
|
||||
export function renderHeader(meta) {
|
||||
return [
|
||||
@@ -14,19 +12,13 @@ export function renderHeader(meta) {
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function renderEventLine(event) {
|
||||
if (event.type === "tool_call") {
|
||||
if (event.toolName === "bash") return `$ ${shortenCommand(event.args.command ?? "")}`;
|
||||
return `→ ${event.toolName} ${JSON.stringify(event.args)}`;
|
||||
}
|
||||
|
||||
if (event.type === "tool_result") {
|
||||
return event.isError ? `✗ ${event.toolName} failed` : `✓ ${event.toolName} done`;
|
||||
}
|
||||
|
||||
if (event.type === "assistant_text") {
|
||||
return event.text || "(no assistant text)";
|
||||
}
|
||||
|
||||
return JSON.stringify(event);
|
||||
export function createEventLineRenderer() {
|
||||
const formatter = createProgressFormatter();
|
||||
return (event) => formatter.format(event);
|
||||
}
|
||||
|
||||
const defaultRenderEventLine = createEventLineRenderer();
|
||||
|
||||
export function renderEventLine(event) {
|
||||
return defaultRenderEventLine(event);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { renderHeader, renderEventLine } from "./render.mjs";
|
||||
import { createEventLineRenderer, renderHeader, renderEventLine } from "./render.mjs";
|
||||
|
||||
test("renderHeader prints generic subagent metadata", () => {
|
||||
const header = renderHeader({
|
||||
@@ -17,7 +17,7 @@ test("renderHeader prints generic subagent metadata", () => {
|
||||
assert.match(header, /Session: \/repo\/\.pi\/subagents\/runs\/run-1\/child-session\.jsonl/);
|
||||
});
|
||||
|
||||
test("renderEventLine makes tool calls readable for subagent transcript output", () => {
|
||||
test("renderEventLine keeps bash commands readable for subagent transcript output", () => {
|
||||
const line = renderEventLine({
|
||||
type: "tool_call",
|
||||
toolName: "bash",
|
||||
@@ -26,3 +26,24 @@ test("renderEventLine makes tool calls readable for subagent transcript output",
|
||||
|
||||
assert.equal(line, "$ rg -n authentication src");
|
||||
});
|
||||
|
||||
test("createEventLineRenderer humanizes read calls and result lines", () => {
|
||||
const render = createEventLineRenderer();
|
||||
|
||||
assert.equal(render({ type: "tool_call", toolName: "read", args: { path: "src/auth.ts" } }), "Reading src/auth.ts");
|
||||
assert.equal(render({ type: "tool_result", toolName: "read", isError: false }), "Finished reading src/auth.ts");
|
||||
});
|
||||
|
||||
test("createEventLineRenderer prefers assistant text and drops blank assistant text", () => {
|
||||
const render = createEventLineRenderer();
|
||||
|
||||
assert.equal(render({ type: "assistant_text", text: "Inspecting auth flow" }), "Inspecting auth flow");
|
||||
assert.equal(render({ type: "assistant_text", text: " " }), null);
|
||||
});
|
||||
|
||||
test("createEventLineRenderer humanizes grep failures", () => {
|
||||
const render = createEventLineRenderer();
|
||||
|
||||
assert.equal(render({ type: "tool_call", toolName: "grep", args: { pattern: "auth" } }), "Searching code for auth");
|
||||
assert.equal(render({ type: "tool_result", toolName: "grep", isError: true }), "Search failed");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user