pi-plans 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +74 -21
  2. package/index.ts +115 -9
  3. package/package.json +7 -1
  4. package/references/pi-planning-workflow.md +18 -3
  5. package/references/state-and-config.md +34 -2
  6. package/scripts/validate.ts +4 -0
  7. package/src/code-graph/commands.ts +437 -0
  8. package/src/code-graph/discovery.ts +118 -0
  9. package/src/code-graph/git.ts +108 -0
  10. package/src/code-graph/identity.ts +59 -0
  11. package/src/code-graph/indexer.ts +281 -0
  12. package/src/code-graph/materialize.ts +166 -0
  13. package/src/code-graph/mode.ts +28 -0
  14. package/src/code-graph/mutations.ts +160 -0
  15. package/src/code-graph/parser.ts +51 -0
  16. package/src/code-graph/parsers/javascript.ts +35 -0
  17. package/src/code-graph/parsers/python.ts +160 -0
  18. package/src/code-graph/parsers/tree-sitter.ts +316 -0
  19. package/src/code-graph/paths.ts +85 -0
  20. package/src/code-graph/prompts.ts +18 -0
  21. package/src/code-graph/resolver.ts +69 -0
  22. package/src/code-graph/runtime.ts +158 -0
  23. package/src/code-graph/schema.ts +135 -0
  24. package/src/code-graph/screening.ts +82 -0
  25. package/src/code-graph/store.ts +278 -0
  26. package/src/code-graph/summary.ts +435 -0
  27. package/src/code-graph/types.ts +163 -0
  28. package/src/compaction.ts +1125 -371
  29. package/src/config-command.ts +326 -0
  30. package/src/exec.ts +356 -686
  31. package/src/refine-prompts.ts +50 -0
  32. package/src/refine-ui-helpers.ts +71 -18
  33. package/src/refine-ui-state.ts +87 -21
  34. package/src/refine-ui.ts +210 -102
  35. package/src/state.ts +19 -6
  36. package/src/subagent.ts +163 -61
  37. package/tests/ask-choice.test.ts +263 -0
  38. package/tests/autocomplete.test.ts +6 -1
  39. package/tests/code-graph-apply.test.ts +185 -0
  40. package/tests/code-graph-commands.test.ts +211 -0
  41. package/tests/code-graph-db.test.ts +166 -0
  42. package/tests/code-graph-discovery.test.ts +38 -0
  43. package/tests/code-graph-git.test.ts +94 -0
  44. package/tests/code-graph-index.test.ts +175 -0
  45. package/tests/code-graph-loop.e2e.test.ts +159 -0
  46. package/tests/code-graph-mutations.test.ts +117 -0
  47. package/tests/code-graph-parser.test.ts +85 -0
  48. package/tests/code-graph-rollback.test.ts +100 -0
  49. package/tests/code-graph-summary-batching.test.ts +518 -0
  50. package/tests/code-graph-summary.test.ts +148 -0
  51. package/tests/compaction.test.ts +371 -57
  52. package/tests/config-command.test.ts +255 -0
  53. package/tests/exec.test.ts +665 -241
  54. package/tests/fixtures/code-graph/sample.js +36 -0
  55. package/tests/fixtures/code-graph/sample.py +20 -0
  56. package/tests/fixtures/code-graph/sample.ts +15 -0
  57. package/tests/graph-aware-file-tools.test.ts +411 -0
  58. package/tests/refine-prompts.test.ts +67 -2
  59. package/tests/refine-ui.test.ts +337 -72
  60. package/tests/subagent.test.ts +26 -20
  61. package/tools/ask-choice.ts +158 -11
  62. package/tools/code-graph.ts +254 -0
  63. package/tools/graph-aware-file-tools.ts +392 -0
  64. package/tools/plans.ts +84 -1
  65. package/tools/refine.ts +61 -15
@@ -23,6 +23,9 @@ import {
23
23
  handleExecutionCompact,
24
24
  handleExecutionTurnCompaction,
25
25
  handleExecutionCompactFailed,
26
+ compactionInFlight,
27
+ noteCompactionStarted,
28
+ noteCompactionEnded,
26
29
  handlePlanningBeforeCompact,
27
30
  handlePlanningCompact,
28
31
  handlePlanningCompactFailed,
@@ -40,7 +43,7 @@ import {
40
43
  updateStatusWidget,
41
44
  } from "../src/exec.ts";
42
45
  import type { CheckItem } from "../src/plan.ts";
43
- import { initState, setRunStatus, startRun } from "../src/state.ts";
46
+ import { initState, setGraphEnabled, setRunStatus, startRun } from "../src/state.ts";
44
47
 
45
48
  interface Recorded {
46
49
  entries: { type: string; customType?: string; data?: unknown }[];
@@ -55,6 +58,7 @@ interface Recorded {
55
58
  selectAnswer?: string;
56
59
  current: { provider: string; id: string } | null;
57
60
  thinking: string | null;
61
+ userMessages: string[];
58
62
  compacts?: { customInstructions?: string }[];
59
63
  }
60
64
 
@@ -78,6 +82,7 @@ function makeHarness(workdir: string): Harness {
78
82
  selects: [],
79
83
  current: { provider: "p", id: "m" },
80
84
  thinking: "high",
85
+ userMessages: [],
81
86
  };
82
87
  let contextPercent: number | null = 0;
83
88
  const registryModels = [
@@ -108,7 +113,9 @@ function makeHarness(workdir: string): Harness {
108
113
  sendMessage: (message: { customType: string; content: string }, options?: { triggerTurn?: boolean }) => {
109
114
  recorded.messages.push({ ...message, options });
110
115
  },
111
- sendUserMessage: async () => {},
116
+ sendUserMessage: async (content: string) => {
117
+ recorded.userMessages.push(content);
118
+ },
112
119
  setModel: async (model: { provider: string; id: string }) => {
113
120
  recorded.models.push({ provider: model.provider, id: model.id });
114
121
  recorded.current = { provider: model.provider, id: model.id };
@@ -177,6 +184,22 @@ function items(...ids: string[]): CheckItem[] {
177
184
  return ids.map((id) => ({ id, text: `\`${id}\` demo item`, done: false }));
178
185
  }
179
186
 
187
+ function compactableBranchEntries(): any[] {
188
+ return [
189
+ { id: "u-1", type: "message", message: { role: "user", content: [{ type: "text", text: "start the work" }] } },
190
+ { id: "a-1", type: "message", message: { role: "assistant", content: [{ type: "text", text: "made progress" }] } },
191
+ { id: "u-2", type: "message", message: { role: "user", content: [{ type: "text", text: "continue from here" }] } },
192
+ { id: "a-2", type: "message", message: { role: "assistant", content: [{ type: "text", text: "tail work" }] } },
193
+ ];
194
+ }
195
+
196
+ function startActiveRun(workdir: string, status: "planning" | "executing" = "planning") {
197
+ initState(workdir);
198
+ const { run } = startRun(workdir, { topic: "compact", skill: "plan-normal", requestText: "x" });
199
+ if (status !== "planning") setRunStatus(workdir, run.run_id, status);
200
+ return run;
201
+ }
202
+
180
203
  describe("execution loop", () => {
181
204
  let tmpRoot: string;
182
205
  let counter = 0;
@@ -206,7 +229,7 @@ describe("execution loop", () => {
206
229
  assert.match(recorded.status ?? "", /out-toks/);
207
230
 
208
231
  assert.ok(getExecution());
209
- const rules = executionContextMessage()!;
232
+ const rules = executionContextMessage(ctx)!;
210
233
  assert.match(rules, /PI-PLANS EXECUTION/);
211
234
  assert.match(rules, /VC-001/);
212
235
  assert.match(rules, /subprocess-backed verification/);
@@ -234,6 +257,86 @@ describe("execution loop", () => {
234
257
  assert.ok(recorded.messages.some((message) => message.customType === "pi-plans-complete"));
235
258
  });
236
259
 
260
+ it("completion enters goal-running continuation and triggers a turn in interactive sessions", async () => {
261
+ const workdir = freshWorkdir();
262
+ const { pi, ctx, recorded } = makeHarness(workdir);
263
+ const planPath = path.join(workdir, "PLAN_v1.md");
264
+ await startExecution(pi, ctx, planPath, items("VC-001", "VC-002"));
265
+ assert.deepEqual(applyDoneMarkers("[DONE:VC-001] [DONE:VC-002]"), ["VC-001", "VC-002"]);
266
+
267
+ await completeExecution(pi, ctx);
268
+
269
+ const completeMessage = recorded.messages.find((message) => message.customType === "pi-plans-complete");
270
+ assert.ok(completeMessage);
271
+ assert.match(completeMessage.content, /Goal-running continuation/);
272
+ assert.match(completeMessage.content, /termination condition of the implementation-review loop/);
273
+ assert.doesNotMatch(completeMessage.content, /Run a post-execution amelioration round/);
274
+ assert.equal(completeMessage.options?.triggerTurn, true);
275
+ const ameliorateEntry = recorded.entries.find((entry) => entry.customType === "pi-plans-ameliorate");
276
+ assert.ok(ameliorateEntry);
277
+ const data = ameliorateEntry.data as Record<string, unknown>;
278
+ assert.equal(data.phase, "goal-started");
279
+ assert.equal(data.rounds, null);
280
+ assert.equal(data.currentRound, 0);
281
+ assert.equal(data.planPath, planPath);
282
+ });
283
+
284
+ it("completion stays silent in headless sessions", async () => {
285
+ const workdir = freshWorkdir();
286
+ const { pi, ctx, recorded } = makeHarness(workdir);
287
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v1.md"), items("VC-001"));
288
+ assert.deepEqual(applyDoneMarkers("[DONE:VC-001]"), ["VC-001"]);
289
+ (ctx as any).hasUI = false;
290
+
291
+ await completeExecution(pi, ctx);
292
+
293
+ const completeMessage = recorded.messages.find((message) => message.customType === "pi-plans-complete");
294
+ assert.ok(completeMessage);
295
+ assert.doesNotMatch(completeMessage.content, /Goal-running continuation/);
296
+ assert.equal(completeMessage.options?.triggerTurn, false);
297
+ assert.equal(
298
+ recorded.entries.some((entry) => entry.customType === "pi-plans-ameliorate"),
299
+ false,
300
+ "headless completion must not append the ameliorate entry",
301
+ );
302
+ });
303
+
304
+ it("restoreFromSession completion triggers the same goal-running continuation in interactive sessions", async () => {
305
+ const workdir = freshWorkdir();
306
+ const { pi, ctx, recorded } = makeHarness(workdir);
307
+ const planPath = path.join(workdir, "PLAN_v1.md");
308
+ fs.writeFileSync(planPath, "# plan");
309
+ const snapshot = {
310
+ planPath,
311
+ items: items("VC-001", "VC-002"),
312
+ startedAt: "2026-08-25T00:00:00Z",
313
+ usage: { inToks: 0, outToks: 0 },
314
+ implItems: [],
315
+ implStatus: {},
316
+ compaction: {
317
+ inFlight: true,
318
+ resumeGuard: true,
319
+ cooldownActive: true,
320
+ lastAttemptReason: "threshold",
321
+ lastSuccessfulUsagePercent: 100,
322
+ lastSuccessfulAt: "2026-08-25T00:01:00Z",
323
+ },
324
+ };
325
+ const entries = [
326
+ { type: "custom", customType: "pi-plans-exec", data: snapshot },
327
+ {
328
+ type: "message",
329
+ message: { role: "assistant", content: [{ type: "text", text: "did [DONE:VC-001] [DONE:VC-002]" }] },
330
+ },
331
+ ];
332
+ await restoreFromSession(pi, ctx, entries as any);
333
+ const completeMessage = recorded.messages.find((message) => message.customType === "pi-plans-complete");
334
+ assert.ok(completeMessage, "restore path must fire completeExecution");
335
+ assert.match(completeMessage.content, /Goal-running continuation/);
336
+ assert.equal(completeMessage.options?.triggerTurn, true);
337
+ assert.ok(recorded.entries.some((entry) => entry.customType === "pi-plans-ameliorate"));
338
+ });
339
+
237
340
  it("restores progress from session entries and rescans messages", async () => {
238
341
  const workdir = freshWorkdir();
239
342
  const { pi, ctx, recorded } = makeHarness(workdir);
@@ -392,36 +495,51 @@ describe("execution loop", () => {
392
495
  assert.equal(getExecution(), null);
393
496
  });
394
497
 
395
- it("lets Pi core own execution compaction scheduling and customizes every reason", async () => {
498
+ it("returns control to Pi core without active execution or planning state", async () => {
396
499
  const workdir = freshWorkdir();
397
- const { pi, ctx, setUsagePercent } = makeHarness(workdir);
398
- await startExecution(pi, ctx, path.join(workdir, "PLAN_v8.md"), items("VC-001", "VC-002"));
500
+ const { pi, ctx } = makeHarness(workdir);
399
501
 
400
- setUsagePercent(50);
401
- const below = handleExecutionBeforeCompact(pi, ctx, {
502
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v0.md"), items("VC-001"));
503
+ const executionResult = handleExecutionBeforeCompact(pi, ctx, {
402
504
  type: "session_before_compact",
403
505
  preparation: makePreparation("threshold", null),
404
- branchEntries: [],
506
+ branchEntries: compactableBranchEntries(),
405
507
  reason: "threshold",
406
508
  willRetry: false,
407
509
  signal: new AbortController().signal,
408
510
  });
409
- assert.equal(below?.cancel, undefined);
410
- assert.ok(below?.compaction, "threshold compaction should remain Pi-core-owned but use the custom summary");
511
+ assert.equal(executionResult, undefined);
512
+ await stopExecution(pi, ctx, "no-active-run");
411
513
 
412
- setUsagePercent(105);
413
- const above = handleExecutionBeforeCompact(pi, ctx, {
514
+ const planningResult = handlePlanningBeforeCompact(pi as any, ctx as any, {
414
515
  type: "session_before_compact",
415
516
  preparation: makePreparation("threshold", null),
416
- branchEntries: [],
517
+ branchEntries: compactableBranchEntries(),
417
518
  reason: "threshold",
418
519
  willRetry: false,
419
520
  signal: new AbortController().signal,
420
521
  });
421
- assert.equal(above?.cancel, undefined);
422
- assert.ok(above?.compaction);
522
+ assert.equal(planningResult, undefined);
523
+ });
524
+
525
+ it("lets Pi core own execution compaction scheduling and customizes safe reasons", async () => {
526
+ const workdir = freshWorkdir();
527
+ startActiveRun(workdir);
528
+ const { pi, ctx, recorded } = makeHarness(workdir);
529
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v8.md"), items("VC-001", "VC-002"));
423
530
 
424
- const overflow = handleExecutionBeforeCompact(pi, ctx, {
531
+ const threshold = handleExecutionBeforeCompact(pi, ctx, {
532
+ type: "session_before_compact",
533
+ preparation: makePreparation("threshold", null),
534
+ branchEntries: compactableBranchEntries(),
535
+ reason: "threshold",
536
+ willRetry: false,
537
+ signal: new AbortController().signal,
538
+ });
539
+ assert.equal(threshold?.cancel, undefined);
540
+ assert.ok(threshold?.compaction, "threshold compaction should use the VCC summary when a legal cut exists");
541
+
542
+ const overflowRetry = handleExecutionBeforeCompact(pi, ctx, {
425
543
  type: "session_before_compact",
426
544
  preparation: makePreparation("overflow", null),
427
545
  branchEntries: [],
@@ -429,8 +547,7 @@ describe("execution loop", () => {
429
547
  willRetry: true,
430
548
  signal: new AbortController().signal,
431
549
  });
432
- assert.equal(overflow?.cancel, undefined);
433
- assert.ok(overflow?.compaction);
550
+ assert.equal(overflowRetry, undefined, "overflow retry falls back to Pi core when VCC has no safe cut");
434
551
 
435
552
  const manual = handleExecutionBeforeCompact(pi, ctx, {
436
553
  type: "session_before_compact",
@@ -440,54 +557,29 @@ describe("execution loop", () => {
440
557
  willRetry: false,
441
558
  signal: new AbortController().signal,
442
559
  });
443
- assert.equal(manual?.cancel, undefined);
444
- assert.ok(manual?.compaction);
560
+ assert.equal(manual?.cancel, true, "manual compaction cancels instead of discarding unsafe context");
561
+ assert.ok(recorded.notifies.some((note) => note.message.includes("Nothing to compact")));
445
562
 
446
563
  await stopExecution(pi, ctx, "test-done");
447
564
  });
448
565
 
449
- it("triggers execution compaction at the high watermark, suppresses repeats, and re-arms below the low watermark", async () => {
566
+ it("does not proactively request execution compaction", async () => {
450
567
  const workdir = freshWorkdir();
568
+ startActiveRun(workdir);
451
569
  const { pi, ctx, recorded, setUsagePercent } = makeHarness(workdir);
452
570
  await startExecution(pi, ctx, path.join(workdir, "PLAN_v9.md"), items("VC-001", "VC-002"));
453
571
 
454
- setUsagePercent(96);
455
- handleExecutionTurnCompaction(ctx);
456
- assert.equal(recorded.compacts?.length ?? 0, 1, "first high-watermark turn should request one compact");
457
-
458
- handleExecutionTurnCompaction(ctx);
459
- assert.equal(recorded.compacts?.length ?? 0, 1, "in-flight compact must not be requested twice");
460
-
461
- handleExecutionCompact(pi, ctx, {
462
- type: "session_compact",
463
- compactionEntry: { type: "compaction" } as never,
464
- fromExtension: true,
465
- reason: "threshold",
466
- willRetry: false,
467
- });
468
- assert.equal(
469
- recorded.messages.filter((message) => message.customType === "pi-plans-exec-resume").length,
470
- 1,
471
- "non-retry execution compaction should queue one hidden resume",
472
- );
473
-
474
- setUsagePercent(96);
572
+ setUsagePercent(99);
475
573
  handleExecutionTurnCompaction(ctx);
476
- assert.equal(recorded.compacts?.length ?? 0, 1, "resume guard should suppress the immediate follow-up turn");
477
-
478
- setUsagePercent(79);
479
574
  handleExecutionTurnCompaction(ctx);
480
- assert.equal(recorded.compacts?.length ?? 0, 1, "low-watermark turns stay quiet while below re-arm");
481
-
482
- setUsagePercent(96);
483
- handleExecutionTurnCompaction(ctx);
484
- assert.equal(recorded.compacts?.length ?? 0, 2, "high watermark should re-arm after the low-watermark drop");
575
+ assert.equal(recorded.compacts?.length ?? 0, 0, "execution scheduling is owned by Pi core");
485
576
 
486
577
  await stopExecution(pi, ctx, "test-done");
487
578
  });
488
579
 
489
- it("skips proactive current-I compaction when no eligible prefix exists", async () => {
580
+ it("ignores current-I growth for proactive execution compaction", async () => {
490
581
  const workdir = freshWorkdir();
582
+ startActiveRun(workdir);
491
583
  const { pi, ctx, recorded } = makeHarness(workdir);
492
584
  await startExecution(pi, ctx, path.join(workdir, "PLAN_v22.md"), items("VC-001"), [
493
585
  { id: "I-001", text: "First item." },
@@ -499,52 +591,82 @@ describe("execution loop", () => {
499
591
  assert.equal(recorded.compacts?.length ?? 0, 0);
500
592
  await stopExecution(pi, ctx, "no-prefix");
501
593
  });
502
- it("wires execution compaction without restoring execution model helpers", () => {
594
+ it("wires execution compaction without restoring proactive requests or model helpers", () => {
503
595
  const indexSource = fs.readFileSync(path.join(process.cwd(), "index.ts"), "utf8");
504
596
  const execSource = fs.readFileSync(path.join(process.cwd(), "src/exec.ts"), "utf8");
505
597
  assert.match(indexSource, /handleExecutionTurnCompaction/);
506
598
  assert.match(execSource, /shouldTriggerExecutionCompaction/);
507
- assert.match(execSource, /requestExecutionCompaction/);
599
+ assert.doesNotMatch(execSource, /ctx\.compact\(/);
508
600
  assert.doesNotMatch(
509
601
  indexSource,
510
602
  /setExecutionModel|chooseExecutionModelSelection|snapshotCurrentModelSelector|ensureExecutionModelActive|restorePlanningModel/,
511
603
  );
512
604
  assert.doesNotMatch(
513
605
  execSource,
514
- /setExecutionModel|chooseExecutionModelSelection|snapshotCurrentModelSelector|ensureExecutionModelActive|restorePlanningModel/,
606
+ /setExecutionModel|chooseExecutionModelSelection|snapshotCurrentModelSelector|ensureExecutionModelActive|restorePlanningModel|buildModelExecutionCompactionResult|modelRegistry\.complete/,
515
607
  );
516
608
  });
517
609
 
518
- it("queues one non-retry resume, skips overflow retry, and keeps execution after failure", async () => {
610
+ it("auto-continues execution only for threshold/overflow and honors manual follow-up prompts", async () => {
519
611
  const workdir = freshWorkdir();
612
+ startActiveRun(workdir);
520
613
  const { pi, ctx, recorded } = makeHarness(workdir);
614
+ (ctx as any).piVersion = "0.84.3";
521
615
  await startExecution(pi, ctx, path.join(workdir, "PLAN_v9.md"), items("VC-001"));
522
616
 
523
- const beforeRequests = recorded.compacts?.length ?? 0;
524
- handleExecutionCompact(pi, ctx, {
617
+ const threshold = handleExecutionBeforeCompact(pi, ctx, {
618
+ type: "session_before_compact",
619
+ preparation: makePreparation("threshold", null),
620
+ branchEntries: compactableBranchEntries(),
621
+ reason: "threshold",
622
+ willRetry: false,
623
+ signal: new AbortController().signal,
624
+ });
625
+ assert.ok(threshold?.compaction);
626
+ await handleExecutionCompact(pi, ctx, {
525
627
  type: "session_compact",
526
628
  compactionEntry: { type: "compaction" } as never,
527
- fromExtension: true,
629
+ fromExtension: false,
528
630
  reason: "threshold",
529
631
  willRetry: false,
530
632
  });
531
- const resumes = recorded.messages.filter((message) => message.customType === "pi-plans-exec-resume");
532
- assert.equal(resumes.length, 1, "non-retry compaction should queue one hidden resume");
533
- assert.equal(resumes[0]?.options?.triggerTurn, true);
534
- assert.equal(recorded.compacts?.length ?? 0, beforeRequests, "compaction hook must not invoke ctx.compact");
633
+ assert.equal(recorded.messages.filter((message) => message.customType === "pi-plans-exec-resume").length, 1);
634
+ assert.ok(recorded.notifies.some((note) => note.message.startsWith("pi-vcc: kept")));
535
635
 
536
- handleExecutionCompact(pi, ctx, {
636
+ handleExecutionBeforeCompact(pi, ctx, {
637
+ type: "session_before_compact",
638
+ preparation: makePreparation("manual", null),
639
+ branchEntries: compactableBranchEntries(),
640
+ reason: "manual",
641
+ willRetry: false,
642
+ signal: new AbortController().signal,
643
+ });
644
+ await handleExecutionCompact(pi, ctx, {
537
645
  type: "session_compact",
538
646
  compactionEntry: { type: "compaction" } as never,
539
- fromExtension: true,
540
- reason: "overflow",
541
- willRetry: true,
647
+ fromExtension: false,
648
+ reason: "manual",
649
+ willRetry: false,
542
650
  });
543
- assert.equal(
544
- recorded.messages.filter((message) => message.customType === "pi-plans-exec-resume").length,
545
- 1,
546
- "overflow retry is owned by Pi core",
547
- );
651
+ assert.equal(recorded.messages.filter((message) => message.customType === "pi-plans-exec-resume").length, 1);
652
+
653
+ handleExecutionBeforeCompact(pi, ctx, {
654
+ type: "session_before_compact",
655
+ preparation: makePreparation("manual", null),
656
+ branchEntries: compactableBranchEntries(),
657
+ customInstructions: "Run focused tests keep:1",
658
+ reason: "manual",
659
+ willRetry: false,
660
+ signal: new AbortController().signal,
661
+ });
662
+ await handleExecutionCompact(pi, ctx, {
663
+ type: "session_compact",
664
+ compactionEntry: { type: "compaction" } as never,
665
+ fromExtension: false,
666
+ reason: "manual",
667
+ willRetry: false,
668
+ });
669
+ assert.deepEqual(recorded.userMessages, ["Run focused tests"]);
548
670
 
549
671
  handleExecutionCompactFailed(pi, ctx, {
550
672
  type: "session_compact_failed",
@@ -560,29 +682,30 @@ describe("execution loop", () => {
560
682
  await stopExecution(pi, ctx, "test-done");
561
683
  });
562
684
 
563
- it("builds a plan-aware summary with per-item sections and chains the previous summary", async () => {
685
+ it("builds a VCC execution summary with phase context and previous summary", async () => {
564
686
  const workdir = freshWorkdir();
565
- const { pi, ctx, setUsagePercent } = makeHarness(workdir);
566
- await startExecution(pi, ctx, path.join(workdir, "PLAN_v10.md"), items("VC-001", "VC-002"));
567
-
568
- applyDoneMarkers("[DONE:VC-001]");
687
+ startActiveRun(workdir);
688
+ const { pi, ctx } = makeHarness(workdir);
689
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v10.md"), items("VC-001", "VC-002"), [
690
+ { id: "I-001", text: "First item." },
691
+ { id: "I-002", text: "Second item." },
692
+ ]);
569
693
 
570
- const previousSummary = "## Goal\nDeliver auto-compact in execution phase.\n\n## Finished Items\n- legacy VC-000 summary";
694
+ const previousSummary = "## Legacy Summary\nDeliver auto-compact in execution phase.";
695
+ applyCurrentIMarker("[I-002:current]");
571
696
  const preparation = makePreparation("threshold", previousSummary);
572
697
  const branchEntries: any[] = [
573
- { id: "exec-start", type: "custom", customType: "pi-plans-exec-start" },
574
698
  { id: "u-1", type: "message", message: { role: "user", content: [{ type: "text", text: "implement VC-001" }] } },
575
- { id: "a-1", type: "message", message: { role: "assistant", content: [{ type: "text", text: "wrote helper [DONE:VC-001]" }] } },
699
+ { id: "a-1", type: "message", message: { role: "assistant", content: [{ type: "text", text: "wrote helper [I-002:current]" }] } },
576
700
  { id: "u-2", type: "message", message: { role: "user", content: [{ type: "text", text: "implement VC-002" }] } },
577
701
  { id: "a-2", type: "message", message: { role: "assistant", content: [{ type: "text", text: "almost done" }] } },
578
- { id: "exec-ctx", type: "custom", customType: "pi-plans-exec-context" },
579
702
  ];
580
703
  const result = buildExecutionCompactionResult(
581
704
  {
582
705
  type: "session_before_compact",
583
706
  preparation,
584
707
  branchEntries,
585
- customInstructions: "keep current task visible",
708
+ customInstructions: "keep:1",
586
709
  reason: "threshold",
587
710
  willRetry: false,
588
711
  signal: new AbortController().signal,
@@ -590,71 +713,51 @@ describe("execution loop", () => {
590
713
  ctx,
591
714
  );
592
715
  assert.ok(result);
593
- assert.match(result!.summary, /## Compact Instructions/);
594
- assert.match(result!.summary, /keep current task visible/);
595
- assert.match(result!.summary, /## Plan Before This Run/);
596
- assert.match(result!.summary, /## Previous Compact Summary/);
597
- assert.match(result!.summary, /## Finished VC Items/);
598
- assert.match(result!.summary, /### `VC-001`/);
599
- assert.match(result!.summary, /## Current Work/);
600
- assert.match(result!.summary, /Raw tail preserved from `u-2`/);
601
- assert.deepEqual(result!.firstKeptEntryId, "u-2");
602
-
603
- setUsagePercent(110);
604
- handleExecutionBeforeCompact(pi, ctx, {
605
- type: "session_before_compact",
606
- preparation,
607
- branchEntries,
608
- reason: "threshold",
609
- willRetry: false,
610
- signal: new AbortController().signal,
611
- });
716
+ assert.equal(result!.firstKeptEntryId, "u-2");
717
+ assert.match(result!.summary, /\[Session Goal\]/);
718
+ assert.match(result!.summary, /Execute accepted plan/);
719
+ assert.match(result!.summary, /\[Outstanding Context\]/);
720
+ assert.match(result!.summary, /Current implementation item: I-002/);
721
+ assert.match(result!.summary, /Remaining verifier items: VC-001, VC-002/);
722
+ assert.match(result!.summary, /Previous compact summary: Legacy Summary Deliver auto-compact/);
723
+ assert.equal((result!.details as any).compactor, "pi-vcc");
724
+ assert.equal((result!.details as any).phase, "execution");
612
725
 
613
726
  await stopExecution(pi, ctx, "test-done");
614
727
  });
615
728
 
616
- it("uses the current model for bounded valid summaries and falls back on invalid output", async () => {
729
+ it("does not call model helpers and respects overrideDefaultCompaction=false", async () => {
617
730
  const workdir = freshWorkdir();
618
- const { pi, ctx, recorded } = makeHarness(workdir);
731
+ startActiveRun(workdir);
732
+ const { pi, ctx } = makeHarness(workdir);
619
733
  await startExecution(pi, ctx, path.join(workdir, "PLAN_v21.md"), items("VC-001"), [
620
734
  { id: "I-001", text: "First item." },
621
735
  ]);
622
- const signal = new AbortController().signal;
623
- const calls: unknown[][] = [];
624
- let response: any = {
625
- stopReason: "stop",
626
- content: [{ type: "text", text: "## Implementation Items\n- I-001\n\n## Current I\n- I-001\n\n## Read Records\n- none\n\n## Compaction Boundary\n- a-2\n\n## Decisions\n- preserved\n\n## Open Questions\n- none\n\n## Next Steps\n- continue" }],
627
- usage: { input: 12, output: 34 },
628
- };
629
- (ctx.modelRegistry as any).complete = async (...args: unknown[]) => {
630
- calls.push(args);
631
- return response;
736
+ let completeCalled = false;
737
+ (ctx.modelRegistry as any).complete = async () => {
738
+ completeCalled = true;
739
+ return {};
632
740
  };
633
741
  const event: any = {
634
742
  type: "session_before_compact",
635
743
  preparation: makePreparation("threshold", null),
636
- branchEntries: [
637
- { id: "i-1", type: "message", tokens: 100, message: { role: "assistant", content: [{ type: "text", text: "[I-001:current] work" }] } },
638
- { id: "a-1", type: "message", tokens: 100, message: { role: "assistant", content: [{ type: "text", text: "details" }] } },
639
- ],
744
+ branchEntries: compactableBranchEntries(),
640
745
  reason: "threshold",
641
746
  willRetry: false,
642
- signal,
747
+ signal: new AbortController().signal,
643
748
  };
644
- const valid = await handleExecutionBeforeCompact(pi, ctx, event);
749
+ const valid = handleExecutionBeforeCompact(pi, ctx, event);
645
750
  assert.ok(valid?.compaction);
646
- assert.equal(calls[0]?.[0], recorded.current);
647
- assert.equal((calls[0]?.[2] as any).signal, signal);
648
- assert.equal((calls[0]?.[2] as any).cacheRetention, "none");
649
- assert.ok((calls[0]?.[2] as any).maxTokens <= 2048);
650
- assert.deepEqual(valid?.compaction?.usage, response.usage);
651
- assert.equal((valid?.compaction?.details as any)?.metrics?.summaryTokens, Math.ceil(response.content[0].text.length / 4));
652
-
653
- response = { stopReason: "length", content: [{ type: "text", text: "## Implementation Items\npartial" }] };
654
- const invalid = await handleExecutionBeforeCompact(pi, ctx, { ...event, signal: new AbortController().signal });
655
- assert.equal(invalid, undefined, "incomplete model output must return control to Pi default compaction");
751
+ assert.equal(completeCalled, false);
752
+
753
+ const configPath = path.join(workdir, ".git", "pi_plans", "pi-vcc-config.json");
754
+ fs.writeFileSync(configPath, JSON.stringify({ overrideDefaultCompaction: false }), "utf8");
755
+ const fallback = handleExecutionBeforeCompact(pi, ctx, { ...event, signal: new AbortController().signal });
756
+ assert.equal(fallback, undefined, "override-disabled should return control to Pi default compaction");
757
+
656
758
  await stopExecution(pi, ctx, "model-test");
657
- }); it("filters the hidden resume message out of the LLM context payload", () => {
759
+ });
760
+ it("filters the hidden resume message out of the model context payload", () => {
658
761
  const messages = [
659
762
  { customType: "user", content: "real prompt" },
660
763
  { customType: "pi-plans-exec-resume", content: "Continue execution." },
@@ -665,37 +768,27 @@ describe("execution loop", () => {
665
768
  assert.equal(filterPlanningResumeMessages(messages).length, 3);
666
769
  });
667
770
 
668
- it("planning compaction cuts at plan-written when present and falls back to run-start", () => {
771
+ it("builds a VCC planning summary from active-run session context", () => {
669
772
  const workdir = freshWorkdir();
670
773
  const { ctx } = makeHarness(workdir);
671
774
  initState(workdir);
672
775
  const { run } = startRun(workdir, { topic: "planning compact", skill: "plan-normal", requestText: "demo" });
673
- fs.writeFileSync(path.join(run.artifact_dir, "PLAN_v1.md"), "# plan");
776
+ const planPath = path.join(run.artifact_dir, "PLAN_v1.md");
777
+ fs.writeFileSync(planPath, "# plan");
674
778
 
675
- const makePlanningPreparation = (previousSummary: string | null) => ({
676
- firstKeptEntryId: "fallback",
677
- messagesToSummarize: [],
678
- turnPrefixMessages: [],
679
- isSplitTurn: false,
680
- tokensBefore: 50000,
681
- previousSummary,
682
- fileOps: { read: [], written: [], edited: [] },
683
- settings: { enabled: true, reserveTokens: 16384, keepRecentTokens: 20000 },
684
- });
685
-
686
- // Case A: plan written → cut at next non-internal entry after plan-written; QA section included.
687
- const branchEntriesWithPlan = [
779
+ const branchEntries = [
688
780
  { id: "rs", type: "custom", customType: PLANNING_RUN_START_CUSTOM_TYPE, data: { runId: run.run_id, artifactDir: run.artifact_dir } },
689
781
  { id: "u-1", type: "message", message: { role: "user", content: [{ type: "text", text: "background question?" }] } },
690
- { id: "a-1", type: "message", message: { role: "assistant", content: [{ type: "text", text: "some context" }] } },
691
- { id: "pw", type: "custom", customType: PLANNING_PLAN_WRITTEN_CUSTOM_TYPE, data: { runId: run.run_id, planPath: path.join(run.artifact_dir, "PLAN_v1.md") } },
782
+ { id: "a-1", type: "message", message: { role: "assistant", content: [{ type: "text", text: "some context [I-004:current]" }] } },
783
+ { id: "pw", type: "custom", customType: PLANNING_PLAN_WRITTEN_CUSTOM_TYPE, data: { runId: run.run_id, planPath } },
692
784
  { id: "u-2", type: "message", message: { role: "user", content: [{ type: "text", text: "review please" }] } },
693
785
  ] as any;
694
786
  const withPlan = buildPlanningCompactionResult(
695
787
  {
696
788
  type: "session_before_compact",
697
- preparation: makePlanningPreparation(null),
698
- branchEntries: branchEntriesWithPlan,
789
+ preparation: makePreparation("threshold", "## Previous\nEarlier summary."),
790
+ branchEntries,
791
+ customInstructions: "keep:1",
699
792
  reason: "threshold",
700
793
  willRetry: false,
701
794
  signal: new AbortController().signal,
@@ -704,95 +797,57 @@ describe("execution loop", () => {
704
797
  );
705
798
  assert.ok(withPlan);
706
799
  assert.deepEqual(withPlan!.firstKeptEntryId, "u-2");
707
- assert.match(withPlan!.summary, /## Q&A During Planning/);
708
- assert.match(withPlan!.summary, /background question?/);
709
-
710
- // Case B: only run-start → cut at first non-internal entry after marker; no QA section.
711
- const branchEntriesWithoutPlan = [
712
- { id: "rs", type: "custom", customType: PLANNING_RUN_START_CUSTOM_TYPE, data: { runId: run.run_id, artifactDir: run.artifact_dir } },
713
- { id: "u-1", type: "message", message: { role: "user", content: [{ type: "text", text: "open question" }] } },
714
- { id: "a-1", type: "message", message: { role: "assistant", content: [{ type: "text", text: "thinking out loud" }] } },
715
- ] as any;
716
- const withoutPlan = buildPlanningCompactionResult(
717
- {
718
- type: "session_before_compact",
719
- preparation: makePlanningPreparation(null),
720
- branchEntries: branchEntriesWithoutPlan,
721
- reason: "manual",
722
- willRetry: false,
723
- signal: new AbortController().signal,
724
- },
725
- ctx,
726
- );
727
- assert.ok(withoutPlan);
728
- assert.deepEqual(withoutPlan!.firstKeptEntryId, "u-1");
729
- assert.doesNotMatch(withoutPlan!.summary, /## Q&A During Planning/);
730
-
731
- // Case C: no markers → fallback to preparation.firstKeptEntryId and no QA section.
732
- const fallback = buildPlanningCompactionResult(
733
- {
734
- type: "session_before_compact",
735
- preparation: makePlanningPreparation("## Previous\nEarlier summary."),
736
- branchEntries: [],
737
- reason: "threshold",
738
- willRetry: false,
739
- signal: new AbortController().signal,
740
- },
741
- ctx,
742
- );
743
- assert.ok(fallback);
744
- assert.deepEqual(fallback!.firstKeptEntryId, "fallback");
745
- assert.doesNotMatch(fallback!.summary, /## Q&A During Planning/);
746
- assert.match(fallback!.summary, /## Previous Compact Summary/);
800
+ assert.match(withPlan!.summary, /\[Session Goal\]/);
801
+ assert.match(withPlan!.summary, new RegExp(run.run_id));
802
+ assert.match(withPlan!.summary, /\[Outstanding Context\]/);
803
+ assert.match(withPlan!.summary, /Latest plan path from session/);
804
+ assert.match(withPlan!.summary, /Planning artifact directory from session/);
805
+ assert.match(withPlan!.summary, /Current implementation marker observed during planning: I-004/);
806
+ assert.match(withPlan!.summary, /Previous compact summary: Previous Earlier summary\./);
807
+ assert.equal((withPlan!.details as any).compactor, "pi-vcc");
808
+ assert.equal((withPlan!.details as any).phase, "planning");
747
809
  });
748
810
 
749
- it("planning hook is gated by run.status=planning and defers to execution hook when execution is running", async () => {
811
+ it("planning hook is gated by run.status=planning and defers while execution is running", async () => {
750
812
  const workdir = freshWorkdir();
751
- const { ctx, setUsagePercent } = makeHarness(workdir);
813
+ const { pi, ctx } = makeHarness(workdir);
752
814
  initState(workdir);
753
815
  const { run } = startRun(workdir, { topic: "planning gate", skill: "plan-small", requestText: "x" });
754
816
 
755
- setUsagePercent(110);
756
- const resultPlanning = handlePlanningBeforeCompact({} as any, ctx as any, {
817
+ const resultPlanning = handlePlanningBeforeCompact(pi as any, ctx as any, {
757
818
  type: "session_before_compact",
758
- preparation: {
759
- firstKeptEntryId: "fb",
760
- messagesToSummarize: [],
761
- turnPrefixMessages: [],
762
- isSplitTurn: false,
763
- tokensBefore: 1,
764
- previousSummary: null,
765
- fileOps: { read: [], written: [], edited: [] },
766
- settings: { enabled: true, reserveTokens: 16384, keepRecentTokens: 20000 },
767
- },
768
- branchEntries: [],
819
+ preparation: makePreparation("threshold", null),
820
+ branchEntries: compactableBranchEntries(),
821
+ customInstructions: "keep:1",
769
822
  reason: "threshold",
770
823
  willRetry: false,
771
824
  signal: new AbortController().signal,
772
825
  });
773
- assert.ok(resultPlanning?.compaction || resultPlanning === undefined);
826
+ assert.ok(resultPlanning?.compaction);
774
827
 
775
- // Flip status to done; planning hook should refuse.
776
828
  setRunStatus(workdir, run.run_id, "done");
777
- setUsagePercent(110);
778
- const resultDone = handlePlanningBeforeCompact({} as any, ctx as any, {
829
+ const resultDone = handlePlanningBeforeCompact(pi as any, ctx as any, {
779
830
  type: "session_before_compact",
780
- preparation: {
781
- firstKeptEntryId: "fb",
782
- messagesToSummarize: [],
783
- turnPrefixMessages: [],
784
- isSplitTurn: false,
785
- tokensBefore: 1,
786
- previousSummary: null,
787
- fileOps: { read: [], written: [], edited: [] },
788
- settings: { enabled: true, reserveTokens: 16384, keepRecentTokens: 20000 },
789
- },
790
- branchEntries: [],
831
+ preparation: makePreparation("threshold", null),
832
+ branchEntries: compactableBranchEntries(),
791
833
  reason: "threshold",
792
834
  willRetry: false,
793
835
  signal: new AbortController().signal,
794
836
  });
795
837
  assert.equal(resultDone, undefined);
838
+
839
+ setRunStatus(workdir, run.run_id, "planning");
840
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v14.md"), items("VC-001"));
841
+ const duringExecution = handlePlanningBeforeCompact(pi as any, ctx as any, {
842
+ type: "session_before_compact",
843
+ preparation: makePreparation("threshold", null),
844
+ branchEntries: compactableBranchEntries(),
845
+ reason: "threshold",
846
+ willRetry: false,
847
+ signal: new AbortController().signal,
848
+ });
849
+ assert.equal(duringExecution, undefined);
850
+ await stopExecution(pi, ctx, "planning-gate");
796
851
  });
797
852
 
798
853
  it("turn_end writes are unconditionally deferred even when isIdle reads true", async () => {
@@ -970,7 +1025,7 @@ describe("execution loop", () => {
970
1025
  await startExecution(pi, ctx, path.join(workdir, "PLAN_v18.md"), items("VC-001"), [
971
1026
  { id: "I-001", text: "First item." },
972
1027
  ]);
973
- const rules = executionContextMessage()!;
1028
+ const rules = executionContextMessage(ctx)!;
974
1029
  assert.match(rules, /\[I-001:implemented\]/);
975
1030
  assert.match(rules, /\[I-001:validating\]/);
976
1031
  assert.match(rules, /subprocess-backed verification/);
@@ -981,42 +1036,411 @@ describe("execution loop", () => {
981
1036
  await stopExecution(pi, ctx, "done");
982
1037
  });
983
1038
 
984
- it("planning compaction honors cooldown + resume guard and survives manual /compact", () => {
1039
+ it("does not proactively request planning compaction", () => {
985
1040
  const workdir = freshWorkdir();
986
- const { pi, ctx, setUsagePercent, recorded } = makeHarness(workdir);
1041
+ const { ctx, setUsagePercent, recorded } = makeHarness(workdir);
987
1042
  initState(workdir);
988
- startRun(workdir, { topic: "planning cooldown", skill: "plan-normal", requestText: "x" });
1043
+ startRun(workdir, { topic: "planning no-op", skill: "plan-normal", requestText: "x" });
989
1044
 
990
1045
  setUsagePercent(120);
991
- assert.equal(shouldTriggerPlanningCompaction(ctx as any), true);
992
- requestPlanningCompaction(ctx as any);
993
- // In flight, second trigger ignored.
994
- requestPlanningCompaction(ctx as any);
995
1046
  assert.equal(shouldTriggerPlanningCompaction(ctx as any), false);
1047
+ requestPlanningCompaction(ctx as any);
1048
+ refreshPlanningCompactionCooldown(ctx as any);
1049
+ assert.equal(recorded.compacts?.length ?? 0, 0, "planning scheduling is owned by Pi core");
1050
+ });
996
1051
 
997
- setUsagePercent(50);
998
- handlePlanningCompact(pi as any, ctx as any, {
1052
+ it("auto-continues planning only for threshold/overflow and honors manual follow-up prompts", async () => {
1053
+ const workdir = freshWorkdir();
1054
+ const { pi, ctx, recorded } = makeHarness(workdir);
1055
+ (ctx as any).piVersion = "0.84.3";
1056
+ initState(workdir);
1057
+ startRun(workdir, { topic: "planning success", skill: "plan-normal", requestText: "x" });
1058
+
1059
+ const threshold = handlePlanningBeforeCompact(pi as any, ctx as any, {
1060
+ type: "session_before_compact",
1061
+ preparation: makePreparation("threshold", null),
1062
+ branchEntries: compactableBranchEntries(),
1063
+ customInstructions: "keep:1",
1064
+ reason: "threshold",
1065
+ willRetry: false,
1066
+ signal: new AbortController().signal,
1067
+ });
1068
+ assert.ok(threshold?.compaction);
1069
+ await handlePlanningCompact(pi as any, ctx as any, {
999
1070
  type: "session_compact",
1000
1071
  compactionEntry: { type: "compaction" } as never,
1001
- fromExtension: true,
1002
- reason: "manual",
1072
+ fromExtension: false,
1073
+ reason: "threshold",
1003
1074
  willRetry: false,
1004
1075
  });
1005
- const resume = recorded.messages.find((message) => message.customType === "pi-plans-plan-resume");
1006
- assert.ok(resume);
1076
+ assert.equal(recorded.messages.filter((message) => message.customType === "pi-plans-plan-resume").length, 1);
1007
1077
  assert.equal(consumePlanningCompactionResumeGuard(ctx as any), true);
1008
- // Cooldown blocks retrigger while usage is still mid-band.
1009
- setUsagePercent(95);
1010
- assert.equal(shouldTriggerPlanningCompaction(ctx as any), false);
1011
- setUsagePercent(50);
1012
- refreshPlanningCompactionCooldown(ctx as any);
1078
+
1079
+ handlePlanningBeforeCompact(pi as any, ctx as any, {
1080
+ type: "session_before_compact",
1081
+ preparation: makePreparation("manual", null),
1082
+ branchEntries: compactableBranchEntries(),
1083
+ customInstructions: "Ask the next question keep:1",
1084
+ reason: "manual",
1085
+ willRetry: false,
1086
+ signal: new AbortController().signal,
1087
+ });
1088
+ await handlePlanningCompact(pi as any, ctx as any, {
1089
+ type: "session_compact",
1090
+ compactionEntry: { type: "compaction" } as never,
1091
+ fromExtension: false,
1092
+ reason: "manual",
1093
+ willRetry: false,
1094
+ });
1095
+ assert.deepEqual(recorded.userMessages, ["Ask the next question"]);
1096
+ assert.equal(recorded.messages.filter((message) => message.customType === "pi-plans-plan-resume").length, 1);
1097
+ });
1098
+
1099
+ it("planning request helper remains a no-op regardless of idleness", () => {
1100
+ const workdir = freshWorkdir();
1101
+ const { ctx, recorded, setUsagePercent } = makeHarness(workdir);
1102
+ initState(workdir);
1103
+ startRun(workdir, { topic: "idle no-op", skill: "plan-normal", requestText: "x" });
1013
1104
  setUsagePercent(120);
1014
- assert.equal(shouldTriggerPlanningCompaction(ctx as any), true);
1105
+
1106
+ (ctx as any).isIdle = () => false;
1107
+ requestPlanningCompaction(ctx as any);
1108
+ (ctx as any).isIdle = () => true;
1109
+ (ctx as any).hasPendingMessages = () => false;
1110
+ requestPlanningCompaction(ctx as any);
1111
+ assert.equal(recorded.compacts?.length ?? 0, 0);
1112
+ assert.equal((ctx as any).sessionManager.__planningCompaction, undefined);
1113
+ });
1114
+
1115
+ it("planning compact failures notify but do not re-request proactively", () => {
1116
+ const workdir = freshWorkdir();
1117
+ const { pi, ctx, recorded } = makeHarness(workdir);
1118
+ initState(workdir);
1119
+ startRun(workdir, { topic: "failure notify", skill: "plan-normal", requestText: "x" });
1120
+ handlePlanningBeforeCompact(pi as any, ctx as any, {
1121
+ type: "session_before_compact",
1122
+ preparation: makePreparation("threshold", null),
1123
+ branchEntries: compactableBranchEntries(),
1124
+ customInstructions: "keep:1",
1125
+ reason: "threshold",
1126
+ willRetry: false,
1127
+ signal: new AbortController().signal,
1128
+ });
1129
+
1130
+ handlePlanningCompactFailed(pi as any, ctx as any, {
1131
+ type: "session_compact_failed",
1132
+ reason: "manual",
1133
+ errorMessage: "Compaction failed: Nothing to compact (session too small)",
1134
+ aborted: false,
1135
+ willRetry: false,
1136
+ fromExtension: false,
1137
+ });
1138
+ assert.ok(recorded.notifies.some((note) => note.message.includes("nothing to summarize")));
1139
+ requestPlanningCompaction(ctx as any);
1140
+ assert.equal(recorded.compacts?.length ?? 0, 0);
1141
+
1142
+ handlePlanningBeforeCompact(pi as any, ctx as any, {
1143
+ type: "session_before_compact",
1144
+ preparation: makePreparation("threshold", null),
1145
+ branchEntries: compactableBranchEntries(),
1146
+ customInstructions: "keep:1",
1147
+ reason: "threshold",
1148
+ willRetry: false,
1149
+ signal: new AbortController().signal,
1150
+ });
1151
+ handlePlanningCompactFailed(pi as any, ctx as any, {
1152
+ type: "session_compact_failed",
1153
+ reason: "manual",
1154
+ errorMessage: "network down",
1155
+ aborted: false,
1156
+ willRetry: false,
1157
+ fromExtension: false,
1158
+ });
1159
+ assert.ok(recorded.notifies.some((note) => note.message.includes("will try again")));
1160
+ });
1161
+
1162
+ it("execution turn compaction helper remains a no-op and failures keep execution active", async () => {
1163
+ const workdir = freshWorkdir();
1164
+ startActiveRun(workdir);
1165
+ const { pi, ctx, recorded, setUsagePercent } = makeHarness(workdir);
1166
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v23.md"), items("VC-001"), [
1167
+ { id: "I-001", text: "First item." },
1168
+ ]);
1169
+
1170
+ setUsagePercent(96);
1171
+ handleExecutionTurnCompaction(ctx);
1172
+ assert.equal(recorded.compacts?.length ?? 0, 0, "execution scheduling is owned by Pi core");
1173
+
1174
+ handleExecutionBeforeCompact(pi, ctx, {
1175
+ type: "session_before_compact",
1176
+ preparation: makePreparation("threshold", null),
1177
+ branchEntries: compactableBranchEntries(),
1178
+ customInstructions: "keep:1",
1179
+ reason: "threshold",
1180
+ willRetry: false,
1181
+ signal: new AbortController().signal,
1182
+ });
1183
+ handleExecutionCompactFailed(pi, ctx, {
1184
+ type: "session_compact_failed",
1185
+ reason: "manual",
1186
+ errorMessage: "Compaction failed: Already compacted",
1187
+ aborted: false,
1188
+ willRetry: false,
1189
+ fromExtension: false,
1190
+ });
1191
+ assert.ok(getExecution());
1192
+ assert.ok(recorded.notifies.some((note) => note.message.includes("nothing to summarize")));
1193
+
1194
+ await stopExecution(pi, ctx, "test-done");
1195
+ });
1196
+
1197
+ it("treats planning abort/stream compact failures as terminal and notifies explicitly", () => {
1198
+ const workdir = freshWorkdir();
1199
+ const { pi, ctx, recorded } = makeHarness(workdir);
1200
+ initState(workdir);
1201
+ startRun(workdir, { topic: "abort-stream classification", skill: "plan-normal", requestText: "x" });
1202
+
1203
+ const abortMessages = [
1204
+ "Auto-compaction failed: Turn prefix summarization failed: This operation was aborted",
1205
+ "Error: OpenAI Responses stream ended before a terminal response event",
1206
+ "Auto-compaction failed: context overflow recovery failed",
1207
+ "this operation was aborted",
1208
+ "aborted",
1209
+ ];
1210
+ for (const errorMessage of abortMessages) {
1211
+ recorded.notifies.length = 0;
1212
+ handlePlanningBeforeCompact(pi as any, ctx as any, {
1213
+ type: "session_before_compact",
1214
+ preparation: makePreparation("threshold", null),
1215
+ branchEntries: compactableBranchEntries(),
1216
+ customInstructions: "keep:1",
1217
+ reason: "threshold",
1218
+ willRetry: false,
1219
+ signal: new AbortController().signal,
1220
+ });
1221
+ handlePlanningCompactFailed(pi as any, ctx as any, {
1222
+ type: "session_compact_failed",
1223
+ reason: "threshold",
1224
+ errorMessage,
1225
+ aborted: errorMessage.includes("aborted"),
1226
+ willRetry: false,
1227
+ fromExtension: false,
1228
+ });
1229
+ const backoffNote = recorded.notifies.find((n) => n.message.includes("was aborted"));
1230
+ assert.ok(backoffNote, `expected abort-class notify for: ${errorMessage}`);
1231
+ assert.match(backoffNote!.message, /provider interruption|competing manual/);
1232
+ const before = recorded.compacts?.length ?? 0;
1233
+ requestPlanningCompaction(ctx as any);
1234
+ assert.equal((recorded.compacts?.length ?? 0) - before, 0);
1235
+ }
1236
+ });
1237
+
1238
+ it("event.aborted=true with empty errorMessage still enters abort-class handling", () => {
1239
+ const workdir = freshWorkdir();
1240
+ const { pi, ctx, recorded } = makeHarness(workdir);
1241
+ initState(workdir);
1242
+ startRun(workdir, { topic: "aborted-no-message", skill: "plan-normal", requestText: "x" });
1243
+ handlePlanningBeforeCompact(pi as any, ctx as any, {
1244
+ type: "session_before_compact",
1245
+ preparation: makePreparation("threshold", null),
1246
+ branchEntries: compactableBranchEntries(),
1247
+ customInstructions: "keep:1",
1248
+ reason: "threshold",
1249
+ willRetry: false,
1250
+ signal: new AbortController().signal,
1251
+ });
1252
+
1253
+ handlePlanningCompactFailed(pi as any, ctx as any, {
1254
+ type: "session_compact_failed",
1255
+ reason: "threshold",
1256
+ errorMessage: undefined,
1257
+ aborted: true,
1258
+ willRetry: false,
1259
+ fromExtension: false,
1260
+ });
1261
+ const backoffNote = recorded.notifies.find((n) => n.message.includes("was aborted"));
1262
+ assert.ok(backoffNote);
1263
+ const before = recorded.compacts?.length ?? 0;
1264
+ requestPlanningCompaction(ctx as any);
1265
+ assert.equal((recorded.compacts?.length ?? 0) - before, 0);
1266
+ });
1267
+
1268
+ it("network-style planning failures stay retryable without proactive requests", () => {
1269
+ const workdir = freshWorkdir();
1270
+ const { pi, ctx, recorded } = makeHarness(workdir);
1271
+ initState(workdir);
1272
+ startRun(workdir, { topic: "network-retryable", skill: "plan-normal", requestText: "x" });
1273
+ handlePlanningBeforeCompact(pi as any, ctx as any, {
1274
+ type: "session_before_compact",
1275
+ preparation: makePreparation("threshold", null),
1276
+ branchEntries: compactableBranchEntries(),
1277
+ customInstructions: "keep:1",
1278
+ reason: "threshold",
1279
+ willRetry: false,
1280
+ signal: new AbortController().signal,
1281
+ });
1282
+
1283
+ handlePlanningCompactFailed(pi as any, ctx as any, {
1284
+ type: "session_compact_failed",
1285
+ reason: "threshold",
1286
+ errorMessage: "network down",
1287
+ aborted: false,
1288
+ willRetry: false,
1289
+ fromExtension: false,
1290
+ });
1291
+ const abortNote = recorded.notifies.find((n) => n.message.includes("was aborted"));
1292
+ assert.equal(abortNote, undefined, "network down must not be classified as abort-class");
1293
+ const retryNote = recorded.notifies.find((n) => n.message.includes("will try again"));
1294
+ assert.ok(retryNote, "network down must keep the retryable path");
1295
+ const before = recorded.compacts?.length ?? 0;
1296
+ requestPlanningCompaction(ctx as any);
1297
+ assert.equal((recorded.compacts?.length ?? 0) - before, 0);
1298
+ });
1299
+
1300
+ it("lifecycle flags distinguish unhinted and phase-attributed compactions", () => {
1301
+ const workdir = freshWorkdir();
1302
+ const { ctx } = makeHarness(workdir);
1303
+
1304
+ noteCompactionStarted(ctx as any, undefined);
1305
+ assert.equal(compactionInFlight(ctx as any, "planning"), true, "unhinted compaction marks planning inFlight");
1306
+ assert.equal(compactionInFlight(ctx as any, "execution"), true, "unhinted compaction marks execution inFlight");
1307
+ noteCompactionEnded(ctx as any, undefined);
1308
+ assert.equal(compactionInFlight(ctx as any, "planning"), false);
1309
+ assert.equal(compactionInFlight(ctx as any, "execution"), false);
1310
+
1311
+ noteCompactionStarted(ctx as any, "pi-plans planning auto compact");
1312
+ assert.equal(compactionInFlight(ctx as any, "planning"), true);
1313
+ assert.equal(compactionInFlight(ctx as any, "execution"), false);
1314
+ noteCompactionEnded(ctx as any, "pi-plans planning auto compact");
1315
+ assert.equal(compactionInFlight(ctx as any, "planning"), false);
1316
+
1317
+ noteCompactionStarted(ctx as any, "pi-plans execution auto compact");
1318
+ assert.equal(compactionInFlight(ctx as any, "planning"), false);
1319
+ assert.equal(compactionInFlight(ctx as any, "execution"), true);
1320
+ noteCompactionEnded(ctx as any, "pi-plans execution auto compact");
1321
+ assert.equal(compactionInFlight(ctx as any, "execution"), false);
1322
+ });
1323
+
1324
+ it("execution handler classifies abort/stream failures as terminal", async () => {
1325
+ const workdir = freshWorkdir();
1326
+ startActiveRun(workdir);
1327
+ const { pi, ctx, recorded } = makeHarness(workdir);
1328
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v4.md"), items("VC-001"));
1329
+
1330
+ const abortMessages = [
1331
+ "Auto-compaction failed: Turn prefix summarization failed: This operation was aborted",
1332
+ "Error: OpenAI Responses stream ended before a terminal response event",
1333
+ "Auto-compaction failed: context overflow recovery failed",
1334
+ "aborted",
1335
+ ];
1336
+ for (const errorMessage of abortMessages) {
1337
+ recorded.notifies.length = 0;
1338
+ handleExecutionBeforeCompact(pi, ctx, {
1339
+ type: "session_before_compact",
1340
+ preparation: makePreparation("threshold", null),
1341
+ branchEntries: compactableBranchEntries(),
1342
+ customInstructions: "keep:1",
1343
+ reason: "threshold",
1344
+ willRetry: false,
1345
+ signal: new AbortController().signal,
1346
+ });
1347
+ handleExecutionCompactFailed(pi, ctx, {
1348
+ type: "session_compact_failed",
1349
+ reason: "threshold",
1350
+ errorMessage,
1351
+ aborted: errorMessage.includes("aborted"),
1352
+ willRetry: false,
1353
+ fromExtension: false,
1354
+ });
1355
+ const note = recorded.notifies.find((n) => n.message.includes("was aborted"));
1356
+ assert.ok(note, `expected abort-class notify for: ${errorMessage}`);
1357
+ assert.match(note!.message, /provider interruption|competing manual/);
1358
+ }
1359
+ handleExecutionBeforeCompact(pi, ctx, {
1360
+ type: "session_before_compact",
1361
+ preparation: makePreparation("threshold", null),
1362
+ branchEntries: compactableBranchEntries(),
1363
+ customInstructions: "keep:1",
1364
+ reason: "threshold",
1365
+ willRetry: false,
1366
+ signal: new AbortController().signal,
1367
+ });
1368
+ handleExecutionCompactFailed(pi, ctx, {
1369
+ type: "session_compact_failed",
1370
+ reason: "threshold",
1371
+ errorMessage: undefined,
1372
+ aborted: true,
1373
+ willRetry: false,
1374
+ fromExtension: false,
1375
+ });
1376
+ const cleanNote = recorded.notifies.find((n) => n.message.includes("was aborted"));
1377
+ assert.ok(cleanNote, "aborted-without-message must enter abort-class handling on execution side too");
1378
+ await stopExecution(pi, ctx, "test-done");
1379
+ });
1380
+
1381
+ it("index.ts wires session_compact/session_compact_failed to clear inFlight flags (F-003/F-004 coverage)", () => {
1382
+ const indexSource = fs.readFileSync(path.join(process.cwd(), "index.ts"), "utf8");
1383
+ assert.match(indexSource, /await handleExecutionCompact\(pi, ctx, event\)/);
1384
+ assert.match(indexSource, /await handlePlanningCompact\(pi, ctx, event\)/);
1385
+
1386
+ const workdir = freshWorkdir();
1387
+ // Use a minimal harness that exposes sessionManager so we can inspect the
1388
+ // in-flight store after dispatching the registered event handlers.
1389
+ const ctx: any = {
1390
+ cwd: workdir,
1391
+ hasUI: false,
1392
+ sessionManager: {},
1393
+ };
1394
+ // Simulate the lifecycle that index.ts wires in production:
1395
+ noteCompactionStarted(ctx, undefined);
1396
+ assert.equal(compactionInFlight(ctx, "planning"), true);
1397
+ assert.equal(compactionInFlight(ctx, "execution"), true);
1398
+ noteCompactionEnded(ctx, undefined);
1399
+ assert.equal(compactionInFlight(ctx, "planning"), false);
1400
+ assert.equal(compactionInFlight(ctx, "execution"), false);
1401
+ // And a planning-attributed start + end still clears both (end has no hint).
1402
+ noteCompactionStarted(ctx, "pi-plans planning auto compact");
1403
+ assert.equal(compactionInFlight(ctx, "planning"), true);
1404
+ noteCompactionEnded(ctx, "pi-plans planning auto compact");
1405
+ assert.equal(compactionInFlight(ctx, "planning"), false);
1406
+ });
1407
+ });
1408
+
1409
+ describe("execution injection reads graph config live", () => {
1410
+ it("reflects graph_enabled flips on the next assembly and surfaces config failures", async (t) => {
1411
+ const workdir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-exec-graph-"));
1412
+ t.after(() => {
1413
+ try {
1414
+ fs.rmSync(workdir, { recursive: true, force: true });
1415
+ } catch {
1416
+ /* ignore */
1417
+ }
1418
+ });
1419
+ const { pi, ctx } = makeHarness(workdir);
1420
+ setGraphEnabled(workdir, true);
1421
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v1.md"), items("VC-001"));
1422
+
1423
+ const enabledRules = executionContextMessage(ctx)!;
1424
+ assert.match(enabledRules, /Code graph loop: indexed code files read as a function digest/);
1425
+ assert.doesNotMatch(enabledRules, /Code graph disabled/);
1426
+
1427
+ setGraphEnabled(workdir, false);
1428
+ assert.match(executionContextMessage(ctx)!, /Code graph disabled/);
1429
+
1430
+ fs.writeFileSync(path.join(workdir, ".git", "pi_plans", "config.json"), "{broken");
1431
+ const brokenRules = executionContextMessage(ctx)!;
1432
+ assert.match(brokenRules, /Code graph disabled/);
1433
+ assert.match(brokenRules, /config unreadable this turn/);
1434
+ });
1435
+
1436
+ it("keeps no graphEnabled snapshot in the execution state source", () => {
1437
+ const source = fs.readFileSync(path.join(process.cwd(), "src", "exec.ts"), "utf8");
1438
+ assert.doesNotMatch(source, /graphEnabled/);
1439
+ assert.match(source, /resolveGraphMode/);
1015
1440
  });
1016
1441
  });
1017
1442
 
1018
- function makePreparation(reason: "manual" | "threshold" | "overflow", previousSummary: string | null): any {
1019
- return {
1443
+ function makePreparation(reason: "manual" | "threshold" | "overflow", previousSummary: string | null): any { return {
1020
1444
  firstKeptEntryId: "a-2",
1021
1445
  messagesToSummarize: [],
1022
1446
  turnPrefixMessages: [],