@pi-archimedes/subagent 2.10.4 → 2.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/package.json +10 -10
- package/src/agent-panel.mcp.test.ts +76 -0
- package/src/agent-panel.tools.test.ts +162 -0
- package/src/agent-panel.ts +37 -8
- package/src/agent-panel.width-audit.test.ts +258 -0
- package/src/agents-parse.test.ts +252 -0
- package/src/agents.ts +72 -5
- package/src/compact.test.ts +136 -57
- package/src/compact.ts +57 -37
- package/src/dispatch.test.ts +73 -1
- package/src/dispatch.ts +20 -2
- package/src/execute.test.ts +9 -1
- package/src/execute.ts +26 -29
- package/src/expanded.test.ts +472 -0
- package/src/expanded.ts +202 -82
- package/src/format.test.ts +71 -29
- package/src/format.ts +71 -28
- package/src/frontmatter-io.test.ts +62 -0
- package/src/frontmatter-io.ts +16 -2
- package/src/handlers.test.ts +21 -3
- package/src/handlers.ts +22 -3
- package/src/index.output-schema.test.ts +317 -0
- package/src/index.test.ts +566 -0
- package/src/index.ts +169 -16
- package/src/render.ts +46 -36
- package/src/row-block.test.ts +244 -0
- package/src/row-block.ts +209 -0
- package/src/run-outcome.test.ts +71 -0
- package/src/run-outcome.ts +84 -0
- package/src/spawn.test.ts +204 -19
- package/src/spawn.ts +49 -17
- package/src/stream.test.ts +215 -17
- package/src/stream.ts +47 -9
- package/src/thinking-validation.test.ts +96 -0
- package/src/thinking-validation.ts +78 -0
- package/src/tool-declarations.test.ts +58 -0
- package/src/tool-row.width-audit.test.ts +720 -0
- package/src/tool-schema.test.ts +154 -0
- package/src/tool-schema.ts +106 -4
- package/src/tool-validation.test.ts +321 -0
- package/src/tool-validation.ts +262 -0
- package/src/types.ts +86 -0
- package/src/usage.test.ts +95 -0
- package/src/usage.ts +87 -0
- package/src/cost.test.ts +0 -62
- package/src/cost.ts +0 -8
package/src/format.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { SubagentResult } from "./types.js";
|
|
1
|
+
import type { RunOutcome, SubagentResult } from "./types.js";
|
|
2
2
|
|
|
3
3
|
export function formatTokens(n: number): string {
|
|
4
4
|
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
|
|
@@ -22,27 +22,6 @@ export function formatCost(cost: number): string {
|
|
|
22
22
|
return "$" + cost.toFixed(2);
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
/**
|
|
26
|
-
* Truncate plain text to a max character length, appending "..." if truncated.
|
|
27
|
-
*
|
|
28
|
-
* Unlike truncateToWidth from pi-tui, this does not handle wide characters or
|
|
29
|
-
* measure ANSI display width. The benefit: the "..." is plain text so it
|
|
30
|
-
* inherits any ANSI color wrapper applied around the result (pi-tui's
|
|
31
|
-
* truncateToWidth appends "..." after closing color codes, leaving it unstyled).
|
|
32
|
-
*
|
|
33
|
-
* If the text contains newlines, truncates at the first newline boundary
|
|
34
|
-
* rather than bleeding into the next line.
|
|
35
|
-
*/
|
|
36
|
-
export function truncLine(text: string, maxLen: number): string {
|
|
37
|
-
// Check for newline first — it's a hard boundary regardless of length
|
|
38
|
-
const nlIdx = text.indexOf("\n");
|
|
39
|
-
if (nlIdx !== -1) {
|
|
40
|
-
return text.slice(0, nlIdx).slice(0, maxLen - 3) + "...";
|
|
41
|
-
}
|
|
42
|
-
if (text.length <= maxLen) return text;
|
|
43
|
-
return text.slice(0, maxLen - 3) + "...";
|
|
44
|
-
}
|
|
45
|
-
|
|
46
25
|
/**
|
|
47
26
|
* Renders one section per parallel result. The header line is intentionally
|
|
48
27
|
* byte-identical to the pre-#55 metrics summary (a contract, not an
|
|
@@ -50,20 +29,84 @@ export function truncLine(text: string, maxLen: number): string {
|
|
|
50
29
|
* (finalOutput ?? error ?? "completed"), `trimEnd()`-ed so sections stay
|
|
51
30
|
* separated by exactly one blank line, and is deliberately untruncated by
|
|
52
31
|
* design (see docs/decisions/0020-subagent-parallel-full-output.md).
|
|
32
|
+
*
|
|
33
|
+
* A run that merely completed keeps that header exactly as it was. A truncated
|
|
34
|
+
* or aborted one says so on the row, because a "✓" the calling model has no way
|
|
35
|
+
* to check would be the worse kind of lie.
|
|
36
|
+
*
|
|
37
|
+
* The failure check reads `error`, not the exit code, and comes before the
|
|
38
|
+
* success marker: a `--mode json` child whose last response errored **exits 0**
|
|
39
|
+
* (`stream.ts` turns that into `error` with no `outcome`), and `index.ts` calls
|
|
40
|
+
* the same result `isError`. A header keyed off `exitCode` alone told the model
|
|
41
|
+
* the opposite of the flag on the tool result it was reading.
|
|
53
42
|
*/
|
|
54
43
|
export function formatParallelResults(results: SubagentResult[]): string {
|
|
55
44
|
const sections = results.map((r) => {
|
|
56
|
-
const status =
|
|
45
|
+
const status =
|
|
46
|
+
r.outcome === "truncated"
|
|
47
|
+
? "⚠ truncated"
|
|
48
|
+
: r.outcome === "aborted"
|
|
49
|
+
? "⚠ aborted"
|
|
50
|
+
: r.error || r.exitCode !== 0
|
|
51
|
+
? "✗"
|
|
52
|
+
: "✓";
|
|
57
53
|
const summary = r.progressSummary
|
|
58
54
|
? `${r.progressSummary.toolCount} tools · ${Math.round(r.progressSummary.tokens / 1000)}k tok · ${Math.round(r.progressSummary.durationMs / 1000)}s`
|
|
59
55
|
: "";
|
|
60
56
|
const header = `${status} ${r.agent}${summary ? " " + summary : ""}`;
|
|
61
|
-
const body = (r.finalOutput ?? r.error ??
|
|
57
|
+
const body = (r.finalOutput ?? r.error ?? fallbackBody(r)).trimEnd();
|
|
62
58
|
return `${header}\n${body}`;
|
|
63
59
|
});
|
|
64
60
|
return sections.join("\n\n");
|
|
65
61
|
}
|
|
66
62
|
|
|
63
|
+
/** What to say when a result has neither output nor an error to show. */
|
|
64
|
+
function fallbackBody(result: SubagentResult): string {
|
|
65
|
+
return result.outcome ? "(no output)" : "completed";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The single-run result text the calling model reads.
|
|
70
|
+
*
|
|
71
|
+
* The outcome is spelled out in words as well as carried in
|
|
72
|
+
* `details.results[].outcome`, because `content` is the half of the contract the
|
|
73
|
+
* model is guaranteed to see — and "this answer was cut off" is not something it
|
|
74
|
+
* can recover from the answer itself.
|
|
75
|
+
*/
|
|
76
|
+
export function formatSingleResult(result: SubagentResult): string {
|
|
77
|
+
const body = result.finalOutput ?? result.error ?? fallbackBody(result);
|
|
78
|
+
const note =
|
|
79
|
+
result.outcome === "truncated"
|
|
80
|
+
? "[truncated: the subagent reached its output token limit — the answer above is partial]"
|
|
81
|
+
: result.outcome === "aborted"
|
|
82
|
+
? "[aborted: the subagent run was cancelled — anything above is partial]"
|
|
83
|
+
: undefined;
|
|
84
|
+
return note ? `${body}\n\n${note}` : body;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The finished-run row, shared by the compact and expanded views.
|
|
89
|
+
*
|
|
90
|
+
* `undefined` means "nothing to report yet" (the run is still going), which is
|
|
91
|
+
* what leaves the callers' running / tool-call rendering untouched.
|
|
92
|
+
*
|
|
93
|
+
* The error text is not capped here: rows are cut at the width pi renders them
|
|
94
|
+
* at, by `RowBlock` (see `row-block.ts`). A fixed character cap is what made
|
|
95
|
+
* these rows die at a third of the frame on a wide terminal.
|
|
96
|
+
*/
|
|
97
|
+
export function buildOutcomeLine(
|
|
98
|
+
data: { outcome?: RunOutcome | undefined; error: string | undefined },
|
|
99
|
+
theme: { fg: (token: string, text: string) => string },
|
|
100
|
+
): string | undefined {
|
|
101
|
+
if (data.error) return theme.fg("error", "✗ " + data.error);
|
|
102
|
+
if (data.outcome === "truncated") {
|
|
103
|
+
return theme.fg("warning", "⚠ Truncated — hit the output token limit, answer is partial");
|
|
104
|
+
}
|
|
105
|
+
if (data.outcome === "aborted") return theme.fg("warning", "⚠ Aborted");
|
|
106
|
+
if (data.outcome === "completed") return theme.fg("success", "✓ Done");
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
|
|
67
110
|
export interface StatsData {
|
|
68
111
|
turns: number | undefined;
|
|
69
112
|
toolCount: number | undefined;
|
|
@@ -96,9 +139,9 @@ export function buildStatsLine(
|
|
|
96
139
|
return parts.map(p => theme.fg("dim", "· " + p)).join(" ");
|
|
97
140
|
}
|
|
98
141
|
|
|
99
|
-
// Build an agent label for display: "agentName:
|
|
100
|
-
//
|
|
101
|
-
//
|
|
142
|
+
// Build an agent label for display: "agentName: task preview".
|
|
143
|
+
// The task is passed through whole: the row it lands on is cut at the render
|
|
144
|
+
// width by `RowBlock`, so the preview fills whatever frame pi draws it in.
|
|
102
145
|
export function buildAgentLabel(
|
|
103
146
|
agent: string,
|
|
104
147
|
task: string | undefined,
|
|
@@ -106,7 +149,7 @@ export function buildAgentLabel(
|
|
|
106
149
|
): string {
|
|
107
150
|
const name = theme.bold(agent);
|
|
108
151
|
if (task) {
|
|
109
|
-
return name + theme.fg("dim", ": " +
|
|
152
|
+
return name + theme.fg("dim", ": " + task);
|
|
110
153
|
}
|
|
111
154
|
return name;
|
|
112
155
|
}
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
2
|
import * as fc from "fast-check";
|
|
3
3
|
import { validateAgentName, serializeAgent, AGENT_NAME_REGEX } from "./frontmatter-io.js";
|
|
4
|
+
// Imported here rather than inside the tests below: nothing in this file mocks
|
|
5
|
+
// `parseFrontmatter`, so a dynamic `await import()` in a test body only served
|
|
6
|
+
// to charge whichever test ran first with loading the whole pi-coding-agent
|
|
7
|
+
// package — measured at 2.6s alone, 6-7.8s on a loaded machine, against a 5s
|
|
8
|
+
// per-test timeout. That is the "Test timed out in 5000ms" flake, and it lands
|
|
9
|
+
// on collection instead, where no test budget is spent.
|
|
10
|
+
import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
|
|
4
11
|
import type { AgentConfig } from "./agents.js";
|
|
5
12
|
|
|
6
13
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
@@ -117,3 +124,58 @@ describe("serializeAgent", () => {
|
|
|
117
124
|
);
|
|
118
125
|
});
|
|
119
126
|
});
|
|
127
|
+
|
|
128
|
+
// ── mcp round-trip (the writer's half of ADR 0028) ──────────────────────────
|
|
129
|
+
|
|
130
|
+
describe("serializeAgent: mcp opt-in", () => {
|
|
131
|
+
it("writes the mcp key when the agent opts in", () => {
|
|
132
|
+
const output = serializeAgent(makeAgent({ tools: ["read", "bash"], mcp: true }));
|
|
133
|
+
expect(output).toContain("mcp: true");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("writes no mcp key when the field is absent or false (closed is the default)", () => {
|
|
137
|
+
expect(serializeAgent(makeAgent({ tools: ["read"] }))).not.toContain("mcp:");
|
|
138
|
+
expect(serializeAgent(makeAgent({ tools: ["read"], mcp: false }))).not.toContain("mcp:");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("survives a write → read round trip as a real boolean, not a string", () => {
|
|
142
|
+
const output = serializeAgent(makeAgent({ tools: ["read", "bash"], mcp: true }));
|
|
143
|
+
const { frontmatter } = parseFrontmatter(output);
|
|
144
|
+
expect(frontmatter.mcp).toBe(true);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("keeps the tools list intact alongside the mcp key", () => {
|
|
148
|
+
const output = serializeAgent(makeAgent({ tools: ["read", "bash"], mcp: true }));
|
|
149
|
+
expect(output).toContain("tools: read, bash");
|
|
150
|
+
expect(output).toContain("mcp: true");
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// ── One `tools:` key, whatever the config object looks like ───────────────
|
|
155
|
+
|
|
156
|
+
describe("serializeAgent: never writes two tools keys", () => {
|
|
157
|
+
// The read path preserves an unreadable `tools:` in `extraFields` so a save
|
|
158
|
+
// cannot delete a line the user typed, while `applyToolEntries` owns the real
|
|
159
|
+
// `tools` field. If both are populated the file gets two `tools:` keys and the
|
|
160
|
+
// last one wins on the next read — which is the broken one.
|
|
161
|
+
const toolsKeys = (yaml: string): string[] =>
|
|
162
|
+
yaml.split("\n").filter((line) => /^tools:/.test(line));
|
|
163
|
+
|
|
164
|
+
it("writes the real field and drops the preserved raw value when both exist", () => {
|
|
165
|
+
const output = serializeAgent(
|
|
166
|
+
makeAgent({ tools: ["read"], extraFields: { tools: "read, +web_search", owner: "x" } }),
|
|
167
|
+
);
|
|
168
|
+
expect(toolsKeys(output)).toEqual(["tools: read"]);
|
|
169
|
+
expect(parseFrontmatter(output).frontmatter.tools).toBe("read");
|
|
170
|
+
// The unrelated extra still round-trips.
|
|
171
|
+
expect(output).toContain("owner: x");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("still writes a preserved raw value when there is no parsed list to replace it", () => {
|
|
175
|
+
// This is the preservation the read path promised: an agent whose `tools:`
|
|
176
|
+
// was rejected keeps its line on save, rather than being silently rewritten
|
|
177
|
+
// without it.
|
|
178
|
+
const output = serializeAgent(makeAgent({ extraFields: { tools: "read, +web_search" } }));
|
|
179
|
+
expect(toolsKeys(output)).toEqual(["tools: read, +web_search"]);
|
|
180
|
+
});
|
|
181
|
+
});
|
package/src/frontmatter-io.ts
CHANGED
|
@@ -65,8 +65,17 @@ export function serializeAgent(config: AgentConfig): string {
|
|
|
65
65
|
lines.push(`description: ${quoteYamlValue(config.description)}`);
|
|
66
66
|
|
|
67
67
|
// Optional known fields
|
|
68
|
-
|
|
69
|
-
|
|
68
|
+
const toolsLine =
|
|
69
|
+
config.tools && config.tools.length > 0
|
|
70
|
+
? `tools: ${quoteYamlValue(config.tools.join(", "))}`
|
|
71
|
+
: undefined;
|
|
72
|
+
if (toolsLine !== undefined) {
|
|
73
|
+
lines.push(toolsLine);
|
|
74
|
+
}
|
|
75
|
+
// Only the opt-in is written: a closed allowlist is the default, so the
|
|
76
|
+
// absence of the key is the meaningful statement (ADR 0028).
|
|
77
|
+
if (config.mcp === true) {
|
|
78
|
+
lines.push("mcp: true");
|
|
70
79
|
}
|
|
71
80
|
if (config.model) {
|
|
72
81
|
lines.push(`model: ${quoteYamlValue(config.model)}`);
|
|
@@ -78,6 +87,11 @@ export function serializeAgent(config: AgentConfig): string {
|
|
|
78
87
|
// Extra fields (sorted alphabetically)
|
|
79
88
|
if (config.extraFields) {
|
|
80
89
|
for (const key of Object.keys(config.extraFields).sort()) {
|
|
90
|
+
// `extraFields.tools` is the read path's copy of a `tools:` line it could
|
|
91
|
+
// not read, kept so a save cannot delete it. It only gets to speak for
|
|
92
|
+
// `tools:` when there is no parsed list to speak for it — otherwise this
|
|
93
|
+
// loop writes a second `tools:` key and the later one wins on reload.
|
|
94
|
+
if (key === "tools" && toolsLine !== undefined) continue;
|
|
81
95
|
const value = config.extraFields[key]!;
|
|
82
96
|
if (typeof value === "string") {
|
|
83
97
|
lines.push(`${key}: ${quoteYamlValue(value)}`);
|
package/src/handlers.test.ts
CHANGED
|
@@ -4,9 +4,27 @@ import { extractArgsPreview } from "./handlers.js";
|
|
|
4
4
|
// ── extractArgsPreview ──────────────────────────────────────────────────────
|
|
5
5
|
|
|
6
6
|
describe("extractArgsPreview", () => {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
// The preview feeds a transcript row, and the row is what decides how much of
|
|
8
|
+
// it fits (`RowBlock` cuts at the width pi renders at). A cap here is therefore
|
|
9
|
+
// a second, invisible limit: cut it tighter than any terminal is wide and the
|
|
10
|
+
// row stops short of the frame with no cut marker, which is the defect #108
|
|
11
|
+
// set out to remove. pi's own bash row keeps the command whole for the same
|
|
12
|
+
// reason. It stays bounded, because the preview is carried in the streamed
|
|
13
|
+
// progress object for the life of the run.
|
|
14
|
+
it("keeps enough of a long command for a wide frame to draw", () => {
|
|
15
|
+
const long = "a".repeat(300);
|
|
16
|
+
expect(extractArgsPreview(long)).toBe(long);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("still bounds what it returns, since the preview is streamed", () => {
|
|
20
|
+
const preview = extractArgsPreview("a".repeat(100_000));
|
|
21
|
+
expect(preview.length).toBeLessThan(1_000);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("bounds the serialised form of object args too", () => {
|
|
25
|
+
const preview = extractArgsPreview({ a: "b".repeat(100_000), c: 1 });
|
|
26
|
+
expect(preview.length).toBeLessThan(1_000);
|
|
27
|
+
expect(preview.length).toBeGreaterThanOrEqual(300);
|
|
10
28
|
});
|
|
11
29
|
|
|
12
30
|
it("replaces newlines in string args", () => {
|
package/src/handlers.ts
CHANGED
|
@@ -1,7 +1,19 @@
|
|
|
1
1
|
import type { StreamState } from "./types.js";
|
|
2
2
|
|
|
3
3
|
// Truncation limits for previews
|
|
4
|
-
|
|
4
|
+
//
|
|
5
|
+
// The args preview is drawn on a transcript row, and the row is what decides how
|
|
6
|
+
// much of it fits: `RowBlock` cuts at the width pi hands `render()` (see
|
|
7
|
+
// `row-block.ts`). A cap here is therefore a second, invisible character limit —
|
|
8
|
+
// the very defect #108 removed — because a preview shorter than the frame ends
|
|
9
|
+
// mid-word with no cut marker. pi's own bash row keeps the command whole for the
|
|
10
|
+
// same reason (`formatShellCall`). So this bound sits above anything a terminal
|
|
11
|
+
// can draw, and exists only to keep the preview from growing the streamed
|
|
12
|
+
// progress object without limit (it is carried for the life of the run).
|
|
13
|
+
const ARGS_PREVIEW_MAX = 512;
|
|
14
|
+
// `recentOutput` is carried in the progress object but drawn by no row, so it
|
|
15
|
+
// keeps the old, tighter bound: widening it would only grow what is streamed.
|
|
16
|
+
const OUTPUT_LINE_MAX = 120;
|
|
5
17
|
const TOOL_CALLS_MAX = 50;
|
|
6
18
|
const RECENT_OUTPUT_MAX = 50;
|
|
7
19
|
|
|
@@ -97,13 +109,13 @@ export function handleToolResult(state: StreamState, event: JsonEvent): void {
|
|
|
97
109
|
|
|
98
110
|
if (typeof content === "string" && content.trim()) {
|
|
99
111
|
const lines = content.split("\n").filter((l) => l.trim());
|
|
100
|
-
state.recentOutput.push(`[${toolName}] ${lines[0]?.slice(0,
|
|
112
|
+
state.recentOutput.push(`[${toolName}] ${lines[0]?.slice(0, OUTPUT_LINE_MAX)}`);
|
|
101
113
|
} else if (Array.isArray(content)) {
|
|
102
114
|
for (const part of content) {
|
|
103
115
|
if (part.type === "text" && (part.text as string)?.trim()) {
|
|
104
116
|
const text = part.text as string;
|
|
105
117
|
const lines = text.split("\n").filter((l) => l.trim());
|
|
106
|
-
state.recentOutput.push(`[${toolName}] ${lines[0]?.slice(0,
|
|
118
|
+
state.recentOutput.push(`[${toolName}] ${lines[0]?.slice(0, OUTPUT_LINE_MAX)}`);
|
|
107
119
|
break;
|
|
108
120
|
}
|
|
109
121
|
}
|
|
@@ -126,6 +138,13 @@ export function handleMessageEnd(state: StreamState, event: JsonEvent): void {
|
|
|
126
138
|
state.model = message.model as string;
|
|
127
139
|
}
|
|
128
140
|
|
|
141
|
+
// How this response ended. The LAST assistant message's stopReason is what
|
|
142
|
+
// decides the run's outcome (`length` ⇒ truncated, `aborted` ⇒ cancelled,
|
|
143
|
+
// `error` ⇒ failed) — the exit code cannot tell them apart.
|
|
144
|
+
if (typeof message.stopReason === "string") {
|
|
145
|
+
state.lastStopReason = message.stopReason;
|
|
146
|
+
}
|
|
147
|
+
|
|
129
148
|
// Collect text + thinking output
|
|
130
149
|
const content = message.content as Array<Record<string, unknown>> | string | undefined;
|
|
131
150
|
if (typeof content === "string" && content.trim()) {
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task 11 item 2 — `subagent` as a programmatic tool.
|
|
3
|
+
*
|
|
4
|
+
* `details` is the renderer's payload: per-run reports plus the live-progress
|
|
5
|
+
* blob (`progress`, with the child's whole streamed output and its tool-call
|
|
6
|
+
* history). A script wants the run reports, not the scrollback, so the
|
|
7
|
+
* structured content is the report set with `progress` left out — see the
|
|
8
|
+
* comment at the declaration in `index.ts`.
|
|
9
|
+
*
|
|
10
|
+
* `subagent` already reports failure as `isError` rather than throwing (ADR
|
|
11
|
+
* 0028), and that shape is unchanged here; what is new is that the failure
|
|
12
|
+
* results carry no structured content, so a script keeps rejecting with the
|
|
13
|
+
* text it rejects with today.
|
|
14
|
+
*
|
|
15
|
+
* `./agents.js` and `./execute.js` are mocked (as `index.test.ts` does) so the
|
|
16
|
+
* assertions are about the result contract rather than a real child process.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
20
|
+
import { Value } from "typebox/value";
|
|
21
|
+
|
|
22
|
+
const discoverAgents = vi.hoisted(() => vi.fn());
|
|
23
|
+
const executeSubagent = vi.hoisted(() => vi.fn());
|
|
24
|
+
const executeParallel = vi.hoisted(() => vi.fn());
|
|
25
|
+
|
|
26
|
+
vi.mock("./agents.js", async (importOriginal) => ({
|
|
27
|
+
...((await importOriginal()) as Record<string, unknown>),
|
|
28
|
+
discoverAgents,
|
|
29
|
+
}));
|
|
30
|
+
vi.mock("./execute.js", () => ({ executeSubagent, executeParallel }));
|
|
31
|
+
|
|
32
|
+
const { registerSubagent } = await import("./index.js");
|
|
33
|
+
const { formatSingleResult, formatParallelResults } = await import("./format.js");
|
|
34
|
+
|
|
35
|
+
type RegisteredTool = {
|
|
36
|
+
name: string;
|
|
37
|
+
outputSchema?: unknown;
|
|
38
|
+
execute: (
|
|
39
|
+
id: string,
|
|
40
|
+
params: unknown,
|
|
41
|
+
signal: AbortSignal | undefined,
|
|
42
|
+
onUpdate: unknown,
|
|
43
|
+
ctx: unknown,
|
|
44
|
+
) => Promise<{
|
|
45
|
+
content: Array<{ type: "text"; text: string }>;
|
|
46
|
+
details: unknown;
|
|
47
|
+
structuredContent?: unknown;
|
|
48
|
+
isError?: boolean;
|
|
49
|
+
}>;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
function subagent(): RegisteredTool {
|
|
53
|
+
const tools: RegisteredTool[] = [];
|
|
54
|
+
registerSubagent({
|
|
55
|
+
registerTool: (t: unknown) => tools.push(t as RegisteredTool),
|
|
56
|
+
on: () => {},
|
|
57
|
+
getAllTools: () => [{ name: "read" }, { name: "bash" }],
|
|
58
|
+
events: { emit: () => {}, on: () => () => {} },
|
|
59
|
+
} as never);
|
|
60
|
+
const tool = tools.find((t) => t.name === "subagent");
|
|
61
|
+
if (!tool) throw new Error("subagent tool was not registered");
|
|
62
|
+
return tool;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function makeCtx(overrides: Record<string, unknown> = {}) {
|
|
66
|
+
return {
|
|
67
|
+
cwd: "/tmp",
|
|
68
|
+
model: undefined,
|
|
69
|
+
modelRegistry: { getAll: () => [], getAvailable: () => [] },
|
|
70
|
+
hasUI: true,
|
|
71
|
+
ui: { notify: vi.fn() },
|
|
72
|
+
...overrides,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A completed run, with the live-progress blob a real stream would attach. */
|
|
77
|
+
const okResult = {
|
|
78
|
+
agent: "reviewer",
|
|
79
|
+
task: "review it",
|
|
80
|
+
childSessionId: "child-uuid",
|
|
81
|
+
exitCode: 0,
|
|
82
|
+
outcome: "completed" as const,
|
|
83
|
+
usage: { input: 10, output: 5, cacheRead: 1, cacheWrite: 2, cost: 0.01, turns: 1 },
|
|
84
|
+
model: "anthropic/claude-opus-4.5",
|
|
85
|
+
finalOutput: "all good",
|
|
86
|
+
error: undefined,
|
|
87
|
+
progress: {
|
|
88
|
+
agent: "reviewer",
|
|
89
|
+
status: "completed" as const,
|
|
90
|
+
outcome: "completed" as const,
|
|
91
|
+
task: "review it",
|
|
92
|
+
currentTool: undefined,
|
|
93
|
+
currentToolArgs: undefined,
|
|
94
|
+
currentToolStartedAt: undefined,
|
|
95
|
+
toolCount: 1,
|
|
96
|
+
inputTokens: 10,
|
|
97
|
+
outputTokens: 5,
|
|
98
|
+
tokens: 15,
|
|
99
|
+
cost: 0.01,
|
|
100
|
+
durationMs: 10,
|
|
101
|
+
error: undefined,
|
|
102
|
+
model: "anthropic/claude-opus-4.5",
|
|
103
|
+
output: "the child's entire scrollback, which a script does not need twice",
|
|
104
|
+
recentOutput: ["x"],
|
|
105
|
+
toolCalls: [{ name: "read", argsPreview: "file", error: false }],
|
|
106
|
+
},
|
|
107
|
+
progressSummary: { toolCount: 1, tokens: 15, durationMs: 10 },
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
function agentConfig(overrides: Record<string, unknown> = {}) {
|
|
111
|
+
return {
|
|
112
|
+
name: "reviewer",
|
|
113
|
+
description: "d",
|
|
114
|
+
systemPrompt: "p",
|
|
115
|
+
source: "user",
|
|
116
|
+
filePath: "/tmp/agents/reviewer.md",
|
|
117
|
+
...overrides,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function check(tool: RegisteredTool, value: unknown): boolean {
|
|
122
|
+
expect(tool.outputSchema, "subagent declares no outputSchema").toBeDefined();
|
|
123
|
+
return Value.Check(tool.outputSchema as never, value);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
beforeEach(() => {
|
|
127
|
+
discoverAgents.mockReset();
|
|
128
|
+
executeSubagent.mockReset();
|
|
129
|
+
executeParallel.mockReset();
|
|
130
|
+
discoverAgents.mockReturnValue([agentConfig()]);
|
|
131
|
+
executeSubagent.mockResolvedValue(okResult);
|
|
132
|
+
executeParallel.mockResolvedValue([okResult]);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe("subagent outputSchema", () => {
|
|
136
|
+
it("declares one, describing mode + results", () => {
|
|
137
|
+
const tool = subagent();
|
|
138
|
+
expect(tool.outputSchema).toBeDefined();
|
|
139
|
+
const props = (tool.outputSchema as { properties?: Record<string, unknown> }).properties ?? {};
|
|
140
|
+
expect(Object.keys(props).sort()).toEqual(["mode", "results"]);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe("subagent structuredContent — single mode", () => {
|
|
145
|
+
it("a completed run reports itself structurally, without the live-progress blob", async () => {
|
|
146
|
+
const tool = subagent();
|
|
147
|
+
const result = await tool.execute("c1", { agent: "reviewer", task: "review it" }, undefined, undefined, makeCtx());
|
|
148
|
+
|
|
149
|
+
expect(result.content[0]!.text).toBe(formatSingleResult(okResult as never));
|
|
150
|
+
expect(result.isError).toBe(false);
|
|
151
|
+
expect(result.structuredContent).toEqual({
|
|
152
|
+
mode: "single",
|
|
153
|
+
results: [
|
|
154
|
+
{
|
|
155
|
+
agent: "reviewer",
|
|
156
|
+
task: "review it",
|
|
157
|
+
childSessionId: "child-uuid",
|
|
158
|
+
exitCode: 0,
|
|
159
|
+
outcome: "completed",
|
|
160
|
+
usage: { input: 10, output: 5, cacheRead: 1, cacheWrite: 2, cost: 0.01, turns: 1 },
|
|
161
|
+
model: "anthropic/claude-opus-4.5",
|
|
162
|
+
finalOutput: "all good",
|
|
163
|
+
progressSummary: { toolCount: 1, tokens: 15, durationMs: 10 },
|
|
164
|
+
},
|
|
165
|
+
],
|
|
166
|
+
});
|
|
167
|
+
expect(check(tool, result.structuredContent)).toBe(true);
|
|
168
|
+
// The scrollback stays in `details` for the renderer, out of the structured payload.
|
|
169
|
+
expect(JSON.stringify(result.structuredContent)).not.toContain("scrollback");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("a failed run is isError with no structured content, content text unchanged", async () => {
|
|
173
|
+
const failed = { ...okResult, outcome: undefined, error: "child crashed", finalOutput: undefined };
|
|
174
|
+
executeSubagent.mockResolvedValueOnce(failed);
|
|
175
|
+
|
|
176
|
+
const tool = subagent();
|
|
177
|
+
const result = await tool.execute("c1", { agent: "reviewer", task: "review it" }, undefined, undefined, makeCtx());
|
|
178
|
+
|
|
179
|
+
expect(result.isError).toBe(true);
|
|
180
|
+
expect(result.content[0]!.text).toBe(formatSingleResult(failed as never));
|
|
181
|
+
expect("structuredContent" in result).toBe(false);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("an unknown agent stays isError with the same text, and carries no structured content", async () => {
|
|
185
|
+
const tool = subagent();
|
|
186
|
+
const result = await tool.execute("c1", { agent: "ghost", task: "t" }, undefined, undefined, makeCtx());
|
|
187
|
+
|
|
188
|
+
expect(result.isError).toBe(true);
|
|
189
|
+
expect(result.content[0]!.text).toContain('Unknown agent: "ghost"');
|
|
190
|
+
expect("structuredContent" in result).toBe(false);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("a call with no task stays isError with the same text", async () => {
|
|
194
|
+
const tool = subagent();
|
|
195
|
+
const result = await tool.execute("c1", {}, undefined, undefined, makeCtx());
|
|
196
|
+
|
|
197
|
+
expect(result.isError).toBe(true);
|
|
198
|
+
expect(result.content[0]!.text).toBe("Missing task parameter");
|
|
199
|
+
expect("structuredContent" in result).toBe(false);
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
describe("subagent structuredContent — parallel mode", () => {
|
|
204
|
+
it("reports every run, in task order", async () => {
|
|
205
|
+
// Both agents must exist, or the batch is rejected before anything runs.
|
|
206
|
+
discoverAgents.mockReturnValue([agentConfig(), agentConfig({ name: "explorer" })]);
|
|
207
|
+
const second = { ...okResult, agent: "explorer", task: "look around", finalOutput: "noted" };
|
|
208
|
+
executeParallel.mockResolvedValueOnce([okResult, second]);
|
|
209
|
+
|
|
210
|
+
const tool = subagent();
|
|
211
|
+
const result = await tool.execute(
|
|
212
|
+
"c2",
|
|
213
|
+
{ tasks: [{ agent: "reviewer", task: "review it" }, { agent: "explorer", task: "look around" }] },
|
|
214
|
+
undefined,
|
|
215
|
+
undefined,
|
|
216
|
+
makeCtx(),
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
expect(result.content[0]!.text).toBe(formatParallelResults([okResult, second] as never));
|
|
220
|
+
const structured = result.structuredContent as { mode: string; results: Array<{ agent: string }> };
|
|
221
|
+
expect(structured.mode).toBe("parallel");
|
|
222
|
+
expect(structured.results.map((r) => r.agent)).toEqual(["reviewer", "explorer"]);
|
|
223
|
+
expect(check(tool, result.structuredContent)).toBe(true);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("a rejected batch (unknown agent in one task) is isError, no structured content", async () => {
|
|
227
|
+
const tool = subagent();
|
|
228
|
+
const result = await tool.execute(
|
|
229
|
+
"c2",
|
|
230
|
+
{ tasks: [{ agent: "ghost", task: "t" }] },
|
|
231
|
+
undefined,
|
|
232
|
+
undefined,
|
|
233
|
+
makeCtx(),
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
expect(result.isError).toBe(true);
|
|
237
|
+
expect(result.content[0]!.text).toContain("Unknown agent(s)");
|
|
238
|
+
expect("structuredContent" in result).toBe(false);
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// The outcomes above tests never produced. `truncated` and `aborted` are declared
|
|
243
|
+
// in `SUBAGENT_OUTPUT_SCHEMA`'s outcome union, and ADR 0028 makes them ordinary
|
|
244
|
+
// successes (something was produced, so the run is not `isError`) — so their
|
|
245
|
+
// reports reach the structured payload, and the declaration has to hold for them
|
|
246
|
+
// too. Nothing here was previously exercised: the suite only ever handed back a
|
|
247
|
+
// `completed` run.
|
|
248
|
+
describe("subagent structuredContent — the other run outcomes", () => {
|
|
249
|
+
it.each(["truncated", "aborted"] as const)("%s run: not an error, carries a report, validates", async (outcome) => {
|
|
250
|
+
discoverAgents.mockReturnValue([agentConfig()]);
|
|
251
|
+
executeSubagent.mockResolvedValueOnce({ ...okResult, outcome, finalOutput: "a partial answer" });
|
|
252
|
+
|
|
253
|
+
const tool = subagent();
|
|
254
|
+
const result = await tool.execute("c3", { agent: "reviewer", task: "review it" }, undefined, undefined, makeCtx());
|
|
255
|
+
|
|
256
|
+
expect(result.isError).toBe(false);
|
|
257
|
+
const report = (result.structuredContent as { results: Array<Record<string, unknown>> }).results[0]!;
|
|
258
|
+
expect(report["outcome"]).toBe(outcome);
|
|
259
|
+
expect(report["error"]).toBeUndefined();
|
|
260
|
+
expect(check(tool, result.structuredContent)).toBe(true);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("a run reporting almost nothing (no session id, model, output or summary) validates", async () => {
|
|
264
|
+
discoverAgents.mockReturnValue([]);
|
|
265
|
+
// The bare shape `executeSubagent` produces for a config-less run that had
|
|
266
|
+
// nothing else to report: every optional field absent, not present-and-null.
|
|
267
|
+
executeSubagent.mockResolvedValueOnce({
|
|
268
|
+
agent: "subagent",
|
|
269
|
+
task: "t",
|
|
270
|
+
exitCode: 0,
|
|
271
|
+
usage: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 1 },
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
const tool = subagent();
|
|
275
|
+
const result = await tool.execute("c4", { task: "t" }, undefined, undefined, makeCtx());
|
|
276
|
+
|
|
277
|
+
expect(result.structuredContent).toEqual({
|
|
278
|
+
mode: "single",
|
|
279
|
+
results: [
|
|
280
|
+
{
|
|
281
|
+
agent: "subagent",
|
|
282
|
+
task: "t",
|
|
283
|
+
exitCode: 0,
|
|
284
|
+
usage: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 1 },
|
|
285
|
+
},
|
|
286
|
+
],
|
|
287
|
+
});
|
|
288
|
+
expect(check(tool, result.structuredContent)).toBe(true);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("a parallel batch with one failed run reports it per-run, inside a valid payload", async () => {
|
|
292
|
+
discoverAgents.mockReturnValue([agentConfig(), agentConfig({ name: "explorer" })]);
|
|
293
|
+
executeParallel.mockResolvedValueOnce([
|
|
294
|
+
okResult,
|
|
295
|
+
{ ...okResult, agent: "explorer", task: "look around", outcome: undefined, finalOutput: undefined, error: "child died" },
|
|
296
|
+
]);
|
|
297
|
+
|
|
298
|
+
const tool = subagent();
|
|
299
|
+
const result = await tool.execute(
|
|
300
|
+
"c5",
|
|
301
|
+
{ tasks: [{ agent: "reviewer", task: "review it" }, { agent: "explorer", task: "look around" }] },
|
|
302
|
+
undefined,
|
|
303
|
+
undefined,
|
|
304
|
+
makeCtx(),
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
// The batch is not `isError` — it is, and was before these schemas existed:
|
|
308
|
+
// one run failing inside a batch is reported per run, in the text (`✗ explorer`)
|
|
309
|
+
// and in the report (`error`). `error` is declared optional for exactly this.
|
|
310
|
+
expect(result.isError).toBeUndefined();
|
|
311
|
+
expect(result.content[0]!.text).toContain("✗ explorer");
|
|
312
|
+
const reports = (result.structuredContent as { results: Array<Record<string, unknown>> }).results;
|
|
313
|
+
expect(reports[1]!["error"]).toBe("child died");
|
|
314
|
+
expect(reports[0]!["error"]).toBeUndefined();
|
|
315
|
+
expect(check(tool, result.structuredContent)).toBe(true);
|
|
316
|
+
});
|
|
317
|
+
});
|