@ilya-lesikov/pi-pi 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/3p/pi-ask-user/index.ts +71 -124
  2. package/3p/pi-ask-user/single-select-layout.ts +3 -14
  3. package/3p/pi-subagents/package.json +13 -7
  4. package/3p/pi-subagents/src/agent-manager.ts +243 -79
  5. package/3p/pi-subagents/src/agent-runner.ts +501 -365
  6. package/3p/pi-subagents/src/agent-types.ts +46 -57
  7. package/3p/pi-subagents/src/cross-extension-rpc.ts +52 -46
  8. package/3p/pi-subagents/src/custom-agents.ts +30 -6
  9. package/3p/pi-subagents/src/default-agents.ts +25 -43
  10. package/3p/pi-subagents/src/enabled-models.ts +180 -0
  11. package/3p/pi-subagents/src/index.ts +599 -839
  12. package/3p/pi-subagents/src/model-resolver.ts +26 -7
  13. package/3p/pi-subagents/src/output-file.ts +21 -2
  14. package/3p/pi-subagents/src/prompts.ts +17 -3
  15. package/3p/pi-subagents/src/schedule-store.ts +153 -0
  16. package/3p/pi-subagents/src/schedule.ts +365 -0
  17. package/3p/pi-subagents/src/settings.ts +261 -0
  18. package/3p/pi-subagents/src/skill-loader.ts +77 -54
  19. package/3p/pi-subagents/src/status-note.ts +25 -0
  20. package/3p/pi-subagents/src/types.ts +97 -6
  21. package/3p/pi-subagents/src/ui/agent-widget.ts +94 -21
  22. package/3p/pi-subagents/src/ui/conversation-viewer.ts +147 -35
  23. package/3p/pi-subagents/src/ui/schedule-menu.ts +104 -0
  24. package/3p/pi-subagents/src/ui/viewer-keys.ts +39 -0
  25. package/3p/pi-subagents/src/usage.ts +60 -0
  26. package/3p/pi-subagents/src/worktree.ts +49 -20
  27. package/extensions/orchestrator/agents/advisor.ts +13 -8
  28. package/extensions/orchestrator/agents/brainstorm-reviewer.ts +8 -3
  29. package/extensions/orchestrator/agents/code-reviewer.ts +8 -3
  30. package/extensions/orchestrator/agents/constraints.test.ts +26 -0
  31. package/extensions/orchestrator/agents/constraints.ts +12 -2
  32. package/extensions/orchestrator/agents/deep-debugger.ts +13 -8
  33. package/extensions/orchestrator/agents/explore.ts +12 -6
  34. package/extensions/orchestrator/agents/librarian.ts +13 -5
  35. package/extensions/orchestrator/agents/plan-reviewer.ts +8 -3
  36. package/extensions/orchestrator/agents/planner.ts +8 -3
  37. package/extensions/orchestrator/agents/pools.test.ts +56 -0
  38. package/extensions/orchestrator/agents/prompts.test.ts +52 -10
  39. package/extensions/orchestrator/agents/registry.test.ts +245 -0
  40. package/extensions/orchestrator/agents/registry.ts +145 -10
  41. package/extensions/orchestrator/agents/reviewer.ts +14 -8
  42. package/extensions/orchestrator/agents/task.ts +12 -6
  43. package/extensions/orchestrator/agents/tool-routing.ts +213 -85
  44. package/extensions/orchestrator/agents/wait-for-completion.test.ts +171 -0
  45. package/extensions/orchestrator/ast-search.test.ts +124 -0
  46. package/extensions/orchestrator/billing-spoof.test.ts +67 -0
  47. package/extensions/orchestrator/billing-spoof.ts +95 -0
  48. package/extensions/orchestrator/cbm.more.test.ts +358 -0
  49. package/extensions/orchestrator/command-handlers.ts +6 -0
  50. package/extensions/orchestrator/commands.test.ts +15 -2
  51. package/extensions/orchestrator/commands.ts +1 -1
  52. package/extensions/orchestrator/config.test.ts +89 -1
  53. package/extensions/orchestrator/config.ts +102 -19
  54. package/extensions/orchestrator/context.test.ts +46 -0
  55. package/extensions/orchestrator/context.ts +18 -5
  56. package/extensions/orchestrator/custom-footer.test.ts +24 -10
  57. package/extensions/orchestrator/custom-footer.ts +4 -2
  58. package/extensions/orchestrator/doctor.more.test.ts +315 -0
  59. package/extensions/orchestrator/doctor.ts +6 -2
  60. package/extensions/orchestrator/event-handlers.more.test.ts +561 -0
  61. package/extensions/orchestrator/event-handlers.test.ts +96 -9
  62. package/extensions/orchestrator/event-handlers.ts +344 -151
  63. package/extensions/orchestrator/exa.more.test.ts +118 -0
  64. package/extensions/orchestrator/flant-infra.more.test.ts +326 -0
  65. package/extensions/orchestrator/flant-infra.test.ts +127 -0
  66. package/extensions/orchestrator/flant-infra.ts +113 -39
  67. package/extensions/orchestrator/index.test.ts +76 -0
  68. package/extensions/orchestrator/index.ts +2 -0
  69. package/extensions/orchestrator/integration.test.ts +183 -65
  70. package/extensions/orchestrator/model-registry.test.ts +2 -1
  71. package/extensions/orchestrator/model-registry.ts +12 -2
  72. package/extensions/orchestrator/orchestrator.test.ts +67 -0
  73. package/extensions/orchestrator/orchestrator.ts +119 -27
  74. package/extensions/orchestrator/phases/brainstorm.test.ts +30 -1
  75. package/extensions/orchestrator/phases/brainstorm.ts +43 -6
  76. package/extensions/orchestrator/phases/implementation.ts +2 -0
  77. package/extensions/orchestrator/phases/machine.test.ts +17 -1
  78. package/extensions/orchestrator/phases/machine.ts +4 -1
  79. package/extensions/orchestrator/phases/planning.test.ts +47 -1
  80. package/extensions/orchestrator/phases/planning.ts +18 -3
  81. package/extensions/orchestrator/phases/review-task.ts +5 -0
  82. package/extensions/orchestrator/phases/review.test.ts +10 -0
  83. package/extensions/orchestrator/phases/review.ts +22 -7
  84. package/extensions/orchestrator/phases/spawn-blocking.test.ts +1 -0
  85. package/extensions/orchestrator/phases/verdict.ts +6 -5
  86. package/extensions/orchestrator/plannotator-open-failure.test.ts +67 -0
  87. package/extensions/orchestrator/plannotator.test.ts +38 -1
  88. package/extensions/orchestrator/plannotator.ts +50 -3
  89. package/extensions/orchestrator/pp-menu.leaves.test.ts +590 -0
  90. package/extensions/orchestrator/pp-menu.more.test.ts +524 -0
  91. package/extensions/orchestrator/pp-menu.test.ts +114 -7
  92. package/extensions/orchestrator/pp-menu.ts +579 -90
  93. package/extensions/orchestrator/pp-state-tools.test.ts +80 -0
  94. package/extensions/orchestrator/pp-state-tools.ts +65 -0
  95. package/extensions/orchestrator/rate-limit-fallback.more.test.ts +241 -0
  96. package/extensions/orchestrator/rate-limit-fallback.test.ts +20 -1
  97. package/extensions/orchestrator/rate-limit-fallback.ts +12 -0
  98. package/extensions/orchestrator/review-files.test.ts +26 -0
  99. package/extensions/orchestrator/review-files.ts +3 -0
  100. package/extensions/orchestrator/state.test.ts +73 -1
  101. package/extensions/orchestrator/state.ts +95 -23
  102. package/extensions/orchestrator/suppress-pierre-theme-spam.test.ts +56 -0
  103. package/extensions/orchestrator/suppress-pierre-theme-spam.ts +45 -0
  104. package/extensions/orchestrator/validate-artifacts.ts +1 -1
  105. package/package.json +6 -2
  106. package/scripts/test-3p.sh +52 -0
@@ -0,0 +1,561 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "fs";
3
+ import { join } from "path";
4
+ import { tmpdir } from "os";
5
+
6
+ const askQueue: Array<any> = [];
7
+ const askQuestions: string[] = [];
8
+ vi.mock("../../3p/pi-ask-user/index.js", () => ({
9
+ isCancel: (r: any) => r?.__cancel === true,
10
+ askUser: vi.fn(async (_ctx: any, opts: any) => {
11
+ askQuestions.push(opts.question);
12
+ if (askQueue.length === 0) return { __cancel: true, reason: "user" };
13
+ return askQueue.shift();
14
+ }),
15
+ }));
16
+
17
+ vi.mock("./pp-menu.js", () => ({
18
+ USER_CANCELLED: Symbol.for("pi-pi:test:user-cancelled"),
19
+ showActiveTaskMenu: vi.fn(async () => "MENU_RESULT"),
20
+ }));
21
+
22
+ import {
23
+ registerEventHandlers,
24
+ registerOrchestratorToolsForTest,
25
+ detectDefaultBranch,
26
+ selectOption,
27
+ enterReviewCycle,
28
+ stopTask,
29
+ checkoutPrHead,
30
+ finalizeReviewCycle,
31
+ finalizeReviewCycleAutonomous,
32
+ isReviewCycleLive,
33
+ } from "./event-handlers.js";
34
+ import { Orchestrator, type ActiveTask } from "./orchestrator.js";
35
+ import { getDefaultConfig } from "./config.js";
36
+ import { normalizeRepoPath, type RepoInfo } from "./repo-utils.js";
37
+
38
+ type Handler = (event: any, ctx: any) => any;
39
+
40
+ function makePi() {
41
+ const handlers = new Map<string, Handler>();
42
+ const eventHandlers = new Map<string, Handler>();
43
+ return {
44
+ on: vi.fn((name: string, handler: Handler) => {
45
+ handlers.set(name, handler);
46
+ }),
47
+ events: {
48
+ on: vi.fn((name: string, handler: Handler) => {
49
+ eventHandlers.set(name, handler);
50
+ }),
51
+ emit: vi.fn(),
52
+ },
53
+ getAllTools: vi.fn().mockReturnValue([{ name: "lsp" }]),
54
+ registerTool: vi.fn(),
55
+ sendMessage: vi.fn(),
56
+ sendUserMessage: vi.fn(),
57
+ setModel: vi.fn(),
58
+ setThinkingLevel: vi.fn(),
59
+ setSessionName: vi.fn(),
60
+ exec: vi.fn(),
61
+ _handlers: handlers,
62
+ _eventHandlers: eventHandlers,
63
+ };
64
+ }
65
+
66
+ function makeConfig() {
67
+ const config = getDefaultConfig();
68
+ config.general.autoCommit = false;
69
+ config.commands.afterEdit = {};
70
+ config.commands.afterImplement = {};
71
+ config.performance.commands.afterEdit = 1;
72
+ config.performance.commands.afterImplement = 1;
73
+ config.performance.internals.subagentStale = 1;
74
+ config.performance.internals.taskLockStale = 1;
75
+ config.performance.internals.taskLockRefresh = 1;
76
+ return config;
77
+ }
78
+
79
+ const tempDirs: string[] = [];
80
+ function makeTaskDir(): string {
81
+ const dir = mkdtempSync(join(tmpdir(), "pi-pi-eh-more-"));
82
+ tempDirs.push(dir);
83
+ return dir;
84
+ }
85
+
86
+ function makeActiveTask(dir?: string): ActiveTask {
87
+ return {
88
+ dir: dir ?? makeTaskDir(),
89
+ type: "implement",
90
+ state: {
91
+ phase: "implement",
92
+ step: "llm_work",
93
+ reviewCycle: null,
94
+ reviewPass: 0,
95
+ from: null,
96
+ description: "Test",
97
+ startedAt: new Date().toISOString(),
98
+ },
99
+ release: null,
100
+ taskId: "123",
101
+ modifiedFiles: new Set(),
102
+ reviewPass: 0,
103
+ description: "Test",
104
+ };
105
+ }
106
+
107
+ let pi: ReturnType<typeof makePi>;
108
+ let orchestrator: Orchestrator;
109
+
110
+ beforeEach(() => {
111
+ askQueue.length = 0;
112
+ askQuestions.length = 0;
113
+ pi = makePi();
114
+ orchestrator = new Orchestrator(pi as any);
115
+ orchestrator.cwd = "/project";
116
+ orchestrator.config = makeConfig() as any;
117
+ registerEventHandlers(orchestrator);
118
+ });
119
+
120
+ afterEach(() => {
121
+ orchestrator.resetTaskScopedState();
122
+ for (const dir of tempDirs.splice(0)) {
123
+ rmSync(dir, { recursive: true, force: true });
124
+ }
125
+ vi.clearAllMocks();
126
+ });
127
+
128
+ function getHandler(name: string): Handler {
129
+ const h = pi._handlers.get(name);
130
+ if (!h) throw new Error(`No handler for ${name}`);
131
+ return h;
132
+ }
133
+
134
+ function getEventHandler(name: string): Handler {
135
+ const h = pi._eventHandlers.get(name);
136
+ if (!h) throw new Error(`No event handler for ${name}`);
137
+ return h;
138
+ }
139
+
140
+ function getTool(name: string): any {
141
+ const call = (pi.registerTool as any).mock.calls.find((c: any[]) => c[0].name === name);
142
+ if (!call) throw new Error(`Tool ${name} not registered`);
143
+ return call[0];
144
+ }
145
+
146
+ describe("detectDefaultBranch", () => {
147
+ function orchWithExec(exec: any) {
148
+ return { pi: { exec } } as any;
149
+ }
150
+
151
+ it("returns a registered repo's explicit baseBranch without touching git", async () => {
152
+ const dir = makeTaskDir();
153
+ const repo: RepoInfo = { path: normalizeRepoPath(dir), baseBranch: "origin/dev", isRoot: true };
154
+ const exec = vi.fn();
155
+ const result = await detectDefaultBranch(orchWithExec(exec), [repo], dir);
156
+ expect(result).toBe("origin/dev");
157
+ expect(exec).not.toHaveBeenCalled();
158
+ });
159
+
160
+ it("resolves origin/HEAD via symbolic-ref", async () => {
161
+ const exec = vi.fn(async (_cmd: string, args: string[]) => {
162
+ if (args[0] === "symbolic-ref") return { code: 0, stdout: "refs/remotes/origin/trunk\n", stderr: "" };
163
+ return { code: 1, stdout: "", stderr: "" };
164
+ });
165
+ const result = await detectDefaultBranch(orchWithExec(exec), [], "/nowhere");
166
+ expect(result).toBe("origin/trunk");
167
+ });
168
+
169
+ it("falls back to origin/main when symbolic-ref fails but main exists", async () => {
170
+ const exec = vi.fn(async (_cmd: string, args: string[]) => {
171
+ if (args[0] === "symbolic-ref") return { code: 1, stdout: "", stderr: "" };
172
+ if (args.includes("refs/remotes/origin/main")) return { code: 0, stdout: "", stderr: "" };
173
+ return { code: 1, stdout: "", stderr: "" };
174
+ });
175
+ const result = await detectDefaultBranch(orchWithExec(exec), [], "/nowhere");
176
+ expect(result).toBe("origin/main");
177
+ });
178
+
179
+ it("falls back to origin/master when only master exists", async () => {
180
+ const exec = vi.fn(async (_cmd: string, args: string[]) => {
181
+ if (args[0] === "symbolic-ref") return { code: 1, stdout: "", stderr: "" };
182
+ if (args.includes("refs/remotes/origin/main")) return { code: 1, stdout: "", stderr: "" };
183
+ if (args.includes("refs/remotes/origin/master")) return { code: 0, stdout: "", stderr: "" };
184
+ return { code: 1, stdout: "", stderr: "" };
185
+ });
186
+ const result = await detectDefaultBranch(orchWithExec(exec), [], "/nowhere");
187
+ expect(result).toBe("origin/master");
188
+ });
189
+
190
+ it("defaults to origin/main when everything fails or throws", async () => {
191
+ const exec = vi.fn(async () => {
192
+ throw new Error("git unavailable");
193
+ });
194
+ const result = await detectDefaultBranch(orchWithExec(exec), [], "/nowhere");
195
+ expect(result).toBe("origin/main");
196
+ });
197
+ });
198
+
199
+ describe("selectOption", () => {
200
+ it("returns the chosen selection and toggles interactivePromptOpen off", async () => {
201
+ askQueue.push({ kind: "selection", selections: ["Beta"] });
202
+ const result = await selectOption({}, "Pick one", ["Alpha", "Beta"]);
203
+ expect(result).toBe("Beta");
204
+ expect(askQuestions[0]).toBe("Pick one");
205
+ expect(orchestrator.interactivePromptOpen).toBe(false);
206
+ });
207
+
208
+ it("returns undefined when the user cancels (isCancel)", async () => {
209
+ askQueue.push({ __cancel: true, reason: "user" });
210
+ const result = await selectOption({}, "Pick one", ["Alpha", "Beta"]);
211
+ expect(result).toBeUndefined();
212
+ expect(orchestrator.interactivePromptOpen).toBe(false);
213
+ });
214
+
215
+ it("returns undefined when the result is not a selection", async () => {
216
+ askQueue.push({ kind: "freeform", text: "typed" });
217
+ const result = await selectOption({}, "Pick one", ["Alpha"]);
218
+ expect(result).toBeUndefined();
219
+ });
220
+ });
221
+
222
+ describe("enterReviewCycle early-return branches", () => {
223
+ it("reports when there is no active task", async () => {
224
+ orchestrator.active = null;
225
+ expect(await enterReviewCycle(orchestrator, {}, "regular")).toBe("No active task.");
226
+ });
227
+
228
+ it("rejects plannotator during the brainstorm phase and clears the cycle", async () => {
229
+ orchestrator.active = makeActiveTask();
230
+ orchestrator.active.state.phase = "brainstorm";
231
+ const msg = await enterReviewCycle(orchestrator, {}, "plannotator");
232
+ expect(msg).toContain("only available for plan and implement");
233
+ expect(orchestrator.active.state.reviewCycle).toBeNull();
234
+ });
235
+
236
+ it("rejects plannotator in plan phase when no synthesized plan exists", async () => {
237
+ orchestrator.active = makeActiveTask();
238
+ orchestrator.active.state.phase = "plan";
239
+ const msg = await enterReviewCycle(orchestrator, {}, "plannotator");
240
+ expect(msg).toContain("No synthesized plan found");
241
+ expect(orchestrator.active.state.reviewCycle).toBeNull();
242
+ });
243
+
244
+ it("redirects plannotator to the per-repo menu during implement", async () => {
245
+ orchestrator.active = makeActiveTask();
246
+ orchestrator.active.state.phase = "implement";
247
+ const msg = await enterReviewCycle(orchestrator, {}, "plannotator");
248
+ expect(msg).toContain("per-repo");
249
+ expect(orchestrator.active.state.reviewCycle).toBeNull();
250
+ });
251
+
252
+ it("reports when no reviewers are enabled for the phase", async () => {
253
+ orchestrator.active = makeActiveTask();
254
+ orchestrator.active.state.phase = "implement";
255
+ const agents = orchestrator.config.agents.subagents.presetGroups.codeReviewers.presets.regular.agents;
256
+ for (const key of Object.keys(agents)) agents[key].enabled = false;
257
+ const msg = await enterReviewCycle(orchestrator, {}, "regular");
258
+ expect(msg).toContain("No code reviewers enabled");
259
+ expect(orchestrator.active.state.reviewCycle).toBeNull();
260
+ });
261
+ });
262
+
263
+ describe("finalizeReviewCycle variants", () => {
264
+ it("finalizeReviewCycle records the pass and returns to user_gate", () => {
265
+ const task = makeActiveTask();
266
+ task.state.reviewCycle = { kind: "auto", step: "apply_feedback", pass: 3 };
267
+ finalizeReviewCycle(task);
268
+ expect(task.state.step).toBe("user_gate");
269
+ expect(task.state.reviewCycle).toBeNull();
270
+ expect(task.state.reviewPass).toBe(3);
271
+ expect(task.reviewPass).toBe(3);
272
+ expect(task.state.reviewPassByKind?.implement?.auto).toBe(1);
273
+ });
274
+
275
+ it("finalizeReviewCycle is a no-op with no cycle", () => {
276
+ const task = makeActiveTask();
277
+ task.state.reviewCycle = null;
278
+ finalizeReviewCycle(task);
279
+ expect(task.state.step).toBe("llm_work");
280
+ });
281
+
282
+ it("finalizeReviewCycleAutonomous routes plan phase to synthesize", () => {
283
+ const task = makeActiveTask();
284
+ task.state.phase = "plan";
285
+ task.state.reviewCycle = { kind: "auto", step: "apply_feedback", pass: 2 };
286
+ finalizeReviewCycleAutonomous(task);
287
+ expect(task.state.step).toBe("synthesize");
288
+ expect(task.state.reviewCycle).toBeNull();
289
+ expect(task.state.reviewPassByKind?.plan?.auto).toBe(1);
290
+ });
291
+
292
+ it("finalizeReviewCycleAutonomous routes non-plan phases to llm_work", () => {
293
+ const task = makeActiveTask();
294
+ task.state.phase = "implement";
295
+ task.state.reviewCycle = { kind: "auto", step: "apply_feedback", pass: 1 };
296
+ finalizeReviewCycleAutonomous(task);
297
+ expect(task.state.step).toBe("llm_work");
298
+ expect(task.state.reviewCycle).toBeNull();
299
+ });
300
+
301
+ it("finalizeReviewCycleAutonomous is a no-op with no cycle", () => {
302
+ const task = makeActiveTask();
303
+ task.state.reviewCycle = null;
304
+ finalizeReviewCycleAutonomous(task);
305
+ expect(task.state.reviewCycle).toBeNull();
306
+ });
307
+
308
+ it("increments the per-kind pass counter across successive cycles", () => {
309
+ const task = makeActiveTask();
310
+ task.state.reviewCycle = { kind: "auto", step: "apply_feedback", pass: 1 };
311
+ finalizeReviewCycle(task);
312
+ task.state.reviewCycle = { kind: "auto", step: "apply_feedback", pass: 2 };
313
+ finalizeReviewCycle(task);
314
+ expect(task.state.reviewPassByKind?.implement?.auto).toBe(2);
315
+ expect(isReviewCycleLive(task)).toBe(false);
316
+ });
317
+ });
318
+
319
+ describe("stopTask", () => {
320
+ it("reports when there is no active task", async () => {
321
+ orchestrator.active = null;
322
+ expect(await stopTask(orchestrator)).toBe("No active task.");
323
+ });
324
+
325
+ it("clears the active task and finalizes through the transition controller", async () => {
326
+ orchestrator.active = makeActiveTask();
327
+ orchestrator.lastCtx = { isIdle: () => true } as any;
328
+ const abortSpy = vi.spyOn(orchestrator, "abortAllSubagents");
329
+ const result = await stopTask(orchestrator);
330
+ expect(result).toContain("stopped");
331
+ expect(orchestrator.active).toBeNull();
332
+ expect(abortSpy).toHaveBeenCalled();
333
+ });
334
+ });
335
+
336
+ describe("checkoutPrHead additional branches", () => {
337
+ function orchWithExec(exec: any) {
338
+ return { pi: { exec } } as any;
339
+ }
340
+
341
+ it("reports a non-zero git status without halting on a specific outcome", async () => {
342
+ const exec = vi.fn(async () => ({ code: 128, stdout: "", stderr: "fatal: not a git repo" }));
343
+ const result = await checkoutPrHead(orchWithExec(exec), "/repo", "feature", "abc123");
344
+ expect(result.ok).toBe(false);
345
+ expect(result.message).toContain("Cannot inspect");
346
+ expect(result.message).toContain("fatal: not a git repo");
347
+ });
348
+
349
+ it("reports when git status throws", async () => {
350
+ const exec = vi.fn(async () => {
351
+ throw new Error("spawn ENOENT");
352
+ });
353
+ const result = await checkoutPrHead(orchWithExec(exec), "/repo", "feature", "abc123");
354
+ expect(result.ok).toBe(false);
355
+ expect(result.message).toContain("Cannot inspect");
356
+ });
357
+
358
+ it("leaves a clean tree as-is when only a branch name (no oid) is provided", async () => {
359
+ const exec = vi.fn(async (_cmd: string, args: string[]) => {
360
+ if (args[0] === "status") return { code: 0, stdout: "", stderr: "" };
361
+ return { code: 0, stdout: "", stderr: "" };
362
+ });
363
+ const result = await checkoutPrHead(orchWithExec(exec), "/repo", "feature", "");
364
+ expect(result.ok).toBe(true);
365
+ expect(result.message).toContain("no PR head commit provided");
366
+ expect(exec).toHaveBeenCalledTimes(1);
367
+ });
368
+ });
369
+
370
+ describe("registered handler branches", () => {
371
+ it("blocks ask_user in autonomous mode", async () => {
372
+ orchestrator.active = makeActiveTask();
373
+ orchestrator.active.state.mode = "autonomous";
374
+ orchestrator.active.state.phase = "implement";
375
+ const result = await getHandler("tool_call")({ toolName: "ask_user", input: {} }, {});
376
+ expect(result?.block).toBe(true);
377
+ expect(result?.reason).toContain("Autonomous mode");
378
+ });
379
+
380
+ it("gates interactive input while awaiting subagents", async () => {
381
+ orchestrator.active = makeActiveTask();
382
+ orchestrator.active.state.step = "await_planners";
383
+ const ctx = { ui: { notify: vi.fn() } };
384
+ const result = await getHandler("input")({ source: "interactive" }, ctx);
385
+ expect(result).toEqual({ action: "handled" });
386
+ expect(ctx.ui.notify).toHaveBeenCalled();
387
+ });
388
+
389
+ it("ignores non-interactive input", async () => {
390
+ orchestrator.active = makeActiveTask();
391
+ orchestrator.active.state.step = "await_planners";
392
+ const ctx = { ui: { notify: vi.fn() } };
393
+ const result = await getHandler("input")({ source: "api" }, ctx);
394
+ expect(result).toBeUndefined();
395
+ expect(ctx.ui.notify).not.toHaveBeenCalled();
396
+ });
397
+
398
+ it("tracks a created subagent and decrements the pending count", () => {
399
+ orchestrator.active = makeActiveTask();
400
+ orchestrator.pendingSubagentSpawns = 2;
401
+ getEventHandler("subagents:created")({ id: "agent-1", description: "planner opus" }, {});
402
+ expect(orchestrator.spawnedAgentIds.has("agent-1")).toBe(true);
403
+ expect(orchestrator.pendingSubagentSpawns).toBe(1);
404
+ expect(orchestrator.agentDescriptions.get("agent-1")).toBe("planner opus");
405
+ });
406
+
407
+ it("removes a completed subagent and emits a result context message", () => {
408
+ orchestrator.active = makeActiveTask();
409
+ orchestrator.spawnedAgentIds.add("agent-1");
410
+ orchestrator.agentDescriptions.set("agent-1", "reviewer gpt");
411
+ getEventHandler("subagents:completed")({ id: "agent-1", description: "reviewer gpt", durationMs: 1500 }, {});
412
+ expect(orchestrator.spawnedAgentIds.has("agent-1")).toBe(false);
413
+ const customCall = (pi.sendMessage as any).mock.calls.find((c: any[]) => c[0]?.customType === "pp-subagent-result");
414
+ expect(customCall).toBeDefined();
415
+ expect(customCall[0].content).toContain("reviewer gpt");
416
+ });
417
+
418
+ it("suppresses the per-agent result message for phased-batch completions (item 6)", () => {
419
+ for (const step of ["await_planners", "await_reviewers"]) {
420
+ (pi.sendMessage as any).mockClear();
421
+ orchestrator.active = makeActiveTask();
422
+ orchestrator.active.state.step = step;
423
+ orchestrator.spawnedAgentIds.add("agent-p");
424
+ orchestrator.agentDescriptions.set("agent-p", "planner opus");
425
+ getEventHandler("subagents:completed")({ id: "agent-p", description: "planner opus", durationMs: 900 }, {});
426
+ const customCall = (pi.sendMessage as any).mock.calls.find((c: any[]) => c[0]?.customType === "pp-subagent-result");
427
+ expect(customCall).toBeUndefined();
428
+ }
429
+ });
430
+
431
+ it("cleans up a stopped subagent without emitting an error", () => {
432
+ orchestrator.active = makeActiveTask();
433
+ orchestrator.spawnedAgentIds.add("agent-1");
434
+ orchestrator.agentDescriptions.set("agent-1", "planner");
435
+ getEventHandler("subagents:failed")({ id: "agent-1", status: "stopped" }, {});
436
+ expect(orchestrator.spawnedAgentIds.has("agent-1")).toBe(false);
437
+ const errCall = (pi.sendMessage as any).mock.calls.find((c: any[]) => c[0]?.customType === "pp-subagent-error");
438
+ expect(errCall).toBeUndefined();
439
+ });
440
+
441
+ it("aborts remaining subagents on an API error and emits an error message", () => {
442
+ orchestrator.active = makeActiveTask();
443
+ orchestrator.spawnedAgentIds.add("agent-1");
444
+ orchestrator.spawnedAgentIds.add("agent-2");
445
+ orchestrator.agentDescriptions.set("agent-1", "planner opus");
446
+ const abortSpy = vi.spyOn(orchestrator, "abortAllSubagents");
447
+ getEventHandler("subagents:failed")({ id: "agent-1", status: "error", toolUses: 0, error: "500 boom" }, {});
448
+ expect(abortSpy).toHaveBeenCalled();
449
+ const errCall = (pi.sendMessage as any).mock.calls.find((c: any[]) => c[0]?.customType === "pp-subagent-error");
450
+ expect(errCall[0].content).toContain("model/API error");
451
+ });
452
+
453
+ it("supplies the transition summary during controller-initiated compaction", async () => {
454
+ orchestrator.active = makeActiveTask();
455
+ orchestrator.lastCtx = { isIdle: () => false } as any;
456
+ void orchestrator.transitionController.requestTransition({ kind: "phase", summary: "PHASE SUMMARY" });
457
+ expect(orchestrator.transitionController.isTransitioning()).toBe(true);
458
+ const result = await getHandler("session_before_compact")(
459
+ { preparation: { firstKeptEntryId: "e1", tokensBefore: 100 }, branchEntries: [] },
460
+ {},
461
+ );
462
+ expect(result?.compaction?.summary).toBe("PHASE SUMMARY");
463
+ });
464
+
465
+ it("session_before_compact is a no-op with no active task and no transition", async () => {
466
+ orchestrator.active = null;
467
+ const result = await getHandler("session_before_compact")(
468
+ { preparation: { firstKeptEntryId: "e1", tokensBefore: 100 } },
469
+ {},
470
+ );
471
+ expect(result).toBeUndefined();
472
+ });
473
+
474
+ it("planner completion with no plan files pushes synthesize-yourself instruction", () => {
475
+ orchestrator.active = makeActiveTask();
476
+ orchestrator.active.state.phase = "plan";
477
+ orchestrator.active.state.step = "await_planners";
478
+ orchestrator.checkPlannerCompletion();
479
+ expect(orchestrator.active.state.step).toBe("synthesize");
480
+ const sent = (pi.sendUserMessage as any).mock.calls.map((c: any[]) => c[0]).join(" ");
481
+ expect(sent).toContain("Create the plan yourself");
482
+ });
483
+
484
+ it("planner completion with plan files advances to synthesize", () => {
485
+ orchestrator.active = makeActiveTask();
486
+ orchestrator.active.state.phase = "plan";
487
+ orchestrator.active.state.step = "await_planners";
488
+ const plansDir = join(orchestrator.active.dir, "plans");
489
+ mkdirSync(plansDir, { recursive: true });
490
+ writeFileSync(join(plansDir, "1_opus.md"), "a plan", "utf-8");
491
+ orchestrator.checkPlannerCompletion();
492
+ expect(orchestrator.active.state.step).toBe("synthesize");
493
+ const sent = (pi.sendUserMessage as any).mock.calls.map((c: any[]) => c[0]).join(" ");
494
+ expect(sent).toContain("All planners completed");
495
+ });
496
+ });
497
+
498
+ describe("pp_phase_complete tool", () => {
499
+ function ctxWithUi() {
500
+ return {
501
+ ui: { setWorkingMessage: vi.fn(), notify: vi.fn() },
502
+ abort: vi.fn(),
503
+ };
504
+ }
505
+
506
+ it("blocks while the step is awaiting reviewers", async () => {
507
+ orchestrator.active = makeActiveTask();
508
+ orchestrator.active.state.step = "await_reviewers";
509
+ registerOrchestratorToolsForTest(orchestrator);
510
+ const tool = getTool("pp_phase_complete");
511
+ const result = await tool.execute("id", { summary: "s" }, undefined, undefined, ctxWithUi());
512
+ expect(result.isError).toBe(true);
513
+ expect(result.content[0].text).toContain("still running");
514
+ });
515
+
516
+ it("blocks while subagents are still tracked", async () => {
517
+ orchestrator.active = makeActiveTask();
518
+ orchestrator.spawnedAgentIds.add("agent-1");
519
+ registerOrchestratorToolsForTest(orchestrator);
520
+ const tool = getTool("pp_phase_complete");
521
+ const result = await tool.execute("id", { summary: "s" }, undefined, undefined, ctxWithUi());
522
+ expect(result.isError).toBe(true);
523
+ expect(result.content[0].text).toContain("subagent(s) still running");
524
+ });
525
+
526
+ it("drives the autonomous transition when the phase mode is autonomous", async () => {
527
+ orchestrator.active = makeActiveTask();
528
+ orchestrator.active.state.phase = "plan";
529
+ orchestrator.active.state.step = "llm_work";
530
+ orchestrator.active.state.mode = "autonomous";
531
+ const transitionSpy = vi.fn(async () => ({ ok: true as const }));
532
+ orchestrator.transitionToNextPhase = transitionSpy;
533
+ registerOrchestratorToolsForTest(orchestrator);
534
+ const tool = getTool("pp_phase_complete");
535
+ const result = await tool.execute("id", { summary: "s" }, undefined, undefined, ctxWithUi());
536
+ expect(transitionSpy).toHaveBeenCalled();
537
+ expect(result.content[0].text).toBe("");
538
+ });
539
+
540
+ it("reports a blocked autonomous transition", async () => {
541
+ orchestrator.active = makeActiveTask();
542
+ orchestrator.active.state.phase = "plan";
543
+ orchestrator.active.state.step = "llm_work";
544
+ orchestrator.active.state.mode = "autonomous";
545
+ orchestrator.transitionToNextPhase = async () => ({ ok: false, error: "boom" });
546
+ registerOrchestratorToolsForTest(orchestrator);
547
+ const tool = getTool("pp_phase_complete");
548
+ const result = await tool.execute("id", { summary: "s" }, undefined, undefined, ctxWithUi());
549
+ expect(result.content[0].text).toContain("Transition blocked: boom");
550
+ });
551
+
552
+ it("returns the guided menu result", async () => {
553
+ orchestrator.active = makeActiveTask();
554
+ orchestrator.active.state.phase = "implement";
555
+ orchestrator.active.state.step = "llm_work";
556
+ registerOrchestratorToolsForTest(orchestrator);
557
+ const tool = getTool("pp_phase_complete");
558
+ const result = await tool.execute("id", { summary: "s" }, undefined, undefined, ctxWithUi());
559
+ expect(result.content[0].text).toBe("MENU_RESULT");
560
+ });
561
+ });