From 86335c2971246fbb313c8082508f00ac790a4b3c Mon Sep 17 00:00:00 2001 From: pi Date: Sun, 12 Apr 2026 10:17:48 +0100 Subject: [PATCH] feat(progress): humanize subagent updates --- .../2026-04-12-subagent-live-progress.md | 216 ++++++++++++++++++ src/progress.mjs | 150 ++++++++++++ src/tool.test.ts | 48 +++- src/tool.ts | 7 +- src/wrapper/cli.mjs | 6 +- src/wrapper/cli.test.ts | 60 +++++ src/wrapper/render.mjs | 28 +-- src/wrapper/render.test.ts | 25 +- 8 files changed, 515 insertions(+), 25 deletions(-) create mode 100644 docs/superpowers/plans/2026-04-12-subagent-live-progress.md create mode 100644 src/progress.mjs diff --git a/docs/superpowers/plans/2026-04-12-subagent-live-progress.md b/docs/superpowers/plans/2026-04-12-subagent-live-progress.md new file mode 100644 index 0000000..35ece85 --- /dev/null +++ b/docs/superpowers/plans/2026-04-12-subagent-live-progress.md @@ -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: `. + +- [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" +``` diff --git a/src/progress.mjs b/src/progress.mjs new file mode 100644 index 0000000..00650ea --- /dev/null +++ b/src/progress.mjs @@ -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); + }, + }; +} diff --git a/src/tool.test.ts b/src/tool.test.ts index bde60bb..d0f97fd 100644 --- a/src/tool.test.ts +++ b/src/tool.test.ts @@ -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 () => { diff --git a/src/tool.ts b/src/tool.ts index b6f4988..b2e8cdf 100644 --- a/src/tool.ts +++ b/src/tool.ts @@ -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, []), }); }, diff --git a/src/wrapper/cli.mjs b/src/wrapper/cli.mjs index ada1d90..8314074 100644 --- a/src/wrapper/cli.mjs +++ b/src/wrapper/cli.mjs @@ -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; diff --git a/src/wrapper/cli.test.ts b/src/wrapper/cli.test.ts index 37a6361..048c495 100644 --- a/src/wrapper/cli.test.ts +++ b/src/wrapper/cli.test.ts @@ -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"); diff --git a/src/wrapper/render.mjs b/src/wrapper/render.mjs index b423fae..4df3420 100644 --- a/src/wrapper/render.mjs +++ b/src/wrapper/render.mjs @@ -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); } diff --git a/src/wrapper/render.test.ts b/src/wrapper/render.test.ts index a4bec32..d40a382 100644 --- a/src/wrapper/render.test.ts +++ b/src/wrapper/render.test.ts @@ -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"); +});