feat(progress): humanize subagent updates
This commit is contained in:
216
docs/superpowers/plans/2026-04-12-subagent-live-progress.md
Normal file
216
docs/superpowers/plans/2026-04-12-subagent-live-progress.md
Normal 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
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);
|
||||
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