@ilya-lesikov/pi-pi 0.10.0 → 0.12.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-subagents/package.json +0 -1
- package/3p/pi-subagents/src/agent-manager.ts +1 -0
- package/3p/pi-subagents/src/index.ts +1 -0
- package/3p/pi-subagents/src/types.ts +8 -0
- package/extensions/orchestrator/agents/advisor.ts +2 -1
- package/extensions/orchestrator/agents/code-reviewer.ts +1 -0
- package/extensions/orchestrator/agents/constraints.test.ts +43 -10
- package/extensions/orchestrator/agents/constraints.ts +26 -5
- package/extensions/orchestrator/agents/deep-debugger.ts +7 -6
- package/extensions/orchestrator/agents/explore.ts +1 -1
- package/extensions/orchestrator/agents/librarian.ts +1 -1
- package/extensions/orchestrator/agents/planner.ts +1 -1
- package/extensions/orchestrator/agents/prompts.test.ts +135 -1
- package/extensions/orchestrator/agents/reviewer.ts +2 -2
- package/extensions/orchestrator/agents/task.ts +6 -2
- package/extensions/orchestrator/agents/tool-routing.ts +22 -6
- package/extensions/orchestrator/assumptions.test.ts +77 -0
- package/extensions/orchestrator/assumptions.ts +85 -0
- package/extensions/orchestrator/cbm.ts +4 -1
- package/extensions/orchestrator/cbm.which.test.ts +92 -0
- package/extensions/orchestrator/command-handlers.ts +9 -1
- package/extensions/orchestrator/config.test.ts +1 -3
- package/extensions/orchestrator/config.ts +3 -4
- package/extensions/orchestrator/context.test.ts +2 -7
- package/extensions/orchestrator/context.ts +3 -3
- package/extensions/orchestrator/doctor.test.ts +35 -0
- package/extensions/orchestrator/doctor.ts +4 -2
- package/extensions/orchestrator/event-handlers.more.test.ts +185 -0
- package/extensions/orchestrator/event-handlers.test.ts +22 -2
- package/extensions/orchestrator/event-handlers.ts +156 -18
- package/extensions/orchestrator/flant-infra.more.test.ts +55 -0
- package/extensions/orchestrator/flant-infra.test.ts +0 -3
- package/extensions/orchestrator/flant-infra.ts +17 -7
- package/extensions/orchestrator/integration.test.ts +355 -173
- package/extensions/orchestrator/orchestrator.ts +3 -9
- package/extensions/orchestrator/phases/brainstorm.test.ts +25 -7
- package/extensions/orchestrator/phases/brainstorm.ts +35 -71
- package/extensions/orchestrator/phases/implementation.test.ts +22 -0
- package/extensions/orchestrator/phases/implementation.ts +6 -0
- package/extensions/orchestrator/phases/machine.test.ts +0 -28
- package/extensions/orchestrator/phases/machine.ts +0 -38
- package/extensions/orchestrator/phases/planning.test.ts +27 -1
- package/extensions/orchestrator/phases/planning.ts +1 -1
- package/extensions/orchestrator/phases/review-task.test.ts +7 -0
- package/extensions/orchestrator/phases/review-task.ts +1 -1
- package/extensions/orchestrator/phases/review.test.ts +47 -10
- package/extensions/orchestrator/phases/review.ts +36 -25
- package/extensions/orchestrator/pp-menu.from.test.ts +65 -0
- package/extensions/orchestrator/pp-menu.leaves.test.ts +17 -0
- package/extensions/orchestrator/pp-menu.test.ts +106 -49
- package/extensions/orchestrator/pp-menu.ts +280 -134
- package/extensions/orchestrator/pp-state-tools.ts +1 -1
- package/extensions/orchestrator/rate-limit-fallback-flow.test.ts +2 -2
- package/extensions/orchestrator/rate-limit-fallback.ts +1 -1
- package/extensions/orchestrator/state.test.ts +17 -17
- package/extensions/orchestrator/state.ts +35 -12
- package/package.json +6 -3
- package/scripts/lib/smoke-resolve.mjs +99 -0
- package/scripts/postinstall.mjs +93 -0
- package/scripts/test-package.sh +62 -0
- package/scripts/postinstall.sh +0 -18
|
@@ -365,6 +365,109 @@ describe("checkoutPrHead additional branches", () => {
|
|
|
365
365
|
expect(result.message).toContain("no PR head commit provided");
|
|
366
366
|
expect(exec).toHaveBeenCalledTimes(1);
|
|
367
367
|
});
|
|
368
|
+
|
|
369
|
+
it("creates a tracking branch and switches when the local branch is missing", async () => {
|
|
370
|
+
// HEAD is asked twice: initial (not on oid) then post-switch (on oid).
|
|
371
|
+
let headCalls = 0;
|
|
372
|
+
const exec = vi.fn(async (_cmd: string, args: string[]) => {
|
|
373
|
+
if (args[0] === "status") return { code: 0, stdout: "", stderr: "" };
|
|
374
|
+
if (args[0] === "rev-parse" && args[1] === "HEAD") return { code: 0, stdout: headCalls++ === 0 ? "othersha" : "abc123", stderr: "" };
|
|
375
|
+
if (args[0] === "rev-parse" && args.includes("--abbrev-ref")) return { code: 0, stdout: "main", stderr: "" };
|
|
376
|
+
if (args[0] === "fetch") return { code: 0, stdout: "", stderr: "" };
|
|
377
|
+
if (args[0] === "rev-parse" && args.includes("refs/remotes/origin/feature")) return { code: 0, stdout: "abc123", stderr: "" };
|
|
378
|
+
if (args[0] === "rev-parse" && args.includes("refs/heads/feature")) return { code: 1, stdout: "", stderr: "" };
|
|
379
|
+
return { code: 0, stdout: "", stderr: "" };
|
|
380
|
+
});
|
|
381
|
+
const result = await checkoutPrHead(orchWithExec(exec), "/repo", "feature", "abc123");
|
|
382
|
+
expect(result.ok).toBe(true);
|
|
383
|
+
expect(result.message).toContain('switched to PR head branch "feature"');
|
|
384
|
+
const createdTracking = exec.mock.calls.some((c: any[]) => c[1][0] === "checkout" && c[1].includes("-b"));
|
|
385
|
+
expect(createdTracking).toBe(true);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it("HALTs when the PR head branch cannot be fetched from origin (fork PR)", async () => {
|
|
389
|
+
let headCalls = 0;
|
|
390
|
+
const exec = vi.fn(async (_cmd: string, args: string[]) => {
|
|
391
|
+
if (args[0] === "status") return { code: 0, stdout: "", stderr: "" };
|
|
392
|
+
if (args[0] === "rev-parse" && args[1] === "HEAD") return { code: 0, stdout: headCalls++ === 0 ? "othersha" : "abc123", stderr: "" };
|
|
393
|
+
if (args[0] === "rev-parse" && args.includes("--abbrev-ref")) return { code: 0, stdout: "main", stderr: "" };
|
|
394
|
+
if (args[0] === "fetch") return { code: 128, stdout: "", stderr: "couldn't find remote ref" };
|
|
395
|
+
return { code: 0, stdout: "", stderr: "" };
|
|
396
|
+
});
|
|
397
|
+
const result = await checkoutPrHead(orchWithExec(exec), "/repo", "feature", "abc123");
|
|
398
|
+
expect(result.ok).toBe(false);
|
|
399
|
+
expect(result.message).toContain("HALT");
|
|
400
|
+
expect(result.message).toContain("could not fetch PR head branch");
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it("HALTs when the fetched origin tip does not match the advertised oid", async () => {
|
|
404
|
+
let headCalls = 0;
|
|
405
|
+
const exec = vi.fn(async (_cmd: string, args: string[]) => {
|
|
406
|
+
if (args[0] === "status") return { code: 0, stdout: "", stderr: "" };
|
|
407
|
+
if (args[0] === "rev-parse" && args[1] === "HEAD") return { code: 0, stdout: headCalls++ === 0 ? "othersha" : "abc123", stderr: "" };
|
|
408
|
+
if (args[0] === "rev-parse" && args.includes("--abbrev-ref")) return { code: 0, stdout: "main", stderr: "" };
|
|
409
|
+
if (args[0] === "fetch") return { code: 0, stdout: "", stderr: "" };
|
|
410
|
+
if (args[0] === "rev-parse" && args.includes("refs/remotes/origin/feature")) return { code: 0, stdout: "differentsha", stderr: "" };
|
|
411
|
+
return { code: 0, stdout: "", stderr: "" };
|
|
412
|
+
});
|
|
413
|
+
const result = await checkoutPrHead(orchWithExec(exec), "/repo", "feature", "abc123");
|
|
414
|
+
expect(result.ok).toBe(false);
|
|
415
|
+
expect(result.message).toContain("HALT");
|
|
416
|
+
expect(result.message).toContain("not the advertised PR head");
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it("fast-forwards and switches an existing local branch that is a safe ancestor of the PR head", async () => {
|
|
420
|
+
let headCalls = 0;
|
|
421
|
+
const exec = vi.fn(async (_cmd: string, args: string[]) => {
|
|
422
|
+
if (args[0] === "status") return { code: 0, stdout: "", stderr: "" };
|
|
423
|
+
if (args[0] === "rev-parse" && args[1] === "HEAD") return { code: 0, stdout: headCalls++ === 0 ? "othersha" : "abc123", stderr: "" };
|
|
424
|
+
if (args[0] === "rev-parse" && args.includes("--abbrev-ref")) return { code: 0, stdout: "main", stderr: "" };
|
|
425
|
+
if (args[0] === "fetch") return { code: 0, stdout: "", stderr: "" };
|
|
426
|
+
if (args[0] === "rev-parse" && args.includes("refs/remotes/origin/feature")) return { code: 0, stdout: "abc123", stderr: "" };
|
|
427
|
+
if (args[0] === "rev-parse" && args.includes("refs/heads/feature")) return { code: 0, stdout: "localtip", stderr: "" };
|
|
428
|
+
if (args[0] === "merge-base" && args.includes("--is-ancestor")) return { code: 0, stdout: "", stderr: "" };
|
|
429
|
+
return { code: 0, stdout: "", stderr: "" };
|
|
430
|
+
});
|
|
431
|
+
const result = await checkoutPrHead(orchWithExec(exec), "/repo", "feature", "abc123");
|
|
432
|
+
expect(result.ok).toBe(true);
|
|
433
|
+
expect(result.message).toContain('switched to PR head branch "feature"');
|
|
434
|
+
// Ancestry was proven BEFORE the checkout.
|
|
435
|
+
const order = exec.mock.calls.map((c: any[]) => c[1].join(" "));
|
|
436
|
+
const ancestorIdx = order.findIndex((c: string) => c.includes("merge-base --is-ancestor"));
|
|
437
|
+
const checkoutIdx = order.findIndex((c: string) => c.startsWith("checkout feature"));
|
|
438
|
+
expect(ancestorIdx).toBeGreaterThanOrEqual(0);
|
|
439
|
+
expect(checkoutIdx).toBeGreaterThan(ancestorIdx);
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it("HALTs on a diverged local branch WITHOUT checking it out (never switches then fails)", async () => {
|
|
443
|
+
const exec = vi.fn(async (_cmd: string, args: string[]) => {
|
|
444
|
+
if (args[0] === "status") return { code: 0, stdout: "", stderr: "" };
|
|
445
|
+
if (args[0] === "rev-parse" && args[1] === "HEAD") return { code: 0, stdout: "othersha", stderr: "" };
|
|
446
|
+
if (args[0] === "rev-parse" && args.includes("--abbrev-ref")) return { code: 0, stdout: "main", stderr: "" };
|
|
447
|
+
if (args[0] === "fetch") return { code: 0, stdout: "", stderr: "" };
|
|
448
|
+
if (args[0] === "rev-parse" && args.includes("refs/remotes/origin/feature")) return { code: 0, stdout: "abc123", stderr: "" };
|
|
449
|
+
if (args[0] === "rev-parse" && args.includes("refs/heads/feature")) return { code: 0, stdout: "divergedtip", stderr: "" };
|
|
450
|
+
if (args[0] === "merge-base" && args.includes("--is-ancestor")) return { code: 1, stdout: "", stderr: "" };
|
|
451
|
+
return { code: 0, stdout: "", stderr: "" };
|
|
452
|
+
});
|
|
453
|
+
const result = await checkoutPrHead(orchWithExec(exec), "/repo", "feature", "abc123");
|
|
454
|
+
expect(result.ok).toBe(false);
|
|
455
|
+
expect(result.message).toContain("HALT");
|
|
456
|
+
expect(result.message).toContain("has diverged");
|
|
457
|
+
// The critical guarantee: no checkout was issued — the user stays on their branch.
|
|
458
|
+
const checkedOut = exec.mock.calls.some((c: any[]) => c[1][0] === "checkout");
|
|
459
|
+
expect(checkedOut).toBe(false);
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
it("still HALTs on a dirty tree regardless of branch", async () => {
|
|
463
|
+
const exec = vi.fn(async (_cmd: string, args: string[]) => {
|
|
464
|
+
if (args[0] === "status") return { code: 0, stdout: " M file.ts", stderr: "" };
|
|
465
|
+
return { code: 0, stdout: "", stderr: "" };
|
|
466
|
+
});
|
|
467
|
+
const result = await checkoutPrHead(orchWithExec(exec), "/repo", "feature", "abc123");
|
|
468
|
+
expect(result.ok).toBe(false);
|
|
469
|
+
expect(result.message).toContain("uncommitted changes");
|
|
470
|
+
});
|
|
368
471
|
});
|
|
369
472
|
|
|
370
473
|
describe("registered handler branches", () => {
|
|
@@ -428,6 +531,33 @@ describe("registered handler branches", () => {
|
|
|
428
531
|
}
|
|
429
532
|
});
|
|
430
533
|
|
|
534
|
+
it("marks a phased-batch agent's record consumed to suppress the vendor nudge; leaves free agents alone", () => {
|
|
535
|
+
const records = new Map<string, { resultConsumed?: boolean }>();
|
|
536
|
+
const managerKey = Symbol.for("pi-subagents:manager");
|
|
537
|
+
(globalThis as any)[managerKey] = { getRecord: (id: string) => records.get(id) };
|
|
538
|
+
try {
|
|
539
|
+
// Phased batch: the record IS marked consumed (nudge suppressed).
|
|
540
|
+
records.set("agent-p", {});
|
|
541
|
+
orchestrator.active = makeActiveTask();
|
|
542
|
+
orchestrator.active.state.step = "await_reviewers";
|
|
543
|
+
orchestrator.spawnedAgentIds.add("agent-p");
|
|
544
|
+
orchestrator.agentDescriptions.set("agent-p", "reviewer gpt");
|
|
545
|
+
getEventHandler("subagents:completed")({ id: "agent-p", description: "reviewer gpt" }, {});
|
|
546
|
+
expect(records.get("agent-p")!.resultConsumed).toBe(true);
|
|
547
|
+
|
|
548
|
+
// Free (non-phased) agent: the record is NOT touched (keeps its nudge).
|
|
549
|
+
records.set("agent-free", {});
|
|
550
|
+
orchestrator.active = makeActiveTask();
|
|
551
|
+
orchestrator.active.state.step = "llm_work";
|
|
552
|
+
orchestrator.spawnedAgentIds.add("agent-free");
|
|
553
|
+
orchestrator.agentDescriptions.set("agent-free", "advisor gpt");
|
|
554
|
+
getEventHandler("subagents:completed")({ id: "agent-free", description: "advisor gpt" }, {});
|
|
555
|
+
expect(records.get("agent-free")!.resultConsumed).toBeUndefined();
|
|
556
|
+
} finally {
|
|
557
|
+
delete (globalThis as any)[managerKey];
|
|
558
|
+
}
|
|
559
|
+
});
|
|
560
|
+
|
|
431
561
|
it("cleans up a stopped subagent without emitting an error", () => {
|
|
432
562
|
orchestrator.active = makeActiveTask();
|
|
433
563
|
orchestrator.spawnedAgentIds.add("agent-1");
|
|
@@ -528,6 +658,7 @@ describe("pp_phase_complete tool", () => {
|
|
|
528
658
|
orchestrator.active.state.phase = "plan";
|
|
529
659
|
orchestrator.active.state.step = "llm_work";
|
|
530
660
|
orchestrator.active.state.mode = "autonomous";
|
|
661
|
+
orchestrator.active.state.reconciledPhase = "plan";
|
|
531
662
|
const transitionSpy = vi.fn(async () => ({ ok: true as const }));
|
|
532
663
|
orchestrator.transitionToNextPhase = transitionSpy;
|
|
533
664
|
registerOrchestratorToolsForTest(orchestrator);
|
|
@@ -542,6 +673,7 @@ describe("pp_phase_complete tool", () => {
|
|
|
542
673
|
orchestrator.active.state.phase = "plan";
|
|
543
674
|
orchestrator.active.state.step = "llm_work";
|
|
544
675
|
orchestrator.active.state.mode = "autonomous";
|
|
676
|
+
orchestrator.active.state.reconciledPhase = "plan";
|
|
545
677
|
orchestrator.transitionToNextPhase = async () => ({ ok: false, error: "boom" });
|
|
546
678
|
registerOrchestratorToolsForTest(orchestrator);
|
|
547
679
|
const tool = getTool("pp_phase_complete");
|
|
@@ -553,9 +685,62 @@ describe("pp_phase_complete tool", () => {
|
|
|
553
685
|
orchestrator.active = makeActiveTask();
|
|
554
686
|
orchestrator.active.state.phase = "implement";
|
|
555
687
|
orchestrator.active.state.step = "llm_work";
|
|
688
|
+
orchestrator.active.state.reconciledPhase = "implement";
|
|
689
|
+
registerOrchestratorToolsForTest(orchestrator);
|
|
690
|
+
const tool = getTool("pp_phase_complete");
|
|
691
|
+
const result = await tool.execute("id", { summary: "s" }, undefined, undefined, ctxWithUi());
|
|
692
|
+
expect(result.content[0].text).toBe("MENU_RESULT");
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
it("prompts to reconcile on the first call of a phase, then proceeds on the re-call", async () => {
|
|
696
|
+
orchestrator.active = makeActiveTask();
|
|
697
|
+
orchestrator.active.state.phase = "implement";
|
|
698
|
+
orchestrator.active.state.step = "llm_work";
|
|
699
|
+
const transitionSpy = vi.fn(async () => ({ ok: true as const }));
|
|
700
|
+
orchestrator.transitionToNextPhase = transitionSpy;
|
|
556
701
|
registerOrchestratorToolsForTest(orchestrator);
|
|
557
702
|
const tool = getTool("pp_phase_complete");
|
|
703
|
+
|
|
704
|
+
const first = await tool.execute("id", { summary: "s" }, undefined, undefined, ctxWithUi());
|
|
705
|
+
expect(first.content[0].text).toContain("reconcile the task's state files");
|
|
706
|
+
expect(first.isError).toBeUndefined();
|
|
707
|
+
expect(transitionSpy).not.toHaveBeenCalled();
|
|
708
|
+
expect(orchestrator.active.state.phase).toBe("implement");
|
|
709
|
+
expect(orchestrator.active.state.reviewCycle).toBeNull();
|
|
710
|
+
|
|
711
|
+
const second = await tool.execute("id", { summary: "s" }, undefined, undefined, ctxWithUi());
|
|
712
|
+
expect(second.content[0].text).toBe("MENU_RESULT");
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
it("an autonomous run reconciles once then advances on the immediate re-call (no loop)", async () => {
|
|
716
|
+
orchestrator.active = makeActiveTask();
|
|
717
|
+
orchestrator.active.state.phase = "plan";
|
|
718
|
+
orchestrator.active.state.step = "llm_work";
|
|
719
|
+
orchestrator.active.state.mode = "autonomous";
|
|
720
|
+
const transitionSpy = vi.fn(async () => ({ ok: true as const }));
|
|
721
|
+
orchestrator.transitionToNextPhase = transitionSpy;
|
|
722
|
+
registerOrchestratorToolsForTest(orchestrator);
|
|
723
|
+
const tool = getTool("pp_phase_complete");
|
|
724
|
+
|
|
725
|
+
const first = await tool.execute("id", { summary: "s" }, undefined, undefined, ctxWithUi());
|
|
726
|
+
expect(first.content[0].text).toContain("reconcile the task's state files");
|
|
727
|
+
expect(transitionSpy).not.toHaveBeenCalled();
|
|
728
|
+
|
|
729
|
+
const second = await tool.execute("id", { summary: "s" }, undefined, undefined, ctxWithUi());
|
|
730
|
+
expect(second.content[0].text).toBe("");
|
|
731
|
+
expect(transitionSpy).toHaveBeenCalledTimes(1);
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
it("never gates a quick-phase task", async () => {
|
|
735
|
+
orchestrator.active = makeActiveTask();
|
|
736
|
+
orchestrator.active.type = "quick";
|
|
737
|
+
orchestrator.active.state.phase = "quick";
|
|
738
|
+
orchestrator.active.state.step = "llm_work";
|
|
739
|
+
registerOrchestratorToolsForTest(orchestrator);
|
|
740
|
+
const tool = getTool("pp_phase_complete");
|
|
741
|
+
|
|
558
742
|
const result = await tool.execute("id", { summary: "s" }, undefined, undefined, ctxWithUi());
|
|
743
|
+
expect(result.content[0].text).not.toContain("reconcile the task's state files");
|
|
559
744
|
expect(result.content[0].text).toBe("MENU_RESULT");
|
|
560
745
|
});
|
|
561
746
|
});
|
|
@@ -615,16 +615,36 @@ describe("checkoutPrHead", () => {
|
|
|
615
615
|
expect(calls.some((c) => c[0] === "checkout")).toBe(false);
|
|
616
616
|
});
|
|
617
617
|
|
|
618
|
-
it("
|
|
618
|
+
it("switches to the PR head branch (clean tree, different branch) after fetch+verify", async () => {
|
|
619
|
+
const { orchestrator, calls } = makeGitOrchestrator({
|
|
620
|
+
"status --porcelain": { code: 0, stdout: "" },
|
|
621
|
+
"rev-parse HEAD": [{ code: 0, stdout: "othersha\n" }, { code: 0, stdout: "abc123\n" }],
|
|
622
|
+
"rev-parse --abbrev-ref HEAD": { code: 0, stdout: "main\n" },
|
|
623
|
+
"fetch origin": { code: 0, stdout: "" },
|
|
624
|
+
"rev-parse --verify refs/remotes/origin/feature": { code: 0, stdout: "abc123\n" },
|
|
625
|
+
"rev-parse --verify --quiet refs/heads/feature": { code: 0, stdout: "localsha\n" },
|
|
626
|
+
"checkout feature": { code: 0, stdout: "" },
|
|
627
|
+
"merge --ff-only abc123": { code: 0, stdout: "Updating localsha..abc123\n" },
|
|
628
|
+
});
|
|
629
|
+
const result = await checkoutPrHead(orchestrator, "/repo", "feature", "abc123");
|
|
630
|
+
expect(result.ok).toBe(true);
|
|
631
|
+
expect(result.message).toContain('switched to PR head branch "feature"');
|
|
632
|
+
expect(calls.some((c) => c[0] === "checkout" && c[1] === "feature")).toBe(true);
|
|
633
|
+
expect(calls.some((c) => c[0] === "fetch")).toBe(true);
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
it("HALTs on a different branch when the fetched tip does not match the advertised oid", async () => {
|
|
619
637
|
const { orchestrator, calls } = makeGitOrchestrator({
|
|
620
638
|
"status --porcelain": { code: 0, stdout: "" },
|
|
621
639
|
"rev-parse HEAD": { code: 0, stdout: "othersha\n" },
|
|
622
640
|
"rev-parse --abbrev-ref HEAD": { code: 0, stdout: "main\n" },
|
|
641
|
+
"fetch origin": { code: 0, stdout: "" },
|
|
642
|
+
"rev-parse --verify refs/remotes/origin/feature": { code: 0, stdout: "differentsha\n" },
|
|
623
643
|
});
|
|
624
644
|
const result = await checkoutPrHead(orchestrator, "/repo", "feature", "abc123");
|
|
625
645
|
expect(result.ok).toBe(false);
|
|
626
646
|
expect(result.message).toContain("HALT");
|
|
627
|
-
expect(result.message).toContain("
|
|
647
|
+
expect(result.message).toContain("not the advertised PR head");
|
|
628
648
|
expect(calls.some((c) => c[0] === "checkout")).toBe(false);
|
|
629
649
|
expect(calls.some((c) => c[0] === "merge")).toBe(false);
|
|
630
650
|
});
|
|
@@ -16,8 +16,8 @@ import {
|
|
|
16
16
|
getArtifactManifest,
|
|
17
17
|
loadPhaseReviewOutputs,
|
|
18
18
|
} from "./context.js";
|
|
19
|
-
import { PRINCIPLES_BLOCK, toolsBlock, identityBlock, delegationBlock } from "./agents/tool-routing.js";
|
|
20
|
-
import { constraintsBlock, phaseConstraint } from "./agents/constraints.js";
|
|
19
|
+
import { PRINCIPLES_BLOCK, IMPLEMENTATION_PRINCIPLES_BLOCK, toolsBlock, identityBlock, delegationBlock } from "./agents/tool-routing.js";
|
|
20
|
+
import { constraintsBlock, phaseConstraint, isReadOnlyPhase } from "./agents/constraints.js";
|
|
21
21
|
import { registerCbmTools } from "./cbm.js";
|
|
22
22
|
import { registerExaTools } from "./exa.js";
|
|
23
23
|
import { registerAstSearchTool } from "./ast-search.js";
|
|
@@ -564,11 +564,15 @@ function registerOrchestratorTools(orchestrator: Orchestrator): void {
|
|
|
564
564
|
// Extension-side PR-head checkout for the review phase. Performed here (never via the
|
|
565
565
|
// agent prompt) so REVIEW_READONLY_CONSTRAINT stays truthful — the agent never runs
|
|
566
566
|
// `git checkout` itself. Lands the repo on its PR head under strict safety rules (no stash,
|
|
567
|
-
// no --force, no
|
|
568
|
-
//
|
|
569
|
-
//
|
|
570
|
-
//
|
|
571
|
-
//
|
|
567
|
+
// no --force, no worktree, no detached checkout): when the tree is clean and on the PR head's
|
|
568
|
+
// branch but merely behind, it fast-forwards the branch to the head (`git merge --ff-only`,
|
|
569
|
+
// which never detaches, never rewrites, and refuses a non-ff move). When the tree is clean but
|
|
570
|
+
// on a DIFFERENT branch, it fetches the PR head ref from origin (forced refspec into the
|
|
571
|
+
// remote-tracking ref only), verifies the fetched tip equals the advertised oid, then fast-
|
|
572
|
+
// forwards an existing local branch or creates a tracking branch at the oid and checks it out.
|
|
573
|
+
// A dirty tree, a detached HEAD, a fork/unfetchable ref, an oid mismatch, or a non-fast-
|
|
574
|
+
// forwardable divergence all HALT with a message for the user. Checking HEAD's SHA first makes
|
|
575
|
+
// this re-entrant and detached-safe.
|
|
572
576
|
export async function checkoutPrHead(
|
|
573
577
|
orchestrator: Orchestrator,
|
|
574
578
|
repoPath: string,
|
|
@@ -632,12 +636,91 @@ export async function checkoutPrHead(
|
|
|
632
636
|
};
|
|
633
637
|
}
|
|
634
638
|
if (name && currentBranch !== name) {
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
639
|
+
// Clean tree on the wrong branch: switch to the PR head branch when it is
|
|
640
|
+
// provably safe. The tree is already known clean (checked above), so no
|
|
641
|
+
// stash is ever needed. We fetch the head ref, verify it matches the
|
|
642
|
+
// advertised oid, then either fast-forward an existing local branch to it
|
|
643
|
+
// or create a fresh tracking branch at it. Anything ambiguous HALTs.
|
|
644
|
+
if (!/^[^\s]+$/.test(name) || name.startsWith("-")) {
|
|
645
|
+
return {
|
|
646
|
+
ok: false,
|
|
647
|
+
message: `HALT: ${repoPath} PR head branch name "${name}" is not a safe ref to fetch. Bring the repo to its PR head (${oid}) yourself, then ask me to continue.`,
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
const fetchRes = await run(["fetch", "origin", `+refs/heads/${name}:refs/remotes/origin/${name}`]);
|
|
651
|
+
if (fetchRes.code !== 0) {
|
|
652
|
+
return {
|
|
653
|
+
ok: false,
|
|
654
|
+
message:
|
|
655
|
+
`HALT: ${repoPath} could not fetch PR head branch "${name}" from origin (a fork PR, or the branch is not on origin). ` +
|
|
656
|
+
`Fetch/add the correct remote and bring the repo to the PR head ${oid} yourself, then ask me to continue. I will not guess a remote for you.` +
|
|
657
|
+
`${fetchRes.stderr?.trim() ? `\n\n${fetchRes.stderr.trim()}` : ""}`,
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
let remoteTip;
|
|
661
|
+
try {
|
|
662
|
+
remoteTip = await run(["rev-parse", "--verify", `refs/remotes/origin/${name}`]);
|
|
663
|
+
} catch (err: any) {
|
|
664
|
+
return { ok: false, message: `Cannot resolve origin/${name} in ${repoPath} after fetch: ${err?.message ?? "git rev-parse failed"}` };
|
|
665
|
+
}
|
|
666
|
+
if (remoteTip.code !== 0 || remoteTip.stdout.trim() !== oid) {
|
|
667
|
+
return {
|
|
668
|
+
ok: false,
|
|
669
|
+
message:
|
|
670
|
+
`HALT: ${repoPath} fetched origin/${name} is ${remoteTip.stdout.trim() || "unresolvable"}, not the advertised PR head ${oid}. ` +
|
|
671
|
+
"The PR head may have moved or lives on a fork. Bring the repo to the exact PR head yourself, then ask me to continue.",
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
const localExists = (await run(["rev-parse", "--verify", "--quiet", `refs/heads/${name}`])).code === 0;
|
|
675
|
+
if (localExists) {
|
|
676
|
+
// Prove the fast-forward is safe BEFORE moving HEAD: if the local branch tip
|
|
677
|
+
// is not an ancestor of the PR head, it has diverged and we must HALT while
|
|
678
|
+
// the user is still on their original branch (never switch then fail).
|
|
679
|
+
const localTip = await run(["rev-parse", "--verify", `refs/heads/${name}`]);
|
|
680
|
+
const ancestry = await run(["merge-base", "--is-ancestor", localTip.stdout.trim(), oid]);
|
|
681
|
+
if (ancestry.code !== 0) {
|
|
682
|
+
return {
|
|
683
|
+
ok: false,
|
|
684
|
+
message:
|
|
685
|
+
`HALT: ${repoPath} local branch "${name}" cannot fast-forward to PR head ${oid} — it has diverged. ` +
|
|
686
|
+
"Reconcile it with the PR head, then ask me to continue. I will not force-move or rebase it for you.",
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
const co = await run(["checkout", name]);
|
|
690
|
+
if (co.code !== 0) {
|
|
691
|
+
return { ok: false, message: `HALT: ${repoPath} could not check out "${name}": ${co.stderr?.trim() || "git checkout failed"}. Resolve it, then ask me to continue.` };
|
|
692
|
+
}
|
|
693
|
+
const ffSwitch = await run(["merge", "--ff-only", oid]);
|
|
694
|
+
if (ffSwitch.code !== 0) {
|
|
695
|
+
return {
|
|
696
|
+
ok: false,
|
|
697
|
+
message:
|
|
698
|
+
`HALT: ${repoPath} local branch "${name}" cannot fast-forward to PR head ${oid} — it has diverged. ` +
|
|
699
|
+
"Reconcile it with the PR head, then ask me to continue. I will not force-move or rebase it for you." +
|
|
700
|
+
`${ffSwitch.stderr?.trim() ? `\n\n${ffSwitch.stderr.trim()}` : ""}`,
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
} else {
|
|
704
|
+
const co = await run(["checkout", "-b", name, "--track", `refs/remotes/origin/${name}`]);
|
|
705
|
+
if (co.code !== 0) {
|
|
706
|
+
return { ok: false, message: `HALT: ${repoPath} could not create tracking branch "${name}" at the PR head: ${co.stderr?.trim() || "git checkout -b failed"}. Resolve it, then ask me to continue.` };
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
let switched;
|
|
710
|
+
try {
|
|
711
|
+
switched = await run(["rev-parse", "HEAD"]);
|
|
712
|
+
} catch (err: any) {
|
|
713
|
+
return { ok: false, message: `Cannot confirm HEAD of ${repoPath} after switching to "${name}": ${err?.message ?? "git rev-parse failed"}` };
|
|
714
|
+
}
|
|
715
|
+
if (switched.stdout.trim() !== oid) {
|
|
716
|
+
return {
|
|
717
|
+
ok: false,
|
|
718
|
+
message:
|
|
719
|
+
`HALT: ${repoPath} is on "${name}" but at ${switched.stdout.trim()}, not the PR head ${oid}. ` +
|
|
720
|
+
"Bring it to the exact PR head yourself, then ask me to continue.",
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
return { ok: true, message: `${repoPath} switched to PR head branch "${name}" at ${oid}.` };
|
|
641
724
|
}
|
|
642
725
|
|
|
643
726
|
// Clean tree, on the PR head's branch, but behind: advance the branch to the head with a
|
|
@@ -690,10 +773,13 @@ function registerCheckoutPrHeadTool(orchestrator: Orchestrator): void {
|
|
|
690
773
|
"Call this ONLY for a PR-scoped review, once per repo, AFTER you have resolved the PR " +
|
|
691
774
|
"(e.g. via `gh pr view --json headRefName,headRefOid`). Do NOT call it for a branch, " +
|
|
692
775
|
"commit-range, or uncommitted-changes review. The extension does this safely (no worktree, " +
|
|
693
|
-
"no stash, no --force, no
|
|
776
|
+
"no stash, no --force, no detached checkout): a clean repo already on the " +
|
|
694
777
|
"PR head succeeds; a clean repo on the PR head's branch but behind is fast-forwarded to the " +
|
|
695
|
-
"head; a
|
|
696
|
-
"
|
|
778
|
+
"head; a clean repo on a DIFFERENT branch is switched to the PR head branch only after fetching " +
|
|
779
|
+
"origin and verifying the fetched tip matches the advertised commit (fast-forwarding an existing " +
|
|
780
|
+
"local branch or creating a tracking one); a dirty tree, a detached HEAD, a fork/unfetchable ref, " +
|
|
781
|
+
"an oid mismatch, or a diverged branch HALTS with a message for you to relay to the user. You " +
|
|
782
|
+
"never run `git checkout` yourself.",
|
|
697
783
|
parameters: Type.Object({
|
|
698
784
|
repoPath: Type.String({ description: "Absolute path to the registered repository" }),
|
|
699
785
|
headRefName: Type.String({ description: "PR head branch name (headRefName)" }),
|
|
@@ -937,6 +1023,37 @@ function registerCommitTool(orchestrator: Orchestrator): void {
|
|
|
937
1023
|
});
|
|
938
1024
|
}
|
|
939
1025
|
|
|
1026
|
+
// Returns a one-time reconcile directive on the first pp_phase_complete of a
|
|
1027
|
+
// phase, null on the acknowledging re-call. Bounded to one round-trip so
|
|
1028
|
+
// autonomous runs never loop.
|
|
1029
|
+
function maybePromptReconcile(orchestrator: Orchestrator): string | null {
|
|
1030
|
+
const active = orchestrator.active;
|
|
1031
|
+
if (!active) return null;
|
|
1032
|
+
const phase = active.state.phase;
|
|
1033
|
+
if (phase === "quick") return null;
|
|
1034
|
+
|
|
1035
|
+
if (active.state.reconciledPhase === phase && !active.state.reconcilePending) {
|
|
1036
|
+
return null;
|
|
1037
|
+
}
|
|
1038
|
+
const alreadyPrompted = active.state.reconcilePromptedPhase === phase && !active.state.reconcilePending;
|
|
1039
|
+
if (alreadyPrompted) {
|
|
1040
|
+
active.state.reconciledPhase = phase;
|
|
1041
|
+
active.state.reconcilePending = false;
|
|
1042
|
+
saveTask(active.dir, active.state);
|
|
1043
|
+
return null;
|
|
1044
|
+
}
|
|
1045
|
+
active.state.reconcilePromptedPhase = phase;
|
|
1046
|
+
active.state.reconcilePending = false;
|
|
1047
|
+
saveTask(active.dir, active.state);
|
|
1048
|
+
return (
|
|
1049
|
+
"Before finishing this phase, reconcile the task's state files with everything you've learned so far: " +
|
|
1050
|
+
"update USER_REQUEST.md, RESEARCH.md, the active synthesized plan, and any artifacts/*.md you own so a " +
|
|
1051
|
+
"freshly-spawned planner or reviewer (which only sees these files) is not working from a stale snapshot. " +
|
|
1052
|
+
"Use pp_write_state_file / pp_edit_state_file. Then call pp_phase_complete again to proceed — if nothing " +
|
|
1053
|
+
"needed changing, just call it again. (This one-time reconcile check runs once per phase.)"
|
|
1054
|
+
);
|
|
1055
|
+
}
|
|
1056
|
+
|
|
940
1057
|
function registerPhaseCompleteTool(orchestrator: Orchestrator): void {
|
|
941
1058
|
const pi = orchestrator.pi;
|
|
942
1059
|
|
|
@@ -961,6 +1078,12 @@ function registerPhaseCompleteTool(orchestrator: Orchestrator): void {
|
|
|
961
1078
|
const count = orchestrator.spawnedAgentIds.size + orchestrator.pendingSubagentSpawns;
|
|
962
1079
|
return { content: [{ type: "text" as const, text: `${count} subagent(s) still running. Wait for them to complete before calling pp_phase_complete.` }], isError: true as const, details: {} };
|
|
963
1080
|
}
|
|
1081
|
+
|
|
1082
|
+
const reconcileDirective = maybePromptReconcile(orchestrator);
|
|
1083
|
+
if (reconcileDirective) {
|
|
1084
|
+
return { content: [{ type: "text" as const, text: reconcileDirective }], details: {} };
|
|
1085
|
+
}
|
|
1086
|
+
|
|
964
1087
|
// Gate on the effective PHASE mode, not the task mode: brainstorm/debug/
|
|
965
1088
|
// review are always user-driven even for an autonomous task, so they must
|
|
966
1089
|
// fall through to the guided menu path below rather than auto-advancing.
|
|
@@ -1441,6 +1564,18 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
1441
1564
|
}
|
|
1442
1565
|
}
|
|
1443
1566
|
|
|
1567
|
+
// Mark ONE agent's record consumed. The subagents:completed event is emitted
|
|
1568
|
+
// synchronously from pi-subagents' onComplete callback BEFORE it checks
|
|
1569
|
+
// record.resultConsumed to decide whether to send the individual nudge, so
|
|
1570
|
+
// setting the flag here (during the event) suppresses the per-agent nudge for
|
|
1571
|
+
// a phased-batch member whose completion is instead reported by the single
|
|
1572
|
+
// aggregated signal once the whole batch finishes.
|
|
1573
|
+
function markAgentConsumed(id: string): void {
|
|
1574
|
+
const mgr = (globalThis as any)[Symbol.for("pi-subagents:manager")];
|
|
1575
|
+
const record = mgr?.getRecord?.(id);
|
|
1576
|
+
if (record) record.resultConsumed = true;
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1444
1579
|
function checkPlannerCompletion(): void {
|
|
1445
1580
|
if (
|
|
1446
1581
|
!orchestrator.active ||
|
|
@@ -1866,7 +2001,9 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
1866
2001
|
// only completion signal.
|
|
1867
2002
|
const step = orchestrator.active.state.step;
|
|
1868
2003
|
const isPhasedBatch = step === "await_planners" || step === "await_reviewers";
|
|
1869
|
-
if (
|
|
2004
|
+
if (isPhasedBatch) {
|
|
2005
|
+
markAgentConsumed(data.id);
|
|
2006
|
+
} else {
|
|
1870
2007
|
const desc = data.description || data.type || data.id;
|
|
1871
2008
|
const duration = data.durationMs ? `${(data.durationMs / 1000).toFixed(1)}s` : "";
|
|
1872
2009
|
const tokens = data.tokens?.total ? `${data.tokens.total} tok` : "";
|
|
@@ -2134,7 +2271,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
2134
2271
|
// user's initiating prompt, collapse whitespace, and cap at ~700 chars so
|
|
2135
2272
|
// the state file never holds an unbounded prompt. Only the genuine first
|
|
2136
2273
|
// user prompt qualifies (not a [PI-PI] injection).
|
|
2137
|
-
const GENERIC_DESCRIPTIONS = ["implement", "
|
|
2274
|
+
const GENERIC_DESCRIPTIONS = ["implement", "brainstorm", "review", "quick"];
|
|
2138
2275
|
if (GENERIC_DESCRIPTIONS.includes(orchestrator.active.state.description) && event.prompt) {
|
|
2139
2276
|
// Collapse the whole multi-line prompt into one line (newlines → spaces)
|
|
2140
2277
|
// so a request whose intent spans several lines isn't cut off at line 1;
|
|
@@ -2202,6 +2339,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
2202
2339
|
identity,
|
|
2203
2340
|
constraintsBlock(phase as Phase, effectiveMode),
|
|
2204
2341
|
PRINCIPLES_BLOCK,
|
|
2342
|
+
isReadOnlyPhase(phase as Phase) ? "" : IMPLEMENTATION_PRINCIPLES_BLOCK,
|
|
2205
2343
|
toolsBlock(mainToolNames),
|
|
2206
2344
|
delegation,
|
|
2207
2345
|
projectContext,
|
|
@@ -235,6 +235,61 @@ describe("updateFlantInfra", () => {
|
|
|
235
235
|
expect([...pi.registered.keys()].sort()).toEqual(["pp-flant-anthropic", "pp-flant-openai"]);
|
|
236
236
|
});
|
|
237
237
|
|
|
238
|
+
it("serves a fresh cache without re-fetching by default", async () => {
|
|
239
|
+
const dir = makeTempDir();
|
|
240
|
+
const cacheDir = join(dir, "extensions", "pp", "cache");
|
|
241
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
242
|
+
writeFileSync(
|
|
243
|
+
join(cacheDir, "flant-models.json"),
|
|
244
|
+
JSON.stringify({
|
|
245
|
+
enabled: true,
|
|
246
|
+
cacheTTLDays: 7,
|
|
247
|
+
lastUpdated: new Date().toISOString(),
|
|
248
|
+
cachedFlantModels: ["claude-opus-4-8"],
|
|
249
|
+
cachedOpenRouterData: {},
|
|
250
|
+
}),
|
|
251
|
+
"utf-8",
|
|
252
|
+
);
|
|
253
|
+
process.env.FLANT_API_KEY = "flant-k";
|
|
254
|
+
const mod = await loadModule(dir);
|
|
255
|
+
const fetchFn = stubFetch(() => { throw new Error("should not fetch"); });
|
|
256
|
+
const res = await mod.updateFlantInfra(makePi());
|
|
257
|
+
expect(res.ok).toBe(true);
|
|
258
|
+
expect(res.models).toEqual(["claude-opus-4-8"]);
|
|
259
|
+
expect(fetchFn).not.toHaveBeenCalled();
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("force bypasses a fresh cache and re-fetches the model list", async () => {
|
|
263
|
+
const dir = makeTempDir();
|
|
264
|
+
const cacheDir = join(dir, "extensions", "pp", "cache");
|
|
265
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
266
|
+
writeFileSync(
|
|
267
|
+
join(cacheDir, "flant-models.json"),
|
|
268
|
+
JSON.stringify({
|
|
269
|
+
enabled: true,
|
|
270
|
+
cacheTTLDays: 7,
|
|
271
|
+
lastUpdated: new Date().toISOString(),
|
|
272
|
+
cachedFlantModels: ["claude-opus-4-8"],
|
|
273
|
+
cachedOpenRouterData: {},
|
|
274
|
+
}),
|
|
275
|
+
"utf-8",
|
|
276
|
+
);
|
|
277
|
+
process.env.FLANT_API_KEY = "flant-k";
|
|
278
|
+
const mod = await loadModule(dir);
|
|
279
|
+
stubFetch((url: string) => {
|
|
280
|
+
if (url.includes("llm-api.flant.ru/v1/models")) {
|
|
281
|
+
return { ok: true, status: 200, json: async () => ({ data: [{ id: "sub/claude-fable-5" }, { id: "claude-fable-5" }] }) };
|
|
282
|
+
}
|
|
283
|
+
if (url.includes("openrouter.ai")) {
|
|
284
|
+
return { ok: true, status: 200, json: async () => ({ data: [] }) };
|
|
285
|
+
}
|
|
286
|
+
throw new Error(`unexpected ${url}`);
|
|
287
|
+
});
|
|
288
|
+
const res = await mod.updateFlantInfra(makePi(), { force: true });
|
|
289
|
+
expect(res.ok).toBe(true);
|
|
290
|
+
expect(res.models).toContain("sub/claude-fable-5");
|
|
291
|
+
});
|
|
292
|
+
|
|
238
293
|
it("falls back to cached models when discovery throws", async () => {
|
|
239
294
|
const dir = makeTempDir();
|
|
240
295
|
const cacheDir = join(dir, "extensions", "pp", "cache");
|
|
@@ -422,7 +422,6 @@ describe("flant-infra", () => {
|
|
|
422
422
|
|
|
423
423
|
expect(config.agents.orchestrators.implement.model).toBe("pp-flant-anthropic/claude-opus-4-6");
|
|
424
424
|
expect(config.agents.orchestrators.plan.model).toBe("pp-flant-anthropic/claude-opus-4-6");
|
|
425
|
-
expect(config.agents.orchestrators.debug.model).toBe("pp-flant-openai/gpt-5-4");
|
|
426
425
|
expect(config.agents.orchestrators.brainstorm.model).toBe("pp-flant-anthropic/claude-opus-4-6");
|
|
427
426
|
expect(config.agents.orchestrators.review.model).toBe("pp-flant-anthropic/claude-opus-4-6");
|
|
428
427
|
expect(config.agents.subagents.simple.explore.model).toBe("pp-flant-openai/gemini-3-1-flash");
|
|
@@ -448,7 +447,6 @@ describe("flant-infra", () => {
|
|
|
448
447
|
expect(config.agents.subagents.simple.task.model).toBe("pp-flant-anthropic-sub/sub/claude-opus-4-8");
|
|
449
448
|
expect(config.agents.subagents.presetGroups.planners.presets.regular.agents.opus.model).toBe("pp-flant-anthropic-sub/sub/claude-opus-4-8");
|
|
450
449
|
// Non-Claude roles stay on the openai (company-billed) provider
|
|
451
|
-
expect(config.agents.orchestrators.debug.model).toBe("pp-flant-openai/gpt-5-4");
|
|
452
450
|
expect(config.agents.subagents.simple.explore.model).toBe("pp-flant-openai/gemini-3-1-flash");
|
|
453
451
|
expect(config.agents.subagents.presetGroups.planners.presets.regular.agents.gpt.model).toBe("pp-flant-openai/gpt-5-4");
|
|
454
452
|
});
|
|
@@ -541,7 +539,6 @@ describe("flant-infra", () => {
|
|
|
541
539
|
|
|
542
540
|
expect(config.agents.orchestrators.implement.model).toBe("pp-flant-anthropic/claude-opus-4-7");
|
|
543
541
|
expect(config.agents.orchestrators.plan.model).toBe("pp-flant-anthropic/claude-opus-4-7");
|
|
544
|
-
expect(config.agents.orchestrators.debug.model).toBe("pp-flant-openai/gpt-5-4");
|
|
545
542
|
expect(config.agents.subagents.presetGroups.planners.presets.regular.agents.gemini.model).toBe("pp-flant-openai/gemini-3-1-pro");
|
|
546
543
|
expect(config.agents.subagents.simple.explore.model).toBe("pp-flant-openai/gemini-3-1-flash-lite");
|
|
547
544
|
});
|
|
@@ -606,10 +606,10 @@ export function registerFlantProviders(
|
|
|
606
606
|
const subscription = options.subscription ?? loadFlantSettings().subscription;
|
|
607
607
|
let subModels: string[] = [];
|
|
608
608
|
if (subscription) {
|
|
609
|
-
// The gateway
|
|
610
|
-
//
|
|
611
|
-
//
|
|
612
|
-
//
|
|
609
|
+
// The gateway defines `sub/` model groups for only some claude models —
|
|
610
|
+
// registering unconfirmed ones yields "Invalid model name" 400s. Model
|
|
611
|
+
// lists cached before the gateway exposed `sub/…` ids have none; fall back
|
|
612
|
+
// to all claude models then.
|
|
613
613
|
const subEligible = subConfirmed.size > 0
|
|
614
614
|
? anthropicModels.filter((m) => subConfirmed.has(m))
|
|
615
615
|
: anthropicModels;
|
|
@@ -786,7 +786,6 @@ export function generateFlantConfig(models: string[], subscriptionActive = false
|
|
|
786
786
|
orchestrators: {
|
|
787
787
|
implement: { model: modelSpec(implementModel, sub(implementModel)), thinking: "high" },
|
|
788
788
|
plan: { model: modelSpec(implementModel, sub(implementModel)), thinking: "high" },
|
|
789
|
-
debug: { model: modelSpec(debugModel, sub(debugModel)), thinking: "high" },
|
|
790
789
|
brainstorm: { model: modelSpec(brainstormModel, sub(brainstormModel)), thinking: "high" },
|
|
791
790
|
review: { model: modelSpec(implementModel, sub(implementModel)), thinking: "high" },
|
|
792
791
|
quick: { model: modelSpec(implementModel, sub(implementModel)), thinking: "high" },
|
|
@@ -899,8 +898,18 @@ export function getFlantGeneratedConfig(): Partial<PiPiConfig> | null {
|
|
|
899
898
|
|
|
900
899
|
(globalThis as any)[Symbol.for("pi-pi:flant-config")] = getFlantGeneratedConfig;
|
|
901
900
|
|
|
901
|
+
export interface UpdateFlantOptions {
|
|
902
|
+
/**
|
|
903
|
+
* Bypass the TTL cache and re-fetch the model list from the gateway even when
|
|
904
|
+
* the cache is still fresh. Set by the explicit "Update now" action so it does
|
|
905
|
+
* what its label promises; auto/startup callers omit it and stay cache-bound.
|
|
906
|
+
*/
|
|
907
|
+
force?: boolean;
|
|
908
|
+
}
|
|
909
|
+
|
|
902
910
|
export async function updateFlantInfra(
|
|
903
911
|
pi: ExtensionAPI,
|
|
912
|
+
options: UpdateFlantOptions = {},
|
|
904
913
|
): Promise<{ ok: boolean; error?: string; models?: string[] }> {
|
|
905
914
|
setPI(pi);
|
|
906
915
|
const settings = loadFlantSettings();
|
|
@@ -911,8 +920,9 @@ export async function updateFlantInfra(
|
|
|
911
920
|
await refreshClaudeOAuthToken();
|
|
912
921
|
}
|
|
913
922
|
|
|
914
|
-
|
|
915
|
-
let
|
|
923
|
+
const cacheOk = !options.force && isCacheValid(settings);
|
|
924
|
+
let models = cacheOk ? settings.cachedFlantModels : null;
|
|
925
|
+
let metadata = cacheOk ? settings.cachedOpenRouterData : null;
|
|
916
926
|
let refreshed = false;
|
|
917
927
|
|
|
918
928
|
if (!models || !metadata) {
|