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