feat(progress): humanize subagent updates

This commit is contained in:
pi
2026-04-12 10:17:48 +01:00
parent a4e627084d
commit 86335c2971
8 changed files with 515 additions and 25 deletions

View File

@@ -0,0 +1,216 @@
# Subagent Live Progress Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking.
**Goal:** Make running subagents show assistant intent text or humanized tool activity instead of raw `tool_call` / `tool_result` labels.
**Architecture:** Add one shared event-to-status formatter in `src/` so parent live updates and wrapper transcript lines use the same wording. Keep wrapper protocol, runner behavior, artifact layout, and `result.json` unchanged; only change formatting and the tiny call sites that consume it.
**Tech Stack:** TypeScript, Node ESM `.mjs`, Node test runner via `tsx --test`
---
## File structure
- Create: `src/progress.mjs` — shared, stateful formatter for normalized child events
- Modify: `src/tool.ts` — use formatter for parent `onUpdate` progress messages
- Modify: `src/wrapper/render.mjs` — use same formatter for transcript lines
- Modify: `src/wrapper/cli.mjs` — skip transcript/log output when formatter returns no line for blank assistant text
- Modify: `src/tool.test.ts` — regressions for parent live progress
- Modify: `src/wrapper/render.test.ts` — regressions for transcript rendering
### Task 1: Shared formatter + transcript rendering
**Files:**
- Create: `src/progress.mjs`
- Modify: `src/wrapper/render.mjs`
- Modify: `src/wrapper/cli.mjs`
- Test: `src/wrapper/render.test.ts`
- [x] **Step 1: Write the failing transcript-render tests**
Add tests to `src/wrapper/render.test.ts` for three behaviors:
```ts
import { createEventLineRenderer, renderHeader, renderEventLine } from "./render.mjs";
test("renderEventLine humanizes read 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("renderEventLine 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("renderEventLine keeps bash commands concise and humanizes grep failures", () => {
const render = createEventLineRenderer();
assert.equal(render({ type: "tool_call", toolName: "bash", args: { command: "rg -n authentication src" } }), "$ rg -n authentication src");
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");
});
```
- [x] **Step 2: Run transcript-render tests to verify they fail**
Run: `cd /home/dev/pi-packages/pi-subagents/.worktree/subagent-live-progress && npx tsx --test src/wrapper/render.test.ts`
Expected: FAIL because `createEventLineRenderer` does not exist and current renderer emits JSON-like tool lines / `(no assistant text)`.
- [x] **Step 3: Write the minimal shared formatter and transcript integration**
Implement `src/progress.mjs` with a stateful formatter API and wire `src/wrapper/render.mjs` to use it:
```js
export function createProgressFormatter() {
let lastTool = null;
return {
format(event) {
if (event.type === "assistant_text") {
const text = typeof event.text === "string" ? event.text.trim() : "";
return text || null;
}
if (event.type === "tool_call") {
lastTool = { toolName: event.toolName, args: event.args ?? {} };
return formatToolCall(lastTool.toolName, lastTool.args);
}
if (event.type === "tool_result") {
return formatToolResult(event, lastTool);
}
return JSON.stringify(event);
},
};
}
```
In `src/wrapper/render.mjs`, export both:
```js
export function createEventLineRenderer() {
const formatter = createProgressFormatter();
return (event) => formatter.format(event);
}
const defaultRenderEventLine = createEventLineRenderer();
export function renderEventLine(event) {
return defaultRenderEventLine(event);
}
```
In `src/wrapper/cli.mjs`, only append/log transcript lines when the rendered value is not `null`.
- [x] **Step 4: Run transcript-render tests to verify they pass**
Run: `cd /home/dev/pi-packages/pi-subagents/.worktree/subagent-live-progress && npx tsx --test src/wrapper/render.test.ts`
Expected: PASS.
### Task 2: Parent live progress updates
**Files:**
- Modify: `src/tool.ts`
- Test: `src/tool.test.ts`
- [x] **Step 1: Write the failing parent-progress tests**
Add tests to `src/tool.test.ts` for assistant-text priority and humanized fallback:
```ts
test("single-mode subagent prefers assistant text for 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: meta.task, exitCode: 0, finalText: "done" };
},
} as any);
await tool.execute(/* existing ctx setup */, (partial: any) => {
const first = partial.content?.[0];
if (first?.type === "text") updates.push(first.text);
}, /* existing ctx */);
assert.deepEqual(updates.slice(0, 3), [
"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 text", 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, exitCode: 0, finalText: "done" };
},
} as any);
await tool.execute(/* existing ctx setup */, (partial: any) => {
const first = partial.content?.[0];
if (first?.type === "text") updates.push(first.text);
}, /* existing ctx */);
assert.deepEqual(updates, ["Searching code for auth"]);
});
```
- [x] **Step 2: Run parent-progress tests to verify they fail**
Run: `cd /home/dev/pi-packages/pi-subagents/.worktree/subagent-live-progress && npx tsx --test src/tool.test.ts`
Expected: FAIL because `src/tool.ts` still emits `Running subagent: <event.type>`.
- [x] **Step 3: Write the minimal parent integration**
Update `src/tool.ts` to create one formatter per child run and emit only formatted lines:
```ts
import { createProgressFormatter } from "./progress.mjs";
const formatter = createProgressFormatter();
onEvent(event) {
const text = formatter.format(event);
if (!text) return;
onUpdate?.({
content: [{ type: "text", text }],
details: makeDetails(input.mode, []),
});
}
```
Keep parallel/chain completion summaries and all result handling unchanged.
- [x] **Step 4: Run parent-progress tests to verify they pass**
Run: `cd /home/dev/pi-packages/pi-subagents/.worktree/subagent-live-progress && npx tsx --test src/tool.test.ts`
Expected: PASS.
### Task 3: Verification
**Files:**
- Verify only
- [x] **Step 1: Run touched regression tests together**
Run: `cd /home/dev/pi-packages/pi-subagents/.worktree/subagent-live-progress && npx tsx --test src/tool.test.ts src/wrapper/render.test.ts src/wrapper/cli.test.ts`
Expected: PASS.
- [x] **Step 2: Run full package test suite**
Run: `cd /home/dev/pi-packages/pi-subagents/.worktree/subagent-live-progress && npm test`
Expected: PASS. If it fails, stop and investigate before claiming completion.
- [ ] **Step 3: Commit implementation**
```bash
cd /home/dev/pi-packages/pi-subagents/.worktree/subagent-live-progress
git add src/progress.mjs src/tool.ts src/tool.test.ts src/wrapper/render.mjs src/wrapper/render.test.ts src/wrapper/cli.mjs docs/superpowers/plans/2026-04-12-subagent-live-progress.md
git commit -m "feat(progress): humanize subagent live updates"
```

150
src/progress.mjs Normal file
View 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);
},
};
}

View File

@@ -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 () => {

View File

@@ -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, []),
});
},

View File

@@ -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;

View File

@@ -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");

View File

@@ -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);
}

View File

@@ -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");
});