pi-plans 0.1.1 → 0.2.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.
@@ -7,16 +7,36 @@ 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
+ applyCurrentIMarker,
12
+ buildExecutionCompactionResult,
13
+ buildPlanningCompactionResult,
10
14
  completeExecution,
11
- consumePendingPanelSync,
15
+ consumePendingExecutionFlush,
16
+ consumePlanningCompactionResumeGuard,
17
+ drainExecutionFlush,
12
18
  executionContextMessage,
19
+ filterExecutionResumeMessages,
20
+ filterPlanningResumeMessages,
13
21
  getExecution,
22
+ handleExecutionBeforeCompact,
23
+ handleExecutionCompact,
24
+ handleExecutionTurnCompaction,
25
+ handleExecutionCompactFailed,
26
+ handlePlanningBeforeCompact,
27
+ handlePlanningCompact,
28
+ handlePlanningCompactFailed,
14
29
  isExecutionComplete,
30
+ PLANNING_PLAN_WRITTEN_CUSTOM_TYPE,
31
+ PLANNING_RUN_START_CUSTOM_TYPE,
32
+ refreshPlanningCompactionCooldown,
33
+ requestPlanningCompaction,
15
34
  restoreFromSession,
35
+ shouldTriggerPlanningCompaction,
16
36
  startExecution,
17
- recordExecutionCompletion,
37
+ recordExecutionTurn,
38
+ registerExecutionTurnHandlers,
18
39
  stopExecution,
19
- toggleExecutionPanelView,
20
40
  updateStatusWidget,
21
41
  } from "../src/exec.ts";
22
42
  import type { CheckItem } from "../src/plan.ts";
@@ -24,40 +44,92 @@ import { initState, setRunStatus, startRun } from "../src/state.ts";
24
44
 
25
45
  interface Recorded {
26
46
  entries: { type: string; customType?: string; data?: unknown }[];
27
- messages: { customType: string; content: string }[];
47
+ messages: { customType: string; content: string; options?: { triggerTurn?: boolean } }[];
28
48
  status: string | undefined;
49
+ statusCalls: number;
29
50
  colors: string[];
30
- widget?: { key: string; options?: unknown; factory: any };
31
- widgetCalls: number;
51
+ models: { provider: string; id: string }[];
52
+ thinkingLevels: (string | null)[];
53
+ notifies: { message: string; severity: string }[];
54
+ selects: { title: string; options: string[] }[];
55
+ selectAnswer?: string;
56
+ current: { provider: string; id: string } | null;
57
+ thinking: string | null;
58
+ compacts?: { customInstructions?: string }[];
32
59
  }
33
60
 
34
61
  interface Harness {
35
62
  pi: any;
36
63
  ctx: any;
37
64
  recorded: Recorded;
65
+ emit: (eventName: string, event: unknown) => Promise<unknown[]>;
38
66
  }
39
67
 
40
68
  function makeHarness(workdir: string): Harness {
41
- const recorded: Recorded = { entries: [], messages: [], status: undefined, colors: [], widgetCalls: 0 };
69
+ const recorded: Recorded = {
70
+ entries: [],
71
+ messages: [],
72
+ status: undefined,
73
+ statusCalls: 0,
74
+ colors: [],
75
+ models: [],
76
+ thinkingLevels: [],
77
+ notifies: [],
78
+ selects: [],
79
+ current: { provider: "p", id: "m" },
80
+ thinking: "high",
81
+ };
82
+ let contextPercent: number | null = 0;
83
+ const registryModels = [
84
+ { provider: "p", id: "m" },
85
+ { provider: "prov", id: "other" },
86
+ ];
87
+ const handlers = new Map<string, Array<(event: unknown, ctx: any) => unknown>>();
88
+ const emit = async (eventName: string, event: unknown): Promise<unknown[]> => {
89
+ const results: unknown[] = [];
90
+ for (const handler of handlers.get(eventName) ?? []) {
91
+ results.push(await handler(event, ctx));
92
+ }
93
+ return results;
94
+ };
42
95
  const pi = {
96
+ on: (eventName: string, handler: (event: unknown, ctx: any) => unknown) => {
97
+ const registered = handlers.get(eventName) ?? [];
98
+ registered.push(handler);
99
+ handlers.set(eventName, registered);
100
+ },
101
+ registerTool: () => {},
102
+ registerCommand: () => {},
103
+ registerShortcut: () => {},
104
+ registerFlag: () => {},
43
105
  appendEntry: (customType: string, data: unknown) => {
44
106
  recorded.entries.push({ type: "custom", customType, data });
45
107
  },
46
- sendMessage: (message: { customType: string; content: string }) => {
47
- recorded.messages.push(message);
108
+ sendMessage: (message: { customType: string; content: string }, options?: { triggerTurn?: boolean }) => {
109
+ recorded.messages.push({ ...message, options });
110
+ },
111
+ sendUserMessage: async () => {},
112
+ setModel: async (model: { provider: string; id: string }) => {
113
+ recorded.models.push({ provider: model.provider, id: model.id });
114
+ recorded.current = { provider: model.provider, id: model.id };
115
+ return true;
116
+ },
117
+ setThinkingLevel: (level: string) => {
118
+ recorded.thinkingLevels.push(level);
119
+ recorded.thinking = level;
48
120
  },
49
121
  };
50
122
  const ui = {
51
123
  setStatus: (_key: string, value: string | undefined) => {
124
+ recorded.statusCalls += 1;
52
125
  recorded.status = value;
53
126
  },
54
- setWidget: (key: string, factory: any, options?: unknown) => {
55
- recorded.widgetCalls += 1;
56
- if (factory === undefined) {
57
- recorded.widget = undefined;
58
- return;
59
- }
60
- recorded.widget = { key, options, factory };
127
+ notify: (message: string, severity: string) => {
128
+ recorded.notifies.push({ message, severity });
129
+ },
130
+ select: async (title: string, options: string[]) => {
131
+ recorded.selects.push({ title, options });
132
+ return recorded.selectAnswer ?? options[0];
61
133
  },
62
134
  theme: {
63
135
  fg: (color: string, text: string) => {
@@ -67,12 +139,38 @@ function makeHarness(workdir: string): Harness {
67
139
  strikethrough: (text: string) => `~~${text}~~`,
68
140
  },
69
141
  };
142
+ const sessionManager: any = {};
70
143
  const ctx = {
71
144
  cwd: workdir,
72
145
  ui,
73
146
  isIdle: () => true,
147
+ hasUI: true,
148
+ scopedModels: [] as Array<{ model: { provider: string; id: string }; thinkingLevel?: string }>,
149
+ get model() {
150
+ return recorded.current;
151
+ },
152
+ get thinkingLevel() {
153
+ return recorded.thinking;
154
+ },
155
+ modelRegistry: {
156
+ find: (provider: string, modelId: string) =>
157
+ registryModels.find((entry) => entry.provider === provider && entry.id === modelId),
158
+ getAvailable: () => registryModels,
159
+ },
160
+ getContextUsage: () =>
161
+ contextPercent === null
162
+ ? undefined
163
+ : { tokens: contextPercent * 1000, contextWindow: 100000, percent: contextPercent },
164
+ compact: (options: { customInstructions?: string }) => {
165
+ recorded.compacts = recorded.compacts ?? [];
166
+ recorded.compacts.push(options);
167
+ },
168
+ sessionManager,
169
+ setUsagePercent: (percent: number | null) => {
170
+ contextPercent = percent;
171
+ },
74
172
  };
75
- return { pi, ctx, recorded };
173
+ return { pi, ctx, recorded, emit, setUsagePercent: ctx.setUsagePercent } as Harness & { setUsagePercent: (percent: number | null) => void };
76
174
  }
77
175
 
78
176
  function items(...ids: string[]): CheckItem[] {
@@ -98,39 +196,25 @@ describe("execution loop", () => {
98
196
  return workdir;
99
197
  }
100
198
 
101
- it("tracks done markers and completes", () => {
199
+ it("tracks done markers and completes", async () => {
102
200
  const workdir = freshWorkdir();
103
201
  const { pi, ctx, recorded } = makeHarness(workdir);
104
- startExecution(pi, ctx, path.join(workdir, "PLAN_v1.md"), items("VC-001", "VC-002"));
202
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v1.md"), items("VC-001", "VC-002"));
105
203
 
106
- // Collapsed by default: progress lives in the bottom status bar (same
107
- // layer as the ⛔/⌛ indicators); no panel widget is registered yet.
108
- assert.equal(recorded.widget, undefined);
109
204
  assert.match(recorded.status ?? "", /⌛ plans 0\/2: spent \d{2}:\d{2}:\d{2}/);
110
205
  assert.match(recorded.status ?? "", /in-toks/);
111
206
  assert.match(recorded.status ?? "", /out-toks/);
112
- assert.match(recorded.status ?? "", /\/plans-list details/);
113
-
114
- toggleExecutionPanelView(pi, ctx);
115
- assert.ok(recorded.widget);
116
- assert.equal(recorded.widget?.key, "pi-plans-execution");
117
- assert.deepEqual(recorded.widget?.options, { placement: "belowEditor" });
118
- const expandedWidget = recorded.widget?.factory(
119
- {} as any,
120
- { fg: (_color: string, text: string) => text, strikethrough: (text: string) => `~~${text}~~` },
121
- );
122
- assert.ok(expandedWidget);
123
- const expandedLines = expandedWidget.render(80);
124
- assert.match(expandedLines.join("\n"), /☐/);
125
- // Detail view never repeats the count or the keyboard hint.
126
- assert.doesNotMatch(expandedLines.join("\n"), /📋 plans/);
127
- assert.doesNotMatch(expandedLines.join("\n"), /alt\+o/);
128
207
 
129
208
  assert.ok(getExecution());
130
209
  const rules = executionContextMessage()!;
131
210
  assert.match(rules, /PI-PLANS EXECUTION/);
132
211
  assert.match(rules, /VC-001/);
133
- // Seven-principle rule set: representative anchors (PLAN_v2 D-003/D-004).
212
+ assert.match(rules, /subprocess-backed verification/);
213
+ assert.match(rules, /waiting for/);
214
+ assert.match(rules, /5s\s*->\s*10s\s*->\s*20s\s*->\s*40s\s*->\s*80s/);
215
+ assert.match(rules, /keep polling at 80s/);
216
+ assert.match(rules, /restart at 5s for each new subprocess/);
217
+ // Representative anchors for the core execution rules (PLAN_v2 D-003/D-004).
134
218
  assert.match(rules, /for the long term/);
135
219
  assert.match(rules, /Simplest implementation/);
136
220
  assert.match(rules, /grow the change in layers/);
@@ -145,28 +229,25 @@ describe("execution loop", () => {
145
229
  assert.deepEqual(applyDoneMarkers("final: [DONE:VC-002]"), ["VC-002"]);
146
230
  assert.equal(isExecutionComplete(), true);
147
231
 
148
- completeExecution(pi, ctx);
232
+ await completeExecution(pi, ctx);
149
233
  assert.equal(getExecution(), null);
150
234
  assert.ok(recorded.messages.some((message) => message.customType === "pi-plans-complete"));
151
235
  });
152
236
 
153
- it("restores progress from session entries and rescans messages", () => {
237
+ it("restores progress from session entries and rescans messages", async () => {
154
238
  const workdir = freshWorkdir();
155
239
  const { pi, ctx, recorded } = makeHarness(workdir);
156
240
  const snapshot = {
157
241
  planPath: path.join(workdir, "PLAN_v1.md"),
158
242
  items: items("VC-001", "VC-002"),
159
243
  startedAt: "2026-08-25T00:00:00Z",
160
- panel: {
161
- expanded: true,
162
- baseline: { added: 0, removed: 0, files: 0 },
163
- lastSnapshot: { added: 1, removed: 0, files: 1 },
164
- touchedPaths: ["src/exec.ts"],
165
- itemSummaries: {
166
- "VC-001": {
167
- summary: { added: 1, removed: 0, files: 1, paths: ["src/exec.ts"] },
168
- },
169
- },
244
+ compaction: {
245
+ inFlight: true,
246
+ resumeGuard: true,
247
+ cooldownActive: true,
248
+ lastAttemptReason: "threshold",
249
+ lastSuccessfulUsagePercent: 100,
250
+ lastSuccessfulAt: "2026-08-25T00:01:00Z",
170
251
  },
171
252
  };
172
253
  fs.writeFileSync(snapshot.planPath, "# plan");
@@ -177,25 +258,17 @@ describe("execution loop", () => {
177
258
  message: { role: "assistant", content: [{ type: "text", text: "did [DONE:VC-001]" }] },
178
259
  },
179
260
  ];
180
- restoreFromSession(pi, ctx, entries as any);
261
+ await restoreFromSession(pi, ctx, entries as any);
181
262
  const execution = getExecution();
182
263
  assert.ok(execution);
183
- assert.ok(recorded.widget);
184
- const widget = recorded.widget?.factory(
185
- {} as any,
186
- { fg: (_color: string, text: string) => text, strikethrough: (text: string) => `~~${text}~~` },
187
- );
188
- assert.ok(widget);
189
- const rendered = widget.render(80);
190
- assert.match(rendered.join("\n"), /☑/);
191
- assert.match(rendered.join("\n"), /\+1/);
264
+ assert.equal("compaction" in execution!, false, "legacy scheduler state must not reactivate on restore");
192
265
 
193
266
  const clearedEntries = [...entries, { type: "custom", customType: "pi-plans-exec-cleared", data: {} }];
194
- restoreFromSession(pi, ctx, clearedEntries as any);
267
+ await restoreFromSession(pi, ctx, clearedEntries as any);
195
268
  assert.equal(getExecution(), null);
196
269
  });
197
270
 
198
- it("ignores restore when the plan file vanished", () => {
271
+ it("ignores restore when the plan file vanished", async () => {
199
272
  const workdir = freshWorkdir();
200
273
  const { pi, ctx } = makeHarness(workdir);
201
274
  const entries = [
@@ -209,28 +282,41 @@ describe("execution loop", () => {
209
282
  },
210
283
  },
211
284
  ];
212
- restoreFromSession(pi, ctx, entries as any);
285
+ await restoreFromSession(pi, ctx, entries as any);
213
286
  assert.equal(getExecution(), null);
214
287
  });
215
288
 
216
- it("stop clears execution", () => {
289
+ it("stop clears execution", async () => {
217
290
  const workdir = freshWorkdir();
218
291
  const { pi, ctx } = makeHarness(workdir);
219
- startExecution(pi, ctx, path.join(workdir, "PLAN_v1.md"), items("VC-001"));
220
- stopExecution(pi, ctx, "test");
292
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v1.md"), items("VC-001"));
293
+ await stopExecution(pi, ctx, "test");
221
294
  assert.equal(getExecution(), null);
222
295
  });
223
296
 
224
- it("keeps the status bar count-free while executing and points at the panel", () => {
297
+ it("starts execution without switching models", async () => {
298
+ const workdir = freshWorkdir();
299
+ const { pi, ctx, recorded } = makeHarness(workdir);
300
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v6.md"), items("VC-001"));
301
+
302
+ assert.deepEqual(recorded.models, []);
303
+ assert.equal(recorded.thinking, "high");
304
+ assert.match(recorded.status ?? "", /⌛ plans 0\/1/);
305
+
306
+ await stopExecution(pi, ctx, "restore-check");
307
+ assert.deepEqual(recorded.models, []);
308
+ assert.equal(recorded.thinking, "high");
309
+ });
310
+
311
+ it("keeps the execution status bar current while executing", async () => {
225
312
  const workdir = freshWorkdir();
226
313
  const { pi, ctx, recorded } = makeHarness(workdir);
227
- startExecution(pi, ctx, path.join(workdir, "PLAN_v2.md"), items("VC-001", "VC-002"));
314
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v2.md"), items("VC-001", "VC-002"));
228
315
 
229
316
  // Bottom status bar carries the count — the same layer as ⛔/⌛ — so both
230
317
  // execution states read from one consistent place.
231
318
  assert.match(recorded.status ?? "", /⌛ plans 0\/2: spent \d{2}:\d{2}:\d{2}/);
232
319
  assert.match(recorded.status ?? "", /in-toks/);
233
- assert.match(recorded.status ?? "", /\/plans-list details/);
234
320
 
235
321
  const start = recorded.messages.find((message) => message.customType === "pi-plans-exec-start");
236
322
  assert.ok(start);
@@ -238,7 +324,7 @@ describe("execution loop", () => {
238
324
  assert.doesNotMatch(start.content, /footer/);
239
325
 
240
326
  applyDoneMarkers("[DONE:VC-001]");
241
- completeExecution(pi, ctx);
327
+ await completeExecution(pi, ctx);
242
328
  assert.equal(getExecution(), null);
243
329
 
244
330
  });
@@ -281,47 +367,663 @@ describe("execution loop", () => {
281
367
  assert.equal(recorded.colors.at(-1), "error");
282
368
  });
283
369
 
284
- it("accumulates token usage only for turns that finish items", () => {
370
+ it("accumulates token usage on token-only and completion turns", async () => {
285
371
  const workdir = freshWorkdir();
286
372
  const { pi, ctx, recorded } = makeHarness(workdir);
287
- startExecution(pi, ctx, path.join(workdir, "PLAN_v5.md"), items("VC-001", "VC-002"));
373
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v5.md"), items("VC-001", "VC-002"));
288
374
 
289
- recordExecutionCompletion(pi, ctx, ["VC-001"], { input: 100, output: 40 });
375
+ recordExecutionTurn(pi, ctx, [], { input: 100, output: 40 });
376
+ updateStatusWidget(ctx);
290
377
  assert.equal(getExecution()?.usage.inToks, 100);
291
378
  assert.equal(getExecution()?.usage.outToks, 40);
379
+ assert.match(recorded.status ?? "", /100 in-toks/);
380
+ assert.match(recorded.status ?? "", /40 out-toks/);
292
381
 
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);
382
+ assert.deepEqual(applyDoneMarkers("[DONE:VC-001]"), ["VC-001"]);
383
+ recordExecutionTurn(pi, ctx, ["VC-001"], { input: 20, output: 10 });
384
+ updateStatusWidget(ctx);
385
+ assert.equal(getExecution()?.usage.inToks, 120);
386
+ assert.equal(getExecution()?.usage.outToks, 50);
387
+ assert.equal(getExecution()?.items[0].done, true);
388
+ assert.match(recorded.status ?? "", /120 in-toks/);
389
+ assert.match(recorded.status ?? "", /50 out-toks/);
297
390
 
298
- stopExecution(pi, ctx, "test-done");
391
+ await stopExecution(pi, ctx, "test-done");
299
392
  assert.equal(getExecution(), null);
300
393
  });
301
394
 
302
- it("defers persistence and widget churn when toggling mid-turn", () => {
395
+ it("lets Pi core own execution compaction scheduling and customizes every reason", async () => {
396
+ const workdir = freshWorkdir();
397
+ const { pi, ctx, setUsagePercent } = makeHarness(workdir);
398
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v8.md"), items("VC-001", "VC-002"));
399
+
400
+ setUsagePercent(50);
401
+ const below = handleExecutionBeforeCompact(pi, ctx, {
402
+ type: "session_before_compact",
403
+ preparation: makePreparation("threshold", null),
404
+ branchEntries: [],
405
+ reason: "threshold",
406
+ willRetry: false,
407
+ signal: new AbortController().signal,
408
+ });
409
+ assert.equal(below?.cancel, undefined);
410
+ assert.ok(below?.compaction, "threshold compaction should remain Pi-core-owned but use the custom summary");
411
+
412
+ setUsagePercent(105);
413
+ const above = handleExecutionBeforeCompact(pi, ctx, {
414
+ type: "session_before_compact",
415
+ preparation: makePreparation("threshold", null),
416
+ branchEntries: [],
417
+ reason: "threshold",
418
+ willRetry: false,
419
+ signal: new AbortController().signal,
420
+ });
421
+ assert.equal(above?.cancel, undefined);
422
+ assert.ok(above?.compaction);
423
+
424
+ const overflow = handleExecutionBeforeCompact(pi, ctx, {
425
+ type: "session_before_compact",
426
+ preparation: makePreparation("overflow", null),
427
+ branchEntries: [],
428
+ reason: "overflow",
429
+ willRetry: true,
430
+ signal: new AbortController().signal,
431
+ });
432
+ assert.equal(overflow?.cancel, undefined);
433
+ assert.ok(overflow?.compaction);
434
+
435
+ const manual = handleExecutionBeforeCompact(pi, ctx, {
436
+ type: "session_before_compact",
437
+ preparation: makePreparation("manual", null),
438
+ branchEntries: [],
439
+ reason: "manual",
440
+ willRetry: false,
441
+ signal: new AbortController().signal,
442
+ });
443
+ assert.equal(manual?.cancel, undefined);
444
+ assert.ok(manual?.compaction);
445
+
446
+ await stopExecution(pi, ctx, "test-done");
447
+ });
448
+
449
+ it("triggers execution compaction at the high watermark, suppresses repeats, and re-arms below the low watermark", async () => {
450
+ const workdir = freshWorkdir();
451
+ const { pi, ctx, recorded, setUsagePercent } = makeHarness(workdir);
452
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v9.md"), items("VC-001", "VC-002"));
453
+
454
+ setUsagePercent(96);
455
+ handleExecutionTurnCompaction(ctx);
456
+ assert.equal(recorded.compacts?.length ?? 0, 1, "first high-watermark turn should request one compact");
457
+
458
+ handleExecutionTurnCompaction(ctx);
459
+ assert.equal(recorded.compacts?.length ?? 0, 1, "in-flight compact must not be requested twice");
460
+
461
+ handleExecutionCompact(pi, ctx, {
462
+ type: "session_compact",
463
+ compactionEntry: { type: "compaction" } as never,
464
+ fromExtension: true,
465
+ reason: "threshold",
466
+ willRetry: false,
467
+ });
468
+ assert.equal(
469
+ recorded.messages.filter((message) => message.customType === "pi-plans-exec-resume").length,
470
+ 1,
471
+ "non-retry execution compaction should queue one hidden resume",
472
+ );
473
+
474
+ setUsagePercent(96);
475
+ handleExecutionTurnCompaction(ctx);
476
+ assert.equal(recorded.compacts?.length ?? 0, 1, "resume guard should suppress the immediate follow-up turn");
477
+
478
+ setUsagePercent(79);
479
+ handleExecutionTurnCompaction(ctx);
480
+ assert.equal(recorded.compacts?.length ?? 0, 1, "low-watermark turns stay quiet while below re-arm");
481
+
482
+ setUsagePercent(96);
483
+ handleExecutionTurnCompaction(ctx);
484
+ assert.equal(recorded.compacts?.length ?? 0, 2, "high watermark should re-arm after the low-watermark drop");
485
+
486
+ await stopExecution(pi, ctx, "test-done");
487
+ });
488
+
489
+ it("skips proactive current-I compaction when no eligible prefix exists", async () => {
490
+ const workdir = freshWorkdir();
491
+ const { pi, ctx, recorded } = makeHarness(workdir);
492
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v22.md"), items("VC-001"), [
493
+ { id: "I-001", text: "First item." },
494
+ ]);
495
+ (ctx.sessionManager as any).getBranch = () => [
496
+ { id: "only", type: "message", tokens: 30000, message: { role: "assistant", content: [{ type: "text", text: "[I-001:current] one oversized turn" }] } },
497
+ ];
498
+ handleExecutionTurnCompaction(ctx);
499
+ assert.equal(recorded.compacts?.length ?? 0, 0);
500
+ await stopExecution(pi, ctx, "no-prefix");
501
+ });
502
+ it("wires execution compaction without restoring execution model helpers", () => {
503
+ const indexSource = fs.readFileSync(path.join(process.cwd(), "index.ts"), "utf8");
504
+ const execSource = fs.readFileSync(path.join(process.cwd(), "src/exec.ts"), "utf8");
505
+ assert.match(indexSource, /handleExecutionTurnCompaction/);
506
+ assert.match(execSource, /shouldTriggerExecutionCompaction/);
507
+ assert.match(execSource, /requestExecutionCompaction/);
508
+ assert.doesNotMatch(
509
+ indexSource,
510
+ /setExecutionModel|chooseExecutionModelSelection|snapshotCurrentModelSelector|ensureExecutionModelActive|restorePlanningModel/,
511
+ );
512
+ assert.doesNotMatch(
513
+ execSource,
514
+ /setExecutionModel|chooseExecutionModelSelection|snapshotCurrentModelSelector|ensureExecutionModelActive|restorePlanningModel/,
515
+ );
516
+ });
517
+
518
+ it("queues one non-retry resume, skips overflow retry, and keeps execution after failure", async () => {
519
+ const workdir = freshWorkdir();
520
+ const { pi, ctx, recorded } = makeHarness(workdir);
521
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v9.md"), items("VC-001"));
522
+
523
+ const beforeRequests = recorded.compacts?.length ?? 0;
524
+ handleExecutionCompact(pi, ctx, {
525
+ type: "session_compact",
526
+ compactionEntry: { type: "compaction" } as never,
527
+ fromExtension: true,
528
+ reason: "threshold",
529
+ willRetry: false,
530
+ });
531
+ const resumes = recorded.messages.filter((message) => message.customType === "pi-plans-exec-resume");
532
+ assert.equal(resumes.length, 1, "non-retry compaction should queue one hidden resume");
533
+ assert.equal(resumes[0]?.options?.triggerTurn, true);
534
+ assert.equal(recorded.compacts?.length ?? 0, beforeRequests, "compaction hook must not invoke ctx.compact");
535
+
536
+ handleExecutionCompact(pi, ctx, {
537
+ type: "session_compact",
538
+ compactionEntry: { type: "compaction" } as never,
539
+ fromExtension: true,
540
+ reason: "overflow",
541
+ willRetry: true,
542
+ });
543
+ assert.equal(
544
+ recorded.messages.filter((message) => message.customType === "pi-plans-exec-resume").length,
545
+ 1,
546
+ "overflow retry is owned by Pi core",
547
+ );
548
+
549
+ handleExecutionCompactFailed(pi, ctx, {
550
+ type: "session_compact_failed",
551
+ reason: "manual",
552
+ aborted: false,
553
+ willRetry: false,
554
+ fromExtension: true,
555
+ errorMessage: "boom",
556
+ });
557
+ assert.ok(getExecution(), "compaction failure must keep execution active");
558
+ assert.ok(recorded.notifies.some((note) => note.severity === "warning" && note.message.includes("execution remains active")));
559
+
560
+ await stopExecution(pi, ctx, "test-done");
561
+ });
562
+
563
+ it("builds a plan-aware summary with per-item sections and chains the previous summary", async () => {
564
+ const workdir = freshWorkdir();
565
+ const { pi, ctx, setUsagePercent } = makeHarness(workdir);
566
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v10.md"), items("VC-001", "VC-002"));
567
+
568
+ applyDoneMarkers("[DONE:VC-001]");
569
+
570
+ const previousSummary = "## Goal\nDeliver auto-compact in execution phase.\n\n## Finished Items\n- legacy VC-000 summary";
571
+ const preparation = makePreparation("threshold", previousSummary);
572
+ const branchEntries: any[] = [
573
+ { id: "exec-start", type: "custom", customType: "pi-plans-exec-start" },
574
+ { id: "u-1", type: "message", message: { role: "user", content: [{ type: "text", text: "implement VC-001" }] } },
575
+ { id: "a-1", type: "message", message: { role: "assistant", content: [{ type: "text", text: "wrote helper [DONE:VC-001]" }] } },
576
+ { id: "u-2", type: "message", message: { role: "user", content: [{ type: "text", text: "implement VC-002" }] } },
577
+ { id: "a-2", type: "message", message: { role: "assistant", content: [{ type: "text", text: "almost done" }] } },
578
+ { id: "exec-ctx", type: "custom", customType: "pi-plans-exec-context" },
579
+ ];
580
+ const result = buildExecutionCompactionResult(
581
+ {
582
+ type: "session_before_compact",
583
+ preparation,
584
+ branchEntries,
585
+ customInstructions: "keep current task visible",
586
+ reason: "threshold",
587
+ willRetry: false,
588
+ signal: new AbortController().signal,
589
+ },
590
+ ctx,
591
+ );
592
+ assert.ok(result);
593
+ assert.match(result!.summary, /## Compact Instructions/);
594
+ assert.match(result!.summary, /keep current task visible/);
595
+ assert.match(result!.summary, /## Plan Before This Run/);
596
+ assert.match(result!.summary, /## Previous Compact Summary/);
597
+ assert.match(result!.summary, /## Finished VC Items/);
598
+ assert.match(result!.summary, /### `VC-001`/);
599
+ assert.match(result!.summary, /## Current Work/);
600
+ assert.match(result!.summary, /Raw tail preserved from `u-2`/);
601
+ assert.deepEqual(result!.firstKeptEntryId, "u-2");
602
+
603
+ setUsagePercent(110);
604
+ handleExecutionBeforeCompact(pi, ctx, {
605
+ type: "session_before_compact",
606
+ preparation,
607
+ branchEntries,
608
+ reason: "threshold",
609
+ willRetry: false,
610
+ signal: new AbortController().signal,
611
+ });
612
+
613
+ await stopExecution(pi, ctx, "test-done");
614
+ });
615
+
616
+ it("uses the current model for bounded valid summaries and falls back on invalid output", async () => {
617
+ const workdir = freshWorkdir();
618
+ const { pi, ctx, recorded } = makeHarness(workdir);
619
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v21.md"), items("VC-001"), [
620
+ { id: "I-001", text: "First item." },
621
+ ]);
622
+ const signal = new AbortController().signal;
623
+ const calls: unknown[][] = [];
624
+ let response: any = {
625
+ stopReason: "stop",
626
+ content: [{ type: "text", text: "## Implementation Items\n- I-001\n\n## Current I\n- I-001\n\n## Read Records\n- none\n\n## Compaction Boundary\n- a-2\n\n## Decisions\n- preserved\n\n## Open Questions\n- none\n\n## Next Steps\n- continue" }],
627
+ usage: { input: 12, output: 34 },
628
+ };
629
+ (ctx.modelRegistry as any).complete = async (...args: unknown[]) => {
630
+ calls.push(args);
631
+ return response;
632
+ };
633
+ const event: any = {
634
+ type: "session_before_compact",
635
+ preparation: makePreparation("threshold", null),
636
+ branchEntries: [
637
+ { id: "i-1", type: "message", tokens: 100, message: { role: "assistant", content: [{ type: "text", text: "[I-001:current] work" }] } },
638
+ { id: "a-1", type: "message", tokens: 100, message: { role: "assistant", content: [{ type: "text", text: "details" }] } },
639
+ ],
640
+ reason: "threshold",
641
+ willRetry: false,
642
+ signal,
643
+ };
644
+ const valid = await handleExecutionBeforeCompact(pi, ctx, event);
645
+ assert.ok(valid?.compaction);
646
+ assert.equal(calls[0]?.[0], recorded.current);
647
+ assert.equal((calls[0]?.[2] as any).signal, signal);
648
+ assert.equal((calls[0]?.[2] as any).cacheRetention, "none");
649
+ assert.ok((calls[0]?.[2] as any).maxTokens <= 2048);
650
+ assert.deepEqual(valid?.compaction?.usage, response.usage);
651
+ assert.equal((valid?.compaction?.details as any)?.metrics?.summaryTokens, Math.ceil(response.content[0].text.length / 4));
652
+
653
+ response = { stopReason: "length", content: [{ type: "text", text: "## Implementation Items\npartial" }] };
654
+ const invalid = await handleExecutionBeforeCompact(pi, ctx, { ...event, signal: new AbortController().signal });
655
+ assert.equal(invalid, undefined, "incomplete model output must return control to Pi default compaction");
656
+ await stopExecution(pi, ctx, "model-test");
657
+ }); it("filters the hidden resume message out of the LLM context payload", () => {
658
+ const messages = [
659
+ { customType: "user", content: "real prompt" },
660
+ { customType: "pi-plans-exec-resume", content: "Continue execution." },
661
+ { customType: "pi-plans-plan-resume", content: "Continue planning." },
662
+ { customType: "assistant", content: "ok" },
663
+ ];
664
+ assert.equal(filterExecutionResumeMessages(messages).length, 3);
665
+ assert.equal(filterPlanningResumeMessages(messages).length, 3);
666
+ });
667
+
668
+ it("planning compaction cuts at plan-written when present and falls back to run-start", () => {
669
+ const workdir = freshWorkdir();
670
+ const { ctx } = makeHarness(workdir);
671
+ initState(workdir);
672
+ const { run } = startRun(workdir, { topic: "planning compact", skill: "plan-normal", requestText: "demo" });
673
+ fs.writeFileSync(path.join(run.artifact_dir, "PLAN_v1.md"), "# plan");
674
+
675
+ const makePlanningPreparation = (previousSummary: string | null) => ({
676
+ firstKeptEntryId: "fallback",
677
+ messagesToSummarize: [],
678
+ turnPrefixMessages: [],
679
+ isSplitTurn: false,
680
+ tokensBefore: 50000,
681
+ previousSummary,
682
+ fileOps: { read: [], written: [], edited: [] },
683
+ settings: { enabled: true, reserveTokens: 16384, keepRecentTokens: 20000 },
684
+ });
685
+
686
+ // Case A: plan written → cut at next non-internal entry after plan-written; QA section included.
687
+ const branchEntriesWithPlan = [
688
+ { id: "rs", type: "custom", customType: PLANNING_RUN_START_CUSTOM_TYPE, data: { runId: run.run_id, artifactDir: run.artifact_dir } },
689
+ { id: "u-1", type: "message", message: { role: "user", content: [{ type: "text", text: "background question?" }] } },
690
+ { id: "a-1", type: "message", message: { role: "assistant", content: [{ type: "text", text: "some context" }] } },
691
+ { id: "pw", type: "custom", customType: PLANNING_PLAN_WRITTEN_CUSTOM_TYPE, data: { runId: run.run_id, planPath: path.join(run.artifact_dir, "PLAN_v1.md") } },
692
+ { id: "u-2", type: "message", message: { role: "user", content: [{ type: "text", text: "review please" }] } },
693
+ ] as any;
694
+ const withPlan = buildPlanningCompactionResult(
695
+ {
696
+ type: "session_before_compact",
697
+ preparation: makePlanningPreparation(null),
698
+ branchEntries: branchEntriesWithPlan,
699
+ reason: "threshold",
700
+ willRetry: false,
701
+ signal: new AbortController().signal,
702
+ },
703
+ ctx,
704
+ );
705
+ assert.ok(withPlan);
706
+ assert.deepEqual(withPlan!.firstKeptEntryId, "u-2");
707
+ assert.match(withPlan!.summary, /## Q&A During Planning/);
708
+ assert.match(withPlan!.summary, /background question?/);
709
+
710
+ // Case B: only run-start → cut at first non-internal entry after marker; no QA section.
711
+ const branchEntriesWithoutPlan = [
712
+ { id: "rs", type: "custom", customType: PLANNING_RUN_START_CUSTOM_TYPE, data: { runId: run.run_id, artifactDir: run.artifact_dir } },
713
+ { id: "u-1", type: "message", message: { role: "user", content: [{ type: "text", text: "open question" }] } },
714
+ { id: "a-1", type: "message", message: { role: "assistant", content: [{ type: "text", text: "thinking out loud" }] } },
715
+ ] as any;
716
+ const withoutPlan = buildPlanningCompactionResult(
717
+ {
718
+ type: "session_before_compact",
719
+ preparation: makePlanningPreparation(null),
720
+ branchEntries: branchEntriesWithoutPlan,
721
+ reason: "manual",
722
+ willRetry: false,
723
+ signal: new AbortController().signal,
724
+ },
725
+ ctx,
726
+ );
727
+ assert.ok(withoutPlan);
728
+ assert.deepEqual(withoutPlan!.firstKeptEntryId, "u-1");
729
+ assert.doesNotMatch(withoutPlan!.summary, /## Q&A During Planning/);
730
+
731
+ // Case C: no markers → fallback to preparation.firstKeptEntryId and no QA section.
732
+ const fallback = buildPlanningCompactionResult(
733
+ {
734
+ type: "session_before_compact",
735
+ preparation: makePlanningPreparation("## Previous\nEarlier summary."),
736
+ branchEntries: [],
737
+ reason: "threshold",
738
+ willRetry: false,
739
+ signal: new AbortController().signal,
740
+ },
741
+ ctx,
742
+ );
743
+ assert.ok(fallback);
744
+ assert.deepEqual(fallback!.firstKeptEntryId, "fallback");
745
+ assert.doesNotMatch(fallback!.summary, /## Q&A During Planning/);
746
+ assert.match(fallback!.summary, /## Previous Compact Summary/);
747
+ });
748
+
749
+ it("planning hook is gated by run.status=planning and defers to execution hook when execution is running", async () => {
750
+ const workdir = freshWorkdir();
751
+ const { ctx, setUsagePercent } = makeHarness(workdir);
752
+ initState(workdir);
753
+ const { run } = startRun(workdir, { topic: "planning gate", skill: "plan-small", requestText: "x" });
754
+
755
+ setUsagePercent(110);
756
+ const resultPlanning = handlePlanningBeforeCompact({} as any, ctx as any, {
757
+ type: "session_before_compact",
758
+ preparation: {
759
+ firstKeptEntryId: "fb",
760
+ messagesToSummarize: [],
761
+ turnPrefixMessages: [],
762
+ isSplitTurn: false,
763
+ tokensBefore: 1,
764
+ previousSummary: null,
765
+ fileOps: { read: [], written: [], edited: [] },
766
+ settings: { enabled: true, reserveTokens: 16384, keepRecentTokens: 20000 },
767
+ },
768
+ branchEntries: [],
769
+ reason: "threshold",
770
+ willRetry: false,
771
+ signal: new AbortController().signal,
772
+ });
773
+ assert.ok(resultPlanning?.compaction || resultPlanning === undefined);
774
+
775
+ // Flip status to done; planning hook should refuse.
776
+ setRunStatus(workdir, run.run_id, "done");
777
+ setUsagePercent(110);
778
+ const resultDone = handlePlanningBeforeCompact({} as any, ctx as any, {
779
+ type: "session_before_compact",
780
+ preparation: {
781
+ firstKeptEntryId: "fb",
782
+ messagesToSummarize: [],
783
+ turnPrefixMessages: [],
784
+ isSplitTurn: false,
785
+ tokensBefore: 1,
786
+ previousSummary: null,
787
+ fileOps: { read: [], written: [], edited: [] },
788
+ settings: { enabled: true, reserveTokens: 16384, keepRecentTokens: 20000 },
789
+ },
790
+ branchEntries: [],
791
+ reason: "threshold",
792
+ willRetry: false,
793
+ signal: new AbortController().signal,
794
+ });
795
+ assert.equal(resultDone, undefined);
796
+ });
797
+
798
+ it("turn_end writes are unconditionally deferred even when isIdle reads true", async () => {
799
+ const workdir = freshWorkdir();
800
+ const { pi, ctx, recorded, setUsagePercent } = makeHarness(workdir);
801
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v11.md"), items("VC-001", "VC-002"));
802
+ const snapshotCount = () => recorded.entries.filter((e) => e.customType === "pi-plans-exec").length;
803
+ const baseline = snapshotCount();
804
+
805
+ // No isIdle override: the harness default (() => true) IS the real
806
+ // turn_end reading — this encodes the field regression (60 writes in
807
+ // 23 minutes) as a permanent zero-write assertion.
808
+ recordExecutionTurn(pi, ctx, [], { input: 10, output: 5 });
809
+ setUsagePercent(50);
810
+ handleExecutionBeforeCompact(pi, ctx, {
811
+ type: "session_before_compact",
812
+ preparation: makePreparation("threshold", null),
813
+ branchEntries: [],
814
+ reason: "threshold",
815
+ willRetry: false,
816
+ signal: new AbortController().signal,
817
+ });
818
+ assert.equal(snapshotCount(), baseline, "turn_end wrote session entries despite deferral");
819
+ // Status line stays real-time while the write is deferred.
820
+ assert.match(recorded.status ?? "", /10 in-toks/);
821
+
822
+ drainExecutionFlush(pi, ctx);
823
+ assert.equal(snapshotCount(), baseline + 1, "settle flush did not write exactly one snapshot");
824
+ const last = (recorded.entries.filter((e) => e.customType === "pi-plans-exec").at(-1)?.data ?? {}) as { usage?: { inToks: number } };
825
+ assert.equal(last.usage?.inToks, 10, "flushed snapshot missing busy-turn usage");
826
+ drainExecutionFlush(pi, ctx);
827
+ assert.equal(snapshotCount(), baseline + 1, "second drain wrote again");
828
+ assert.equal(consumePendingExecutionFlush(), false);
829
+
830
+ await stopExecution(pi, ctx, "done");
831
+ });
832
+
833
+ it("stop and complete drain pending writes synchronously with final state", async () => {
303
834
  const workdir = freshWorkdir();
304
835
  const { pi, ctx, recorded } = makeHarness(workdir);
305
- startExecution(pi, ctx, path.join(workdir, "PLAN_v3.md"), items("VC-001", "VC-002"));
306
-
307
- const entriesBefore = recorded.entries.length;
308
- const factoriesBefore = recorded.widgetCalls;
309
-
310
- // Mid-turn: flip must be pure memory — no session writes, no re-register.
311
- (ctx as any).isIdle = () => false;
312
- assert.equal(toggleExecutionPanelView(pi, ctx), true);
313
- assert.equal(recorded.entries.length, entriesBefore, "busy toggle appended a session entry");
314
- assert.equal(recorded.widgetCalls, factoriesBefore, "busy toggle re-registered the widget");
315
- assert.equal(consumePendingPanelSync(), true, "expected a pending panel sync marker");
316
- assert.equal(consumePendingPanelSync(), false, "marker should be consumed exactly once");
317
-
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).
320
- ctx.isIdle = () => true;
321
- assert.equal(toggleExecutionPanelView(pi, ctx), false);
322
- assert.ok(recorded.entries.length > entriesBefore, "idle toggle did not persist");
323
- assert.equal(recorded.widgetCalls, factoriesBefore + 1, "idle toggle churned the widget registration");
324
-
325
- stopExecution(pi, ctx, "test-done");
836
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v12.md"), items("VC-001", "VC-002"));
837
+ const snapshotCount = () => recorded.entries.filter((e) => e.customType === "pi-plans-exec").length;
838
+
839
+ ctx.isIdle = () => false;
840
+ recordExecutionTurn(pi, ctx, [], { input: 7, output: 3 });
841
+ const busyCount = snapshotCount();
842
+
843
+ await stopExecution(pi, ctx, "force");
844
+ assert.ok(snapshotCount() > busyCount, "stop did not write the final snapshot");
845
+ assert.ok(recorded.entries.some((e) => e.customType === "pi-plans-exec-cleared"));
846
+ const lastStop = (recorded.entries.filter((e) => e.customType === "pi-plans-exec").at(-1)?.data ?? {}) as { usage?: { inToks: number } };
847
+ assert.equal(lastStop.usage?.inToks, 7, "stop lost the busy-turn usage");
848
+
849
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v13.md"), items("VC-001"));
850
+ ctx.isIdle = () => false;
851
+ applyDoneMarkers("[DONE:VC-001]");
852
+ recordExecutionTurn(pi, ctx, ["VC-001"], { input: 1, output: 1 });
853
+ await completeExecution(pi, ctx);
854
+ assert.equal(getExecution(), null);
855
+ const lastComplete = (recorded.entries.filter((e) => e.customType === "pi-plans-exec").at(-1)?.data ?? {}) as { items?: Array<{ done: boolean }> };
856
+ assert.equal(lastComplete.items?.[0]?.done, true, "completion snapshot missing final done state");
857
+ });
858
+
859
+ it("updates the status bar in real time on every turn without session writes", async () => {
860
+ const workdir = freshWorkdir();
861
+ const { pi, ctx, recorded, setUsagePercent } = makeHarness(workdir);
862
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v15.md"), items("VC-001", "VC-002"), [
863
+ { id: "I-001", text: "First item." },
864
+ { id: "I-002", text: "Second item." },
865
+ ]);
866
+ const snapshotCount = () => recorded.entries.filter((e) => e.customType === "pi-plans-exec").length;
867
+ const baseline = snapshotCount();
868
+
869
+ recordExecutionTurn(pi, ctx, [], { input: 33, output: 11 });
870
+ // Real-time: status line already reflects the turn's usage...
871
+ assert.match(recorded.status ?? "", /33 in-toks/);
872
+ assert.match(recorded.status ?? "", /⌛ plans 0\/2: spent/);
873
+ // ...without any session write (anti-jitter preserved).
874
+ assert.equal(snapshotCount(), baseline);
875
+
876
+ setUsagePercent(null);
877
+ drainExecutionFlush(pi, ctx);
878
+ assert.equal(snapshotCount(), baseline + 1);
879
+
880
+ await stopExecution(pi, ctx, "done");
881
+ });
882
+
883
+ it("syncs progress through the registered message_end and turn_end handlers", async () => {
884
+ const workdir = freshWorkdir();
885
+ const { pi, ctx, recorded, emit } = makeHarness(workdir);
886
+ registerExecutionTurnHandlers(pi);
887
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v19.md"), items("VC-001", "VC-002", "VC-003"));
888
+
889
+ // The usage is delivered by message_end and consumed by the following
890
+ // turn_end, matching Pi's lifecycle contract.
891
+ await emit("message_end", {
892
+ message: { role: "assistant", usage: { input: 12, output: 5 } },
893
+ });
894
+ await emit("turn_end", {
895
+ message: { role: "assistant", content: [{ type: "text", text: "verified [DONE:VC-001]" }] },
896
+ });
897
+ assert.match(recorded.status ?? "", /⌛ plans 1\/3: spent/);
898
+ assert.match(recorded.status ?? "", /12 in-toks/);
899
+
900
+ await emit("message_end", {
901
+ message: { role: "assistant", usage: { input: 8, output: 3 } },
902
+ });
903
+ await emit("turn_end", {
904
+ message: { role: "assistant", content: [{ type: "text", text: "verified [DONE:VC-002]" }] },
905
+ });
906
+ assert.match(recorded.status ?? "", /⌛ plans 2\/3: spent/);
907
+ assert.match(recorded.status ?? "", /20 in-toks/);
908
+
909
+ await stopExecution(pi, ctx, "event-chain-test");
910
+ });
911
+ it("applies impl markers with silent unknown ids and later-overwrite semantics", async () => {
912
+ const workdir = freshWorkdir();
913
+ const { pi, ctx } = makeHarness(workdir);
914
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v16.md"), items("VC-001"), [
915
+ { id: "I-001", text: "First item." },
916
+ ]);
917
+
918
+ assert.deepEqual(applyImplMarkers("[I-999:implemented]"), []); // unknown id silently ignored
919
+ assert.deepEqual(applyImplMarkers("[I-001:implemented]"), ["I-001"]);
920
+ assert.deepEqual(applyImplMarkers("[I-001:validating]"), ["I-001"]); // later overwrites
921
+ assert.equal(getExecution()?.implStatus?.["I-001"], "validating");
922
+
923
+ await stopExecution(pi, ctx, "done");
924
+ });
925
+
926
+ it("applies current-I markers to the live progress bar and snapshot", async () => {
927
+ const workdir = freshWorkdir();
928
+ const { pi, ctx, recorded, emit } = makeHarness(workdir);
929
+ registerExecutionTurnHandlers(pi);
930
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v20.md"), items("VC-001", "VC-002", "VC-003"), [
931
+ { id: "I-001", text: "First item." },
932
+ { id: "I-002", text: "Second item." },
933
+ { id: "I-003", text: "Third item." },
934
+ ]);
935
+
936
+ await emit("turn_end", {
937
+ message: { role: "assistant", content: [{ type: "text", text: "starting [I-003:current]" }] },
938
+ });
939
+ assert.match(recorded.status ?? "", /⌛ plans 2\/3: spent/);
940
+ assert.equal(getExecution()?.currentI, "I-003");
941
+ drainExecutionFlush(pi, ctx);
942
+ const snapshot = recorded.entries.filter((entry) => entry.customType === "pi-plans-exec").at(-1)?.data as { currentI?: string };
943
+ assert.equal(snapshot.currentI, "I-003");
944
+ assert.equal(applyCurrentIMarker("[I-999:current]"), false);
945
+ await stopExecution(pi, ctx, "current-I-test");
946
+ });
947
+ it("replays impl markers from post-snapshot messages on restore", async () => {
948
+ const workdir = freshWorkdir();
949
+ const { pi, ctx } = makeHarness(workdir);
950
+ const snapshot = {
951
+ planPath: path.join(workdir, "PLAN_v17.md"),
952
+ items: items("VC-001"),
953
+ startedAt: "2026-08-29T00:00:00Z",
954
+ implItems: [{ id: "I-001", text: "First item." }],
955
+ implStatus: {},
956
+ };
957
+ fs.writeFileSync(snapshot.planPath, "# plan");
958
+ const entries = [
959
+ { type: "custom", customType: "pi-plans-exec", data: snapshot },
960
+ { type: "message", message: { role: "assistant", content: [{ type: "text", text: "work done [I-001:implemented]" }] } },
961
+ ];
962
+ await restoreFromSession(pi, ctx, entries as any);
963
+ assert.equal(getExecution()?.implStatus?.["I-001"], "implemented");
964
+ });
965
+
966
+
967
+ it("keeps the injection rules teaching the impl markers", async () => {
968
+ const workdir = freshWorkdir();
969
+ const { pi, ctx } = makeHarness(workdir);
970
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v18.md"), items("VC-001"), [
971
+ { id: "I-001", text: "First item." },
972
+ ]);
973
+ const rules = executionContextMessage()!;
974
+ assert.match(rules, /\[I-001:implemented\]/);
975
+ assert.match(rules, /\[I-001:validating\]/);
976
+ assert.match(rules, /subprocess-backed verification/);
977
+ assert.match(rules, /waiting for/);
978
+ assert.match(rules, /5s\s*->\s*10s\s*->\s*20s\s*->\s*40s\s*->\s*80s/);
979
+ assert.match(rules, /keep polling at 80s/);
980
+ assert.match(rules, /restart at 5s for each new subprocess/);
981
+ await stopExecution(pi, ctx, "done");
982
+ });
983
+
984
+ it("planning compaction honors cooldown + resume guard and survives manual /compact", () => {
985
+ const workdir = freshWorkdir();
986
+ const { pi, ctx, setUsagePercent, recorded } = makeHarness(workdir);
987
+ initState(workdir);
988
+ startRun(workdir, { topic: "planning cooldown", skill: "plan-normal", requestText: "x" });
989
+
990
+ setUsagePercent(120);
991
+ assert.equal(shouldTriggerPlanningCompaction(ctx as any), true);
992
+ requestPlanningCompaction(ctx as any);
993
+ // In flight, second trigger ignored.
994
+ requestPlanningCompaction(ctx as any);
995
+ assert.equal(shouldTriggerPlanningCompaction(ctx as any), false);
996
+
997
+ setUsagePercent(50);
998
+ handlePlanningCompact(pi as any, ctx as any, {
999
+ type: "session_compact",
1000
+ compactionEntry: { type: "compaction" } as never,
1001
+ fromExtension: true,
1002
+ reason: "manual",
1003
+ willRetry: false,
1004
+ });
1005
+ const resume = recorded.messages.find((message) => message.customType === "pi-plans-plan-resume");
1006
+ assert.ok(resume);
1007
+ assert.equal(consumePlanningCompactionResumeGuard(ctx as any), true);
1008
+ // Cooldown blocks retrigger while usage is still mid-band.
1009
+ setUsagePercent(95);
1010
+ assert.equal(shouldTriggerPlanningCompaction(ctx as any), false);
1011
+ setUsagePercent(50);
1012
+ refreshPlanningCompactionCooldown(ctx as any);
1013
+ setUsagePercent(120);
1014
+ assert.equal(shouldTriggerPlanningCompaction(ctx as any), true);
326
1015
  });
327
1016
  });
1017
+
1018
+ function makePreparation(reason: "manual" | "threshold" | "overflow", previousSummary: string | null): any {
1019
+ return {
1020
+ firstKeptEntryId: "a-2",
1021
+ messagesToSummarize: [],
1022
+ turnPrefixMessages: [],
1023
+ isSplitTurn: false,
1024
+ tokensBefore: 12345,
1025
+ previousSummary,
1026
+ fileOps: { read: [], written: [], edited: [] },
1027
+ settings: { enabled: true, reserveTokens: 16384, keepRecentTokens: 20000 },
1028
+ };
1029
+ }