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 assert from "node:assert/strict";
|
||||||
import { createSubagentTool } from "./tool.ts";
|
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 updates: string[] = [];
|
||||||
|
|
||||||
const tool = createSubagentTool({
|
const tool = createSubagentTool({
|
||||||
runSingleTask: async ({ onEvent, meta }: any) => {
|
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_call", toolName: "read", args: { path: "src/auth.ts" } });
|
||||||
|
onEvent?.({ type: "tool_result", toolName: "read", isError: false });
|
||||||
return {
|
return {
|
||||||
runId: "run-1",
|
runId: "run-1",
|
||||||
task: "inspect auth",
|
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]?.paneId, "%3");
|
||||||
assert.equal(result.details.results[0]?.requestedModel, "anthropic/claude-sonnet-4-5");
|
assert.equal(result.details.results[0]?.requestedModel, "anthropic/claude-sonnet-4-5");
|
||||||
assert.equal("agent" in (result.details.results[0] ?? {}), false);
|
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 () => {
|
test("single-mode subagent requires a top-level model even when execute is called directly", async () => {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
type SubagentRunResult,
|
type SubagentRunResult,
|
||||||
type SubagentToolDetails,
|
type SubagentToolDetails,
|
||||||
} from "./schema.ts";
|
} from "./schema.ts";
|
||||||
|
import { createProgressFormatter } from "./progress.mjs";
|
||||||
|
|
||||||
const MAX_PARALLEL_TASKS = 8;
|
const MAX_PARALLEL_TASKS = 8;
|
||||||
const MAX_CONCURRENCY = 4;
|
const MAX_CONCURRENCY = 4;
|
||||||
@@ -141,11 +142,15 @@ export function createSubagentTool(deps: {
|
|||||||
topLevelModel: params.model,
|
topLevelModel: params.model,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const progressFormatter = createProgressFormatter();
|
||||||
|
|
||||||
return deps.runSingleTask?.({
|
return deps.runSingleTask?.({
|
||||||
cwd: input.cwd ?? ctx.cwd,
|
cwd: input.cwd ?? ctx.cwd,
|
||||||
onEvent(event) {
|
onEvent(event) {
|
||||||
|
const text = progressFormatter.format(event);
|
||||||
|
if (!text) return;
|
||||||
onUpdate?.({
|
onUpdate?.({
|
||||||
content: [{ type: "text", text: `Running subagent: ${event.type}` }],
|
content: [{ type: "text", text }],
|
||||||
details: makeDetails(input.mode, []),
|
details: makeDetails(input.mode, []),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -117,8 +117,10 @@ async function runWrapper(meta, startedAt) {
|
|||||||
|
|
||||||
await appendJsonLine(meta.eventsPath, normalized);
|
await appendJsonLine(meta.eventsPath, normalized);
|
||||||
const rendered = renderEventLine(normalized);
|
const rendered = renderEventLine(normalized);
|
||||||
|
if (rendered !== null) {
|
||||||
await appendBestEffort(meta.transcriptPath, `${rendered}\n`);
|
await appendBestEffort(meta.transcriptPath, `${rendered}\n`);
|
||||||
console.log(rendered);
|
console.log(rendered);
|
||||||
|
}
|
||||||
|
|
||||||
if (normalized.type === "assistant_text") {
|
if (normalized.type === "assistant_text") {
|
||||||
finalText = normalized.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"/);
|
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 () => {
|
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 dir = await mkdtemp(join(tmpdir(), "pi-subagents-wrapper-"));
|
||||||
const metaPath = join(dir, "meta.json");
|
const metaPath = join(dir, "meta.json");
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
function shortenCommand(command) {
|
import { createProgressFormatter } from "../progress.mjs";
|
||||||
return command.length > 100 ? `${command.slice(0, 100)}…` : command;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function renderHeader(meta) {
|
export function renderHeader(meta) {
|
||||||
return [
|
return [
|
||||||
@@ -14,19 +12,13 @@ export function renderHeader(meta) {
|
|||||||
].join("\n");
|
].join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderEventLine(event) {
|
export function createEventLineRenderer() {
|
||||||
if (event.type === "tool_call") {
|
const formatter = createProgressFormatter();
|
||||||
if (event.toolName === "bash") return `$ ${shortenCommand(event.args.command ?? "")}`;
|
return (event) => formatter.format(event);
|
||||||
return `→ ${event.toolName} ${JSON.stringify(event.args)}`;
|
}
|
||||||
}
|
|
||||||
|
const defaultRenderEventLine = createEventLineRenderer();
|
||||||
if (event.type === "tool_result") {
|
|
||||||
return event.isError ? `✗ ${event.toolName} failed` : `✓ ${event.toolName} done`;
|
export function renderEventLine(event) {
|
||||||
}
|
return defaultRenderEventLine(event);
|
||||||
|
|
||||||
if (event.type === "assistant_text") {
|
|
||||||
return event.text || "(no assistant text)";
|
|
||||||
}
|
|
||||||
|
|
||||||
return JSON.stringify(event);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import assert from "node:assert/strict";
|
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", () => {
|
test("renderHeader prints generic subagent metadata", () => {
|
||||||
const header = renderHeader({
|
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/);
|
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({
|
const line = renderEventLine({
|
||||||
type: "tool_call",
|
type: "tool_call",
|
||||||
toolName: "bash",
|
toolName: "bash",
|
||||||
@@ -26,3 +26,24 @@ test("renderEventLine makes tool calls readable for subagent transcript output",
|
|||||||
|
|
||||||
assert.equal(line, "$ rg -n authentication src");
|
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