jeopi-cli 16.2.22 → 16.2.24

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 (49) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/dist/cli.js +2565 -2524
  3. package/dist/types/advisor/runtime.d.ts +9 -1
  4. package/dist/types/cli/update-cli.d.ts +37 -0
  5. package/dist/types/config/settings-schema.d.ts +12 -1
  6. package/dist/types/extensibility/plugins/loader.d.ts +4 -4
  7. package/dist/types/extensibility/plugins/trust.d.ts +30 -0
  8. package/dist/types/goals/completion-evidence.d.ts +21 -0
  9. package/dist/types/goals/runtime.d.ts +1 -1
  10. package/dist/types/goals/state.d.ts +3 -0
  11. package/dist/types/goals/tools/goal-tool.d.ts +2 -0
  12. package/dist/types/main.d.ts +20 -0
  13. package/dist/types/mnemopi/state.d.ts +1 -0
  14. package/dist/types/session/agent-session.d.ts +3 -1
  15. package/dist/types/task/critic-gate.d.ts +34 -0
  16. package/dist/types/tools/builtin-names.d.ts +1 -1
  17. package/dist/types/tools/index.d.ts +6 -1
  18. package/dist/types/tools/subagent.d.ts +70 -0
  19. package/dist/types/tools/subagent.test.d.ts +1 -0
  20. package/package.json +12 -12
  21. package/src/advisor/__tests__/advisor.test.ts +173 -0
  22. package/src/advisor/runtime.ts +66 -2
  23. package/src/cli/update-cli.ts +144 -0
  24. package/src/config/settings-schema.ts +14 -2
  25. package/src/extensibility/plugins/loader.ts +35 -9
  26. package/src/extensibility/plugins/trust.ts +133 -0
  27. package/src/goals/completion-evidence.ts +73 -0
  28. package/src/goals/runtime.ts +6 -1
  29. package/src/goals/state.ts +3 -0
  30. package/src/goals/tools/goal-tool.ts +16 -1
  31. package/src/internal-urls/docs-index.generated.txt +2 -2
  32. package/src/main.ts +56 -10
  33. package/src/memories/index.ts +21 -1
  34. package/src/mnemopi/state.ts +10 -2
  35. package/src/modes/interactive-mode.ts +21 -0
  36. package/src/prompts/memories/read-path.md +3 -1
  37. package/src/prompts/system/safety-kernel.md +17 -0
  38. package/src/prompts/system/tool-call-cycle-redirect.md +5 -0
  39. package/src/prompts/tools/goal.md +1 -1
  40. package/src/prompts/tools/subagent.md +29 -0
  41. package/src/session/agent-session.ts +68 -19
  42. package/src/slash-commands/builtin-registry.ts +18 -0
  43. package/src/system-prompt.ts +14 -2
  44. package/src/task/critic-gate.ts +46 -0
  45. package/src/task/index.ts +63 -2
  46. package/src/tools/builtin-names.ts +1 -0
  47. package/src/tools/index.ts +9 -1
  48. package/src/tools/subagent.test.ts +285 -0
  49. package/src/tools/subagent.ts +582 -0
package/src/task/index.ts CHANGED
@@ -45,10 +45,18 @@ import {
45
45
  // Import review tools for side effects (registers subagent tool handlers)
46
46
  import "../tools/review";
47
47
  import type { AsyncJobManager } from "../async";
48
- import type { LocalProtocolOptions } from "../internal-urls";
48
+ import { type LocalProtocolOptions, resolveLocalUrlToPath } from "../internal-urls";
49
49
  import { loadOverallPlanReference } from "../plan-mode/plan-handoff";
50
50
  import { AgentRegistry, MAIN_AGENT_ID } from "../registry/agent-registry";
51
- import { CRITIC_AGENT_NAME, evaluateCriticGateSpawn, parseCriticVerdict, updateCriticGateState } from "./critic-gate";
51
+ import {
52
+ CRITIC_AGENT_NAME,
53
+ evaluateCriticGateSpawn,
54
+ GATED_PLAN_LOCAL_URL,
55
+ hashPlanContent,
56
+ parseCriticVerdict,
57
+ planIntegrityMismatchMessage,
58
+ updateCriticGateState,
59
+ } from "./critic-gate";
52
60
  import { type DiscoveryResult, discoverAgents, getAgent } from "./discovery";
53
61
  import { runSubprocess } from "./executor";
54
62
  import {
@@ -547,6 +555,30 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
547
555
  return this.session.settings.get("task.batch");
548
556
  }
549
557
 
558
+ /** Local protocol options for resolving `local://` paths outside a specific
559
+ * subagent spawn (e.g. the critic-gate plan-integrity check below). */
560
+ #sessionLocalProtocolOptions(): LocalProtocolOptions {
561
+ return (
562
+ this.session.localProtocolOptions ?? {
563
+ getArtifactsDir: this.session.getArtifactsDir ?? (() => null),
564
+ getSessionId: this.session.getSessionId ?? (() => null),
565
+ }
566
+ );
567
+ }
568
+
569
+ /** Read + hash `local://jeo-plan.md` for the critic-gate plan-integrity pin.
570
+ * Returns `null` when the file does not exist or can't be read — callers
571
+ * treat that as "no current plan to compare against". */
572
+ async #hashGatedPlanContent(): Promise<string | null> {
573
+ try {
574
+ const absolutePath = resolveLocalUrlToPath(GATED_PLAN_LOCAL_URL, this.#sessionLocalProtocolOptions());
575
+ const content = await fs.readFile(absolutePath, "utf8");
576
+ return hashPlanContent(content);
577
+ } catch {
578
+ return null;
579
+ }
580
+ }
581
+
550
582
  #getSpawnSemaphore(): Semaphore {
551
583
  const max = this.session.settings.get("task.maxConcurrency");
552
584
  if (this.#spawnSemaphore) {
@@ -1134,6 +1166,21 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1134
1166
  };
1135
1167
  }
1136
1168
 
1169
+ // Plan-integrity pin: even with the gate clear (latest verdict `okay`), a
1170
+ // non-read-only spawn is refused if `local://jeo-plan.md` no longer matches
1171
+ // the content that verdict approved — closes the TOCTOU window between
1172
+ // critic approval and execution (see task/critic-gate.ts).
1173
+ const approvedPlanHash = this.session.getApprovedPlanHash?.();
1174
+ if (approvedPlanHash && !isReadOnlyAgent(agent)) {
1175
+ const currentHash = await this.#hashGatedPlanContent();
1176
+ if (currentHash !== approvedPlanHash.hash) {
1177
+ return {
1178
+ content: [{ type: "text", text: planIntegrityMismatchMessage(approvedPlanHash) }],
1179
+ details: { projectAgentsDir, results: [], totalDurationMs: 0 },
1180
+ };
1181
+ }
1182
+ }
1183
+
1137
1184
  const planModeState = this.session.getPlanModeState?.();
1138
1185
  const planModeBaseTools = ["read", "grep", "glob", "lsp", "web_search"];
1139
1186
  const planModeTools = [
@@ -1418,6 +1465,20 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1418
1465
  this.session.setCriticGateState(
1419
1466
  updateCriticGateState(this.session.getCriticGateState?.(), parsed, result.id),
1420
1467
  );
1468
+ // Plan-integrity pin: an `okay` verdict records the current plan
1469
+ // content's hash so a later spawn can detect the plan changing out
1470
+ // from under this exact approval (see the check above and
1471
+ // task/critic-gate.ts). A non-okay verdict clears any prior pin —
1472
+ // the stronger critic-gate write/spawn lock takes over until the
1473
+ // next `okay`, so a stale pin has no protective value to keep.
1474
+ if (this.session.setApprovedPlanHash) {
1475
+ if (parsed.verdict === "okay") {
1476
+ const hash = await this.#hashGatedPlanContent();
1477
+ this.session.setApprovedPlanHash(hash ? { hash, agentId: result.id, at: Date.now() } : undefined);
1478
+ } else {
1479
+ this.session.setApprovedPlanHash(undefined);
1480
+ }
1481
+ }
1421
1482
  }
1422
1483
  }
1423
1484
 
@@ -19,6 +19,7 @@ export const BUILTIN_TOOL_NAMES = [
19
19
  "task",
20
20
  "job",
21
21
  "irc",
22
+ "subagent",
22
23
  "todo",
23
24
  "web_search",
24
25
  "search_tool_bm25",
@@ -27,7 +27,7 @@ import type { CustomMessage } from "../session/messages";
27
27
  import type { UsageStatistics } from "../session/session-entries";
28
28
  import type { ToolChoiceQueue } from "../session/tool-choice-queue";
29
29
  import { TaskTool } from "../task";
30
- import type { CriticGateState } from "../task/critic-gate";
30
+ import type { ApprovedPlanHash, CriticGateState } from "../task/critic-gate";
31
31
  import type { AgentOutputManager } from "../task/output-manager";
32
32
  import { canSpawnAtDepth } from "../task/types";
33
33
  import { countToolsForAutoDiscovery, resolveEffectiveToolDiscoveryMode } from "../tool-discovery/mode";
@@ -64,6 +64,7 @@ import { ResolveTool } from "./resolve";
64
64
  import { reportFindingTool } from "./review";
65
65
  import { SearchToolBm25Tool } from "./search-tool-bm25";
66
66
  import { loadSshTool } from "./ssh";
67
+ import { isSubagentToolEnabled, SubagentTool } from "./subagent";
67
68
  import { type TodoPhase, TodoTool } from "./todo";
68
69
  import { WriteTool } from "./write";
69
70
  import { YieldTool } from "./yield";
@@ -102,6 +103,7 @@ export * from "./resolve";
102
103
  export * from "./review";
103
104
  export * from "./search-tool-bm25";
104
105
  export * from "./ssh";
106
+ export * from "./subagent";
105
107
  export * from "./todo";
106
108
  export * from "./tts";
107
109
  export * from "./write";
@@ -278,6 +280,10 @@ export interface ToolSession {
278
280
  getCriticGateState?: () => CriticGateState | undefined;
279
281
  /** Replace the critic gate state (undefined clears the gate). */
280
282
  setCriticGateState?: (state: CriticGateState | undefined) => void;
283
+ /** Plan content hash a critic verdict of `okay` approved (see `task/critic-gate.ts`); survives the gate clearing on `okay` so a later spawn can detect post-approval plan drift. */
284
+ getApprovedPlanHash?: () => ApprovedPlanHash | undefined;
285
+ /** Replace the approved-plan hash (undefined clears it). */
286
+ setApprovedPlanHash?: (hash: ApprovedPlanHash | undefined) => void;
281
287
  /** Goal mode state (if active or paused) */
282
288
  getGoalModeState?: () => GoalModeState | undefined;
283
289
  /** Goal runtime for the active agent session. */
@@ -466,6 +472,7 @@ export const BUILTIN_TOOLS: Record<BuiltinToolName, ToolFactory> = {
466
472
  task: s => TaskTool.create(s),
467
473
  job: s => new JobTool(s),
468
474
  irc: IrcTool.createIf,
475
+ subagent: SubagentTool.createIf,
469
476
  todo: s => new TodoTool(s),
470
477
  web_search: s => new WebSearchTool(s),
471
478
  search_tool_bm25: SearchToolBm25Tool.createIf,
@@ -620,6 +627,7 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
620
627
  if (name === "browser") return session.settings.get("browser.enabled");
621
628
  if (name === "checkpoint" || name === "rewind") return session.settings.get("checkpoint.enabled");
622
629
  if (name === "irc") return isIrcEnabled(session.settings, session.taskDepth ?? 0);
630
+ if (name === "subagent") return isSubagentToolEnabled(session.settings, session.taskDepth ?? 0);
623
631
  if (name === "retain" || name === "recall" || name === "reflect") {
624
632
  return ["hindsight", "mnemopi"].includes(session.settings.get("memory.backend") ?? "");
625
633
  }
@@ -0,0 +1,285 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2
+ import { AsyncJobManager } from "../async";
3
+ import { Settings } from "../config/settings";
4
+ import { IrcBus } from "../irc/bus";
5
+ import { AgentRegistry } from "../registry/agent-registry";
6
+ import type { AgentSession } from "../session/agent-session";
7
+ import type { ToolSession } from "./index";
8
+ import { SubagentTool } from "./subagent";
9
+ import { ToolError } from "./tool-errors";
10
+
11
+ function makeSession(opts: { agentId: string; registry: AgentRegistry; manager?: AsyncJobManager }): ToolSession {
12
+ return {
13
+ cwd: process.cwd(),
14
+ hasUI: false,
15
+ settings: Settings.isolated(),
16
+ getSessionFile: () => null,
17
+ getSessionSpawns: () => "*",
18
+ agentRegistry: opts.registry,
19
+ asyncJobManager: opts.manager,
20
+ getAgentId: () => opts.agentId,
21
+ };
22
+ }
23
+
24
+ function textOf(result: { content: Array<{ type: string; text?: string }> }): string {
25
+ return result.content.find(part => part.type === "text")?.text ?? "";
26
+ }
27
+
28
+ /** Registers a `task` job whose `run` never resolves until the test resolves it. */
29
+ function registerPendingTaskJob(manager: AsyncJobManager, ownerId: string, id: string) {
30
+ const { promise, resolve } = Promise.withResolvers<string>();
31
+ manager.register(
32
+ "task",
33
+ id,
34
+ async ({ markRunning }) => {
35
+ markRunning();
36
+ return promise;
37
+ },
38
+ { id, ownerId },
39
+ );
40
+ return { resolve };
41
+ }
42
+
43
+ describe("SubagentTool", () => {
44
+ let registry: AgentRegistry;
45
+ let manager: AsyncJobManager;
46
+
47
+ beforeEach(() => {
48
+ // `IrcBus.global()` (used internally by steer/resume/pause) always routes
49
+ // through `AgentRegistry.global()`, so the fake session must live there
50
+ // too for delivery to resolve the same refs `list`/`inspect` see.
51
+ AgentRegistry.resetGlobalForTests();
52
+ registry = AgentRegistry.global();
53
+ manager = new AsyncJobManager({ onJobComplete: () => {} });
54
+ IrcBus.resetGlobalForTests();
55
+ });
56
+
57
+ afterEach(() => {
58
+ manager.cancelAll();
59
+ });
60
+
61
+ it("scopes `list` to the calling agent's own spawns", async () => {
62
+ registerPendingTaskJob(manager, "Main", "Sub1");
63
+ registerPendingTaskJob(manager, "Other", "Sub2");
64
+
65
+ const tool = new SubagentTool(makeSession({ agentId: "Main", registry, manager }));
66
+ const result = await tool.execute("call-1", { action: "list" });
67
+
68
+ expect(result.details?.subagents.map(s => s.id)).toEqual(["Sub1"]);
69
+ });
70
+
71
+ it('reports a spawn as "queued" until its runner calls markRunning', async () => {
72
+ const { promise, resolve } = Promise.withResolvers<string>();
73
+ manager.register("task", "Sub1", async () => promise, { id: "Sub1", ownerId: "Main", queued: true });
74
+
75
+ const tool = new SubagentTool(makeSession({ agentId: "Main", registry, manager }));
76
+ const result = await tool.execute("call-1", { action: "list" });
77
+
78
+ expect(result.details?.subagents[0]).toMatchObject({ id: "Sub1", status: "queued" });
79
+ resolve("cleanup");
80
+ });
81
+
82
+ it("rejects cancel without a target id", async () => {
83
+ const tool = new SubagentTool(makeSession({ agentId: "Main", registry, manager }));
84
+ await expect(tool.execute("call-1", { action: "cancel" })).rejects.toThrow(ToolError);
85
+ });
86
+
87
+ it("classifies cancel outcomes: cancelled, not_found, already_completed", async () => {
88
+ const { resolve } = registerPendingTaskJob(manager, "Main", "Running1");
89
+ manager.register("task", "Done1", async () => "done", { id: "Done1", ownerId: "Main" });
90
+ // Let Done1 settle before cancelling it.
91
+ await manager.getJob("Done1")?.promise;
92
+
93
+ const tool = new SubagentTool(makeSession({ agentId: "Main", registry, manager }));
94
+ const result = await tool.execute("call-1", { action: "cancel", ids: ["Running1", "Done1", "Ghost"] });
95
+
96
+ expect(result.details?.cancelled).toEqual(
97
+ expect.arrayContaining([
98
+ { id: "Running1", status: "cancelled" },
99
+ { id: "Done1", status: "already_completed" },
100
+ { id: "Ghost", status: "not_found" },
101
+ ]),
102
+ );
103
+ resolve("cleanup");
104
+ });
105
+
106
+ it("cannot cancel a job owned by a different agent", async () => {
107
+ const { resolve } = registerPendingTaskJob(manager, "Other", "Sub1");
108
+ const tool = new SubagentTool(makeSession({ agentId: "Main", registry, manager }));
109
+ const result = await tool.execute("call-1", { action: "cancel", ids: ["Sub1"] });
110
+
111
+ expect(result.details?.cancelled).toEqual([{ id: "Sub1", status: "not_found" }]);
112
+ expect(manager.getJob("Sub1")?.status).toBe("running");
113
+ resolve("cleanup");
114
+ });
115
+
116
+ it("rejects steer/resume/pause targeting the caller itself", async () => {
117
+ const tool = new SubagentTool(makeSession({ agentId: "Main", registry, manager }));
118
+ await expect(tool.execute("c1", { action: "steer", id: "Main", message: "hi" })).rejects.toThrow(ToolError);
119
+ await expect(tool.execute("c2", { action: "resume", id: "Main", message: "hi" })).rejects.toThrow(ToolError);
120
+ await expect(tool.execute("c3", { action: "pause", id: "Main" })).rejects.toThrow(ToolError);
121
+ });
122
+
123
+ it("resume is a no-op on a still-running subagent and does not require a message", async () => {
124
+ const { resolve } = registerPendingTaskJob(manager, "Main", "Sub1");
125
+ registry.register({ id: "Sub1", displayName: "Sub1", kind: "sub", session: null, status: "running" });
126
+
127
+ const tool = new SubagentTool(makeSession({ agentId: "Main", registry, manager }));
128
+ const result = await tool.execute("call-1", { action: "resume", id: "Sub1" });
129
+
130
+ expect(result.isError).toBeFalsy();
131
+ expect(textOf(result)).toContain("already running");
132
+ resolve("cleanup");
133
+ });
134
+
135
+ it("resume requires a message once the subagent has finished", async () => {
136
+ manager.register("task", "Sub1", async () => "done", { id: "Sub1", ownerId: "Main" });
137
+ await manager.getJob("Sub1")?.promise;
138
+ registry.register({ id: "Sub1", displayName: "Sub1", kind: "sub", session: null, status: "idle" });
139
+
140
+ const tool = new SubagentTool(makeSession({ agentId: "Main", registry, manager }));
141
+ await expect(tool.execute("call-1", { action: "resume", id: "Sub1" })).rejects.toThrow(ToolError);
142
+ });
143
+
144
+ it("marks a finished-but-resident subagent as resumable in its snapshot", async () => {
145
+ manager.register("task", "Sub1", async () => "done", { id: "Sub1", ownerId: "Main" });
146
+ await manager.getJob("Sub1")?.promise;
147
+ registry.register({ id: "Sub1", displayName: "Sub1", kind: "sub", session: null, status: "idle" });
148
+
149
+ const tool = new SubagentTool(makeSession({ agentId: "Main", registry, manager }));
150
+ const result = await tool.execute("call-1", { action: "list" });
151
+
152
+ expect(result.details?.subagents[0]).toMatchObject({ id: "Sub1", status: "completed", resumable: true });
153
+ });
154
+
155
+ it("pause is a no-op once the subagent already finished", async () => {
156
+ manager.register("task", "Sub1", async () => "done", { id: "Sub1", ownerId: "Main" });
157
+ await manager.getJob("Sub1")?.promise;
158
+ registry.register({ id: "Sub1", displayName: "Sub1", kind: "sub", session: null, status: "idle" });
159
+
160
+ const tool = new SubagentTool(makeSession({ agentId: "Main", registry, manager }));
161
+ const result = await tool.execute("call-1", { action: "pause", id: "Sub1" });
162
+
163
+ expect(textOf(result)).toContain("not running");
164
+ });
165
+ it('pause marks a still-running subagent "paused"; a later resume clears it', async () => {
166
+ const { resolve } = registerPendingTaskJob(manager, "Main", "Sub1");
167
+ const fakeSession = { deliverIrcMessage: async () => "injected" as const } as unknown as AgentSession;
168
+ registry.register({ id: "Sub1", displayName: "Sub1", kind: "sub", session: fakeSession, status: "running" });
169
+
170
+ const tool = new SubagentTool(makeSession({ agentId: "Main", registry, manager }));
171
+ const pauseResult = await tool.execute("c1", { action: "pause", id: "Sub1" });
172
+ expect(pauseResult.isError).toBeFalsy();
173
+
174
+ // Simulate the subagent actually reaching a safe boundary and stopping in
175
+ // response to the pause request (a real run would flip both together).
176
+ resolve("stopped early");
177
+ await manager.getJob("Sub1")?.promise;
178
+ registry.setStatus("Sub1", "idle");
179
+
180
+ const paused = await tool.execute("c2", { action: "list" });
181
+ expect(paused.details?.subagents[0]).toMatchObject({ id: "Sub1", status: "paused", resumable: true });
182
+
183
+ const resumeResult = await tool.execute("c3", { action: "resume", id: "Sub1", message: "continue" });
184
+ expect(resumeResult.isError).toBeFalsy();
185
+
186
+ const afterResume = await tool.execute("c4", { action: "list" });
187
+ expect(afterResume.details?.subagents[0].status).not.toBe("paused");
188
+ });
189
+
190
+ it("a subagent that goes idle while running is never mistaken for paused", async () => {
191
+ const { resolve } = registerPendingTaskJob(manager, "Main", "Sub1");
192
+ registry.register({ id: "Sub1", displayName: "Sub1", kind: "sub", session: null, status: "running" });
193
+ resolve("finished on its own");
194
+ await manager.getJob("Sub1")?.promise;
195
+ registry.setStatus("Sub1", "idle");
196
+
197
+ const tool = new SubagentTool(makeSession({ agentId: "Main", registry, manager }));
198
+ const result = await tool.execute("call-1", { action: "list" });
199
+
200
+ expect(result.details?.subagents[0]).toMatchObject({ id: "Sub1", status: "completed", resumable: true });
201
+ });
202
+
203
+ it("cancel aborts a live resumed session even without an active async job", async () => {
204
+ const abortReasons: (string | undefined)[] = [];
205
+ const fakeSession = {
206
+ abort: async (opts?: { reason?: string }) => {
207
+ abortReasons.push(opts?.reason);
208
+ },
209
+ } as unknown as AgentSession;
210
+ manager.register("task", "Sub1", async () => "done", { id: "Sub1", ownerId: "Main" });
211
+ await manager.getJob("Sub1")?.promise;
212
+ registry.register({ id: "Sub1", displayName: "Sub1", kind: "sub", session: fakeSession, status: "running" });
213
+
214
+ const tool = new SubagentTool(makeSession({ agentId: "Main", registry, manager }));
215
+ const result = await tool.execute("call-1", { action: "cancel", id: "Sub1" });
216
+
217
+ expect(result.details?.cancelled).toEqual([{ id: "Sub1", status: "cancelled" }]);
218
+ expect(abortReasons).toEqual(["Cancelled via subagent tool"]);
219
+ });
220
+
221
+ it("applies `limit` to `list` and reports how many were hidden", async () => {
222
+ registerPendingTaskJob(manager, "Main", "Sub1");
223
+ registerPendingTaskJob(manager, "Main", "Sub2");
224
+ registerPendingTaskJob(manager, "Main", "Sub3");
225
+
226
+ const tool = new SubagentTool(makeSession({ agentId: "Main", registry, manager }));
227
+ const result = await tool.execute("call-1", { action: "list", limit: 2 });
228
+
229
+ expect(result.details?.subagents).toHaveLength(2);
230
+ expect(result.details?.truncated).toBe(1);
231
+ });
232
+
233
+ it("verbosity widths match GJC: receipt<=280, preview<=2000, full<=12000 chars", async () => {
234
+ const longOutput = "x".repeat(15_000);
235
+ manager.register("task", "Sub1", async () => longOutput, { id: "Sub1", ownerId: "Main" });
236
+ await manager.getJob("Sub1")?.promise;
237
+
238
+ const tool = new SubagentTool(makeSession({ agentId: "Main", registry, manager }));
239
+
240
+ // `verbosity` defaults to "receipt" per GJC (not "preview").
241
+ const defaulted = await tool.execute("c0", { action: "inspect", ids: ["Sub1"] });
242
+ expect(defaulted.details?.subagents[0].resultText).toHaveLength(280);
243
+
244
+ const receipt = await tool.execute("c1", { action: "inspect", ids: ["Sub1"], verbosity: "receipt" });
245
+ expect(receipt.details?.subagents[0].resultText).toHaveLength(280);
246
+
247
+ const preview = await tool.execute("c2", { action: "inspect", ids: ["Sub1"], verbosity: "preview" });
248
+ expect(preview.details?.subagents[0].resultText).toHaveLength(2_000);
249
+
250
+ const full = await tool.execute("c3", { action: "inspect", ids: ["Sub1"], verbosity: "full" });
251
+ expect(full.details?.subagents[0].resultText).toHaveLength(12_000);
252
+ });
253
+
254
+ it('rejects verbosity="full" on `list`, and on any action without explicit ids', async () => {
255
+ const tool = new SubagentTool(makeSession({ agentId: "Main", registry, manager }));
256
+ await expect(tool.execute("c1", { action: "list", verbosity: "full" })).rejects.toThrow(ToolError);
257
+ await expect(tool.execute("c2", { action: "inspect", verbosity: "full" })).rejects.toThrow(ToolError);
258
+ // Explicit ids makes `full` valid for a non-list action.
259
+ const result = await tool.execute("c3", { action: "inspect", ids: ["Ghost"], verbosity: "full" });
260
+ expect(result.isError).toBe(true);
261
+ });
262
+
263
+ it("`list` defaults to 10 results and clamps `limit` to a max of 50", async () => {
264
+ for (let i = 0; i < 12; i++) {
265
+ registerPendingTaskJob(manager, "Main", `Sub${i}`);
266
+ }
267
+ const tool = new SubagentTool(makeSession({ agentId: "Main", registry, manager }));
268
+
269
+ const defaulted = await tool.execute("c1", { action: "list" });
270
+ expect(defaulted.details?.subagents).toHaveLength(10);
271
+ expect(defaulted.details?.truncated).toBe(2);
272
+
273
+ const overshoot = await tool.execute("c2", { action: "list", limit: 1_000 });
274
+ expect(overshoot.details?.subagents).toHaveLength(12);
275
+ expect(overshoot.details?.truncated).toBeUndefined();
276
+ });
277
+
278
+ it("accepts `timeout_ms` (snake_case, matching GJC) for `await`", async () => {
279
+ registerPendingTaskJob(manager, "Main", "Sub1");
280
+ const tool = new SubagentTool(makeSession({ agentId: "Main", registry, manager }));
281
+
282
+ const result = await tool.execute("c1", { action: "await", ids: ["Sub1"], timeout_ms: 5 });
283
+ expect(result.details?.subagents[0]).toMatchObject({ id: "Sub1", status: "running" });
284
+ });
285
+ });