@ilya-lesikov/pi-pi 0.7.0 → 0.9.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-plannotator/apps/pi-extension/README.md +6 -5
- package/3p/pi-plannotator/apps/pi-extension/plannotator-browser.ts +25 -10
- package/3p/pi-plannotator/apps/pi-extension/plannotator-events.code-review.test.ts +172 -0
- package/3p/pi-plannotator/apps/pi-extension/plannotator-events.ts +50 -12
- package/3p/pi-plannotator/apps/pi-extension/server/project.test.ts +45 -0
- package/3p/pi-plannotator/apps/pi-extension/server/project.ts +7 -6
- package/3p/pi-plannotator/apps/pi-extension/server/serverReview.ts +41 -25
- package/3p/pi-subagents/src/agent-manager.ts +34 -1
- package/3p/pi-subagents/src/agent-runner.ts +66 -33
- package/3p/pi-subagents/src/index.ts +3 -38
- package/3p/pi-subagents/src/types.ts +4 -0
- package/extensions/orchestrator/agents/advisor.ts +35 -0
- package/extensions/orchestrator/agents/brainstorm-reviewer.ts +7 -7
- package/extensions/orchestrator/agents/code-reviewer.ts +27 -11
- package/extensions/orchestrator/agents/constraints.test.ts +82 -0
- package/extensions/orchestrator/agents/constraints.ts +66 -2
- package/extensions/orchestrator/agents/deep-debugger.ts +35 -0
- package/extensions/orchestrator/agents/plan-reviewer.ts +7 -7
- package/extensions/orchestrator/agents/planner.ts +9 -8
- package/extensions/orchestrator/agents/prompts.test.ts +120 -0
- package/extensions/orchestrator/agents/reviewer.ts +48 -0
- package/extensions/orchestrator/agents/task.ts +6 -20
- package/extensions/orchestrator/agents/tool-routing.ts +39 -1
- package/extensions/orchestrator/ai-comment-cleanup.test.ts +89 -0
- package/extensions/orchestrator/ai-comment-cleanup.ts +73 -0
- package/extensions/orchestrator/command-handlers.test.ts +47 -0
- package/extensions/orchestrator/command-handlers.ts +44 -28
- package/extensions/orchestrator/config.test.ts +40 -1
- package/extensions/orchestrator/config.ts +18 -2
- package/extensions/orchestrator/context.test.ts +54 -0
- package/extensions/orchestrator/context.ts +81 -2
- package/extensions/orchestrator/custom-footer.test.ts +91 -0
- package/extensions/orchestrator/custom-footer.ts +20 -20
- package/extensions/orchestrator/event-handlers.test.ts +412 -2
- package/extensions/orchestrator/event-handlers.ts +556 -227
- package/extensions/orchestrator/flant-infra.test.ts +25 -0
- package/extensions/orchestrator/flant-infra.ts +91 -0
- package/extensions/orchestrator/index.ts +1 -1
- package/extensions/orchestrator/integration.test.ts +411 -20
- package/extensions/orchestrator/messages.test.ts +30 -0
- package/extensions/orchestrator/messages.ts +6 -0
- package/extensions/orchestrator/model-registry.test.ts +48 -1
- package/extensions/orchestrator/model-registry.ts +43 -1
- package/extensions/orchestrator/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/extensions/orchestrator/orchestrator.test.ts +113 -0
- package/extensions/orchestrator/orchestrator.ts +197 -60
- package/extensions/orchestrator/phases/brainstorm.ts +20 -27
- package/extensions/orchestrator/phases/implementation.test.ts +11 -0
- package/extensions/orchestrator/phases/implementation.ts +4 -6
- package/extensions/orchestrator/phases/machine.test.ts +36 -0
- package/extensions/orchestrator/phases/machine.ts +11 -1
- package/extensions/orchestrator/phases/planning.test.ts +16 -0
- package/extensions/orchestrator/phases/planning.ts +11 -4
- package/extensions/orchestrator/phases/review-task.test.ts +20 -0
- package/extensions/orchestrator/phases/review-task.ts +47 -22
- package/extensions/orchestrator/phases/review.test.ts +34 -0
- package/extensions/orchestrator/phases/review.ts +44 -6
- package/extensions/orchestrator/plannotator.ts +9 -6
- package/extensions/orchestrator/pp-menu.test.ts +207 -1
- package/extensions/orchestrator/pp-menu.ts +514 -402
- package/extensions/orchestrator/pp-state-tools.test.ts +192 -0
- package/extensions/orchestrator/pp-state-tools.ts +249 -0
- package/extensions/orchestrator/rate-limit-fallback-flow.test.ts +128 -0
- package/extensions/orchestrator/rate-limit-fallback.test.ts +98 -0
- package/extensions/orchestrator/rate-limit-fallback.ts +243 -0
- package/extensions/orchestrator/state.test.ts +9 -0
- package/extensions/orchestrator/state.ts +15 -0
- package/extensions/orchestrator/test-helpers.ts +4 -1
- package/extensions/orchestrator/transition-controller.test.ts +100 -0
- package/extensions/orchestrator/transition-controller.ts +61 -3
- package/extensions/orchestrator/usage-tracker.ts +5 -1
- package/extensions/orchestrator/validate-artifacts.test.ts +20 -0
- package/extensions/orchestrator/validate-artifacts.ts +2 -2
- package/package.json +1 -1
- package/AGENTS.md +0 -28
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
import { describe, expect, it, vi, beforeEach } from "vitest";
|
|
2
|
-
import {
|
|
1
|
+
import { afterEach, describe, expect, it, vi, beforeEach } from "vitest";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { tmpdir } from "os";
|
|
5
|
+
import { registerEventHandlers, checkoutPrHead, isReviewCycleLive, finalizeReviewCycle } from "./event-handlers.js";
|
|
3
6
|
import { Orchestrator, type ActiveTask } from "./orchestrator.js";
|
|
4
7
|
import { getDefaultConfig } from "./config.js";
|
|
5
8
|
|
|
@@ -19,6 +22,7 @@ function makePi() {
|
|
|
19
22
|
emit: vi.fn(),
|
|
20
23
|
},
|
|
21
24
|
getAllTools: vi.fn().mockReturnValue([{ name: "lsp" }]),
|
|
25
|
+
registerTool: vi.fn(),
|
|
22
26
|
sendMessage: vi.fn(),
|
|
23
27
|
sendUserMessage: vi.fn(),
|
|
24
28
|
setModel: vi.fn(),
|
|
@@ -141,6 +145,55 @@ describe("tool_call write protection", () => {
|
|
|
141
145
|
});
|
|
142
146
|
});
|
|
143
147
|
|
|
148
|
+
describe("review-phase AI_COMMENT write guard", () => {
|
|
149
|
+
function reviewTask(): ActiveTask {
|
|
150
|
+
const task = makeActiveTask();
|
|
151
|
+
task.state.phase = "review";
|
|
152
|
+
return task;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
it("allows an edit that only inserts an AI_COMMENT marker", async () => {
|
|
156
|
+
orchestrator.active = reviewTask();
|
|
157
|
+
const handler = getHandler("tool_call");
|
|
158
|
+
const result = await handler(
|
|
159
|
+
{ toolName: "edit", input: { path: "src/a.ts", edits: [{ oldText: "const x = 1;", newText: "const x = 1;\n// AI_COMMENT: check" }] } },
|
|
160
|
+
{},
|
|
161
|
+
);
|
|
162
|
+
expect(result).toBeUndefined();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("blocks an edit that changes real code during review", async () => {
|
|
166
|
+
orchestrator.active = reviewTask();
|
|
167
|
+
const handler = getHandler("tool_call");
|
|
168
|
+
const result = await handler(
|
|
169
|
+
{ toolName: "edit", input: { path: "src/a.ts", edits: [{ oldText: "const x = 1;", newText: "const x = 2;" }] } },
|
|
170
|
+
{},
|
|
171
|
+
);
|
|
172
|
+
expect(result?.block).toBe(true);
|
|
173
|
+
expect(result?.reason).toContain("AI_COMMENT");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("blocks a brand-new source file write during review", async () => {
|
|
177
|
+
orchestrator.active = reviewTask();
|
|
178
|
+
const handler = getHandler("tool_call");
|
|
179
|
+
const result = await handler(
|
|
180
|
+
{ toolName: "write", input: { path: "src/new.ts", content: "// AI_COMMENT: note\n" } },
|
|
181
|
+
{},
|
|
182
|
+
);
|
|
183
|
+
expect(result?.block).toBe(true);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("does not restrict source edits outside the review phase", async () => {
|
|
187
|
+
orchestrator.active = makeActiveTask(); // implement phase
|
|
188
|
+
const handler = getHandler("tool_call");
|
|
189
|
+
const result = await handler(
|
|
190
|
+
{ toolName: "edit", input: { path: "src/a.ts", edits: [{ oldText: "const x = 1;", newText: "const x = 2;" }] } },
|
|
191
|
+
{},
|
|
192
|
+
);
|
|
193
|
+
expect(result).toBeUndefined();
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
144
197
|
describe("tool_result implementation tracking", () => {
|
|
145
198
|
it("adds file to modifiedFiles when input uses path field", async () => {
|
|
146
199
|
orchestrator.active = makeActiveTask();
|
|
@@ -251,3 +304,360 @@ describe("ask_user ESC aborts the turn", () => {
|
|
|
251
304
|
expect(ctx.abort).not.toHaveBeenCalled();
|
|
252
305
|
});
|
|
253
306
|
});
|
|
307
|
+
|
|
308
|
+
describe("tool_call Agent routing and spawn-time context injection", () => {
|
|
309
|
+
const tempDirs: string[] = [];
|
|
310
|
+
|
|
311
|
+
function makeTaskDir(): string {
|
|
312
|
+
const dir = mkdtempSync(join(tmpdir(), "pi-pi-agent-hook-test-"));
|
|
313
|
+
tempDirs.push(dir);
|
|
314
|
+
writeFileSync(join(dir, "USER_REQUEST.md"), "the user request body", "utf-8");
|
|
315
|
+
writeFileSync(join(dir, "RESEARCH.md"), "the research body", "utf-8");
|
|
316
|
+
const artifactsDir = join(dir, "artifacts");
|
|
317
|
+
mkdirSync(artifactsDir, { recursive: true });
|
|
318
|
+
writeFileSync(join(artifactsDir, "design.md"), "# Design Doc\n\nstuff", "utf-8");
|
|
319
|
+
const plansDir = join(dir, "plans");
|
|
320
|
+
mkdirSync(plansDir, { recursive: true });
|
|
321
|
+
writeFileSync(join(plansDir, "1_synthesized.md"), "the plan", "utf-8");
|
|
322
|
+
return dir;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function activeWith(dir: string): ActiveTask {
|
|
326
|
+
const task = makeActiveTask();
|
|
327
|
+
task.dir = dir;
|
|
328
|
+
return task;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
afterEach(() => {
|
|
332
|
+
for (const dir of tempDirs.splice(0)) {
|
|
333
|
+
rmSync(dir, { recursive: true, force: true });
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("blocks a missing subagent_type with a valid-types message", async () => {
|
|
338
|
+
orchestrator.active = makeActiveTask();
|
|
339
|
+
const handler = getHandler("tool_call");
|
|
340
|
+
const result = await handler({ toolName: "Agent", input: { prompt: "do a thing" } }, {});
|
|
341
|
+
expect(result?.block).toBe(true);
|
|
342
|
+
expect(result?.reason).toContain("subagent_type is required");
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it("blocks an unknown subagent_type instead of collapsing to task", async () => {
|
|
346
|
+
orchestrator.active = makeActiveTask();
|
|
347
|
+
const handler = getHandler("tool_call");
|
|
348
|
+
const input: Record<string, unknown> = { subagent_type: "wizard", prompt: "x" };
|
|
349
|
+
const result = await handler({ toolName: "Agent", input }, {});
|
|
350
|
+
expect(result?.block).toBe(true);
|
|
351
|
+
expect(result?.reason).toContain('Unknown subagent_type "wizard"');
|
|
352
|
+
expect(result?.reason).toContain("deep-debugger");
|
|
353
|
+
// must NOT have been rewritten to task
|
|
354
|
+
expect(input.subagent_type).toBe("wizard");
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it("routes advisor to its own model/thinking and injects context into the prompt", async () => {
|
|
358
|
+
const dir = makeTaskDir();
|
|
359
|
+
orchestrator.active = activeWith(dir);
|
|
360
|
+
const handler = getHandler("tool_call");
|
|
361
|
+
const input: Record<string, unknown> = { subagent_type: "advisor", prompt: "why is this broken" };
|
|
362
|
+
const result = await handler({ toolName: "Agent", input }, {});
|
|
363
|
+
expect(result).toBeUndefined();
|
|
364
|
+
expect(input.subagent_type).toBe("advisor");
|
|
365
|
+
expect(input.thinking).toBe("high");
|
|
366
|
+
const prompt = input.prompt as string;
|
|
367
|
+
expect(prompt).toContain("why is this broken");
|
|
368
|
+
expect(prompt).toContain("=== USER REQUEST ===");
|
|
369
|
+
expect(prompt).toContain("the user request body");
|
|
370
|
+
expect(prompt).toContain("=== RESEARCH ===");
|
|
371
|
+
expect(prompt).toContain("the research body");
|
|
372
|
+
// manifest lists real paths, not inlined content
|
|
373
|
+
expect(prompt).toContain(join(dir, "artifacts", "design.md"));
|
|
374
|
+
expect(prompt).toContain(join(dir, "plans", "1_synthesized.md"));
|
|
375
|
+
expect(prompt).not.toContain("the plan");
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it("maps deep-debugger via bracket-notation config", async () => {
|
|
379
|
+
const dir = makeTaskDir();
|
|
380
|
+
orchestrator.active = activeWith(dir);
|
|
381
|
+
const handler = getHandler("tool_call");
|
|
382
|
+
const input: Record<string, unknown> = { subagent_type: "deep-debugger", prompt: "trace it" };
|
|
383
|
+
const result = await handler({ toolName: "Agent", input }, {});
|
|
384
|
+
expect(result).toBeUndefined();
|
|
385
|
+
expect(input.subagent_type).toBe("deep-debugger");
|
|
386
|
+
expect(input.thinking).toBe("high");
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it("does NOT inject task context into explore spawns", async () => {
|
|
390
|
+
const dir = makeTaskDir();
|
|
391
|
+
orchestrator.active = activeWith(dir);
|
|
392
|
+
const handler = getHandler("tool_call");
|
|
393
|
+
const input: Record<string, unknown> = { subagent_type: "explore", prompt: "find X" };
|
|
394
|
+
const result = await handler({ toolName: "Agent", input }, {});
|
|
395
|
+
expect(result).toBeUndefined();
|
|
396
|
+
expect(input.subagent_type).toBe("explore");
|
|
397
|
+
expect(input.prompt).toBe("find X");
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
describe("main-turn stall watchdog (BUG-2)", () => {
|
|
402
|
+
beforeEach(() => {
|
|
403
|
+
vi.useFakeTimers();
|
|
404
|
+
});
|
|
405
|
+
afterEach(() => {
|
|
406
|
+
vi.useRealTimers();
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it("recovers a wedged main turn via the idle-gated single-send path", async () => {
|
|
410
|
+
orchestrator.active = makeActiveTask();
|
|
411
|
+
orchestrator.config.performance.internals.mainTurnStale = 60000;
|
|
412
|
+
const sendSpy = vi.spyOn(orchestrator, "sendUserMessageWhenIdle").mockImplementation(() => {});
|
|
413
|
+
orchestrator.lastCtx = { isIdle: () => true } as any;
|
|
414
|
+
|
|
415
|
+
// A turn starts and then never terminates.
|
|
416
|
+
await getHandler("turn_start")({ type: "turn_start", turnIndex: 0 }, {});
|
|
417
|
+
expect(orchestrator.mainTurnInFlight).toBe(true);
|
|
418
|
+
|
|
419
|
+
// Before the threshold: no recovery.
|
|
420
|
+
vi.advanceTimersByTime(30000);
|
|
421
|
+
expect(sendSpy).not.toHaveBeenCalled();
|
|
422
|
+
|
|
423
|
+
// Past the threshold with no activity: watchdog fires once.
|
|
424
|
+
vi.advanceTimersByTime(61000);
|
|
425
|
+
expect(sendSpy).toHaveBeenCalledTimes(1);
|
|
426
|
+
expect(sendSpy.mock.calls[0][0]).toContain("stalled without completing");
|
|
427
|
+
expect(orchestrator.mainTurnRecovering).toBe(true);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it("does not fire while the turn keeps making activity", async () => {
|
|
431
|
+
orchestrator.active = makeActiveTask();
|
|
432
|
+
orchestrator.config.performance.internals.mainTurnStale = 60000;
|
|
433
|
+
const sendSpy = vi.spyOn(orchestrator, "sendUserMessageWhenIdle").mockImplementation(() => {});
|
|
434
|
+
|
|
435
|
+
await getHandler("turn_start")({ type: "turn_start", turnIndex: 0 }, {});
|
|
436
|
+
for (let i = 0; i < 5; i++) {
|
|
437
|
+
vi.advanceTimersByTime(30000);
|
|
438
|
+
await getHandler("tool_call")({ toolName: "read", input: { path: "a.ts" } }, {});
|
|
439
|
+
}
|
|
440
|
+
expect(sendSpy).not.toHaveBeenCalled();
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
it("does not fire after the turn terminates", async () => {
|
|
444
|
+
orchestrator.active = makeActiveTask();
|
|
445
|
+
orchestrator.config.performance.internals.mainTurnStale = 60000;
|
|
446
|
+
const sendSpy = vi.spyOn(orchestrator, "sendUserMessageWhenIdle").mockImplementation(() => {});
|
|
447
|
+
|
|
448
|
+
const ctx = { ui: { setStatus: vi.fn(), setWorkingMessage: vi.fn(), notify: vi.fn() }, isIdle: () => true } as any;
|
|
449
|
+
await getHandler("turn_start")({ type: "turn_start", turnIndex: 0 }, ctx);
|
|
450
|
+
await getHandler("turn_end")({ type: "turn_end", turnIndex: 0, message: {}, toolResults: [] }, ctx);
|
|
451
|
+
expect(orchestrator.mainTurnInFlight).toBe(false);
|
|
452
|
+
vi.advanceTimersByTime(120000);
|
|
453
|
+
expect(sendSpy).not.toHaveBeenCalled();
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it("does not fire while an interactive dialogue is open (#11)", async () => {
|
|
457
|
+
orchestrator.active = makeActiveTask();
|
|
458
|
+
orchestrator.config.performance.internals.mainTurnStale = 60000;
|
|
459
|
+
const sendSpy = vi.spyOn(orchestrator, "sendUserMessageWhenIdle").mockImplementation(() => {});
|
|
460
|
+
orchestrator.lastCtx = { isIdle: () => true } as any;
|
|
461
|
+
|
|
462
|
+
await getHandler("turn_start")({ type: "turn_start", turnIndex: 0 }, {});
|
|
463
|
+
orchestrator.interactivePromptOpen = true;
|
|
464
|
+
// Well past the stale threshold: the user is parked on a prompt, not wedged.
|
|
465
|
+
vi.advanceTimersByTime(600000);
|
|
466
|
+
expect(sendSpy).not.toHaveBeenCalled();
|
|
467
|
+
|
|
468
|
+
// Once the dialogue closes, a genuinely stalled turn recovers again.
|
|
469
|
+
orchestrator.interactivePromptOpen = false;
|
|
470
|
+
vi.advanceTimersByTime(61000);
|
|
471
|
+
expect(sendSpy).toHaveBeenCalledTimes(1);
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it("does not fire while the subscription fallback dialogue is open (#11)", async () => {
|
|
475
|
+
orchestrator.active = makeActiveTask();
|
|
476
|
+
orchestrator.config.performance.internals.mainTurnStale = 60000;
|
|
477
|
+
const sendSpy = vi.spyOn(orchestrator, "sendUserMessageWhenIdle").mockImplementation(() => {});
|
|
478
|
+
orchestrator.lastCtx = { isIdle: () => true } as any;
|
|
479
|
+
|
|
480
|
+
await getHandler("turn_start")({ type: "turn_start", turnIndex: 0 }, {});
|
|
481
|
+
orchestrator.subFallbackDialogPending = true;
|
|
482
|
+
vi.advanceTimersByTime(600000);
|
|
483
|
+
expect(sendSpy).not.toHaveBeenCalled();
|
|
484
|
+
orchestrator.subFallbackDialogPending = false;
|
|
485
|
+
});
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
describe("checkoutPrHead", () => {
|
|
489
|
+
type GitResult = { code: number; stdout?: string; stderr?: string };
|
|
490
|
+
// A key maps to a single result, or an array consumed in call order (last entry repeats).
|
|
491
|
+
function makeGitOrchestrator(script: Record<string, GitResult | GitResult[]>) {
|
|
492
|
+
const calls: string[][] = [];
|
|
493
|
+
const seen: Record<string, number> = {};
|
|
494
|
+
const exec = vi.fn(async (_cmd: string, args: string[]) => {
|
|
495
|
+
calls.push(args);
|
|
496
|
+
const key = args.join(" ");
|
|
497
|
+
const matched = Object.keys(script).find((k) => key.startsWith(k));
|
|
498
|
+
const entry = matched ? script[matched] : { code: 0, stdout: "", stderr: "" };
|
|
499
|
+
let res: GitResult;
|
|
500
|
+
if (Array.isArray(entry)) {
|
|
501
|
+
const i = Math.min(seen[matched!] ?? 0, entry.length - 1);
|
|
502
|
+
seen[matched!] = (seen[matched!] ?? 0) + 1;
|
|
503
|
+
res = entry[i];
|
|
504
|
+
} else {
|
|
505
|
+
res = entry;
|
|
506
|
+
}
|
|
507
|
+
return { code: res.code, stdout: res.stdout ?? "", stderr: res.stderr ?? "" };
|
|
508
|
+
});
|
|
509
|
+
return { orchestrator: { pi: { exec } } as any, calls };
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
it("is a no-op when no PR head is provided (non-PR review scope)", async () => {
|
|
513
|
+
const { orchestrator, calls } = makeGitOrchestrator({});
|
|
514
|
+
const result = await checkoutPrHead(orchestrator, "/repo", "", "");
|
|
515
|
+
expect(result.ok).toBe(true);
|
|
516
|
+
expect(result.message).toContain("non-PR review scope");
|
|
517
|
+
expect(calls).toHaveLength(0);
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
it("HALTs on a dirty working tree without checking out", async () => {
|
|
521
|
+
const { orchestrator, calls } = makeGitOrchestrator({
|
|
522
|
+
"status --porcelain": { code: 0, stdout: " M src/a.ts\n" },
|
|
523
|
+
});
|
|
524
|
+
const result = await checkoutPrHead(orchestrator, "/repo", "feature", "abc123");
|
|
525
|
+
expect(result.ok).toBe(false);
|
|
526
|
+
expect(result.message).toContain("HALT");
|
|
527
|
+
expect(result.message).toContain("uncommitted changes");
|
|
528
|
+
expect(calls.some((c) => c[0] === "checkout")).toBe(false);
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
it("HALTs when on a different branch, and never runs git checkout", async () => {
|
|
532
|
+
const { orchestrator, calls } = makeGitOrchestrator({
|
|
533
|
+
"status --porcelain": { code: 0, stdout: "" },
|
|
534
|
+
"rev-parse HEAD": { code: 0, stdout: "othersha\n" },
|
|
535
|
+
"rev-parse --abbrev-ref HEAD": { code: 0, stdout: "main\n" },
|
|
536
|
+
});
|
|
537
|
+
const result = await checkoutPrHead(orchestrator, "/repo", "feature", "abc123");
|
|
538
|
+
expect(result.ok).toBe(false);
|
|
539
|
+
expect(result.message).toContain("HALT");
|
|
540
|
+
expect(result.message).toContain("feature");
|
|
541
|
+
expect(calls.some((c) => c[0] === "checkout")).toBe(false);
|
|
542
|
+
expect(calls.some((c) => c[0] === "merge")).toBe(false);
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
it("fast-forwards a clean branch that is behind the PR head, without checkout or force", async () => {
|
|
546
|
+
const { orchestrator, calls } = makeGitOrchestrator({
|
|
547
|
+
"status --porcelain": { code: 0, stdout: "" },
|
|
548
|
+
"rev-parse HEAD": [{ code: 0, stdout: "oldsha\n" }, { code: 0, stdout: "abc123\n" }],
|
|
549
|
+
"rev-parse --abbrev-ref HEAD": { code: 0, stdout: "feature\n" },
|
|
550
|
+
"merge --ff-only abc123": { code: 0, stdout: "Updating oldsha..abc123\n" },
|
|
551
|
+
});
|
|
552
|
+
const result = await checkoutPrHead(orchestrator, "/repo", "feature", "abc123");
|
|
553
|
+
expect(result.ok).toBe(true);
|
|
554
|
+
expect(result.message).toContain("fast-forwarded");
|
|
555
|
+
expect(calls.some((c) => c[0] === "merge" && c[1] === "--ff-only" && c[2] === "abc123")).toBe(true);
|
|
556
|
+
expect(calls.some((c) => c[0] === "checkout")).toBe(false);
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
it("HALTs when the branch is AHEAD of the PR head (ff-only is a no-op that leaves HEAD ahead)", async () => {
|
|
560
|
+
const { orchestrator, calls } = makeGitOrchestrator({
|
|
561
|
+
"status --porcelain": { code: 0, stdout: "" },
|
|
562
|
+
// ff-only to an ancestor returns exit 0 ("Already up to date") without moving HEAD.
|
|
563
|
+
"rev-parse HEAD": { code: 0, stdout: "aheadsha\n" },
|
|
564
|
+
"rev-parse --abbrev-ref HEAD": { code: 0, stdout: "feature\n" },
|
|
565
|
+
"merge --ff-only abc123": { code: 0, stdout: "Already up to date.\n" },
|
|
566
|
+
});
|
|
567
|
+
const result = await checkoutPrHead(orchestrator, "/repo", "feature", "abc123");
|
|
568
|
+
expect(result.ok).toBe(false);
|
|
569
|
+
expect(result.message).toContain("HALT");
|
|
570
|
+
expect(result.message).toContain("ahead of the PR head");
|
|
571
|
+
expect(calls.some((c) => c[0] === "checkout")).toBe(false);
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
it("HALTs when the branch has diverged and cannot fast-forward", async () => {
|
|
575
|
+
const { orchestrator, calls } = makeGitOrchestrator({
|
|
576
|
+
"status --porcelain": { code: 0, stdout: "" },
|
|
577
|
+
"rev-parse HEAD": { code: 0, stdout: "oldsha\n" },
|
|
578
|
+
"rev-parse --abbrev-ref HEAD": { code: 0, stdout: "feature\n" },
|
|
579
|
+
"merge --ff-only abc123": { code: 1, stderr: "fatal: Not possible to fast-forward, aborting." },
|
|
580
|
+
});
|
|
581
|
+
const result = await checkoutPrHead(orchestrator, "/repo", "feature", "abc123");
|
|
582
|
+
expect(result.ok).toBe(false);
|
|
583
|
+
expect(result.message).toContain("HALT");
|
|
584
|
+
expect(result.message).toContain("diverged");
|
|
585
|
+
expect(calls.some((c) => c[0] === "checkout")).toBe(false);
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
it("succeeds without any checkout or merge when already on the PR head commit", async () => {
|
|
589
|
+
const { orchestrator, calls } = makeGitOrchestrator({
|
|
590
|
+
"status --porcelain": { code: 0, stdout: "" },
|
|
591
|
+
"rev-parse HEAD": { code: 0, stdout: "abc123\n" },
|
|
592
|
+
});
|
|
593
|
+
const result = await checkoutPrHead(orchestrator, "/repo", "feature", "abc123");
|
|
594
|
+
expect(result.ok).toBe(true);
|
|
595
|
+
expect(result.message).toContain("on PR head");
|
|
596
|
+
expect(calls.some((c) => c[0] === "checkout" || c[0] === "merge")).toBe(false);
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
it("succeeds on a detached HEAD already at the PR head (re-entrant)", async () => {
|
|
600
|
+
const { orchestrator, calls } = makeGitOrchestrator({
|
|
601
|
+
"status --porcelain": { code: 0, stdout: "" },
|
|
602
|
+
"rev-parse HEAD": { code: 0, stdout: "abc123\n" },
|
|
603
|
+
});
|
|
604
|
+
const result = await checkoutPrHead(orchestrator, "/repo", "feature", "abc123");
|
|
605
|
+
expect(result.ok).toBe(true);
|
|
606
|
+
expect(calls.some((c) => c[0] === "checkout" || c[0] === "merge")).toBe(false);
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
it("HALTs on a detached HEAD that is not at the PR head", async () => {
|
|
610
|
+
const { orchestrator, calls } = makeGitOrchestrator({
|
|
611
|
+
"status --porcelain": { code: 0, stdout: "" },
|
|
612
|
+
"rev-parse HEAD": { code: 0, stdout: "oldsha\n" },
|
|
613
|
+
"rev-parse --abbrev-ref HEAD": { code: 0, stdout: "HEAD\n" },
|
|
614
|
+
});
|
|
615
|
+
const result = await checkoutPrHead(orchestrator, "/repo", "feature", "abc123");
|
|
616
|
+
expect(result.ok).toBe(false);
|
|
617
|
+
expect(result.message).toContain("detached HEAD");
|
|
618
|
+
expect(calls.some((c) => c[0] === "checkout" || c[0] === "merge")).toBe(false);
|
|
619
|
+
});
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
describe("pp_checkout_pr_head tool registration", () => {
|
|
623
|
+
it("registers the checkout tool during orchestrator tool setup", async () => {
|
|
624
|
+
const { registerOrchestratorToolsForTest } = await import("./event-handlers.js");
|
|
625
|
+
orchestrator.active = makeActiveTask();
|
|
626
|
+
registerOrchestratorToolsForTest(orchestrator);
|
|
627
|
+
const names = (pi.registerTool as any).mock.calls.map((c: any[]) => c[0].name);
|
|
628
|
+
expect(names).toContain("pp_checkout_pr_head");
|
|
629
|
+
});
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
describe("isReviewCycleLive (#3b re-entrancy guard)", () => {
|
|
633
|
+
it("is true only for a cycle awaiting reviewers", () => {
|
|
634
|
+
const task = makeActiveTask();
|
|
635
|
+
task.state.reviewCycle = null;
|
|
636
|
+
expect(isReviewCycleLive(task)).toBe(false);
|
|
637
|
+
task.state.reviewCycle = { kind: "auto", step: "spawn_reviewers", pass: 1 };
|
|
638
|
+
expect(isReviewCycleLive(task)).toBe(false);
|
|
639
|
+
task.state.reviewCycle = { kind: "auto", step: "await_reviewers", pass: 1 };
|
|
640
|
+
expect(isReviewCycleLive(task)).toBe(true);
|
|
641
|
+
task.state.reviewCycle = { kind: "auto", step: "apply_feedback", pass: 1 };
|
|
642
|
+
expect(isReviewCycleLive(task)).toBe(false);
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
it("a live await_reviewers cycle is left intact when the guard blocks (no finalize)", () => {
|
|
646
|
+
const task = makeActiveTask();
|
|
647
|
+
task.state.reviewCycle = { kind: "auto", step: "await_reviewers", pass: 2 };
|
|
648
|
+
// The menu action returns early on isReviewCycleLive WITHOUT calling
|
|
649
|
+
// finalizeReviewCycle, so the running cycle survives a second selection.
|
|
650
|
+
expect(isReviewCycleLive(task)).toBe(true);
|
|
651
|
+
expect(task.state.reviewCycle).toEqual({ kind: "auto", step: "await_reviewers", pass: 2 });
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
it("finalizeReviewCycle only clears a completed (non-live) cycle for the next pass", () => {
|
|
655
|
+
const task = makeActiveTask();
|
|
656
|
+
task.dir = mkdtempSync(join(tmpdir(), "pp-review-"));
|
|
657
|
+
task.state.reviewCycle = { kind: "auto", step: "apply_feedback", pass: 1 };
|
|
658
|
+
expect(isReviewCycleLive(task)).toBe(false);
|
|
659
|
+
finalizeReviewCycle(task);
|
|
660
|
+
expect(task.state.reviewCycle).toBeNull();
|
|
661
|
+
rmSync(task.dir, { recursive: true, force: true });
|
|
662
|
+
});
|
|
663
|
+
});
|