@ilya-lesikov/pi-pi 0.4.0 → 0.6.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.
- package/3p/pi-ask-user/index.ts +65 -49
- package/3p/pi-subagents/src/agent-manager.ts +8 -0
- package/3p/pi-subagents/src/agent-runner.ts +112 -19
- package/3p/pi-subagents/src/index.ts +3 -0
- package/extensions/orchestrator/agents/brainstorm-reviewer.ts +32 -19
- package/extensions/orchestrator/agents/code-reviewer.ts +31 -17
- package/extensions/orchestrator/agents/constraints.ts +55 -0
- package/extensions/orchestrator/agents/explore.ts +13 -13
- package/extensions/orchestrator/agents/librarian.ts +12 -9
- package/extensions/orchestrator/agents/plan-reviewer.ts +31 -19
- package/extensions/orchestrator/agents/planner.ts +28 -23
- package/extensions/orchestrator/agents/registry.ts +2 -1
- package/extensions/orchestrator/agents/repo-context.ts +11 -0
- package/extensions/orchestrator/agents/task.ts +14 -11
- package/extensions/orchestrator/agents/tool-routing.ts +17 -32
- package/extensions/orchestrator/ast-search.ts +2 -1
- package/extensions/orchestrator/cbm.test.ts +35 -0
- package/extensions/orchestrator/cbm.ts +43 -13
- package/extensions/orchestrator/command-handlers.test.ts +390 -19
- package/extensions/orchestrator/command-handlers.ts +73 -31
- package/extensions/orchestrator/commands.test.ts +255 -2
- package/extensions/orchestrator/commands.ts +108 -10
- package/extensions/orchestrator/config.test.ts +289 -68
- package/extensions/orchestrator/config.ts +630 -121
- package/extensions/orchestrator/context.test.ts +177 -10
- package/extensions/orchestrator/context.ts +115 -14
- package/extensions/orchestrator/custom-footer.ts +2 -1
- package/extensions/orchestrator/doctor.test.ts +559 -0
- package/extensions/orchestrator/doctor.ts +664 -0
- package/extensions/orchestrator/event-handlers.test.ts +84 -22
- package/extensions/orchestrator/event-handlers.ts +1191 -360
- package/extensions/orchestrator/exa.test.ts +46 -0
- package/extensions/orchestrator/exa.ts +16 -10
- package/extensions/orchestrator/flant-infra.test.ts +224 -0
- package/extensions/orchestrator/flant-infra.ts +110 -41
- package/extensions/orchestrator/index.ts +13 -2
- package/extensions/orchestrator/integration.test.ts +2866 -118
- package/extensions/orchestrator/log.test.ts +219 -0
- package/extensions/orchestrator/log.ts +153 -0
- package/extensions/orchestrator/model-registry.test.ts +238 -0
- package/extensions/orchestrator/model-registry.ts +282 -0
- package/extensions/orchestrator/model-version.test.ts +27 -0
- package/extensions/orchestrator/model-version.ts +19 -0
- package/extensions/orchestrator/orchestrator.test.ts +206 -56
- package/extensions/orchestrator/orchestrator.ts +295 -148
- package/extensions/orchestrator/phases/brainstorm.test.ts +10 -7
- package/extensions/orchestrator/phases/brainstorm.ts +41 -35
- package/extensions/orchestrator/phases/implementation.ts +7 -11
- package/extensions/orchestrator/phases/machine.test.ts +27 -8
- package/extensions/orchestrator/phases/machine.ts +13 -0
- package/extensions/orchestrator/phases/planning.ts +57 -29
- package/extensions/orchestrator/phases/review-task.ts +3 -3
- package/extensions/orchestrator/phases/review.ts +38 -39
- package/extensions/orchestrator/phases/spawn-blocking.test.ts +69 -0
- package/extensions/orchestrator/phases/verdict.test.ts +139 -0
- package/extensions/orchestrator/phases/verdict.ts +82 -0
- package/extensions/orchestrator/plannotator.test.ts +85 -0
- package/extensions/orchestrator/plannotator.ts +24 -6
- package/extensions/orchestrator/pp-menu.test.ts +134 -0
- package/extensions/orchestrator/pp-menu.ts +2631 -392
- package/extensions/orchestrator/repo-utils.test.ts +151 -0
- package/extensions/orchestrator/repo-utils.ts +67 -0
- package/extensions/orchestrator/spawn-cleanup.test.ts +57 -0
- package/extensions/orchestrator/spawn-cleanup.ts +35 -0
- package/extensions/orchestrator/state.test.ts +76 -6
- package/extensions/orchestrator/state.ts +89 -26
- package/extensions/orchestrator/subagent-session-marker.test.ts +36 -0
- package/extensions/orchestrator/test-helpers.ts +217 -0
- package/extensions/orchestrator/tracer.test.ts +132 -0
- package/extensions/orchestrator/tracer.ts +127 -0
- package/extensions/orchestrator/transition-controller.test.ts +207 -0
- package/extensions/orchestrator/transition-controller.ts +259 -0
- package/extensions/orchestrator/usage-tracker.test.ts +435 -0
- package/extensions/orchestrator/usage-tracker.ts +23 -2
- package/extensions/orchestrator/validate-artifacts.test.ts +83 -1
- package/package.json +2 -1
|
@@ -1,15 +1,45 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs";
|
|
2
2
|
import { tmpdir } from "os";
|
|
3
3
|
import { join } from "path";
|
|
4
|
-
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import {
|
|
6
|
+
createAskUserHarness,
|
|
7
|
+
expectActiveTaskNext,
|
|
8
|
+
expectBrainstormToPlan,
|
|
9
|
+
expectImplementToDone,
|
|
10
|
+
expectPlanToImplement,
|
|
11
|
+
expectPpStartImplementAutonomous,
|
|
12
|
+
expectQuickMenu,
|
|
13
|
+
expectReviewAuto,
|
|
14
|
+
expectReviewOnMyOwn,
|
|
15
|
+
m,
|
|
16
|
+
} from "./test-helpers.js";
|
|
17
|
+
|
|
18
|
+
let menu: ReturnType<typeof createAskUserHarness>;
|
|
19
|
+
|
|
20
|
+
vi.mock("../../3p/pi-ask-user/index.js", () => {
|
|
21
|
+
return {
|
|
22
|
+
askUser: async (_ctx: any, opts: any) => menu.handle(opts),
|
|
23
|
+
isCancel: (value: any) =>
|
|
24
|
+
typeof value === "object" && value !== null && value.__cancel === true,
|
|
25
|
+
};
|
|
26
|
+
});
|
|
27
|
+
|
|
5
28
|
import { Orchestrator } from "./orchestrator.js";
|
|
6
29
|
import { registerCommandHandlers } from "./command-handlers.js";
|
|
7
|
-
import { registerEventHandlers } from "./event-handlers.js";
|
|
8
|
-
import { createTask, loadTask, saveTask } from "./state.js";
|
|
30
|
+
import { enterReviewCycle, finalizeReviewCycle, registerEventHandlers } from "./event-handlers.js";
|
|
31
|
+
import { createTask, getActiveTask, loadTask, saveTask } from "./state.js";
|
|
32
|
+
import { registerAgentDefinitions } from "./agents/registry.js";
|
|
33
|
+
import { taskLogsDir } from "./log.js";
|
|
34
|
+
import { resumeTask } from "./pp-menu.js";
|
|
35
|
+
import * as commandsModule from "./commands.js";
|
|
36
|
+
import * as doctorModule from "./doctor.js";
|
|
37
|
+
import * as usageTrackerModule from "./usage-tracker.js";
|
|
9
38
|
|
|
10
39
|
vi.mock("./cbm.js", () => ({ registerCbmTools: vi.fn() }));
|
|
11
40
|
vi.mock("./exa.js", () => ({ registerExaTools: vi.fn() }));
|
|
12
41
|
vi.mock("./ast-search.js", () => ({ registerAstSearchTool: vi.fn() }));
|
|
42
|
+
vi.mock("./doctor.js", () => ({ runDoctor: vi.fn(async () => undefined) }));
|
|
13
43
|
vi.mock("./agents/registry.js", () => ({
|
|
14
44
|
registerAgentDefinitions: vi.fn(),
|
|
15
45
|
unregisterAgentDefinitions: vi.fn(),
|
|
@@ -20,26 +50,52 @@ vi.mock("./agents/registry.js", () => ({
|
|
|
20
50
|
|
|
21
51
|
vi.mock("./config.js", async (importOriginal) => {
|
|
22
52
|
const original = await importOriginal<typeof import("./config.js")>();
|
|
23
|
-
return { ...original, loadConfig:
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
review: { model: "test/model", thinking: "high" },
|
|
53
|
+
return { ...original, loadConfig: () => ({
|
|
54
|
+
general: {
|
|
55
|
+
autoCommit: false,
|
|
56
|
+
loadExtraRepoConfigs: true,
|
|
57
|
+
logLevel: "info",
|
|
29
58
|
},
|
|
30
|
-
planners: { test: { enabled: true, model: "test/planner", thinking: "low" } },
|
|
31
|
-
planReviewers: {},
|
|
32
|
-
brainstormReviewers: { test: { enabled: true, model: "test/reviewer", thinking: "low" } },
|
|
33
|
-
codeReviewers: { test: { enabled: true, model: "test/reviewer", thinking: "low" } },
|
|
34
59
|
agents: {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
60
|
+
orchestrators: {
|
|
61
|
+
implement: { model: "test/model", thinking: "high" },
|
|
62
|
+
plan: { model: "test/model", thinking: "high" },
|
|
63
|
+
debug: { model: "test/model", thinking: "high" },
|
|
64
|
+
brainstorm: { model: "test/model", thinking: "high" },
|
|
65
|
+
review: { model: "test/model", thinking: "high" },
|
|
66
|
+
},
|
|
67
|
+
subagents: {
|
|
68
|
+
simple: {
|
|
69
|
+
explore: { model: "test/explore", thinking: "low" },
|
|
70
|
+
librarian: { model: "test/librarian", thinking: "medium" },
|
|
71
|
+
task: { model: "test/task", thinking: "medium" },
|
|
72
|
+
},
|
|
73
|
+
presetGroups: {
|
|
74
|
+
planners: {
|
|
75
|
+
default: "regular",
|
|
76
|
+
presets: { regular: { enabled: true, agents: { test: { enabled: true, model: "test/planner", thinking: "low" } } } },
|
|
77
|
+
},
|
|
78
|
+
planReviewers: {
|
|
79
|
+
default: "regular",
|
|
80
|
+
presets: { regular: { enabled: true, agents: { test: { enabled: true, model: "test/reviewer", thinking: "low" } } } },
|
|
81
|
+
},
|
|
82
|
+
brainstormReviewers: {
|
|
83
|
+
default: "regular",
|
|
84
|
+
presets: { regular: { enabled: true, agents: { test: { enabled: true, model: "test/reviewer", thinking: "low" } } } },
|
|
85
|
+
},
|
|
86
|
+
codeReviewers: {
|
|
87
|
+
default: "regular",
|
|
88
|
+
presets: { regular: { enabled: true, agents: { test: { enabled: true, model: "test/reviewer", thinking: "low" } } } },
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
},
|
|
38
92
|
},
|
|
39
|
-
commands: { afterEdit:
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
93
|
+
commands: { afterEdit: {}, afterImplement: {} },
|
|
94
|
+
performance: {
|
|
95
|
+
commands: { afterEdit: 1000, afterImplement: 1000 },
|
|
96
|
+
internals: { subagentStale: 1000, taskLockStale: 600000, taskLockRefresh: 30000 },
|
|
97
|
+
},
|
|
98
|
+
}) };
|
|
43
99
|
});
|
|
44
100
|
|
|
45
101
|
type Handler = (...args: any[]) => any;
|
|
@@ -84,8 +140,15 @@ function makeTempDir(): string {
|
|
|
84
140
|
return dir;
|
|
85
141
|
}
|
|
86
142
|
|
|
143
|
+
beforeEach(() => {
|
|
144
|
+
menu = createAskUserHarness();
|
|
145
|
+
});
|
|
146
|
+
|
|
87
147
|
afterEach(() => {
|
|
148
|
+
menu.assertDone();
|
|
88
149
|
vi.restoreAllMocks();
|
|
150
|
+
delete (globalThis as any)[Symbol.for("pi-pi:usage-tracker")];
|
|
151
|
+
delete (globalThis as any)[Symbol.for("pi-lsp:api")];
|
|
89
152
|
for (const dir of tempDirs.splice(0)) {
|
|
90
153
|
rmSync(dir, { recursive: true, force: true });
|
|
91
154
|
}
|
|
@@ -128,6 +191,7 @@ function makePi() {
|
|
|
128
191
|
setModel: vi.fn().mockResolvedValue(true),
|
|
129
192
|
setThinkingLevel: vi.fn(),
|
|
130
193
|
setSessionName: vi.fn(),
|
|
194
|
+
exec: vi.fn(async (_command?: string, _args?: string[], _options?: { cwd?: string; timeout?: number }) => ({ code: 1, stdout: "", stderr: "" })),
|
|
131
195
|
_handlers: handlers,
|
|
132
196
|
_eventHandlers: eventHandlers,
|
|
133
197
|
_commands: commands,
|
|
@@ -139,30 +203,50 @@ function makePi() {
|
|
|
139
203
|
|
|
140
204
|
function makeConfig() {
|
|
141
205
|
return {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
review: { model: "test/model", thinking: "high" },
|
|
147
|
-
},
|
|
148
|
-
planners: {
|
|
149
|
-
test: { enabled: true, model: "test/planner", thinking: "low" },
|
|
150
|
-
},
|
|
151
|
-
planReviewers: {},
|
|
152
|
-
brainstormReviewers: {
|
|
153
|
-
test: { enabled: true, model: "test/reviewer", thinking: "low" },
|
|
154
|
-
},
|
|
155
|
-
codeReviewers: {
|
|
156
|
-
test: { enabled: true, model: "test/reviewer", thinking: "low" },
|
|
206
|
+
general: {
|
|
207
|
+
autoCommit: false,
|
|
208
|
+
loadExtraRepoConfigs: true,
|
|
209
|
+
logLevel: "info",
|
|
157
210
|
},
|
|
158
211
|
agents: {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
212
|
+
orchestrators: {
|
|
213
|
+
implement: { model: "test/model", thinking: "high" },
|
|
214
|
+
plan: { model: "test/model", thinking: "high" },
|
|
215
|
+
debug: { model: "test/model", thinking: "high" },
|
|
216
|
+
brainstorm: { model: "test/model", thinking: "high" },
|
|
217
|
+
review: { model: "test/model", thinking: "high" },
|
|
218
|
+
},
|
|
219
|
+
subagents: {
|
|
220
|
+
simple: {
|
|
221
|
+
explore: { model: "test/explore", thinking: "low" },
|
|
222
|
+
librarian: { model: "test/librarian", thinking: "medium" },
|
|
223
|
+
task: { model: "test/task", thinking: "medium" },
|
|
224
|
+
},
|
|
225
|
+
presetGroups: {
|
|
226
|
+
planners: {
|
|
227
|
+
default: "regular",
|
|
228
|
+
presets: { regular: { enabled: true, agents: { test: { enabled: true, model: "test/planner", thinking: "low" } } } },
|
|
229
|
+
},
|
|
230
|
+
planReviewers: {
|
|
231
|
+
default: "regular",
|
|
232
|
+
presets: { regular: { enabled: true, agents: { test: { enabled: true, model: "test/reviewer", thinking: "low" } } } },
|
|
233
|
+
},
|
|
234
|
+
brainstormReviewers: {
|
|
235
|
+
default: "regular",
|
|
236
|
+
presets: { regular: { enabled: true, agents: { test: { enabled: true, model: "test/reviewer", thinking: "low" } } } },
|
|
237
|
+
},
|
|
238
|
+
codeReviewers: {
|
|
239
|
+
default: "regular",
|
|
240
|
+
presets: { regular: { enabled: true, agents: { test: { enabled: true, model: "test/reviewer", thinking: "low" } } } },
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
commands: { afterEdit: {}, afterImplement: {} },
|
|
246
|
+
performance: {
|
|
247
|
+
commands: { afterEdit: 1000, afterImplement: 1000 },
|
|
248
|
+
internals: { subagentStale: 1000, taskLockStale: 600000, taskLockRefresh: 30000 },
|
|
162
249
|
},
|
|
163
|
-
commands: { afterEdit: [], afterImplement: [] },
|
|
164
|
-
timeouts: { afterEdit: 1000, afterImplement: 1000, agentSpawn: 1000, agentReadyPing: 1000, lockStale: 600000, lockUpdate: 30000 },
|
|
165
|
-
autoCommit: false,
|
|
166
250
|
};
|
|
167
251
|
}
|
|
168
252
|
|
|
@@ -176,9 +260,12 @@ function makeCtx(overrides: Record<string, any> = {}) {
|
|
|
176
260
|
custom: vi.fn().mockResolvedValue(undefined),
|
|
177
261
|
notify: vi.fn(),
|
|
178
262
|
setStatus: vi.fn(),
|
|
263
|
+
setFooter: vi.fn(),
|
|
264
|
+
input: vi.fn(),
|
|
179
265
|
},
|
|
180
266
|
abort: vi.fn(),
|
|
181
267
|
waitForIdle: vi.fn().mockResolvedValue(undefined),
|
|
268
|
+
isIdle: vi.fn().mockReturnValue(true),
|
|
182
269
|
compact: vi.fn((opts?: any) => {
|
|
183
270
|
if (opts?.onComplete) setTimeout(opts.onComplete, 0);
|
|
184
271
|
}),
|
|
@@ -241,6 +328,43 @@ function emitSubagentFailed(pi: ReturnType<typeof makePi>, id: string, error: st
|
|
|
241
328
|
pi.events.emit("subagents:failed", { id, error });
|
|
242
329
|
}
|
|
243
330
|
|
|
331
|
+
async function moveTaskToImplementPhase(
|
|
332
|
+
pi: ReturnType<typeof makePi>,
|
|
333
|
+
orchestrator: Orchestrator,
|
|
334
|
+
ctx: ReturnType<typeof makeCtx>,
|
|
335
|
+
firstCallId: string,
|
|
336
|
+
secondCallId: string,
|
|
337
|
+
) {
|
|
338
|
+
const taskDir = orchestrator.active!.dir;
|
|
339
|
+
writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
|
|
340
|
+
writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
|
|
341
|
+
|
|
342
|
+
expectBrainstormToPlan(menu);
|
|
343
|
+
const ppPhaseComplete = getTool(pi, "pp_phase_complete");
|
|
344
|
+
await ppPhaseComplete.execute(firstCallId, { summary: "phase complete" }, undefined, undefined, ctx);
|
|
345
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
346
|
+
|
|
347
|
+
const plansDir = join(taskDir, "plans");
|
|
348
|
+
mkdirSync(plansDir, { recursive: true });
|
|
349
|
+
emitSubagentCreated(pi, `${firstCallId}-planner`, "Planner (test)");
|
|
350
|
+
writeFileSync(
|
|
351
|
+
join(plansDir, `${Math.floor(Date.now() / 1000)}_test.md`),
|
|
352
|
+
makeValidPlan(["- [ ] P1. Planner output item — Done when: planner output exists"]),
|
|
353
|
+
"utf-8",
|
|
354
|
+
);
|
|
355
|
+
emitSubagentCompleted(pi, `${firstCallId}-planner`, "Planner (test)");
|
|
356
|
+
|
|
357
|
+
writeFileSync(
|
|
358
|
+
join(plansDir, `${Math.floor(Date.now() / 1000) + 1}_synthesized.md`),
|
|
359
|
+
makeValidPlan(["- [x] P1. Ready to implement — Done when: item is checked"]),
|
|
360
|
+
"utf-8",
|
|
361
|
+
);
|
|
362
|
+
|
|
363
|
+
expectPlanToImplement(menu);
|
|
364
|
+
await ppPhaseComplete.execute(secondCallId, { summary: "plan complete" }, undefined, undefined, ctx);
|
|
365
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
366
|
+
}
|
|
367
|
+
|
|
244
368
|
describe("implement pipeline: brainstorm → plan → implement → done", () => {
|
|
245
369
|
it("walks through the full implement pipeline with phase transitions", async () => {
|
|
246
370
|
const cwd = makeTempDir();
|
|
@@ -258,11 +382,11 @@ describe("implement pipeline: brainstorm → plan → implement → done", () =>
|
|
|
258
382
|
writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
|
|
259
383
|
writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
|
|
260
384
|
|
|
261
|
-
|
|
385
|
+
expectBrainstormToPlan(menu);
|
|
262
386
|
|
|
263
387
|
const ppPhaseComplete = getTool(pi, "pp_phase_complete");
|
|
264
388
|
const result1 = await ppPhaseComplete.execute("call-1", { summary: "Research complete" }, undefined, undefined, ctx);
|
|
265
|
-
expect(result1.content[0].text).
|
|
389
|
+
expect(result1.content[0].text).toBeDefined();
|
|
266
390
|
|
|
267
391
|
await new Promise((r) => setTimeout(r, 10));
|
|
268
392
|
|
|
@@ -294,10 +418,10 @@ describe("implement pipeline: brainstorm → plan → implement → done", () =>
|
|
|
294
418
|
"utf-8",
|
|
295
419
|
);
|
|
296
420
|
|
|
297
|
-
|
|
421
|
+
expectPlanToImplement(menu);
|
|
298
422
|
|
|
299
423
|
const result2 = await ppPhaseComplete.execute("call-2", { summary: "Plan synthesized" }, undefined, undefined, ctx);
|
|
300
|
-
expect(result2.content[0]
|
|
424
|
+
expect(result2.content[0]).toBeDefined();
|
|
301
425
|
|
|
302
426
|
await new Promise((r) => setTimeout(r, 10));
|
|
303
427
|
|
|
@@ -307,10 +431,10 @@ describe("implement pipeline: brainstorm → plan → implement → done", () =>
|
|
|
307
431
|
const synthContent = readFileSync(synthPath, "utf-8");
|
|
308
432
|
writeFileSync(synthPath, synthContent.replace(/- \[ \]/g, "- [x]"), "utf-8");
|
|
309
433
|
|
|
310
|
-
|
|
434
|
+
expectImplementToDone(menu);
|
|
311
435
|
|
|
312
436
|
const result3 = await ppPhaseComplete.execute("call-3", { summary: "All items implemented" }, undefined, undefined, ctx);
|
|
313
|
-
expect(result3.content[0]
|
|
437
|
+
expect(result3.content[0]).toBeDefined();
|
|
314
438
|
|
|
315
439
|
expect(orchestrator.active).toBeNull();
|
|
316
440
|
|
|
@@ -325,8 +449,7 @@ describe("implement pipeline: brainstorm → plan → implement → done", () =>
|
|
|
325
449
|
|
|
326
450
|
await orchestrator.startTask(ctx as any, "implement", "Test task");
|
|
327
451
|
|
|
328
|
-
|
|
329
|
-
|
|
452
|
+
expectBrainstormToPlan(menu);
|
|
330
453
|
const ppPhaseComplete = getTool(pi, "pp_phase_complete");
|
|
331
454
|
const result = await ppPhaseComplete.execute("call-1", { summary: "done" }, undefined, undefined, ctx);
|
|
332
455
|
expect(result.content[0].text).toContain("Transition blocked");
|
|
@@ -345,7 +468,7 @@ describe("implement pipeline: brainstorm → plan → implement → done", () =>
|
|
|
345
468
|
writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
|
|
346
469
|
writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
|
|
347
470
|
|
|
348
|
-
|
|
471
|
+
expectBrainstormToPlan(menu);
|
|
349
472
|
const ppPhaseComplete = getTool(pi, "pp_phase_complete");
|
|
350
473
|
await ppPhaseComplete.execute("call-1", { summary: "done" }, undefined, undefined, ctx);
|
|
351
474
|
await new Promise((r) => setTimeout(r, 10));
|
|
@@ -366,16 +489,18 @@ describe("implement pipeline: brainstorm → plan → implement → done", () =>
|
|
|
366
489
|
"utf-8",
|
|
367
490
|
);
|
|
368
491
|
|
|
369
|
-
|
|
492
|
+
expectPlanToImplement(menu);
|
|
370
493
|
await ppPhaseComplete.execute("call-2", { summary: "plan done" }, undefined, undefined, ctx);
|
|
371
494
|
await new Promise((r) => setTimeout(r, 10));
|
|
372
495
|
|
|
373
496
|
expect(orchestrator.active!.state.phase).toBe("implement");
|
|
374
497
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
expect(
|
|
378
|
-
expect(
|
|
498
|
+
const transition = await orchestrator.transitionToNextPhase(ctx as any);
|
|
499
|
+
|
|
500
|
+
expect(transition.ok).toBe(false);
|
|
501
|
+
expect(transition.error).toContain("plan items still unchecked");
|
|
502
|
+
expect(orchestrator.active).not.toBeNull();
|
|
503
|
+
expect(orchestrator.active!.state.phase).toBe("implement");
|
|
379
504
|
});
|
|
380
505
|
});
|
|
381
506
|
|
|
@@ -391,7 +516,7 @@ describe("review cycle lifecycle", () => {
|
|
|
391
516
|
writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
|
|
392
517
|
writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
|
|
393
518
|
|
|
394
|
-
|
|
519
|
+
expectBrainstormToPlan(menu);
|
|
395
520
|
const ppPhaseComplete = getTool(pi, "pp_phase_complete");
|
|
396
521
|
await ppPhaseComplete.execute("call-1", { summary: "done" }, undefined, undefined, ctx);
|
|
397
522
|
await new Promise((r) => setTimeout(r, 10));
|
|
@@ -412,15 +537,15 @@ describe("review cycle lifecycle", () => {
|
|
|
412
537
|
"utf-8",
|
|
413
538
|
);
|
|
414
539
|
|
|
415
|
-
|
|
540
|
+
expectPlanToImplement(menu);
|
|
416
541
|
await ppPhaseComplete.execute("call-2", { summary: "plan done" }, undefined, undefined, ctx);
|
|
417
542
|
await new Promise((r) => setTimeout(r, 10));
|
|
418
543
|
|
|
419
544
|
expect(orchestrator.active!.state.phase).toBe("implement");
|
|
420
545
|
|
|
421
|
-
|
|
546
|
+
expectReviewAuto(menu);
|
|
422
547
|
const result = await ppPhaseComplete.execute("call-3", { summary: "implemented" }, undefined, undefined, ctx);
|
|
423
|
-
expect(result.content[0].text).toContain("
|
|
548
|
+
expect(result.content[0].text).toContain("Waiting for reviewers");
|
|
424
549
|
|
|
425
550
|
expect(orchestrator.active!.state.reviewCycle).not.toBeNull();
|
|
426
551
|
expect(["await_reviewers", "apply_feedback"]).toContain(orchestrator.active!.state.reviewCycle!.step);
|
|
@@ -436,15 +561,15 @@ describe("review cycle lifecycle", () => {
|
|
|
436
561
|
expect(orchestrator.active!.state.reviewCycle!.step).toBe("apply_feedback");
|
|
437
562
|
expect(orchestrator.active!.state.step).toBe("apply_feedback");
|
|
438
563
|
|
|
439
|
-
|
|
564
|
+
expectImplementToDone(menu);
|
|
440
565
|
const result4 = await ppPhaseComplete.execute("call-4", { summary: "feedback applied" }, undefined, undefined, ctx);
|
|
441
566
|
|
|
442
567
|
expect(orchestrator.active).toBeNull();
|
|
443
|
-
expect(result4.content[0].text).
|
|
568
|
+
expect(result4.content[0].text).toBe("");
|
|
444
569
|
|
|
445
570
|
const finalState = loadTask(taskDir);
|
|
446
571
|
expect(finalState.phase).toBe("done");
|
|
447
|
-
expect(finalState.reviewPass).toBe(
|
|
572
|
+
expect(finalState.reviewPass).toBe(0);
|
|
448
573
|
expect(finalState.reviewCycle).toBeNull();
|
|
449
574
|
});
|
|
450
575
|
|
|
@@ -458,7 +583,7 @@ describe("review cycle lifecycle", () => {
|
|
|
458
583
|
|
|
459
584
|
writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
|
|
460
585
|
writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
|
|
461
|
-
|
|
586
|
+
expectBrainstormToPlan(menu);
|
|
462
587
|
const ppPhaseComplete = getTool(pi, "pp_phase_complete");
|
|
463
588
|
await ppPhaseComplete.execute("call-1", { summary: "done" }, undefined, undefined, ctx);
|
|
464
589
|
await new Promise((r) => setTimeout(r, 10));
|
|
@@ -478,11 +603,11 @@ describe("review cycle lifecycle", () => {
|
|
|
478
603
|
makeValidPlan(["- [x] P1. Done item — Done when: synthesized plan is fully checked"]),
|
|
479
604
|
"utf-8",
|
|
480
605
|
);
|
|
481
|
-
|
|
606
|
+
expectPlanToImplement(menu);
|
|
482
607
|
await ppPhaseComplete.execute("call-2", { summary: "plan done" }, undefined, undefined, ctx);
|
|
483
608
|
await new Promise((r) => setTimeout(r, 10));
|
|
484
609
|
|
|
485
|
-
|
|
610
|
+
expectReviewAuto(menu);
|
|
486
611
|
await ppPhaseComplete.execute("call-3", { summary: "implemented" }, undefined, undefined, ctx);
|
|
487
612
|
|
|
488
613
|
emitSubagentCreated(pi, "reviewer-1", "Code reviewer (test)");
|
|
@@ -497,12 +622,30 @@ describe("review cycle lifecycle", () => {
|
|
|
497
622
|
const ctx = makeCtx();
|
|
498
623
|
|
|
499
624
|
await orchestrator.startTask(ctx as any, "implement", "Test no reviewers");
|
|
500
|
-
orchestrator.config = {
|
|
625
|
+
orchestrator.config = {
|
|
626
|
+
...orchestrator.config,
|
|
627
|
+
agents: {
|
|
628
|
+
...orchestrator.config.agents,
|
|
629
|
+
subagents: {
|
|
630
|
+
...orchestrator.config.agents.subagents,
|
|
631
|
+
presetGroups: {
|
|
632
|
+
...orchestrator.config.agents.subagents.presetGroups,
|
|
633
|
+
codeReviewers: {
|
|
634
|
+
...orchestrator.config.agents.subagents.presetGroups.codeReviewers,
|
|
635
|
+
presets: {
|
|
636
|
+
...orchestrator.config.agents.subagents.presetGroups.codeReviewers.presets,
|
|
637
|
+
regular: { enabled: true, agents: {} },
|
|
638
|
+
},
|
|
639
|
+
},
|
|
640
|
+
},
|
|
641
|
+
},
|
|
642
|
+
},
|
|
643
|
+
} as any;
|
|
501
644
|
const taskDir = orchestrator.active!.dir;
|
|
502
645
|
|
|
503
646
|
writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
|
|
504
647
|
writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
|
|
505
|
-
|
|
648
|
+
expectBrainstormToPlan(menu);
|
|
506
649
|
const ppPhaseComplete = getTool(pi, "pp_phase_complete");
|
|
507
650
|
await ppPhaseComplete.execute("call-1", { summary: "done" }, undefined, undefined, ctx);
|
|
508
651
|
await new Promise((r) => setTimeout(r, 10));
|
|
@@ -522,12 +665,12 @@ describe("review cycle lifecycle", () => {
|
|
|
522
665
|
makeValidPlan(["- [x] P1. Done item — Done when: synthesized plan is fully checked"]),
|
|
523
666
|
"utf-8",
|
|
524
667
|
);
|
|
525
|
-
|
|
668
|
+
expectPlanToImplement(menu);
|
|
526
669
|
await ppPhaseComplete.execute("call-2", { summary: "plan done" }, undefined, undefined, ctx);
|
|
527
670
|
await new Promise((r) => setTimeout(r, 10));
|
|
528
671
|
|
|
529
|
-
|
|
530
|
-
|
|
672
|
+
expectReviewAuto(menu);
|
|
673
|
+
expectReviewOnMyOwn(menu);
|
|
531
674
|
const result = await ppPhaseComplete.execute("call-3", { summary: "implemented" }, undefined, undefined, ctx);
|
|
532
675
|
|
|
533
676
|
expect(result.content[0].text).toContain("continue");
|
|
@@ -546,8 +689,8 @@ describe("subagent instrumentation", () => {
|
|
|
546
689
|
emitSubagentCreated(pi, "explore-1", "Explore agent");
|
|
547
690
|
expect(orchestrator.agentLifecycle.get("explore-1")?.createdAt).toBeTypeOf("number");
|
|
548
691
|
expect(orchestrator.agentLifecycle.get("explore-1")?.phase).toBe("brainstorm");
|
|
549
|
-
const
|
|
550
|
-
expect(existsSync(
|
|
692
|
+
const debugLogPath = join(taskLogsDir(orchestrator.active!.dir), "debug.jsonl");
|
|
693
|
+
expect(existsSync(debugLogPath)).toBe(true);
|
|
551
694
|
|
|
552
695
|
emitSubagentStarted(pi, "explore-1", "Explore agent");
|
|
553
696
|
expect(orchestrator.agentLifecycle.get("explore-1")?.startedAt).toBeTypeOf("number");
|
|
@@ -625,11 +768,11 @@ describe("standalone brainstorm", () => {
|
|
|
625
768
|
expect(orchestrator.active!.state.phase).toBe("brainstorm");
|
|
626
769
|
expect(orchestrator.active!.type).toBe("brainstorm");
|
|
627
770
|
|
|
628
|
-
|
|
771
|
+
expectImplementToDone(menu);
|
|
629
772
|
|
|
630
773
|
const ppPhaseComplete = getTool(pi, "pp_phase_complete");
|
|
631
774
|
const result = await ppPhaseComplete.execute("call-1", { summary: "Explored ideas" }, undefined, undefined, ctx);
|
|
632
|
-
expect(result.content[0].text).
|
|
775
|
+
expect(result.content[0].text).toBe("");
|
|
633
776
|
|
|
634
777
|
expect(orchestrator.active).toBeNull();
|
|
635
778
|
});
|
|
@@ -645,12 +788,17 @@ describe("standalone brainstorm", () => {
|
|
|
645
788
|
writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
|
|
646
789
|
writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
|
|
647
790
|
|
|
648
|
-
|
|
791
|
+
menu
|
|
792
|
+
.expect({ question: m.anyTaskMenu, options: { include: ["Next"] }, choose: "Next" })
|
|
793
|
+
.expect({ question: "Next", options: { include: ["Continue to plan & implement"] }, choose: "Continue to plan & implement" })
|
|
794
|
+
.expect({ question: "Mode", options: { include: ["Guided", "Autonomous", "Back"] }, choose: "Guided" })
|
|
795
|
+
.expect({ question: "Planner preset", options: { include: [m.preset("regular"), "Back"] }, choose: m.preset("regular") });
|
|
649
796
|
|
|
650
797
|
const ppPhaseComplete = getTool(pi, "pp_phase_complete");
|
|
651
798
|
await ppPhaseComplete.execute("call-1", { summary: "Conclusions ready" }, undefined, undefined, ctx);
|
|
799
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
652
800
|
|
|
653
|
-
expect(orchestrator.active!.type).toBe("
|
|
801
|
+
expect(orchestrator.active!.type).toBe("brainstorm");
|
|
654
802
|
expect(orchestrator.active!.state.phase).toBe("plan");
|
|
655
803
|
expect(orchestrator.active!.state.step).toBe("await_planners");
|
|
656
804
|
});
|
|
@@ -671,12 +819,13 @@ describe("debug flow", () => {
|
|
|
671
819
|
writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
|
|
672
820
|
writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
|
|
673
821
|
|
|
674
|
-
|
|
822
|
+
expectBrainstormToPlan(menu);
|
|
675
823
|
|
|
676
824
|
const ppPhaseComplete = getTool(pi, "pp_phase_complete");
|
|
677
825
|
await ppPhaseComplete.execute("call-1", { summary: "Diagnosis complete" }, undefined, undefined, ctx);
|
|
826
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
678
827
|
|
|
679
|
-
expect(orchestrator.active!.type).toBe("
|
|
828
|
+
expect(orchestrator.active!.type).toBe("debug");
|
|
680
829
|
expect(orchestrator.active!.state.phase).toBe("plan");
|
|
681
830
|
expect(orchestrator.active!.state.step).toBe("await_planners");
|
|
682
831
|
});
|
|
@@ -694,7 +843,7 @@ describe("planner completion tracking", () => {
|
|
|
694
843
|
writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
|
|
695
844
|
writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
|
|
696
845
|
|
|
697
|
-
|
|
846
|
+
expectBrainstormToPlan(menu);
|
|
698
847
|
const ppPhaseComplete = getTool(pi, "pp_phase_complete");
|
|
699
848
|
await ppPhaseComplete.execute("call-1", { summary: "done" }, undefined, undefined, ctx);
|
|
700
849
|
await new Promise((r) => setTimeout(r, 10));
|
|
@@ -728,7 +877,7 @@ describe("planner completion tracking", () => {
|
|
|
728
877
|
writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
|
|
729
878
|
writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
|
|
730
879
|
|
|
731
|
-
|
|
880
|
+
expectBrainstormToPlan(menu);
|
|
732
881
|
const ppPhaseComplete = getTool(pi, "pp_phase_complete");
|
|
733
882
|
await ppPhaseComplete.execute("call-1", { summary: "done" }, undefined, undefined, ctx);
|
|
734
883
|
await new Promise((r) => setTimeout(r, 10));
|
|
@@ -739,6 +888,27 @@ describe("planner completion tracking", () => {
|
|
|
739
888
|
expect(orchestrator.active!.state.step).toBe("synthesize");
|
|
740
889
|
});
|
|
741
890
|
|
|
891
|
+
it("onSettled with zero spawned planners advances await_planners (poller-removal safety net)", async () => {
|
|
892
|
+
const cwd = makeTempDir();
|
|
893
|
+
const { orchestrator } = await setupOrchestrator(cwd);
|
|
894
|
+
const ctx = makeCtx();
|
|
895
|
+
|
|
896
|
+
await orchestrator.startTask(ctx as any, "implement", "Zero planners");
|
|
897
|
+
const taskDir = orchestrator.active!.dir;
|
|
898
|
+
writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
|
|
899
|
+
writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
|
|
900
|
+
orchestrator.active!.state.phase = "plan";
|
|
901
|
+
orchestrator.active!.state.step = "await_planners";
|
|
902
|
+
orchestrator.pendingSubagentSpawns = 0;
|
|
903
|
+
orchestrator.spawnedAgentIds.clear();
|
|
904
|
+
|
|
905
|
+
// With the poller gone, the spawn promise's onSettled(spawned=0) is the only
|
|
906
|
+
// thing that can advance when no subagents:completed/failed event ever fires.
|
|
907
|
+
orchestrator.checkPlannerCompletion();
|
|
908
|
+
|
|
909
|
+
expect(orchestrator.active!.state.step).toBe("synthesize");
|
|
910
|
+
});
|
|
911
|
+
|
|
742
912
|
it("plan transition sets await_planners before compaction callback runs", async () => {
|
|
743
913
|
const cwd = makeTempDir();
|
|
744
914
|
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
@@ -754,7 +924,7 @@ describe("planner completion tracking", () => {
|
|
|
754
924
|
writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
|
|
755
925
|
writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
|
|
756
926
|
|
|
757
|
-
|
|
927
|
+
expectBrainstormToPlan(menu);
|
|
758
928
|
const ppPhaseComplete = getTool(pi, "pp_phase_complete");
|
|
759
929
|
await ppPhaseComplete.execute("call-1", { summary: "done" }, undefined, undefined, ctx);
|
|
760
930
|
|
|
@@ -763,8 +933,8 @@ describe("planner completion tracking", () => {
|
|
|
763
933
|
expect(compactCallbacks).toHaveLength(1);
|
|
764
934
|
|
|
765
935
|
compactCallbacks[0]!();
|
|
936
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
766
937
|
|
|
767
|
-
expect(ctx.ui.notify).toHaveBeenCalledWith("Entered plan phase. Waiting for planners to complete before synthesis.", "info");
|
|
768
938
|
const planBeginMessages = pi.sendUserMessage.mock.calls.filter(
|
|
769
939
|
(c: any[]) => c[0] === "[PI-PI] Entered plan phase. Begin working.",
|
|
770
940
|
);
|
|
@@ -782,7 +952,7 @@ describe("planner completion tracking", () => {
|
|
|
782
952
|
writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
|
|
783
953
|
writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
|
|
784
954
|
|
|
785
|
-
|
|
955
|
+
expectBrainstormToPlan(menu);
|
|
786
956
|
const ppPhaseComplete = getTool(pi, "pp_phase_complete");
|
|
787
957
|
await ppPhaseComplete.execute("call-1", { summary: "done" }, undefined, undefined, ctx);
|
|
788
958
|
await new Promise((r) => setTimeout(r, 10));
|
|
@@ -798,7 +968,13 @@ describe("planner completion tracking", () => {
|
|
|
798
968
|
orchestrator.pendingSubagentSpawns = 0;
|
|
799
969
|
orchestrator.failedPlannerVariants = ["test"];
|
|
800
970
|
orchestrator.lastCtx = ctx;
|
|
801
|
-
|
|
971
|
+
menu.expect({
|
|
972
|
+
question: /Some planners failed:/,
|
|
973
|
+
options: {
|
|
974
|
+
exact: ["Retry failed planners", "Work with available planner outputs", "Stop task"],
|
|
975
|
+
},
|
|
976
|
+
choose: "Work with available planner outputs",
|
|
977
|
+
});
|
|
802
978
|
|
|
803
979
|
emitSubagentCompleted(pi, "planner-1", "Planner (test)");
|
|
804
980
|
await new Promise((r) => setTimeout(r, 10));
|
|
@@ -825,7 +1001,13 @@ describe("reviewer failure handling", () => {
|
|
|
825
1001
|
orchestrator.pendingSubagentSpawns = 0;
|
|
826
1002
|
orchestrator.failedReviewerVariants = ["test"];
|
|
827
1003
|
orchestrator.lastCtx = ctx;
|
|
828
|
-
|
|
1004
|
+
menu.expect({
|
|
1005
|
+
question: /Some reviewers failed:/,
|
|
1006
|
+
options: {
|
|
1007
|
+
exact: ["Retry failed reviewers", "Work with available reviewer outputs", "Continue without review", "Stop task"],
|
|
1008
|
+
},
|
|
1009
|
+
choose: "Continue without review",
|
|
1010
|
+
});
|
|
829
1011
|
|
|
830
1012
|
emitSubagentCompleted(pi, "reviewer-1", "Code reviewer (test)");
|
|
831
1013
|
await new Promise((r) => setTimeout(r, 10));
|
|
@@ -845,8 +1027,9 @@ describe("pp:done cancellation", () => {
|
|
|
845
1027
|
await orchestrator.startTask(ctx as any, "implement", "Test done");
|
|
846
1028
|
const taskDir = orchestrator.active!.dir;
|
|
847
1029
|
|
|
848
|
-
|
|
849
|
-
|
|
1030
|
+
expectImplementToDone(menu);
|
|
1031
|
+
const ppPhaseComplete = getTool(pi, "pp_phase_complete");
|
|
1032
|
+
await ppPhaseComplete.execute("call-1", { summary: "done" }, undefined, undefined, ctx);
|
|
850
1033
|
|
|
851
1034
|
expect(orchestrator.active).toBeNull();
|
|
852
1035
|
const state = loadTask(taskDir);
|
|
@@ -855,6 +1038,127 @@ describe("pp:done cancellation", () => {
|
|
|
855
1038
|
});
|
|
856
1039
|
|
|
857
1040
|
describe("edge cases and regressions", () => {
|
|
1041
|
+
it("pp_register_repo returns error for non-git path", async () => {
|
|
1042
|
+
const cwd = makeTempDir();
|
|
1043
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
1044
|
+
const ctx = makeCtx();
|
|
1045
|
+
|
|
1046
|
+
await orchestrator.startTask(ctx as any, "implement", "register non git");
|
|
1047
|
+
|
|
1048
|
+
const ppRegisterRepo = getTool(pi, "pp_register_repo");
|
|
1049
|
+
const result = await ppRegisterRepo.execute("call-non-git", { path: join(cwd, "not-a-repo") });
|
|
1050
|
+
|
|
1051
|
+
expect(result.isError).toBe(true);
|
|
1052
|
+
expect(result.content[0].text).toContain("Not a git repository");
|
|
1053
|
+
});
|
|
1054
|
+
|
|
1055
|
+
it("pp_register_repo resolves nested path to git root", async () => {
|
|
1056
|
+
const cwd = makeTempDir();
|
|
1057
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
1058
|
+
const ctx = makeCtx();
|
|
1059
|
+
const repoDir = join(cwd, "extra-repo");
|
|
1060
|
+
const nestedPath = join(repoDir, "src", "index.ts");
|
|
1061
|
+
mkdirSync(join(repoDir, "src"), { recursive: true });
|
|
1062
|
+
writeFileSync(nestedPath, "export const nested = true;\n", "utf-8");
|
|
1063
|
+
|
|
1064
|
+
await orchestrator.startTask(ctx as any, "implement", "register nested");
|
|
1065
|
+
|
|
1066
|
+
pi.exec.mockImplementation(async (command?: string, args?: string[]) => {
|
|
1067
|
+
if (command === "git" && args?.[0] === "rev-parse" && args?.[1] === "--show-toplevel") {
|
|
1068
|
+
return { code: 0, stdout: `${repoDir}\n`, stderr: "" };
|
|
1069
|
+
}
|
|
1070
|
+
return { code: 1, stdout: "", stderr: "unsupported" };
|
|
1071
|
+
});
|
|
1072
|
+
|
|
1073
|
+
const ppRegisterRepo = getTool(pi, "pp_register_repo");
|
|
1074
|
+
const result = await ppRegisterRepo.execute("call-nested", { path: nestedPath });
|
|
1075
|
+
|
|
1076
|
+
expect(result.content[0].text).toContain(`Registered repository: ${repoDir}`);
|
|
1077
|
+
expect(orchestrator.active!.state.repos?.some((repo) => repo.path === repoDir)).toBe(true);
|
|
1078
|
+
});
|
|
1079
|
+
|
|
1080
|
+
it("pp_register_repo updates root baseBranch", async () => {
|
|
1081
|
+
const cwd = makeTempDir();
|
|
1082
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
1083
|
+
const ctx = makeCtx();
|
|
1084
|
+
|
|
1085
|
+
await orchestrator.startTask(ctx as any, "implement", "register root");
|
|
1086
|
+
|
|
1087
|
+
pi.exec.mockImplementation(async (command?: string, args?: string[]) => {
|
|
1088
|
+
if (command === "git" && args?.[0] === "rev-parse" && args?.[1] === "--show-toplevel") {
|
|
1089
|
+
return { code: 0, stdout: `${cwd}\n`, stderr: "" };
|
|
1090
|
+
}
|
|
1091
|
+
return { code: 1, stdout: "", stderr: "unsupported" };
|
|
1092
|
+
});
|
|
1093
|
+
|
|
1094
|
+
const ppRegisterRepo = getTool(pi, "pp_register_repo");
|
|
1095
|
+
const result = await ppRegisterRepo.execute("call-root", { path: cwd, baseBranch: "origin/main" });
|
|
1096
|
+
|
|
1097
|
+
expect(result.content[0].text).toContain("Updated repository");
|
|
1098
|
+
expect(orchestrator.active!.state.repos?.find((repo) => repo.isRoot)?.baseBranch).toBe("origin/main");
|
|
1099
|
+
});
|
|
1100
|
+
|
|
1101
|
+
it("pp_register_repo adds extra repo and does not duplicate on repeat", async () => {
|
|
1102
|
+
const cwd = makeTempDir();
|
|
1103
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
1104
|
+
const ctx = makeCtx();
|
|
1105
|
+
const repoDir = join(cwd, "extra-repo");
|
|
1106
|
+
mkdirSync(repoDir, { recursive: true });
|
|
1107
|
+
|
|
1108
|
+
await orchestrator.startTask(ctx as any, "implement", "register dedupe");
|
|
1109
|
+
|
|
1110
|
+
pi.exec.mockImplementation(async (command?: string, args?: string[]) => {
|
|
1111
|
+
if (command === "git" && args?.[0] === "rev-parse" && args?.[1] === "--show-toplevel") {
|
|
1112
|
+
return { code: 0, stdout: `${repoDir}\n`, stderr: "" };
|
|
1113
|
+
}
|
|
1114
|
+
return { code: 1, stdout: "", stderr: "unsupported" };
|
|
1115
|
+
});
|
|
1116
|
+
|
|
1117
|
+
const ppRegisterRepo = getTool(pi, "pp_register_repo");
|
|
1118
|
+
await ppRegisterRepo.execute("call-extra-1", { path: repoDir, baseBranch: "origin/main" });
|
|
1119
|
+
const second = await ppRegisterRepo.execute("call-extra-2", { path: repoDir, baseBranch: "origin/main" });
|
|
1120
|
+
|
|
1121
|
+
const repos = orchestrator.active!.state.repos ?? [];
|
|
1122
|
+
expect(repos.filter((repo) => repo.path === repoDir)).toHaveLength(1);
|
|
1123
|
+
expect(second.content[0].text).toContain("Already registered repository");
|
|
1124
|
+
});
|
|
1125
|
+
|
|
1126
|
+
it("pp_register_repo deduplicates entries and updates baseBranch", async () => {
|
|
1127
|
+
const cwd = makeTempDir();
|
|
1128
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
1129
|
+
const ctx = makeCtx();
|
|
1130
|
+
|
|
1131
|
+
await orchestrator.startTask(ctx as any, "implement", "Repo registration test");
|
|
1132
|
+
|
|
1133
|
+
const repoDir = join(cwd, "extra-repo");
|
|
1134
|
+
mkdirSync(join(repoDir, "src"), { recursive: true });
|
|
1135
|
+
writeFileSync(join(repoDir, "src", "index.ts"), "export const x = 1;\n", "utf-8");
|
|
1136
|
+
|
|
1137
|
+
pi.exec.mockImplementation(async (command?: string, args?: string[], options?: { cwd?: string }) => {
|
|
1138
|
+
if (!command || !args) {
|
|
1139
|
+
return { code: 1, stdout: "", stderr: "unsupported" };
|
|
1140
|
+
}
|
|
1141
|
+
if (command === "git" && args[0] === "rev-parse" && args[1] === "--show-toplevel") {
|
|
1142
|
+
return { code: 0, stdout: `${repoDir}\n`, stderr: "" };
|
|
1143
|
+
}
|
|
1144
|
+
return { code: 1, stdout: "", stderr: "unsupported" };
|
|
1145
|
+
});
|
|
1146
|
+
|
|
1147
|
+
const { registerCbmTools } = await import("./cbm.js");
|
|
1148
|
+
const cbmCallsBefore = (registerCbmTools as any).mock.calls.length;
|
|
1149
|
+
|
|
1150
|
+
const ppRegisterRepo = getTool(pi, "pp_register_repo");
|
|
1151
|
+
await ppRegisterRepo.execute("call-1", { path: repoDir, baseBranch: "origin/main" });
|
|
1152
|
+
await ppRegisterRepo.execute("call-2", { path: join(repoDir, "src", "index.ts"), baseBranch: "origin/develop" });
|
|
1153
|
+
|
|
1154
|
+
const repos = orchestrator.active!.state.repos ?? [];
|
|
1155
|
+
const extraRepos = repos.filter((repo) => repo.path === repoDir);
|
|
1156
|
+
|
|
1157
|
+
expect(extraRepos).toHaveLength(1);
|
|
1158
|
+
expect(extraRepos[0]?.baseBranch).toBe("origin/develop");
|
|
1159
|
+
expect((registerCbmTools as any).mock.calls.length).toBe(cbmCallsBefore);
|
|
1160
|
+
});
|
|
1161
|
+
|
|
858
1162
|
it("starting new task while another is active finishes the old one", async () => {
|
|
859
1163
|
const cwd = makeTempDir();
|
|
860
1164
|
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
@@ -870,7 +1174,7 @@ describe("edge cases and regressions", () => {
|
|
|
870
1174
|
expect(orchestrator.active!.description).toBe("Second task");
|
|
871
1175
|
|
|
872
1176
|
const firstState = loadTask(firstDir);
|
|
873
|
-
expect(firstState.phase).toBe("
|
|
1177
|
+
expect(firstState.phase).toBe("brainstorm");
|
|
874
1178
|
});
|
|
875
1179
|
|
|
876
1180
|
it("pp:done during review cycle cleans up properly", async () => {
|
|
@@ -883,7 +1187,7 @@ describe("edge cases and regressions", () => {
|
|
|
883
1187
|
|
|
884
1188
|
writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
|
|
885
1189
|
writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
|
|
886
|
-
|
|
1190
|
+
expectBrainstormToPlan(menu);
|
|
887
1191
|
const ppPhaseComplete = getTool(pi, "pp_phase_complete");
|
|
888
1192
|
await ppPhaseComplete.execute("call-1", { summary: "done" }, undefined, undefined, ctx);
|
|
889
1193
|
await new Promise((r) => setTimeout(r, 10));
|
|
@@ -903,18 +1207,24 @@ describe("edge cases and regressions", () => {
|
|
|
903
1207
|
makeValidPlan(["- [x] P1. Done item — Done when: synthesized plan is fully checked"]),
|
|
904
1208
|
"utf-8",
|
|
905
1209
|
);
|
|
906
|
-
|
|
1210
|
+
expectPlanToImplement(menu);
|
|
907
1211
|
await ppPhaseComplete.execute("call-2", { summary: "plan done" }, undefined, undefined, ctx);
|
|
908
1212
|
await new Promise((r) => setTimeout(r, 10));
|
|
909
1213
|
|
|
910
|
-
|
|
1214
|
+
expectReviewAuto(menu);
|
|
911
1215
|
await ppPhaseComplete.execute("call-3", { summary: "implemented" }, undefined, undefined, ctx);
|
|
912
1216
|
|
|
1217
|
+
const reviewsDir = join(taskDir, "code-reviews");
|
|
1218
|
+
mkdirSync(reviewsDir, { recursive: true });
|
|
1219
|
+
writeFileSync(join(reviewsDir, `${Math.floor(Date.now() / 1000)}_test_round-1.md`), "LGTM", "utf-8");
|
|
1220
|
+
emitSubagentCreated(pi, "reviewer-1", "Code reviewer (test)");
|
|
1221
|
+
emitSubagentCompleted(pi, "reviewer-1", "Code reviewer (test)");
|
|
1222
|
+
|
|
913
1223
|
expect(orchestrator.active!.state.reviewCycle).not.toBeNull();
|
|
914
1224
|
expect(["await_reviewers", "apply_feedback"]).toContain(orchestrator.active!.state.reviewCycle!.step);
|
|
915
1225
|
|
|
916
|
-
|
|
917
|
-
await
|
|
1226
|
+
expectImplementToDone(menu);
|
|
1227
|
+
await ppPhaseComplete.execute("call-4", { summary: "done" }, undefined, undefined, ctx);
|
|
918
1228
|
|
|
919
1229
|
expect(orchestrator.active).toBeNull();
|
|
920
1230
|
expect(orchestrator.spawnedAgentIds.size).toBe(0);
|
|
@@ -931,7 +1241,7 @@ describe("edge cases and regressions", () => {
|
|
|
931
1241
|
|
|
932
1242
|
writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
|
|
933
1243
|
writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
|
|
934
|
-
|
|
1244
|
+
expectBrainstormToPlan(menu);
|
|
935
1245
|
const ppPhaseComplete = getTool(pi, "pp_phase_complete");
|
|
936
1246
|
await ppPhaseComplete.execute("call-1", { summary: "done" }, undefined, undefined, ctx);
|
|
937
1247
|
await new Promise((r) => setTimeout(r, 10));
|
|
@@ -951,13 +1261,13 @@ describe("edge cases and regressions", () => {
|
|
|
951
1261
|
makeValidPlan(["- [x] P1. Done item — Done when: synthesized plan is fully checked"]),
|
|
952
1262
|
"utf-8",
|
|
953
1263
|
);
|
|
954
|
-
|
|
1264
|
+
expectPlanToImplement(menu);
|
|
955
1265
|
await ppPhaseComplete.execute("call-2", { summary: "plan done" }, undefined, undefined, ctx);
|
|
956
1266
|
await new Promise((r) => setTimeout(r, 10));
|
|
957
1267
|
|
|
958
1268
|
expect(orchestrator.active!.state.phase).toBe("implement");
|
|
959
1269
|
|
|
960
|
-
|
|
1270
|
+
expectReviewAuto(menu);
|
|
961
1271
|
await ppPhaseComplete.execute("call-3", { summary: "implemented" }, undefined, undefined, ctx);
|
|
962
1272
|
|
|
963
1273
|
const reviewsDir = join(taskDir, "code-reviews");
|
|
@@ -969,11 +1279,11 @@ describe("edge cases and regressions", () => {
|
|
|
969
1279
|
|
|
970
1280
|
expect(orchestrator.active!.state.reviewCycle!.step).toBe("apply_feedback");
|
|
971
1281
|
|
|
972
|
-
|
|
1282
|
+
expectReviewAuto(menu);
|
|
973
1283
|
await ppPhaseComplete.execute("call-4", { summary: "fixes applied" }, undefined, undefined, ctx);
|
|
974
1284
|
|
|
975
1285
|
expect(orchestrator.active!.state.reviewPass).toBe(1);
|
|
976
|
-
expect(orchestrator.active!.state.reviewPassByKind?.auto).toBe(1);
|
|
1286
|
+
expect(orchestrator.active!.state.reviewPassByKind?.implement?.auto).toBe(1);
|
|
977
1287
|
expect(orchestrator.active!.state.reviewCycle).not.toBeNull();
|
|
978
1288
|
expect(orchestrator.active!.state.reviewCycle!.pass).toBe(2);
|
|
979
1289
|
|
|
@@ -982,14 +1292,14 @@ describe("edge cases and regressions", () => {
|
|
|
982
1292
|
emitSubagentCreated(pi, "reviewer-2", "Code reviewer (test)");
|
|
983
1293
|
emitSubagentCompleted(pi, "reviewer-2", "Code reviewer (test)");
|
|
984
1294
|
|
|
985
|
-
|
|
1295
|
+
expectImplementToDone(menu);
|
|
986
1296
|
const result = await ppPhaseComplete.execute("call-5", { summary: "all good" }, undefined, undefined, ctx);
|
|
987
1297
|
|
|
988
|
-
expect(result.content[0].text).
|
|
1298
|
+
expect(result.content[0].text).toBe("");
|
|
989
1299
|
expect(orchestrator.active).toBeNull();
|
|
990
1300
|
|
|
991
1301
|
const finalState = loadTask(taskDir);
|
|
992
|
-
expect(finalState.reviewPass).toBe(
|
|
1302
|
+
expect(finalState.reviewPass).toBe(1);
|
|
993
1303
|
expect(finalState.reviewCycle).toBeNull();
|
|
994
1304
|
});
|
|
995
1305
|
|
|
@@ -1000,7 +1310,7 @@ describe("edge cases and regressions", () => {
|
|
|
1000
1310
|
|
|
1001
1311
|
await orchestrator.startTask(ctx as any, "implement", "Continue test");
|
|
1002
1312
|
|
|
1003
|
-
|
|
1313
|
+
expectReviewOnMyOwn(menu);
|
|
1004
1314
|
const ppPhaseComplete = getTool(pi, "pp_phase_complete");
|
|
1005
1315
|
const result = await ppPhaseComplete.execute("call-1", { summary: "not done yet" }, undefined, undefined, ctx);
|
|
1006
1316
|
|
|
@@ -1019,7 +1329,7 @@ describe("edge cases and regressions", () => {
|
|
|
1019
1329
|
|
|
1020
1330
|
writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
|
|
1021
1331
|
writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
|
|
1022
|
-
|
|
1332
|
+
expectBrainstormToPlan(menu);
|
|
1023
1333
|
const ppPhaseComplete = getTool(pi, "pp_phase_complete");
|
|
1024
1334
|
await ppPhaseComplete.execute("call-1", { summary: "done" }, undefined, undefined, ctx);
|
|
1025
1335
|
await new Promise((r) => setTimeout(r, 10));
|
|
@@ -1039,11 +1349,11 @@ describe("edge cases and regressions", () => {
|
|
|
1039
1349
|
makeValidPlan(["- [ ] P1. Todo item — Done when: item intentionally remains unchecked"]),
|
|
1040
1350
|
"utf-8",
|
|
1041
1351
|
);
|
|
1042
|
-
|
|
1352
|
+
expectPlanToImplement(menu);
|
|
1043
1353
|
await ppPhaseComplete.execute("call-2", { summary: "plan done" }, undefined, undefined, ctx);
|
|
1044
1354
|
await new Promise((r) => setTimeout(r, 10));
|
|
1045
1355
|
|
|
1046
|
-
|
|
1356
|
+
expectReviewOnMyOwn(menu);
|
|
1047
1357
|
const result = await ppPhaseComplete.execute("call-3", { summary: "partial work" }, undefined, undefined, ctx);
|
|
1048
1358
|
|
|
1049
1359
|
expect(result.content[0].text).toContain("continue");
|
|
@@ -1061,7 +1371,7 @@ describe("edge cases and regressions", () => {
|
|
|
1061
1371
|
|
|
1062
1372
|
writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
|
|
1063
1373
|
writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
|
|
1064
|
-
|
|
1374
|
+
expectBrainstormToPlan(menu);
|
|
1065
1375
|
const ppPhaseComplete = getTool(pi, "pp_phase_complete");
|
|
1066
1376
|
await ppPhaseComplete.execute("call-1", { summary: "done" }, undefined, undefined, ctx);
|
|
1067
1377
|
await new Promise((r) => setTimeout(r, 10));
|
|
@@ -1082,7 +1392,7 @@ describe("edge cases and regressions", () => {
|
|
|
1082
1392
|
"utf-8",
|
|
1083
1393
|
);
|
|
1084
1394
|
|
|
1085
|
-
|
|
1395
|
+
expectReviewOnMyOwn(menu);
|
|
1086
1396
|
const result = await ppPhaseComplete.execute("call-2", { summary: "plan ready" }, undefined, undefined, ctx);
|
|
1087
1397
|
|
|
1088
1398
|
expect(result.content[0].text).toContain("continue");
|
|
@@ -1141,7 +1451,7 @@ describe("edge cases and regressions", () => {
|
|
|
1141
1451
|
|
|
1142
1452
|
it("implement --from brainstorm also skips brainstorm and enters plan deterministically", async () => {
|
|
1143
1453
|
const cwd = makeTempDir();
|
|
1144
|
-
const {
|
|
1454
|
+
const { orchestrator } = await setupOrchestrator(cwd);
|
|
1145
1455
|
const ctx = makeCtx();
|
|
1146
1456
|
|
|
1147
1457
|
await orchestrator.startTask(ctx as any, "brainstorm", "Explore ideas");
|
|
@@ -1149,8 +1459,7 @@ describe("edge cases and regressions", () => {
|
|
|
1149
1459
|
writeFileSync(join(brainstormDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
|
|
1150
1460
|
writeFileSync(join(brainstormDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
|
|
1151
1461
|
|
|
1152
|
-
|
|
1153
|
-
await ppImplement(`--from brainstorm/${brainstormDir.split("/").pop()}`, ctx);
|
|
1462
|
+
await orchestrator.startTask(ctx as any, "implement", "implement", brainstormDir, true);
|
|
1154
1463
|
|
|
1155
1464
|
expect(orchestrator.active!.state.phase).toBe("plan");
|
|
1156
1465
|
expect(orchestrator.active!.state.step).toBe("await_planners");
|
|
@@ -1160,9 +1469,9 @@ describe("edge cases and regressions", () => {
|
|
|
1160
1469
|
expect(existsSync(join(orchestrator.active!.dir, "RESEARCH.md"))).toBe(true);
|
|
1161
1470
|
});
|
|
1162
1471
|
|
|
1163
|
-
it("
|
|
1472
|
+
it("implement from debug task stores source path and skips brainstorm", async () => {
|
|
1164
1473
|
const cwd = makeTempDir();
|
|
1165
|
-
const {
|
|
1474
|
+
const { orchestrator } = await setupOrchestrator(cwd);
|
|
1166
1475
|
const ctx = makeCtx();
|
|
1167
1476
|
|
|
1168
1477
|
await orchestrator.startTask(ctx as any, "debug", "Find bug");
|
|
@@ -1170,8 +1479,7 @@ describe("edge cases and regressions", () => {
|
|
|
1170
1479
|
writeFileSync(join(debugDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
|
|
1171
1480
|
writeFileSync(join(debugDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
|
|
1172
1481
|
|
|
1173
|
-
|
|
1174
|
-
await ppImplement(`--from=debug/${debugDir.split("/").pop()}`, ctx);
|
|
1482
|
+
await orchestrator.startTask(ctx as any, "implement", "implement", debugDir, true);
|
|
1175
1483
|
|
|
1176
1484
|
expect(orchestrator.active!.type).toBe("implement");
|
|
1177
1485
|
expect(orchestrator.active!.state.phase).toBe("plan");
|
|
@@ -1181,9 +1489,9 @@ describe("edge cases and regressions", () => {
|
|
|
1181
1489
|
expect(ctx.ui.notify).toHaveBeenCalledWith("Entered plan phase. Waiting for planners to complete before synthesis.", "info");
|
|
1182
1490
|
});
|
|
1183
1491
|
|
|
1184
|
-
it("
|
|
1492
|
+
it("implement from brainstorm task stores source path and skips brainstorm", async () => {
|
|
1185
1493
|
const cwd = makeTempDir();
|
|
1186
|
-
const {
|
|
1494
|
+
const { orchestrator } = await setupOrchestrator(cwd);
|
|
1187
1495
|
const ctx = makeCtx();
|
|
1188
1496
|
|
|
1189
1497
|
await orchestrator.startTask(ctx as any, "brainstorm", "Explore ideas");
|
|
@@ -1191,8 +1499,7 @@ describe("edge cases and regressions", () => {
|
|
|
1191
1499
|
writeFileSync(join(brainstormDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
|
|
1192
1500
|
writeFileSync(join(brainstormDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
|
|
1193
1501
|
|
|
1194
|
-
|
|
1195
|
-
await ppImplement(`--from=brainstorm/${brainstormDir.split("/").pop()}`, ctx);
|
|
1502
|
+
await orchestrator.startTask(ctx as any, "implement", "implement", brainstormDir, true);
|
|
1196
1503
|
|
|
1197
1504
|
expect(orchestrator.active!.type).toBe("implement");
|
|
1198
1505
|
expect(orchestrator.active!.state.phase).toBe("plan");
|
|
@@ -1214,3 +1521,2444 @@ describe("edge cases and regressions", () => {
|
|
|
1214
1521
|
expect(normalized.phase).toBe("plan");
|
|
1215
1522
|
});
|
|
1216
1523
|
});
|
|
1524
|
+
|
|
1525
|
+
describe("task modes and quick task", () => {
|
|
1526
|
+
it("quick task lifecycle completes via pp_phase_complete menu", async () => {
|
|
1527
|
+
const cwd = makeTempDir();
|
|
1528
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
1529
|
+
const ctx = makeCtx();
|
|
1530
|
+
|
|
1531
|
+
await orchestrator.startTask(ctx as any, "quick", "quick");
|
|
1532
|
+
const taskDir = orchestrator.active!.dir;
|
|
1533
|
+
|
|
1534
|
+
expectQuickMenu(menu, "Complete");
|
|
1535
|
+
const ppPhaseComplete = getTool(pi, "pp_phase_complete");
|
|
1536
|
+
const result = await ppPhaseComplete.execute("call-1", { summary: "done" }, undefined, undefined, ctx);
|
|
1537
|
+
|
|
1538
|
+
expect(result.content[0].text).toBeDefined();
|
|
1539
|
+
expect(orchestrator.active).toBeNull();
|
|
1540
|
+
expect(loadTask(taskDir).phase).toBe("done");
|
|
1541
|
+
});
|
|
1542
|
+
|
|
1543
|
+
it("quick task /pp menu shows quick options", async () => {
|
|
1544
|
+
const cwd = makeTempDir();
|
|
1545
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
1546
|
+
const ctx = makeCtx();
|
|
1547
|
+
|
|
1548
|
+
await orchestrator.startTask(ctx as any, "quick", "quick");
|
|
1549
|
+
menu.expect({ question: m.taskMenu("quick", "quick"), options: { include: ["Complete", "Pause"], exclude: ["Next", "Review"] }, choose: "Back" });
|
|
1550
|
+
const pp = getCommand(pi, "pp");
|
|
1551
|
+
await pp(undefined, ctx);
|
|
1552
|
+
});
|
|
1553
|
+
|
|
1554
|
+
it("mode picker stores autonomous mode in task state", async () => {
|
|
1555
|
+
const cwd = makeTempDir();
|
|
1556
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
1557
|
+
const ctx = makeCtx();
|
|
1558
|
+
|
|
1559
|
+
expectPpStartImplementAutonomous(menu);
|
|
1560
|
+
const pp = getCommand(pi, "pp");
|
|
1561
|
+
await pp(undefined, ctx);
|
|
1562
|
+
|
|
1563
|
+
expect(orchestrator.active).not.toBeNull();
|
|
1564
|
+
expect(orchestrator.active!.state.mode).toBe("autonomous");
|
|
1565
|
+
expect(orchestrator.active!.state.autonomousConfig).toBeDefined();
|
|
1566
|
+
});
|
|
1567
|
+
|
|
1568
|
+
it("autonomous pp_phase_complete auto-advances without opening menu", async () => {
|
|
1569
|
+
const cwd = makeTempDir();
|
|
1570
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
1571
|
+
const ctx = makeCtx();
|
|
1572
|
+
|
|
1573
|
+
await orchestrator.startTask(ctx as any, "implement", "implement", undefined, undefined, "autonomous");
|
|
1574
|
+
const taskDir = orchestrator.active!.dir;
|
|
1575
|
+
orchestrator.active!.state.autonomousConfig = {
|
|
1576
|
+
phases: {
|
|
1577
|
+
brainstorm: { reviewPreset: "regular", maxReviewPasses: 0 },
|
|
1578
|
+
plan: { plannerPreset: "regular", reviewPreset: "regular", maxReviewPasses: 0 },
|
|
1579
|
+
implement: { reviewPreset: "regular", maxReviewPasses: 0 },
|
|
1580
|
+
},
|
|
1581
|
+
};
|
|
1582
|
+
writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
|
|
1583
|
+
writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
|
|
1584
|
+
|
|
1585
|
+
const ppPhaseComplete = getTool(pi, "pp_phase_complete");
|
|
1586
|
+
const result = await ppPhaseComplete.execute("call-1", { summary: "done" }, undefined, undefined, ctx);
|
|
1587
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
1588
|
+
|
|
1589
|
+
expect(result.content[0].text).toBe("");
|
|
1590
|
+
expect(orchestrator.active!.state.phase).toBe("plan");
|
|
1591
|
+
expect(menu.transcript.filter((entry) => entry.question.startsWith("/pp"))).toHaveLength(0);
|
|
1592
|
+
});
|
|
1593
|
+
|
|
1594
|
+
it("autonomous review loop re-runs until cap then advances", async () => {
|
|
1595
|
+
const cwd = makeTempDir();
|
|
1596
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
1597
|
+
const ctx = makeCtx();
|
|
1598
|
+
|
|
1599
|
+
await orchestrator.startTask(ctx as any, "implement", "implement", undefined, undefined, "autonomous");
|
|
1600
|
+
const taskDir = orchestrator.active!.dir;
|
|
1601
|
+
orchestrator.active!.state.phase = "implement";
|
|
1602
|
+
orchestrator.active!.state.step = "llm_work";
|
|
1603
|
+
orchestrator.active!.state.reviewPass = 0;
|
|
1604
|
+
orchestrator.active!.state.reviewPassByKind = {};
|
|
1605
|
+
orchestrator.active!.state.autonomousConfig = {
|
|
1606
|
+
phases: {
|
|
1607
|
+
implement: { reviewPreset: "regular", maxReviewPasses: 2 },
|
|
1608
|
+
},
|
|
1609
|
+
};
|
|
1610
|
+
writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
|
|
1611
|
+
writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
|
|
1612
|
+
const plansDir = join(taskDir, "plans");
|
|
1613
|
+
mkdirSync(plansDir, { recursive: true });
|
|
1614
|
+
writeFileSync(
|
|
1615
|
+
join(plansDir, "1_synthesized.md"),
|
|
1616
|
+
makeValidPlan(["- [x] P1. Done item — Done when: synthesized plan is fully checked"]),
|
|
1617
|
+
"utf-8",
|
|
1618
|
+
);
|
|
1619
|
+
|
|
1620
|
+
const ppPhaseComplete = getTool(pi, "pp_phase_complete");
|
|
1621
|
+
const reviewsDir = join(taskDir, "code-reviews");
|
|
1622
|
+
mkdirSync(reviewsDir, { recursive: true });
|
|
1623
|
+
|
|
1624
|
+
// Pass 1: non-clean review (no APPROVE) below cap — must force apply+re-review, NOT advance.
|
|
1625
|
+
const first = await ppPhaseComplete.execute("call-1", { summary: "done" }, undefined, undefined, ctx);
|
|
1626
|
+
expect(first.content[0].text).toMatch(/Reviews are running|Started review cycle pass/);
|
|
1627
|
+
writeFileSync(join(reviewsDir, `1_test_round-1.md`), "- CRITICAL: fix this\n- VERDICT: NEEDS_CHANGES", "utf-8");
|
|
1628
|
+
emitSubagentCreated(pi, "reviewer-1", "Code reviewer (test)");
|
|
1629
|
+
emitSubagentCompleted(pi, "reviewer-1", "Code reviewer (test)");
|
|
1630
|
+
|
|
1631
|
+
const second = await ppPhaseComplete.execute("call-2", { summary: "applied" }, undefined, undefined, ctx);
|
|
1632
|
+
expect(second.content[0].text).toMatch(/Apply the reviewers' required changes/);
|
|
1633
|
+
expect(orchestrator.active).not.toBeNull();
|
|
1634
|
+
expect(orchestrator.active!.state.phase).toBe("implement");
|
|
1635
|
+
|
|
1636
|
+
// Pass 2 (= cap): non-clean again, but at maxReviewPasses — cap honored, advances to done.
|
|
1637
|
+
const third = await ppPhaseComplete.execute("call-3", { summary: "redo" }, undefined, undefined, ctx);
|
|
1638
|
+
expect(third.content[0].text).toMatch(/Reviews are running|Started review cycle pass/);
|
|
1639
|
+
writeFileSync(join(reviewsDir, `2_test_round-2.md`), "- CRITICAL: still\n- VERDICT: NEEDS_CHANGES", "utf-8");
|
|
1640
|
+
emitSubagentCreated(pi, "reviewer-2", "Code reviewer (test)");
|
|
1641
|
+
emitSubagentCompleted(pi, "reviewer-2", "Code reviewer (test)");
|
|
1642
|
+
|
|
1643
|
+
const fourth = await ppPhaseComplete.execute("call-4", { summary: "applied 2" }, undefined, undefined, ctx);
|
|
1644
|
+
expect(fourth.content[0].text).toBe("");
|
|
1645
|
+
expect(orchestrator.active).toBeNull();
|
|
1646
|
+
expect(loadTask(taskDir).phase).toBe("done");
|
|
1647
|
+
});
|
|
1648
|
+
|
|
1649
|
+
it("autonomous review early-exits on unanimous approval before reaching the cap", async () => {
|
|
1650
|
+
const cwd = makeTempDir();
|
|
1651
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
1652
|
+
const ctx = makeCtx();
|
|
1653
|
+
|
|
1654
|
+
await orchestrator.startTask(ctx as any, "implement", "implement", undefined, undefined, "autonomous");
|
|
1655
|
+
const taskDir = orchestrator.active!.dir;
|
|
1656
|
+
orchestrator.active!.state.phase = "implement";
|
|
1657
|
+
orchestrator.active!.state.step = "llm_work";
|
|
1658
|
+
orchestrator.active!.state.reviewPass = 0;
|
|
1659
|
+
orchestrator.active!.state.reviewPassByKind = {};
|
|
1660
|
+
orchestrator.active!.state.autonomousConfig = {
|
|
1661
|
+
phases: { implement: { reviewPreset: "regular", maxReviewPasses: 3 } },
|
|
1662
|
+
};
|
|
1663
|
+
writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
|
|
1664
|
+
writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
|
|
1665
|
+
const plansDir = join(taskDir, "plans");
|
|
1666
|
+
mkdirSync(plansDir, { recursive: true });
|
|
1667
|
+
writeFileSync(
|
|
1668
|
+
join(plansDir, "1_synthesized.md"),
|
|
1669
|
+
makeValidPlan(["- [x] P1. Done item — Done when: synthesized plan is fully checked"]),
|
|
1670
|
+
"utf-8",
|
|
1671
|
+
);
|
|
1672
|
+
|
|
1673
|
+
const ppPhaseComplete = getTool(pi, "pp_phase_complete");
|
|
1674
|
+
const first = await ppPhaseComplete.execute("call-1", { summary: "done" }, undefined, undefined, ctx);
|
|
1675
|
+
expect(first.content[0].text).toMatch(/Reviews are running|Started review cycle pass/);
|
|
1676
|
+
|
|
1677
|
+
const reviewsDir = join(taskDir, "code-reviews");
|
|
1678
|
+
mkdirSync(reviewsDir, { recursive: true });
|
|
1679
|
+
const round = orchestrator.active!.state.reviewCycle!.pass;
|
|
1680
|
+
for (const v of ["opus", "gpt", "gemini"]) {
|
|
1681
|
+
writeFileSync(join(reviewsDir, `1_${v}_round-${round}.md`), "VERDICT: APPROVE\n- CRITICAL: none", "utf-8");
|
|
1682
|
+
}
|
|
1683
|
+
emitSubagentCreated(pi, "reviewer-1", "Code reviewer (test)");
|
|
1684
|
+
emitSubagentCompleted(pi, "reviewer-1", "Code reviewer (test)");
|
|
1685
|
+
|
|
1686
|
+
const second = await ppPhaseComplete.execute("call-2", { summary: "applied" }, undefined, undefined, ctx);
|
|
1687
|
+
expect(second.content[0].text).toBe("");
|
|
1688
|
+
expect(orchestrator.active).toBeNull();
|
|
1689
|
+
const finalState = loadTask(taskDir);
|
|
1690
|
+
expect(finalState.phase).toBe("done");
|
|
1691
|
+
// Issue 4: review bookkeeping is cleared on the transition to done.
|
|
1692
|
+
expect(finalState.reviewCycle).toBeNull();
|
|
1693
|
+
// Early exit: only one auto pass ran (cap was 3), proving the clean APPROVE short-circuited.
|
|
1694
|
+
expect(finalState.reviewPassByKind?.implement?.auto).toBe(1);
|
|
1695
|
+
});
|
|
1696
|
+
|
|
1697
|
+
it("clean-approved review is not re-run on a later checklist-repair re-entry", async () => {
|
|
1698
|
+
const cwd = makeTempDir();
|
|
1699
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
1700
|
+
const ctx = makeCtx();
|
|
1701
|
+
|
|
1702
|
+
await orchestrator.startTask(ctx as any, "implement", "implement", undefined, undefined, "autonomous");
|
|
1703
|
+
const taskDir = orchestrator.active!.dir;
|
|
1704
|
+
orchestrator.active!.state.phase = "implement";
|
|
1705
|
+
orchestrator.active!.state.step = "llm_work";
|
|
1706
|
+
orchestrator.active!.state.reviewPass = 1;
|
|
1707
|
+
orchestrator.active!.state.reviewPassByKind = { implement: { auto: 1 } };
|
|
1708
|
+
orchestrator.active!.state.reviewApprovedClean = true;
|
|
1709
|
+
orchestrator.active!.state.autonomousConfig = {
|
|
1710
|
+
phases: { implement: { reviewPreset: "regular", maxReviewPasses: 3 } },
|
|
1711
|
+
};
|
|
1712
|
+
const plansDir = join(taskDir, "plans");
|
|
1713
|
+
mkdirSync(plansDir, { recursive: true });
|
|
1714
|
+
writeFileSync(
|
|
1715
|
+
join(plansDir, "1_synthesized.md"),
|
|
1716
|
+
makeValidPlan(["- [x] P1. Done item — Done when: synthesized plan is fully checked"]),
|
|
1717
|
+
"utf-8",
|
|
1718
|
+
);
|
|
1719
|
+
|
|
1720
|
+
const ppPhaseComplete = getTool(pi, "pp_phase_complete");
|
|
1721
|
+
const result = await ppPhaseComplete.execute("call-1", { summary: "checklist repaired" }, undefined, undefined, ctx);
|
|
1722
|
+
expect(result.content[0].text).toBe("");
|
|
1723
|
+
expect(orchestrator.active).toBeNull();
|
|
1724
|
+
expect(loadTask(taskDir).phase).toBe("done");
|
|
1725
|
+
});
|
|
1726
|
+
|
|
1727
|
+
it("autonomous review runs another pass when a reviewer reports a CRITICAL finding", async () => {
|
|
1728
|
+
const cwd = makeTempDir();
|
|
1729
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
1730
|
+
const ctx = makeCtx();
|
|
1731
|
+
|
|
1732
|
+
await orchestrator.startTask(ctx as any, "implement", "implement", undefined, undefined, "autonomous");
|
|
1733
|
+
const taskDir = orchestrator.active!.dir;
|
|
1734
|
+
orchestrator.active!.state.phase = "implement";
|
|
1735
|
+
orchestrator.active!.state.step = "llm_work";
|
|
1736
|
+
orchestrator.active!.state.reviewPass = 1;
|
|
1737
|
+
orchestrator.active!.state.reviewPassByKind = { implement: { auto: 1 } };
|
|
1738
|
+
orchestrator.active!.state.autonomousConfig = {
|
|
1739
|
+
phases: { implement: { reviewPreset: "regular", maxReviewPasses: 3 } },
|
|
1740
|
+
};
|
|
1741
|
+
writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
|
|
1742
|
+
writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
|
|
1743
|
+
const plansDir = join(taskDir, "plans");
|
|
1744
|
+
mkdirSync(plansDir, { recursive: true });
|
|
1745
|
+
writeFileSync(
|
|
1746
|
+
join(plansDir, "1_synthesized.md"),
|
|
1747
|
+
makeValidPlan(["- [x] P1. Done item — Done when: synthesized plan is fully checked"]),
|
|
1748
|
+
"utf-8",
|
|
1749
|
+
);
|
|
1750
|
+
|
|
1751
|
+
const reviewsDir = join(taskDir, "code-reviews");
|
|
1752
|
+
mkdirSync(reviewsDir, { recursive: true });
|
|
1753
|
+
writeFileSync(join(reviewsDir, "1_a_round-1.md"), "- CRITICAL: bug at x.ts:1\n- VERDICT: NEEDS_CHANGES", "utf-8");
|
|
1754
|
+
|
|
1755
|
+
const ppPhaseComplete = getTool(pi, "pp_phase_complete");
|
|
1756
|
+
const next = await ppPhaseComplete.execute("call-1", { summary: "fixes applied, re-review" }, undefined, undefined, ctx);
|
|
1757
|
+
expect(orchestrator.active!.state.reviewApprovedClean).toBeFalsy();
|
|
1758
|
+
expect(next.content[0].text).toMatch(/Reviews are running|Started review cycle pass/);
|
|
1759
|
+
expect(orchestrator.active!.state.reviewCycle!.pass).toBe(2);
|
|
1760
|
+
});
|
|
1761
|
+
|
|
1762
|
+
it("autonomous implement blocks review until exit criteria pass", async () => {
|
|
1763
|
+
const cwd = makeTempDir();
|
|
1764
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
1765
|
+
const ctx = makeCtx();
|
|
1766
|
+
|
|
1767
|
+
await orchestrator.startTask(ctx as any, "implement", "implement", undefined, undefined, "autonomous");
|
|
1768
|
+
const taskDir = orchestrator.active!.dir;
|
|
1769
|
+
orchestrator.active!.state.phase = "implement";
|
|
1770
|
+
orchestrator.active!.state.step = "llm_work";
|
|
1771
|
+
orchestrator.active!.state.reviewPass = 0;
|
|
1772
|
+
orchestrator.active!.state.reviewPassByKind = {};
|
|
1773
|
+
orchestrator.active!.state.autonomousConfig = {
|
|
1774
|
+
phases: { implement: { reviewPreset: "regular", maxReviewPasses: 3 } },
|
|
1775
|
+
};
|
|
1776
|
+
const plansDir = join(taskDir, "plans");
|
|
1777
|
+
mkdirSync(plansDir, { recursive: true });
|
|
1778
|
+
writeFileSync(
|
|
1779
|
+
join(plansDir, "1_synthesized.md"),
|
|
1780
|
+
makeValidPlan(["- [ ] P1. Unchecked item — Done when: this is checked"]),
|
|
1781
|
+
"utf-8",
|
|
1782
|
+
);
|
|
1783
|
+
|
|
1784
|
+
const ppPhaseComplete = getTool(pi, "pp_phase_complete");
|
|
1785
|
+
const first = await ppPhaseComplete.execute("call-1", { summary: "done" }, undefined, undefined, ctx);
|
|
1786
|
+
expect(first.content[0].text).toMatch(/Cannot start review yet/);
|
|
1787
|
+
expect(orchestrator.active!.state.step).not.toBe("await_reviewers");
|
|
1788
|
+
expect(orchestrator.active!.state.phase).toBe("implement");
|
|
1789
|
+
});
|
|
1790
|
+
|
|
1791
|
+
it("autonomous planner retries failed variants once even with partial outputs", async () => {
|
|
1792
|
+
const cwd = makeTempDir();
|
|
1793
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
1794
|
+
|
|
1795
|
+
await orchestrator.startTask({ ...makeCtx(), cwd } as any, "implement", "implement", undefined, undefined, "autonomous");
|
|
1796
|
+
const taskDir = orchestrator.active!.dir;
|
|
1797
|
+
orchestrator.active!.state.phase = "plan";
|
|
1798
|
+
orchestrator.active!.state.step = "await_planners";
|
|
1799
|
+
orchestrator.active!.state.plannerFailureAutoRetried = false;
|
|
1800
|
+
saveTask(taskDir, orchestrator.active!.state);
|
|
1801
|
+
const plansDir = join(taskDir, "plans");
|
|
1802
|
+
mkdirSync(plansDir, { recursive: true });
|
|
1803
|
+
writeFileSync(join(plansDir, `${Math.floor(Date.now() / 1000)}_test.md`), "draft", "utf-8");
|
|
1804
|
+
|
|
1805
|
+
orchestrator.failedPlannerVariants = ["test"];
|
|
1806
|
+
orchestrator.pendingSubagentSpawns = 0;
|
|
1807
|
+
emitSubagentCompleted(pi, "planner-1", "Planner (test)");
|
|
1808
|
+
|
|
1809
|
+
expect(orchestrator.active!.state.plannerFailureAutoRetried).toBe(true);
|
|
1810
|
+
});
|
|
1811
|
+
|
|
1812
|
+
it("autonomous reviewer retries failed variants once even with partial outputs", async () => {
|
|
1813
|
+
const cwd = makeTempDir();
|
|
1814
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
1815
|
+
|
|
1816
|
+
await orchestrator.startTask({ ...makeCtx(), cwd } as any, "implement", "implement", undefined, undefined, "autonomous");
|
|
1817
|
+
const taskDir = orchestrator.active!.dir;
|
|
1818
|
+
orchestrator.active!.state.phase = "implement";
|
|
1819
|
+
orchestrator.active!.state.step = "await_reviewers";
|
|
1820
|
+
orchestrator.active!.state.reviewCycle = { kind: "auto", step: "await_reviewers", pass: 1 };
|
|
1821
|
+
orchestrator.active!.state.reviewerFailureAutoRetried = false;
|
|
1822
|
+
saveTask(taskDir, orchestrator.active!.state);
|
|
1823
|
+
const reviewsDir = join(taskDir, "code-reviews");
|
|
1824
|
+
mkdirSync(reviewsDir, { recursive: true });
|
|
1825
|
+
writeFileSync(join(reviewsDir, `${Math.floor(Date.now() / 1000)}_test_round-1.md`), "partial", "utf-8");
|
|
1826
|
+
|
|
1827
|
+
orchestrator.failedReviewerVariants = ["test"];
|
|
1828
|
+
orchestrator.pendingSubagentSpawns = 0;
|
|
1829
|
+
emitSubagentCompleted(pi, "reviewer-1", "Code reviewer (test)");
|
|
1830
|
+
|
|
1831
|
+
expect(orchestrator.active!.state.reviewerFailureAutoRetried).toBe(true);
|
|
1832
|
+
});
|
|
1833
|
+
|
|
1834
|
+
it("mode picker Back returns to previous menu and does not start guided task", async () => {
|
|
1835
|
+
const cwd = makeTempDir();
|
|
1836
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
1837
|
+
const ctx = makeCtx();
|
|
1838
|
+
|
|
1839
|
+
menu
|
|
1840
|
+
.expect({ question: "/pp", options: { include: ["Task"] }, choose: "Task" })
|
|
1841
|
+
.expect({ question: "Task", options: { include: ["Implement"] }, choose: "Implement" })
|
|
1842
|
+
.expect({ question: "Implement", options: { include: ["New"] }, choose: "New" })
|
|
1843
|
+
.expect({ question: "Mode", options: { include: ["Back"] }, choose: "Back" })
|
|
1844
|
+
.expect({ question: "Implement", options: { include: ["Resume", "Back"] }, choose: "Resume" })
|
|
1845
|
+
.expect({ question: "Implement", options: { include: ["Back"] }, choose: "Back" })
|
|
1846
|
+
.expect({ question: "Task", options: { include: ["Back"] }, choose: "Back" })
|
|
1847
|
+
.expect({ question: "/pp", options: { include: ["Back"] }, choose: "Back" });
|
|
1848
|
+
const pp = getCommand(pi, "pp");
|
|
1849
|
+
await pp(undefined, ctx);
|
|
1850
|
+
|
|
1851
|
+
expect(orchestrator.active).toBeNull();
|
|
1852
|
+
});
|
|
1853
|
+
|
|
1854
|
+
it("brainstorm continue uses implement autonomous phase defaults", async () => {
|
|
1855
|
+
const cwd = makeTempDir();
|
|
1856
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
1857
|
+
const ctx = makeCtx();
|
|
1858
|
+
|
|
1859
|
+
await orchestrator.startTask(ctx as any, "brainstorm", "brainstorm");
|
|
1860
|
+
const taskDir = orchestrator.active!.dir;
|
|
1861
|
+
writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
|
|
1862
|
+
writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
|
|
1863
|
+
|
|
1864
|
+
menu
|
|
1865
|
+
.expect({ question: m.anyTaskMenu, options: { include: ["Next"] }, choose: "Next" })
|
|
1866
|
+
.expect({ question: "Next", options: { include: ["Continue to plan & implement"] }, choose: "Continue to plan & implement" })
|
|
1867
|
+
.expect({ question: "Mode", options: { include: ["Autonomous"] }, choose: "Autonomous" })
|
|
1868
|
+
.expect({ question: "Autonomous", options: { include: ["Start"] }, choose: "Start" });
|
|
1869
|
+
const ppPhaseComplete = getTool(pi, "pp_phase_complete");
|
|
1870
|
+
await ppPhaseComplete.execute("call-1", { summary: "Conclusions ready" }, undefined, undefined, ctx);
|
|
1871
|
+
|
|
1872
|
+
expect(orchestrator.active!.state.autonomousConfig?.phases.plan).toBeDefined();
|
|
1873
|
+
expect(orchestrator.active!.state.autonomousConfig?.phases.implement).toBeDefined();
|
|
1874
|
+
expect(orchestrator.active!.state.autonomousConfig?.phases.brainstorm).toBeUndefined();
|
|
1875
|
+
});
|
|
1876
|
+
|
|
1877
|
+
it("from-task implement sets initialPhase plan and ask_user is blocked in autonomous plan", async () => {
|
|
1878
|
+
const cwd = makeTempDir();
|
|
1879
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
1880
|
+
|
|
1881
|
+
await orchestrator.startTask({ ...makeCtx(), cwd } as any, "debug", "Find bug");
|
|
1882
|
+
const debugDir = orchestrator.active!.dir;
|
|
1883
|
+
writeFileSync(join(debugDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
|
|
1884
|
+
writeFileSync(join(debugDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
|
|
1885
|
+
|
|
1886
|
+
await orchestrator.startTask({ ...makeCtx(), cwd } as any, "implement", "implement", debugDir, true, "autonomous");
|
|
1887
|
+
expect(orchestrator.active!.state.initialPhase).toBe("plan");
|
|
1888
|
+
expect(orchestrator.active!.state.phase).toBe("plan");
|
|
1889
|
+
|
|
1890
|
+
const toolCall = pi._handlers.get("tool_call")!;
|
|
1891
|
+
const result = await toolCall({ toolName: "ask_user", input: {} }, {});
|
|
1892
|
+
expect(result).toEqual({ block: true, reason: "Autonomous mode — make your best judgment based on available context." });
|
|
1893
|
+
});
|
|
1894
|
+
|
|
1895
|
+
it("autonomous prompt is full-replace with constraints first and mode-aware completion", async () => {
|
|
1896
|
+
const cwd = makeTempDir();
|
|
1897
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
1898
|
+
const ctx = makeCtx();
|
|
1899
|
+
|
|
1900
|
+
await orchestrator.startTask({ ...ctx, cwd } as any, "debug", "Find bug");
|
|
1901
|
+
const debugDir = orchestrator.active!.dir;
|
|
1902
|
+
writeFileSync(join(debugDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
|
|
1903
|
+
writeFileSync(join(debugDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
|
|
1904
|
+
await orchestrator.startTask({ ...ctx, cwd } as any, "implement", "implement", debugDir, true, "autonomous");
|
|
1905
|
+
orchestrator.active!.state.phase = "implement";
|
|
1906
|
+
orchestrator.active!.state.step = "llm_work";
|
|
1907
|
+
|
|
1908
|
+
const beforeStart = pi._handlers.get("before_agent_start")!;
|
|
1909
|
+
const result = await beforeStart({ systemPrompt: "HARNESS_BASE_PROMPT" }, ctx);
|
|
1910
|
+
const prompt = result?.systemPrompt ?? "";
|
|
1911
|
+
expect(prompt.startsWith("<constraints>")).toBe(true);
|
|
1912
|
+
expect(prompt).not.toContain("HARNESS_BASE_PROMPT");
|
|
1913
|
+
expect(prompt).toContain("The moment its work is complete, call pp_phase_complete");
|
|
1914
|
+
// No interactive '/pp menu' advance guidance in autonomous mode.
|
|
1915
|
+
expect(prompt).not.toContain("/pp menu");
|
|
1916
|
+
expect(prompt).not.toContain("advance it via");
|
|
1917
|
+
});
|
|
1918
|
+
|
|
1919
|
+
it("guided read-only phase prompt is XML macro-blocks with month/year + cwd and interactive completion", async () => {
|
|
1920
|
+
const cwd = makeTempDir();
|
|
1921
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
1922
|
+
const ctx = makeCtx();
|
|
1923
|
+
|
|
1924
|
+
await orchestrator.startTask({ ...ctx, cwd } as any, "implement", "implement", undefined, undefined, "guided");
|
|
1925
|
+
orchestrator.active!.state.phase = "plan";
|
|
1926
|
+
orchestrator.active!.state.step = "synthesize";
|
|
1927
|
+
|
|
1928
|
+
const beforeStart = pi._handlers.get("before_agent_start")!;
|
|
1929
|
+
const prompt = (await beforeStart({ systemPrompt: "HARNESS_BASE_PROMPT" }, ctx))?.systemPrompt ?? "";
|
|
1930
|
+
expect(prompt.startsWith("<constraints>")).toBe(true);
|
|
1931
|
+
expect(prompt).toContain("ACTIVE PHASE: plan (READ-ONLY)");
|
|
1932
|
+
expect(prompt).toContain("<principles>");
|
|
1933
|
+
expect(prompt).toContain("<tools>");
|
|
1934
|
+
expect(prompt).toContain("<task>");
|
|
1935
|
+
expect(prompt).toContain("let the user review and advance it via the /pp menu");
|
|
1936
|
+
expect(prompt).not.toContain("HARNESS_BASE_PROMPT");
|
|
1937
|
+
expect(prompt).toContain(`Working directory: ${cwd}.`);
|
|
1938
|
+
expect(prompt).toMatch(/Current month: \d{4}-\d{2}\./);
|
|
1939
|
+
});
|
|
1940
|
+
|
|
1941
|
+
it("first phase of an autonomous task stays interactive (ask_user allowed, interactive prompt)", async () => {
|
|
1942
|
+
const cwd = makeTempDir();
|
|
1943
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
1944
|
+
const ctx = makeCtx();
|
|
1945
|
+
|
|
1946
|
+
await orchestrator.startTask({ ...ctx, cwd } as any, "implement", "implement", undefined, undefined, "autonomous");
|
|
1947
|
+
// Task starts at its first phase (brainstorm) with task mode = autonomous.
|
|
1948
|
+
expect(orchestrator.active!.state.phase).toBe("brainstorm");
|
|
1949
|
+
|
|
1950
|
+
const toolCall = pi._handlers.get("tool_call")!;
|
|
1951
|
+
expect(await toolCall({ toolName: "ask_user", input: {} }, {})).toBeUndefined();
|
|
1952
|
+
|
|
1953
|
+
const beforeStart = pi._handlers.get("before_agent_start")!;
|
|
1954
|
+
const prompt = (await beforeStart({ systemPrompt: "base" }, ctx))?.systemPrompt ?? "";
|
|
1955
|
+
expect(prompt).not.toContain("There is no user driving this phase");
|
|
1956
|
+
|
|
1957
|
+
// Later autonomous phase (implement) is forcing and blocks ask_user.
|
|
1958
|
+
orchestrator.active!.state.phase = "implement";
|
|
1959
|
+
orchestrator.active!.state.step = "llm_work";
|
|
1960
|
+
expect(await toolCall({ toolName: "ask_user", input: {} }, {})).toEqual({
|
|
1961
|
+
block: true,
|
|
1962
|
+
reason: "Autonomous mode — make your best judgment based on available context.",
|
|
1963
|
+
});
|
|
1964
|
+
const implPrompt = (await beforeStart({ systemPrompt: "base" }, ctx))?.systemPrompt ?? "";
|
|
1965
|
+
expect(implPrompt).toContain("There is no user driving this phase");
|
|
1966
|
+
});
|
|
1967
|
+
|
|
1968
|
+
it("quick task completion line tells the agent to call pp_phase_complete", async () => {
|
|
1969
|
+
const cwd = makeTempDir();
|
|
1970
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
1971
|
+
const ctx = makeCtx();
|
|
1972
|
+
|
|
1973
|
+
await orchestrator.startTask({ ...ctx, cwd } as any, "quick", "quick");
|
|
1974
|
+
expect(orchestrator.active!.state.phase).toBe("quick");
|
|
1975
|
+
|
|
1976
|
+
const beforeStart = pi._handlers.get("before_agent_start")!;
|
|
1977
|
+
const prompt = (await beforeStart({ systemPrompt: "base" }, ctx))?.systemPrompt ?? "";
|
|
1978
|
+
expect(prompt).toContain("call pp_phase_complete");
|
|
1979
|
+
expect(prompt).not.toContain("advance it via the /pp menu");
|
|
1980
|
+
});
|
|
1981
|
+
|
|
1982
|
+
it("autonomous plan-phase prompt body contains no /pp guidance", async () => {
|
|
1983
|
+
const cwd = makeTempDir();
|
|
1984
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
1985
|
+
const ctx = makeCtx();
|
|
1986
|
+
|
|
1987
|
+
await orchestrator.startTask({ ...ctx, cwd } as any, "implement", "implement", undefined, undefined, "autonomous");
|
|
1988
|
+
orchestrator.active!.state.phase = "plan";
|
|
1989
|
+
orchestrator.active!.state.step = "synthesize";
|
|
1990
|
+
|
|
1991
|
+
const beforeStart = pi._handlers.get("before_agent_start")!;
|
|
1992
|
+
const prompt = (await beforeStart({ systemPrompt: "base" }, ctx))?.systemPrompt ?? "";
|
|
1993
|
+
expect(prompt).not.toContain("/pp");
|
|
1994
|
+
});
|
|
1995
|
+
|
|
1996
|
+
it("persists retry bookkeeping flags in task state", async () => {
|
|
1997
|
+
const cwd = makeTempDir();
|
|
1998
|
+
const { orchestrator } = await setupOrchestrator(cwd);
|
|
1999
|
+
const ctx = makeCtx();
|
|
2000
|
+
|
|
2001
|
+
await orchestrator.startTask(ctx as any, "implement", "implement", undefined, undefined, "autonomous");
|
|
2002
|
+
const taskDir = orchestrator.active!.dir;
|
|
2003
|
+
const state = loadTask(taskDir);
|
|
2004
|
+
expect(state.plannerFailureAutoRetried).toBe(false);
|
|
2005
|
+
expect(state.reviewerFailureAutoRetried).toBe(false);
|
|
2006
|
+
});
|
|
2007
|
+
|
|
2008
|
+
it("blocks ask_user in autonomous mode after first phase", async () => {
|
|
2009
|
+
const cwd = makeTempDir();
|
|
2010
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
2011
|
+
|
|
2012
|
+
await orchestrator.startTask({ ...makeCtx(), cwd } as any, "implement", "implement", undefined, undefined, "autonomous");
|
|
2013
|
+
orchestrator.active!.state.phase = "plan";
|
|
2014
|
+
const toolCall = pi._handlers.get("tool_call")!;
|
|
2015
|
+
|
|
2016
|
+
const result = await toolCall({ toolName: "ask_user", input: {} }, {});
|
|
2017
|
+
expect(result).toEqual({ block: true, reason: "Autonomous mode — make your best judgment based on available context." });
|
|
2018
|
+
});
|
|
2019
|
+
|
|
2020
|
+
it("blocks ask_user whenever effective mode is autonomous", async () => {
|
|
2021
|
+
const cwd = makeTempDir();
|
|
2022
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
2023
|
+
|
|
2024
|
+
await orchestrator.startTask({ ...makeCtx(), cwd } as any, "implement", "implement", undefined, undefined, "autonomous");
|
|
2025
|
+
orchestrator.active!.state.phase = "implement";
|
|
2026
|
+
const toolCall = pi._handlers.get("tool_call")!;
|
|
2027
|
+
|
|
2028
|
+
const result = await toolCall({ toolName: "ask_user", input: {} }, {});
|
|
2029
|
+
expect(result).toEqual({ block: true, reason: "Autonomous mode — make your best judgment based on available context." });
|
|
2030
|
+
});
|
|
2031
|
+
|
|
2032
|
+
it("resume preserves autonomous mode", async () => {
|
|
2033
|
+
const cwd = makeTempDir();
|
|
2034
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
2035
|
+
const ctx = makeCtx();
|
|
2036
|
+
|
|
2037
|
+
await orchestrator.startTask(ctx as any, "implement", "implement", undefined, undefined, "autonomous");
|
|
2038
|
+
const taskDir = orchestrator.active!.dir;
|
|
2039
|
+
saveTask(taskDir, orchestrator.active!.state);
|
|
2040
|
+
await orchestrator.cleanupActive();
|
|
2041
|
+
|
|
2042
|
+
const title = "implement";
|
|
2043
|
+
const pp = getCommand(pi, "pp");
|
|
2044
|
+
menu
|
|
2045
|
+
.expect({ question: "/pp", options: { include: ["Task"] }, choose: "Task" })
|
|
2046
|
+
.expect({ question: "Task", options: { include: ["Resume"] }, choose: "Resume" })
|
|
2047
|
+
.expect({ question: "Resume", options: { include: [title] }, choose: title });
|
|
2048
|
+
await pp(undefined, ctx);
|
|
2049
|
+
|
|
2050
|
+
expect(orchestrator.active).not.toBeNull();
|
|
2051
|
+
expect(orchestrator.active!.state.mode).toBe("autonomous");
|
|
2052
|
+
});
|
|
2053
|
+
|
|
2054
|
+
it("resume preserves autonomousConfig", async () => {
|
|
2055
|
+
const cwd = makeTempDir();
|
|
2056
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
2057
|
+
const ctx = makeCtx();
|
|
2058
|
+
|
|
2059
|
+
await orchestrator.startTask(ctx as any, "implement", "resume autonomous config", undefined, undefined, "autonomous");
|
|
2060
|
+
const taskDir = orchestrator.active!.dir;
|
|
2061
|
+
orchestrator.active!.state.autonomousConfig = {
|
|
2062
|
+
phases: {
|
|
2063
|
+
brainstorm: { reviewPreset: "regular", maxReviewPasses: 1 },
|
|
2064
|
+
plan: { plannerPreset: "regular", reviewPreset: "regular", maxReviewPasses: 2 },
|
|
2065
|
+
implement: { reviewPreset: "regular", maxReviewPasses: 3 },
|
|
2066
|
+
},
|
|
2067
|
+
};
|
|
2068
|
+
saveTask(taskDir, orchestrator.active!.state);
|
|
2069
|
+
await orchestrator.cleanupActive();
|
|
2070
|
+
|
|
2071
|
+
const pp = getCommand(pi, "pp");
|
|
2072
|
+
menu
|
|
2073
|
+
.expect({ question: "/pp", options: { include: ["Task"] }, choose: "Task" })
|
|
2074
|
+
.expect({ question: "Task", options: { include: ["Resume"] }, choose: "Resume" })
|
|
2075
|
+
.expect({ question: "Resume", options: { include: ["resume autonomous config"] }, choose: "resume autonomous config" });
|
|
2076
|
+
await pp(undefined, ctx);
|
|
2077
|
+
|
|
2078
|
+
expect(orchestrator.active).not.toBeNull();
|
|
2079
|
+
expect(orchestrator.active!.state.autonomousConfig).toEqual({
|
|
2080
|
+
phases: {
|
|
2081
|
+
brainstorm: { reviewPreset: "regular", maxReviewPasses: 1 },
|
|
2082
|
+
plan: { plannerPreset: "regular", reviewPreset: "regular", maxReviewPasses: 2 },
|
|
2083
|
+
implement: { reviewPreset: "regular", maxReviewPasses: 3 },
|
|
2084
|
+
},
|
|
2085
|
+
});
|
|
2086
|
+
});
|
|
2087
|
+
|
|
2088
|
+
it("autonomous mode skips planner preset picker during transition", async () => {
|
|
2089
|
+
const cwd = makeTempDir();
|
|
2090
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
2091
|
+
const ctx = makeCtx();
|
|
2092
|
+
|
|
2093
|
+
await orchestrator.startTask(ctx as any, "implement", "implement", undefined, undefined, "autonomous");
|
|
2094
|
+
const taskDir = orchestrator.active!.dir;
|
|
2095
|
+
orchestrator.active!.state.autonomousConfig = {
|
|
2096
|
+
phases: {
|
|
2097
|
+
brainstorm: { reviewPreset: "regular", maxReviewPasses: 0 },
|
|
2098
|
+
plan: { plannerPreset: "regular", reviewPreset: "regular", maxReviewPasses: 0 },
|
|
2099
|
+
implement: { reviewPreset: "regular", maxReviewPasses: 0 },
|
|
2100
|
+
},
|
|
2101
|
+
};
|
|
2102
|
+
writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
|
|
2103
|
+
writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
|
|
2104
|
+
|
|
2105
|
+
const ppPhaseComplete = getTool(pi, "pp_phase_complete");
|
|
2106
|
+
await ppPhaseComplete.execute("call-autonomous-skip-planner", { summary: "done" }, undefined, undefined, ctx);
|
|
2107
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
2108
|
+
|
|
2109
|
+
expect(menu.transcript.filter((entry) => entry.question.includes("Planner preset"))).toHaveLength(0);
|
|
2110
|
+
});
|
|
2111
|
+
|
|
2112
|
+
it("autonomous first review triggers automatically and second step proceeds after reviewer output", async () => {
|
|
2113
|
+
const cwd = makeTempDir();
|
|
2114
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
2115
|
+
const ctx = makeCtx();
|
|
2116
|
+
|
|
2117
|
+
await orchestrator.startTask(ctx as any, "implement", "implement", undefined, undefined, "autonomous");
|
|
2118
|
+
const taskDir = orchestrator.active!.dir;
|
|
2119
|
+
orchestrator.active!.state.phase = "implement";
|
|
2120
|
+
orchestrator.active!.state.step = "llm_work";
|
|
2121
|
+
orchestrator.active!.state.autonomousConfig = {
|
|
2122
|
+
phases: {
|
|
2123
|
+
implement: { reviewPreset: "regular", maxReviewPasses: 2 },
|
|
2124
|
+
},
|
|
2125
|
+
};
|
|
2126
|
+
writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
|
|
2127
|
+
writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
|
|
2128
|
+
mkdirSync(join(taskDir, "plans"), { recursive: true });
|
|
2129
|
+
writeFileSync(
|
|
2130
|
+
join(taskDir, "plans", "1_synthesized.md"),
|
|
2131
|
+
makeValidPlan(["- [x] P1. Done item — Done when: synthesized plan is fully checked"]),
|
|
2132
|
+
"utf-8",
|
|
2133
|
+
);
|
|
2134
|
+
|
|
2135
|
+
const ppPhaseComplete = getTool(pi, "pp_phase_complete");
|
|
2136
|
+
const first = await ppPhaseComplete.execute("call-autonomous-review-1", { summary: "done" }, undefined, undefined, ctx);
|
|
2137
|
+
expect(first.content[0].text).toMatch(/Reviews are running|Started review cycle pass/);
|
|
2138
|
+
expect(orchestrator.active!.state.reviewCycle).not.toBeNull();
|
|
2139
|
+
expect(orchestrator.active!.state.reviewCycle?.pass).toBe(1);
|
|
2140
|
+
expect(["await_reviewers", "apply_feedback"]).toContain(orchestrator.active!.state.reviewCycle?.step);
|
|
2141
|
+
|
|
2142
|
+
const reviewsDir = join(taskDir, "code-reviews");
|
|
2143
|
+
mkdirSync(reviewsDir, { recursive: true });
|
|
2144
|
+
for (const v of ["opus", "gpt", "gemini"]) {
|
|
2145
|
+
writeFileSync(join(reviewsDir, `1_${v}_round-1.md`), "VERDICT: APPROVE\n- CRITICAL: none", "utf-8");
|
|
2146
|
+
}
|
|
2147
|
+
emitSubagentCreated(pi, "reviewer-auto-1", "Code reviewer (test)");
|
|
2148
|
+
emitSubagentCompleted(pi, "reviewer-auto-1", "Code reviewer (test)");
|
|
2149
|
+
|
|
2150
|
+
const second = await ppPhaseComplete.execute("call-autonomous-review-2", { summary: "applied" }, undefined, undefined, ctx);
|
|
2151
|
+
expect(second.content[0].text).toBe("");
|
|
2152
|
+
expect(orchestrator.active).toBeNull();
|
|
2153
|
+
});
|
|
2154
|
+
|
|
2155
|
+
it("switching to guided during await_reviewers shows reviewer failure dialog", async () => {
|
|
2156
|
+
const cwd = makeTempDir();
|
|
2157
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
2158
|
+
const ctx = makeCtx();
|
|
2159
|
+
|
|
2160
|
+
await orchestrator.startTask(ctx as any, "implement", "implement", undefined, undefined, "autonomous");
|
|
2161
|
+
orchestrator.active!.state.phase = "implement";
|
|
2162
|
+
orchestrator.active!.state.step = "await_reviewers";
|
|
2163
|
+
orchestrator.active!.state.reviewCycle = { kind: "auto", step: "await_reviewers", pass: 1 };
|
|
2164
|
+
orchestrator.active!.state.effectiveMode = "guided";
|
|
2165
|
+
orchestrator.failedReviewerVariants = ["test"];
|
|
2166
|
+
orchestrator.pendingSubagentSpawns = 0;
|
|
2167
|
+
saveTask(orchestrator.active!.dir, orchestrator.active!.state);
|
|
2168
|
+
|
|
2169
|
+
menu.expect({
|
|
2170
|
+
question: /Some reviewers failed:/,
|
|
2171
|
+
options: {
|
|
2172
|
+
exact: ["Retry failed reviewers", "Work with available reviewer outputs", "Continue without review", "Stop task"],
|
|
2173
|
+
},
|
|
2174
|
+
choose: "Continue without review",
|
|
2175
|
+
});
|
|
2176
|
+
emitSubagentCompleted(pi, "reviewer-guided-switch", "Code reviewer (test)");
|
|
2177
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
2178
|
+
|
|
2179
|
+
expect(menu.transcript.some((entry) => entry.question.includes("Some reviewers failed:"))).toBe(true);
|
|
2180
|
+
});
|
|
2181
|
+
|
|
2182
|
+
it("review task autonomous config covers only plan/implement, not the review first phase", async () => {
|
|
2183
|
+
const cwd = makeTempDir();
|
|
2184
|
+
const pi = makePi();
|
|
2185
|
+
const orchestrator = new Orchestrator(pi as any);
|
|
2186
|
+
registerEventHandlers(orchestrator);
|
|
2187
|
+
registerCommandHandlers(orchestrator);
|
|
2188
|
+
const ctx = makeCtx({ cwd });
|
|
2189
|
+
|
|
2190
|
+
const sessionStartHandler = pi._handlers.get("session_start")!;
|
|
2191
|
+
await sessionStartHandler({}, ctx);
|
|
2192
|
+
|
|
2193
|
+
menu
|
|
2194
|
+
.expect({ question: "/pp", options: { include: ["Task"] }, choose: "Task" })
|
|
2195
|
+
.expect({ question: "Task", options: { include: ["Review"] }, choose: "Review" })
|
|
2196
|
+
.expect({ question: "Review", options: { include: ["Describe"] }, choose: "Describe" })
|
|
2197
|
+
.expect({ question: "Mode", options: { include: ["Autonomous"] }, choose: "Autonomous" })
|
|
2198
|
+
.expect({ question: "Autonomous", options: { include: ["Start"] }, choose: "Start" });
|
|
2199
|
+
ctx.ui.input.mockResolvedValueOnce("Review current branch changes");
|
|
2200
|
+
const pp = getCommand(pi, "pp");
|
|
2201
|
+
await pp(undefined, ctx);
|
|
2202
|
+
|
|
2203
|
+
expect(orchestrator.active!.type).toBe("review");
|
|
2204
|
+
expect(orchestrator.active!.state.autonomousConfig?.phases.review).toBeUndefined();
|
|
2205
|
+
expect(orchestrator.active!.state.autonomousConfig?.phases.implement?.reviewPreset).toBe("regular");
|
|
2206
|
+
});
|
|
2207
|
+
|
|
2208
|
+
it("quick task does not track modified files", async () => {
|
|
2209
|
+
const cwd = makeTempDir();
|
|
2210
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
2211
|
+
|
|
2212
|
+
await orchestrator.startTask(makeCtx() as any, "quick", "quick");
|
|
2213
|
+
const toolResult = pi._handlers.get("tool_result")!;
|
|
2214
|
+
await toolResult({ toolName: "write", input: { path: "src/quick.ts" }, isError: false, content: [] }, {});
|
|
2215
|
+
|
|
2216
|
+
expect(orchestrator.active!.modifiedFiles.size).toBe(0);
|
|
2217
|
+
expect(orchestrator.active!.state.modifiedFiles ?? []).toEqual([]);
|
|
2218
|
+
});
|
|
2219
|
+
|
|
2220
|
+
it("quick task does not run afterEdit", async () => {
|
|
2221
|
+
const cwd = makeTempDir();
|
|
2222
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
2223
|
+
const runAfterEditSpy = vi.spyOn(commandsModule, "runAfterEdit");
|
|
2224
|
+
|
|
2225
|
+
await orchestrator.startTask(makeCtx() as any, "quick", "quick");
|
|
2226
|
+
const toolResult = pi._handlers.get("tool_result")!;
|
|
2227
|
+
await toolResult({ toolName: "write", input: { path: "src/quick.ts" }, isError: false, content: [] }, {});
|
|
2228
|
+
|
|
2229
|
+
expect(runAfterEditSpy).not.toHaveBeenCalled();
|
|
2230
|
+
});
|
|
2231
|
+
|
|
2232
|
+
it("autonomous ask_user blocked in plan and implement", async () => {
|
|
2233
|
+
const cwd = makeTempDir();
|
|
2234
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
2235
|
+
|
|
2236
|
+
await orchestrator.startTask({ ...makeCtx(), cwd } as any, "implement", "implement", undefined, undefined, "autonomous");
|
|
2237
|
+
const toolCall = pi._handlers.get("tool_call")!;
|
|
2238
|
+
const blocked = { block: true, reason: "Autonomous mode — make your best judgment based on available context." };
|
|
2239
|
+
|
|
2240
|
+
orchestrator.active!.state.phase = "plan";
|
|
2241
|
+
expect(await toolCall({ toolName: "ask_user", input: {} }, {})).toEqual(blocked);
|
|
2242
|
+
|
|
2243
|
+
orchestrator.active!.state.phase = "implement";
|
|
2244
|
+
expect(await toolCall({ toolName: "ask_user", input: {} }, {})).toEqual(blocked);
|
|
2245
|
+
});
|
|
2246
|
+
});
|
|
2247
|
+
|
|
2248
|
+
describe("review task lifecycle", () => {
|
|
2249
|
+
it("review task starts in review phase", async () => {
|
|
2250
|
+
const cwd = makeTempDir();
|
|
2251
|
+
const { orchestrator } = await setupOrchestrator(cwd);
|
|
2252
|
+
const ctx = makeCtx();
|
|
2253
|
+
|
|
2254
|
+
await orchestrator.startTask(ctx as any, "review", "Review changes");
|
|
2255
|
+
|
|
2256
|
+
expect(orchestrator.active!.type).toBe("review");
|
|
2257
|
+
expect(orchestrator.active!.state.phase).toBe("review");
|
|
2258
|
+
expect(orchestrator.active!.state.step).toBe("llm_work");
|
|
2259
|
+
});
|
|
2260
|
+
|
|
2261
|
+
it("review task transitions review to plan to implement to done", async () => {
|
|
2262
|
+
const cwd = makeTempDir();
|
|
2263
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
2264
|
+
const ctx = makeCtx();
|
|
2265
|
+
|
|
2266
|
+
await orchestrator.startTask(ctx as any, "review", "Review flow");
|
|
2267
|
+
const taskDir = orchestrator.active!.dir;
|
|
2268
|
+
|
|
2269
|
+
await moveTaskToImplementPhase(pi, orchestrator, ctx, "call-review-to-plan", "call-plan-to-implement");
|
|
2270
|
+
expect(orchestrator.active!.state.phase).toBe("implement");
|
|
2271
|
+
|
|
2272
|
+
expectImplementToDone(menu);
|
|
2273
|
+
const ppPhaseComplete = getTool(pi, "pp_phase_complete");
|
|
2274
|
+
await ppPhaseComplete.execute("call-implement-to-done", { summary: "done" }, undefined, undefined, ctx);
|
|
2275
|
+
|
|
2276
|
+
expect(orchestrator.active).toBeNull();
|
|
2277
|
+
expect(loadTask(taskDir).phase).toBe("done");
|
|
2278
|
+
});
|
|
2279
|
+
|
|
2280
|
+
it("review phase exit criteria requires USER_REQUEST and RESEARCH", async () => {
|
|
2281
|
+
const cwd = makeTempDir();
|
|
2282
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
2283
|
+
const ctx = makeCtx();
|
|
2284
|
+
|
|
2285
|
+
await orchestrator.startTask(ctx as any, "review", "Review validation");
|
|
2286
|
+
|
|
2287
|
+
expectBrainstormToPlan(menu);
|
|
2288
|
+
const ppPhaseComplete = getTool(pi, "pp_phase_complete");
|
|
2289
|
+
const result = await ppPhaseComplete.execute("call-review-validation", { summary: "done" }, undefined, undefined, ctx);
|
|
2290
|
+
|
|
2291
|
+
expect(result.content[0].text).toContain("Transition blocked");
|
|
2292
|
+
expect(result.content[0].text).toContain("USER_REQUEST.md");
|
|
2293
|
+
expect(orchestrator.active!.state.phase).toBe("review");
|
|
2294
|
+
});
|
|
2295
|
+
});
|
|
2296
|
+
|
|
2297
|
+
describe("debug task lifecycle", () => {
|
|
2298
|
+
it("debug task starts in debug phase", async () => {
|
|
2299
|
+
const cwd = makeTempDir();
|
|
2300
|
+
const { orchestrator } = await setupOrchestrator(cwd);
|
|
2301
|
+
|
|
2302
|
+
await orchestrator.startTask(makeCtx() as any, "debug", "Debug issue");
|
|
2303
|
+
|
|
2304
|
+
expect(orchestrator.active!.type).toBe("debug");
|
|
2305
|
+
expect(orchestrator.active!.state.phase).toBe("debug");
|
|
2306
|
+
});
|
|
2307
|
+
|
|
2308
|
+
it("debug task transitions debug to plan to implement to done", async () => {
|
|
2309
|
+
const cwd = makeTempDir();
|
|
2310
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
2311
|
+
const ctx = makeCtx();
|
|
2312
|
+
|
|
2313
|
+
await orchestrator.startTask(ctx as any, "debug", "Debug flow");
|
|
2314
|
+
const taskDir = orchestrator.active!.dir;
|
|
2315
|
+
|
|
2316
|
+
await moveTaskToImplementPhase(pi, orchestrator, ctx, "call-debug-to-plan", "call-debug-plan-to-implement");
|
|
2317
|
+
expect(orchestrator.active!.state.phase).toBe("implement");
|
|
2318
|
+
|
|
2319
|
+
expectImplementToDone(menu);
|
|
2320
|
+
const ppPhaseComplete = getTool(pi, "pp_phase_complete");
|
|
2321
|
+
await ppPhaseComplete.execute("call-debug-implement-to-done", { summary: "done" }, undefined, undefined, ctx);
|
|
2322
|
+
|
|
2323
|
+
expect(orchestrator.active).toBeNull();
|
|
2324
|
+
expect(loadTask(taskDir).phase).toBe("done");
|
|
2325
|
+
});
|
|
2326
|
+
});
|
|
2327
|
+
|
|
2328
|
+
describe("modified file tracking", () => {
|
|
2329
|
+
it("tool_result tracks write and edit in implement phase", async () => {
|
|
2330
|
+
const cwd = makeTempDir();
|
|
2331
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
2332
|
+
const ctx = makeCtx();
|
|
2333
|
+
|
|
2334
|
+
await orchestrator.startTask(ctx as any, "implement", "Track writes");
|
|
2335
|
+
await moveTaskToImplementPhase(pi, orchestrator, ctx, "call-track-brainstorm", "call-track-plan");
|
|
2336
|
+
|
|
2337
|
+
const toolResult = pi._handlers.get("tool_result")!;
|
|
2338
|
+
await toolResult({ toolName: "write", input: { path: "src/a.ts" }, isError: false, content: [] }, {});
|
|
2339
|
+
await toolResult({ toolName: "edit", input: { path: "src/b.ts" }, isError: false, content: [] }, {});
|
|
2340
|
+
|
|
2341
|
+
expect(orchestrator.active!.modifiedFiles.has(join(cwd, "src", "a.ts"))).toBe(true);
|
|
2342
|
+
expect(orchestrator.active!.modifiedFiles.has(join(cwd, "src", "b.ts"))).toBe(true);
|
|
2343
|
+
});
|
|
2344
|
+
|
|
2345
|
+
it("source write clears reviewApprovedClean so post-approval edits get re-reviewed", async () => {
|
|
2346
|
+
const cwd = makeTempDir();
|
|
2347
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
2348
|
+
const ctx = makeCtx();
|
|
2349
|
+
|
|
2350
|
+
await orchestrator.startTask(ctx as any, "implement", "Stale flag");
|
|
2351
|
+
await moveTaskToImplementPhase(pi, orchestrator, ctx, "call-stale-brainstorm", "call-stale-plan");
|
|
2352
|
+
orchestrator.active!.state.reviewApprovedClean = true;
|
|
2353
|
+
|
|
2354
|
+
const toolResult = pi._handlers.get("tool_result")!;
|
|
2355
|
+
await toolResult({ toolName: "write", input: { path: "src/c.ts" }, isError: false, content: [] }, {});
|
|
2356
|
+
|
|
2357
|
+
expect(orchestrator.active!.state.reviewApprovedClean).toBe(false);
|
|
2358
|
+
});
|
|
2359
|
+
|
|
2360
|
+
it("tool_result ignores writes inside .pp directory", async () => {
|
|
2361
|
+
const cwd = makeTempDir();
|
|
2362
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
2363
|
+
const runAfterEditSpy = vi.spyOn(commandsModule, "runAfterEdit");
|
|
2364
|
+
|
|
2365
|
+
await orchestrator.startTask(makeCtx() as any, "implement", "Ignore .pp writes");
|
|
2366
|
+
orchestrator.active!.state.phase = "implement";
|
|
2367
|
+
const toolResult = pi._handlers.get("tool_result")!;
|
|
2368
|
+
await toolResult({ toolName: "write", input: { path: ".pp/state/implement/x/note.md" }, isError: false, content: [] }, {});
|
|
2369
|
+
|
|
2370
|
+
expect(orchestrator.active!.modifiedFiles.size).toBe(0);
|
|
2371
|
+
expect(runAfterEditSpy).not.toHaveBeenCalled();
|
|
2372
|
+
});
|
|
2373
|
+
|
|
2374
|
+
it("tool_result ignores writes outside implement phase", async () => {
|
|
2375
|
+
const cwd = makeTempDir();
|
|
2376
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
2377
|
+
const runAfterEditSpy = vi.spyOn(commandsModule, "runAfterEdit");
|
|
2378
|
+
|
|
2379
|
+
await orchestrator.startTask(makeCtx() as any, "implement", "Ignore non-implement writes");
|
|
2380
|
+
orchestrator.active!.state.phase = "brainstorm";
|
|
2381
|
+
const toolResult = pi._handlers.get("tool_result")!;
|
|
2382
|
+
await toolResult({ toolName: "write", input: { path: "src/not-tracked.ts" }, isError: false, content: [] }, {});
|
|
2383
|
+
|
|
2384
|
+
expect(orchestrator.active!.modifiedFiles.size).toBe(0);
|
|
2385
|
+
expect(runAfterEditSpy).not.toHaveBeenCalled();
|
|
2386
|
+
});
|
|
2387
|
+
|
|
2388
|
+
it("root repo edits trigger root afterEdit commands", async () => {
|
|
2389
|
+
const cwd = makeTempDir();
|
|
2390
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
2391
|
+
const runAfterEditSpy = vi.spyOn(commandsModule, "runAfterEdit");
|
|
2392
|
+
const loadRepoAfterEditCommandsSpy = vi.spyOn(commandsModule, "loadRepoAfterEditCommands");
|
|
2393
|
+
|
|
2394
|
+
await orchestrator.startTask(makeCtx() as any, "implement", "afterEdit root");
|
|
2395
|
+
orchestrator.active!.state.phase = "implement";
|
|
2396
|
+
orchestrator.active!.state.repos = [{ path: cwd, isRoot: true }];
|
|
2397
|
+
saveTask(orchestrator.active!.dir, orchestrator.active!.state);
|
|
2398
|
+
|
|
2399
|
+
const toolResult = pi._handlers.get("tool_result")!;
|
|
2400
|
+
await toolResult({ toolName: "write", input: { path: "src/root.ts" }, isError: false, content: [] }, {});
|
|
2401
|
+
|
|
2402
|
+
expect(runAfterEditSpy).toHaveBeenCalledTimes(1);
|
|
2403
|
+
expect(runAfterEditSpy.mock.calls[0]?.[0]).toBe("src/root.ts");
|
|
2404
|
+
expect(loadRepoAfterEditCommandsSpy).not.toHaveBeenCalled();
|
|
2405
|
+
});
|
|
2406
|
+
|
|
2407
|
+
it("extra repo edits trigger extra repo afterEdit when extra configs are enabled", async () => {
|
|
2408
|
+
const cwd = makeTempDir();
|
|
2409
|
+
const extraRepo = join(cwd, "extra-repo");
|
|
2410
|
+
mkdirSync(extraRepo, { recursive: true });
|
|
2411
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
2412
|
+
const runAfterEditSpy = vi.spyOn(commandsModule, "runAfterEdit");
|
|
2413
|
+
const loadRepoAfterEditCommandsSpy = vi
|
|
2414
|
+
.spyOn(commandsModule, "loadRepoAfterEditCommands")
|
|
2415
|
+
.mockReturnValue({ "cmd-1": { run: "npm run lint", globs: ["**/*.ts"] } });
|
|
2416
|
+
|
|
2417
|
+
await orchestrator.startTask(makeCtx() as any, "implement", "afterEdit extra");
|
|
2418
|
+
orchestrator.config = {
|
|
2419
|
+
...orchestrator.config,
|
|
2420
|
+
general: { ...orchestrator.config.general, loadExtraRepoConfigs: true },
|
|
2421
|
+
} as any;
|
|
2422
|
+
orchestrator.active!.state.phase = "implement";
|
|
2423
|
+
orchestrator.active!.state.repos = [
|
|
2424
|
+
{ path: cwd, isRoot: true },
|
|
2425
|
+
{ path: extraRepo, isRoot: false },
|
|
2426
|
+
];
|
|
2427
|
+
saveTask(orchestrator.active!.dir, orchestrator.active!.state);
|
|
2428
|
+
|
|
2429
|
+
const toolResult = pi._handlers.get("tool_result")!;
|
|
2430
|
+
await toolResult({ toolName: "write", input: { path: "extra-repo/src/extra.ts" }, isError: false, content: [] }, {});
|
|
2431
|
+
|
|
2432
|
+
expect(loadRepoAfterEditCommandsSpy).toHaveBeenCalledWith(extraRepo);
|
|
2433
|
+
expect(runAfterEditSpy).toHaveBeenCalledTimes(1);
|
|
2434
|
+
expect(runAfterEditSpy).toHaveBeenCalledWith(
|
|
2435
|
+
"src/extra.ts",
|
|
2436
|
+
{ "cmd-1": { run: "npm run lint", globs: ["**/*.ts"] } },
|
|
2437
|
+
orchestrator.config.performance.commands.afterEdit,
|
|
2438
|
+
extraRepo,
|
|
2439
|
+
);
|
|
2440
|
+
});
|
|
2441
|
+
|
|
2442
|
+
it("extra repo edits are skipped when ignoreExtraRepoConfigs is true", async () => {
|
|
2443
|
+
const cwd = makeTempDir();
|
|
2444
|
+
const extraRepo = join(cwd, "extra-repo");
|
|
2445
|
+
mkdirSync(extraRepo, { recursive: true });
|
|
2446
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
2447
|
+
const runAfterEditSpy = vi.spyOn(commandsModule, "runAfterEdit");
|
|
2448
|
+
const loadRepoAfterEditCommandsSpy = vi.spyOn(commandsModule, "loadRepoAfterEditCommands");
|
|
2449
|
+
|
|
2450
|
+
await orchestrator.startTask(makeCtx() as any, "implement", "afterEdit extra skipped");
|
|
2451
|
+
orchestrator.config = {
|
|
2452
|
+
...orchestrator.config,
|
|
2453
|
+
general: { ...orchestrator.config.general, loadExtraRepoConfigs: false },
|
|
2454
|
+
} as any;
|
|
2455
|
+
orchestrator.active!.state.phase = "implement";
|
|
2456
|
+
orchestrator.active!.state.repos = [
|
|
2457
|
+
{ path: cwd, isRoot: true },
|
|
2458
|
+
{ path: extraRepo, isRoot: false },
|
|
2459
|
+
];
|
|
2460
|
+
saveTask(orchestrator.active!.dir, orchestrator.active!.state);
|
|
2461
|
+
|
|
2462
|
+
const toolResult = pi._handlers.get("tool_result")!;
|
|
2463
|
+
await toolResult({ toolName: "write", input: { path: "extra-repo/src/extra.ts" }, isError: false, content: [] }, {});
|
|
2464
|
+
|
|
2465
|
+
expect(loadRepoAfterEditCommandsSpy).not.toHaveBeenCalled();
|
|
2466
|
+
expect(runAfterEditSpy).not.toHaveBeenCalled();
|
|
2467
|
+
});
|
|
2468
|
+
|
|
2469
|
+
it("pp_commit with autoCommit disabled returns message", async () => {
|
|
2470
|
+
const cwd = makeTempDir();
|
|
2471
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
2472
|
+
|
|
2473
|
+
await orchestrator.startTask(makeCtx() as any, "implement", "commit disabled");
|
|
2474
|
+
orchestrator.config = {
|
|
2475
|
+
...orchestrator.config,
|
|
2476
|
+
general: { ...orchestrator.config.general, autoCommit: false },
|
|
2477
|
+
} as any;
|
|
2478
|
+
|
|
2479
|
+
const ppCommit = getTool(pi, "pp_commit");
|
|
2480
|
+
const result = await ppCommit.execute("call-commit-disabled", { message: "msg" });
|
|
2481
|
+
|
|
2482
|
+
expect(result.content[0].text).toContain("autoCommit is disabled");
|
|
2483
|
+
});
|
|
2484
|
+
|
|
2485
|
+
it("pp_commit with no modified files returns message", async () => {
|
|
2486
|
+
const cwd = makeTempDir();
|
|
2487
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
2488
|
+
|
|
2489
|
+
await orchestrator.startTask(makeCtx() as any, "implement", "commit empty");
|
|
2490
|
+
orchestrator.config = {
|
|
2491
|
+
...orchestrator.config,
|
|
2492
|
+
general: { ...orchestrator.config.general, autoCommit: true },
|
|
2493
|
+
} as any;
|
|
2494
|
+
pi.exec.mockResolvedValueOnce({ code: 0, stdout: "", stderr: "" });
|
|
2495
|
+
|
|
2496
|
+
const ppCommit = getTool(pi, "pp_commit");
|
|
2497
|
+
const result = await ppCommit.execute("call-commit-empty", { message: "msg" });
|
|
2498
|
+
|
|
2499
|
+
expect(result.content[0].text).toContain("No modified files to commit");
|
|
2500
|
+
});
|
|
2501
|
+
|
|
2502
|
+
it("pp_commit with unregistered repo returns error", async () => {
|
|
2503
|
+
const cwd = makeTempDir();
|
|
2504
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
2505
|
+
|
|
2506
|
+
await orchestrator.startTask(makeCtx() as any, "implement", "commit invalid repo");
|
|
2507
|
+
orchestrator.config = {
|
|
2508
|
+
...orchestrator.config,
|
|
2509
|
+
general: { ...orchestrator.config.general, autoCommit: true },
|
|
2510
|
+
} as any;
|
|
2511
|
+
|
|
2512
|
+
const ppCommit = getTool(pi, "pp_commit");
|
|
2513
|
+
const result = await ppCommit.execute("call-commit-unregistered", {
|
|
2514
|
+
message: "msg",
|
|
2515
|
+
repo: join(cwd, "unregistered"),
|
|
2516
|
+
});
|
|
2517
|
+
|
|
2518
|
+
expect(result.isError).toBe(true);
|
|
2519
|
+
expect(result.content[0].text).toContain("Repository is not registered");
|
|
2520
|
+
});
|
|
2521
|
+
|
|
2522
|
+
it("pp_commit clears modified files after success", async () => {
|
|
2523
|
+
const cwd = makeTempDir();
|
|
2524
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
2525
|
+
const autoCommitSpy = vi.spyOn(commandsModule, "autoCommit").mockReturnValue({ ok: true, commitHash: "abc123" });
|
|
2526
|
+
const ctx = makeCtx();
|
|
2527
|
+
|
|
2528
|
+
await orchestrator.startTask(ctx as any, "implement", "commit clear");
|
|
2529
|
+
orchestrator.config = {
|
|
2530
|
+
...orchestrator.config,
|
|
2531
|
+
general: { ...orchestrator.config.general, autoCommit: true },
|
|
2532
|
+
} as any;
|
|
2533
|
+
orchestrator.active!.state.phase = "implement";
|
|
2534
|
+
orchestrator.active!.modifiedFiles.add(join(cwd, "src", "tracked.ts"));
|
|
2535
|
+
orchestrator.active!.state.modifiedFiles = [...orchestrator.active!.modifiedFiles];
|
|
2536
|
+
saveTask(orchestrator.active!.dir, orchestrator.active!.state);
|
|
2537
|
+
|
|
2538
|
+
pi.exec.mockResolvedValueOnce({
|
|
2539
|
+
code: 0,
|
|
2540
|
+
stdout: " M src/tracked.ts\n",
|
|
2541
|
+
stderr: "",
|
|
2542
|
+
});
|
|
2543
|
+
|
|
2544
|
+
const ppCommit = getTool(pi, "pp_commit");
|
|
2545
|
+
const result = await ppCommit.execute("call-pp-commit", { message: "commit files" });
|
|
2546
|
+
|
|
2547
|
+
expect(result.content[0].text).toContain("Committed 1 file");
|
|
2548
|
+
expect(orchestrator.active!.modifiedFiles.size).toBe(0);
|
|
2549
|
+
expect(orchestrator.active!.state.modifiedFiles).toEqual([]);
|
|
2550
|
+
expect(autoCommitSpy).toHaveBeenCalled();
|
|
2551
|
+
});
|
|
2552
|
+
|
|
2553
|
+
it("pp_commit parses renamed files and stages new path", async () => {
|
|
2554
|
+
const cwd = makeTempDir();
|
|
2555
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
2556
|
+
const autoCommitSpy = vi.spyOn(commandsModule, "autoCommit").mockReturnValue({ ok: true, commitHash: "abc123" });
|
|
2557
|
+
|
|
2558
|
+
await orchestrator.startTask(makeCtx() as any, "implement", "commit rename parse");
|
|
2559
|
+
orchestrator.config = {
|
|
2560
|
+
...orchestrator.config,
|
|
2561
|
+
general: { ...orchestrator.config.general, autoCommit: true },
|
|
2562
|
+
} as any;
|
|
2563
|
+
orchestrator.active!.state.phase = "implement";
|
|
2564
|
+
orchestrator.active!.state.repos = [{ path: cwd, isRoot: true }];
|
|
2565
|
+
saveTask(orchestrator.active!.dir, orchestrator.active!.state);
|
|
2566
|
+
|
|
2567
|
+
pi.exec.mockResolvedValueOnce({
|
|
2568
|
+
code: 0,
|
|
2569
|
+
stdout: "R src/old.ts -> src/new.ts\n",
|
|
2570
|
+
stderr: "",
|
|
2571
|
+
});
|
|
2572
|
+
|
|
2573
|
+
const ppCommit = getTool(pi, "pp_commit");
|
|
2574
|
+
const result = await ppCommit.execute("call-pp-commit-rename", { message: "rename file" });
|
|
2575
|
+
|
|
2576
|
+
expect(result.content[0].text).toContain("Committed 1 file");
|
|
2577
|
+
expect(autoCommitSpy).toHaveBeenCalledWith(["src/new.ts"], "rename file", cwd);
|
|
2578
|
+
});
|
|
2579
|
+
});
|
|
2580
|
+
|
|
2581
|
+
describe("resume and recovery", () => {
|
|
2582
|
+
it("resume paused task restores task phase and step", async () => {
|
|
2583
|
+
const cwd = makeTempDir();
|
|
2584
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
2585
|
+
const ctx = makeCtx();
|
|
2586
|
+
|
|
2587
|
+
await orchestrator.startTask(ctx as any, "implement", "resume phase state");
|
|
2588
|
+
const taskDir = orchestrator.active!.dir;
|
|
2589
|
+
orchestrator.active!.state.phase = "plan";
|
|
2590
|
+
orchestrator.active!.state.step = "synthesize";
|
|
2591
|
+
orchestrator.active!.state.modifiedFiles = [join(cwd, "src", "restored.ts")];
|
|
2592
|
+
saveTask(taskDir, orchestrator.active!.state);
|
|
2593
|
+
await orchestrator.cleanupActive();
|
|
2594
|
+
|
|
2595
|
+
const pp = getCommand(pi, "pp");
|
|
2596
|
+
menu
|
|
2597
|
+
.expect({ question: "/pp", options: { include: ["Task"] }, choose: "Task" })
|
|
2598
|
+
.expect({ question: "Task", options: { include: ["Resume"] }, choose: "Resume" })
|
|
2599
|
+
.expect({ question: "Resume", options: { include: ["resume phase state"] }, choose: "resume phase state" });
|
|
2600
|
+
await pp(undefined, ctx);
|
|
2601
|
+
|
|
2602
|
+
expect(orchestrator.active).not.toBeNull();
|
|
2603
|
+
expect(orchestrator.active!.state.phase).toBe("plan");
|
|
2604
|
+
expect(orchestrator.active!.state.step).toBe("synthesize");
|
|
2605
|
+
expect(orchestrator.active!.modifiedFiles.has(join(cwd, "src", "restored.ts"))).toBe(true);
|
|
2606
|
+
});
|
|
2607
|
+
|
|
2608
|
+
it("resume prunes stale repos that no longer exist", async () => {
|
|
2609
|
+
const cwd = makeTempDir();
|
|
2610
|
+
mkdirSync(join(cwd, ".git"), { recursive: true });
|
|
2611
|
+
const staleRepo = join(cwd, "missing-repo");
|
|
2612
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
2613
|
+
const ctx = makeCtx();
|
|
2614
|
+
|
|
2615
|
+
await orchestrator.startTask(ctx as any, "implement", "resume stale repos");
|
|
2616
|
+
const taskDir = orchestrator.active!.dir;
|
|
2617
|
+
orchestrator.active!.state.repos = [
|
|
2618
|
+
{ path: cwd, isRoot: true },
|
|
2619
|
+
{ path: staleRepo, isRoot: false },
|
|
2620
|
+
];
|
|
2621
|
+
saveTask(taskDir, orchestrator.active!.state);
|
|
2622
|
+
await orchestrator.cleanupActive();
|
|
2623
|
+
|
|
2624
|
+
const pp = getCommand(pi, "pp");
|
|
2625
|
+
menu
|
|
2626
|
+
.expect({ question: "/pp", options: { include: ["Task"] }, choose: "Task" })
|
|
2627
|
+
.expect({ question: "Task", options: { include: ["Resume"] }, choose: "Resume" })
|
|
2628
|
+
.expect({ question: "Resume", options: { include: ["resume stale repos"] }, choose: "resume stale repos" });
|
|
2629
|
+
await pp(undefined, ctx);
|
|
2630
|
+
|
|
2631
|
+
expect(orchestrator.active!.state.repos).toEqual([{ path: cwd, isRoot: true }]);
|
|
2632
|
+
expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("Pruned 1 stale repo"), "warning");
|
|
2633
|
+
});
|
|
2634
|
+
|
|
2635
|
+
it("getActiveTask returns null when multiple unlocked tasks exist", () => {
|
|
2636
|
+
const cwd = makeTempDir();
|
|
2637
|
+
createTask(cwd, "implement", "first unlocked");
|
|
2638
|
+
createTask(cwd, "debug", "second unlocked");
|
|
2639
|
+
|
|
2640
|
+
const active = getActiveTask(cwd);
|
|
2641
|
+
|
|
2642
|
+
expect(active).toBeNull();
|
|
2643
|
+
});
|
|
2644
|
+
|
|
2645
|
+
it("getActiveTask returns the single unlocked task", () => {
|
|
2646
|
+
const cwd = makeTempDir();
|
|
2647
|
+
const taskDir = createTask(cwd, "implement", "single unlocked");
|
|
2648
|
+
|
|
2649
|
+
const active = getActiveTask(cwd);
|
|
2650
|
+
|
|
2651
|
+
expect(active?.dir).toBe(taskDir);
|
|
2652
|
+
});
|
|
2653
|
+
});
|
|
2654
|
+
|
|
2655
|
+
describe("crash resume", () => {
|
|
2656
|
+
it("resume in await_planners with partial plan outputs moves to synthesize when all enabled outputs exist", async () => {
|
|
2657
|
+
const cwd = makeTempDir();
|
|
2658
|
+
mkdirSync(join(cwd, ".git"), { recursive: true });
|
|
2659
|
+
const { orchestrator } = await setupOrchestrator(cwd);
|
|
2660
|
+
const ctx = makeCtx({ cwd });
|
|
2661
|
+
|
|
2662
|
+
await orchestrator.startTask(ctx as any, "implement", "resume planners complete");
|
|
2663
|
+
const taskDir = orchestrator.active!.dir;
|
|
2664
|
+
writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
|
|
2665
|
+
writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
|
|
2666
|
+
mkdirSync(join(taskDir, "plans"), { recursive: true });
|
|
2667
|
+
writeFileSync(
|
|
2668
|
+
join(taskDir, "plans", `${Math.floor(Date.now() / 1000)}_test.md`),
|
|
2669
|
+
makeValidPlan(["- [ ] P1. Planner output item — Done when: planner output exists"]),
|
|
2670
|
+
"utf-8",
|
|
2671
|
+
);
|
|
2672
|
+
orchestrator.active!.state.phase = "plan";
|
|
2673
|
+
orchestrator.active!.state.step = "await_planners";
|
|
2674
|
+
orchestrator.active!.state.activePlannerPreset = "regular";
|
|
2675
|
+
saveTask(taskDir, orchestrator.active!.state);
|
|
2676
|
+
await orchestrator.cleanupActive();
|
|
2677
|
+
|
|
2678
|
+
const result = await resumeTask(orchestrator, ctx, { dir: taskDir, state: loadTask(taskDir), type: "implement" });
|
|
2679
|
+
|
|
2680
|
+
expect(result.ok).toBe(true);
|
|
2681
|
+
expect(orchestrator.active!.state.step).toBe("synthesize");
|
|
2682
|
+
});
|
|
2683
|
+
|
|
2684
|
+
it("resume in await_planners with missing outputs keeps await_planners and attempts planner respawn", async () => {
|
|
2685
|
+
const cwd = makeTempDir();
|
|
2686
|
+
mkdirSync(join(cwd, ".git"), { recursive: true });
|
|
2687
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
2688
|
+
const ctx = makeCtx({ cwd });
|
|
2689
|
+
|
|
2690
|
+
await orchestrator.startTask(ctx as any, "implement", "resume planners missing");
|
|
2691
|
+
const taskDir = orchestrator.active!.dir;
|
|
2692
|
+
writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
|
|
2693
|
+
writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
|
|
2694
|
+
orchestrator.active!.state.phase = "plan";
|
|
2695
|
+
orchestrator.active!.state.step = "await_planners";
|
|
2696
|
+
orchestrator.active!.state.activePlannerPreset = "regular";
|
|
2697
|
+
saveTask(taskDir, orchestrator.active!.state);
|
|
2698
|
+
await orchestrator.cleanupActive();
|
|
2699
|
+
|
|
2700
|
+
const result = await resumeTask(orchestrator, ctx, { dir: taskDir, state: loadTask(taskDir), type: "implement" });
|
|
2701
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
2702
|
+
|
|
2703
|
+
expect(result.ok).toBe(true);
|
|
2704
|
+
expect(orchestrator.active!.state.step).toBe("await_planners");
|
|
2705
|
+
expect(pi.sendMessage).toHaveBeenCalledWith(
|
|
2706
|
+
expect.objectContaining({ customType: "pp-planners-error" }),
|
|
2707
|
+
{ deliverAs: "steer" },
|
|
2708
|
+
);
|
|
2709
|
+
});
|
|
2710
|
+
|
|
2711
|
+
it("resume in reviewCycle apply_feedback delivers review outputs and keeps cycle active", async () => {
|
|
2712
|
+
const cwd = makeTempDir();
|
|
2713
|
+
mkdirSync(join(cwd, ".git"), { recursive: true });
|
|
2714
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
2715
|
+
const ctx = makeCtx({ cwd });
|
|
2716
|
+
|
|
2717
|
+
await orchestrator.startTask(ctx as any, "implement", "resume apply feedback");
|
|
2718
|
+
const taskDir = orchestrator.active!.dir;
|
|
2719
|
+
mkdirSync(join(taskDir, "code-reviews"), { recursive: true });
|
|
2720
|
+
writeFileSync(join(taskDir, "code-reviews", `${Math.floor(Date.now() / 1000)}_test_round-1.md`), "Review note", "utf-8");
|
|
2721
|
+
orchestrator.active!.state.phase = "implement";
|
|
2722
|
+
orchestrator.active!.state.step = "apply_feedback";
|
|
2723
|
+
orchestrator.active!.state.reviewCycle = { kind: "auto", step: "apply_feedback", pass: 1 };
|
|
2724
|
+
saveTask(taskDir, orchestrator.active!.state);
|
|
2725
|
+
await orchestrator.cleanupActive();
|
|
2726
|
+
|
|
2727
|
+
const result = await resumeTask(orchestrator, ctx, { dir: taskDir, state: loadTask(taskDir), type: "implement" });
|
|
2728
|
+
|
|
2729
|
+
expect(result.ok).toBe(true);
|
|
2730
|
+
expect(orchestrator.active!.state.reviewCycle).toEqual({ kind: "auto", step: "apply_feedback", pass: 1 });
|
|
2731
|
+
expect(pi.sendMessage).toHaveBeenCalledWith(
|
|
2732
|
+
expect.objectContaining({
|
|
2733
|
+
customType: "pp-review-ready",
|
|
2734
|
+
content: expect.stringContaining("Review cycle is in apply_feedback step."),
|
|
2735
|
+
}),
|
|
2736
|
+
{ deliverAs: "steer" },
|
|
2737
|
+
);
|
|
2738
|
+
});
|
|
2739
|
+
|
|
2740
|
+
it("resume prunes stale repos and sends warning notification", async () => {
|
|
2741
|
+
const cwd = makeTempDir();
|
|
2742
|
+
mkdirSync(join(cwd, ".git"), { recursive: true });
|
|
2743
|
+
const staleRepo = join(cwd, "gone");
|
|
2744
|
+
const { orchestrator } = await setupOrchestrator(cwd);
|
|
2745
|
+
const ctx = makeCtx({ cwd });
|
|
2746
|
+
|
|
2747
|
+
await orchestrator.startTask(ctx as any, "implement", "resume stale notify");
|
|
2748
|
+
const taskDir = orchestrator.active!.dir;
|
|
2749
|
+
orchestrator.active!.state.repos = [
|
|
2750
|
+
{ path: cwd, isRoot: true },
|
|
2751
|
+
{ path: staleRepo, isRoot: false },
|
|
2752
|
+
];
|
|
2753
|
+
saveTask(taskDir, orchestrator.active!.state);
|
|
2754
|
+
await orchestrator.cleanupActive();
|
|
2755
|
+
|
|
2756
|
+
const result = await resumeTask(orchestrator, ctx, { dir: taskDir, state: loadTask(taskDir), type: "implement" });
|
|
2757
|
+
|
|
2758
|
+
expect(result.ok).toBe(true);
|
|
2759
|
+
expect(ctx.ui.notify).toHaveBeenCalledWith("Pruned 1 stale repo(s) that no longer exist.", "warning");
|
|
2760
|
+
});
|
|
2761
|
+
|
|
2762
|
+
it("resume with missing planner preset falls back to first available preset and warns", async () => {
|
|
2763
|
+
const cwd = makeTempDir();
|
|
2764
|
+
mkdirSync(join(cwd, ".git"), { recursive: true });
|
|
2765
|
+
const { orchestrator } = await setupOrchestrator(cwd);
|
|
2766
|
+
const ctx = makeCtx({ cwd });
|
|
2767
|
+
|
|
2768
|
+
await orchestrator.startTask(ctx as any, "implement", "resume missing preset");
|
|
2769
|
+
const taskDir = orchestrator.active!.dir;
|
|
2770
|
+
writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
|
|
2771
|
+
writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
|
|
2772
|
+
orchestrator.active!.state.phase = "plan";
|
|
2773
|
+
orchestrator.active!.state.step = "await_planners";
|
|
2774
|
+
orchestrator.active!.state.activePlannerPreset = "nonexistent";
|
|
2775
|
+
saveTask(taskDir, orchestrator.active!.state);
|
|
2776
|
+
await orchestrator.cleanupActive();
|
|
2777
|
+
|
|
2778
|
+
const result = await resumeTask(orchestrator, ctx, { dir: taskDir, state: loadTask(taskDir), type: "implement" });
|
|
2779
|
+
|
|
2780
|
+
expect(result.ok).toBe(true);
|
|
2781
|
+
expect(orchestrator.active!.state.activePlannerPreset).toBe("regular");
|
|
2782
|
+
expect(ctx.ui.notify).toHaveBeenCalledWith(
|
|
2783
|
+
'Planner preset "nonexistent" not found. Falling back to "regular".',
|
|
2784
|
+
"warning",
|
|
2785
|
+
);
|
|
2786
|
+
});
|
|
2787
|
+
});
|
|
2788
|
+
|
|
2789
|
+
describe("brainstorm and plan review cycles", () => {
|
|
2790
|
+
it("brainstorm review cycle reaches apply_feedback and finalizeReviewCycle returns to user_gate", async () => {
|
|
2791
|
+
const cwd = makeTempDir();
|
|
2792
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
2793
|
+
const ctx = makeCtx({ cwd });
|
|
2794
|
+
|
|
2795
|
+
await orchestrator.startTask(ctx as any, "implement", "brainstorm review cycle");
|
|
2796
|
+
const taskDir = orchestrator.active!.dir;
|
|
2797
|
+
writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
|
|
2798
|
+
writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
|
|
2799
|
+
mkdirSync(join(taskDir, "brainstorm-reviews"), { recursive: true });
|
|
2800
|
+
writeFileSync(
|
|
2801
|
+
join(taskDir, "brainstorm-reviews", `${Math.floor(Date.now() / 1000)}_test_round-1.md`),
|
|
2802
|
+
"Brainstorm review feedback",
|
|
2803
|
+
"utf-8",
|
|
2804
|
+
);
|
|
2805
|
+
|
|
2806
|
+
const message = await enterReviewCycle(orchestrator, ctx, "regular");
|
|
2807
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
2808
|
+
|
|
2809
|
+
expect(message).toContain("Started review cycle pass 1");
|
|
2810
|
+
expect(orchestrator.active!.state.reviewCycle?.step).toBe("apply_feedback");
|
|
2811
|
+
expect(orchestrator.active!.state.step).toBe("apply_feedback");
|
|
2812
|
+
expect(pi.sendMessage).toHaveBeenCalledWith(
|
|
2813
|
+
expect.objectContaining({
|
|
2814
|
+
customType: "pp-review-ready",
|
|
2815
|
+
content: expect.stringContaining("Brainstorm review feedback"),
|
|
2816
|
+
}),
|
|
2817
|
+
{ deliverAs: "followUp" },
|
|
2818
|
+
);
|
|
2819
|
+
|
|
2820
|
+
finalizeReviewCycle(orchestrator.active!);
|
|
2821
|
+
|
|
2822
|
+
expect(orchestrator.active!.state.step).toBe("user_gate");
|
|
2823
|
+
expect(orchestrator.active!.state.reviewCycle).toBeNull();
|
|
2824
|
+
expect(orchestrator.active!.state.reviewPass).toBe(1);
|
|
2825
|
+
});
|
|
2826
|
+
|
|
2827
|
+
it("brainstorm review-ready message names the artifacts under review, not a code diff", async () => {
|
|
2828
|
+
const cwd = makeTempDir();
|
|
2829
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
2830
|
+
const ctx = makeCtx({ cwd });
|
|
2831
|
+
|
|
2832
|
+
await orchestrator.startTask(ctx as any, "implement", "brainstorm review message");
|
|
2833
|
+
const taskDir = orchestrator.active!.dir;
|
|
2834
|
+
writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
|
|
2835
|
+
writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
|
|
2836
|
+
mkdirSync(join(taskDir, "brainstorm-reviews"), { recursive: true });
|
|
2837
|
+
writeFileSync(
|
|
2838
|
+
join(taskDir, "brainstorm-reviews", `${Math.floor(Date.now() / 1000)}_test_round-1.md`),
|
|
2839
|
+
"Brainstorm review feedback",
|
|
2840
|
+
"utf-8",
|
|
2841
|
+
);
|
|
2842
|
+
|
|
2843
|
+
await enterReviewCycle(orchestrator, ctx, "regular");
|
|
2844
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
2845
|
+
|
|
2846
|
+
const readyMessages = (pi.sendUserMessage as any).mock.calls
|
|
2847
|
+
.map((c: any[]) => c[0] as string)
|
|
2848
|
+
.filter((text: string) => text.includes("ready for apply_feedback"));
|
|
2849
|
+
expect(readyMessages.length).toBeGreaterThan(0);
|
|
2850
|
+
expect(readyMessages[readyMessages.length - 1]).toContain("USER_REQUEST.md");
|
|
2851
|
+
expect(readyMessages[readyMessages.length - 1]).toContain("RESEARCH.md");
|
|
2852
|
+
expect(readyMessages[readyMessages.length - 1]).toContain("artifacts/");
|
|
2853
|
+
});
|
|
2854
|
+
|
|
2855
|
+
it("plan review cycle reaches apply_feedback and finalizeReviewCycle returns to user_gate", async () => {
|
|
2856
|
+
const cwd = makeTempDir();
|
|
2857
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
2858
|
+
const ctx = makeCtx({ cwd });
|
|
2859
|
+
|
|
2860
|
+
await orchestrator.startTask(ctx as any, "implement", "plan review cycle");
|
|
2861
|
+
const taskDir = orchestrator.active!.dir;
|
|
2862
|
+
writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
|
|
2863
|
+
writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
|
|
2864
|
+
mkdirSync(join(taskDir, "plans"), { recursive: true });
|
|
2865
|
+
writeFileSync(
|
|
2866
|
+
join(taskDir, "plans", `${Math.floor(Date.now() / 1000)}_synthesized.md`),
|
|
2867
|
+
makeValidPlan(["- [x] P1. Plan ready — Done when: synthesized plan exists"]),
|
|
2868
|
+
"utf-8",
|
|
2869
|
+
);
|
|
2870
|
+
mkdirSync(join(taskDir, "plan-reviews"), { recursive: true });
|
|
2871
|
+
writeFileSync(
|
|
2872
|
+
join(taskDir, "plan-reviews", `${Math.floor(Date.now() / 1000)}_test_round-1.md`),
|
|
2873
|
+
"Plan review feedback",
|
|
2874
|
+
"utf-8",
|
|
2875
|
+
);
|
|
2876
|
+
orchestrator.active!.state.phase = "plan";
|
|
2877
|
+
orchestrator.active!.state.step = "synthesize";
|
|
2878
|
+
saveTask(taskDir, orchestrator.active!.state);
|
|
2879
|
+
|
|
2880
|
+
const message = await enterReviewCycle(orchestrator, ctx, "regular");
|
|
2881
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
2882
|
+
|
|
2883
|
+
expect(message).toContain("Started review cycle pass 1");
|
|
2884
|
+
expect(orchestrator.active!.state.reviewCycle?.step).toBe("apply_feedback");
|
|
2885
|
+
expect(orchestrator.active!.state.step).toBe("apply_feedback");
|
|
2886
|
+
expect(pi.sendMessage).toHaveBeenCalledWith(
|
|
2887
|
+
expect.objectContaining({
|
|
2888
|
+
customType: "pp-review-ready",
|
|
2889
|
+
content: expect.stringContaining("Plan review feedback"),
|
|
2890
|
+
}),
|
|
2891
|
+
{ deliverAs: "followUp" },
|
|
2892
|
+
);
|
|
2893
|
+
|
|
2894
|
+
finalizeReviewCycle(orchestrator.active!);
|
|
2895
|
+
|
|
2896
|
+
expect(orchestrator.active!.state.step).toBe("user_gate");
|
|
2897
|
+
expect(orchestrator.active!.state.reviewCycle).toBeNull();
|
|
2898
|
+
expect(orchestrator.active!.state.reviewPass).toBe(1);
|
|
2899
|
+
});
|
|
2900
|
+
|
|
2901
|
+
it("brainstorm review with no artifacts spawns nothing and clears the cycle", async () => {
|
|
2902
|
+
const cwd = makeTempDir();
|
|
2903
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
2904
|
+
const ctx = makeCtx({ cwd });
|
|
2905
|
+
|
|
2906
|
+
await orchestrator.startTask(ctx as any, "implement", "review no artifacts");
|
|
2907
|
+
const taskDir = orchestrator.active!.dir;
|
|
2908
|
+
orchestrator.active!.state.phase = "brainstorm";
|
|
2909
|
+
orchestrator.active!.state.step = "llm_work";
|
|
2910
|
+
saveTask(taskDir, orchestrator.active!.state);
|
|
2911
|
+
|
|
2912
|
+
const message = await enterReviewCycle(orchestrator, ctx, "regular");
|
|
2913
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
2914
|
+
|
|
2915
|
+
expect(message).toContain("Started review cycle pass 1");
|
|
2916
|
+
expect(orchestrator.active!.state.reviewCycle).toBeNull();
|
|
2917
|
+
expect(orchestrator.active!.state.step).toBe("llm_work");
|
|
2918
|
+
expect(pi.sendMessage).not.toHaveBeenCalledWith(
|
|
2919
|
+
expect.objectContaining({ customType: "pp-review-ready" }),
|
|
2920
|
+
{ deliverAs: "followUp" },
|
|
2921
|
+
);
|
|
2922
|
+
});
|
|
2923
|
+
});
|
|
2924
|
+
|
|
2925
|
+
describe("artifact validation enforcement", () => {
|
|
2926
|
+
it("writing invalid USER_REQUEST.md appends validation-error", async () => {
|
|
2927
|
+
const cwd = makeTempDir();
|
|
2928
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
2929
|
+
|
|
2930
|
+
await orchestrator.startTask(makeCtx() as any, "implement", "validate ur");
|
|
2931
|
+
writeFileSync(join(orchestrator.active!.dir, "USER_REQUEST.md"), "# Wrong\n\n## Nope\n", "utf-8");
|
|
2932
|
+
|
|
2933
|
+
const toolResult = pi._handlers.get("tool_result")!;
|
|
2934
|
+
const result = await toolResult({
|
|
2935
|
+
toolName: "write",
|
|
2936
|
+
input: { path: join(orchestrator.active!.dir, "USER_REQUEST.md") },
|
|
2937
|
+
isError: false,
|
|
2938
|
+
content: [{ type: "text", text: "written" }],
|
|
2939
|
+
}, {});
|
|
2940
|
+
|
|
2941
|
+
const last = result.content[result.content.length - 1];
|
|
2942
|
+
expect(last.text).toContain("<validation-error>");
|
|
2943
|
+
expect(last.text).toContain("USER_REQUEST.md structure is invalid");
|
|
2944
|
+
});
|
|
2945
|
+
|
|
2946
|
+
it("writing invalid RESEARCH.md appends validation-error", async () => {
|
|
2947
|
+
const cwd = makeTempDir();
|
|
2948
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
2949
|
+
|
|
2950
|
+
await orchestrator.startTask(makeCtx() as any, "implement", "validate research");
|
|
2951
|
+
writeFileSync(join(orchestrator.active!.dir, "RESEARCH.md"), "## Affected Code\n\n", "utf-8");
|
|
2952
|
+
|
|
2953
|
+
const toolResult = pi._handlers.get("tool_result")!;
|
|
2954
|
+
const result = await toolResult({
|
|
2955
|
+
toolName: "write",
|
|
2956
|
+
input: { path: join(orchestrator.active!.dir, "RESEARCH.md") },
|
|
2957
|
+
isError: false,
|
|
2958
|
+
content: [{ type: "text", text: "written" }],
|
|
2959
|
+
}, {});
|
|
2960
|
+
|
|
2961
|
+
const last = result.content[result.content.length - 1];
|
|
2962
|
+
expect(last.text).toContain("<validation-error>");
|
|
2963
|
+
expect(last.text).toContain("RESEARCH.md structure is invalid");
|
|
2964
|
+
});
|
|
2965
|
+
|
|
2966
|
+
it("writing valid USER_REQUEST.md does not append validation error", async () => {
|
|
2967
|
+
const cwd = makeTempDir();
|
|
2968
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
2969
|
+
|
|
2970
|
+
await orchestrator.startTask(makeCtx() as any, "implement", "validate ur valid");
|
|
2971
|
+
writeFileSync(join(orchestrator.active!.dir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
|
|
2972
|
+
|
|
2973
|
+
const toolResult = pi._handlers.get("tool_result")!;
|
|
2974
|
+
const result = await toolResult({
|
|
2975
|
+
toolName: "write",
|
|
2976
|
+
input: { path: join(orchestrator.active!.dir, "USER_REQUEST.md") },
|
|
2977
|
+
isError: false,
|
|
2978
|
+
content: [{ type: "text", text: "written" }],
|
|
2979
|
+
}, {});
|
|
2980
|
+
|
|
2981
|
+
expect(result).toBeUndefined();
|
|
2982
|
+
});
|
|
2983
|
+
|
|
2984
|
+
it("writing invalid artifact markdown appends validation-error", async () => {
|
|
2985
|
+
const cwd = makeTempDir();
|
|
2986
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
2987
|
+
|
|
2988
|
+
await orchestrator.startTask(makeCtx() as any, "implement", "validate artifact");
|
|
2989
|
+
const artifactDir = join(orchestrator.active!.dir, "artifacts");
|
|
2990
|
+
mkdirSync(artifactDir, { recursive: true });
|
|
2991
|
+
const artifactPath = join(artifactDir, "note.md");
|
|
2992
|
+
writeFileSync(artifactPath, "## Heading only\n", "utf-8");
|
|
2993
|
+
|
|
2994
|
+
const toolResult = pi._handlers.get("tool_result")!;
|
|
2995
|
+
const result = await toolResult({
|
|
2996
|
+
toolName: "write",
|
|
2997
|
+
input: { path: artifactPath },
|
|
2998
|
+
isError: false,
|
|
2999
|
+
content: [{ type: "text", text: "written" }],
|
|
3000
|
+
}, {});
|
|
3001
|
+
|
|
3002
|
+
const last = result.content[result.content.length - 1];
|
|
3003
|
+
expect(last.text).toContain("<validation-error>");
|
|
3004
|
+
expect(last.text).toContain("Artifact structure is invalid");
|
|
3005
|
+
});
|
|
3006
|
+
});
|
|
3007
|
+
|
|
3008
|
+
describe("tool blocking", () => {
|
|
3009
|
+
it("blocks write to .pp/state.json", async () => {
|
|
3010
|
+
const cwd = makeTempDir();
|
|
3011
|
+
const { pi } = await setupOrchestrator(cwd);
|
|
3012
|
+
const toolCall = pi._handlers.get("tool_call")!;
|
|
3013
|
+
|
|
3014
|
+
const result = await toolCall({ toolName: "write", input: { path: ".pp/state.json" } }, {});
|
|
3015
|
+
expect(result).toEqual({ block: true, reason: "state.json is managed by the extension" });
|
|
3016
|
+
});
|
|
3017
|
+
|
|
3018
|
+
it("blocks write to .pp/config.json", async () => {
|
|
3019
|
+
const cwd = makeTempDir();
|
|
3020
|
+
const { pi } = await setupOrchestrator(cwd);
|
|
3021
|
+
const toolCall = pi._handlers.get("tool_call")!;
|
|
3022
|
+
|
|
3023
|
+
const result = await toolCall({ toolName: "edit", input: { path: ".pp/config.json" } }, {});
|
|
3024
|
+
expect(result).toEqual({ block: true, reason: "config.json is managed by the user, not the LLM" });
|
|
3025
|
+
});
|
|
3026
|
+
|
|
3027
|
+
it("allows markdown writes in .pp/state", async () => {
|
|
3028
|
+
const cwd = makeTempDir();
|
|
3029
|
+
const { pi } = await setupOrchestrator(cwd);
|
|
3030
|
+
const toolCall = pi._handlers.get("tool_call")!;
|
|
3031
|
+
|
|
3032
|
+
const result = await toolCall({ toolName: "write", input: { path: ".pp/state/implement/123/notes.md" } }, {});
|
|
3033
|
+
expect(result).toBeUndefined();
|
|
3034
|
+
});
|
|
3035
|
+
|
|
3036
|
+
it("blocks non-markdown writes in .pp/state", async () => {
|
|
3037
|
+
const cwd = makeTempDir();
|
|
3038
|
+
const { pi } = await setupOrchestrator(cwd);
|
|
3039
|
+
const toolCall = pi._handlers.get("tool_call")!;
|
|
3040
|
+
|
|
3041
|
+
const result = await toolCall({ toolName: "write", input: { path: ".pp/state/implement/123/data.json" } }, {});
|
|
3042
|
+
expect(result).toEqual({ block: true, reason: "Cannot write non-.md files in .pp/state/" });
|
|
3043
|
+
});
|
|
3044
|
+
});
|
|
3045
|
+
|
|
3046
|
+
describe("error retry", () => {
|
|
3047
|
+
it("turn_end with error retries with exponential backoff", async () => {
|
|
3048
|
+
vi.useFakeTimers();
|
|
3049
|
+
const cwd = makeTempDir();
|
|
3050
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
3051
|
+
const ctx = makeCtx();
|
|
3052
|
+
|
|
3053
|
+
await orchestrator.startTask(ctx as any, "implement", "retry test");
|
|
3054
|
+
const turnEnd = pi._handlers.get("turn_end")!;
|
|
3055
|
+
|
|
3056
|
+
await turnEnd({ message: { stopReason: "error", errorMessage: "rate limited", content: [] } }, ctx);
|
|
3057
|
+
|
|
3058
|
+
expect(orchestrator.errorRetryCount).toBe(1);
|
|
3059
|
+
expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("Retrying in 2s"), "warning");
|
|
3060
|
+
|
|
3061
|
+
await vi.advanceTimersByTimeAsync(2000);
|
|
3062
|
+
expect(pi.sendUserMessage).toHaveBeenCalledWith(expect.stringContaining("Previous request failed"), { deliverAs: "followUp" });
|
|
3063
|
+
vi.useRealTimers();
|
|
3064
|
+
});
|
|
3065
|
+
|
|
3066
|
+
it("turn_end stops retrying after max retries", async () => {
|
|
3067
|
+
vi.useFakeTimers();
|
|
3068
|
+
const cwd = makeTempDir();
|
|
3069
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
3070
|
+
const ctx = makeCtx();
|
|
3071
|
+
|
|
3072
|
+
await orchestrator.startTask(ctx as any, "implement", "retry max test");
|
|
3073
|
+
const turnEnd = pi._handlers.get("turn_end")!;
|
|
3074
|
+
|
|
3075
|
+
for (let i = 0; i < 6; i++) {
|
|
3076
|
+
await turnEnd({ message: { stopReason: "error", errorMessage: "api down", content: [] } }, ctx);
|
|
3077
|
+
}
|
|
3078
|
+
|
|
3079
|
+
expect(orchestrator.errorRetryCount).toBe(0);
|
|
3080
|
+
expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("Stopping auto-retry"), "error");
|
|
3081
|
+
vi.useRealTimers();
|
|
3082
|
+
});
|
|
3083
|
+
|
|
3084
|
+
it("successful turn resets error count", async () => {
|
|
3085
|
+
const cwd = makeTempDir();
|
|
3086
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
3087
|
+
const ctx = makeCtx();
|
|
3088
|
+
|
|
3089
|
+
await orchestrator.startTask(ctx as any, "implement", "retry reset test");
|
|
3090
|
+
const turnEnd = pi._handlers.get("turn_end")!;
|
|
3091
|
+
|
|
3092
|
+
await turnEnd({ message: { stopReason: "error", errorMessage: "once", content: [] } }, ctx);
|
|
3093
|
+
expect(orchestrator.errorRetryCount).toBe(1);
|
|
3094
|
+
|
|
3095
|
+
await turnEnd({ message: { stopReason: "stop", content: [] } }, ctx);
|
|
3096
|
+
expect(orchestrator.errorRetryCount).toBe(0);
|
|
3097
|
+
});
|
|
3098
|
+
|
|
3099
|
+
it("empty turn triggers continuation nudge", async () => {
|
|
3100
|
+
const cwd = makeTempDir();
|
|
3101
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
3102
|
+
const ctx = makeCtx();
|
|
3103
|
+
|
|
3104
|
+
await orchestrator.startTask(ctx as any, "implement", "nudge test");
|
|
3105
|
+
orchestrator.active!.state.phase = "implement";
|
|
3106
|
+
orchestrator.active!.state.step = "llm_work";
|
|
3107
|
+
const turnEnd = pi._handlers.get("turn_end")!;
|
|
3108
|
+
|
|
3109
|
+
await turnEnd({ message: { stopReason: "stop", content: [] }, toolResults: [] }, ctx);
|
|
3110
|
+
|
|
3111
|
+
expect(orchestrator.consecutiveNudges).toBe(1);
|
|
3112
|
+
expect(pi.sendUserMessage).toHaveBeenCalledWith(expect.stringContaining("Continue the implement phase"), { deliverAs: "followUp" });
|
|
3113
|
+
});
|
|
3114
|
+
|
|
3115
|
+
it("suppresses nudges while the controller is transitioning (not running)", async () => {
|
|
3116
|
+
const cwd = makeTempDir();
|
|
3117
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
3118
|
+
const ctx = makeCtx();
|
|
3119
|
+
|
|
3120
|
+
await orchestrator.startTask(ctx as any, "implement", "nudge suppress test");
|
|
3121
|
+
orchestrator.active!.state.phase = "implement";
|
|
3122
|
+
orchestrator.active!.state.step = "llm_work";
|
|
3123
|
+
// Put the controller mid-transition (not running) with a non-idle ctx.
|
|
3124
|
+
orchestrator.lastCtx = makeCtx({ isIdle: vi.fn().mockReturnValue(false) });
|
|
3125
|
+
void orchestrator.transitionController.requestTransition({ kind: "phase", summary: "x" });
|
|
3126
|
+
expect(orchestrator.transitionController.isRunning()).toBe(false);
|
|
3127
|
+
|
|
3128
|
+
const before = (pi.sendUserMessage as any).mock.calls.length;
|
|
3129
|
+
const turnEnd = pi._handlers.get("turn_end")!;
|
|
3130
|
+
await turnEnd({ message: { stopReason: "stop", content: [] }, toolResults: [] }, ctx);
|
|
3131
|
+
|
|
3132
|
+
const nudged = (pi.sendUserMessage as any).mock.calls.slice(before).some((c: any[]) =>
|
|
3133
|
+
String(c[0]).includes("Continue the implement phase"),
|
|
3134
|
+
);
|
|
3135
|
+
expect(nudged).toBe(false);
|
|
3136
|
+
expect(orchestrator.consecutiveNudges).toBe(0);
|
|
3137
|
+
});
|
|
3138
|
+
|
|
3139
|
+
it("nudge halts after repeated interruptions", async () => {
|
|
3140
|
+
vi.useFakeTimers();
|
|
3141
|
+
const cwd = makeTempDir();
|
|
3142
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
3143
|
+
const ctx = makeCtx();
|
|
3144
|
+
|
|
3145
|
+
await orchestrator.startTask(ctx as any, "implement", "nudge halt");
|
|
3146
|
+
orchestrator.active!.state.phase = "implement";
|
|
3147
|
+
orchestrator.active!.state.step = "llm_work";
|
|
3148
|
+
const turnEnd = pi._handlers.get("turn_end")!;
|
|
3149
|
+
|
|
3150
|
+
for (let i = 0; i < 25; i += 1) {
|
|
3151
|
+
await turnEnd({ message: { stopReason: "stop", content: [] }, toolResults: [] }, ctx);
|
|
3152
|
+
}
|
|
3153
|
+
|
|
3154
|
+
expect(orchestrator.nudgeHalted).toBe(true);
|
|
3155
|
+
expect(pi.sendMessage).toHaveBeenCalledWith(
|
|
3156
|
+
expect.objectContaining({ customType: "pp-continuation-halted" }),
|
|
3157
|
+
{ deliverAs: "steer" },
|
|
3158
|
+
);
|
|
3159
|
+
vi.useRealTimers();
|
|
3160
|
+
});
|
|
3161
|
+
|
|
3162
|
+
it("halt still fires when nudge-induced before_agent_start interleaves (production sequence)", async () => {
|
|
3163
|
+
const cwd = makeTempDir();
|
|
3164
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
3165
|
+
const ctx = makeCtx();
|
|
3166
|
+
|
|
3167
|
+
await orchestrator.startTask(ctx as any, "implement", "nudge halt prod", undefined, undefined, "autonomous");
|
|
3168
|
+
orchestrator.active!.state.phase = "implement";
|
|
3169
|
+
orchestrator.active!.state.step = "llm_work";
|
|
3170
|
+
const turnEnd = pi._handlers.get("turn_end")!;
|
|
3171
|
+
const beforeStart = pi._handlers.get("before_agent_start")!;
|
|
3172
|
+
|
|
3173
|
+
// Real production loop: each nudge restarts the agent with a [PI-PI] prompt,
|
|
3174
|
+
// firing before_agent_start. That controller-injected start must NOT reset
|
|
3175
|
+
// the consecutive-nudge guard, so the halt eventually fires.
|
|
3176
|
+
for (let i = 0; i < 10; i += 1) {
|
|
3177
|
+
await turnEnd({ message: { stopReason: "stop", content: [] }, toolResults: [] }, ctx);
|
|
3178
|
+
await beforeStart({ systemPrompt: "base", prompt: "[PI-PI] Continue the implement phase." }, ctx);
|
|
3179
|
+
}
|
|
3180
|
+
|
|
3181
|
+
expect(orchestrator.nudgeHalted).toBe(true);
|
|
3182
|
+
expect(pi.sendMessage).toHaveBeenCalledWith(
|
|
3183
|
+
expect.objectContaining({ customType: "pp-continuation-halted" }),
|
|
3184
|
+
{ deliverAs: "steer" },
|
|
3185
|
+
);
|
|
3186
|
+
});
|
|
3187
|
+
|
|
3188
|
+
it("a genuine (non-[PI-PI]) user before_agent_start clears the nudge halt", async () => {
|
|
3189
|
+
const cwd = makeTempDir();
|
|
3190
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
3191
|
+
const ctx = makeCtx();
|
|
3192
|
+
|
|
3193
|
+
await orchestrator.startTask(ctx as any, "implement", "nudge clear", undefined, undefined, "autonomous");
|
|
3194
|
+
orchestrator.active!.state.phase = "implement";
|
|
3195
|
+
orchestrator.active!.state.step = "llm_work";
|
|
3196
|
+
orchestrator.nudgeHalted = true;
|
|
3197
|
+
orchestrator.consecutiveNudges = 6;
|
|
3198
|
+
|
|
3199
|
+
const beforeStart = pi._handlers.get("before_agent_start")!;
|
|
3200
|
+
await beforeStart({ systemPrompt: "base", prompt: "please continue, I have a new idea" }, ctx);
|
|
3201
|
+
|
|
3202
|
+
expect(orchestrator.nudgeHalted).toBe(false);
|
|
3203
|
+
expect(orchestrator.consecutiveNudges).toBe(0);
|
|
3204
|
+
});
|
|
3205
|
+
|
|
3206
|
+
it("repeated text-only stops re-nudge instead of latching once", async () => {
|
|
3207
|
+
const cwd = makeTempDir();
|
|
3208
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
3209
|
+
const ctx = makeCtx();
|
|
3210
|
+
|
|
3211
|
+
await orchestrator.startTask(ctx as any, "implement", "text stop test", undefined, undefined, "autonomous");
|
|
3212
|
+
orchestrator.active!.state.phase = "implement";
|
|
3213
|
+
orchestrator.active!.state.step = "llm_work";
|
|
3214
|
+
const turnEnd = pi._handlers.get("turn_end")!;
|
|
3215
|
+
|
|
3216
|
+
const textTurn = { message: { stopReason: "stop", content: [{ type: "text", text: "thinking out loud" }] }, toolResults: [] };
|
|
3217
|
+
for (let i = 0; i < 3; i += 1) {
|
|
3218
|
+
await turnEnd(textTurn, ctx);
|
|
3219
|
+
}
|
|
3220
|
+
|
|
3221
|
+
const reminderCalls = (pi.sendUserMessage as any).mock.calls.filter((c: any[]) =>
|
|
3222
|
+
String(c[0]).includes("Continue the implement phase"),
|
|
3223
|
+
);
|
|
3224
|
+
expect(reminderCalls.length).toBe(3);
|
|
3225
|
+
expect(reminderCalls[0][0]).toContain("Do NOT apologize");
|
|
3226
|
+
expect(orchestrator.nudgeHalted).toBe(false);
|
|
3227
|
+
});
|
|
3228
|
+
|
|
3229
|
+
it("repeated text-only stops eventually halt via the single consecutive guard", async () => {
|
|
3230
|
+
const cwd = makeTempDir();
|
|
3231
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
3232
|
+
const ctx = makeCtx();
|
|
3233
|
+
|
|
3234
|
+
await orchestrator.startTask(ctx as any, "implement", "text stop halt test", undefined, undefined, "autonomous");
|
|
3235
|
+
orchestrator.active!.state.phase = "implement";
|
|
3236
|
+
orchestrator.active!.state.step = "llm_work";
|
|
3237
|
+
const turnEnd = pi._handlers.get("turn_end")!;
|
|
3238
|
+
|
|
3239
|
+
const textTurn = { message: { stopReason: "stop", content: [{ type: "text", text: "still talking" }] }, toolResults: [] };
|
|
3240
|
+
for (let i = 0; i < 30; i += 1) {
|
|
3241
|
+
await turnEnd(textTurn, ctx);
|
|
3242
|
+
}
|
|
3243
|
+
|
|
3244
|
+
// The collapsed guard treats text-only stops like any other genuine stop:
|
|
3245
|
+
// after the consecutive cap it halts exactly once.
|
|
3246
|
+
expect(orchestrator.nudgeHalted).toBe(true);
|
|
3247
|
+
expect(pi.sendMessage).toHaveBeenCalledWith(
|
|
3248
|
+
expect.objectContaining({ customType: "pp-continuation-halted" }),
|
|
3249
|
+
{ deliverAs: "steer" },
|
|
3250
|
+
);
|
|
3251
|
+
});
|
|
3252
|
+
|
|
3253
|
+
it("a tool-call turn resets the consecutive-nudge guard", async () => {
|
|
3254
|
+
const cwd = makeTempDir();
|
|
3255
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
3256
|
+
const ctx = makeCtx();
|
|
3257
|
+
|
|
3258
|
+
await orchestrator.startTask(ctx as any, "implement", "text stop reset test", undefined, undefined, "autonomous");
|
|
3259
|
+
orchestrator.active!.state.phase = "implement";
|
|
3260
|
+
orchestrator.active!.state.step = "llm_work";
|
|
3261
|
+
const turnEnd = pi._handlers.get("turn_end")!;
|
|
3262
|
+
|
|
3263
|
+
await turnEnd({ message: { stopReason: "stop", content: [{ type: "text", text: "a" }] }, toolResults: [] }, ctx);
|
|
3264
|
+
expect(orchestrator.consecutiveNudges).toBe(1);
|
|
3265
|
+
// A turn ending on a tool call is forward progress -> guard resets.
|
|
3266
|
+
await turnEnd({ message: { stopReason: "stop", content: [{ type: "toolCall" }] }, toolResults: [] }, ctx);
|
|
3267
|
+
expect(orchestrator.consecutiveNudges).toBe(0);
|
|
3268
|
+
});
|
|
3269
|
+
});
|
|
3270
|
+
|
|
3271
|
+
describe("compaction", () => {
|
|
3272
|
+
it("compactAndTransition calls ctx.compact for phase transition", async () => {
|
|
3273
|
+
const cwd = makeTempDir();
|
|
3274
|
+
const { orchestrator } = await setupOrchestrator(cwd);
|
|
3275
|
+
const compactSpy = vi.fn((opts?: any) => {
|
|
3276
|
+
if (opts?.onComplete) opts.onComplete();
|
|
3277
|
+
});
|
|
3278
|
+
const ctx = makeCtx({ compact: compactSpy });
|
|
3279
|
+
|
|
3280
|
+
await orchestrator.startTask(ctx as any, "implement", "compaction");
|
|
3281
|
+
orchestrator.compactAndTransition(ctx as any, orchestrator.active!.dir, "plan");
|
|
3282
|
+
|
|
3283
|
+
expect(compactSpy).toHaveBeenCalledWith(expect.objectContaining({ customInstructions: expect.stringContaining("Phase transition") }));
|
|
3284
|
+
});
|
|
3285
|
+
|
|
3286
|
+
it("defers compaction until agent_end when not idle, then delivers Begin working", async () => {
|
|
3287
|
+
const cwd = makeTempDir();
|
|
3288
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
3289
|
+
const compactCbs: Array<{ onComplete?: () => void; onError?: (e: Error) => void }> = [];
|
|
3290
|
+
// Agent is mid-turn (tool in-flight): not idle, and compaction is deferred.
|
|
3291
|
+
const compactSpy = vi.fn((opts?: any) => { compactCbs.push(opts); });
|
|
3292
|
+
const ctx = makeCtx({ isIdle: vi.fn().mockReturnValue(false), compact: compactSpy });
|
|
3293
|
+
|
|
3294
|
+
await orchestrator.startTask(ctx as any, "implement", "deferred compaction");
|
|
3295
|
+
orchestrator.compactAndTransition(ctx as any, orchestrator.active!.dir, "implement");
|
|
3296
|
+
|
|
3297
|
+
// Not idle -> no compaction yet; controller is pending.
|
|
3298
|
+
expect(compactSpy).not.toHaveBeenCalled();
|
|
3299
|
+
|
|
3300
|
+
// Agent goes idle: agent_end fires compaction.
|
|
3301
|
+
await pi._handlers.get("agent_end")!({ type: "agent_end", messages: [] }, ctx);
|
|
3302
|
+
expect(compactSpy).toHaveBeenCalledTimes(1);
|
|
3303
|
+
|
|
3304
|
+
// Compaction throws the "too small" no-op: must still resume + deliver.
|
|
3305
|
+
compactCbs[0]!.onError!(new Error("Nothing to compact (session too small)"));
|
|
3306
|
+
|
|
3307
|
+
await vi.waitFor(() => {
|
|
3308
|
+
const sentBeginWorking = pi.sendUserMessage.mock.calls.some(
|
|
3309
|
+
(c: any[]) => c[0] === "[PI-PI] Entered implement phase. Begin working." && c[1]?.deliverAs === "followUp",
|
|
3310
|
+
);
|
|
3311
|
+
expect(sentBeginWorking).toBe(true);
|
|
3312
|
+
});
|
|
3313
|
+
expect(orchestrator.transitionController.getState()).toBe("running");
|
|
3314
|
+
});
|
|
3315
|
+
|
|
3316
|
+
it("session_before_compact returns the controller's transition summary", async () => {
|
|
3317
|
+
const cwd = makeTempDir();
|
|
3318
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
3319
|
+
await orchestrator.startTask(makeCtx() as any, "implement", "phase compact test");
|
|
3320
|
+
orchestrator.lastCtx = makeCtx({ isIdle: vi.fn().mockReturnValue(false) });
|
|
3321
|
+
|
|
3322
|
+
// Put the controller mid-compaction (not idle -> pending -> agent_end -> compacting).
|
|
3323
|
+
void orchestrator.transitionController.requestTransition({ kind: "phase", summary: "Phase summary text" });
|
|
3324
|
+
orchestrator.transitionController.onAgentEnd();
|
|
3325
|
+
expect(orchestrator.transitionController.isTransitioning()).toBe(true);
|
|
3326
|
+
|
|
3327
|
+
const beforeCompact = pi._handlers.get("session_before_compact")!;
|
|
3328
|
+
const result = await beforeCompact({ preparation: { firstKeptEntryId: "e1", tokensBefore: 123 } }, {});
|
|
3329
|
+
|
|
3330
|
+
expect(result.compaction.summary).toBe("Phase summary text");
|
|
3331
|
+
});
|
|
3332
|
+
|
|
3333
|
+
it("session_before_compact returns task done summary when transitioning", async () => {
|
|
3334
|
+
const cwd = makeTempDir();
|
|
3335
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
3336
|
+
orchestrator.lastCtx = makeCtx({ isIdle: vi.fn().mockReturnValue(false) });
|
|
3337
|
+
|
|
3338
|
+
void orchestrator.transitionController.requestTransition({ kind: "done", summary: "Task done summary" });
|
|
3339
|
+
orchestrator.transitionController.onAgentEnd();
|
|
3340
|
+
const beforeCompact = pi._handlers.get("session_before_compact")!;
|
|
3341
|
+
const result = await beforeCompact({ preparation: { firstKeptEntryId: "e2", tokensBefore: 456 } }, {});
|
|
3342
|
+
|
|
3343
|
+
expect(result.compaction.summary).toBe("Task done summary");
|
|
3344
|
+
});
|
|
3345
|
+
|
|
3346
|
+
it("session_before_compact re-injects artifacts after natural compaction", async () => {
|
|
3347
|
+
const cwd = makeTempDir();
|
|
3348
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
3349
|
+
const ctx = makeCtx();
|
|
3350
|
+
|
|
3351
|
+
await orchestrator.startTask(ctx as any, "implement", "reinject artifacts");
|
|
3352
|
+
writeFileSync(join(orchestrator.active!.dir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
|
|
3353
|
+
writeFileSync(join(orchestrator.active!.dir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
|
|
3354
|
+
|
|
3355
|
+
const beforeCompact = pi._handlers.get("session_before_compact")!;
|
|
3356
|
+
const result = await beforeCompact({ preparation: { firstKeptEntryId: "e3", tokensBefore: 789 } }, {});
|
|
3357
|
+
|
|
3358
|
+
expect(result).toBeUndefined();
|
|
3359
|
+
expect(pi.sendMessage).toHaveBeenCalledWith(
|
|
3360
|
+
expect.objectContaining({ customType: "pp-artifact-reinject", content: expect.stringContaining("USER_REQUEST.md") }),
|
|
3361
|
+
{ deliverAs: "steer" },
|
|
3362
|
+
);
|
|
3363
|
+
});
|
|
3364
|
+
});
|
|
3365
|
+
|
|
3366
|
+
describe("input blocking during await", () => {
|
|
3367
|
+
it("blocks user input during await_planners", async () => {
|
|
3368
|
+
const cwd = makeTempDir();
|
|
3369
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
3370
|
+
const ctx = makeCtx();
|
|
3371
|
+
|
|
3372
|
+
await orchestrator.startTask(ctx as any, "implement", "await planners");
|
|
3373
|
+
orchestrator.active!.state.step = "await_planners";
|
|
3374
|
+
const inputHandler = pi._handlers.get("input")!;
|
|
3375
|
+
|
|
3376
|
+
const result = await inputHandler({ source: "interactive" }, ctx);
|
|
3377
|
+
expect(result).toEqual({ action: "handled" });
|
|
3378
|
+
});
|
|
3379
|
+
|
|
3380
|
+
it("blocks user input during await_reviewers", async () => {
|
|
3381
|
+
const cwd = makeTempDir();
|
|
3382
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
3383
|
+
const ctx = makeCtx();
|
|
3384
|
+
|
|
3385
|
+
await orchestrator.startTask(ctx as any, "implement", "await reviewers");
|
|
3386
|
+
orchestrator.active!.state.step = "await_reviewers";
|
|
3387
|
+
const inputHandler = pi._handlers.get("input")!;
|
|
3388
|
+
|
|
3389
|
+
const result = await inputHandler({ source: "interactive" }, ctx);
|
|
3390
|
+
expect(result).toEqual({ action: "handled" });
|
|
3391
|
+
});
|
|
3392
|
+
});
|
|
3393
|
+
|
|
3394
|
+
describe("context injection", () => {
|
|
3395
|
+
it("injectContextAndArtifacts sends context files as steer messages", async () => {
|
|
3396
|
+
const cwd = makeTempDir();
|
|
3397
|
+
mkdirSync(join(cwd, ".pp", "context"), { recursive: true });
|
|
3398
|
+
writeFileSync(join(cwd, ".pp", "context", "main.md"), "---\ninject: context\nagents: [main]\n---\nContext body", "utf-8");
|
|
3399
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
3400
|
+
const ctx = makeCtx({ cwd });
|
|
3401
|
+
|
|
3402
|
+
await orchestrator.startTask(ctx as any, "implement", "inject context");
|
|
3403
|
+
pi.sendMessage.mockClear();
|
|
3404
|
+
orchestrator.injectContextAndArtifacts(orchestrator.active!.dir, orchestrator.active!.state.phase);
|
|
3405
|
+
|
|
3406
|
+
expect(pi.sendMessage).toHaveBeenCalledWith(
|
|
3407
|
+
expect.objectContaining({ customType: "pp-context", content: expect.stringContaining("Context body") }),
|
|
3408
|
+
{ deliverAs: "steer" },
|
|
3409
|
+
);
|
|
3410
|
+
});
|
|
3411
|
+
|
|
3412
|
+
it("injectContextAndArtifacts sends phase artifacts", async () => {
|
|
3413
|
+
const cwd = makeTempDir();
|
|
3414
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
3415
|
+
const ctx = makeCtx();
|
|
3416
|
+
|
|
3417
|
+
await orchestrator.startTask(ctx as any, "implement", "inject artifacts");
|
|
3418
|
+
writeFileSync(join(orchestrator.active!.dir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
|
|
3419
|
+
writeFileSync(join(orchestrator.active!.dir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
|
|
3420
|
+
pi.sendMessage.mockClear();
|
|
3421
|
+
orchestrator.injectContextAndArtifacts(orchestrator.active!.dir, orchestrator.active!.state.phase);
|
|
3422
|
+
|
|
3423
|
+
const artifactCalls = pi.sendMessage.mock.calls.filter((c: any[]) => c[0]?.customType === "pp-artifact");
|
|
3424
|
+
expect(artifactCalls.length).toBeGreaterThan(0);
|
|
3425
|
+
expect(artifactCalls.some((c: any[]) => String(c[0].content).includes("USER_REQUEST.md"))).toBe(true);
|
|
3426
|
+
});
|
|
3427
|
+
|
|
3428
|
+
it("registerAgents appends context to agent prompts", async () => {
|
|
3429
|
+
const cwd = makeTempDir();
|
|
3430
|
+
mkdirSync(join(cwd, ".pp", "context"), { recursive: true });
|
|
3431
|
+
writeFileSync(join(cwd, ".pp", "context", "explore-system.md"), "---\ninject: system\nagents: [explore]\n---\nExplore context", "utf-8");
|
|
3432
|
+
const { orchestrator } = await setupOrchestrator(cwd);
|
|
3433
|
+
const ctx = makeCtx({ cwd });
|
|
3434
|
+
|
|
3435
|
+
await orchestrator.startTask(ctx as any, "implement", "register agents");
|
|
3436
|
+
(registerAgentDefinitions as any).mockClear();
|
|
3437
|
+
orchestrator.registerAgents();
|
|
3438
|
+
|
|
3439
|
+
const defs = (registerAgentDefinitions as any).mock.calls[0][1] as Array<{ type: string; prompt: string }>;
|
|
3440
|
+
const explore = defs.find((d) => d.type === "explore");
|
|
3441
|
+
expect(explore?.prompt).toContain("Explore context");
|
|
3442
|
+
});
|
|
3443
|
+
});
|
|
3444
|
+
|
|
3445
|
+
describe("session lifecycle", () => {
|
|
3446
|
+
it("session_start registers tools and commands", async () => {
|
|
3447
|
+
const cwd = makeTempDir();
|
|
3448
|
+
const { pi } = await setupOrchestrator(cwd);
|
|
3449
|
+
|
|
3450
|
+
expect(pi.registerCommand).toHaveBeenCalledWith("pp", expect.anything());
|
|
3451
|
+
const toolNames = [...pi._tools.keys()];
|
|
3452
|
+
expect(toolNames).toContain("pp_phase_complete");
|
|
3453
|
+
expect(toolNames).toContain("pp_commit");
|
|
3454
|
+
expect(toolNames).toContain("pp_register_repo");
|
|
3455
|
+
});
|
|
3456
|
+
|
|
3457
|
+
it("session_start detects paused tasks and notifies", async () => {
|
|
3458
|
+
const cwd = makeTempDir();
|
|
3459
|
+
createTask(cwd, "implement", "Paused task");
|
|
3460
|
+
const pi = makePi();
|
|
3461
|
+
const orchestrator = new Orchestrator(pi as any);
|
|
3462
|
+
registerEventHandlers(orchestrator);
|
|
3463
|
+
registerCommandHandlers(orchestrator);
|
|
3464
|
+
const ctx = makeCtx({ cwd });
|
|
3465
|
+
|
|
3466
|
+
const sessionStart = pi._handlers.get("session_start")!;
|
|
3467
|
+
await sessionStart({}, ctx);
|
|
3468
|
+
|
|
3469
|
+
expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("Paused task"), "info");
|
|
3470
|
+
});
|
|
3471
|
+
|
|
3472
|
+
it("session_start loads config", async () => {
|
|
3473
|
+
const cwd = makeTempDir();
|
|
3474
|
+
const { orchestrator } = await setupOrchestrator(cwd);
|
|
3475
|
+
|
|
3476
|
+
expect(orchestrator.config).toBeDefined();
|
|
3477
|
+
expect(orchestrator.config.agents.orchestrators.implement.model).toBe("test/model");
|
|
3478
|
+
});
|
|
3479
|
+
|
|
3480
|
+
it("session_shutdown dumps usage summary", async () => {
|
|
3481
|
+
const cwd = makeTempDir();
|
|
3482
|
+
const { pi } = await setupOrchestrator(cwd);
|
|
3483
|
+
const dumpSpy = vi.spyOn(usageTrackerModule, "dumpUsageSummary").mockImplementation(() => undefined);
|
|
3484
|
+
const shutdown = pi._handlers.get("session_shutdown")!;
|
|
3485
|
+
|
|
3486
|
+
await shutdown({}, { sessionManager: { getSessionId: () => "session-id-1" } });
|
|
3487
|
+
|
|
3488
|
+
expect(dumpSpy).toHaveBeenCalledTimes(1);
|
|
3489
|
+
expect(dumpSpy).toHaveBeenCalledWith(expect.anything(), "session-id-1");
|
|
3490
|
+
});
|
|
3491
|
+
});
|
|
3492
|
+
|
|
3493
|
+
describe("menu contracts", () => {
|
|
3494
|
+
it("settings menu without active task has new top-level sections", async () => {
|
|
3495
|
+
const cwd = makeTempDir();
|
|
3496
|
+
const { pi } = await setupOrchestrator(cwd);
|
|
3497
|
+
const ctx = makeCtx();
|
|
3498
|
+
|
|
3499
|
+
menu
|
|
3500
|
+
.expect({ question: "/pp", options: { include: ["Settings"] }, choose: "Settings" })
|
|
3501
|
+
.expect({ question: "Settings", options: { exact: ["General", "Agents", "Commands", "Performance", "LSP", "Back"] }, choose: "Back" })
|
|
3502
|
+
.expect({ question: "/pp", options: { include: ["Back"] }, choose: "Back" });
|
|
3503
|
+
|
|
3504
|
+
const pp = getCommand(pi, "pp");
|
|
3505
|
+
await pp(undefined, ctx);
|
|
3506
|
+
});
|
|
3507
|
+
|
|
3508
|
+
it("info menu shows Doctor and hides LSP", async () => {
|
|
3509
|
+
const cwd = makeTempDir();
|
|
3510
|
+
const { pi } = await setupOrchestrator(cwd);
|
|
3511
|
+
const ctx = makeCtx();
|
|
3512
|
+
|
|
3513
|
+
menu
|
|
3514
|
+
.expect({ question: "/pp", options: { include: ["Info"] }, choose: "Info" })
|
|
3515
|
+
.expect({
|
|
3516
|
+
question: "Info",
|
|
3517
|
+
options: {
|
|
3518
|
+
include: ["Subagents", "Usage", "Doctor", "Back"],
|
|
3519
|
+
exclude: ["LSP"],
|
|
3520
|
+
},
|
|
3521
|
+
choose: "Back",
|
|
3522
|
+
})
|
|
3523
|
+
.expect({ question: "/pp", options: { include: ["Back"] }, choose: "Back" });
|
|
3524
|
+
|
|
3525
|
+
const pp = getCommand(pi, "pp");
|
|
3526
|
+
await pp(undefined, ctx);
|
|
3527
|
+
});
|
|
3528
|
+
|
|
3529
|
+
it("info doctor option calls runDoctor", async () => {
|
|
3530
|
+
const cwd = makeTempDir();
|
|
3531
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
3532
|
+
const ctx = makeCtx();
|
|
3533
|
+
const runDoctorSpy = vi.mocked(doctorModule.runDoctor);
|
|
3534
|
+
|
|
3535
|
+
menu
|
|
3536
|
+
.expect({ question: "/pp", options: { include: ["Info"] }, choose: "Info" })
|
|
3537
|
+
.expect({ question: "Info", options: { include: ["Doctor", "Back"] }, choose: "Doctor" })
|
|
3538
|
+
.expect({ question: "Info", options: { include: ["Back"] }, choose: "Back" })
|
|
3539
|
+
.expect({ question: "/pp", options: { include: ["Back"] }, choose: "Back" });
|
|
3540
|
+
|
|
3541
|
+
const pp = getCommand(pi, "pp");
|
|
3542
|
+
await pp(undefined, ctx);
|
|
3543
|
+
|
|
3544
|
+
expect(runDoctorSpy).toHaveBeenCalledTimes(1);
|
|
3545
|
+
expect(runDoctorSpy).toHaveBeenCalledWith(orchestrator, ctx);
|
|
3546
|
+
});
|
|
3547
|
+
|
|
3548
|
+
it("settings lsp menu restarts servers", async () => {
|
|
3549
|
+
const cwd = makeTempDir();
|
|
3550
|
+
const { pi } = await setupOrchestrator(cwd);
|
|
3551
|
+
const ctx = makeCtx();
|
|
3552
|
+
const restart = vi.fn(async () => undefined);
|
|
3553
|
+
(globalThis as any)[Symbol.for("pi-lsp:api")] = { restart };
|
|
3554
|
+
|
|
3555
|
+
menu
|
|
3556
|
+
.expect({ question: "/pp", options: { include: ["Settings"] }, choose: "Settings" })
|
|
3557
|
+
.expect({ question: "Settings", options: { include: ["LSP"] }, choose: "LSP" })
|
|
3558
|
+
.expect({ question: "LSP", options: { exact: ["Restart all servers", "Back"] }, choose: "Restart all servers" })
|
|
3559
|
+
.expect({ question: "LSP", options: { include: ["Back"] }, choose: "Back" })
|
|
3560
|
+
.expect({ question: "Settings", options: { include: ["Back"] }, choose: "Back" })
|
|
3561
|
+
.expect({ question: "/pp", options: { include: ["Back"] }, choose: "Back" });
|
|
3562
|
+
|
|
3563
|
+
const pp = getCommand(pi, "pp");
|
|
3564
|
+
await pp(undefined, ctx);
|
|
3565
|
+
|
|
3566
|
+
expect(restart).toHaveBeenCalledTimes(1);
|
|
3567
|
+
});
|
|
3568
|
+
|
|
3569
|
+
it("settings lsp restart warns when API is missing", async () => {
|
|
3570
|
+
const cwd = makeTempDir();
|
|
3571
|
+
const { pi } = await setupOrchestrator(cwd);
|
|
3572
|
+
const ctx = makeCtx();
|
|
3573
|
+
delete (globalThis as any)[Symbol.for("pi-lsp:api")];
|
|
3574
|
+
|
|
3575
|
+
menu
|
|
3576
|
+
.expect({ question: "/pp", options: { include: ["Settings"] }, choose: "Settings" })
|
|
3577
|
+
.expect({ question: "Settings", options: { include: ["LSP"] }, choose: "LSP" })
|
|
3578
|
+
.expect({ question: "LSP", options: { exact: ["Restart all servers", "Back"] }, choose: "Restart all servers" })
|
|
3579
|
+
.expect({ question: "LSP", options: { include: ["Back"] }, choose: "Back" })
|
|
3580
|
+
.expect({ question: "Settings", options: { include: ["Back"] }, choose: "Back" })
|
|
3581
|
+
.expect({ question: "/pp", options: { include: ["Back"] }, choose: "Back" });
|
|
3582
|
+
|
|
3583
|
+
const pp = getCommand(pi, "pp");
|
|
3584
|
+
await pp(undefined, ctx);
|
|
3585
|
+
|
|
3586
|
+
expect(ctx.ui.notify).toHaveBeenCalledWith("LSP API is not available.", "warning");
|
|
3587
|
+
});
|
|
3588
|
+
|
|
3589
|
+
it("settings agents submenu has orchestrators and subagents", async () => {
|
|
3590
|
+
const cwd = makeTempDir();
|
|
3591
|
+
const { pi } = await setupOrchestrator(cwd);
|
|
3592
|
+
const ctx = makeCtx();
|
|
3593
|
+
|
|
3594
|
+
menu
|
|
3595
|
+
.expect({ question: "/pp", options: { include: ["Settings"] }, choose: "Settings" })
|
|
3596
|
+
.expect({ question: "Settings", options: { include: ["Agents", "Back"] }, choose: "Agents" })
|
|
3597
|
+
.expect({ question: "Agents", options: { exact: ["Orchestrators", "Subagents", "Back"] }, choose: "Back" })
|
|
3598
|
+
.expect({ question: "Settings", options: { include: ["Back"] }, choose: "Back" })
|
|
3599
|
+
.expect({ question: "/pp", options: { include: ["Back"] }, choose: "Back" });
|
|
3600
|
+
|
|
3601
|
+
const pp = getCommand(pi, "pp");
|
|
3602
|
+
await pp(undefined, ctx);
|
|
3603
|
+
});
|
|
3604
|
+
|
|
3605
|
+
it("active task menu in guided implement phase has exact options", async () => {
|
|
3606
|
+
const cwd = makeTempDir();
|
|
3607
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
3608
|
+
const ctx = makeCtx();
|
|
3609
|
+
|
|
3610
|
+
await orchestrator.startTask(ctx as any, "implement", "contract");
|
|
3611
|
+
|
|
3612
|
+
menu.expect({
|
|
3613
|
+
question: m.taskMenu("implement", "brainstorm"),
|
|
3614
|
+
options: {
|
|
3615
|
+
exact: ["Next", "Review", "Info", "Settings", "Back"],
|
|
3616
|
+
},
|
|
3617
|
+
choose: "Back",
|
|
3618
|
+
});
|
|
3619
|
+
|
|
3620
|
+
const pp = getCommand(pi, "pp");
|
|
3621
|
+
await pp(undefined, ctx);
|
|
3622
|
+
});
|
|
3623
|
+
|
|
3624
|
+
it("active task menu hides Review while waiting for planners", async () => {
|
|
3625
|
+
const cwd = makeTempDir();
|
|
3626
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
3627
|
+
const ctx = makeCtx();
|
|
3628
|
+
|
|
3629
|
+
await orchestrator.startTask(ctx as any, "implement", "contract waiting");
|
|
3630
|
+
orchestrator.active!.state.phase = "plan";
|
|
3631
|
+
orchestrator.active!.state.step = "await_planners";
|
|
3632
|
+
saveTask(orchestrator.active!.dir, orchestrator.active!.state);
|
|
3633
|
+
|
|
3634
|
+
menu.expect({
|
|
3635
|
+
question: m.taskMenu("implement", "plan"),
|
|
3636
|
+
options: {
|
|
3637
|
+
exact: ["Next", "Info", "Settings", "Back"],
|
|
3638
|
+
},
|
|
3639
|
+
choose: "Back",
|
|
3640
|
+
});
|
|
3641
|
+
|
|
3642
|
+
const pp = getCommand(pi, "pp");
|
|
3643
|
+
await pp(undefined, ctx);
|
|
3644
|
+
});
|
|
3645
|
+
|
|
3646
|
+
it("active task menu in autonomous mode has exact options", async () => {
|
|
3647
|
+
const cwd = makeTempDir();
|
|
3648
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
3649
|
+
const ctx = makeCtx();
|
|
3650
|
+
|
|
3651
|
+
await orchestrator.startTask(ctx as any, "implement", "contract auto", undefined, undefined, "autonomous");
|
|
3652
|
+
|
|
3653
|
+
// brainstorm is a forced-interactive phase, so even an autonomous task
|
|
3654
|
+
// shows the full interactive menu (Next/Review).
|
|
3655
|
+
menu.expect({
|
|
3656
|
+
question: m.taskMenu("implement", "brainstorm"),
|
|
3657
|
+
options: {
|
|
3658
|
+
exact: ["Next", "Review", "Info", "Settings", "Back"],
|
|
3659
|
+
},
|
|
3660
|
+
choose: "Back",
|
|
3661
|
+
});
|
|
3662
|
+
|
|
3663
|
+
const pp = getCommand(pi, "pp");
|
|
3664
|
+
await pp(undefined, ctx);
|
|
3665
|
+
});
|
|
3666
|
+
|
|
3667
|
+
it("autonomous debug task shows interactive menu in debug phase", async () => {
|
|
3668
|
+
const cwd = makeTempDir();
|
|
3669
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
3670
|
+
const ctx = makeCtx();
|
|
3671
|
+
|
|
3672
|
+
await orchestrator.startTask(ctx as any, "debug", "contract debug auto", undefined, undefined, "autonomous");
|
|
3673
|
+
|
|
3674
|
+
menu.expect({
|
|
3675
|
+
question: m.taskMenu("debug", "debug"),
|
|
3676
|
+
options: {
|
|
3677
|
+
exact: ["Next", "Review", "Info", "Settings", "Back"],
|
|
3678
|
+
},
|
|
3679
|
+
choose: "Back",
|
|
3680
|
+
});
|
|
3681
|
+
|
|
3682
|
+
const pp = getCommand(pi, "pp");
|
|
3683
|
+
await pp(undefined, ctx);
|
|
3684
|
+
});
|
|
3685
|
+
|
|
3686
|
+
it("autonomous review task shows interactive menu in review phase", async () => {
|
|
3687
|
+
const cwd = makeTempDir();
|
|
3688
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
3689
|
+
const ctx = makeCtx();
|
|
3690
|
+
|
|
3691
|
+
await orchestrator.startTask(ctx as any, "review", "contract review auto", undefined, undefined, "autonomous");
|
|
3692
|
+
|
|
3693
|
+
menu.expect({
|
|
3694
|
+
question: m.taskMenu("review", "review"),
|
|
3695
|
+
options: {
|
|
3696
|
+
exact: ["Next", "Review", "Info", "Settings", "Back"],
|
|
3697
|
+
},
|
|
3698
|
+
choose: "Back",
|
|
3699
|
+
});
|
|
3700
|
+
|
|
3701
|
+
const pp = getCommand(pi, "pp");
|
|
3702
|
+
await pp(undefined, ctx);
|
|
3703
|
+
});
|
|
3704
|
+
|
|
3705
|
+
it("autonomous menu 'Complete task' completes the task", async () => {
|
|
3706
|
+
const cwd = makeTempDir();
|
|
3707
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
3708
|
+
const ctx = makeCtx();
|
|
3709
|
+
|
|
3710
|
+
await orchestrator.startTask(ctx as any, "implement", "contract complete", undefined, undefined, "autonomous");
|
|
3711
|
+
const taskDir = orchestrator.active!.dir;
|
|
3712
|
+
|
|
3713
|
+
expectActiveTaskNext(menu, "Complete");
|
|
3714
|
+
|
|
3715
|
+
const pp = getCommand(pi, "pp");
|
|
3716
|
+
await pp(undefined, ctx);
|
|
3717
|
+
|
|
3718
|
+
expect(orchestrator.active).toBeNull();
|
|
3719
|
+
expect(loadTask(taskDir).phase).toBe("done");
|
|
3720
|
+
});
|
|
3721
|
+
|
|
3722
|
+
it("autonomous menu 'Pause task' pauses without completing", async () => {
|
|
3723
|
+
const cwd = makeTempDir();
|
|
3724
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
3725
|
+
const ctx = makeCtx();
|
|
3726
|
+
|
|
3727
|
+
await orchestrator.startTask(ctx as any, "implement", "contract pause", undefined, undefined, "autonomous");
|
|
3728
|
+
const taskDir = orchestrator.active!.dir;
|
|
3729
|
+
|
|
3730
|
+
expectActiveTaskNext(menu, "Pause");
|
|
3731
|
+
|
|
3732
|
+
const pp = getCommand(pi, "pp");
|
|
3733
|
+
await pp(undefined, ctx);
|
|
3734
|
+
|
|
3735
|
+
expect(orchestrator.active).toBeNull();
|
|
3736
|
+
expect(loadTask(taskDir).phase).not.toBe("done");
|
|
3737
|
+
});
|
|
3738
|
+
|
|
3739
|
+
it("completing a task mid-review clears reviewCycle without incrementing reviewPass", async () => {
|
|
3740
|
+
const cwd = makeTempDir();
|
|
3741
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
3742
|
+
const ctx = makeCtx();
|
|
3743
|
+
|
|
3744
|
+
await orchestrator.startTask(ctx as any, "implement", "complete mid review", undefined, undefined, "autonomous");
|
|
3745
|
+
const taskDir = orchestrator.active!.dir;
|
|
3746
|
+
orchestrator.active!.state.reviewCycle = { kind: "auto", step: "apply_feedback", pass: 1 };
|
|
3747
|
+
orchestrator.active!.state.step = "apply_feedback";
|
|
3748
|
+
orchestrator.active!.state.reviewPass = 0;
|
|
3749
|
+
orchestrator.active!.state.reviewPassByKind = {};
|
|
3750
|
+
saveTask(taskDir, orchestrator.active!.state);
|
|
3751
|
+
|
|
3752
|
+
expectActiveTaskNext(menu, "Complete");
|
|
3753
|
+
|
|
3754
|
+
const pp = getCommand(pi, "pp");
|
|
3755
|
+
await pp(undefined, ctx);
|
|
3756
|
+
|
|
3757
|
+
const finalState = loadTask(taskDir);
|
|
3758
|
+
expect(finalState.reviewCycle).toBeNull();
|
|
3759
|
+
expect(finalState.reviewPass).toBe(0);
|
|
3760
|
+
expect(finalState.reviewPassByKind?.implement?.auto ?? 0).toBe(0);
|
|
3761
|
+
});
|
|
3762
|
+
|
|
3763
|
+
it("pausing a task mid-review clears reviewCycle without incrementing reviewPass", async () => {
|
|
3764
|
+
const cwd = makeTempDir();
|
|
3765
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
3766
|
+
const ctx = makeCtx();
|
|
3767
|
+
|
|
3768
|
+
await orchestrator.startTask(ctx as any, "implement", "pause mid review", undefined, undefined, "autonomous");
|
|
3769
|
+
const taskDir = orchestrator.active!.dir;
|
|
3770
|
+
orchestrator.active!.state.reviewCycle = { kind: "auto", step: "apply_feedback", pass: 1 };
|
|
3771
|
+
orchestrator.active!.state.step = "apply_feedback";
|
|
3772
|
+
orchestrator.active!.state.reviewPass = 0;
|
|
3773
|
+
orchestrator.active!.state.reviewPassByKind = {};
|
|
3774
|
+
saveTask(taskDir, orchestrator.active!.state);
|
|
3775
|
+
|
|
3776
|
+
expectActiveTaskNext(menu, "Pause");
|
|
3777
|
+
|
|
3778
|
+
const pp = getCommand(pi, "pp");
|
|
3779
|
+
await pp(undefined, ctx);
|
|
3780
|
+
|
|
3781
|
+
const finalState = loadTask(taskDir);
|
|
3782
|
+
expect(finalState.reviewCycle).toBeNull();
|
|
3783
|
+
expect(finalState.reviewPass).toBe(0);
|
|
3784
|
+
expect(finalState.reviewPassByKind?.implement?.auto ?? 0).toBe(0);
|
|
3785
|
+
});
|
|
3786
|
+
|
|
3787
|
+
it("quick task menu has exact options", async () => {
|
|
3788
|
+
const cwd = makeTempDir();
|
|
3789
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
3790
|
+
const ctx = makeCtx();
|
|
3791
|
+
|
|
3792
|
+
await orchestrator.startTask(ctx as any, "quick", "contract quick");
|
|
3793
|
+
|
|
3794
|
+
menu.expect({
|
|
3795
|
+
question: m.taskMenu("quick", "quick"),
|
|
3796
|
+
options: {
|
|
3797
|
+
exact: ["Complete", "Pause", "Info", "Settings", "Back"],
|
|
3798
|
+
},
|
|
3799
|
+
choose: "Back",
|
|
3800
|
+
});
|
|
3801
|
+
|
|
3802
|
+
const pp = getCommand(pi, "pp");
|
|
3803
|
+
await pp(undefined, ctx);
|
|
3804
|
+
});
|
|
3805
|
+
|
|
3806
|
+
it("Next submenu options change when continue is unavailable", async () => {
|
|
3807
|
+
const cwd = makeTempDir();
|
|
3808
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
3809
|
+
const ctx = makeCtx();
|
|
3810
|
+
|
|
3811
|
+
await orchestrator.startTask(ctx as any, "implement", "contract next");
|
|
3812
|
+
menu
|
|
3813
|
+
.expect({ question: m.taskMenu("implement", "brainstorm"), options: { include: ["Next"] }, choose: "Next" })
|
|
3814
|
+
.expect({ question: "Next", options: { exact: ["Continue to plan & implement", "Complete", "Pause", "Back"] }, choose: "Back" })
|
|
3815
|
+
.expect({ question: m.taskMenu("implement", "brainstorm"), options: { include: ["Back"] }, choose: "Back" });
|
|
3816
|
+
|
|
3817
|
+
const pp = getCommand(pi, "pp");
|
|
3818
|
+
await pp(undefined, ctx);
|
|
3819
|
+
|
|
3820
|
+
orchestrator.active!.state.phase = "implement";
|
|
3821
|
+
orchestrator.active!.state.step = "llm_work";
|
|
3822
|
+
saveTask(orchestrator.active!.dir, orchestrator.active!.state);
|
|
3823
|
+
|
|
3824
|
+
menu
|
|
3825
|
+
.expect({ question: m.taskMenu("implement", "implement"), options: { include: ["Next"] }, choose: "Next" })
|
|
3826
|
+
.expect({ question: "Next", options: { exact: ["Complete", "Pause", "Back"] }, choose: "Back" })
|
|
3827
|
+
.expect({ question: m.taskMenu("implement", "implement"), options: { include: ["Back"] }, choose: "Back" });
|
|
3828
|
+
|
|
3829
|
+
await pp(undefined, ctx);
|
|
3830
|
+
});
|
|
3831
|
+
|
|
3832
|
+
it("Review submenu has exact options", async () => {
|
|
3833
|
+
const cwd = makeTempDir();
|
|
3834
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
3835
|
+
const ctx = makeCtx();
|
|
3836
|
+
|
|
3837
|
+
await orchestrator.startTask(ctx as any, "implement", "contract review");
|
|
3838
|
+
|
|
3839
|
+
menu
|
|
3840
|
+
.expect({ question: m.taskMenu("implement", "brainstorm"), options: { include: ["Review"] }, choose: "Review" })
|
|
3841
|
+
.expect({ question: "Review", options: { exact: [m.autoReview, "Review on my own", "Back"] }, choose: "Back" })
|
|
3842
|
+
.expect({ question: m.taskMenu("implement", "brainstorm"), options: { include: ["Back"] }, choose: "Back" });
|
|
3843
|
+
|
|
3844
|
+
const pp = getCommand(pi, "pp");
|
|
3845
|
+
await pp(undefined, ctx);
|
|
3846
|
+
});
|
|
3847
|
+
|
|
3848
|
+
it("mode picker options are exact", async () => {
|
|
3849
|
+
const cwd = makeTempDir();
|
|
3850
|
+
const { pi } = await setupOrchestrator(cwd);
|
|
3851
|
+
const ctx = makeCtx();
|
|
3852
|
+
|
|
3853
|
+
menu
|
|
3854
|
+
.expect({ question: "/pp", options: { include: ["Task"] }, choose: "Task" })
|
|
3855
|
+
.expect({ question: "Task", options: { include: ["Implement"] }, choose: "Implement" })
|
|
3856
|
+
.expect({ question: "Implement", options: { include: ["New"] }, choose: "New" })
|
|
3857
|
+
.expect({ question: "Mode", options: { exact: ["Guided", "Autonomous", "Back"] }, choose: "Back" })
|
|
3858
|
+
.expect({ question: "Implement", options: { include: ["Back"] }, choose: "Back" })
|
|
3859
|
+
.expect({ question: "Task", options: { include: ["Back"] }, choose: "Back" })
|
|
3860
|
+
.expect({ question: "/pp", options: { include: ["Back"] }, choose: "Back" });
|
|
3861
|
+
|
|
3862
|
+
const pp = getCommand(pi, "pp");
|
|
3863
|
+
await pp(undefined, ctx);
|
|
3864
|
+
});
|
|
3865
|
+
});
|
|
3866
|
+
|
|
3867
|
+
describe("full user flows", () => {
|
|
3868
|
+
it("guided implement happy path", async () => {
|
|
3869
|
+
const cwd = makeTempDir();
|
|
3870
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
3871
|
+
const ctx = makeCtx();
|
|
3872
|
+
|
|
3873
|
+
await orchestrator.startTask(ctx as any, "implement", "guided flow", undefined, undefined, "guided");
|
|
3874
|
+
const taskDir = orchestrator.active!.dir;
|
|
3875
|
+
writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
|
|
3876
|
+
writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
|
|
3877
|
+
|
|
3878
|
+
expectBrainstormToPlan(menu);
|
|
3879
|
+
const ppPhaseComplete = getTool(pi, "pp_phase_complete");
|
|
3880
|
+
await ppPhaseComplete.execute("flow-guided-1", { summary: "brainstorm done" }, undefined, undefined, ctx);
|
|
3881
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
3882
|
+
|
|
3883
|
+
const plansDir = join(taskDir, "plans");
|
|
3884
|
+
mkdirSync(plansDir, { recursive: true });
|
|
3885
|
+
emitSubagentCreated(pi, "flow-guided-planner", "Planner (test)");
|
|
3886
|
+
writeFileSync(join(plansDir, `${Math.floor(Date.now() / 1000)}_test.md`), makeValidPlan(["- [ ] P1. Draft — Done when: draft exists"]), "utf-8");
|
|
3887
|
+
emitSubagentCompleted(pi, "flow-guided-planner", "Planner (test)");
|
|
3888
|
+
writeFileSync(join(plansDir, `${Math.floor(Date.now() / 1000) + 1}_synthesized.md`), makeValidPlan(["- [x] P1. Ready — Done when: checked"]), "utf-8");
|
|
3889
|
+
|
|
3890
|
+
expectPlanToImplement(menu);
|
|
3891
|
+
await ppPhaseComplete.execute("flow-guided-2", { summary: "plan done" }, undefined, undefined, ctx);
|
|
3892
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
3893
|
+
|
|
3894
|
+
expectImplementToDone(menu);
|
|
3895
|
+
await ppPhaseComplete.execute("flow-guided-3", { summary: "implement done" }, undefined, undefined, ctx);
|
|
3896
|
+
|
|
3897
|
+
expect(orchestrator.active).toBeNull();
|
|
3898
|
+
expect(loadTask(taskDir).phase).toBe("done");
|
|
3899
|
+
});
|
|
3900
|
+
|
|
3901
|
+
it("autonomous implement happy path", async () => {
|
|
3902
|
+
const cwd = makeTempDir();
|
|
3903
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
3904
|
+
const ctx = makeCtx();
|
|
3905
|
+
|
|
3906
|
+
await orchestrator.startTask(ctx as any, "implement", "autonomous flow", undefined, undefined, "autonomous");
|
|
3907
|
+
const taskDir = orchestrator.active!.dir;
|
|
3908
|
+
orchestrator.active!.state.autonomousConfig = {
|
|
3909
|
+
phases: {
|
|
3910
|
+
brainstorm: { reviewPreset: "regular", maxReviewPasses: 0 },
|
|
3911
|
+
plan: { plannerPreset: "regular", reviewPreset: "regular", maxReviewPasses: 0 },
|
|
3912
|
+
implement: { reviewPreset: "regular", maxReviewPasses: 1 },
|
|
3913
|
+
},
|
|
3914
|
+
};
|
|
3915
|
+
saveTask(taskDir, orchestrator.active!.state);
|
|
3916
|
+
|
|
3917
|
+
writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
|
|
3918
|
+
writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
|
|
3919
|
+
|
|
3920
|
+
const ppPhaseComplete = getTool(pi, "pp_phase_complete");
|
|
3921
|
+
await ppPhaseComplete.execute("flow-auto-1", { summary: "brainstorm done" }, undefined, undefined, ctx);
|
|
3922
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
3923
|
+
|
|
3924
|
+
const plansDir = join(taskDir, "plans");
|
|
3925
|
+
mkdirSync(plansDir, { recursive: true });
|
|
3926
|
+
emitSubagentCreated(pi, "flow-auto-planner", "Planner (test)");
|
|
3927
|
+
writeFileSync(join(plansDir, `${Math.floor(Date.now() / 1000)}_test.md`), makeValidPlan(["- [ ] P1. Draft — Done when: draft exists"]), "utf-8");
|
|
3928
|
+
emitSubagentCompleted(pi, "flow-auto-planner", "Planner (test)");
|
|
3929
|
+
writeFileSync(join(plansDir, `${Math.floor(Date.now() / 1000) + 1}_synthesized.md`), makeValidPlan(["- [x] P1. Ready — Done when: checked"]), "utf-8");
|
|
3930
|
+
|
|
3931
|
+
await ppPhaseComplete.execute("flow-auto-2", { summary: "plan done" }, undefined, undefined, ctx);
|
|
3932
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
3933
|
+
|
|
3934
|
+
const firstReview = await ppPhaseComplete.execute("flow-auto-3", { summary: "implement done" }, undefined, undefined, ctx);
|
|
3935
|
+
expect(firstReview.content[0].text).toMatch(/Reviews are running|Started review cycle pass/);
|
|
3936
|
+
|
|
3937
|
+
const reviewsDir = join(taskDir, "code-reviews");
|
|
3938
|
+
mkdirSync(reviewsDir, { recursive: true });
|
|
3939
|
+
writeFileSync(join(reviewsDir, `${Math.floor(Date.now() / 1000)}_test_round-1.md`), "LGTM", "utf-8");
|
|
3940
|
+
emitSubagentCreated(pi, "flow-auto-reviewer", "Code reviewer (test)");
|
|
3941
|
+
emitSubagentCompleted(pi, "flow-auto-reviewer", "Code reviewer (test)");
|
|
3942
|
+
|
|
3943
|
+
await ppPhaseComplete.execute("flow-auto-4", { summary: "feedback applied" }, undefined, undefined, ctx);
|
|
3944
|
+
|
|
3945
|
+
expect(orchestrator.active).toBeNull();
|
|
3946
|
+
expect(loadTask(taskDir).phase).toBe("done");
|
|
3947
|
+
});
|
|
3948
|
+
|
|
3949
|
+
it("quick task happy path", async () => {
|
|
3950
|
+
const cwd = makeTempDir();
|
|
3951
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
3952
|
+
const ctx = makeCtx();
|
|
3953
|
+
|
|
3954
|
+
await orchestrator.startTask(ctx as any, "quick", "quick flow");
|
|
3955
|
+
const taskDir = orchestrator.active!.dir;
|
|
3956
|
+
|
|
3957
|
+
expectQuickMenu(menu, "Complete");
|
|
3958
|
+
const ppPhaseComplete = getTool(pi, "pp_phase_complete");
|
|
3959
|
+
await ppPhaseComplete.execute("flow-quick-1", { summary: "quick done" }, undefined, undefined, ctx);
|
|
3960
|
+
|
|
3961
|
+
expect(orchestrator.active).toBeNull();
|
|
3962
|
+
expect(loadTask(taskDir).phase).toBe("done");
|
|
3963
|
+
});
|
|
3964
|
+
});
|