pi-plans 0.1.1 → 0.1.2

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.
@@ -7,48 +7,126 @@ import * as path from "node:path";
7
7
  import { after, before, describe, it } from "node:test";
8
8
  import {
9
9
  applyDoneMarkers,
10
+ applyImplMarkers,
11
+ buildExecutionCompactionResult,
12
+ buildPlanningCompactionResult,
10
13
  completeExecution,
14
+ consumePendingExecutionFlush,
15
+ consumePlanningCompactionResumeGuard,
11
16
  consumePendingPanelSync,
17
+ drainExecutionFlush,
12
18
  executionContextMessage,
19
+ filterExecutionResumeMessages,
20
+ filterPlanningResumeMessages,
13
21
  getExecution,
22
+ handleExecutionBeforeCompact,
23
+ handleExecutionCompact,
24
+ handleExecutionCompactFailed,
25
+ handlePlanningBeforeCompact,
26
+ handlePlanningCompact,
27
+ handlePlanningCompactFailed,
14
28
  isExecutionComplete,
29
+ PLANNING_PLAN_WRITTEN_CUSTOM_TYPE,
30
+ PLANNING_RUN_START_CUSTOM_TYPE,
31
+ refreshPlanningCompactionCooldown,
32
+ requestPlanningCompaction,
15
33
  restoreFromSession,
34
+ shouldTriggerPlanningCompaction,
35
+ ensureExecutionModelActive,
16
36
  startExecution,
17
- recordExecutionCompletion,
37
+ recordExecutionTurn,
38
+ registerExecutionTurnHandlers,
18
39
  stopExecution,
40
+ chooseExecutionModelSelection,
19
41
  toggleExecutionPanelView,
20
42
  updateStatusWidget,
21
43
  } from "../src/exec.ts";
22
44
  import type { CheckItem } from "../src/plan.ts";
23
- import { initState, setRunStatus, startRun } from "../src/state.ts";
45
+ import { initState, setExecutionModel, setRunStatus, showConfig, startRun } from "../src/state.ts";
24
46
 
25
47
  interface Recorded {
26
48
  entries: { type: string; customType?: string; data?: unknown }[];
27
- messages: { customType: string; content: string }[];
49
+ messages: { customType: string; content: string; options?: { triggerTurn?: boolean } }[];
28
50
  status: string | undefined;
51
+ statusCalls: number;
29
52
  colors: string[];
30
53
  widget?: { key: string; options?: unknown; factory: any };
31
54
  widgetCalls: number;
55
+ models: { provider: string; id: string }[];
56
+ thinkingLevels: (string | null)[];
57
+ notifies: { message: string; severity: string }[];
58
+ selects: { title: string; options: string[] }[];
59
+ selectAnswer?: string;
60
+ current: { provider: string; id: string } | null;
61
+ thinking: string | null;
62
+ compacts?: { customInstructions?: string }[];
32
63
  }
33
64
 
34
65
  interface Harness {
35
66
  pi: any;
36
67
  ctx: any;
37
68
  recorded: Recorded;
69
+ emit: (eventName: string, event: unknown) => Promise<unknown[]>;
38
70
  }
39
71
 
40
72
  function makeHarness(workdir: string): Harness {
41
- const recorded: Recorded = { entries: [], messages: [], status: undefined, colors: [], widgetCalls: 0 };
73
+ const recorded: Recorded = {
74
+ entries: [],
75
+ messages: [],
76
+ status: undefined,
77
+ statusCalls: 0,
78
+ colors: [],
79
+ widgetCalls: 0,
80
+ models: [],
81
+ thinkingLevels: [],
82
+ notifies: [],
83
+ selects: [],
84
+ current: { provider: "p", id: "m" },
85
+ thinking: "high",
86
+ };
87
+ let contextPercent: number | null = 0;
88
+ const registryModels = [
89
+ { provider: "p", id: "m" },
90
+ { provider: "prov", id: "other" },
91
+ ];
92
+ const handlers = new Map<string, Array<(event: unknown, ctx: any) => unknown>>();
93
+ const emit = async (eventName: string, event: unknown): Promise<unknown[]> => {
94
+ const results: unknown[] = [];
95
+ for (const handler of handlers.get(eventName) ?? []) {
96
+ results.push(await handler(event, ctx));
97
+ }
98
+ return results;
99
+ };
42
100
  const pi = {
101
+ on: (eventName: string, handler: (event: unknown, ctx: any) => unknown) => {
102
+ const registered = handlers.get(eventName) ?? [];
103
+ registered.push(handler);
104
+ handlers.set(eventName, registered);
105
+ },
106
+ registerTool: () => {},
107
+ registerCommand: () => {},
108
+ registerShortcut: () => {},
109
+ registerFlag: () => {},
43
110
  appendEntry: (customType: string, data: unknown) => {
44
111
  recorded.entries.push({ type: "custom", customType, data });
45
112
  },
46
- sendMessage: (message: { customType: string; content: string }) => {
47
- recorded.messages.push(message);
113
+ sendMessage: (message: { customType: string; content: string }, options?: { triggerTurn?: boolean }) => {
114
+ recorded.messages.push({ ...message, options });
115
+ },
116
+ sendUserMessage: async () => {},
117
+ setModel: async (model: { provider: string; id: string }) => {
118
+ recorded.models.push({ provider: model.provider, id: model.id });
119
+ recorded.current = { provider: model.provider, id: model.id };
120
+ return true;
121
+ },
122
+ setThinkingLevel: (level: string) => {
123
+ recorded.thinkingLevels.push(level);
124
+ recorded.thinking = level;
48
125
  },
49
126
  };
50
127
  const ui = {
51
128
  setStatus: (_key: string, value: string | undefined) => {
129
+ recorded.statusCalls += 1;
52
130
  recorded.status = value;
53
131
  },
54
132
  setWidget: (key: string, factory: any, options?: unknown) => {
@@ -59,6 +137,13 @@ function makeHarness(workdir: string): Harness {
59
137
  }
60
138
  recorded.widget = { key, options, factory };
61
139
  },
140
+ notify: (message: string, severity: string) => {
141
+ recorded.notifies.push({ message, severity });
142
+ },
143
+ select: async (title: string, options: string[]) => {
144
+ recorded.selects.push({ title, options });
145
+ return recorded.selectAnswer ?? options[0];
146
+ },
62
147
  theme: {
63
148
  fg: (color: string, text: string) => {
64
149
  recorded.colors.push(color);
@@ -67,12 +152,38 @@ function makeHarness(workdir: string): Harness {
67
152
  strikethrough: (text: string) => `~~${text}~~`,
68
153
  },
69
154
  };
155
+ const sessionManager: any = {};
70
156
  const ctx = {
71
157
  cwd: workdir,
72
158
  ui,
73
159
  isIdle: () => true,
160
+ hasUI: true,
161
+ scopedModels: [] as Array<{ model: { provider: string; id: string }; thinkingLevel?: string }>,
162
+ get model() {
163
+ return recorded.current;
164
+ },
165
+ get thinkingLevel() {
166
+ return recorded.thinking;
167
+ },
168
+ modelRegistry: {
169
+ find: (provider: string, modelId: string) =>
170
+ registryModels.find((entry) => entry.provider === provider && entry.id === modelId),
171
+ getAvailable: () => registryModels,
172
+ },
173
+ getContextUsage: () =>
174
+ contextPercent === null
175
+ ? undefined
176
+ : { tokens: contextPercent * 1000, contextWindow: 100000, percent: contextPercent },
177
+ compact: (options: { customInstructions?: string }) => {
178
+ recorded.compacts = recorded.compacts ?? [];
179
+ recorded.compacts.push(options);
180
+ },
181
+ sessionManager,
182
+ setUsagePercent: (percent: number | null) => {
183
+ contextPercent = percent;
184
+ },
74
185
  };
75
- return { pi, ctx, recorded };
186
+ return { pi, ctx, recorded, emit, setUsagePercent: ctx.setUsagePercent } as Harness & { setUsagePercent: (percent: number | null) => void };
76
187
  }
77
188
 
78
189
  function items(...ids: string[]): CheckItem[] {
@@ -98,10 +209,10 @@ describe("execution loop", () => {
98
209
  return workdir;
99
210
  }
100
211
 
101
- it("tracks done markers and completes", () => {
212
+ it("tracks done markers and completes", async () => {
102
213
  const workdir = freshWorkdir();
103
214
  const { pi, ctx, recorded } = makeHarness(workdir);
104
- startExecution(pi, ctx, path.join(workdir, "PLAN_v1.md"), items("VC-001", "VC-002"));
215
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v1.md"), items("VC-001", "VC-002"));
105
216
 
106
217
  // Collapsed by default: progress lives in the bottom status bar (same
107
218
  // layer as the ⛔/⌛ indicators); no panel widget is registered yet.
@@ -109,21 +220,23 @@ describe("execution loop", () => {
109
220
  assert.match(recorded.status ?? "", /⌛ plans 0\/2: spent \d{2}:\d{2}:\d{2}/);
110
221
  assert.match(recorded.status ?? "", /in-toks/);
111
222
  assert.match(recorded.status ?? "", /out-toks/);
112
- assert.match(recorded.status ?? "", /\/plans-list details/);
113
223
 
114
224
  toggleExecutionPanelView(pi, ctx);
115
225
  assert.ok(recorded.widget);
116
226
  assert.equal(recorded.widget?.key, "pi-plans-execution");
117
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);
118
230
  const expandedWidget = recorded.widget?.factory(
119
231
  {} as any,
120
232
  { fg: (_color: string, text: string) => text, strikethrough: (text: string) => `~~${text}~~` },
121
233
  );
122
234
  assert.ok(expandedWidget);
123
235
  const expandedLines = expandedWidget.render(80);
236
+ // Header line first, then the legacy item list.
237
+ assert.match(expandedLines[0] ?? "", /⌛ plans 0\/2: spent/);
124
238
  assert.match(expandedLines.join("\n"), /☐/);
125
- // Detail view never repeats the count or the keyboard hint.
126
- assert.doesNotMatch(expandedLines.join("\n"), /📋 plans/);
239
+ // Detail view never repeats the keyboard hint.
127
240
  assert.doesNotMatch(expandedLines.join("\n"), /alt\+o/);
128
241
 
129
242
  assert.ok(getExecution());
@@ -145,12 +258,12 @@ describe("execution loop", () => {
145
258
  assert.deepEqual(applyDoneMarkers("final: [DONE:VC-002]"), ["VC-002"]);
146
259
  assert.equal(isExecutionComplete(), true);
147
260
 
148
- completeExecution(pi, ctx);
261
+ await completeExecution(pi, ctx);
149
262
  assert.equal(getExecution(), null);
150
263
  assert.ok(recorded.messages.some((message) => message.customType === "pi-plans-complete"));
151
264
  });
152
265
 
153
- it("restores progress from session entries and rescans messages", () => {
266
+ it("restores progress from session entries and rescans messages", async () => {
154
267
  const workdir = freshWorkdir();
155
268
  const { pi, ctx, recorded } = makeHarness(workdir);
156
269
  const snapshot = {
@@ -166,7 +279,15 @@ describe("execution loop", () => {
166
279
  "VC-001": {
167
280
  summary: { added: 1, removed: 0, files: 1, paths: ["src/exec.ts"] },
168
281
  },
169
- },
282
+ },
283
+ },
284
+ compaction: {
285
+ inFlight: true,
286
+ resumeGuard: true,
287
+ cooldownActive: true,
288
+ lastAttemptReason: "threshold",
289
+ lastSuccessfulUsagePercent: 100,
290
+ lastSuccessfulAt: "2026-08-25T00:01:00Z",
170
291
  },
171
292
  };
172
293
  fs.writeFileSync(snapshot.planPath, "# plan");
@@ -177,9 +298,10 @@ describe("execution loop", () => {
177
298
  message: { role: "assistant", content: [{ type: "text", text: "did [DONE:VC-001]" }] },
178
299
  },
179
300
  ];
180
- restoreFromSession(pi, ctx, entries as any);
301
+ await restoreFromSession(pi, ctx, entries as any);
181
302
  const execution = getExecution();
182
303
  assert.ok(execution);
304
+ assert.equal("compaction" in execution!, false, "legacy scheduler state must not reactivate on restore");
183
305
  assert.ok(recorded.widget);
184
306
  const widget = recorded.widget?.factory(
185
307
  {} as any,
@@ -191,11 +313,11 @@ describe("execution loop", () => {
191
313
  assert.match(rendered.join("\n"), /\+1/);
192
314
 
193
315
  const clearedEntries = [...entries, { type: "custom", customType: "pi-plans-exec-cleared", data: {} }];
194
- restoreFromSession(pi, ctx, clearedEntries as any);
316
+ await restoreFromSession(pi, ctx, clearedEntries as any);
195
317
  assert.equal(getExecution(), null);
196
318
  });
197
319
 
198
- it("ignores restore when the plan file vanished", () => {
320
+ it("ignores restore when the plan file vanished", async () => {
199
321
  const workdir = freshWorkdir();
200
322
  const { pi, ctx } = makeHarness(workdir);
201
323
  const entries = [
@@ -209,28 +331,119 @@ describe("execution loop", () => {
209
331
  },
210
332
  },
211
333
  ];
212
- restoreFromSession(pi, ctx, entries as any);
334
+ await restoreFromSession(pi, ctx, entries as any);
213
335
  assert.equal(getExecution(), null);
214
336
  });
215
337
 
216
- it("stop clears execution", () => {
338
+ it("stop clears execution", async () => {
217
339
  const workdir = freshWorkdir();
218
340
  const { pi, ctx } = makeHarness(workdir);
219
- startExecution(pi, ctx, path.join(workdir, "PLAN_v1.md"), items("VC-001"));
220
- stopExecution(pi, ctx, "test");
341
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v1.md"), items("VC-001"));
342
+ await stopExecution(pi, ctx, "test");
221
343
  assert.equal(getExecution(), null);
222
344
  });
223
345
 
224
- it("keeps the status bar count-free while executing and points at the panel", () => {
346
+ it("switches to the execution model and restores the planning model", async () => {
347
+ const workdir = freshWorkdir();
348
+ 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
+ });
353
+
354
+ assert.deepEqual(recorded.models, [{ provider: "prov", id: "other" }]);
355
+ assert.equal(recorded.thinking, "xhigh");
356
+ assert.match(recorded.status ?? "", /⌛ plans 0\/1/);
357
+
358
+ 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
+ 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
+ assert.equal(recorded.thinking, "high");
391
+ });
392
+
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 () => {
225
439
  const workdir = freshWorkdir();
226
440
  const { pi, ctx, recorded } = makeHarness(workdir);
227
- startExecution(pi, ctx, path.join(workdir, "PLAN_v2.md"), items("VC-001", "VC-002"));
441
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v2.md"), items("VC-001", "VC-002"));
228
442
 
229
443
  // Bottom status bar carries the count — the same layer as ⛔/⌛ — so both
230
444
  // execution states read from one consistent place.
231
445
  assert.match(recorded.status ?? "", /⌛ plans 0\/2: spent \d{2}:\d{2}:\d{2}/);
232
446
  assert.match(recorded.status ?? "", /in-toks/);
233
- assert.match(recorded.status ?? "", /\/plans-list details/);
234
447
 
235
448
  const start = recorded.messages.find((message) => message.customType === "pi-plans-exec-start");
236
449
  assert.ok(start);
@@ -238,7 +451,7 @@ describe("execution loop", () => {
238
451
  assert.doesNotMatch(start.content, /footer/);
239
452
 
240
453
  applyDoneMarkers("[DONE:VC-001]");
241
- completeExecution(pi, ctx);
454
+ await completeExecution(pi, ctx);
242
455
  assert.equal(getExecution(), null);
243
456
 
244
457
  });
@@ -281,28 +494,35 @@ describe("execution loop", () => {
281
494
  assert.equal(recorded.colors.at(-1), "error");
282
495
  });
283
496
 
284
- it("accumulates token usage only for turns that finish items", () => {
497
+ it("accumulates token usage on token-only and completion turns", async () => {
285
498
  const workdir = freshWorkdir();
286
499
  const { pi, ctx, recorded } = makeHarness(workdir);
287
- startExecution(pi, ctx, path.join(workdir, "PLAN_v5.md"), items("VC-001", "VC-002"));
500
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v5.md"), items("VC-001", "VC-002"));
288
501
 
289
- recordExecutionCompletion(pi, ctx, ["VC-001"], { input: 100, output: 40 });
502
+ recordExecutionTurn(pi, ctx, [], { input: 100, output: 40 });
503
+ updateStatusWidget(ctx);
290
504
  assert.equal(getExecution()?.usage.inToks, 100);
291
505
  assert.equal(getExecution()?.usage.outToks, 40);
506
+ assert.match(recorded.status ?? "", /100 in-toks/);
507
+ assert.match(recorded.status ?? "", /40 out-toks/);
292
508
 
293
- // A completion without usage data must keep prior totals intact.
294
- recordExecutionCompletion(pi, ctx, ["VC-002"]);
295
- assert.equal(getExecution()?.usage.inToks, 100);
296
- assert.equal(getExecution()?.usage.outToks, 40);
509
+ assert.deepEqual(applyDoneMarkers("[DONE:VC-001]"), ["VC-001"]);
510
+ recordExecutionTurn(pi, ctx, ["VC-001"], { input: 20, output: 10 });
511
+ updateStatusWidget(ctx);
512
+ assert.equal(getExecution()?.usage.inToks, 120);
513
+ assert.equal(getExecution()?.usage.outToks, 50);
514
+ assert.equal(getExecution()?.items[0].done, true);
515
+ assert.match(recorded.status ?? "", /120 in-toks/);
516
+ assert.match(recorded.status ?? "", /50 out-toks/);
297
517
 
298
- stopExecution(pi, ctx, "test-done");
518
+ await stopExecution(pi, ctx, "test-done");
299
519
  assert.equal(getExecution(), null);
300
520
  });
301
521
 
302
- it("defers persistence and widget churn when toggling mid-turn", () => {
522
+ it("defers persistence and widget churn when toggling mid-turn", async () => {
303
523
  const workdir = freshWorkdir();
304
524
  const { pi, ctx, recorded } = makeHarness(workdir);
305
- startExecution(pi, ctx, path.join(workdir, "PLAN_v3.md"), items("VC-001", "VC-002"));
525
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v3.md"), items("VC-001", "VC-002"));
306
526
 
307
527
  const entriesBefore = recorded.entries.length;
308
528
  const factoriesBefore = recorded.widgetCalls;
@@ -315,13 +535,590 @@ describe("execution loop", () => {
315
535
  assert.equal(consumePendingPanelSync(), true, "expected a pending panel sync marker");
316
536
  assert.equal(consumePendingPanelSync(), false, "marker should be consumed exactly once");
317
537
 
318
- // Back to idle: the next toggle persists and syncs. It flips the panel to
319
- // expanded, which registers the detail widget exactly once (no teardown).
538
+ // Back to idle: the next toggle persists and syncs without touching the
539
+ // widget slot because the panel is collapsed again.
320
540
  ctx.isIdle = () => true;
321
541
  assert.equal(toggleExecutionPanelView(pi, ctx), false);
322
542
  assert.ok(recorded.entries.length > entriesBefore, "idle toggle did not persist");
323
- assert.equal(recorded.widgetCalls, factoriesBefore + 1, "idle toggle churned the widget registration");
543
+ assert.equal(recorded.widgetCalls, factoriesBefore, "collapsed toggle churned the widget registration");
544
+
545
+ await stopExecution(pi, ctx, "test-done");
546
+ });
547
+
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"));
552
+
553
+ setUsagePercent(50);
554
+ const below = handleExecutionBeforeCompact(pi, ctx, {
555
+ type: "session_before_compact",
556
+ preparation: makePreparation("threshold", null),
557
+ branchEntries: [],
558
+ reason: "threshold",
559
+ willRetry: false,
560
+ signal: new AbortController().signal,
561
+ });
562
+ assert.equal(below?.cancel, undefined);
563
+ assert.ok(below?.compaction, "threshold compaction should remain Pi-core-owned but use the custom summary");
564
+
565
+ setUsagePercent(105);
566
+ const above = handleExecutionBeforeCompact(pi, ctx, {
567
+ type: "session_before_compact",
568
+ preparation: makePreparation("threshold", null),
569
+ branchEntries: [],
570
+ reason: "threshold",
571
+ willRetry: false,
572
+ signal: new AbortController().signal,
573
+ });
574
+ assert.equal(above?.cancel, undefined);
575
+ assert.ok(above?.compaction);
576
+
577
+ const overflow = handleExecutionBeforeCompact(pi, ctx, {
578
+ type: "session_before_compact",
579
+ preparation: makePreparation("overflow", null),
580
+ branchEntries: [],
581
+ reason: "overflow",
582
+ willRetry: true,
583
+ signal: new AbortController().signal,
584
+ });
585
+ assert.equal(overflow?.cancel, undefined);
586
+ assert.ok(overflow?.compaction);
587
+
588
+ const manual = handleExecutionBeforeCompact(pi, ctx, {
589
+ type: "session_before_compact",
590
+ preparation: makePreparation("manual", null),
591
+ branchEntries: [],
592
+ reason: "manual",
593
+ willRetry: false,
594
+ signal: new AbortController().signal,
595
+ });
596
+ assert.equal(manual?.cancel, undefined);
597
+ assert.ok(manual?.compaction);
598
+
599
+ await stopExecution(pi, ctx, "test-done");
600
+ });
601
+
602
+ it("does not install an execution threshold trigger in turn_end", () => {
603
+ const indexSource = fs.readFileSync(path.join(process.cwd(), "index.ts"), "utf8");
604
+ 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/);
611
+ });
612
+
613
+ it("queues one non-retry resume, skips overflow retry, and keeps execution after failure", async () => {
614
+ const workdir = freshWorkdir();
615
+ const { pi, ctx, recorded } = makeHarness(workdir);
616
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v9.md"), items("VC-001"));
617
+
618
+ const beforeRequests = recorded.compacts?.length ?? 0;
619
+ handleExecutionCompact(pi, ctx, {
620
+ type: "session_compact",
621
+ compactionEntry: { type: "compaction" } as never,
622
+ fromExtension: true,
623
+ reason: "threshold",
624
+ willRetry: false,
625
+ });
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");
630
+
631
+ handleExecutionCompact(pi, ctx, {
632
+ type: "session_compact",
633
+ compactionEntry: { type: "compaction" } as never,
634
+ fromExtension: true,
635
+ reason: "overflow",
636
+ willRetry: true,
637
+ });
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
+ );
643
+
644
+ handleExecutionCompactFailed(pi, ctx, {
645
+ type: "session_compact_failed",
646
+ reason: "manual",
647
+ aborted: false,
648
+ willRetry: false,
649
+ fromExtension: true,
650
+ errorMessage: "boom",
651
+ });
652
+ assert.ok(getExecution(), "compaction failure must keep execution active");
653
+ assert.ok(recorded.notifies.some((note) => note.severity === "warning" && note.message.includes("execution remains active")));
654
+
655
+ await stopExecution(pi, ctx, "test-done");
656
+ });
657
+
658
+ it("builds a plan-aware summary with per-item sections and chains the previous summary", async () => {
659
+ 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]");
664
+
665
+ const previousSummary = "## Goal\nDeliver auto-compact in execution phase.\n\n## Finished Items\n- legacy VC-000 summary";
666
+ const preparation = makePreparation("threshold", previousSummary);
667
+ const branchEntries: any[] = [
668
+ { id: "exec-start", type: "custom", customType: "pi-plans-exec-start" },
669
+ { 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]" }] } },
671
+ { id: "u-2", type: "message", message: { role: "user", content: [{ type: "text", text: "implement VC-002" }] } },
672
+ { 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
+ ];
675
+ const result = buildExecutionCompactionResult(
676
+ {
677
+ type: "session_before_compact",
678
+ preparation,
679
+ branchEntries,
680
+ customInstructions: "keep current task visible",
681
+ reason: "threshold",
682
+ willRetry: false,
683
+ signal: new AbortController().signal,
684
+ },
685
+ ctx,
686
+ );
687
+ 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, {
700
+ type: "session_before_compact",
701
+ preparation,
702
+ branchEntries,
703
+ reason: "threshold",
704
+ willRetry: false,
705
+ signal: new AbortController().signal,
706
+ });
707
+
708
+ await stopExecution(pi, ctx, "test-done");
709
+ });
710
+
711
+ it("filters the hidden resume message out of the LLM context payload", () => {
712
+ const messages = [
713
+ { customType: "user", content: "real prompt" },
714
+ { customType: "pi-plans-exec-resume", content: "Continue execution." },
715
+ { customType: "pi-plans-plan-resume", content: "Continue planning." },
716
+ { customType: "assistant", content: "ok" },
717
+ ];
718
+ assert.equal(filterExecutionResumeMessages(messages).length, 3);
719
+ assert.equal(filterPlanningResumeMessages(messages).length, 3);
720
+ });
721
+
722
+ it("planning compaction cuts at plan-written when present and falls back to run-start", () => {
723
+ const workdir = freshWorkdir();
724
+ const { ctx } = makeHarness(workdir);
725
+ initState(workdir);
726
+ 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
+ });
739
+
740
+ // Case A: plan written → cut at next non-internal entry after plan-written; QA section included.
741
+ const branchEntriesWithPlan = [
742
+ { id: "rs", type: "custom", customType: PLANNING_RUN_START_CUSTOM_TYPE, data: { runId: run.run_id, artifactDir: run.artifact_dir } },
743
+ { 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") } },
746
+ { id: "u-2", type: "message", message: { role: "user", content: [{ type: "text", text: "review please" }] } },
747
+ ] as any;
748
+ const withPlan = buildPlanningCompactionResult(
749
+ {
750
+ type: "session_before_compact",
751
+ preparation: makePlanningPreparation(null),
752
+ branchEntries: branchEntriesWithPlan,
753
+ reason: "threshold",
754
+ willRetry: false,
755
+ signal: new AbortController().signal,
756
+ },
757
+ ctx,
758
+ );
759
+ assert.ok(withPlan);
760
+ 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/);
801
+ });
802
+
803
+ it("planning hook is gated by run.status=planning and defers to execution hook when execution is running", async () => {
804
+ const workdir = freshWorkdir();
805
+ const { ctx, setUsagePercent } = makeHarness(workdir);
806
+ initState(workdir);
807
+ const { run } = startRun(workdir, { topic: "planning gate", skill: "plan-small", requestText: "x" });
808
+
809
+ setUsagePercent(110);
810
+ const resultPlanning = handlePlanningBeforeCompact({} as any, ctx as any, {
811
+ 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: [],
823
+ reason: "threshold",
824
+ willRetry: false,
825
+ signal: new AbortController().signal,
826
+ });
827
+ assert.ok(resultPlanning?.compaction || resultPlanning === undefined);
828
+
829
+ // Flip status to done; planning hook should refuse.
830
+ setRunStatus(workdir, run.run_id, "done");
831
+ setUsagePercent(110);
832
+ const resultDone = handlePlanningBeforeCompact({} as any, ctx as any, {
833
+ 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: [],
845
+ reason: "threshold",
846
+ willRetry: false,
847
+ signal: new AbortController().signal,
848
+ });
849
+ assert.equal(resultDone, undefined);
850
+ });
851
+
852
+ it("turn_end writes are unconditionally deferred even when isIdle reads true", async () => {
853
+ const workdir = freshWorkdir();
854
+ const { pi, ctx, recorded, setUsagePercent } = makeHarness(workdir);
855
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v11.md"), items("VC-001", "VC-002"));
856
+ const snapshotCount = () => recorded.entries.filter((e) => e.customType === "pi-plans-exec").length;
857
+ const baseline = snapshotCount();
858
+
859
+ // No isIdle override: the harness default (() => true) IS the real
860
+ // turn_end reading — this encodes the field regression (60 writes in
861
+ // 23 minutes) as a permanent zero-write assertion.
862
+ recordExecutionTurn(pi, ctx, [], { input: 10, output: 5 });
863
+ setUsagePercent(50);
864
+ handleExecutionBeforeCompact(pi, ctx, {
865
+ type: "session_before_compact",
866
+ preparation: makePreparation("threshold", null),
867
+ branchEntries: [],
868
+ reason: "threshold",
869
+ willRetry: false,
870
+ signal: new AbortController().signal,
871
+ });
872
+ assert.equal(snapshotCount(), baseline, "turn_end wrote session entries despite deferral");
873
+ // Status line stays real-time while the write is deferred.
874
+ assert.match(recorded.status ?? "", /10 in-toks/);
875
+
876
+ drainExecutionFlush(pi, ctx);
877
+ assert.equal(snapshotCount(), baseline + 1, "settle flush did not write exactly one snapshot");
878
+ const last = (recorded.entries.filter((e) => e.customType === "pi-plans-exec").at(-1)?.data ?? {}) as { usage?: { inToks: number } };
879
+ assert.equal(last.usage?.inToks, 10, "flushed snapshot missing busy-turn usage");
880
+ drainExecutionFlush(pi, ctx);
881
+ assert.equal(snapshotCount(), baseline + 1, "second drain wrote again");
882
+ assert.equal(consumePendingExecutionFlush(), false);
883
+
884
+ await stopExecution(pi, ctx, "done");
885
+ });
886
+
887
+ it("stop and complete drain pending writes synchronously with final state", async () => {
888
+ const workdir = freshWorkdir();
889
+ const { pi, ctx, recorded } = makeHarness(workdir);
890
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v12.md"), items("VC-001", "VC-002"));
891
+ const snapshotCount = () => recorded.entries.filter((e) => e.customType === "pi-plans-exec").length;
892
+
893
+ ctx.isIdle = () => false;
894
+ recordExecutionTurn(pi, ctx, [], { input: 7, output: 3 });
895
+ const busyCount = snapshotCount();
896
+
897
+ await stopExecution(pi, ctx, "force");
898
+ assert.ok(snapshotCount() > busyCount, "stop did not write the final snapshot");
899
+ assert.ok(recorded.entries.some((e) => e.customType === "pi-plans-exec-cleared"));
900
+ const lastStop = (recorded.entries.filter((e) => e.customType === "pi-plans-exec").at(-1)?.data ?? {}) as { usage?: { inToks: number } };
901
+ assert.equal(lastStop.usage?.inToks, 7, "stop lost the busy-turn usage");
902
+
903
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v13.md"), items("VC-001"));
904
+ ctx.isIdle = () => false;
905
+ applyDoneMarkers("[DONE:VC-001]");
906
+ recordExecutionTurn(pi, ctx, ["VC-001"], { input: 1, output: 1 });
907
+ await completeExecution(pi, ctx);
908
+ assert.equal(getExecution(), null);
909
+ const lastComplete = (recorded.entries.filter((e) => e.customType === "pi-plans-exec").at(-1)?.data ?? {}) as { items?: Array<{ done: boolean }> };
910
+ assert.equal(lastComplete.items?.[0]?.done, true, "completion snapshot missing final done state");
911
+ });
912
+
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
+ it("updates the status bar in real time on every turn without session writes", async () => {
967
+ const workdir = freshWorkdir();
968
+ const { pi, ctx, recorded, setUsagePercent } = makeHarness(workdir);
969
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v15.md"), items("VC-001", "VC-002"), undefined, [
970
+ { id: "I-001", text: "First item." },
971
+ { id: "I-002", text: "Second item." },
972
+ ]);
973
+ const snapshotCount = () => recorded.entries.filter((e) => e.customType === "pi-plans-exec").length;
974
+ const baseline = snapshotCount();
975
+
976
+ recordExecutionTurn(pi, ctx, [], { input: 33, output: 11 });
977
+ // Real-time: status line already reflects the turn's usage...
978
+ assert.match(recorded.status ?? "", /33 in-toks/);
979
+ assert.match(recorded.status ?? "", /⌛ plans 0\/2: spent/);
980
+ // ...without any session write (anti-jitter preserved).
981
+ assert.equal(snapshotCount(), baseline);
982
+
983
+ setUsagePercent(null);
984
+ drainExecutionFlush(pi, ctx);
985
+ assert.equal(snapshotCount(), baseline + 1);
986
+
987
+ await stopExecution(pi, ctx, "done");
988
+ });
989
+
990
+ it("syncs progress through the registered message_end and turn_end handlers", async () => {
991
+ const workdir = freshWorkdir();
992
+ const { pi, ctx, recorded, emit } = makeHarness(workdir);
993
+ registerExecutionTurnHandlers(pi);
994
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v19.md"), items("VC-001", "VC-002", "VC-003"));
995
+
996
+ // The usage is delivered by message_end and consumed by the following
997
+ // turn_end, matching Pi's lifecycle contract.
998
+ await emit("message_end", {
999
+ message: { role: "assistant", usage: { input: 12, output: 5 } },
1000
+ });
1001
+ await emit("turn_end", {
1002
+ message: { role: "assistant", content: [{ type: "text", text: "verified [DONE:VC-001]" }] },
1003
+ });
1004
+ assert.match(recorded.status ?? "", /⌛ plans 1\/3: spent/);
1005
+ 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
+
1020
+ await emit("message_end", {
1021
+ message: { role: "assistant", usage: { input: 8, output: 3 } },
1022
+ });
1023
+ await emit("turn_end", {
1024
+ message: { role: "assistant", content: [{ type: "text", text: "verified [DONE:VC-002]" }] },
1025
+ });
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/);
1030
+
1031
+ await stopExecution(pi, ctx, "event-chain-test");
1032
+ });
1033
+ it("applies impl markers with silent unknown ids and later-overwrite semantics", async () => {
1034
+ const workdir = freshWorkdir();
1035
+ const { pi, ctx } = makeHarness(workdir);
1036
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v16.md"), items("VC-001"), undefined, [
1037
+ { id: "I-001", text: "First item." },
1038
+ ]);
1039
+
1040
+ assert.deepEqual(applyImplMarkers("[I-999:implemented]"), []); // unknown id silently ignored
1041
+ assert.deepEqual(applyImplMarkers("[I-001:implemented]"), ["I-001"]);
1042
+ assert.deepEqual(applyImplMarkers("[I-001:validating]"), ["I-001"]); // later overwrites
1043
+ assert.equal(getExecution()?.implStatus?.["I-001"], "validating");
324
1044
 
325
- stopExecution(pi, ctx, "test-done");
1045
+ await stopExecution(pi, ctx, "done");
1046
+ });
1047
+
1048
+ it("replays impl markers from post-snapshot messages on restore", async () => {
1049
+ const workdir = freshWorkdir();
1050
+ const { pi, ctx } = makeHarness(workdir);
1051
+ const snapshot = {
1052
+ planPath: path.join(workdir, "PLAN_v17.md"),
1053
+ items: items("VC-001"),
1054
+ startedAt: "2026-08-29T00:00:00Z",
1055
+ implItems: [{ id: "I-001", text: "First item." }],
1056
+ implStatus: {},
1057
+ };
1058
+ fs.writeFileSync(snapshot.planPath, "# plan");
1059
+ const entries = [
1060
+ { type: "custom", customType: "pi-plans-exec", data: snapshot },
1061
+ { type: "message", message: { role: "assistant", content: [{ type: "text", text: "work done [I-001:implemented]" }] } },
1062
+ ];
1063
+ await restoreFromSession(pi, ctx, entries as any);
1064
+ assert.equal(getExecution()?.implStatus?.["I-001"], "implemented");
1065
+ });
1066
+
1067
+ it("keeps the injection rules teaching the impl markers", async () => {
1068
+ const workdir = freshWorkdir();
1069
+ const { pi, ctx } = makeHarness(workdir);
1070
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v18.md"), items("VC-001"), undefined, [
1071
+ { id: "I-001", text: "First item." },
1072
+ ]);
1073
+ const rules = executionContextMessage()!;
1074
+ assert.match(rules, /\[I-001:implemented\]/);
1075
+ assert.match(rules, /\[I-001:validating\]/);
1076
+ await stopExecution(pi, ctx, "done");
1077
+ });
1078
+
1079
+ it("planning compaction honors cooldown + resume guard and survives manual /compact", () => {
1080
+ const workdir = freshWorkdir();
1081
+ const { pi, ctx, setUsagePercent, recorded } = makeHarness(workdir);
1082
+ initState(workdir);
1083
+ startRun(workdir, { topic: "planning cooldown", skill: "plan-normal", requestText: "x" });
1084
+
1085
+ 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
+ assert.equal(shouldTriggerPlanningCompaction(ctx as any), false);
1091
+
1092
+ setUsagePercent(50);
1093
+ handlePlanningCompact(pi as any, ctx as any, {
1094
+ type: "session_compact",
1095
+ compactionEntry: { type: "compaction" } as never,
1096
+ fromExtension: true,
1097
+ reason: "manual",
1098
+ willRetry: false,
1099
+ });
1100
+ const resume = recorded.messages.find((message) => message.customType === "pi-plans-plan-resume");
1101
+ assert.ok(resume);
1102
+ 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);
1108
+ setUsagePercent(120);
1109
+ assert.equal(shouldTriggerPlanningCompaction(ctx as any), true);
326
1110
  });
327
1111
  });
1112
+
1113
+ function makePreparation(reason: "manual" | "threshold" | "overflow", previousSummary: string | null): any {
1114
+ return {
1115
+ firstKeptEntryId: "a-2",
1116
+ messagesToSummarize: [],
1117
+ turnPrefixMessages: [],
1118
+ isSplitTurn: false,
1119
+ tokensBefore: 12345,
1120
+ previousSummary,
1121
+ fileOps: { read: [], written: [], edited: [] },
1122
+ settings: { enabled: true, reserveTokens: 16384, keepRecentTokens: 20000 },
1123
+ };
1124
+ }