@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.
Files changed (47) hide show
  1. package/README.md +2 -2
  2. package/package.json +10 -10
  3. package/src/agent-panel.mcp.test.ts +76 -0
  4. package/src/agent-panel.tools.test.ts +162 -0
  5. package/src/agent-panel.ts +37 -8
  6. package/src/agent-panel.width-audit.test.ts +258 -0
  7. package/src/agents-parse.test.ts +252 -0
  8. package/src/agents.ts +72 -5
  9. package/src/compact.test.ts +136 -57
  10. package/src/compact.ts +57 -37
  11. package/src/dispatch.test.ts +73 -1
  12. package/src/dispatch.ts +20 -2
  13. package/src/execute.test.ts +9 -1
  14. package/src/execute.ts +26 -29
  15. package/src/expanded.test.ts +472 -0
  16. package/src/expanded.ts +202 -82
  17. package/src/format.test.ts +71 -29
  18. package/src/format.ts +71 -28
  19. package/src/frontmatter-io.test.ts +62 -0
  20. package/src/frontmatter-io.ts +16 -2
  21. package/src/handlers.test.ts +21 -3
  22. package/src/handlers.ts +22 -3
  23. package/src/index.output-schema.test.ts +317 -0
  24. package/src/index.test.ts +566 -0
  25. package/src/index.ts +169 -16
  26. package/src/render.ts +46 -36
  27. package/src/row-block.test.ts +244 -0
  28. package/src/row-block.ts +209 -0
  29. package/src/run-outcome.test.ts +71 -0
  30. package/src/run-outcome.ts +84 -0
  31. package/src/spawn.test.ts +204 -19
  32. package/src/spawn.ts +49 -17
  33. package/src/stream.test.ts +215 -17
  34. package/src/stream.ts +47 -9
  35. package/src/thinking-validation.test.ts +96 -0
  36. package/src/thinking-validation.ts +78 -0
  37. package/src/tool-declarations.test.ts +58 -0
  38. package/src/tool-row.width-audit.test.ts +720 -0
  39. package/src/tool-schema.test.ts +154 -0
  40. package/src/tool-schema.ts +106 -4
  41. package/src/tool-validation.test.ts +321 -0
  42. package/src/tool-validation.ts +262 -0
  43. package/src/types.ts +86 -0
  44. package/src/usage.test.ts +95 -0
  45. package/src/usage.ts +87 -0
  46. package/src/cost.test.ts +0 -62
  47. 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 = r.exitCode === 0 ? "✓" : "✗";
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 ?? "completed").trimEnd();
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: truncated task preview".
100
- // Truncation width of 60 keeps compact rows readable on standard terminal widths
101
- // while still showing meaningful task context.
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", ": " + truncLine(task, 60));
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
+ });
@@ -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
- if (config.tools && config.tools.length > 0) {
69
- lines.push(`tools: ${quoteYamlValue(config.tools.join(", "))}`);
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)}`);
@@ -4,9 +4,27 @@ import { extractArgsPreview } from "./handlers.js";
4
4
  // ── extractArgsPreview ──────────────────────────────────────────────────────
5
5
 
6
6
  describe("extractArgsPreview", () => {
7
- it("returns string args truncated", () => {
8
- const long = "a".repeat(200);
9
- expect(extractArgsPreview(long)).toBe("a".repeat(120));
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
- const ARGS_PREVIEW_MAX = 120;
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, ARGS_PREVIEW_MAX)}`);
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, ARGS_PREVIEW_MAX)}`);
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
+ });