@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
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Issue #114 — the parameter set `subagent` declares is the whole contract, so
3
+ * every name in it must be a name `execute` actually reads.
4
+ *
5
+ * #114 deleted a declared `async` boolean ("Run asynchronously
6
+ * (fire-and-forget)") that nothing read: a model that passed `async: true` got a
7
+ * fully synchronous run with no warning. That is a false statement in a
8
+ * model-facing contract, and worse than a missing feature, because the contract
9
+ * *is* what the model believes about the tool — it is what gets rendered into
10
+ * the tool list, what the model plans around, what a wrong answer gets blamed
11
+ * on. Deleting the claim is the fix; implementing fire-and-forget is out of
12
+ * scope and blocked on #113 (reaping subagent children across session
13
+ * replacement).
14
+ *
15
+ * The review that followed found a SECOND instance of the same defect class the
16
+ * issue never named: `count?: number` on the parallel task item, read nowhere.
17
+ * It differed from `async` in one instructive way: it lived only in the
18
+ * hand-written `params` type on `execute` in `index.ts`, never in `TaskItem`, so
19
+ * the JSON Schema the model sees never advertised it and a guard that enumerates
20
+ * the schema alone could not have caught it. The root cause of both was one
21
+ * contract stated twice — once for the model, once for TypeScript — with nothing
22
+ * keeping the two in step. So the fix is structural rather than another
23
+ * assertion: `SubagentParams` in `tool-schema.ts` is
24
+ * `Static<typeof SUBAGENT_PARAMS_SCHEMA>` and `execute` takes that, which is the
25
+ * `ask` / `sudo` precedent. There is no second list left to drift on its own, so
26
+ * the only low-friction way to introduce a `count` is to say so at one of the
27
+ * two surfaces a reviewer reads: the schema, or `execute`'s signature.
28
+ *
29
+ * What is guarded where, stated honestly:
30
+ *
31
+ * - the schema's declared sets are pinned below, exactly and enumerated from
32
+ * the schema object rather than restated;
33
+ * - there is no second TS-side list left for a test to police. `tsc` does
34
+ * reject a params type that *contradicts* the schema — `model?: number` at
35
+ * the params position fails `registerTool`'s `execute` with TS2322 — but
36
+ * that check is neither new nor the derivation's doing: `registerTool`
37
+ * infers `TParams` from `parameters` and declares `execute`'s `params` as
38
+ * `Static<TParams>` (`pi-coding-agent` `dist/core/extensions/types.d.ts:514`),
39
+ * so the same TS2322 fires with or without `SubagentParams` (measured against
40
+ * the pre-derivation literal, and it failed there too).
41
+ * - what `tsc` never caught, and still does not, is the phantom itself. An
42
+ * extra *optional* member only widens the params type, and pi declares
43
+ * `execute` as a method, so method parameters are bivariant: `count?: number`
44
+ * in the old literal, in `TaskItem` inside that literal, and spliced on as
45
+ * `params: SubagentParams & { count?: number }` all compile (each measured).
46
+ * So the derivation removes the *place* a phantom hid — it does not add a
47
+ * compiler check that would catch one. Writing a phantom now takes a
48
+ * deliberate `& { … }` at the params position, an edit a reviewer sees, where
49
+ * before it was the default way to state the contract.
50
+ * - the residual gap, stated against what this branch removed rather than
51
+ * against the compiler. The ~370-line source-text scanner deleted in
52
+ * `ca1a328` DID catch a declared-and-unread task field: it parsed
53
+ * `index.ts` as text, derived the task fields the parallel branch reads
54
+ * (`readTaskFields()`) and compared them against
55
+ * `declaredTaskItemParams()`, pinning both directions — declared and not
56
+ * read, and read and not declared. So type erasure was never what defeated
57
+ * that guard: it read the file, and a test over source text does see what
58
+ * the body reads. It was deleted for its false-positive cost, not because it
59
+ * did not work — it could redden on an unrelated `index.ts` edit (Greptile's
60
+ * P2 on PR #143), and that brittleness is why the maintainer took the
61
+ * schema-shared-type option instead. Losing it has a real price: on the
62
+ * task-item surface a schema field that `execute` declares but ignores is
63
+ * once again undetectable here, which is exactly how `async` survived. The
64
+ * cheap, non-brittle replacement is a BEHAVIOURAL pin, not a second parser:
65
+ * `index.test.ts` ("the object forwarded to the executor") now asserts the
66
+ * exact key set handed to `executeParallel` / `executeSubagent`, so dropping
67
+ * a forwarded task field — or adding one nobody declared — fails a test. Its
68
+ * limit, stated rather than sold: it pins the shape handed over, so a schema
69
+ * field that `execute` never forwards at all is invisible to it, and it
70
+ * cannot prove the executor reads a key it is handed. That residue is the
71
+ * phantom class, and it is genuinely uncovered again. What still covers it is
72
+ * the two pins below, a schema small enough to read in one screen — five
73
+ * top-level parameters, four task fields — and review.
74
+ *
75
+ * If a name reappears here it has to arrive together with the behaviour that
76
+ * honours it (#113 first, for `async`) and a consumer that reads it; until then
77
+ * the honest description is the one that does not mention it.
78
+ */
79
+
80
+ import { describe, it, expect } from "vitest";
81
+ import { Value } from "typebox/value";
82
+ import { SUBAGENT_PARAMS_SCHEMA } from "./tool-schema.js";
83
+
84
+ /** Read a schema node's declared property names, failing loudly if it has none. */
85
+ function propertyNames(node: unknown, what: string): string[] {
86
+ const properties = (node as { properties?: Record<string, unknown> }).properties;
87
+ if (!properties) throw new Error(`${what} declares no properties`);
88
+ return Object.keys(properties);
89
+ }
90
+
91
+ /** The top-level parameters `subagent` declares. Read from the schema, never restated. */
92
+ function declaredParams(): string[] {
93
+ return propertyNames(SUBAGENT_PARAMS_SCHEMA, "SUBAGENT_PARAMS_SCHEMA");
94
+ }
95
+
96
+ /**
97
+ * The properties declared for ONE entry of the `tasks` array — `TaskItem`.
98
+ * Reached through the schema rather than exported from `tool-schema.ts`, so the
99
+ * guard depends on the contract the model actually receives and the module
100
+ * keeps its current public surface (`TaskItem` is deliberately unexported).
101
+ */
102
+ function declaredTaskItemParams(): string[] {
103
+ const tasks = (SUBAGENT_PARAMS_SCHEMA as { properties?: Record<string, unknown> }).properties?.tasks;
104
+ if (!tasks) throw new Error("SUBAGENT_PARAMS_SCHEMA declares no `tasks` property");
105
+ const items = (tasks as { items?: unknown }).items;
106
+ if (!items) throw new Error("`tasks` is not an array schema — no item schema to enumerate");
107
+ return propertyNames(items, "The `tasks` item schema (TaskItem)");
108
+ }
109
+
110
+ describe("SUBAGENT_PARAMS_SCHEMA — declared parameter set (issue #114)", () => {
111
+ it("pins the five declared parameters", () => {
112
+ // Exact and sorted, so it fails in BOTH directions: adding a name the pin
113
+ // does not know (`async` would) and deleting one of the five. Read from the
114
+ // schema object, never restated by hand. And because `SubagentParams` is
115
+ // derived, adding a name here is also adding it to what `execute` is handed,
116
+ // so this pin is where a new name has to be justified. What it does NOT
117
+ // check is whether anything reads the name — that cross-surface claim
118
+ // belonged to the scanner described in the header and no longer exists, so
119
+ // the name says only what the body proves.
120
+ expect([...declaredParams()].sort()).toEqual(["agent", "cwd", "model", "task", "tasks"]);
121
+ });
122
+ });
123
+
124
+ describe("TaskItem — declared parallel task item (issue #114, second instance: `count`)", () => {
125
+ it("pins the four declared task-item properties", () => {
126
+ // `count` was the proof this surface needs its own pin: a phantom can be
127
+ // added to the task item while the top-level set above stays green. Like the
128
+ // pin above this one enumerates the SCHEMA only — with the scanner gone
129
+ // nothing here compares the four against what the parallel branch reads, so
130
+ // the name claims the enumeration and no more.
131
+ expect([...declaredTaskItemParams()].sort()).toEqual(["agent", "cwd", "model", "task"]);
132
+ });
133
+ });
134
+
135
+ // Legacy-caller compatibility check — a real behavioural guarantee, so it is
136
+ // pinned rather than assumed. TypeBox `Type.Object` does not set
137
+ // `additionalProperties: false`, so deleting `async` — and deleting `count` from
138
+ // the TS type, which the schema never advertised anyway — stops promising the
139
+ // option without turning an existing caller's payload into a validation error: a
140
+ // caller still passing `async: true`, or a `count` on a task, keeps validating, it
141
+ // just no longer gets a promise about it. That is what makes both deletions safe
142
+ // to land in a patch release, and it is the one thing here that fails on its own
143
+ // if anyone ever tightens the schema with `additionalProperties: false`.
144
+ describe("SUBAGENT_PARAMS_SCHEMA — legacy callers passing the deleted fields still validate", () => {
145
+ it("accepts a payload carrying `async`", () => {
146
+ expect(Value.Check(SUBAGENT_PARAMS_SCHEMA as never, { task: "x", async: true })).toBe(true);
147
+ });
148
+
149
+ it("accepts a task item carrying `count`", () => {
150
+ expect(
151
+ Value.Check(SUBAGENT_PARAMS_SCHEMA as never, { tasks: [{ task: "x", count: 1 }] }),
152
+ ).toBe(true);
153
+ });
154
+ });
@@ -1,4 +1,5 @@
1
- import { Type } from "typebox";
1
+ import { Type, type Static } from "typebox";
2
+ import type { SubagentResult, SubagentRunReport } from "./types.js";
2
3
 
3
4
  // ── JSON Schema for tool parameters (TypeBox) ──────────────────────────────
4
5
 
@@ -24,10 +25,111 @@ export const SUBAGENT_PARAMS_SCHEMA = Type.Object({
24
25
  model: Type.Optional(Type.String({
25
26
  description: "Model override for the subagent",
26
27
  })),
27
- async: Type.Optional(Type.Boolean({
28
- description: "Run asynchronously (fire-and-forget)",
29
- })),
30
28
  cwd: Type.Optional(Type.String({
31
29
  description: "Working directory for the subagent",
32
30
  })),
33
31
  });
32
+
33
+ /**
34
+ * What `execute` is handed, derived from the schema rather than written out —
35
+ * the `ask` / `sudo` precedent (`AskParams`, `SudoExecInput`).
36
+ *
37
+ * #114's two phantoms were only possible because one contract was stated twice,
38
+ * independently: `async` in this schema, `count?: number` in the hand-written
39
+ * `params` type in `index.ts`. Nothing kept the two in step, and only one of them
40
+ * is what the model sees. Stating the contract once removes the second list: the
41
+ * schema is now the only *default* source of a name in `execute`'s declared
42
+ * params type — see the caveat below, which is the whole difference between
43
+ * "only default" and "only".
44
+ *
45
+ * That is a structural fix, not a check. It does not stop anyone reading a
46
+ * declared field, and it does not make a phantom a compile error — `execute` is
47
+ * declared as a method, so bivariant parameters accept a widening `& { … }` at
48
+ * the params position. See the header of `tool-schema.test.ts` for what is
49
+ * enforced where, measured.
50
+ */
51
+ export type SubagentParams = Static<typeof SUBAGENT_PARAMS_SCHEMA>;
52
+
53
+ // ── JSON Schema for `structuredContent` ────────────────────────────────
54
+ //
55
+ // One report per run: what ran, how it ended, what it cost, what it answered.
56
+ // It is `details` minus the live-progress blob — `details.progress[i].output`
57
+ // is the child's whole streamed scrollback and `toolCalls` its call history, for
58
+ // the row renderer, and a script that wants the answer already has
59
+ // `finalOutput`. `usageAccountedElsewhere` is left out too: it is this session's
60
+ // bookkeeping flag (ADR 0028), not a fact about the run.
61
+ //
62
+ // pi hands this to a codemode script instead of the text and validates it
63
+ // against nothing, so `index.output-schema.test.ts` runs the handler and checks
64
+ // the payload against this declaration.
65
+ //
66
+ // A single run that reports itself as failed is `isError` and carries none of it
67
+ // (see the note in `index.ts`), so a script keeps rejecting with the text it
68
+ // rejects with today. A parallel batch is the other case: one failed run inside
69
+ // a batch does not make the batch an error — it stays a report of N runs, with
70
+ // that run's `error` field set inside `results` (pre-existing shape, unchanged
71
+ // by these schemas; see the per-run failure markers in `formatParallelResults`).
72
+
73
+ const UsageSchema = Type.Object({
74
+ input: Type.Number(),
75
+ output: Type.Number(),
76
+ cacheRead: Type.Number(),
77
+ cacheWrite: Type.Number(),
78
+ cost: Type.Number(),
79
+ turns: Type.Number(),
80
+ });
81
+
82
+ const ProgressSummarySchema = Type.Object({
83
+ toolCount: Type.Number(),
84
+ tokens: Type.Number(),
85
+ durationMs: Type.Number(),
86
+ });
87
+
88
+ const RunReportSchema = Type.Object({
89
+ agent: Type.String(),
90
+ task: Type.String(),
91
+ exitCode: Type.Number(),
92
+ childSessionId: Type.Optional(Type.String()),
93
+ outcome: Type.Optional(
94
+ Type.Union([Type.Literal("completed"), Type.Literal("truncated"), Type.Literal("aborted")]),
95
+ ),
96
+ usage: UsageSchema,
97
+ model: Type.Optional(Type.String()),
98
+ finalOutput: Type.Optional(Type.String({ description: "The run's answer text, when it produced one" })),
99
+ error: Type.Optional(Type.String({ description: "Set when the run failed before or during execution" })),
100
+ progressSummary: Type.Optional(ProgressSummarySchema),
101
+ });
102
+
103
+ export const SUBAGENT_OUTPUT_SCHEMA = Type.Object({
104
+ mode: Type.Union([Type.Literal("single"), Type.Literal("parallel")]),
105
+ results: Type.Array(RunReportSchema),
106
+ });
107
+
108
+ /** The report for one run; `undefined` fields are omitted, not null. */
109
+ export function subagentStructuredContent(
110
+ mode: "single" | "parallel",
111
+ results: SubagentResult[],
112
+ ): { mode: "single" | "parallel"; results: SubagentRunReport[] } {
113
+ return {
114
+ mode,
115
+ results: results.map((result) => ({
116
+ agent: result.agent,
117
+ task: result.task,
118
+ exitCode: result.exitCode,
119
+ ...(result.childSessionId !== undefined && { childSessionId: result.childSessionId }),
120
+ ...(result.outcome !== undefined && { outcome: result.outcome }),
121
+ usage: {
122
+ input: result.usage.input,
123
+ output: result.usage.output,
124
+ cacheRead: result.usage.cacheRead,
125
+ cacheWrite: result.usage.cacheWrite,
126
+ cost: result.usage.cost,
127
+ turns: result.usage.turns,
128
+ },
129
+ ...(result.model !== undefined && { model: result.model }),
130
+ ...(result.finalOutput !== undefined && { finalOutput: result.finalOutput }),
131
+ ...(result.error !== undefined && { error: result.error }),
132
+ ...(result.progressSummary !== undefined && { progressSummary: { ...result.progressSummary } }),
133
+ })),
134
+ };
135
+ }
@@ -0,0 +1,321 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ splitToolEntries,
4
+ readToolEntries,
5
+ validateToolEntries,
6
+ applyToolEntries,
7
+ unknownToolEntries,
8
+ formatUnknownToolsWarning,
9
+ } from "./tool-validation.js";
10
+ import type { AgentConfig } from "./agents.js";
11
+
12
+ // ── The split this module encodes ───────────────────────────────────────────
13
+ //
14
+ // Two questions, asked at two different moments:
15
+ //
16
+ // * Can this string be a tool name at all? — **lexical**. Answerable anywhere,
17
+ // including at config read, where there is no tool registry: `discoverAgents()`
18
+ // is pure filesystem. Rejects here.
19
+ // * Does pi have this tool right now? — **membership**. Needs the ExtensionAPI
20
+ // handle (`pi.getAllTools()`), so it can only be asked at dispatch, and the
21
+ // answer is a warning, never a refusal: a legitimate name is absent whenever
22
+ // its plugin is toggled off or its MCP server is disconnected.
23
+ //
24
+ // Nothing in this file asserts that a name is or is not a known tool except in
25
+ // the `unknownToolEntries` block, which is the membership half.
26
+
27
+ function makeAgent(overrides: Partial<AgentConfig> = {}): AgentConfig {
28
+ return {
29
+ name: "reviewer",
30
+ description: "d",
31
+ systemPrompt: "p",
32
+ source: "user",
33
+ filePath: "/tmp/agents/reviewer.md",
34
+ ...overrides,
35
+ };
36
+ }
37
+
38
+ const CTX = { agentName: "reviewer", agentFilePath: "/tmp/agents/reviewer.md" };
39
+
40
+ describe("splitToolEntries", () => {
41
+ it("keeps empty entries so they can be reported instead of filtered away", () => {
42
+ expect(splitToolEntries("read,,bash")).toEqual(["read", "", "bash"]);
43
+ expect(splitToolEntries("read, bash ")).toEqual(["read", "bash"]);
44
+ expect(splitToolEntries(" ")).toEqual([""]);
45
+ expect(splitToolEntries("")).toEqual([]);
46
+ });
47
+ });
48
+
49
+ describe("validateToolEntries (lexical only)", () => {
50
+ it("accepts a plain list of names", () => {
51
+ expect(validateToolEntries(["read", "bash", "mcp__radius__query"], CTX)).toEqual({ ok: true });
52
+ expect(validateToolEntries([], CTX)).toEqual({ ok: true });
53
+ });
54
+
55
+ it("rejects an empty entry and names it", () => {
56
+ const result = validateToolEntries(["read", "", "bash"], CTX);
57
+ expect(result.ok).toBe(false);
58
+ if (!result.ok) {
59
+ expect(result.error).toContain('""');
60
+ expect(result.error).toContain("reviewer");
61
+ expect(result.error).toContain("/tmp/agents/reviewer.md");
62
+ }
63
+ });
64
+
65
+ it("rejects a whitespace-only entry as the empty entry it parses to", () => {
66
+ const result = validateToolEntries(splitToolEntries("read, , bash"), CTX);
67
+ expect(result.ok).toBe(false);
68
+ if (!result.ok) {
69
+ expect(result.error).toContain('""');
70
+ expect(result.error).toMatch(/empty/i);
71
+ }
72
+ });
73
+
74
+ it("rejects a leading-+ entry as a modifier and names it", () => {
75
+ const result = validateToolEntries(["read", "+web_search"], CTX);
76
+ expect(result.ok).toBe(false);
77
+ if (!result.ok) {
78
+ expect(result.error).toContain("+web_search");
79
+ // The reason matters: pi reads it as a modifier, not a name.
80
+ expect(result.error).toMatch(/modifier/i);
81
+ }
82
+ });
83
+
84
+ it("rejects a leading-- entry as a modifier and names it", () => {
85
+ const result = validateToolEntries(["-bash"], CTX);
86
+ expect(result.ok).toBe(false);
87
+ if (!result.ok) expect(result.error).toContain("-bash");
88
+ });
89
+
90
+ it("rejects a duplicated entry and names it", () => {
91
+ const result = validateToolEntries(["read", "bash", "read"], CTX);
92
+ expect(result.ok).toBe(false);
93
+ if (!result.ok) {
94
+ expect(result.error).toContain("read");
95
+ expect(result.error).toMatch(/twice|more than once/i);
96
+ }
97
+ });
98
+
99
+ it("reports the first problem it finds, in list order", () => {
100
+ const result = validateToolEntries(["read", "read", "+bash"], CTX);
101
+ expect(result.ok).toBe(false);
102
+ if (!result.ok) expect(result.error).not.toContain("+bash");
103
+ });
104
+
105
+ it("says the field and file when both are known, and degrades to a generic message when not", () => {
106
+ const named = validateToolEntries(["read", "read"], { agentName: "explore" });
107
+ expect(!named.ok && named.error).toContain("explore");
108
+ const bare = validateToolEntries(["read", "read"]);
109
+ expect(bare.ok).toBe(false);
110
+ if (!bare.ok) expect(bare.error).toContain("read");
111
+ });
112
+ });
113
+
114
+ describe("applyToolEntries (the edit-site half: reject leaves the previous value alone)", () => {
115
+ it("applies a valid list", () => {
116
+ const agent = makeAgent({ tools: ["bash"] });
117
+ const error = applyToolEntries(agent, ["read", "bash"], CTX);
118
+ expect(error).toBeNull();
119
+ expect(agent.tools).toEqual(["read", "bash"]);
120
+ });
121
+
122
+ it("an empty list means no allowlist, and clears the field", () => {
123
+ const agent = makeAgent({ tools: ["bash"] });
124
+ expect(applyToolEntries(agent, [], CTX)).toBeNull();
125
+ expect(agent.tools).toBeUndefined();
126
+ });
127
+
128
+ it("a rejected entry leaves the previous list intact", () => {
129
+ const agent = makeAgent({ tools: ["read", "bash"] });
130
+ const error = applyToolEntries(agent, ["read", "+bash"], CTX);
131
+ expect(error).toBeTruthy();
132
+ expect(agent.tools).toEqual(["read", "bash"]);
133
+ });
134
+
135
+ it("a rejected empty entry leaves the previous list intact", () => {
136
+ const agent = makeAgent({ tools: ["read"] });
137
+ expect(applyToolEntries(agent, ["read", " "], CTX)).toBeTruthy();
138
+ expect(agent.tools).toEqual(["read"]);
139
+ });
140
+
141
+ it("a rejected duplicate leaves the previous list intact", () => {
142
+ const agent = makeAgent();
143
+ expect(applyToolEntries(agent, ["read", "read"], CTX)).toBeTruthy();
144
+ expect(agent.tools).toBeUndefined();
145
+ });
146
+ });
147
+
148
+ describe("applyToolEntries: a commit replaces the rejected value it is superseding", () => {
149
+ // The config read keeps an unreadable `tools:` in `extraFields` so that saving
150
+ // an agent cannot silently delete the line the user typed (`agents.ts`). That
151
+ // promise is to the *rejected* value. Once the panel commits a list, the old
152
+ // raw value has to go with the rejection: `serializeAgent` writes `tools` and
153
+ // then every extra field, so a surviving `extraFields.tools` puts a SECOND
154
+ // `tools:` key in the file (or the only one, when the list was cleared) and the
155
+ // agent comes back broken after a reload.
156
+ const rejected = () =>
157
+ makeAgent({
158
+ toolsError:
159
+ 'Agent "reviewer" has a tools entry starting with "+" (in /tmp/agents/reviewer.md): "+web_search".',
160
+ extraFields: { tools: "read, +web_search", owner: "someone" },
161
+ });
162
+
163
+ it("drops the preserved raw value when a valid list is committed", () => {
164
+ const agent = rejected();
165
+ expect(applyToolEntries(agent, ["read"], CTX)).toBeNull();
166
+ expect(agent.tools).toEqual(["read"]);
167
+ expect(agent.extraFields ?? {}).not.toHaveProperty("tools");
168
+ // Only the superseded value goes; unrelated extras stay.
169
+ expect(agent.extraFields?.owner).toBe("someone");
170
+ });
171
+
172
+ it("drops the preserved raw value when the allowlist is cleared", () => {
173
+ // Clearing is the case that used to look like success and change nothing:
174
+ // `tools` was deleted, the raw value stayed, and the writer put the broken
175
+ // line straight back.
176
+ const agent = rejected();
177
+ expect(applyToolEntries(agent, [], CTX)).toBeNull();
178
+ expect(agent.tools).toBeUndefined();
179
+ expect(agent.extraFields ?? {}).not.toHaveProperty("tools");
180
+ });
181
+
182
+ it("clears the stale rejection so the agent dispatches again in this session", () => {
183
+ const agent = rejected();
184
+ expect(applyToolEntries(agent, ["read"], CTX)).toBeNull();
185
+ expect(agent.toolsError).toBeUndefined();
186
+ });
187
+
188
+ it("a REFUSED commit destroys neither the list, the raw value, nor the rejection", () => {
189
+ const agent = rejected();
190
+ expect(applyToolEntries(agent, ["read", "+x"], CTX)).toBeTruthy();
191
+ expect(agent.tools).toBeUndefined();
192
+ expect(agent.extraFields?.tools).toBe("read, +web_search");
193
+ expect(agent.toolsError).toBeTruthy();
194
+ });
195
+
196
+ it("never mutates the extraFields object it was handed", () => {
197
+ // `/agents` builds its edit copy with `{ ...agent }` (`agent-panel.ts`), so
198
+ // `extraFields` is ONE object shared with `state.editOriginal` and with the
199
+ // discovered agent. Deleting a key in place therefore discards the preserved
200
+ // raw value from the *original* too: commit, then discard the edit, and the
201
+ // next save writes no `tools:` line at all — the allowlist disappears from
202
+ // the file without the user asking. Copy instead.
203
+ const shared = { tools: "read, +web_search", owner: "someone" };
204
+ const agent = makeAgent({ extraFields: shared });
205
+ expect(applyToolEntries(agent, ["read"], CTX)).toBeNull();
206
+ expect(shared.tools).toBe("read, +web_search");
207
+ expect(agent.extraFields).not.toBe(shared);
208
+ expect(agent.extraFields?.owner).toBe("someone");
209
+ });
210
+
211
+ it("drops the whole extraFields object when tools was the only field", () => {
212
+ // An empty record would otherwise render as an empty "extra fields" section
213
+ // and be re-written as nothing at all.
214
+ const agent = makeAgent({ extraFields: { tools: "read, +x" } });
215
+ expect(applyToolEntries(agent, ["read"], CTX)).toBeNull();
216
+ expect(agent.extraFields).toBeUndefined();
217
+ });
218
+ });
219
+
220
+ describe("unknownToolEntries (membership, asked at dispatch)", () => {
221
+ const KNOWN = ["read", "bash", "mcp__radius__query"];
222
+
223
+ it("reports entries pi does not currently have", () => {
224
+ expect(unknownToolEntries(["read", "web_search"], KNOWN)).toEqual(["web_search"]);
225
+ });
226
+
227
+ it("reports nothing when every entry is available", () => {
228
+ expect(unknownToolEntries(["read", "bash"], KNOWN)).toEqual([]);
229
+ });
230
+
231
+ it("treats a * entry as a pattern over the tools pi actually has", () => {
232
+ expect(unknownToolEntries(["mcp__radius__*"], KNOWN)).toEqual([]);
233
+ expect(unknownToolEntries(["mcp__nope__*"], KNOWN)).toEqual(["mcp__nope__*"]);
234
+ });
235
+
236
+ it("names every offending entry in the warning", () => {
237
+ const warning = formatUnknownToolsWarning("reviewer", ["web_search", "sudo_exec"]);
238
+ expect(warning).toContain("web_search");
239
+ expect(warning).toContain("sudo_exec");
240
+ // The whole point of warn-not-fail: the run still happens.
241
+ expect(warning).toMatch(/proceed|continues/i);
242
+ });
243
+ });
244
+
245
+ describe("readToolEntries: every YAML spelling of a tools value", () => {
246
+ // pi's frontmatter parser hands the config read whatever YAML said, so the
247
+ // comma string this suite writes is only one of three shapes that mean
248
+ // "allowlist". Reading only that one silently dropped list-shaped configs —
249
+ // no allowlist at all, which is the unrestricted child ADR 0028 forbids.
250
+ const ok = (r: ReturnType<typeof readToolEntries>) => ("entries" in r ? r.entries : undefined);
251
+
252
+ it("reads the comma-separated form the suite's writer emits", () => {
253
+ expect(ok(readToolEntries("read, bash"))).toEqual(["read", "bash"]);
254
+ });
255
+
256
+ it("reads a block list, trimming each entry", () => {
257
+ expect(ok(readToolEntries(["read", " bash "]))).toEqual(["read", "bash"]);
258
+ });
259
+
260
+ it("reads an inline list, and keeps an empty entry visible for the name rules to reject", () => {
261
+ expect(ok(readToolEntries(["read", ""]))).toEqual(["read", ""]);
262
+ });
263
+
264
+ it("reports an entry that is not a string, naming it", () => {
265
+ const r = readToolEntries(["read", { count: 2 }], { agentName: "probe" });
266
+ expect("error" in r ? r.error : undefined).toMatch(/not a tool name/);
267
+ expect("error" in r ? r.error : "").toContain('{"count":2}');
268
+ expect("error" in r ? r.error : "").toContain('Agent "probe"');
269
+ });
270
+
271
+ it("reports a value that is not a list at all, and says what to write instead", () => {
272
+ const r = readToolEntries(42, { agentFilePath: "/x/probe.md" });
273
+ expect("error" in r ? r.error : undefined).toMatch(/not a list of tool names/);
274
+ expect("error" in r ? r.error : "").toContain("/x/probe.md");
275
+ expect("error" in r ? r.error : "").toContain("read, bash");
276
+ });
277
+
278
+ it("an absent key is the caller's business, not an error here (it never reaches this gate)", () => {
279
+ expect(ok(readToolEntries(""))).toEqual([]);
280
+ });
281
+
282
+ it("splits a list entry that carries a comma, because the child splits it too", () => {
283
+ // `--tools` is ONE argv string the host re-splits on commas
284
+ // (`pi/dist/cli/args.js`: `args[++i].split(",")`), so an entry written
285
+ // `"read,+bash"` in a YAML list reaches the child as two entries — the
286
+ // validator has to look at the same list the child will run with, or it
287
+ // certifies a shape it never sees.
288
+ expect(ok(readToolEntries(["read,+bash"]))).toEqual(["read", "+bash"]);
289
+ expect(ok(readToolEntries([" read , bash "]))).toEqual(["read", "bash"]);
290
+ });
291
+
292
+ it("keeps a hidden modifier visible for the name rules, so the read rejects it", () => {
293
+ const entries = readToolEntries(["read,+bash"]);
294
+ if (!("entries" in entries)) throw new Error("expected entries");
295
+ const check = validateToolEntries(entries.entries, CTX);
296
+ expect(check.ok).toBe(false);
297
+ if (!check.ok) expect(check.error).toContain("+bash");
298
+ });
299
+ });
300
+
301
+ describe("validateToolEntries: a comma inside one entry", () => {
302
+ it("is refused rather than certified, because the child would split it", () => {
303
+ // The read path splits, so this rule guards every *other* caller —
304
+ // `applyToolEntries` and anything that builds an AgentConfig directly. A
305
+ // list we validated must be the list the child runs with: pi would turn
306
+ // `["read,+bash"]` into `--tools read,+bash`, reject the whole flag as
307
+ // mixed names-and-modifiers, and run the child with NO allowlist at all
308
+ // (verified against a live 1.2.0 child: exit 1, `Error: --tools: tool
309
+ // names cannot be mixed with +name or -name entries`).
310
+ const result = validateToolEntries(["read", "read,+bash"], CTX);
311
+ expect(result.ok).toBe(false);
312
+ if (!result.ok) {
313
+ expect(result.error).toContain("read,+bash");
314
+ expect(result.error).toContain("reviewer");
315
+ }
316
+ });
317
+
318
+ it("is refused even when it is the only entry", () => {
319
+ expect(validateToolEntries(["a,b"], CTX).ok).toBe(false);
320
+ });
321
+ });