@pi-archimedes/subagent 2.10.4 → 2.11.0

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 (46) hide show
  1. package/README.md +1 -1
  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 +61 -0
  16. package/src/expanded.ts +96 -80
  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 +486 -0
  25. package/src/index.ts +168 -8
  26. package/src/render.ts +46 -36
  27. package/src/row-block.test.ts +195 -0
  28. package/src/row-block.ts +186 -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.ts +85 -0
  40. package/src/tool-validation.test.ts +321 -0
  41. package/src/tool-validation.ts +262 -0
  42. package/src/types.ts +86 -0
  43. package/src/usage.test.ts +95 -0
  44. package/src/usage.ts +87 -0
  45. package/src/cost.test.ts +0 -62
  46. package/src/cost.ts +0 -8
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Width-aware rows for the subagent tool's transcript rows.
3
+ *
4
+ * pi draws a tool row at the width it is rendering at: bash truncates inside
5
+ * `VisualLinePreview`/`truncateToVisualLines`, which are handed `render(width)`'s
6
+ * width. That module is not importable (pi's export map is `.` and `./rpc-entry`),
7
+ * so the rows here get the same behaviour from pi-tui's own helpers — the
8
+ * sanctioned route, per ADR 0029 ("width maths goes through pi-tui helpers").
9
+ *
10
+ * Two kinds of content, because the subagent draws two kinds of thing:
11
+ *
12
+ * * a **row** — one logical line: the agent label, the stats line, the activity
13
+ * line, the outcome line. It is cut at the render width, so it fills the
14
+ * frame and truncates there, exactly like the bash row. Leaving it uncapped
15
+ * inside a `Text` is not the fix: `Text.render(width)` *wraps*, so a long task
16
+ * would become a multi-row block instead of one full-width row.
17
+ * * a **block** — multi-line content that is meant to be read in full (the
18
+ * expanded view's output, its task text, its tool-call history). It wraps,
19
+ * as it always has (ADR 0020: expanded output is deliberately untruncated).
20
+ *
21
+ * The width therefore arrives per-render and nothing is cached across renders
22
+ * except keyed by (content, width).
23
+ */
24
+
25
+ import { Text, sliceByColumn, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
26
+
27
+ /** The marker appended to a row that was cut. Plain text, so it inherits colour. */
28
+ export const ROW_ELLIPSIS = "...";
29
+
30
+ /** SGR reset, appended after a cut so the row's padding is not painted. */
31
+ const RESET = "\x1b[0m";
32
+
33
+ /** How `Text` renders a tab; kept identical so short rows stay byte-identical. */
34
+ const TAB_WIDTH = 3;
35
+
36
+ /** `row`: one line, truncated to the frame. `block`: wrapped, never cut. */
37
+ export type RowKind = "row" | "block";
38
+
39
+ export interface RowSegment {
40
+ readonly kind: RowKind;
41
+ readonly text: string;
42
+ }
43
+
44
+ /** One logical line that must stay one line at any width. */
45
+ export function row(text: string): RowSegment {
46
+ return { kind: "row", text };
47
+ }
48
+
49
+ /**
50
+ * What the row builders draw into: the two ways content reaches a `RowBlock`.
51
+ * Kept as a projection of `RowBlock` so a builder cannot ask for anything a row
52
+ * does not do (no `tui`, no width, no renderer — see ADR 0027).
53
+ */
54
+ export type RowSink = Pick<RowBlock, "setText" | "setSegments">;
55
+
56
+ /** Multi-line content that keeps pi-tui's wrapping. */
57
+ export function block(text: string): RowSegment {
58
+ return { kind: "block", text };
59
+ }
60
+
61
+ /** Treat every logical line of `text` as a row (the compact view is all rows). */
62
+ export function rowsFromText(text: string): RowSegment[] {
63
+ return text.split("\n").map(row);
64
+ }
65
+
66
+ /**
67
+ * Cut one styled row to `width` display cells.
68
+ *
69
+ * The ellipsis stays inside the styled span — the property the old character-based
70
+ * `truncLine` existed to keep, and the reason `truncateToWidth` is not used for
71
+ * the cut: it emits `…\x1b[0m…`, i.e. the dots land *after* a colour reset, so a
72
+ * truncated span loses its colour (`row-block.test.ts` pins those bytes).
73
+ * `sliceByColumn` cuts on display cells instead — wide glyphs and emoji included —
74
+ * and leaves the ANSI state open where it cuts, so a plain `"..."` appended after
75
+ * it inherits whatever colour the row was drawing at that point. The trailing
76
+ * reset then closes what was left open, so the row's padding is not painted.
77
+ *
78
+ * A newline is a hard boundary: the row stops at the first one rather than
79
+ * bleeding into the next row, and says so with the ellipsis.
80
+ */
81
+ export function truncateRow(line: string, width: number): string {
82
+ if (width <= 0) return "";
83
+ const nlIdx = line.indexOf("\n");
84
+ const firstLine = nlIdx === -1 ? line : line.slice(0, nlIdx);
85
+ if (nlIdx === -1 && visibleWidth(firstLine) <= width) return firstLine;
86
+
87
+ const budget = width - ROW_ELLIPSIS.length;
88
+ // No room for both content and dots: show as many dots as the frame holds, so
89
+ // the row still reads as "cut" rather than as "the whole task".
90
+ if (budget <= 0) return sliceByColumn(ROW_ELLIPSIS, 0, width, true);
91
+ return sliceByColumn(firstLine, 0, budget, true) + ROW_ELLIPSIS + RESET;
92
+ }
93
+
94
+ /** Pad a rendered line out to the frame, the way `Text` does. */
95
+ function padToWidth(line: string, width: number): string {
96
+ const used = visibleWidth(line);
97
+ return used >= width ? line : line + " ".repeat(width - used);
98
+ }
99
+
100
+ /**
101
+ * The rows of one subagent result, truncated at the width pi renders them at.
102
+ *
103
+ * A `Text` subclass, because everything that already knows how to hold a row —
104
+ * pi's own tool shell, and this package's `setText`-shaped renderers — expects
105
+ * one. `setText` treats every logical line as a row; `setSegments` is for the
106
+ * views that mix rows with wrapped blocks (expanded).
107
+ */
108
+ export class RowBlock extends Text {
109
+ private segments: RowSegment[];
110
+ private content: string;
111
+ private cacheKey: string | undefined;
112
+ private cacheWidth: number | undefined;
113
+ private cacheLines: string[] | undefined;
114
+
115
+ constructor(text = "") {
116
+ // Edge-to-edge: pi's tool shell supplies the row's inset (a Box with its own
117
+ // padding), exactly as it did for the `Text` this replaces.
118
+ super(text, 0, 0);
119
+ this.segments = rowsFromText(text);
120
+ this.content = text;
121
+ }
122
+
123
+ /** Every logical line of `text` is a row. */
124
+ override setText(text: string): void {
125
+ super.setText(text);
126
+ this.content = text;
127
+ this.segments = rowsFromText(text);
128
+ this.cacheKey = undefined;
129
+ this.cacheWidth = undefined;
130
+ this.cacheLines = undefined;
131
+ }
132
+
133
+ /** Mixed content: rows truncated to the frame, blocks wrapped. */
134
+ setSegments(segments: RowSegment[]): void {
135
+ const text = segments.map((segment) => segment.text).join("\n");
136
+ super.setText(text);
137
+ this.content = text;
138
+ this.segments = segments;
139
+ this.cacheKey = undefined;
140
+ this.cacheWidth = undefined;
141
+ this.cacheLines = undefined;
142
+ }
143
+
144
+ override invalidate(): void {
145
+ super.invalidate();
146
+ this.cacheKey = undefined;
147
+ this.cacheWidth = undefined;
148
+ this.cacheLines = undefined;
149
+ }
150
+
151
+ override render(width: number): string[] {
152
+ // Cached across frames exactly the way `Text` caches: by content and width,
153
+ // because the duration in a running row changes the content between frames.
154
+ if (
155
+ this.cacheLines !== undefined &&
156
+ this.cacheKey === this.content &&
157
+ this.cacheWidth === width
158
+ ) {
159
+ return this.cacheLines;
160
+ }
161
+ const lines = this.layout(Math.max(1, width));
162
+ this.cacheKey = this.content;
163
+ this.cacheWidth = width;
164
+ this.cacheLines = lines;
165
+ return lines;
166
+ }
167
+
168
+ private layout(width: number): string[] {
169
+ // Nothing but blanks to draw: render no lines at all, matching `Text`.
170
+ if (this.segments.length === 0 || this.segments.every((s) => s.text.trim() === "")) {
171
+ return [];
172
+ }
173
+ const lines: string[] = [];
174
+ for (const segment of this.segments) {
175
+ const text = segment.text.replace(/\t/g, " ".repeat(TAB_WIDTH));
176
+ if (segment.kind === "block") {
177
+ for (const wrapped of wrapTextWithAnsi(text, width)) {
178
+ lines.push(padToWidth(wrapped, width));
179
+ }
180
+ } else {
181
+ lines.push(padToWidth(truncateRow(text, width), width));
182
+ }
183
+ }
184
+ return lines;
185
+ }
186
+ }
@@ -0,0 +1,71 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { decideRunOutcome } from "./run-outcome.js";
3
+
4
+ // The decision table, pinned where it lives. Every row is a case the child can
5
+ // actually produce on pi 1.2.0 — see the table comment in `run-outcome.ts` for
6
+ // where each one comes from.
7
+
8
+ const SETTLED = { settledAborted: false, stopReason: undefined, exitCode: 0, signal: undefined };
9
+
10
+ describe("decideRunOutcome", () => {
11
+ it("completed: exit 0, clean stopReason, settled not aborted", () => {
12
+ expect(decideRunOutcome({ ...SETTLED, stopReason: "stop" })).toBe("completed");
13
+ expect(decideRunOutcome(SETTLED)).toBe("completed");
14
+ });
15
+
16
+ it("truncated: stopReason length, whether the child exited 0 (json mode) or 1 (print mode)", () => {
17
+ expect(decideRunOutcome({ ...SETTLED, stopReason: "length" })).toBe("truncated");
18
+ expect(decideRunOutcome({ ...SETTLED, stopReason: "length", exitCode: 1 })).toBe("truncated");
19
+ });
20
+
21
+ it("aborted: agent_settled.aborted wins over a clean stopReason and a nonzero exit", () => {
22
+ expect(decideRunOutcome({ ...SETTLED, settledAborted: true })).toBe("aborted");
23
+ expect(decideRunOutcome({ ...SETTLED, settledAborted: true, exitCode: 143 })).toBe("aborted");
24
+ expect(decideRunOutcome({ ...SETTLED, settledAborted: true, stopReason: "length" })).toBe("aborted");
25
+ });
26
+
27
+ it("aborted: the provider aborted the last response", () => {
28
+ expect(decideRunOutcome({ ...SETTLED, stopReason: "aborted" })).toBe("aborted");
29
+ });
30
+
31
+ it("aborted: killed by a cancellation signal before it could settle", () => {
32
+ const killed = { ...SETTLED, exitCode: null };
33
+ expect(decideRunOutcome({ ...killed, signal: "SIGTERM" })).toBe("aborted");
34
+ expect(decideRunOutcome({ ...killed, signal: "SIGKILL" })).toBe("aborted");
35
+ expect(decideRunOutcome({ ...killed, signal: "SIGHUP" })).toBe("aborted");
36
+ expect(decideRunOutcome({ ...killed, signal: "SIGINT" })).toBe("aborted");
37
+ });
38
+
39
+ it("aborted: pi's own signal handlers exit 143/129 instead of dying by the signal", () => {
40
+ // Observed against a real 1.2.0 child SIGTERM'd mid-run: `close` reported
41
+ // code 143 and NO signal, because print mode catches SIGTERM/SIGHUP and
42
+ // calls process.exit(143)/process.exit(129) itself
43
+ // (`dist/modes/print-mode.js`, registerSignalHandlers). Reading only
44
+ // `signal` would call a cancellation a failure.
45
+ expect(decideRunOutcome({ ...SETTLED, exitCode: 143 })).toBe("aborted");
46
+ expect(decideRunOutcome({ ...SETTLED, exitCode: 129 })).toBe("aborted");
47
+ // A completed last response does not make a cancelled child un-cancelled.
48
+ expect(decideRunOutcome({ ...SETTLED, exitCode: 143, stopReason: "stop" })).toBe("aborted");
49
+ // And it outranks the shapes a cancellation can leave behind on the way out.
50
+ expect(decideRunOutcome({ ...SETTLED, exitCode: 143, stopReason: "error" })).toBe("aborted");
51
+ });
52
+
53
+ it("failed: the child's last response errored, whatever the exit code says", () => {
54
+ expect(decideRunOutcome({ ...SETTLED, stopReason: "error", exitCode: 1 })).toBe("failed");
55
+ expect(decideRunOutcome({ ...SETTLED, stopReason: "error", exitCode: 0 })).toBe("failed");
56
+ });
57
+
58
+ it("failed: a nonzero exit with no run behind it", () => {
59
+ expect(decideRunOutcome({ ...SETTLED, exitCode: 1 })).toBe("failed");
60
+ expect(decideRunOutcome({ ...SETTLED, exitCode: 2 })).toBe("failed");
61
+ // 130 (SIGINT) is NOT one of pi's handler exits — SIGINT is uncaught, so it
62
+ // arrives as a signal death above; anything else stays a failure.
63
+ expect(decideRunOutcome({ ...SETTLED, exitCode: 130 })).toBe("failed");
64
+ expect(decideRunOutcome({ ...SETTLED, exitCode: 137 })).toBe("failed");
65
+ });
66
+
67
+ it("failed: a crash on an unexpected signal is not someone cancelling", () => {
68
+ expect(decideRunOutcome({ ...SETTLED, exitCode: null, signal: "SIGSEGV" })).toBe("failed");
69
+ expect(decideRunOutcome({ ...SETTLED, exitCode: null, signal: undefined })).toBe("failed");
70
+ });
71
+ });
@@ -0,0 +1,84 @@
1
+ import type { RunOutcome } from "./types.js";
2
+
3
+ // ── The *Run outcome* decision table (CONTEXT.md vocabulary, ADR 0028) ──────
4
+ //
5
+ // The child's exit code cannot tell these apart, and after pi 1.2.0 it cannot
6
+ // even be trusted to mean "failed": print mode exits **1** when the final
7
+ // response hit the output token limit, so a useful-but-cut-off answer arrives as
8
+ // the same `1` as a crash. `stream.ts` used to reduce the child to `code ?? 1`,
9
+ // which destroyed that distinction exactly where the plan says it must not.
10
+ //
11
+ // The two signals that do carry it are the child's own `stopReason` (on the last
12
+ // assistant `message_end`) and pi 1.1.0's `agent_settled.aborted`. Note that
13
+ // `--mode json` — what the child actually runs — does *not* map `stopReason` onto
14
+ // the exit code at all (that mapping lives in print mode's text branch), so a
15
+ // json child reports truncation through `stopReason` alone and exits 0. Both
16
+ // shapes are handled: truncation is read from the child, never inferred from
17
+ // the code.
18
+ //
19
+ // | input | outcome |
20
+ // |-------------------------------------------------------------|-------------|
21
+ // | `agent_settled.aborted: true` | aborted |
22
+ // | last assistant `stopReason: "aborted"` | aborted |
23
+ // | killed by a cancellation signal (SIGKILL/SIGINT — uncaught) | aborted |
24
+ // | killed by an unexpected signal (SIGSEGV, …) | failed |
25
+ // | exit 143 / 129 (pi caught SIGTERM / SIGHUP and exited) | aborted |
26
+ // | last assistant `stopReason: "error"` | failed |
27
+ // | last assistant `stopReason: "length"` — exit 0 **or** 1 | truncated |
28
+ // | any other nonzero exit | failed |
29
+ // | exit 0, nothing above matched | completed |
30
+ //
31
+ // `truncated` is not an error and neither is `aborted`; `failed` is, and is what
32
+ // the caller reports as one (the result carries no outcome in that case).
33
+
34
+ /** Signals that mean "someone cancelled this run", as opposed to "it broke". */
35
+ const CANCELLATION_SIGNALS = new Set(["SIGTERM", "SIGKILL", "SIGHUP", "SIGINT"]);
36
+
37
+ /**
38
+ * The same cancellation, arriving as an exit code instead of a signal.
39
+ *
40
+ * print mode catches SIGTERM (and SIGHUP on non-Windows) and exits by itself —
41
+ * `process.exit(signal === "SIGHUP" ? 129 : 143)`
42
+ * (`pi-coding-agent/dist/modes/print-mode.js`, `registerSignalHandlers`) — so a
43
+ * real SIGTERM'd child closes with code 143 and **no** signal, which the table
44
+ * would otherwise read as an ordinary failure. Verified against a live 1.2.0
45
+ * child killed mid-run. SIGINT is not caught, so it still arrives as a signal
46
+ * death; SIGKILL cannot be caught at all. Hence exactly these two codes.
47
+ */
48
+ const CANCELLATION_EXIT_CODES = new Set([143, 129]);
49
+
50
+ export interface RunOutcomeInput {
51
+ /** `agent_settled.aborted` seen on the child's event stream. */
52
+ settledAborted: boolean;
53
+ /** `stopReason` of the last assistant message the child emitted. */
54
+ stopReason: string | undefined;
55
+ /** Process exit code, or `null` when it was terminated by a signal. */
56
+ exitCode: number | null;
57
+ /** The terminating signal, when there was one. */
58
+ signal: string | undefined;
59
+ }
60
+
61
+ /**
62
+ * Decide the run's terminal state, or `failed` when the run did not reach one.
63
+ *
64
+ * Order is the table above: cancellation first (an aborted run's `stopReason` may
65
+ * be `error` or `length` on the way out), then a signal death — which is decided
66
+ * before `stopReason`, because a killed child's last message says nothing about
67
+ * how it died — then the child's own account of how its last response ended
68
+ * (`error`, then `length`), then the exit code, which is the least informative of
69
+ * the three and therefore last. That last ordering is the whole point of the
70
+ * 1.2.0 change: exit 1 arrives for a crash and for a truncated answer alike, so
71
+ * `length` has to be read before the exit code or the distinction is lost again.
72
+ */
73
+ export function decideRunOutcome(input: RunOutcomeInput): RunOutcome | "failed" {
74
+ if (input.settledAborted) return "aborted";
75
+ if (input.stopReason === "aborted") return "aborted";
76
+ if (input.exitCode === null) {
77
+ return CANCELLATION_SIGNALS.has(input.signal ?? "") ? "aborted" : "failed";
78
+ }
79
+ if (CANCELLATION_EXIT_CODES.has(input.exitCode)) return "aborted";
80
+ if (input.stopReason === "error") return "failed";
81
+ if (input.stopReason === "length") return "truncated";
82
+ if (input.exitCode !== 0) return "failed";
83
+ return "completed";
84
+ }
package/src/spawn.test.ts CHANGED
@@ -1,18 +1,46 @@
1
1
  import { describe, it, expect, vi, afterEach } from "vitest";
2
2
  import * as net from "node:net";
3
- import { getBus, Events } from "@pi-archimedes/core/bus";
4
- import { startAskSocketServer } from "./spawn.js";
3
+ import { EventEmitter } from "node:events";
4
+ import type { ChildProcess } from "node:child_process";
5
+ import { Events } from "@pi-archimedes/core/events";
6
+ import type { AgentConfig } from "./agents.js";
7
+
8
+ // The child's argv is a contract with the `pi` CLI, and CLI strings are invisible
9
+ // to tsc — these tests are the only guard on what the child actually receives.
10
+ // `node:child_process` is mocked so no real pi process is spawned.
11
+ const spawnMock = vi.hoisted(() => vi.fn());
12
+ vi.mock("node:child_process", () => ({ spawn: spawnMock }));
13
+
14
+ const { startAskSocketServer, spawnSubagent } = await import("./spawn.js");
5
15
 
6
16
  // startAskSocketServer is tested directly (not via spawnSubagent, which would
7
- // spawn a real `pi` process). The real bus is used — subscriptions are
8
- // unsubscribed in afterEach so they don't leak into other test files in the
9
- // same worker (the bus is a globalThis singleton).
17
+ // spawn a real `pi` process). Each test gets its OWN bus stand-in — the host
18
+ // handle is passed in rather than reached for, so there is no process-wide
19
+ // singleton to leak between tests any more.
20
+
21
+ /** Stands in for the host's `createEventBus()` (same emit/on/unsubscribe shape). */
22
+ function fakeBus() {
23
+ const listeners = new Map<string, Array<(data: unknown) => void>>();
24
+ return {
25
+ emit(channel: string, data: unknown): void {
26
+ for (const handler of [...(listeners.get(channel) ?? [])]) handler(data);
27
+ },
28
+ on(channel: string, handler: (data: unknown) => void): () => void {
29
+ const subs = listeners.get(channel) ?? [];
30
+ subs.push(handler);
31
+ listeners.set(channel, subs);
32
+ return () => {
33
+ const current = listeners.get(channel) ?? [];
34
+ const idx = current.indexOf(handler);
35
+ if (idx !== -1) current.splice(idx, 1);
36
+ };
37
+ },
38
+ };
39
+ }
10
40
 
11
41
  const cleanups: Array<() => void> = [];
12
- const unsubs: Array<() => void> = [];
13
42
 
14
43
  afterEach(() => {
15
- unsubs.splice(0).forEach((u) => u());
16
44
  cleanups.splice(0).forEach((c) => c());
17
45
  });
18
46
 
@@ -20,6 +48,83 @@ function track(fn: () => void): void {
20
48
  cleanups.push(fn);
21
49
  }
22
50
 
51
+ // ── spawnSubagent argv ──────────────────────────────────────────────────────
52
+
53
+ /** A minimal spawn target: the argv assertions never touch the process. */
54
+ function fakeSpawnResult(): ChildProcess {
55
+ const child = new EventEmitter() as ChildProcess & { pid: number };
56
+ Object.assign(child, { pid: 4242, killed: false, kill: () => true });
57
+ return child;
58
+ }
59
+
60
+ /** Run spawnSubagent against the mocked spawn and return the argv it received. */
61
+ function argvFor(agent: Partial<AgentConfig> | undefined): string[] {
62
+ spawnMock.mockReset();
63
+ spawnMock.mockReturnValue(fakeSpawnResult());
64
+ const events = fakeBus();
65
+ const socket = startAskSocketServer(agent?.name ?? "general", events);
66
+ track(socket.cleanup);
67
+ spawnSubagent({
68
+ task: "do the thing",
69
+ model: undefined,
70
+ activeModel: undefined,
71
+ cwd: "/tmp",
72
+ signal: undefined,
73
+ events,
74
+ agent: agent
75
+ ? { name: agent.name ?? "general", description: "d", systemPrompt: "p", source: "user", filePath: "/tmp/a.md", ...agent }
76
+ : undefined,
77
+ });
78
+ const call = spawnMock.mock.calls[0] as [string, string[]] | undefined;
79
+ if (!call) throw new Error("spawn was not called");
80
+ return call[1];
81
+ }
82
+
83
+ describe("spawnSubagent tool allowlist (closed set, ADR 0028)", () => {
84
+ afterEach(() => {
85
+ spawnMock.mockReset();
86
+ });
87
+
88
+ it("a non-empty tools allowlist also passes --no-mcp, so the list stays closed", () => {
89
+ const argv = argvFor({ tools: ["read", "bash"] });
90
+ expect(argv).toContain("--tools");
91
+ expect(argv[argv.indexOf("--tools") + 1]).toBe("read,bash");
92
+ expect(argv).toContain("--no-mcp");
93
+ });
94
+
95
+ it("mcp: true is the only way the allowlist stops being closed", () => {
96
+ const argv = argvFor({ tools: ["read", "bash"], mcp: true });
97
+ expect(argv).toContain("--tools");
98
+ expect(argv).not.toContain("--no-mcp");
99
+ });
100
+
101
+ it("an empty tools list changes neither the tool set nor MCP", () => {
102
+ const argv = argvFor({ tools: [] });
103
+ expect(argv).not.toContain("--tools");
104
+ expect(argv).not.toContain("--no-mcp");
105
+ });
106
+
107
+ it("mcp: true without an allowlist passes neither flag (nothing to close)", () => {
108
+ const argv = argvFor({ mcp: true });
109
+ expect(argv).not.toContain("--tools");
110
+ expect(argv).not.toContain("--no-mcp");
111
+ });
112
+
113
+ it("the recursion guard survives the closed allowlist", () => {
114
+ const argv = argvFor({ tools: ["read"] });
115
+ const excludeIdx = argv.indexOf("--exclude-tools");
116
+ expect(excludeIdx).toBeGreaterThan(-1);
117
+ expect(argv[excludeIdx + 1]).toContain("subagent");
118
+ });
119
+
120
+ it("an agent with no config at all gets neither --tools nor --no-mcp", () => {
121
+ const argv = argvFor(undefined);
122
+ expect(argv).not.toContain("--tools");
123
+ expect(argv).not.toContain("--no-mcp");
124
+ expect(argv).toContain("--exclude-tools");
125
+ });
126
+ });
127
+
23
128
  function connectClient(socketPath: string): Promise<net.Socket> {
24
129
  return new Promise((resolve, reject) => {
25
130
  const client = net.connect(socketPath, () => resolve(client));
@@ -40,12 +145,13 @@ function writeAskRequest(client: net.Socket, requestId: string, toolCallId?: str
40
145
 
41
146
  describe("startAskSocketServer", () => {
42
147
  it("a child ask_request carrying toolCallId → the bus ASK_REQUEST carries the same toolCallId", async () => {
43
- const { socketPath, cleanup } = startAskSocketServer("test-agent");
148
+ const events = fakeBus();
149
+ const { socketPath, cleanup } = startAskSocketServer("test-agent", events);
44
150
  track(cleanup);
45
151
  await new Promise((r) => setTimeout(r, 50)); // let the server listen
46
152
 
47
153
  const requests: unknown[] = [];
48
- unsubs.push(getBus().on(Events.ASK_REQUEST, (p) => requests.push(p)));
154
+ events.on(Events.ASK_REQUEST, (p) => requests.push(p));
49
155
 
50
156
  const client = await connectClient(socketPath);
51
157
  writeAskRequest(client, "req-1", "tool-9");
@@ -57,9 +163,9 @@ describe("startAskSocketServer", () => {
57
163
  expect(req.toolCallId).toBe("tool-9");
58
164
  expect(req.questions).toEqual([{ id: "q1", question: "Which?", options: [{ label: "A" }] }]);
59
165
 
60
- // Deliver the response (drains pending) before closing so no ASK_CANCEL
61
- // leaks onto the global bus queue for the next test
62
- getBus().emit(Events.ASK_RESPONSE, {
166
+ // Deliver the response (drains pending) before closing so no ASK_CANCEL is
167
+ // signalled for a request that was already answered.
168
+ events.emit(Events.ASK_RESPONSE, {
63
169
  requestId: "req-1",
64
170
  cancelled: false,
65
171
  results: [{ id: "q1", selectedOptions: ["A"] }],
@@ -69,15 +175,60 @@ describe("startAskSocketServer", () => {
69
175
  await new Promise((r) => setTimeout(r, 50)); // close handler ran (no-op)
70
176
  });
71
177
 
178
+ /**
179
+ * The ask round-trip, over the handle: the child's socket request goes out on
180
+ * the bus, the answer comes back on the SAME handle, and the server writes it
181
+ * back down the socket. Before this migration both halves went through the
182
+ * suite's `globalThis` bus; the transport changed, the socket contract did not.
183
+ */
184
+ it("round-trip: ask_request in → ASK_REQUEST out on the handle → ASK_RESPONSE in → ask_response back down the socket", async () => {
185
+ const events = fakeBus();
186
+ const { socketPath, cleanup } = startAskSocketServer("test-agent", events);
187
+ track(cleanup);
188
+ await new Promise((r) => setTimeout(r, 50));
189
+
190
+ const frames: unknown[] = [];
191
+ const requests: unknown[] = [];
192
+ events.on(Events.ASK_REQUEST, (p) => requests.push(p));
193
+
194
+ const client = await connectClient(socketPath);
195
+ client.on("data", (chunk) => {
196
+ for (const line of chunk.toString("utf-8").split("\n")) {
197
+ if (line.trim()) frames.push(JSON.parse(line));
198
+ }
199
+ });
200
+
201
+ writeAskRequest(client, "req-rt", "tool-rt");
202
+ await vi.waitFor(() => expect(requests.length).toBe(1));
203
+
204
+ events.emit(Events.ASK_RESPONSE, {
205
+ requestId: "req-rt",
206
+ cancelled: false,
207
+ results: [{ id: "q1", selectedOptions: ["A"], customInput: undefined }],
208
+ });
209
+
210
+ await vi.waitFor(() => expect(frames.length).toBe(1));
211
+ expect(frames[0]).toEqual({
212
+ type: "ask_response",
213
+ requestId: "req-rt",
214
+ cancelled: false,
215
+ results: [{ id: "q1", selectedOptions: ["A"] }],
216
+ });
217
+
218
+ client.destroy();
219
+ await new Promise((r) => setTimeout(r, 50));
220
+ });
221
+
72
222
  it("closing the child socket → ASK_CANCEL for the pending requestId + source", async () => {
73
- const { socketPath, cleanup } = startAskSocketServer("test-agent");
223
+ const events = fakeBus();
224
+ const { socketPath, cleanup } = startAskSocketServer("test-agent", events);
74
225
  track(cleanup);
75
226
  await new Promise((r) => setTimeout(r, 50)); // let the server listen
76
227
 
77
228
  const cancels: unknown[] = [];
78
- unsubs.push(getBus().on(Events.ASK_CANCEL, (p) => cancels.push(p)));
229
+ events.on(Events.ASK_CANCEL, (p) => cancels.push(p));
79
230
  const requests: unknown[] = [];
80
- unsubs.push(getBus().on(Events.ASK_REQUEST, (p) => requests.push(p)));
231
+ events.on(Events.ASK_REQUEST, (p) => requests.push(p));
81
232
 
82
233
  const client = await connectClient(socketPath);
83
234
  writeAskRequest(client, "req-2");
@@ -91,21 +242,22 @@ describe("startAskSocketServer", () => {
91
242
  });
92
243
 
93
244
  it("no ASK_CANCEL once the response has already been delivered (pending drained)", async () => {
94
- const { socketPath, cleanup } = startAskSocketServer("test-agent");
245
+ const events = fakeBus();
246
+ const { socketPath, cleanup } = startAskSocketServer("test-agent", events);
95
247
  track(cleanup);
96
248
  await new Promise((r) => setTimeout(r, 50)); // let the server listen
97
249
 
98
250
  const cancels: unknown[] = [];
99
- unsubs.push(getBus().on(Events.ASK_CANCEL, (p) => cancels.push(p)));
251
+ events.on(Events.ASK_CANCEL, (p) => cancels.push(p));
100
252
  const requests: unknown[] = [];
101
- unsubs.push(getBus().on(Events.ASK_REQUEST, (p) => requests.push(p)));
253
+ events.on(Events.ASK_REQUEST, (p) => requests.push(p));
102
254
 
103
255
  const client = await connectClient(socketPath);
104
256
  writeAskRequest(client, "req-3");
105
257
  await vi.waitFor(() => expect(requests.length).toBe(1));
106
258
 
107
259
  // Deliver the response (drains pending) before the socket closes
108
- getBus().emit(Events.ASK_RESPONSE, {
260
+ events.emit(Events.ASK_RESPONSE, {
109
261
  requestId: "req-3",
110
262
  cancelled: false,
111
263
  results: [{ id: "q1", selectedOptions: ["A"] }],
@@ -117,3 +269,36 @@ describe("startAskSocketServer", () => {
117
269
  expect(cancels.length).toBe(0);
118
270
  });
119
271
  });
272
+
273
+ // ── thinking level (pi warns and drops; we must not feed it one) ───────────
274
+
275
+ describe("spawnSubagent thinking level", () => {
276
+ afterEach(() => {
277
+ spawnMock.mockReset();
278
+ });
279
+
280
+ it("passes a level pi accepts through to --thinking", () => {
281
+ const argv = argvFor({ thinking: "xhigh" });
282
+ const at = argv.indexOf("--thinking");
283
+ expect(at).toBeGreaterThan(-1);
284
+ expect(argv[at + 1]).toBe("xhigh");
285
+ });
286
+
287
+ it("does not pass a level pi would warn about and drop", () => {
288
+ // `--thinking ultra` is a `warning` diagnostic and a dropped flag in pi's
289
+ // own parser (dist/cli/args.js), so the child ran at the default thinking
290
+ // level while its config said otherwise — and in `--mode json` that warning
291
+ // is stderr, which the stream reader only shows for a failed run. Passing
292
+ // nothing is the same outcome, said out loud at dispatch instead.
293
+ const argv = argvFor({ thinking: "ultra" });
294
+ expect(argv).not.toContain("--thinking");
295
+ });
296
+
297
+ it("is case-sensitive, exactly like pi's list", () => {
298
+ expect(argvFor({ thinking: "Medium" })).not.toContain("--thinking");
299
+ });
300
+
301
+ it("passes no --thinking when the config sets none", () => {
302
+ expect(argvFor({})).not.toContain("--thinking");
303
+ });
304
+ });