pi-plans 0.1.0 → 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,44 +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,
37
+ recordExecutionTurn,
38
+ registerExecutionTurnHandlers,
17
39
  stopExecution,
40
+ chooseExecutionModelSelection,
18
41
  toggleExecutionPanelView,
42
+ updateStatusWidget,
19
43
  } from "../src/exec.ts";
20
44
  import type { CheckItem } from "../src/plan.ts";
45
+ import { initState, setExecutionModel, setRunStatus, showConfig, startRun } from "../src/state.ts";
21
46
 
22
47
  interface Recorded {
23
48
  entries: { type: string; customType?: string; data?: unknown }[];
24
- messages: { customType: string; content: string }[];
49
+ messages: { customType: string; content: string; options?: { triggerTurn?: boolean } }[];
25
50
  status: string | undefined;
51
+ statusCalls: number;
52
+ colors: string[];
26
53
  widget?: { key: string; options?: unknown; factory: any };
27
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 }[];
28
63
  }
29
64
 
30
65
  interface Harness {
31
66
  pi: any;
32
67
  ctx: any;
33
68
  recorded: Recorded;
69
+ emit: (eventName: string, event: unknown) => Promise<unknown[]>;
34
70
  }
35
71
 
36
72
  function makeHarness(workdir: string): Harness {
37
- const recorded: Recorded = { entries: [], messages: [], status: undefined, 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
+ };
38
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: () => {},
39
110
  appendEntry: (customType: string, data: unknown) => {
40
111
  recorded.entries.push({ type: "custom", customType, data });
41
112
  },
42
- sendMessage: (message: { customType: string; content: string }) => {
43
- 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;
44
125
  },
45
126
  };
46
127
  const ui = {
47
128
  setStatus: (_key: string, value: string | undefined) => {
129
+ recorded.statusCalls += 1;
48
130
  recorded.status = value;
49
131
  },
50
132
  setWidget: (key: string, factory: any, options?: unknown) => {
@@ -55,17 +137,53 @@ function makeHarness(workdir: string): Harness {
55
137
  }
56
138
  recorded.widget = { key, options, factory };
57
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
+ },
58
147
  theme: {
59
- fg: (_color: string, text: string) => text,
148
+ fg: (color: string, text: string) => {
149
+ recorded.colors.push(color);
150
+ return text;
151
+ },
60
152
  strikethrough: (text: string) => `~~${text}~~`,
61
153
  },
62
154
  };
155
+ const sessionManager: any = {};
63
156
  const ctx = {
64
157
  cwd: workdir,
65
158
  ui,
66
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
+ },
67
185
  };
68
- return { pi, ctx, recorded };
186
+ return { pi, ctx, recorded, emit, setUsagePercent: ctx.setUsagePercent } as Harness & { setUsagePercent: (percent: number | null) => void };
69
187
  }
70
188
 
71
189
  function items(...ids: string[]): CheckItem[] {
@@ -91,47 +209,61 @@ describe("execution loop", () => {
91
209
  return workdir;
92
210
  }
93
211
 
94
- it("tracks done markers and completes", () => {
212
+ it("tracks done markers and completes", async () => {
95
213
  const workdir = freshWorkdir();
96
214
  const { pi, ctx, recorded } = makeHarness(workdir);
97
- startExecution(pi, ctx, path.join(workdir, "PLAN_v1.md"), items("VC-001", "VC-002"));
98
- assert.ok(recorded.widget);
99
- assert.equal(recorded.widget?.key, "pi-plans-execution");
100
- assert.deepEqual(recorded.widget?.options, { placement: "belowEditor" });
101
- const widget = recorded.widget?.factory(
102
- {} as any,
103
- { fg: (_color: string, text: string) => text, strikethrough: (text: string) => `~~${text}~~` },
104
- );
105
- assert.ok(widget);
106
- const rendered = widget.render(80);
107
- assert.match(rendered[0] ?? "", /alt\+o/);
108
- assert.match(rendered[0] ?? "", /📋 plans 0\/2/);
215
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v1.md"), items("VC-001", "VC-002"));
216
+
217
+ // Collapsed by default: progress lives in the bottom status bar (same
218
+ // layer as the ⛔/⌛ indicators); no panel widget is registered yet.
219
+ assert.equal(recorded.widget, undefined);
220
+ assert.match(recorded.status ?? "", /⌛ plans 0\/2: spent \d{2}:\d{2}:\d{2}/);
221
+ assert.match(recorded.status ?? "", /in-toks/);
222
+ assert.match(recorded.status ?? "", /out-toks/);
109
223
 
110
224
  toggleExecutionPanelView(pi, ctx);
111
225
  assert.ok(recorded.widget);
226
+ assert.equal(recorded.widget?.key, "pi-plans-execution");
227
+ assert.deepEqual(recorded.widget?.options, { placement: "belowEditor" });
228
+ // Expanded: footer ⌛ line is cleared — the panel renders its own header.
229
+ assert.equal(recorded.status, undefined);
112
230
  const expandedWidget = recorded.widget?.factory(
113
231
  {} as any,
114
232
  { fg: (_color: string, text: string) => text, strikethrough: (text: string) => `~~${text}~~` },
115
233
  );
116
234
  assert.ok(expandedWidget);
117
235
  const expandedLines = expandedWidget.render(80);
236
+ // Header line first, then the legacy item list.
237
+ assert.match(expandedLines[0] ?? "", /⌛ plans 0\/2: spent/);
118
238
  assert.match(expandedLines.join("\n"), /☐/);
239
+ // Detail view never repeats the keyboard hint.
240
+ assert.doesNotMatch(expandedLines.join("\n"), /alt\+o/);
119
241
 
120
242
  assert.ok(getExecution());
121
- assert.match(executionContextMessage()!, /PI-PLANS EXECUTION/);
122
- assert.match(executionContextMessage()!, /VC-001/);
243
+ const rules = executionContextMessage()!;
244
+ assert.match(rules, /PI-PLANS EXECUTION/);
245
+ assert.match(rules, /VC-001/);
246
+ // Seven-principle rule set: representative anchors (PLAN_v2 D-003/D-004).
247
+ assert.match(rules, /for the long term/);
248
+ assert.match(rules, /Simplest implementation/);
249
+ assert.match(rules, /grow the change in layers/);
250
+ assert.match(rules, /existing dependencies \(docs and types\)/);
251
+ assert.match(rules, /well-maintained libraries/);
252
+ assert.match(rules, /clearly separated concerns/);
253
+ assert.match(rules, /no stopgaps/);
254
+ assert.doesNotMatch(rules, /ponytail/i);
123
255
 
124
256
  assert.deepEqual(applyDoneMarkers("progress… [DONE:VC-001] done"), ["VC-001"]);
125
257
  assert.equal(isExecutionComplete(), false);
126
258
  assert.deepEqual(applyDoneMarkers("final: [DONE:VC-002]"), ["VC-002"]);
127
259
  assert.equal(isExecutionComplete(), true);
128
260
 
129
- completeExecution(pi, ctx);
261
+ await completeExecution(pi, ctx);
130
262
  assert.equal(getExecution(), null);
131
263
  assert.ok(recorded.messages.some((message) => message.customType === "pi-plans-complete"));
132
264
  });
133
265
 
134
- it("restores progress from session entries and rescans messages", () => {
266
+ it("restores progress from session entries and rescans messages", async () => {
135
267
  const workdir = freshWorkdir();
136
268
  const { pi, ctx, recorded } = makeHarness(workdir);
137
269
  const snapshot = {
@@ -147,7 +279,15 @@ describe("execution loop", () => {
147
279
  "VC-001": {
148
280
  summary: { added: 1, removed: 0, files: 1, paths: ["src/exec.ts"] },
149
281
  },
150
- },
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",
151
291
  },
152
292
  };
153
293
  fs.writeFileSync(snapshot.planPath, "# plan");
@@ -158,9 +298,10 @@ describe("execution loop", () => {
158
298
  message: { role: "assistant", content: [{ type: "text", text: "did [DONE:VC-001]" }] },
159
299
  },
160
300
  ];
161
- restoreFromSession(pi, ctx, entries as any);
301
+ await restoreFromSession(pi, ctx, entries as any);
162
302
  const execution = getExecution();
163
303
  assert.ok(execution);
304
+ assert.equal("compaction" in execution!, false, "legacy scheduler state must not reactivate on restore");
164
305
  assert.ok(recorded.widget);
165
306
  const widget = recorded.widget?.factory(
166
307
  {} as any,
@@ -172,11 +313,11 @@ describe("execution loop", () => {
172
313
  assert.match(rendered.join("\n"), /\+1/);
173
314
 
174
315
  const clearedEntries = [...entries, { type: "custom", customType: "pi-plans-exec-cleared", data: {} }];
175
- restoreFromSession(pi, ctx, clearedEntries as any);
316
+ await restoreFromSession(pi, ctx, clearedEntries as any);
176
317
  assert.equal(getExecution(), null);
177
318
  });
178
319
 
179
- it("ignores restore when the plan file vanished", () => {
320
+ it("ignores restore when the plan file vanished", async () => {
180
321
  const workdir = freshWorkdir();
181
322
  const { pi, ctx } = makeHarness(workdir);
182
323
  const entries = [
@@ -190,41 +331,198 @@ describe("execution loop", () => {
190
331
  },
191
332
  },
192
333
  ];
193
- restoreFromSession(pi, ctx, entries as any);
334
+ await restoreFromSession(pi, ctx, entries as any);
194
335
  assert.equal(getExecution(), null);
195
336
  });
196
337
 
197
- it("stop clears execution", () => {
338
+ it("stop clears execution", async () => {
198
339
  const workdir = freshWorkdir();
199
340
  const { pi, ctx } = makeHarness(workdir);
200
- startExecution(pi, ctx, path.join(workdir, "PLAN_v1.md"), items("VC-001"));
201
- stopExecution(pi, ctx, "test");
341
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v1.md"), items("VC-001"));
342
+ await stopExecution(pi, ctx, "test");
202
343
  assert.equal(getExecution(), null);
203
344
  });
204
345
 
205
- 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 () => {
206
347
  const workdir = freshWorkdir();
207
348
  const { pi, ctx, recorded } = makeHarness(workdir);
208
- startExecution(pi, ctx, path.join(workdir, "PLAN_v2.md"), items("VC-001", "VC-002"));
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
+ });
209
353
 
210
- // The below-editor panel owns progress; the status item must be cleared,
211
- // never carrying a duplicate “📋 plans x/y” count (regression: double display).
212
- assert.equal(recorded.status, undefined);
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 () => {
439
+ const workdir = freshWorkdir();
440
+ const { pi, ctx, recorded } = makeHarness(workdir);
441
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v2.md"), items("VC-001", "VC-002"));
442
+
443
+ // Bottom status bar carries the count — the same layer as ⛔/⌛ — so both
444
+ // execution states read from one consistent place.
445
+ assert.match(recorded.status ?? "", /⌛ plans 0\/2: spent \d{2}:\d{2}:\d{2}/);
446
+ assert.match(recorded.status ?? "", /in-toks/);
213
447
 
214
448
  const start = recorded.messages.find((message) => message.customType === "pi-plans-exec-start");
215
449
  assert.ok(start);
216
- assert.match(start.content, /Progress appears below the editor/);
450
+ assert.match(start.content, /Progress appears in the bottom status bar/);
217
451
  assert.doesNotMatch(start.content, /footer/);
218
452
 
219
453
  applyDoneMarkers("[DONE:VC-001]");
220
- completeExecution(pi, ctx);
454
+ await completeExecution(pi, ctx);
221
455
  assert.equal(getExecution(), null);
456
+
222
457
  });
223
458
 
224
- it("defers persistence and widget churn when toggling mid-turn", () => {
459
+ it("renders the idle indicator by run status", () => {
460
+ const workdir = freshWorkdir();
461
+ const { ctx, recorded } = makeHarness(workdir);
462
+ initState(workdir);
463
+ const { run } = startRun(workdir, { topic: "demo", skill: "plan-small", requestText: "x" });
464
+
465
+ // planning before any PLAN draft exists: 💬 (Q&A phase).
466
+ updateStatusWidget(ctx);
467
+ assert.match(recorded.status ?? "", /💬 plans: /);
468
+ assert.equal(recorded.colors.at(-1), "muted");
469
+
470
+ // Once a draft lands: 📝, kept until execution starts.
471
+ fs.writeFileSync(path.join(run.artifact_dir, "PLAN_v1.md"), "# plan");
472
+ updateStatusWidget(ctx);
473
+ assert.match(recorded.status ?? "", /📝 plans: /);
474
+ assert.equal(recorded.colors.at(-1), "muted");
475
+
476
+ setRunStatus(workdir, run.run_id, "accepted");
477
+ updateStatusWidget(ctx);
478
+ assert.match(recorded.status ?? "", /⌛ plans: /);
479
+ assert.equal(recorded.colors.at(-1), "warning");
480
+
481
+ setRunStatus(workdir, run.run_id, "stopped");
482
+ updateStatusWidget(ctx);
483
+ assert.match(recorded.status ?? "", /⛔ plans: /);
484
+ assert.equal(recorded.colors.at(-1), "warning");
485
+
486
+ setRunStatus(workdir, run.run_id, "done");
487
+ updateStatusWidget(ctx);
488
+ assert.match(recorded.status ?? "", /🎯 plans: .*\(done\)/);
489
+ assert.equal(recorded.colors.at(-1), "success");
490
+
491
+ setRunStatus(workdir, run.run_id, "abandoned");
492
+ updateStatusWidget(ctx);
493
+ assert.match(recorded.status ?? "", /🚫 plans: /);
494
+ assert.equal(recorded.colors.at(-1), "error");
495
+ });
496
+
497
+ it("accumulates token usage on token-only and completion turns", async () => {
225
498
  const workdir = freshWorkdir();
226
499
  const { pi, ctx, recorded } = makeHarness(workdir);
227
- startExecution(pi, ctx, path.join(workdir, "PLAN_v3.md"), items("VC-001", "VC-002"));
500
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v5.md"), items("VC-001", "VC-002"));
501
+
502
+ recordExecutionTurn(pi, ctx, [], { input: 100, output: 40 });
503
+ updateStatusWidget(ctx);
504
+ assert.equal(getExecution()?.usage.inToks, 100);
505
+ assert.equal(getExecution()?.usage.outToks, 40);
506
+ assert.match(recorded.status ?? "", /100 in-toks/);
507
+ assert.match(recorded.status ?? "", /40 out-toks/);
508
+
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/);
517
+
518
+ await stopExecution(pi, ctx, "test-done");
519
+ assert.equal(getExecution(), null);
520
+ });
521
+
522
+ it("defers persistence and widget churn when toggling mid-turn", async () => {
523
+ const workdir = freshWorkdir();
524
+ const { pi, ctx, recorded } = makeHarness(workdir);
525
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v3.md"), items("VC-001", "VC-002"));
228
526
 
229
527
  const entriesBefore = recorded.entries.length;
230
528
  const factoriesBefore = recorded.widgetCalls;
@@ -237,13 +535,590 @@ describe("execution loop", () => {
237
535
  assert.equal(consumePendingPanelSync(), true, "expected a pending panel sync marker");
238
536
  assert.equal(consumePendingPanelSync(), false, "marker should be consumed exactly once");
239
537
 
240
- // Back to idle: the next toggle persists and syncs, but still reuses the
241
- // already-registered factory (cache-drop instead of teardown).
538
+ // Back to idle: the next toggle persists and syncs without touching the
539
+ // widget slot because the panel is collapsed again.
242
540
  ctx.isIdle = () => true;
243
541
  assert.equal(toggleExecutionPanelView(pi, ctx), false);
244
542
  assert.ok(recorded.entries.length > entriesBefore, "idle toggle did not persist");
245
- assert.equal(recorded.widgetCalls, factoriesBefore, "idle toggle replaced the widget factory");
543
+ assert.equal(recorded.widgetCalls, factoriesBefore, "collapsed toggle churned the widget registration");
246
544
 
247
- stopExecution(pi, ctx, "test-done");
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");
1044
+
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);
248
1110
  });
249
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
+ }