pi-plans 0.1.2 → 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 (76) hide show
  1. package/README.md +98 -19
  2. package/index.ts +147 -66
  3. package/package.json +7 -1
  4. package/references/pi-planning-workflow.md +21 -3
  5. package/references/state-and-config.md +35 -3
  6. package/scripts/validate.ts +4 -0
  7. package/src/autocomplete.ts +163 -0
  8. package/src/code-graph/commands.ts +437 -0
  9. package/src/code-graph/discovery.ts +118 -0
  10. package/src/code-graph/git.ts +108 -0
  11. package/src/code-graph/identity.ts +59 -0
  12. package/src/code-graph/indexer.ts +281 -0
  13. package/src/code-graph/materialize.ts +166 -0
  14. package/src/code-graph/mode.ts +28 -0
  15. package/src/code-graph/mutations.ts +160 -0
  16. package/src/code-graph/parser.ts +51 -0
  17. package/src/code-graph/parsers/javascript.ts +35 -0
  18. package/src/code-graph/parsers/python.ts +160 -0
  19. package/src/code-graph/parsers/tree-sitter.ts +316 -0
  20. package/src/code-graph/paths.ts +85 -0
  21. package/src/code-graph/prompts.ts +18 -0
  22. package/src/code-graph/resolver.ts +69 -0
  23. package/src/code-graph/runtime.ts +158 -0
  24. package/src/code-graph/schema.ts +135 -0
  25. package/src/code-graph/screening.ts +82 -0
  26. package/src/code-graph/store.ts +278 -0
  27. package/src/code-graph/summary.ts +435 -0
  28. package/src/code-graph/types.ts +163 -0
  29. package/src/compaction.ts +1256 -0
  30. package/src/config-command.ts +326 -0
  31. package/src/exec.ts +519 -625
  32. package/src/plan.ts +37 -0
  33. package/src/query-hook.ts +82 -0
  34. package/src/refine-prompts.ts +50 -0
  35. package/src/refine-ui-helpers.ts +142 -0
  36. package/src/refine-ui-state.ts +144 -0
  37. package/src/refine-ui.ts +430 -0
  38. package/src/state.ts +24 -29
  39. package/src/subagent.ts +299 -70
  40. package/tests/ask-choice.test.ts +263 -0
  41. package/tests/autocomplete.test.ts +147 -0
  42. package/tests/code-graph-apply.test.ts +185 -0
  43. package/tests/code-graph-commands.test.ts +211 -0
  44. package/tests/code-graph-db.test.ts +166 -0
  45. package/tests/code-graph-discovery.test.ts +38 -0
  46. package/tests/code-graph-git.test.ts +94 -0
  47. package/tests/code-graph-index.test.ts +175 -0
  48. package/tests/code-graph-loop.e2e.test.ts +159 -0
  49. package/tests/code-graph-mutations.test.ts +117 -0
  50. package/tests/code-graph-parser.test.ts +85 -0
  51. package/tests/code-graph-rollback.test.ts +100 -0
  52. package/tests/code-graph-summary-batching.test.ts +518 -0
  53. package/tests/code-graph-summary.test.ts +148 -0
  54. package/tests/compaction.test.ts +388 -0
  55. package/tests/config-command.test.ts +255 -0
  56. package/tests/exec.test.ts +751 -422
  57. package/tests/execute-plan.test.ts +65 -0
  58. package/tests/fixtures/code-graph/sample.js +36 -0
  59. package/tests/fixtures/code-graph/sample.py +20 -0
  60. package/tests/fixtures/code-graph/sample.ts +15 -0
  61. package/tests/graph-aware-file-tools.test.ts +411 -0
  62. package/tests/plan.test.ts +11 -1
  63. package/tests/plans.test.ts +6 -5
  64. package/tests/query-hook.test.ts +82 -0
  65. package/tests/refine-prompts.test.ts +67 -2
  66. package/tests/refine-ui.test.ts +392 -0
  67. package/tests/state.test.ts +12 -15
  68. package/tests/subagent.test.ts +120 -0
  69. package/tools/ask-choice.ts +180 -11
  70. package/tools/code-graph.ts +254 -0
  71. package/tools/execute-plan.ts +7 -39
  72. package/tools/graph-aware-file-tools.ts +392 -0
  73. package/tools/plans.ts +84 -18
  74. package/tools/refine.ts +180 -80
  75. package/src/execution-panel.ts +0 -633
  76. package/tests/execution-panel.test.ts +0 -234
@@ -8,12 +8,12 @@ import { after, before, describe, it } from "node:test";
8
8
  import {
9
9
  applyDoneMarkers,
10
10
  applyImplMarkers,
11
+ applyCurrentIMarker,
11
12
  buildExecutionCompactionResult,
12
13
  buildPlanningCompactionResult,
13
14
  completeExecution,
14
15
  consumePendingExecutionFlush,
15
16
  consumePlanningCompactionResumeGuard,
16
- consumePendingPanelSync,
17
17
  drainExecutionFlush,
18
18
  executionContextMessage,
19
19
  filterExecutionResumeMessages,
@@ -21,7 +21,11 @@ import {
21
21
  getExecution,
22
22
  handleExecutionBeforeCompact,
23
23
  handleExecutionCompact,
24
+ handleExecutionTurnCompaction,
24
25
  handleExecutionCompactFailed,
26
+ compactionInFlight,
27
+ noteCompactionStarted,
28
+ noteCompactionEnded,
25
29
  handlePlanningBeforeCompact,
26
30
  handlePlanningCompact,
27
31
  handlePlanningCompactFailed,
@@ -32,17 +36,14 @@ import {
32
36
  requestPlanningCompaction,
33
37
  restoreFromSession,
34
38
  shouldTriggerPlanningCompaction,
35
- ensureExecutionModelActive,
36
39
  startExecution,
37
40
  recordExecutionTurn,
38
41
  registerExecutionTurnHandlers,
39
42
  stopExecution,
40
- chooseExecutionModelSelection,
41
- toggleExecutionPanelView,
42
43
  updateStatusWidget,
43
44
  } from "../src/exec.ts";
44
45
  import type { CheckItem } from "../src/plan.ts";
45
- import { initState, setExecutionModel, setRunStatus, showConfig, startRun } from "../src/state.ts";
46
+ import { initState, setGraphEnabled, setRunStatus, startRun } from "../src/state.ts";
46
47
 
47
48
  interface Recorded {
48
49
  entries: { type: string; customType?: string; data?: unknown }[];
@@ -50,8 +51,6 @@ interface Recorded {
50
51
  status: string | undefined;
51
52
  statusCalls: number;
52
53
  colors: string[];
53
- widget?: { key: string; options?: unknown; factory: any };
54
- widgetCalls: number;
55
54
  models: { provider: string; id: string }[];
56
55
  thinkingLevels: (string | null)[];
57
56
  notifies: { message: string; severity: string }[];
@@ -59,6 +58,7 @@ interface Recorded {
59
58
  selectAnswer?: string;
60
59
  current: { provider: string; id: string } | null;
61
60
  thinking: string | null;
61
+ userMessages: string[];
62
62
  compacts?: { customInstructions?: string }[];
63
63
  }
64
64
 
@@ -76,13 +76,13 @@ function makeHarness(workdir: string): Harness {
76
76
  status: undefined,
77
77
  statusCalls: 0,
78
78
  colors: [],
79
- widgetCalls: 0,
80
79
  models: [],
81
80
  thinkingLevels: [],
82
81
  notifies: [],
83
82
  selects: [],
84
83
  current: { provider: "p", id: "m" },
85
84
  thinking: "high",
85
+ userMessages: [],
86
86
  };
87
87
  let contextPercent: number | null = 0;
88
88
  const registryModels = [
@@ -113,7 +113,9 @@ function makeHarness(workdir: string): Harness {
113
113
  sendMessage: (message: { customType: string; content: string }, options?: { triggerTurn?: boolean }) => {
114
114
  recorded.messages.push({ ...message, options });
115
115
  },
116
- sendUserMessage: async () => {},
116
+ sendUserMessage: async (content: string) => {
117
+ recorded.userMessages.push(content);
118
+ },
117
119
  setModel: async (model: { provider: string; id: string }) => {
118
120
  recorded.models.push({ provider: model.provider, id: model.id });
119
121
  recorded.current = { provider: model.provider, id: model.id };
@@ -129,14 +131,6 @@ function makeHarness(workdir: string): Harness {
129
131
  recorded.statusCalls += 1;
130
132
  recorded.status = value;
131
133
  },
132
- setWidget: (key: string, factory: any, options?: unknown) => {
133
- recorded.widgetCalls += 1;
134
- if (factory === undefined) {
135
- recorded.widget = undefined;
136
- return;
137
- }
138
- recorded.widget = { key, options, factory };
139
- },
140
134
  notify: (message: string, severity: string) => {
141
135
  recorded.notifies.push({ message, severity });
142
136
  },
@@ -190,6 +184,22 @@ function items(...ids: string[]): CheckItem[] {
190
184
  return ids.map((id) => ({ id, text: `\`${id}\` demo item`, done: false }));
191
185
  }
192
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
+
193
203
  describe("execution loop", () => {
194
204
  let tmpRoot: string;
195
205
  let counter = 0;
@@ -214,36 +224,20 @@ describe("execution loop", () => {
214
224
  const { pi, ctx, recorded } = makeHarness(workdir);
215
225
  await startExecution(pi, ctx, path.join(workdir, "PLAN_v1.md"), items("VC-001", "VC-002"));
216
226
 
217
- // Collapsed by default: progress lives in the bottom status bar (same
218
- // layer as the ⛔/⌛ indicators); no panel widget is registered yet.
219
- assert.equal(recorded.widget, undefined);
220
227
  assert.match(recorded.status ?? "", /⌛ plans 0\/2: spent \d{2}:\d{2}:\d{2}/);
221
228
  assert.match(recorded.status ?? "", /in-toks/);
222
229
  assert.match(recorded.status ?? "", /out-toks/);
223
230
 
224
- toggleExecutionPanelView(pi, ctx);
225
- assert.ok(recorded.widget);
226
- assert.equal(recorded.widget?.key, "pi-plans-execution");
227
- assert.deepEqual(recorded.widget?.options, { placement: "belowEditor" });
228
- // Expanded: footer ⌛ line is cleared — the panel renders its own header.
229
- assert.equal(recorded.status, undefined);
230
- const expandedWidget = recorded.widget?.factory(
231
- {} as any,
232
- { fg: (_color: string, text: string) => text, strikethrough: (text: string) => `~~${text}~~` },
233
- );
234
- assert.ok(expandedWidget);
235
- const expandedLines = expandedWidget.render(80);
236
- // Header line first, then the legacy item list.
237
- assert.match(expandedLines[0] ?? "", /⌛ plans 0\/2: spent/);
238
- assert.match(expandedLines.join("\n"), /☐/);
239
- // Detail view never repeats the keyboard hint.
240
- assert.doesNotMatch(expandedLines.join("\n"), /alt\+o/);
241
-
242
231
  assert.ok(getExecution());
243
- const rules = executionContextMessage()!;
232
+ const rules = executionContextMessage(ctx)!;
244
233
  assert.match(rules, /PI-PLANS EXECUTION/);
245
234
  assert.match(rules, /VC-001/);
246
- // Seven-principle rule set: representative anchors (PLAN_v2 D-003/D-004).
235
+ assert.match(rules, /subprocess-backed verification/);
236
+ assert.match(rules, /waiting for/);
237
+ assert.match(rules, /5s\s*->\s*10s\s*->\s*20s\s*->\s*40s\s*->\s*80s/);
238
+ assert.match(rules, /keep polling at 80s/);
239
+ assert.match(rules, /restart at 5s for each new subprocess/);
240
+ // Representative anchors for the core execution rules (PLAN_v2 D-003/D-004).
247
241
  assert.match(rules, /for the long term/);
248
242
  assert.match(rules, /Simplest implementation/);
249
243
  assert.match(rules, /grow the change in layers/);
@@ -263,24 +257,93 @@ describe("execution loop", () => {
263
257
  assert.ok(recorded.messages.some((message) => message.customType === "pi-plans-complete"));
264
258
  });
265
259
 
266
- it("restores progress from session entries and rescans messages", async () => {
260
+ it("completion enters goal-running continuation and triggers a turn in interactive sessions", async () => {
267
261
  const workdir = freshWorkdir();
268
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");
269
309
  const snapshot = {
270
- planPath: path.join(workdir, "PLAN_v1.md"),
310
+ planPath,
271
311
  items: items("VC-001", "VC-002"),
272
312
  startedAt: "2026-08-25T00:00:00Z",
273
- panel: {
274
- expanded: true,
275
- baseline: { added: 0, removed: 0, files: 0 },
276
- lastSnapshot: { added: 1, removed: 0, files: 1 },
277
- touchedPaths: ["src/exec.ts"],
278
- itemSummaries: {
279
- "VC-001": {
280
- summary: { added: 1, removed: 0, files: 1, paths: ["src/exec.ts"] },
281
- },
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",
282
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]" }] },
283
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
+
340
+ it("restores progress from session entries and rescans messages", async () => {
341
+ const workdir = freshWorkdir();
342
+ const { pi, ctx, recorded } = makeHarness(workdir);
343
+ const snapshot = {
344
+ planPath: path.join(workdir, "PLAN_v1.md"),
345
+ items: items("VC-001", "VC-002"),
346
+ startedAt: "2026-08-25T00:00:00Z",
284
347
  compaction: {
285
348
  inFlight: true,
286
349
  resumeGuard: true,
@@ -302,15 +365,6 @@ describe("execution loop", () => {
302
365
  const execution = getExecution();
303
366
  assert.ok(execution);
304
367
  assert.equal("compaction" in execution!, false, "legacy scheduler state must not reactivate on restore");
305
- assert.ok(recorded.widget);
306
- const widget = recorded.widget?.factory(
307
- {} as any,
308
- { fg: (_color: string, text: string) => text, strikethrough: (text: string) => `~~${text}~~` },
309
- );
310
- assert.ok(widget);
311
- const rendered = widget.render(80);
312
- assert.match(rendered.join("\n"), /☑/);
313
- assert.match(rendered.join("\n"), /\+1/);
314
368
 
315
369
  const clearedEntries = [...entries, { type: "custom", customType: "pi-plans-exec-cleared", data: {} }];
316
370
  await restoreFromSession(pi, ctx, clearedEntries as any);
@@ -343,99 +397,21 @@ describe("execution loop", () => {
343
397
  assert.equal(getExecution(), null);
344
398
  });
345
399
 
346
- it("switches to the execution model and restores the planning model", async () => {
400
+ it("starts execution without switching models", async () => {
347
401
  const workdir = freshWorkdir();
348
402
  const { pi, ctx, recorded } = makeHarness(workdir);
349
- await startExecution(pi, ctx, path.join(workdir, "PLAN_v6.md"), items("VC-001"), {
350
- planningSelector: "p/m:high",
351
- executionSelector: "prov/other:xhigh",
352
- });
403
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v6.md"), items("VC-001"));
353
404
 
354
- assert.deepEqual(recorded.models, [{ provider: "prov", id: "other" }]);
355
- assert.equal(recorded.thinking, "xhigh");
405
+ assert.deepEqual(recorded.models, []);
406
+ assert.equal(recorded.thinking, "high");
356
407
  assert.match(recorded.status ?? "", /⌛ plans 0\/1/);
357
408
 
358
409
  await stopExecution(pi, ctx, "restore-check");
359
- assert.equal(recorded.models.at(-1)?.provider, "p");
360
- assert.equal(recorded.thinking, "high");
361
- });
362
-
363
- it("defers execution model restore until the first agent turn and restores planning model on completion", async () => {
364
- const workdir = freshWorkdir();
365
- const { pi, ctx, recorded } = makeHarness(workdir);
366
- const snapshot = {
367
- planPath: path.join(workdir, "PLAN_v7.md"),
368
- items: items("VC-001"),
369
- startedAt: "2026-08-25T00:00:00Z",
370
- modelState: { planningSelector: "p/m:high", executionSelector: "prov/other:xhigh" },
371
- };
372
- fs.writeFileSync(snapshot.planPath, "# plan");
373
- const entries = [{ type: "custom", customType: "pi-plans-exec", data: snapshot }];
374
-
375
- await restoreFromSession(pi, ctx, entries as any);
376
- assert.ok(getExecution());
377
410
  assert.deepEqual(recorded.models, []);
378
-
379
- await ensureExecutionModelActive(pi, ctx);
380
- assert.deepEqual(recorded.models, [{ provider: "prov", id: "other" }]);
381
- assert.equal(recorded.thinking, "xhigh");
382
-
383
- const doneEntries = [
384
- ...entries,
385
- { type: "message", message: { role: "assistant", content: [{ type: "text", text: "did [DONE:VC-001]" }] } },
386
- ];
387
- await restoreFromSession(pi, ctx, doneEntries as any);
388
- assert.equal(getExecution(), null);
389
- assert.equal(recorded.models.at(-1)?.provider, "p");
390
411
  assert.equal(recorded.thinking, "high");
391
412
  });
392
413
 
393
- it("prompts for the execution model when unset and confirms inherit current session model", async () => {
394
- const workdir = freshWorkdir();
395
- const { ctx, recorded } = makeHarness(workdir);
396
- initState(workdir);
397
- recorded.selectAnswer = "Inherit current session model";
398
- const selection = await chooseExecutionModelSelection(ctx as any, "Execution model", {
399
- model_selector: null,
400
- source: "unset",
401
- updated_at: null,
402
- }, async (selector) => {
403
- setExecutionModel(workdir, { modelSelector: selector, source: "user" });
404
- });
405
-
406
- assert.equal(recorded.selects.length, 1);
407
- assert.equal(recorded.selects[0]?.options[0], "Inherit current session model");
408
- assert.equal(selection?.selector, "p/m:high");
409
- assert.equal(showConfig(workdir).execution.source, "user");
410
- assert.equal(showConfig(workdir).execution.model_selector, null);
411
- });
412
-
413
- it("does not prompt when the execution model is already confirmed as inherit", async () => {
414
- const workdir = freshWorkdir();
415
- const { ctx, recorded } = makeHarness(workdir);
416
- initState(workdir);
417
- setExecutionModel(workdir, { modelSelector: "inherit", source: "user" });
418
- const selection = await chooseExecutionModelSelection(ctx as any, "Execution model", showConfig(workdir).execution);
419
-
420
- assert.equal(recorded.selects.length, 0);
421
- assert.equal(selection?.selector, "p/m:high");
422
- });
423
-
424
- it("continues on the current model with a warning when the selector is unavailable", async () => {
425
- const workdir = freshWorkdir();
426
- const { ctx, recorded } = makeHarness(workdir);
427
- (ctx as any).hasUI = false;
428
- const selection = await chooseExecutionModelSelection(ctx as any, "Execution model", {
429
- model_selector: "prov/missing:xhigh",
430
- source: "user",
431
- updated_at: null,
432
- });
433
-
434
- assert.equal(selection?.provider, "p");
435
- assert.ok(recorded.notifies.some((n) => n.severity === "warning" && n.message.includes("prov/missing")));
436
- });
437
-
438
- it("keeps the status bar count-free while executing and points at the panel", async () => {
414
+ it("keeps the execution status bar current while executing", async () => {
439
415
  const workdir = freshWorkdir();
440
416
  const { pi, ctx, recorded } = makeHarness(workdir);
441
417
  await startExecution(pi, ctx, path.join(workdir, "PLAN_v2.md"), items("VC-001", "VC-002"));
@@ -519,62 +495,51 @@ describe("execution loop", () => {
519
495
  assert.equal(getExecution(), null);
520
496
  });
521
497
 
522
- it("defers persistence and widget churn when toggling mid-turn", async () => {
498
+ it("returns control to Pi core without active execution or planning state", async () => {
523
499
  const workdir = freshWorkdir();
524
- const { pi, ctx, recorded } = makeHarness(workdir);
525
- await startExecution(pi, ctx, path.join(workdir, "PLAN_v3.md"), items("VC-001", "VC-002"));
526
-
527
- const entriesBefore = recorded.entries.length;
528
- const factoriesBefore = recorded.widgetCalls;
529
-
530
- // Mid-turn: flip must be pure memory — no session writes, no re-register.
531
- (ctx as any).isIdle = () => false;
532
- assert.equal(toggleExecutionPanelView(pi, ctx), true);
533
- assert.equal(recorded.entries.length, entriesBefore, "busy toggle appended a session entry");
534
- assert.equal(recorded.widgetCalls, factoriesBefore, "busy toggle re-registered the widget");
535
- assert.equal(consumePendingPanelSync(), true, "expected a pending panel sync marker");
536
- assert.equal(consumePendingPanelSync(), false, "marker should be consumed exactly once");
537
-
538
- // Back to idle: the next toggle persists and syncs without touching the
539
- // widget slot because the panel is collapsed again.
540
- ctx.isIdle = () => true;
541
- assert.equal(toggleExecutionPanelView(pi, ctx), false);
542
- assert.ok(recorded.entries.length > entriesBefore, "idle toggle did not persist");
543
- assert.equal(recorded.widgetCalls, factoriesBefore, "collapsed toggle churned the widget registration");
544
-
545
- await stopExecution(pi, ctx, "test-done");
546
- });
500
+ const { pi, ctx } = makeHarness(workdir);
547
501
 
548
- it("lets Pi core own execution compaction scheduling and customizes every reason", async () => {
549
- const workdir = freshWorkdir();
550
- const { pi, ctx, setUsagePercent } = makeHarness(workdir);
551
- await startExecution(pi, ctx, path.join(workdir, "PLAN_v8.md"), items("VC-001", "VC-002"));
502
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v0.md"), items("VC-001"));
503
+ const executionResult = handleExecutionBeforeCompact(pi, ctx, {
504
+ type: "session_before_compact",
505
+ preparation: makePreparation("threshold", null),
506
+ branchEntries: compactableBranchEntries(),
507
+ reason: "threshold",
508
+ willRetry: false,
509
+ signal: new AbortController().signal,
510
+ });
511
+ assert.equal(executionResult, undefined);
512
+ await stopExecution(pi, ctx, "no-active-run");
552
513
 
553
- setUsagePercent(50);
554
- const below = handleExecutionBeforeCompact(pi, ctx, {
514
+ const planningResult = handlePlanningBeforeCompact(pi as any, ctx as any, {
555
515
  type: "session_before_compact",
556
516
  preparation: makePreparation("threshold", null),
557
- branchEntries: [],
517
+ branchEntries: compactableBranchEntries(),
558
518
  reason: "threshold",
559
519
  willRetry: false,
560
520
  signal: new AbortController().signal,
561
521
  });
562
- assert.equal(below?.cancel, undefined);
563
- assert.ok(below?.compaction, "threshold compaction should remain Pi-core-owned but use the custom summary");
522
+ assert.equal(planningResult, undefined);
523
+ });
564
524
 
565
- setUsagePercent(105);
566
- const above = handleExecutionBeforeCompact(pi, ctx, {
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"));
530
+
531
+ const threshold = handleExecutionBeforeCompact(pi, ctx, {
567
532
  type: "session_before_compact",
568
533
  preparation: makePreparation("threshold", null),
569
- branchEntries: [],
534
+ branchEntries: compactableBranchEntries(),
570
535
  reason: "threshold",
571
536
  willRetry: false,
572
537
  signal: new AbortController().signal,
573
538
  });
574
- assert.equal(above?.cancel, undefined);
575
- assert.ok(above?.compaction);
539
+ assert.equal(threshold?.cancel, undefined);
540
+ assert.ok(threshold?.compaction, "threshold compaction should use the VCC summary when a legal cut exists");
576
541
 
577
- const overflow = handleExecutionBeforeCompact(pi, ctx, {
542
+ const overflowRetry = handleExecutionBeforeCompact(pi, ctx, {
578
543
  type: "session_before_compact",
579
544
  preparation: makePreparation("overflow", null),
580
545
  branchEntries: [],
@@ -582,8 +547,7 @@ describe("execution loop", () => {
582
547
  willRetry: true,
583
548
  signal: new AbortController().signal,
584
549
  });
585
- assert.equal(overflow?.cancel, undefined);
586
- assert.ok(overflow?.compaction);
550
+ assert.equal(overflowRetry, undefined, "overflow retry falls back to Pi core when VCC has no safe cut");
587
551
 
588
552
  const manual = handleExecutionBeforeCompact(pi, ctx, {
589
553
  type: "session_before_compact",
@@ -593,53 +557,116 @@ describe("execution loop", () => {
593
557
  willRetry: false,
594
558
  signal: new AbortController().signal,
595
559
  });
596
- assert.equal(manual?.cancel, undefined);
597
- 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")));
562
+
563
+ await stopExecution(pi, ctx, "test-done");
564
+ });
565
+
566
+ it("does not proactively request execution compaction", async () => {
567
+ const workdir = freshWorkdir();
568
+ startActiveRun(workdir);
569
+ const { pi, ctx, recorded, setUsagePercent } = makeHarness(workdir);
570
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v9.md"), items("VC-001", "VC-002"));
571
+
572
+ setUsagePercent(99);
573
+ handleExecutionTurnCompaction(ctx);
574
+ handleExecutionTurnCompaction(ctx);
575
+ assert.equal(recorded.compacts?.length ?? 0, 0, "execution scheduling is owned by Pi core");
598
576
 
599
577
  await stopExecution(pi, ctx, "test-done");
600
578
  });
601
579
 
602
- it("does not install an execution threshold trigger in turn_end", () => {
580
+ it("ignores current-I growth for proactive execution compaction", async () => {
581
+ const workdir = freshWorkdir();
582
+ startActiveRun(workdir);
583
+ const { pi, ctx, recorded } = makeHarness(workdir);
584
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v22.md"), items("VC-001"), [
585
+ { id: "I-001", text: "First item." },
586
+ ]);
587
+ (ctx.sessionManager as any).getBranch = () => [
588
+ { id: "only", type: "message", tokens: 30000, message: { role: "assistant", content: [{ type: "text", text: "[I-001:current] one oversized turn" }] } },
589
+ ];
590
+ handleExecutionTurnCompaction(ctx);
591
+ assert.equal(recorded.compacts?.length ?? 0, 0);
592
+ await stopExecution(pi, ctx, "no-prefix");
593
+ });
594
+ it("wires execution compaction without restoring proactive requests or model helpers", () => {
603
595
  const indexSource = fs.readFileSync(path.join(process.cwd(), "index.ts"), "utf8");
604
596
  const execSource = fs.readFileSync(path.join(process.cwd(), "src/exec.ts"), "utf8");
605
- const turnEndStart = execSource.indexOf('pi.on("turn_end"');
606
- const nextSection = execSource.indexOf("type CompactBranchEntry", turnEndStart);
607
- assert.match(indexSource, /registerExecutionTurnHandlers\(pi/);
608
- assert.ok(turnEndStart >= 0);
609
- assert.ok(nextSection > turnEndStart);
610
- assert.doesNotMatch(execSource.slice(turnEndStart, nextSection), /requestExecutionCompaction|shouldTriggerExecutionCompaction|ctx\.compact/);
597
+ assert.match(indexSource, /handleExecutionTurnCompaction/);
598
+ assert.match(execSource, /shouldTriggerExecutionCompaction/);
599
+ assert.doesNotMatch(execSource, /ctx\.compact\(/);
600
+ assert.doesNotMatch(
601
+ indexSource,
602
+ /setExecutionModel|chooseExecutionModelSelection|snapshotCurrentModelSelector|ensureExecutionModelActive|restorePlanningModel/,
603
+ );
604
+ assert.doesNotMatch(
605
+ execSource,
606
+ /setExecutionModel|chooseExecutionModelSelection|snapshotCurrentModelSelector|ensureExecutionModelActive|restorePlanningModel|buildModelExecutionCompactionResult|modelRegistry\.complete/,
607
+ );
611
608
  });
612
609
 
613
- 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 () => {
614
611
  const workdir = freshWorkdir();
612
+ startActiveRun(workdir);
615
613
  const { pi, ctx, recorded } = makeHarness(workdir);
614
+ (ctx as any).piVersion = "0.84.3";
616
615
  await startExecution(pi, ctx, path.join(workdir, "PLAN_v9.md"), items("VC-001"));
617
616
 
618
- const beforeRequests = recorded.compacts?.length ?? 0;
619
- 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, {
620
627
  type: "session_compact",
621
628
  compactionEntry: { type: "compaction" } as never,
622
- fromExtension: true,
629
+ fromExtension: false,
623
630
  reason: "threshold",
624
631
  willRetry: false,
625
632
  });
626
- const resumes = recorded.messages.filter((message) => message.customType === "pi-plans-exec-resume");
627
- assert.equal(resumes.length, 1, "non-retry compaction should queue one hidden resume");
628
- assert.equal(resumes[0]?.options?.triggerTurn, true);
629
- 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")));
630
635
 
631
- 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, {
632
645
  type: "session_compact",
633
646
  compactionEntry: { type: "compaction" } as never,
634
- fromExtension: true,
635
- reason: "overflow",
636
- willRetry: true,
647
+ fromExtension: false,
648
+ reason: "manual",
649
+ willRetry: false,
637
650
  });
638
- assert.equal(
639
- recorded.messages.filter((message) => message.customType === "pi-plans-exec-resume").length,
640
- 1,
641
- "overflow retry is owned by Pi core",
642
- );
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"]);
643
670
 
644
671
  handleExecutionCompactFailed(pi, ctx, {
645
672
  type: "session_compact_failed",
@@ -655,29 +682,30 @@ describe("execution loop", () => {
655
682
  await stopExecution(pi, ctx, "test-done");
656
683
  });
657
684
 
658
- 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 () => {
659
686
  const workdir = freshWorkdir();
660
- const { pi, ctx, setUsagePercent } = makeHarness(workdir);
661
- await startExecution(pi, ctx, path.join(workdir, "PLAN_v10.md"), items("VC-001", "VC-002"));
662
-
663
- 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
+ ]);
664
693
 
665
- 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]");
666
696
  const preparation = makePreparation("threshold", previousSummary);
667
697
  const branchEntries: any[] = [
668
- { id: "exec-start", type: "custom", customType: "pi-plans-exec-start" },
669
698
  { id: "u-1", type: "message", message: { role: "user", content: [{ type: "text", text: "implement VC-001" }] } },
670
- { 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]" }] } },
671
700
  { id: "u-2", type: "message", message: { role: "user", content: [{ type: "text", text: "implement VC-002" }] } },
672
701
  { id: "a-2", type: "message", message: { role: "assistant", content: [{ type: "text", text: "almost done" }] } },
673
- { id: "exec-ctx", type: "custom", customType: "pi-plans-exec-context" },
674
702
  ];
675
703
  const result = buildExecutionCompactionResult(
676
704
  {
677
705
  type: "session_before_compact",
678
706
  preparation,
679
707
  branchEntries,
680
- customInstructions: "keep current task visible",
708
+ customInstructions: "keep:1",
681
709
  reason: "threshold",
682
710
  willRetry: false,
683
711
  signal: new AbortController().signal,
@@ -685,30 +713,51 @@ describe("execution loop", () => {
685
713
  ctx,
686
714
  );
687
715
  assert.ok(result);
688
- assert.match(result!.summary, /## Compact Instructions/);
689
- assert.match(result!.summary, /keep current task visible/);
690
- assert.match(result!.summary, /## Plan Before This Run/);
691
- assert.match(result!.summary, /## Previous Compact Summary/);
692
- assert.match(result!.summary, /## Finished VC Items/);
693
- assert.match(result!.summary, /### `VC-001`/);
694
- assert.match(result!.summary, /## Current Work/);
695
- assert.match(result!.summary, /Raw tail preserved from `u-2`/);
696
- assert.deepEqual(result!.firstKeptEntryId, "u-2");
697
-
698
- setUsagePercent(110);
699
- handleExecutionBeforeCompact(pi, ctx, {
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");
725
+
726
+ await stopExecution(pi, ctx, "test-done");
727
+ });
728
+
729
+ it("does not call model helpers and respects overrideDefaultCompaction=false", async () => {
730
+ const workdir = freshWorkdir();
731
+ startActiveRun(workdir);
732
+ const { pi, ctx } = makeHarness(workdir);
733
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v21.md"), items("VC-001"), [
734
+ { id: "I-001", text: "First item." },
735
+ ]);
736
+ let completeCalled = false;
737
+ (ctx.modelRegistry as any).complete = async () => {
738
+ completeCalled = true;
739
+ return {};
740
+ };
741
+ const event: any = {
700
742
  type: "session_before_compact",
701
- preparation,
702
- branchEntries,
743
+ preparation: makePreparation("threshold", null),
744
+ branchEntries: compactableBranchEntries(),
703
745
  reason: "threshold",
704
746
  willRetry: false,
705
747
  signal: new AbortController().signal,
706
- });
748
+ };
749
+ const valid = handleExecutionBeforeCompact(pi, ctx, event);
750
+ assert.ok(valid?.compaction);
751
+ assert.equal(completeCalled, false);
707
752
 
708
- await stopExecution(pi, ctx, "test-done");
709
- });
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");
710
757
 
711
- it("filters the hidden resume message out of the LLM context payload", () => {
758
+ await stopExecution(pi, ctx, "model-test");
759
+ });
760
+ it("filters the hidden resume message out of the model context payload", () => {
712
761
  const messages = [
713
762
  { customType: "user", content: "real prompt" },
714
763
  { customType: "pi-plans-exec-resume", content: "Continue execution." },
@@ -719,37 +768,27 @@ describe("execution loop", () => {
719
768
  assert.equal(filterPlanningResumeMessages(messages).length, 3);
720
769
  });
721
770
 
722
- 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", () => {
723
772
  const workdir = freshWorkdir();
724
773
  const { ctx } = makeHarness(workdir);
725
774
  initState(workdir);
726
775
  const { run } = startRun(workdir, { topic: "planning compact", skill: "plan-normal", requestText: "demo" });
727
- fs.writeFileSync(path.join(run.artifact_dir, "PLAN_v1.md"), "# plan");
728
-
729
- const makePlanningPreparation = (previousSummary: string | null) => ({
730
- firstKeptEntryId: "fallback",
731
- messagesToSummarize: [],
732
- turnPrefixMessages: [],
733
- isSplitTurn: false,
734
- tokensBefore: 50000,
735
- previousSummary,
736
- fileOps: { read: [], written: [], edited: [] },
737
- settings: { enabled: true, reserveTokens: 16384, keepRecentTokens: 20000 },
738
- });
776
+ const planPath = path.join(run.artifact_dir, "PLAN_v1.md");
777
+ fs.writeFileSync(planPath, "# plan");
739
778
 
740
- // Case A: plan written → cut at next non-internal entry after plan-written; QA section included.
741
- const branchEntriesWithPlan = [
779
+ const branchEntries = [
742
780
  { id: "rs", type: "custom", customType: PLANNING_RUN_START_CUSTOM_TYPE, data: { runId: run.run_id, artifactDir: run.artifact_dir } },
743
781
  { id: "u-1", type: "message", message: { role: "user", content: [{ type: "text", text: "background question?" }] } },
744
- { id: "a-1", type: "message", message: { role: "assistant", content: [{ type: "text", text: "some context" }] } },
745
- { 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 } },
746
784
  { id: "u-2", type: "message", message: { role: "user", content: [{ type: "text", text: "review please" }] } },
747
785
  ] as any;
748
786
  const withPlan = buildPlanningCompactionResult(
749
787
  {
750
788
  type: "session_before_compact",
751
- preparation: makePlanningPreparation(null),
752
- branchEntries: branchEntriesWithPlan,
789
+ preparation: makePreparation("threshold", "## Previous\nEarlier summary."),
790
+ branchEntries,
791
+ customInstructions: "keep:1",
753
792
  reason: "threshold",
754
793
  willRetry: false,
755
794
  signal: new AbortController().signal,
@@ -758,95 +797,57 @@ describe("execution loop", () => {
758
797
  );
759
798
  assert.ok(withPlan);
760
799
  assert.deepEqual(withPlan!.firstKeptEntryId, "u-2");
761
- assert.match(withPlan!.summary, /## Q&A During Planning/);
762
- assert.match(withPlan!.summary, /background question?/);
763
-
764
- // Case B: only run-start → cut at first non-internal entry after marker; no QA section.
765
- const branchEntriesWithoutPlan = [
766
- { id: "rs", type: "custom", customType: PLANNING_RUN_START_CUSTOM_TYPE, data: { runId: run.run_id, artifactDir: run.artifact_dir } },
767
- { id: "u-1", type: "message", message: { role: "user", content: [{ type: "text", text: "open question" }] } },
768
- { id: "a-1", type: "message", message: { role: "assistant", content: [{ type: "text", text: "thinking out loud" }] } },
769
- ] as any;
770
- const withoutPlan = buildPlanningCompactionResult(
771
- {
772
- type: "session_before_compact",
773
- preparation: makePlanningPreparation(null),
774
- branchEntries: branchEntriesWithoutPlan,
775
- reason: "manual",
776
- willRetry: false,
777
- signal: new AbortController().signal,
778
- },
779
- ctx,
780
- );
781
- assert.ok(withoutPlan);
782
- assert.deepEqual(withoutPlan!.firstKeptEntryId, "u-1");
783
- assert.doesNotMatch(withoutPlan!.summary, /## Q&A During Planning/);
784
-
785
- // Case C: no markers → fallback to preparation.firstKeptEntryId and no QA section.
786
- const fallback = buildPlanningCompactionResult(
787
- {
788
- type: "session_before_compact",
789
- preparation: makePlanningPreparation("## Previous\nEarlier summary."),
790
- branchEntries: [],
791
- reason: "threshold",
792
- willRetry: false,
793
- signal: new AbortController().signal,
794
- },
795
- ctx,
796
- );
797
- assert.ok(fallback);
798
- assert.deepEqual(fallback!.firstKeptEntryId, "fallback");
799
- assert.doesNotMatch(fallback!.summary, /## Q&A During Planning/);
800
- 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");
801
809
  });
802
810
 
803
- 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 () => {
804
812
  const workdir = freshWorkdir();
805
- const { ctx, setUsagePercent } = makeHarness(workdir);
813
+ const { pi, ctx } = makeHarness(workdir);
806
814
  initState(workdir);
807
815
  const { run } = startRun(workdir, { topic: "planning gate", skill: "plan-small", requestText: "x" });
808
816
 
809
- setUsagePercent(110);
810
- const resultPlanning = handlePlanningBeforeCompact({} as any, ctx as any, {
817
+ const resultPlanning = handlePlanningBeforeCompact(pi as any, ctx as any, {
811
818
  type: "session_before_compact",
812
- preparation: {
813
- firstKeptEntryId: "fb",
814
- messagesToSummarize: [],
815
- turnPrefixMessages: [],
816
- isSplitTurn: false,
817
- tokensBefore: 1,
818
- previousSummary: null,
819
- fileOps: { read: [], written: [], edited: [] },
820
- settings: { enabled: true, reserveTokens: 16384, keepRecentTokens: 20000 },
821
- },
822
- branchEntries: [],
819
+ preparation: makePreparation("threshold", null),
820
+ branchEntries: compactableBranchEntries(),
821
+ customInstructions: "keep:1",
823
822
  reason: "threshold",
824
823
  willRetry: false,
825
824
  signal: new AbortController().signal,
826
825
  });
827
- assert.ok(resultPlanning?.compaction || resultPlanning === undefined);
826
+ assert.ok(resultPlanning?.compaction);
828
827
 
829
- // Flip status to done; planning hook should refuse.
830
828
  setRunStatus(workdir, run.run_id, "done");
831
- setUsagePercent(110);
832
- const resultDone = handlePlanningBeforeCompact({} as any, ctx as any, {
829
+ const resultDone = handlePlanningBeforeCompact(pi as any, ctx as any, {
833
830
  type: "session_before_compact",
834
- preparation: {
835
- firstKeptEntryId: "fb",
836
- messagesToSummarize: [],
837
- turnPrefixMessages: [],
838
- isSplitTurn: false,
839
- tokensBefore: 1,
840
- previousSummary: null,
841
- fileOps: { read: [], written: [], edited: [] },
842
- settings: { enabled: true, reserveTokens: 16384, keepRecentTokens: 20000 },
843
- },
844
- branchEntries: [],
831
+ preparation: makePreparation("threshold", null),
832
+ branchEntries: compactableBranchEntries(),
845
833
  reason: "threshold",
846
834
  willRetry: false,
847
835
  signal: new AbortController().signal,
848
836
  });
849
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");
850
851
  });
851
852
 
852
853
  it("turn_end writes are unconditionally deferred even when isIdle reads true", async () => {
@@ -910,63 +911,10 @@ describe("execution loop", () => {
910
911
  assert.equal(lastComplete.items?.[0]?.done, true, "completion snapshot missing final done state");
911
912
  });
912
913
 
913
- it("plans-list groups by implementation item with status tags and strikethrough", async () => {
914
- const workdir = freshWorkdir();
915
- const { pi, ctx, recorded } = makeHarness(workdir);
916
- const implItems = [
917
- { id: "I-001", text: "Add state helpers; evaluate percent." },
918
- { id: "I-002", text: "Wire the turn_end trigger and status bar." },
919
- { id: "I-003", text: "Zero coverage orphan item." },
920
- ];
921
- const vcItems: CheckItem[] = [
922
- { id: "VC-001", text: "`VC-001` covers `I-001`; pass condition: tests", done: false },
923
- { id: "VC-002", text: "`VC-002` covers `I-001` and `I-002`; pass condition: lint", done: false },
924
- ];
925
- await startExecution(pi, ctx, path.join(workdir, "PLAN_v14.md"), vcItems, undefined, implItems);
926
-
927
- // Status bar counts by I, excluding the zero-coverage orphan.
928
- updateStatusWidget(ctx);
929
- assert.match(recorded.status ?? "", /⌛ plans 0\/2: spent/);
930
-
931
- toggleExecutionPanelView(pi, ctx);
932
- assert.ok(recorded.widget);
933
- // Expanded: footer ⌛ line cleared — the panel renders its own header.
934
- assert.equal(recorded.status, undefined);
935
- const widget = recorded.widget!.factory(
936
- {} as any,
937
- { fg: (_c: string, t: string) => t, strikethrough: (t: string) => `~~${t}~~` },
938
- );
939
- const lines = widget.render(120).join("\n");
940
- assert.match(lines, /⌛ plans 0\/2: spent/);
941
- assert.match(lines, /\[Implementing\] I-001: Add state helpers/);
942
- assert.match(lines, /\[Pending\] I-002: Wire the turn_end trigger/);
943
- assert.match(lines, /\[Pending\] I-003: Zero coverage orphan item/);
944
- // Only I status rows — no VC sub-rows in grouped mode.
945
- assert.doesNotMatch(lines, /☐/);
946
- assert.doesNotMatch(lines, /`VC-001` covers/);
947
-
948
- // Markers move the states; fan-out advances both covered I items.
949
- applyImplMarkers("[I-001:implemented]");
950
- applyDoneMarkers("[DONE:VC-001]");
951
- applyDoneMarkers("[DONE:VC-002]");
952
- widget.invalidate();
953
- const rendered = widget.render(120).join("\n");
954
- assert.match(rendered, /~~\[VC passed\] I-001: Add state helpers~~/);
955
- assert.match(rendered, /~~\[VC passed\] I-002: Wire the turn_end trigger and status bar\.~~/);
956
-
957
- // Status bar now counts both I items as passed.
958
- updateStatusWidget(ctx);
959
- assert.equal(recorded.status, undefined, "expanded panel keeps the footer ⌛ line cleared");
960
-
961
- await stopExecution(pi, ctx, "done");
962
- // Panel cleared and execution over: the ⌛ line must not linger.
963
- assert.doesNotMatch(recorded.status ?? "", /⌛ plans/);
964
- });
965
-
966
914
  it("updates the status bar in real time on every turn without session writes", async () => {
967
915
  const workdir = freshWorkdir();
968
916
  const { pi, ctx, recorded, setUsagePercent } = makeHarness(workdir);
969
- await startExecution(pi, ctx, path.join(workdir, "PLAN_v15.md"), items("VC-001", "VC-002"), undefined, [
917
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v15.md"), items("VC-001", "VC-002"), [
970
918
  { id: "I-001", text: "First item." },
971
919
  { id: "I-002", text: "Second item." },
972
920
  ]);
@@ -1003,19 +951,6 @@ describe("execution loop", () => {
1003
951
  });
1004
952
  assert.match(recorded.status ?? "", /⌛ plans 1\/3: spent/);
1005
953
  assert.match(recorded.status ?? "", /12 in-toks/);
1006
- assert.equal(recorded.widgetCalls, 0, "collapsed progress must not touch the widget slot");
1007
-
1008
- // Expanded progress is updated through the same event handler without
1009
- // re-registering the live widget.
1010
- toggleExecutionPanelView(pi, ctx);
1011
- const renderRequests = { count: 0 };
1012
- const widget = recorded.widget?.factory(
1013
- { requestRender: () => { renderRequests.count += 1; } },
1014
- { fg: (_color: string, text: string) => text, strikethrough: (text: string) => text },
1015
- );
1016
- assert.ok(widget);
1017
- assert.match(widget.render(100)[0] ?? "", /⌛ plans 1\/3: spent/);
1018
- const widgetCalls = recorded.widgetCalls;
1019
954
 
1020
955
  await emit("message_end", {
1021
956
  message: { role: "assistant", usage: { input: 8, output: 3 } },
@@ -1023,17 +958,15 @@ describe("execution loop", () => {
1023
958
  await emit("turn_end", {
1024
959
  message: { role: "assistant", content: [{ type: "text", text: "verified [DONE:VC-002]" }] },
1025
960
  });
1026
- assert.equal(recorded.status, undefined, "expanded execution keeps the footer indicator cleared");
1027
- assert.equal(recorded.widgetCalls, widgetCalls, "progress refresh must reuse the live widget");
1028
- assert.equal(renderRequests.count, 1, "expanded progress must request a TUI render");
1029
- assert.match(widget.render(100)[0] ?? "", /⌛ plans 2\/3: spent/);
961
+ assert.match(recorded.status ?? "", /⌛ plans 2\/3: spent/);
962
+ assert.match(recorded.status ?? "", /20 in-toks/);
1030
963
 
1031
964
  await stopExecution(pi, ctx, "event-chain-test");
1032
965
  });
1033
966
  it("applies impl markers with silent unknown ids and later-overwrite semantics", async () => {
1034
967
  const workdir = freshWorkdir();
1035
968
  const { pi, ctx } = makeHarness(workdir);
1036
- await startExecution(pi, ctx, path.join(workdir, "PLAN_v16.md"), items("VC-001"), undefined, [
969
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v16.md"), items("VC-001"), [
1037
970
  { id: "I-001", text: "First item." },
1038
971
  ]);
1039
972
 
@@ -1045,6 +978,27 @@ describe("execution loop", () => {
1045
978
  await stopExecution(pi, ctx, "done");
1046
979
  });
1047
980
 
981
+ it("applies current-I markers to the live progress bar and snapshot", async () => {
982
+ const workdir = freshWorkdir();
983
+ const { pi, ctx, recorded, emit } = makeHarness(workdir);
984
+ registerExecutionTurnHandlers(pi);
985
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v20.md"), items("VC-001", "VC-002", "VC-003"), [
986
+ { id: "I-001", text: "First item." },
987
+ { id: "I-002", text: "Second item." },
988
+ { id: "I-003", text: "Third item." },
989
+ ]);
990
+
991
+ await emit("turn_end", {
992
+ message: { role: "assistant", content: [{ type: "text", text: "starting [I-003:current]" }] },
993
+ });
994
+ assert.match(recorded.status ?? "", /⌛ plans 2\/3: spent/);
995
+ assert.equal(getExecution()?.currentI, "I-003");
996
+ drainExecutionFlush(pi, ctx);
997
+ const snapshot = recorded.entries.filter((entry) => entry.customType === "pi-plans-exec").at(-1)?.data as { currentI?: string };
998
+ assert.equal(snapshot.currentI, "I-003");
999
+ assert.equal(applyCurrentIMarker("[I-999:current]"), false);
1000
+ await stopExecution(pi, ctx, "current-I-test");
1001
+ });
1048
1002
  it("replays impl markers from post-snapshot messages on restore", async () => {
1049
1003
  const workdir = freshWorkdir();
1050
1004
  const { pi, ctx } = makeHarness(workdir);
@@ -1064,54 +1018,429 @@ describe("execution loop", () => {
1064
1018
  assert.equal(getExecution()?.implStatus?.["I-001"], "implemented");
1065
1019
  });
1066
1020
 
1021
+
1067
1022
  it("keeps the injection rules teaching the impl markers", async () => {
1068
1023
  const workdir = freshWorkdir();
1069
1024
  const { pi, ctx } = makeHarness(workdir);
1070
- await startExecution(pi, ctx, path.join(workdir, "PLAN_v18.md"), items("VC-001"), undefined, [
1025
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v18.md"), items("VC-001"), [
1071
1026
  { id: "I-001", text: "First item." },
1072
1027
  ]);
1073
- const rules = executionContextMessage()!;
1028
+ const rules = executionContextMessage(ctx)!;
1074
1029
  assert.match(rules, /\[I-001:implemented\]/);
1075
1030
  assert.match(rules, /\[I-001:validating\]/);
1031
+ assert.match(rules, /subprocess-backed verification/);
1032
+ assert.match(rules, /waiting for/);
1033
+ assert.match(rules, /5s\s*->\s*10s\s*->\s*20s\s*->\s*40s\s*->\s*80s/);
1034
+ assert.match(rules, /keep polling at 80s/);
1035
+ assert.match(rules, /restart at 5s for each new subprocess/);
1076
1036
  await stopExecution(pi, ctx, "done");
1077
1037
  });
1078
1038
 
1079
- it("planning compaction honors cooldown + resume guard and survives manual /compact", () => {
1039
+ it("does not proactively request planning compaction", () => {
1080
1040
  const workdir = freshWorkdir();
1081
- const { pi, ctx, setUsagePercent, recorded } = makeHarness(workdir);
1041
+ const { ctx, setUsagePercent, recorded } = makeHarness(workdir);
1082
1042
  initState(workdir);
1083
- startRun(workdir, { topic: "planning cooldown", skill: "plan-normal", requestText: "x" });
1043
+ startRun(workdir, { topic: "planning no-op", skill: "plan-normal", requestText: "x" });
1084
1044
 
1085
1045
  setUsagePercent(120);
1086
- assert.equal(shouldTriggerPlanningCompaction(ctx as any), true);
1087
- requestPlanningCompaction(ctx as any);
1088
- // In flight, second trigger ignored.
1089
- requestPlanningCompaction(ctx as any);
1090
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
+ });
1091
1051
 
1092
- setUsagePercent(50);
1093
- 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, {
1094
1070
  type: "session_compact",
1095
1071
  compactionEntry: { type: "compaction" } as never,
1096
- fromExtension: true,
1097
- reason: "manual",
1072
+ fromExtension: false,
1073
+ reason: "threshold",
1098
1074
  willRetry: false,
1099
1075
  });
1100
- const resume = recorded.messages.find((message) => message.customType === "pi-plans-plan-resume");
1101
- assert.ok(resume);
1076
+ assert.equal(recorded.messages.filter((message) => message.customType === "pi-plans-plan-resume").length, 1);
1102
1077
  assert.equal(consumePlanningCompactionResumeGuard(ctx as any), true);
1103
- // Cooldown blocks retrigger while usage is still mid-band.
1104
- setUsagePercent(95);
1105
- assert.equal(shouldTriggerPlanningCompaction(ctx as any), false);
1106
- setUsagePercent(50);
1107
- 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" });
1108
1104
  setUsagePercent(120);
1109
- 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/);
1110
1440
  });
1111
1441
  });
1112
1442
 
1113
- function makePreparation(reason: "manual" | "threshold" | "overflow", previousSummary: string | null): any {
1114
- return {
1443
+ function makePreparation(reason: "manual" | "threshold" | "overflow", previousSummary: string | null): any { return {
1115
1444
  firstKeptEntryId: "a-2",
1116
1445
  messagesToSummarize: [],
1117
1446
  turnPrefixMessages: [],