@pi-archimedes/subagent 2.10.4 → 2.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/package.json +10 -10
- package/src/agent-panel.mcp.test.ts +76 -0
- package/src/agent-panel.tools.test.ts +162 -0
- package/src/agent-panel.ts +37 -8
- package/src/agent-panel.width-audit.test.ts +258 -0
- package/src/agents-parse.test.ts +252 -0
- package/src/agents.ts +72 -5
- package/src/compact.test.ts +136 -57
- package/src/compact.ts +57 -37
- package/src/dispatch.test.ts +73 -1
- package/src/dispatch.ts +20 -2
- package/src/execute.test.ts +9 -1
- package/src/execute.ts +26 -29
- package/src/expanded.test.ts +472 -0
- package/src/expanded.ts +202 -82
- package/src/format.test.ts +71 -29
- package/src/format.ts +71 -28
- package/src/frontmatter-io.test.ts +62 -0
- package/src/frontmatter-io.ts +16 -2
- package/src/handlers.test.ts +21 -3
- package/src/handlers.ts +22 -3
- package/src/index.output-schema.test.ts +317 -0
- package/src/index.test.ts +566 -0
- package/src/index.ts +169 -16
- package/src/render.ts +46 -36
- package/src/row-block.test.ts +244 -0
- package/src/row-block.ts +209 -0
- package/src/run-outcome.test.ts +71 -0
- package/src/run-outcome.ts +84 -0
- package/src/spawn.test.ts +204 -19
- package/src/spawn.ts +49 -17
- package/src/stream.test.ts +215 -17
- package/src/stream.ts +47 -9
- package/src/thinking-validation.test.ts +96 -0
- package/src/thinking-validation.ts +78 -0
- package/src/tool-declarations.test.ts +58 -0
- package/src/tool-row.width-audit.test.ts +720 -0
- package/src/tool-schema.test.ts +154 -0
- package/src/tool-schema.ts +106 -4
- package/src/tool-validation.test.ts +321 -0
- package/src/tool-validation.ts +262 -0
- package/src/types.ts +86 -0
- package/src/usage.test.ts +95 -0
- package/src/usage.ts +87 -0
- package/src/cost.test.ts +0 -62
- package/src/cost.ts +0 -8
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dispatch-time behaviour of the `subagent` tool: the two halves of tool-name
|
|
3
|
+
* validation as the calling model sees them.
|
|
4
|
+
*
|
|
5
|
+
* * A `tools:` list the config read rejected (an entry pi cannot read as a
|
|
6
|
+
* name) refuses the dispatch — the child must never be spawned with a list
|
|
7
|
+
* whose meaning is up to the host's parser.
|
|
8
|
+
* * A name that is well formed but not in `pi.getAllTools()` right now is a
|
|
9
|
+
* **warning** and the dispatch runs anyway. A plugin being toggled off, or an
|
|
10
|
+
* MCP server being disconnected, must not make `tools: web_search` unusable.
|
|
11
|
+
*
|
|
12
|
+
* `./agents.js` and `./execute.js` are mocked so the assertions are about the
|
|
13
|
+
* gate, not about the filesystem or a real child process.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
17
|
+
import { Events } from "@pi-archimedes/core/events";
|
|
18
|
+
|
|
19
|
+
const discoverAgents = vi.hoisted(() => vi.fn());
|
|
20
|
+
const executeSubagent = vi.hoisted(() => vi.fn());
|
|
21
|
+
const executeParallel = vi.hoisted(() => vi.fn());
|
|
22
|
+
|
|
23
|
+
vi.mock("./agents.js", async (importOriginal) => ({
|
|
24
|
+
...((await importOriginal()) as Record<string, unknown>),
|
|
25
|
+
discoverAgents,
|
|
26
|
+
}));
|
|
27
|
+
vi.mock("./execute.js", () => ({
|
|
28
|
+
executeSubagent,
|
|
29
|
+
executeParallel,
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
const { registerSubagent } = await import("./index.js");
|
|
33
|
+
|
|
34
|
+
interface FakeTool {
|
|
35
|
+
name: string;
|
|
36
|
+
execute: (
|
|
37
|
+
id: string,
|
|
38
|
+
params: unknown,
|
|
39
|
+
signal: AbortSignal | undefined,
|
|
40
|
+
onUpdate: unknown,
|
|
41
|
+
ctx: unknown,
|
|
42
|
+
) => Promise<{ content: Array<{ text: string }>; isError?: boolean }>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function register(): FakeTool[] {
|
|
46
|
+
const tools: FakeTool[] = [];
|
|
47
|
+
const pi = {
|
|
48
|
+
registerTool: (def: FakeTool) => tools.push(def),
|
|
49
|
+
on: () => {},
|
|
50
|
+
getAllTools: () => [{ name: "read" }, { name: "bash" }],
|
|
51
|
+
// The handle the tool forwards to `executeSubagent`. Recorded so the
|
|
52
|
+
// COST_UPDATE assertions below can say "nothing was published here".
|
|
53
|
+
events: { emit: (channel: string, payload: unknown) => busEmits.push([channel, payload]), on: () => () => {} },
|
|
54
|
+
};
|
|
55
|
+
// `as never` at the seam only: the fake API carries the members the
|
|
56
|
+
// registration path actually touches.
|
|
57
|
+
registerSubagent(pi as never);
|
|
58
|
+
return tools;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Everything the tool emitted on its handle, in order. */
|
|
62
|
+
const busEmits: Array<[string, unknown]> = [];
|
|
63
|
+
|
|
64
|
+
beforeEach(() => {
|
|
65
|
+
busEmits.length = 0;
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
function makeCtx(overrides: Record<string, unknown> = {}) {
|
|
69
|
+
return {
|
|
70
|
+
cwd: "/tmp",
|
|
71
|
+
model: undefined,
|
|
72
|
+
modelRegistry: { getAll: () => [], getAvailable: () => [] },
|
|
73
|
+
hasUI: true,
|
|
74
|
+
ui: { notify: vi.fn() },
|
|
75
|
+
...overrides,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const okResult = {
|
|
80
|
+
agent: "reviewer",
|
|
81
|
+
task: "review it",
|
|
82
|
+
exitCode: 0,
|
|
83
|
+
usage: { input: 10, output: 5, cacheRead: 0, cacheWrite: 0, cost: 0.01, turns: 1 },
|
|
84
|
+
model: "anthropic/claude-opus-4.5",
|
|
85
|
+
finalOutput: "all good",
|
|
86
|
+
error: undefined,
|
|
87
|
+
progress: undefined,
|
|
88
|
+
progressSummary: { toolCount: 1, tokens: 15, durationMs: 10 },
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
function agentConfig(overrides: Record<string, unknown> = {}) {
|
|
92
|
+
return {
|
|
93
|
+
name: "reviewer",
|
|
94
|
+
description: "d",
|
|
95
|
+
systemPrompt: "p",
|
|
96
|
+
source: "user",
|
|
97
|
+
filePath: "/tmp/agents/reviewer.md",
|
|
98
|
+
...overrides,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function subagent(tools: FakeTool[]): FakeTool {
|
|
103
|
+
const tool = tools.find((t) => t.name === "subagent");
|
|
104
|
+
if (!tool) throw new Error("subagent tool was not registered");
|
|
105
|
+
return tool;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
beforeEach(() => {
|
|
109
|
+
discoverAgents.mockReset();
|
|
110
|
+
executeSubagent.mockReset();
|
|
111
|
+
executeParallel.mockReset();
|
|
112
|
+
executeSubagent.mockResolvedValue(okResult);
|
|
113
|
+
executeParallel.mockResolvedValue([okResult]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("dispatch: membership of tools: entries", () => {
|
|
117
|
+
it("a well-formed name pi does not have right now warns, naming the entry", async () => {
|
|
118
|
+
discoverAgents.mockReturnValue([agentConfig({ tools: ["read", "web_search"] })]);
|
|
119
|
+
const ctx = makeCtx();
|
|
120
|
+
const result = await subagent(register()).execute(
|
|
121
|
+
"call-1",
|
|
122
|
+
{ agent: "reviewer", task: "review it" },
|
|
123
|
+
undefined,
|
|
124
|
+
undefined,
|
|
125
|
+
ctx,
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
const warnings = ctx.ui.notify.mock.calls.filter(([, kind]) => kind === "warning");
|
|
129
|
+
expect(warnings.length).toBe(1);
|
|
130
|
+
expect(String(warnings[0]![0])).toContain("web_search");
|
|
131
|
+
expect(result.isError).toBeFalsy();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("and the dispatch still runs, returning the child's real result", async () => {
|
|
135
|
+
discoverAgents.mockReturnValue([agentConfig({ tools: ["read", "web_search"] })]);
|
|
136
|
+
const ctx = makeCtx();
|
|
137
|
+
const result = await subagent(register()).execute(
|
|
138
|
+
"call-1",
|
|
139
|
+
{ agent: "reviewer", task: "review it" },
|
|
140
|
+
undefined,
|
|
141
|
+
undefined,
|
|
142
|
+
ctx,
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
// The success path, not just the message: the child ran and its output is
|
|
146
|
+
// what the model gets back.
|
|
147
|
+
expect(executeSubagent).toHaveBeenCalledTimes(1);
|
|
148
|
+
expect(result.content[0]!.text).toBe("all good");
|
|
149
|
+
expect(result.isError).toBeFalsy();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("names every unavailable entry in one warning", async () => {
|
|
153
|
+
discoverAgents.mockReturnValue([agentConfig({ tools: ["read", "web_search", "fetch_content"] })]);
|
|
154
|
+
const ctx = makeCtx();
|
|
155
|
+
await subagent(register()).execute("c", { agent: "reviewer", task: "t" }, undefined, undefined, ctx);
|
|
156
|
+
const message = String(ctx.ui.notify.mock.calls.find(([, kind]) => kind === "warning")?.[0] ?? "");
|
|
157
|
+
expect(message).toContain("web_search");
|
|
158
|
+
expect(message).toContain("fetch_content");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("says nothing when every entry is a tool pi has", async () => {
|
|
162
|
+
discoverAgents.mockReturnValue([agentConfig({ tools: ["read", "bash"] })]);
|
|
163
|
+
const ctx = makeCtx();
|
|
164
|
+
await subagent(register()).execute("c", { agent: "reviewer", task: "t" }, undefined, undefined, ctx);
|
|
165
|
+
expect(ctx.ui.notify).not.toHaveBeenCalled();
|
|
166
|
+
expect(executeSubagent).toHaveBeenCalledTimes(1);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("says nothing when the agent has no allowlist (nothing to check)", async () => {
|
|
170
|
+
discoverAgents.mockReturnValue([agentConfig()]);
|
|
171
|
+
const ctx = makeCtx();
|
|
172
|
+
await subagent(register()).execute("c", { agent: "reviewer", task: "t" }, undefined, undefined, ctx);
|
|
173
|
+
expect(ctx.ui.notify).not.toHaveBeenCalled();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("a * entry is a pattern, so it is judged against the tools pi has, not by name", async () => {
|
|
177
|
+
const tools: Array<{ name: string }> = [{ name: "read" }, { name: "bash" }, { name: "mcp__radius__query" }];
|
|
178
|
+
discoverAgents.mockReturnValue([agentConfig({ tools: ["read", "mcp__radius__*"] })]);
|
|
179
|
+
const ctx = makeCtx();
|
|
180
|
+
const registered: FakeTool[] = [];
|
|
181
|
+
registerSubagent({
|
|
182
|
+
registerTool: (def: FakeTool) => registered.push(def),
|
|
183
|
+
on: () => {},
|
|
184
|
+
getAllTools: () => tools,
|
|
185
|
+
} as never);
|
|
186
|
+
await subagent(registered).execute("c", { agent: "reviewer", task: "t" }, undefined, undefined, ctx);
|
|
187
|
+
expect(ctx.ui.notify).not.toHaveBeenCalled();
|
|
188
|
+
expect(executeSubagent).toHaveBeenCalledTimes(1);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("a pattern that nothing currently satisfies is named in the warning", async () => {
|
|
192
|
+
discoverAgents.mockReturnValue([agentConfig({ tools: ["read", "mcp__*"] })]);
|
|
193
|
+
const ctx = makeCtx();
|
|
194
|
+
await subagent(register()).execute("c", { agent: "reviewer", task: "t" }, undefined, undefined, ctx);
|
|
195
|
+
const warning = ctx.ui.notify.mock.calls.find(([, kind]) => kind === "warning");
|
|
196
|
+
expect(warning).toBeTruthy();
|
|
197
|
+
expect(String(warning![0])).toContain("mcp__*");
|
|
198
|
+
// Still a warning: the run happens.
|
|
199
|
+
expect(executeSubagent).toHaveBeenCalledTimes(1);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("warns per task in parallel mode and still dispatches the batch", async () => {
|
|
203
|
+
discoverAgents.mockReturnValue([agentConfig({ tools: ["read", "web_search"] })]);
|
|
204
|
+
const ctx = makeCtx();
|
|
205
|
+
const result = await subagent(register()).execute(
|
|
206
|
+
"c",
|
|
207
|
+
{ tasks: [{ agent: "reviewer", task: "a" }, { agent: "reviewer", task: "b" }] },
|
|
208
|
+
undefined,
|
|
209
|
+
undefined,
|
|
210
|
+
ctx,
|
|
211
|
+
);
|
|
212
|
+
expect(executeParallel).toHaveBeenCalledTimes(1);
|
|
213
|
+
expect(result.isError).toBeFalsy();
|
|
214
|
+
const warnings = ctx.ui.notify.mock.calls.filter(([, kind]) => kind === "warning");
|
|
215
|
+
expect(warnings.length).toBeGreaterThan(0);
|
|
216
|
+
expect(String(warnings[0]![0])).toContain("web_search");
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("stays quiet when there is no UI to notify, and still runs", async () => {
|
|
220
|
+
discoverAgents.mockReturnValue([agentConfig({ tools: ["web_search"] })]);
|
|
221
|
+
const ctx = makeCtx({ hasUI: false });
|
|
222
|
+
await subagent(register()).execute("c", { agent: "reviewer", task: "t" }, undefined, undefined, ctx);
|
|
223
|
+
expect(ctx.ui.notify).not.toHaveBeenCalled();
|
|
224
|
+
expect(executeSubagent).toHaveBeenCalledTimes(1);
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
describe("dispatch: a tools list the config read rejected", () => {
|
|
229
|
+
it("refuses to spawn, naming the entry and the file", async () => {
|
|
230
|
+
discoverAgents.mockReturnValue([
|
|
231
|
+
agentConfig({
|
|
232
|
+
tools: undefined,
|
|
233
|
+
toolsError: 'Agent "reviewer" has a tools entry starting with "+" (in /tmp/agents/reviewer.md): "+web_search".',
|
|
234
|
+
}),
|
|
235
|
+
]);
|
|
236
|
+
const ctx = makeCtx();
|
|
237
|
+
const result = await subagent(register()).execute(
|
|
238
|
+
"c",
|
|
239
|
+
{ agent: "reviewer", task: "t" },
|
|
240
|
+
undefined,
|
|
241
|
+
undefined,
|
|
242
|
+
ctx,
|
|
243
|
+
);
|
|
244
|
+
expect(executeSubagent).not.toHaveBeenCalled();
|
|
245
|
+
expect(result.isError).toBe(true);
|
|
246
|
+
expect(result.content[0]!.text).toContain("+web_search");
|
|
247
|
+
expect(result.content[0]!.text).toContain("/tmp/agents/reviewer.md");
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("refuses the whole parallel batch rather than running the poisoned task", async () => {
|
|
251
|
+
discoverAgents.mockReturnValue([
|
|
252
|
+
agentConfig({ toolsError: 'Agent "reviewer" lists the tool "read" more than once (in /tmp/agents/reviewer.md).' }),
|
|
253
|
+
]);
|
|
254
|
+
const ctx = makeCtx();
|
|
255
|
+
const result = await subagent(register()).execute(
|
|
256
|
+
"c",
|
|
257
|
+
{ tasks: [{ agent: "reviewer", task: "a" }] },
|
|
258
|
+
undefined,
|
|
259
|
+
undefined,
|
|
260
|
+
ctx,
|
|
261
|
+
);
|
|
262
|
+
expect(executeParallel).not.toHaveBeenCalled();
|
|
263
|
+
expect(result.isError).toBe(true);
|
|
264
|
+
expect(result.content[0]!.text).toContain("read");
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
describe("dispatch: the result the model reads carries the run outcome", () => {
|
|
269
|
+
it("marks a truncated answer, and does not report it as an error", async () => {
|
|
270
|
+
discoverAgents.mockReturnValue([]);
|
|
271
|
+
executeSubagent.mockResolvedValue({
|
|
272
|
+
...okResult,
|
|
273
|
+
outcome: "truncated",
|
|
274
|
+
exitCode: 1,
|
|
275
|
+
finalOutput: "here is most of it",
|
|
276
|
+
error: undefined,
|
|
277
|
+
});
|
|
278
|
+
const ctx = makeCtx();
|
|
279
|
+
const result = await subagent(register()).execute("c", { task: "t" }, undefined, undefined, ctx);
|
|
280
|
+
expect(result.isError).toBeFalsy();
|
|
281
|
+
expect(result.content[0]!.text).toContain("here is most of it");
|
|
282
|
+
expect(result.content[0]!.text).toMatch(/truncated/i);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("marks a cancelled run", async () => {
|
|
286
|
+
discoverAgents.mockReturnValue([]);
|
|
287
|
+
executeSubagent.mockResolvedValue({
|
|
288
|
+
...okResult,
|
|
289
|
+
outcome: "aborted",
|
|
290
|
+
exitCode: 143,
|
|
291
|
+
finalOutput: undefined,
|
|
292
|
+
});
|
|
293
|
+
const result = await subagent(register()).execute("c", { task: "t" }, undefined, undefined, makeCtx());
|
|
294
|
+
expect(result.isError).toBeFalsy();
|
|
295
|
+
expect(result.content[0]!.text).toMatch(/aborted/i);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("keeps reporting a failed run as an error, with no outcome claim", async () => {
|
|
299
|
+
discoverAgents.mockReturnValue([]);
|
|
300
|
+
executeSubagent.mockResolvedValue({
|
|
301
|
+
...okResult,
|
|
302
|
+
outcome: undefined,
|
|
303
|
+
exitCode: 1,
|
|
304
|
+
finalOutput: undefined,
|
|
305
|
+
error: "boom",
|
|
306
|
+
});
|
|
307
|
+
const result = await subagent(register()).execute("c", { task: "t" }, undefined, undefined, makeCtx());
|
|
308
|
+
expect(result.isError).toBe(true);
|
|
309
|
+
expect(result.content[0]!.text).toContain("boom");
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("reports a failed run that exited 0 as an error too", async () => {
|
|
313
|
+
// The exit code cannot be the trigger: `--mode json` — what the child runs —
|
|
314
|
+
// never maps `stopReason` onto it (that mapping is print mode's text branch),
|
|
315
|
+
// so a child whose last response errored exits 0. Keying `isError` off the
|
|
316
|
+
// code reported a provider failure to the model as a success.
|
|
317
|
+
discoverAgents.mockReturnValue([]);
|
|
318
|
+
executeSubagent.mockResolvedValue({
|
|
319
|
+
...okResult,
|
|
320
|
+
outcome: undefined,
|
|
321
|
+
exitCode: 0,
|
|
322
|
+
finalOutput: undefined,
|
|
323
|
+
error: '401 unauthorized',
|
|
324
|
+
});
|
|
325
|
+
const result = await subagent(register()).execute("c", { task: "t" }, undefined, undefined, makeCtx());
|
|
326
|
+
expect(result.isError).toBe(true);
|
|
327
|
+
expect(result.content[0]!.text).toContain("401");
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("says nothing extra for a completed run", async () => {
|
|
331
|
+
discoverAgents.mockReturnValue([]);
|
|
332
|
+
const result = await subagent(register()).execute("c", { task: "t" }, undefined, undefined, makeCtx());
|
|
333
|
+
expect(result.content[0]!.text).toBe("all good");
|
|
334
|
+
expect(result.isError).toBeFalsy();
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
describe("dispatch: a thinking level pi would drop is said out loud", () => {
|
|
339
|
+
// pi's `--thinking` parser warns and drops an unknown level, and a json
|
|
340
|
+
// child's stderr is invisible on a successful run — so without this the user
|
|
341
|
+
// reads "thinking: ultra" in the config and gets the default. The run still
|
|
342
|
+
// goes ahead (that IS what pi would have done); only the silence changes.
|
|
343
|
+
it("warns, naming the level and what runs instead, and still dispatches", async () => {
|
|
344
|
+
discoverAgents.mockReturnValue([agentConfig({ thinking: "ultra" })]);
|
|
345
|
+
const ctx = makeCtx();
|
|
346
|
+
const result = await subagent(register()).execute(
|
|
347
|
+
"c",
|
|
348
|
+
{ agent: "reviewer", task: "t" },
|
|
349
|
+
undefined,
|
|
350
|
+
undefined,
|
|
351
|
+
ctx,
|
|
352
|
+
);
|
|
353
|
+
const warnings = ctx.ui.notify.mock.calls.filter(([, kind]) => kind === "warning");
|
|
354
|
+
expect(warnings.length).toBe(1);
|
|
355
|
+
expect(String(warnings[0]![0])).toContain("ultra");
|
|
356
|
+
expect(String(warnings[0]![0])).toMatch(/default thinking level/i);
|
|
357
|
+
expect(result.isError).toBeFalsy();
|
|
358
|
+
expect(executeSubagent).toHaveBeenCalledTimes(1);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it("says nothing for a level pi takes", async () => {
|
|
362
|
+
discoverAgents.mockReturnValue([agentConfig({ thinking: "medium" })]);
|
|
363
|
+
const ctx = makeCtx();
|
|
364
|
+
await subagent(register()).execute("c", { agent: "reviewer", task: "t" }, undefined, undefined, ctx);
|
|
365
|
+
expect(ctx.ui.notify).not.toHaveBeenCalled();
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it("says nothing when the config sets no thinking level", async () => {
|
|
369
|
+
discoverAgents.mockReturnValue([agentConfig()]);
|
|
370
|
+
const ctx = makeCtx();
|
|
371
|
+
await subagent(register()).execute("c", { agent: "reviewer", task: "t" }, undefined, undefined, ctx);
|
|
372
|
+
expect(ctx.ui.notify).not.toHaveBeenCalled();
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it("reports it in parallel mode too, once per distinct level", async () => {
|
|
376
|
+
discoverAgents.mockReturnValue([agentConfig({ thinking: "turbo" })]);
|
|
377
|
+
const ctx = makeCtx();
|
|
378
|
+
await subagent(register()).execute(
|
|
379
|
+
"c",
|
|
380
|
+
{ tasks: [{ agent: "reviewer", task: "a" }, { agent: "reviewer", task: "b" }] },
|
|
381
|
+
undefined,
|
|
382
|
+
undefined,
|
|
383
|
+
ctx,
|
|
384
|
+
);
|
|
385
|
+
const warnings = ctx.ui.notify.mock.calls.filter(([, kind]) => kind === "warning");
|
|
386
|
+
expect(warnings.length).toBe(1);
|
|
387
|
+
expect(String(warnings[0]![0])).toContain("turbo");
|
|
388
|
+
expect(executeParallel).toHaveBeenCalledTimes(1);
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
describe("dispatch: cost is reported natively, not on the bus", () => {
|
|
393
|
+
it("the tool result carries usage in pi's shape, so /session folds it in", async () => {
|
|
394
|
+
discoverAgents.mockReturnValue([]);
|
|
395
|
+
const result = (await subagent(register()).execute(
|
|
396
|
+
"c",
|
|
397
|
+
{ task: "t" },
|
|
398
|
+
undefined,
|
|
399
|
+
undefined,
|
|
400
|
+
makeCtx(),
|
|
401
|
+
)) as unknown as {
|
|
402
|
+
usage?: {
|
|
403
|
+
input: number;
|
|
404
|
+
output: number;
|
|
405
|
+
cacheRead: number;
|
|
406
|
+
cacheWrite: number;
|
|
407
|
+
totalTokens: number;
|
|
408
|
+
cost: { total: number };
|
|
409
|
+
};
|
|
410
|
+
};
|
|
411
|
+
expect(result.usage?.input).toBe(10);
|
|
412
|
+
expect(result.usage?.output).toBe(5);
|
|
413
|
+
expect(result.usage?.totalTokens).toBe(15);
|
|
414
|
+
expect(result.usage?.cost.total).toBeCloseTo(0.01);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it("a parallel batch carries the summed usage of its runs", async () => {
|
|
418
|
+
discoverAgents.mockReturnValue([]);
|
|
419
|
+
executeParallel.mockResolvedValue([okResult, okResult]);
|
|
420
|
+
const result = (await subagent(register()).execute(
|
|
421
|
+
"c",
|
|
422
|
+
{ tasks: [{ task: "a" }, { task: "b" }] },
|
|
423
|
+
undefined,
|
|
424
|
+
undefined,
|
|
425
|
+
makeCtx(),
|
|
426
|
+
)) as unknown as { usage?: { input: number; cost: { total: number } } };
|
|
427
|
+
expect(result.usage?.input).toBe(20);
|
|
428
|
+
expect(result.usage?.cost.total).toBeCloseTo(0.02);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it("does not republish the usage of a run the desktop performed (bridge mode)", async () => {
|
|
432
|
+
// A bridge-dispatched child is a session of the desktop's own and books its
|
|
433
|
+
// usage there. pi folds a tool result's `usage` into THIS session's totals,
|
|
434
|
+
// so copying it across would bill the parent for a run it did not perform —
|
|
435
|
+
// the same double-count that deleted the COST_UPDATE route. `executeSubagent`
|
|
436
|
+
// marks the result; the tool has to honour the mark.
|
|
437
|
+
discoverAgents.mockReturnValue([]);
|
|
438
|
+
executeSubagent.mockResolvedValue({ ...okResult, usageAccountedElsewhere: true });
|
|
439
|
+
const result = (await subagent(register()).execute(
|
|
440
|
+
"c",
|
|
441
|
+
{ task: "t" },
|
|
442
|
+
undefined,
|
|
443
|
+
undefined,
|
|
444
|
+
makeCtx(),
|
|
445
|
+
)) as unknown as { usage?: unknown; content: Array<{ text: string }> };
|
|
446
|
+
expect(result.usage).toBeUndefined();
|
|
447
|
+
// Only the cost report is withheld — the run's output is untouched.
|
|
448
|
+
expect(result.content[0]!.text).toBe("all good");
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it("reports the fork path's usage even when a sibling run was done elsewhere", async () => {
|
|
452
|
+
discoverAgents.mockReturnValue([]);
|
|
453
|
+
executeParallel.mockResolvedValue([
|
|
454
|
+
okResult,
|
|
455
|
+
{ ...okResult, usageAccountedElsewhere: true },
|
|
456
|
+
]);
|
|
457
|
+
const result = (await subagent(register()).execute(
|
|
458
|
+
"c",
|
|
459
|
+
{ tasks: [{ task: "a" }, { task: "b" }] },
|
|
460
|
+
undefined,
|
|
461
|
+
undefined,
|
|
462
|
+
makeCtx(),
|
|
463
|
+
)) as unknown as { usage?: { input: number; cost: { total: number } } };
|
|
464
|
+
expect(result.usage?.input).toBe(10);
|
|
465
|
+
expect(result.usage?.cost.total).toBeCloseTo(0.01);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it("publishes nothing on COST_UPDATE while a run reports its cost", async () => {
|
|
469
|
+
discoverAgents.mockReturnValue([]);
|
|
470
|
+
await subagent(register()).execute("c", { task: "t" }, undefined, undefined, makeCtx());
|
|
471
|
+
expect(busEmits.filter(([channel]) => channel === Events.COST_UPDATE)).toEqual([]);
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it("publishes nothing on COST_UPDATE for a parallel batch either", async () => {
|
|
475
|
+
executeParallel.mockResolvedValue([okResult, okResult]);
|
|
476
|
+
discoverAgents.mockReturnValue([]);
|
|
477
|
+
await subagent(register()).execute(
|
|
478
|
+
"c",
|
|
479
|
+
{ tasks: [{ task: "a" }, { task: "b" }] },
|
|
480
|
+
undefined,
|
|
481
|
+
undefined,
|
|
482
|
+
makeCtx(),
|
|
483
|
+
);
|
|
484
|
+
expect(busEmits.filter(([channel]) => channel === Events.COST_UPDATE)).toEqual([]);
|
|
485
|
+
});
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
// The shape handed to the executor, pinned as data.
|
|
489
|
+
//
|
|
490
|
+
// `index.ts` maps each incoming task into the object `executeParallel` receives
|
|
491
|
+
// (and builds the single equivalent for `executeSubagent`). That map is the only
|
|
492
|
+
// place a per-task `cwd` or `model` reaches the child, and it had no guard: the
|
|
493
|
+
// tests above drive both branches through the mocked executors and assert the
|
|
494
|
+
// outcome, never the argument, so deleting a forwarded key stayed green
|
|
495
|
+
// everywhere. This is the behavioural replacement for the source-text scanner
|
|
496
|
+
// deleted in the same branch: it pins what is actually handed over by running the
|
|
497
|
+
// tool, instead of parsing `index.ts` and risking a red run on an unrelated edit.
|
|
498
|
+
// It is a narrower claim than the scanner's — it says nothing about whether the
|
|
499
|
+
// executor later reads a key it is given — see the header of
|
|
500
|
+
// `tool-schema.test.ts`, which states the residue that leaves uncovered.
|
|
501
|
+
describe("dispatch: the object forwarded to the executor", () => {
|
|
502
|
+
it("forwards the full per-task shape to executeParallel", async () => {
|
|
503
|
+
discoverAgents.mockReturnValue([agentConfig()]);
|
|
504
|
+
await subagent(register()).execute(
|
|
505
|
+
"c",
|
|
506
|
+
{ tasks: [{ agent: "reviewer", task: "a", model: "anthropic/claude-opus-4.5", cwd: "/tmp/work" }] },
|
|
507
|
+
undefined,
|
|
508
|
+
undefined,
|
|
509
|
+
makeCtx(),
|
|
510
|
+
);
|
|
511
|
+
|
|
512
|
+
expect(executeParallel).toHaveBeenCalledTimes(1);
|
|
513
|
+
const calls = executeParallel.mock.calls as unknown as Array<[{ tasks: Array<Record<string, unknown>> }]>;
|
|
514
|
+
const forwarded = calls[0]![0].tasks[0]!;
|
|
515
|
+
// Exact and sorted, so it fails in both directions: dropping a forward
|
|
516
|
+
// (`cwd`, `model`, …) silently loses a documented per-task option, and
|
|
517
|
+
// adding one changes the executor's input contract.
|
|
518
|
+
expect(Object.keys(forwarded).sort()).toEqual([
|
|
519
|
+
"activeModel",
|
|
520
|
+
"agent",
|
|
521
|
+
"agentConfig",
|
|
522
|
+
"cwd",
|
|
523
|
+
"model",
|
|
524
|
+
"task",
|
|
525
|
+
"toolCallId",
|
|
526
|
+
]);
|
|
527
|
+
// The key set alone would still pass with the values swapped or dropped to
|
|
528
|
+
// undefined, so the two the #114 discussion cares about are pinned by value.
|
|
529
|
+
expect(forwarded.cwd).toBe("/tmp/work");
|
|
530
|
+
expect(forwarded.model).toBe("anthropic/claude-opus-4.5");
|
|
531
|
+
expect(forwarded.task).toBe("a");
|
|
532
|
+
expect(forwarded.toolCallId).toBe("c");
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
it("forwards the same per-task shape to executeSubagent on the single-run path", async () => {
|
|
536
|
+
discoverAgents.mockReturnValue([agentConfig()]);
|
|
537
|
+
await subagent(register()).execute(
|
|
538
|
+
"c",
|
|
539
|
+
{ agent: "reviewer", task: "t", model: "anthropic/claude-opus-4.5", cwd: "/tmp/work" },
|
|
540
|
+
undefined,
|
|
541
|
+
undefined,
|
|
542
|
+
makeCtx(),
|
|
543
|
+
);
|
|
544
|
+
|
|
545
|
+
expect(executeSubagent).toHaveBeenCalledTimes(1);
|
|
546
|
+
const calls = executeSubagent.mock.calls as unknown as Array<[Record<string, unknown>]>;
|
|
547
|
+
const forwarded = calls[0]![0];
|
|
548
|
+
// The single path takes its arguments flat rather than nested under `tasks`,
|
|
549
|
+
// so its key set differs from the parallel item's by exactly that: no
|
|
550
|
+
// nesting, and `signal` / `events` / `onUpdate` carried per run.
|
|
551
|
+
expect(Object.keys(forwarded).sort()).toEqual([
|
|
552
|
+
"activeModel",
|
|
553
|
+
"agent",
|
|
554
|
+
"agentConfig",
|
|
555
|
+
"cwd",
|
|
556
|
+
"events",
|
|
557
|
+
"model",
|
|
558
|
+
"onUpdate",
|
|
559
|
+
"signal",
|
|
560
|
+
"task",
|
|
561
|
+
"toolCallId",
|
|
562
|
+
]);
|
|
563
|
+
expect(forwarded.cwd).toBe("/tmp/work");
|
|
564
|
+
expect(forwarded.model).toBe("anthropic/claude-opus-4.5");
|
|
565
|
+
});
|
|
566
|
+
});
|