@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
@@ -1,12 +1,39 @@
1
1
  import { EventEmitter } from "node:events";
2
2
  import { PassThrough } from "node:stream";
3
3
  import type { ChildProcess } from "node:child_process";
4
- import { describe, expect, it } from "vitest";
5
- import { getBus, Events } from "@pi-archimedes/core/bus";
4
+ import { describe, expect, it, beforeEach } from "vitest";
5
+ import { Events } from "@pi-archimedes/core/events";
6
6
  import { streamEvents, type StreamCallbacks } from "./stream.js";
7
7
 
8
8
  type FakeChild = ChildProcess & { stdout: PassThrough; stderr: PassThrough };
9
9
 
10
+ /** Stands in for the host's `createEventBus()` — the emit/on/unsubscribe contract `pi.events` implements. */
11
+ function fakeBus() {
12
+ const listeners = new Map<string, Array<(data: unknown) => void>>();
13
+ return {
14
+ emit(channel: string, data: unknown): void {
15
+ for (const handler of [...(listeners.get(channel) ?? [])]) handler(data);
16
+ },
17
+ on(channel: string, handler: (data: unknown) => void): () => void {
18
+ const subs = listeners.get(channel) ?? [];
19
+ subs.push(handler);
20
+ listeners.set(channel, subs);
21
+ return () => {
22
+ const current = listeners.get(channel) ?? [];
23
+ const idx = current.indexOf(handler);
24
+ if (idx !== -1) current.splice(idx, 1);
25
+ };
26
+ },
27
+ };
28
+ }
29
+
30
+ /** Fresh handle per test: the emitter is handed this, the assertions listen on it. */
31
+ let bus: ReturnType<typeof fakeBus>;
32
+
33
+ beforeEach(() => {
34
+ bus = fakeBus();
35
+ });
36
+
10
37
  function fakeChild(): FakeChild {
11
38
  const child = new EventEmitter() as FakeChild;
12
39
  Object.assign(child, {
@@ -19,10 +46,10 @@ function fakeChild(): FakeChild {
19
46
 
20
47
  async function finishWith(
21
48
  events: Array<Record<string, unknown>>,
22
- callbacks: StreamCallbacks = {},
49
+ callbacks: Partial<StreamCallbacks> = {},
23
50
  ) {
24
51
  const child = fakeChild();
25
- const result = streamEvents(child, callbacks);
52
+ const result = streamEvents(child, { events: bus, ...callbacks } as StreamCallbacks);
26
53
  for (const event of events) {
27
54
  child.stdout.write(`${JSON.stringify(event)}\n`);
28
55
  }
@@ -65,10 +92,10 @@ describe("streamEvents todo mirroring", () => {
65
92
  it("mirrors the child-accepted state from result.details.todos", async () => {
66
93
  const updates: Array<{ source: string; todos: unknown[] }> = [];
67
94
  const clears: Array<{ source: string }> = [];
68
- const off = getBus().on(Events.TODOS_UPDATE, (p: unknown) =>
95
+ const off = bus.on(Events.TODOS_UPDATE, (p: unknown) =>
69
96
  updates.push(p as (typeof updates)[number]),
70
97
  );
71
- const offClear = getBus().on(Events.TODOS_CLEAR, (p: unknown) =>
98
+ const offClear = bus.on(Events.TODOS_CLEAR, (p: unknown) =>
72
99
  clears.push(p as { source: string }),
73
100
  );
74
101
 
@@ -99,15 +126,16 @@ describe("streamEvents todo mirroring", () => {
99
126
  expect(updates[0]!.todos).toEqual([
100
127
  { content: "Fix auth", status: "pending" },
101
128
  ]);
102
- // Bus re-drains queued events (emitted while unsubscribed) to the next
103
- // subscriber, so stale "subagent:general" clears from earlier tests may
104
- // be present — assert on the per-agent source instead of the raw count.
129
+ // Each test owns its handle now, so `clears` can only hold this test's
130
+ // emit — but the assertion stays source-scoped, because that is the
131
+ // contract a reader needs to see: the clear names the child it belongs to.
132
+ expect(clears.filter((c) => c.source === "subagent:t-acc")).toHaveLength(1);
105
133
  expect(clears.filter((c) => c.source === "subagent:t-acc")).toHaveLength(1);
106
134
  });
107
135
 
108
136
  it("mirrors nothing when the tool returned a validation rejection (result.isError)", async () => {
109
137
  const updates: Array<{ source: string; todos: unknown[] }> = [];
110
- const off = getBus().on(Events.TODOS_UPDATE, (p: unknown) =>
138
+ const off = bus.on(Events.TODOS_UPDATE, (p: unknown) =>
111
139
  updates.push(p as (typeof updates)[number]),
112
140
  );
113
141
 
@@ -139,7 +167,7 @@ describe("streamEvents todo mirroring", () => {
139
167
 
140
168
  it("mirrors nothing when the harness reported an error (event.isError)", async () => {
141
169
  const updates: Array<{ source: string; todos: unknown[] }> = [];
142
- const off = getBus().on(Events.TODOS_UPDATE, (p: unknown) =>
170
+ const off = bus.on(Events.TODOS_UPDATE, (p: unknown) =>
143
171
  updates.push(p as (typeof updates)[number]),
144
172
  );
145
173
 
@@ -166,7 +194,7 @@ describe("streamEvents todo mirroring", () => {
166
194
 
167
195
  it("falls back to normalized raw args when the accepted result has no details", async () => {
168
196
  const updates: Array<{ source: string; todos: unknown[] }> = [];
169
- const off = getBus().on(Events.TODOS_UPDATE, (p: unknown) =>
197
+ const off = bus.on(Events.TODOS_UPDATE, (p: unknown) =>
170
198
  updates.push(p as (typeof updates)[number]),
171
199
  );
172
200
 
@@ -194,7 +222,7 @@ describe("streamEvents todo mirroring", () => {
194
222
 
195
223
  it("stows raw args under the alias key todo_list (result has no details)", async () => {
196
224
  const updates: Array<{ source: string; todos: unknown[] }> = [];
197
- const off = getBus().on(Events.TODOS_UPDATE, (p: unknown) =>
225
+ const off = bus.on(Events.TODOS_UPDATE, (p: unknown) =>
198
226
  updates.push(p as (typeof updates)[number]),
199
227
  );
200
228
 
@@ -230,7 +258,7 @@ describe("streamEvents todo mirroring", () => {
230
258
 
231
259
  it("does not crash or emit when the end event has no matching start", async () => {
232
260
  const updates: Array<{ source: string; todos: unknown[] }> = [];
233
- const off = getBus().on(Events.TODOS_UPDATE, (p: unknown) =>
261
+ const off = bus.on(Events.TODOS_UPDATE, (p: unknown) =>
234
262
  updates.push(p as (typeof updates)[number]),
235
263
  );
236
264
 
@@ -254,7 +282,7 @@ describe("streamEvents todo mirroring", () => {
254
282
 
255
283
  it("does not emit for non-todo tools with todoList-shaped args", async () => {
256
284
  const updates: Array<{ source: string; todos: unknown[] }> = [];
257
- const off = getBus().on(Events.TODOS_UPDATE, (p: unknown) =>
285
+ const off = bus.on(Events.TODOS_UPDATE, (p: unknown) =>
258
286
  updates.push(p as (typeof updates)[number]),
259
287
  );
260
288
 
@@ -293,10 +321,10 @@ describe("streamEvents todo mirroring", () => {
293
321
  it("uses a unique per-child source when the child provides a session id", async () => {
294
322
  const updates: Array<{ source: string; todos: unknown[] }> = [];
295
323
  const clears: Array<{ source: string }> = [];
296
- const off = getBus().on(Events.TODOS_UPDATE, (p: unknown) =>
324
+ const off = bus.on(Events.TODOS_UPDATE, (p: unknown) =>
297
325
  updates.push(p as (typeof updates)[number]),
298
326
  );
299
- const offClear = getBus().on(Events.TODOS_CLEAR, (p: unknown) =>
327
+ const offClear = bus.on(Events.TODOS_CLEAR, (p: unknown) =>
300
328
  clears.push(p as { source: string }),
301
329
  );
302
330
 
@@ -345,3 +373,173 @@ describe("streamEvents todo mirroring", () => {
345
373
  });
346
374
  });
347
375
 
376
+
377
+ // ── Run outcomes (CONTEXT.md: *Run outcome*; ADR 0028) ──────────────────────
378
+ //
379
+ // pi 1.2.0 made print mode exit 1 when the final response hit the output token
380
+ // limit, and text-mode truncation is reported that way — so an exit code alone
381
+ // can no longer tell a useful-but-cut-off answer from a crash. The child's own
382
+ // `stopReason` and 1.1.0's `agent_settled.aborted` are what carry the
383
+ // distinction; `agent_end` fires for a cancelled run too and says nothing about
384
+ // either. Cost has to survive all three outcomes.
385
+
386
+ const USAGE = {
387
+ input: 100,
388
+ output: 40,
389
+ cacheRead: 7,
390
+ cacheWrite: 2,
391
+ totalTokens: 149,
392
+ cost: { input: 0.001, output: 0.002, cacheRead: 0, cacheWrite: 0, total: 0.003 },
393
+ };
394
+
395
+ function assistantMessage(stopReason: string): Record<string, unknown> {
396
+ return {
397
+ type: "message_end",
398
+ message: {
399
+ role: "assistant",
400
+ content: [{ type: "text", text: "partial answer" }],
401
+ model: "anthropic/claude-opus-4.5",
402
+ stopReason,
403
+ usage: USAGE,
404
+ },
405
+ };
406
+ }
407
+
408
+ async function finishChild(
409
+ events: Array<Record<string, unknown>>,
410
+ close: [code: number | null, signal?: NodeJS.Signals | null],
411
+ stderr = "",
412
+ ) {
413
+ const child = fakeChild();
414
+ const result = streamEvents(child, { agent: "reviewer", task: "t", events: bus });
415
+ if (stderr) child.stderr.write(stderr);
416
+ for (const event of events) {
417
+ child.stdout.write(`${JSON.stringify(event)}\n`);
418
+ }
419
+ child.emit("close", close[0], close[1] ?? null);
420
+ return result;
421
+ }
422
+
423
+ describe("streamEvents run outcomes", () => {
424
+ it("a clean finish is `completed`", async () => {
425
+ const result = await finishChild(
426
+ [
427
+ assistantMessage("stop"),
428
+ { type: "agent_end", messages: [] },
429
+ { type: "agent_settled", aborted: false },
430
+ ],
431
+ [0],
432
+ );
433
+ expect(result.outcome).toBe("completed");
434
+ expect(result.error).toBeUndefined();
435
+ });
436
+
437
+ it("exit 1 with stopReason `length` is `truncated`, not a failure, and keeps the cost", async () => {
438
+ const result = await finishChild(
439
+ [
440
+ assistantMessage("length"),
441
+ { type: "agent_end", messages: [] },
442
+ { type: "agent_settled", aborted: false },
443
+ ],
444
+ [1],
445
+ "Response was truncated: the model reached its output token limit.\n",
446
+ );
447
+ expect(result.outcome).toBe("truncated");
448
+ expect(result.error).toBeUndefined();
449
+ expect(result.usage.cost).toBeCloseTo(0.003);
450
+ expect(result.usage.input).toBe(100);
451
+ expect(result.usage.output).toBe(40);
452
+ });
453
+
454
+ it("`--mode json` exits 0 for the same truncation, and it is still `truncated`", async () => {
455
+ // Verified on 1.2.0: the exit-1 mapping lives in print mode's *text* branch,
456
+ // so a json child reports truncation only through stopReason.
457
+ const result = await finishChild(
458
+ [assistantMessage("length"), { type: "agent_settled", aborted: false }],
459
+ [0],
460
+ );
461
+ expect(result.outcome).toBe("truncated");
462
+ expect(result.error).toBeUndefined();
463
+ });
464
+
465
+ it("`agent_settled.aborted: true` is `aborted`, whatever the exit code says", async () => {
466
+ // The child's print mode exits 143 when it is SIGTERM'd on the way out.
467
+ const result = await finishChild(
468
+ [assistantMessage("stop"), { type: "agent_settled", aborted: true }],
469
+ [143],
470
+ );
471
+ expect(result.outcome).toBe("aborted");
472
+ expect(result.error).toBeUndefined();
473
+ expect(result.usage.cost).toBeCloseTo(0.003);
474
+ });
475
+
476
+ it("a child killed by the abort signal before it settled is `aborted`", async () => {
477
+ const result = await finishChild([assistantMessage("stop")], [null, "SIGTERM"]);
478
+ expect(result.outcome).toBe("aborted");
479
+ expect(result.error).toBeUndefined();
480
+ });
481
+
482
+ it("a real SIGTERM'd child exits 143 with no signal and no settle event, and is still `aborted`", async () => {
483
+ // The shape a live 1.2.0 child actually produces when the parent's abort
484
+ // handler SIGTERMs it: print mode catches the signal and `process.exit(143)`,
485
+ // so there is no `signal`, and the process dies before it can emit
486
+ // `agent_settled`. Whatever it had printed to stderr (here, an unrelated
487
+ // provider warning) must not be promoted to an error.
488
+ const result = await finishChild([assistantMessage("stop")], [143], "LiteLLM discovery: warning");
489
+ expect(result.outcome).toBe("aborted");
490
+ expect(result.error).toBeUndefined();
491
+ });
492
+
493
+ it("an assistant message aborted at the provider is `aborted` even with no settle event", async () => {
494
+ const result = await finishChild([assistantMessage("aborted")], [0]);
495
+ expect(result.outcome).toBe("aborted");
496
+ });
497
+
498
+ it("a pre/during-run error stays a failure: no outcome, error from stderr", async () => {
499
+ const result = await finishChild(
500
+ [assistantMessage("error"), { type: "agent_settled", aborted: false }],
501
+ [1],
502
+ "401 unauthorized",
503
+ );
504
+ expect(result.outcome).toBeUndefined();
505
+ expect(result.error).toContain("401");
506
+ });
507
+
508
+ it("a nonzero exit with no run behind it stays a failure", async () => {
509
+ const result = await finishChild([], [2], "unknown flag --nope");
510
+ expect(result.outcome).toBeUndefined();
511
+ expect(result.error).toContain("unknown flag");
512
+ });
513
+
514
+ it("a failed run always says why, even on a clean exit with empty stderr", async () => {
515
+ // The shape a json child produces when the provider errors mid-run: the last
516
+ // assistant message ends `stopReason: "error"`, the exit code stays 0 (the
517
+ // code mapping is print mode's text branch) and stderr is empty. Without a
518
+ // synthesised explanation this is indistinguishable from a successful run.
519
+ const result = await finishChild(
520
+ [assistantMessage("error"), { type: "agent_settled", aborted: false }],
521
+ [0],
522
+ );
523
+ expect(result.outcome).toBeUndefined();
524
+ expect(result.error).toMatch(/stopReason|failed/i);
525
+ });
526
+
527
+ it("a crash on an unexpected signal is a failure, not a cancellation", async () => {
528
+ const result = await finishChild([assistantMessage("stop")], [null, "SIGSEGV"]);
529
+ expect(result.outcome).toBeUndefined();
530
+ expect(result.error).toBeTruthy();
531
+ });
532
+
533
+ it("the final progress carries the outcome, so the live view can show it", async () => {
534
+ const seen: Array<string | undefined> = [];
535
+ const result = await finishChild(
536
+ [assistantMessage("length"), { type: "agent_settled", aborted: false }],
537
+ [0],
538
+ ).then((r) => {
539
+ seen.push(r.progress?.outcome);
540
+ return r;
541
+ });
542
+ expect(seen).toEqual(["truncated"]);
543
+ expect(result.progress?.status).toBe("completed");
544
+ });
545
+ });
package/src/stream.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { createInterface } from "node:readline";
2
2
  import type { ChildProcess } from "node:child_process";
3
+ import type { EventBus } from "@earendil-works/pi-coding-agent";
3
4
  import type { StreamState, SubagentProgress, SubagentResult } from "./types.js";
4
- import { getBus, Events } from "@pi-archimedes/core/bus";
5
+ import { Events, emitOn } from "@pi-archimedes/core/events";
5
6
  import { normalizeTodoItems, pickTodoListRaw } from "@pi-archimedes/todo/prepare-args";
6
7
  import {
7
8
  type JsonEvent,
@@ -11,11 +12,19 @@ import {
11
12
  handleMessageEnd,
12
13
  handleAgentEnd,
13
14
  } from "./handlers.js";
15
+ import { decideRunOutcome } from "./run-outcome.js";
14
16
 
15
17
  export interface StreamCallbacks {
16
18
  agent?: string;
17
19
  task?: string;
18
20
  onProgress?: (progress: SubagentProgress) => void;
21
+ /**
22
+ * The parent's host handle (`pi.events`), injected. `streamEvents` is a plain
23
+ * function over a child process, so it has no `pi` of its own; the child's
24
+ * todo state is announced on the handle the caller is holding, which is the
25
+ * same one the todo package subscribed on in this process.
26
+ */
27
+ events: EventBus;
19
28
  }
20
29
 
21
30
  /**
@@ -27,7 +36,7 @@ export interface StreamCallbacks {
27
36
  */
28
37
  export function streamEvents(
29
38
  child: ChildProcess,
30
- callbacks: StreamCallbacks = {},
39
+ callbacks: StreamCallbacks,
31
40
  ): Promise<SubagentResult> {
32
41
  return new Promise((resolve, reject) => {
33
42
  const startTime = Date.now();
@@ -175,7 +184,7 @@ export function streamEvents(
175
184
  const detailsTodos = (result?.details as Record<string, unknown> | undefined)?.todos;
176
185
  const todos = normalizeTodoItems(Array.isArray(detailsTodos) ? detailsTodos : stowed);
177
186
  if (todos) {
178
- getBus().emit(Events.TODOS_UPDATE, { source: subagentSource, todos });
187
+ emitOn(callbacks.events, Events.TODOS_UPDATE, { source: subagentSource, todos });
179
188
  }
180
189
  }
181
190
  }
@@ -198,21 +207,48 @@ export function streamEvents(
198
207
  handleAgentEnd(state, event);
199
208
  break;
200
209
  }
210
+ case "agent_settled": {
211
+ // pi 1.1.0: the child's own statement that its run is over, and
212
+ // whether it was cancelled. `agent_end` fires for a cancelled run too
213
+ // (and can fire while a continuation is still pending), so this — not
214
+ // `agent_end` — is what an outcome is read from.
215
+ if (event.aborted === true) state.settledAborted = true;
216
+ break;
217
+ }
201
218
  // Ignore: agent_start, message_start, message_update, turn_end, tool_execution_update
202
219
  }
203
220
  });
204
221
 
205
222
  // Handle process exit
206
- child.on("close", (code) => {
223
+ child.on("close", (code, signal) => {
207
224
  clearStartupTimer();
208
225
  clearInterval(heartbeat);
209
226
  const durationMs = Date.now() - startTime;
210
227
  const exitCode = code ?? 1;
211
228
 
212
- // Surface stderr as error if the process failed and we have no other error
213
- if (exitCode !== 0 && !error) {
229
+ const outcome = decideRunOutcome({
230
+ settledAborted: state.settledAborted === true,
231
+ stopReason: state.lastStopReason,
232
+ // The raw code, not `exitCode`: `null` is the difference between "it was
233
+ // killed" and "it failed", and the outcome table reads that difference.
234
+ exitCode: code,
235
+ signal: signal ?? undefined,
236
+ });
237
+
238
+ // stderr explains a failure and nothing else. A truncated run's stderr is
239
+ // pi's own "Response was truncated" notice and an aborted one's is noise —
240
+ // promoting either to `error` would report a real answer as a broken one.
241
+ if (outcome === "failed" && !error) {
214
242
  const stderr = Buffer.concat(stderrChunks).toString("utf-8").trim();
215
243
  if (stderr) error = stderr;
244
+ else if (code === null) error = `subagent terminated by ${signal ?? "a signal"}`;
245
+ // A json child that errored leaves nothing behind to read: the exit code
246
+ // stays 0 (the stopReason→code mapping is print mode's text branch) and
247
+ // stderr is clean, which used to make a failed run byte-identical to a
248
+ // successful one at this boundary. A failed run always says why.
249
+ else if (state.lastStopReason === "error")
250
+ error = 'subagent failed: the child\u2019s last response ended with stopReason "error"';
251
+ else error = `subagent failed with exit code ${code ?? "null"}`;
216
252
  }
217
253
 
218
254
  const result: SubagentResult = {
@@ -220,6 +256,7 @@ export function streamEvents(
220
256
  task: callbacks.task ?? "",
221
257
  ...(state.childSessionId ? { childSessionId: state.childSessionId } : {}),
222
258
  exitCode,
259
+ ...(outcome === "failed" ? {} : { outcome }),
223
260
  model: state.model,
224
261
  usage: {
225
262
  input: state.totalInput,
@@ -233,7 +270,8 @@ export function streamEvents(
233
270
  error,
234
271
  progress: {
235
272
  ...buildProgress(),
236
- status: exitCode === 0 ? "completed" : "failed",
273
+ status: outcome === "failed" ? "failed" : "completed",
274
+ ...(outcome === "failed" ? {} : { outcome }),
237
275
  durationMs,
238
276
  },
239
277
  progressSummary: {
@@ -245,8 +283,8 @@ export function streamEvents(
245
283
 
246
284
  callbacks.onProgress?.(result.progress!);
247
285
 
248
- // Clear subagent todos from the bus on exit
249
- getBus().emit(Events.TODOS_CLEAR, {
286
+ // Clear subagent todos from the handle on exit
287
+ emitOn(callbacks.events, Events.TODOS_CLEAR, {
250
288
  source: subagentSource,
251
289
  });
252
290
 
@@ -0,0 +1,96 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ THINKING_LEVELS,
4
+ THINKING_LEVELS_PI_VERSION,
5
+ isThinkingLevel,
6
+ resolveThinkingLevel,
7
+ formatUnknownThinkingWarning,
8
+ } from "./thinking-validation.js";
9
+
10
+ // pi's CLI keeps its own list of accepted `--thinking` levels and, for anything
11
+ // outside it, warns and drops the flag rather than failing
12
+ // (`pi/dist/cli/args.js`: `VALID_THINKING_LEVELS`). A child configured with a
13
+ // level pi does not take therefore runs at the default while its config claims
14
+ // otherwise, and in `--mode json` the warning lands in stderr — which the stream
15
+ // reader only ever shows for a failed run.
16
+ //
17
+ // This file pins the copy of that list the suite checks against. The host's own
18
+ // validator cannot be consulted: pi's `exports` map publishes only `"."` and
19
+ // `"./rpc-entry"`, so `dist/cli/args.js` is not importable, and the root entry
20
+ // re-exports neither `isValidThinkingLevel` nor `VALID_THINKING_LEVELS` nor the
21
+ // `ThinkingLevel` type that could pin it at compile time. What the test can do is
22
+ // pin the list and the pi version it claims to describe, so drift is a
23
+ // deliberately-edited constant rather than an invisible difference — and the
24
+ // consequence of not editing it stays a warning plus pi's default, never a
25
+ // dispatch refused over a word the host has since learned.
26
+
27
+ describe("isThinkingLevel", () => {
28
+ it("accepts exactly pi's seven levels", () => {
29
+ expect([...THINKING_LEVELS]).toEqual([
30
+ "off",
31
+ "minimal",
32
+ "low",
33
+ "medium",
34
+ "high",
35
+ "xhigh",
36
+ "max",
37
+ ]);
38
+ for (const level of THINKING_LEVELS) expect(isThinkingLevel(level)).toBe(true);
39
+ });
40
+
41
+ it("refines the value it accepts", () => {
42
+ const value: unknown = "high";
43
+ if (isThinkingLevel(value)) {
44
+ const asString: string = value;
45
+ expect(asString).toBe("high");
46
+ } else {
47
+ throw new Error("high should be a thinking level");
48
+ }
49
+ });
50
+
51
+ it("refuses a level pi warns about and drops", () => {
52
+ for (const bad of ["ultra", "Medium", "medium ", "highest", "auto", "0"]) {
53
+ expect(isThinkingLevel(bad), bad).toBe(false);
54
+ }
55
+ });
56
+
57
+ it("refuses anything that is not a string at all", () => {
58
+ for (const bad of [undefined, null, 2, true, ["low"]]) {
59
+ expect(isThinkingLevel(bad)).toBe(false);
60
+ }
61
+ });
62
+ });
63
+
64
+ describe("resolveThinkingLevel", () => {
65
+ it("passes a valid level through", () => {
66
+ expect(resolveThinkingLevel("xhigh")).toEqual({ level: "xhigh", warning: undefined });
67
+ });
68
+
69
+ it("says nothing when the config set no level", () => {
70
+ expect(resolveThinkingLevel(undefined)).toEqual({ level: undefined, warning: undefined });
71
+ expect(resolveThinkingLevel("")).toEqual({ level: undefined, warning: undefined });
72
+ });
73
+
74
+ it("withholds a level pi would drop, and says so", () => {
75
+ const resolved = resolveThinkingLevel("ultra");
76
+ expect(resolved.level).toBeUndefined();
77
+ expect(resolved.warning).toContain('"ultra"');
78
+ expect(resolved.warning).toMatch(/default thinking level/i);
79
+ for (const level of THINKING_LEVELS) {
80
+ expect(resolved.warning).toContain(level);
81
+ }
82
+ });
83
+ });
84
+
85
+ describe("formatUnknownThinkingWarning", () => {
86
+ it("names the value rather than describing the problem in the abstract", () => {
87
+ expect(formatUnknownThinkingWarning("turbo")).toContain("turbo");
88
+ });
89
+
90
+ it("says which pi the level list came from", () => {
91
+ // The list is a copy of the host's, so the sentence that quotes it has to
92
+ // say whose list it is — a reader on a newer pi should question it rather
93
+ // than trust it.
94
+ expect(formatUnknownThinkingWarning("turbo")).toContain(THINKING_LEVELS_PI_VERSION);
95
+ });
96
+ });
@@ -0,0 +1,78 @@
1
+ // ── The `thinking:` field: what the child will actually accept ─────────────
2
+ //
3
+ // A child is spawned with `--thinking <level>`, and pi's CLI parser keeps its
4
+ // own list (`pi/dist/cli/args.js`: `VALID_THINKING_LEVELS`, `isValidThinkingLevel`).
5
+ // A level outside that list is **not** an error there: the parser pushes a
6
+ // `warning` diagnostic and drops the flag, so the child runs at the default
7
+ // thinking level while the config says something else. In `--mode json` that
8
+ // warning goes to stderr, and `stream.ts` only reads stderr for a *failed* run —
9
+ // so a successful dispatch reported "thinking: medium" having run nothing of the
10
+ // sort. Greptile found it; a live 1.2.0 child confirmed the mechanism.
11
+ //
12
+ // So the suite drops the same value first and says so out loud, at dispatch,
13
+ // where the user is watching. Not a refusal: the run still happens, at exactly
14
+ // the level pi would have used anyway — the difference is that it is now named
15
+ // instead of swallowed. That is also what keeps this safe across a pi upgrade
16
+ // that *adds* a level: the worst case is a warning and the default, never a
17
+ // dispatch refused over a word the host has since learned.
18
+ //
19
+ // The list is duplicated here because there is no other way to read it. pi
20
+ // exports only `"."` and `"./rpc-entry"` (its package `exports` map), so
21
+ // `@earendil-works/pi-coding-agent/dist/cli/args.js` — where `isValidThinkingLevel`
22
+ // and `VALID_THINKING_LEVELS` live — is not importable; the root entry does not
23
+ // re-export either, nor the `ThinkingLevel` type that would pin the set at
24
+ // compile time (that lives in `@earendil-works/pi-agent-core`, which this package
25
+ // cannot resolve). So the copy below is checked by test, not by the host, and the
26
+ // warning names the pi version it was read from — `thinking-validation.test.ts`
27
+ // pins both. `model-validation.ts` (ADR 0008) already keeps a host-owned set the
28
+ // same way for model ids, obtaining those through the `modelRegistry` handle
29
+ // instead, which for levels does not exist.
30
+
31
+ /** pi's own `VALID_THINKING_LEVELS` were read from, and the version they were
32
+ * read from. The warning quotes the version so a reader on a newer host knows
33
+ * which list they are being handed. */
34
+ export const THINKING_LEVELS_PI_VERSION = "1.2.0";
35
+
36
+ /** pi 1.2.0's `VALID_THINKING_LEVELS`, in pi's order. */
37
+ export const THINKING_LEVELS = [
38
+ "off",
39
+ "minimal",
40
+ "low",
41
+ "medium",
42
+ "high",
43
+ "xhigh",
44
+ "max",
45
+ ] as const;
46
+
47
+ /** Whether `level` is a thinking level pi's own `--thinking` accepts. */
48
+ export function isThinkingLevel(level: unknown): level is string {
49
+ return typeof level === "string" && (THINKING_LEVELS as readonly string[]).includes(level);
50
+ }
51
+
52
+ /**
53
+ * The thinking level to put on the child's command line, and what to say about
54
+ * the one we are not putting there.
55
+ *
56
+ * `level` is what to pass (undefined = no `--thinking` flag at all); `warning`
57
+ * is set when the config asked for something pi would have dropped anyway.
58
+ */
59
+ export function resolveThinkingLevel(
60
+ configured: string | undefined,
61
+ ): { level: string | undefined; warning: string | undefined } {
62
+ if (configured === undefined || configured === "") return { level: undefined, warning: undefined };
63
+ if (isThinkingLevel(configured)) return { level: configured, warning: undefined };
64
+ return { level: undefined, warning: formatUnknownThinkingWarning(configured) };
65
+ }
66
+
67
+ /**
68
+ * The warning for a level pi's `--thinking` does not take.
69
+ *
70
+ * It names the value, the accepted set, and what the child is running at
71
+ * instead — "the flag was dropped" is only useful if you learn the consequence.
72
+ */
73
+ export function formatUnknownThinkingWarning(configured: string): string {
74
+ return [
75
+ `Subagent asked for thinking level "${configured}", which pi's --thinking does not take.`,
76
+ `The run proceeds at pi's default thinking level. Valid levels (pi ${THINKING_LEVELS_PI_VERSION}): ${THINKING_LEVELS.join(", ")}.`,
77
+ ].join(" ");
78
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Task 11 item 3 — `subagent` is for the model, not for scripts, and
3
+ * `list_agents` stays reachable both ways.
4
+ *
5
+ * `exposure: "model-only"` is measured, not assumed (see the same file in
6
+ * `ask` for the method and the pi source references). For `subagent` the cost is
7
+ * concrete and it was observed before the change: a codemode script called
8
+ * `tools.subagent({ task: "Reply with exactly the single word: child" })` and
9
+ * got the whole run back as a structured value —
10
+ * `{"mode":"single","results":[{... "outcome":"completed","finalOutput":"child" ...}]}`.
11
+ * After the change that same script line reports
12
+ * `tools.subagent does not exist` and `subagent` is absent from `ALL_TOOLS`.
13
+ *
14
+ * The plan takes the exposure anyway (`docs/roadmap/pi-1-x-alignment.md`,
15
+ * Task 11 item 3): a subagent dispatch is an orchestration step that pi's own
16
+ * docs file under "Use it for orchestrating or interactive tools", the model
17
+ * still drives it, and the nested-call path it does remove is one where the
18
+ * child's row renders inside a script row the user cannot expand. Say so to the
19
+ * maintainer rather than discover it later.
20
+ */
21
+
22
+ import { describe, it, expect, vi } from "vitest";
23
+ import { registerSubagent } from "./index.js";
24
+
25
+ type RegisteredTool = { name: string; exposure?: string; annotations?: Record<string, boolean> };
26
+
27
+ function register(): Record<string, RegisteredTool> {
28
+ const tools: RegisteredTool[] = [];
29
+ registerSubagent({
30
+ registerTool: (t: unknown) => tools.push(t as RegisteredTool),
31
+ on: () => {},
32
+ getAllTools: () => [],
33
+ events: { emit: () => {}, on: () => () => {} },
34
+ } as never);
35
+ return Object.fromEntries(tools.map((t) => [t.name, t]));
36
+ }
37
+
38
+ describe("subagent exposure", () => {
39
+ it("subagent is model-only: declared to the model, never callable from a script", () => {
40
+ expect(register().subagent!.exposure).toBe("model-only");
41
+ });
42
+
43
+ it("list_agents stays direct — a script that dispatches nothing still reads the catalogue", () => {
44
+ expect(register().list_agents!.exposure ?? "direct").toBe("direct");
45
+ });
46
+
47
+ it("neither tool carries annotations", () => {
48
+ const tools = register();
49
+ expect(tools.subagent!.annotations).toBeUndefined();
50
+ expect(tools.list_agents!.annotations).toBeUndefined();
51
+ });
52
+
53
+ it("nothing in this package is codemode or deferred exposure (ADR 0029)", () => {
54
+ for (const tool of Object.values(register())) {
55
+ expect(["codemode", "deferred", "hidden"]).not.toContain(tool.exposure);
56
+ }
57
+ });
58
+ });