pi-plans 0.5.7 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/CONTRIBUTING.md +126 -0
  2. package/README.md +49 -39
  3. package/agents/ref-analyst.md +7 -4
  4. package/agents/reviewer.md +12 -3
  5. package/index.ts +74 -40
  6. package/package.json +2 -1
  7. package/references/pi-planning-workflow.md +45 -58
  8. package/references/plan-artifact-template.md +71 -60
  9. package/references/state-and-config.md +63 -47
  10. package/scripts/bench/pi-adapter/pi_plans_bench.py +33 -22
  11. package/scripts/bench/pi-adapter/rpc_driver.mjs +4 -4
  12. package/scripts/run-tests.ts +12 -1
  13. package/scripts/validate.ts +22 -10
  14. package/skills/debug-and-plan/SKILL.md +4 -4
  15. package/skills/plan-big/SKILL.md +5 -5
  16. package/skills/plan-normal/SKILL.md +5 -5
  17. package/skills/plan-small/SKILL.md +5 -5
  18. package/skills/plan-with-refs/SKILL.md +8 -8
  19. package/skills/planning/SKILL.md +1 -1
  20. package/src/ask-form.ts +4 -4
  21. package/src/auditor.ts +126 -0
  22. package/src/auto-approve.ts +1 -1
  23. package/src/autocomplete.ts +19 -17
  24. package/src/code-graph/commands.ts +2 -2
  25. package/src/code-graph/community.ts +1 -1
  26. package/src/code-graph/paths.ts +1 -1
  27. package/src/code-graph/watch.ts +2 -2
  28. package/src/compaction.ts +3 -3
  29. package/src/config-command.ts +154 -76
  30. package/src/dashboard.ts +257 -0
  31. package/src/exec.ts +709 -705
  32. package/src/global-state.ts +304 -0
  33. package/src/guard.ts +16 -3
  34. package/src/messaging.ts +44 -0
  35. package/src/plan.ts +421 -112
  36. package/src/query-hook.ts +4 -4
  37. package/src/refine-prompts.ts +14 -72
  38. package/src/refine-ui-helpers.ts +24 -5
  39. package/src/refine-ui-state.ts +1 -1
  40. package/src/refine-ui.ts +1 -1
  41. package/src/resume-command.ts +40 -130
  42. package/src/resume.ts +15 -17
  43. package/src/role-panels.ts +542 -0
  44. package/src/run-context.ts +5 -4
  45. package/src/run-picker.ts +98 -0
  46. package/src/state.ts +380 -77
  47. package/src/subagent.ts +32 -1
  48. package/src/task-tool.ts +100 -0
  49. package/src/tasks.ts +189 -0
  50. package/src/thinking-levels.ts +67 -0
  51. package/src/ui-language.ts +3 -54
  52. package/src/workflow-state.ts +78 -57
  53. package/tests/analyze-refs.test.ts +35 -18
  54. package/tests/ask-choice-pros-cons.test.ts +147 -0
  55. package/tests/ask-choice-schema.test.ts +0 -12
  56. package/tests/ask-choice.test.ts +2 -49
  57. package/tests/ask-form-tool.test.ts +4 -5
  58. package/tests/ask-form.test.ts +2 -2
  59. package/tests/auditor.test.ts +111 -0
  60. package/tests/auto-approve.test.ts +7 -10
  61. package/tests/autocomplete.test.ts +8 -11
  62. package/tests/code-graph-apply-action.test.ts +2 -2
  63. package/tests/code-graph-commands.test.ts +2 -2
  64. package/tests/code-graph-index.test.ts +2 -2
  65. package/tests/code-graph-loop.e2e.test.ts +1 -1
  66. package/tests/code-graph-mutations.test.ts +1 -1
  67. package/tests/code-graph-rollback.test.ts +1 -1
  68. package/tests/code-graph-v05.test.ts +2 -2
  69. package/tests/compaction.test.ts +1 -1
  70. package/tests/config-command.test.ts +103 -100
  71. package/tests/dashboard.test.ts +268 -0
  72. package/tests/exec-lifecycle.test.ts +181 -115
  73. package/tests/exec-panel-lifecycle.test.ts +106 -251
  74. package/tests/exec.test.ts +617 -1706
  75. package/tests/execute-plan.test.ts +44 -19
  76. package/tests/extension-load.test.ts +48 -0
  77. package/tests/global-state.test.ts +371 -0
  78. package/tests/graph-aware-file-tools.test.ts +5 -5
  79. package/tests/guard.test.ts +1 -1
  80. package/tests/multi-run.test.ts +184 -0
  81. package/tests/plan.test.ts +139 -62
  82. package/tests/plans.test.ts +7 -79
  83. package/tests/refine-prompts.test.ts +20 -71
  84. package/tests/refine-resume.test.ts +27 -22
  85. package/tests/refine-ui.test.ts +6 -15
  86. package/tests/resume-lifecycle.test.ts +37 -22
  87. package/tests/resume.test.ts +43 -88
  88. package/tests/role-panels.test.ts +391 -0
  89. package/tests/run-context.test.ts +1 -1
  90. package/tests/run-ownership.test.ts +1 -1
  91. package/tests/stale-ctx.test.ts +218 -0
  92. package/tests/state.test.ts +151 -32
  93. package/tests/subagent-thinking.test.ts +65 -0
  94. package/tests/subagent-usage.test.ts +1 -1
  95. package/tests/task-tool.test.ts +61 -0
  96. package/tests/thinking-levels.test.ts +77 -0
  97. package/tests/ui-language.test.ts +2 -17
  98. package/tests/workflow-state.test.ts +17 -99
  99. package/tools/analyze-refs.ts +67 -32
  100. package/tools/ask-choice.ts +19 -49
  101. package/tools/code-graph.ts +2 -2
  102. package/tools/execute-plan.ts +63 -33
  103. package/tools/graph-aware-file-tools.ts +6 -4
  104. package/tools/plans.ts +40 -66
  105. package/tools/refine.ts +101 -164
  106. package/agents/criticizer.md +0 -18
  107. package/scripts/bench/pi-adapter/__pycache__/pi_plans_bench.cpython-312.pyc +0 -0
  108. package/src/panel.ts +0 -473
  109. package/src/termination-prompt.ts +0 -73
  110. package/tests/goal-wait.test.ts +0 -269
  111. package/tests/panel-i-zero.test.ts +0 -420
  112. package/tests/panel.test.ts +0 -355
@@ -1,4 +1,9 @@
1
- /** Tests for the execution loop: marker tracking, session restore, completion. */
1
+ /**
2
+ * Task-tree execution core tests (v0.6.1): startExecution, task-tool-driven
3
+ * progress persistence, the per-turn injection, the stall watchdog, the
4
+ * completion-audit flow (pass, rollback, round cap under auto-approve), the
5
+ * checkpoint schema round-trip, and restoreFromSession.
6
+ */
2
7
 
3
8
  import * as assert from "node:assert/strict";
4
9
  import * as fs from "node:fs";
@@ -6,1765 +11,671 @@ import * as os from "node:os";
6
11
  import * as path from "node:path";
7
12
  import { after, before, describe, it } from "node:test";
8
13
  import {
9
- ameliorationPromptText,
10
- applyDoneMarkers,
11
- applyImplMarkers,
12
- applyCurrentIMarker,
13
- buildExecutionCompactionResult,
14
- buildPlanningCompactionResult,
15
- completeExecution,
16
- consumePendingExecutionFlush,
17
- consumePlanningCompactionResumeGuard,
18
- consumePrePlanCompactPending,
19
- drainExecutionFlush,
14
+ __setAuditRunnerForTests,
20
15
  executionContextMessage,
21
- filterExecutionResumeMessages,
22
- filterPlanningResumeMessages,
23
16
  getExecution,
24
- handleExecutionBeforeCompact,
25
- handleExecutionCompact,
26
- handleExecutionTurnCompaction,
27
- handleExecutionCompactFailed,
28
- compactionInFlight,
29
- noteCompactionStarted,
30
- noteCompactionEnded,
31
- handlePlanningBeforeCompact,
32
- handlePlanningCompact,
33
- handlePlanningCompactFailed,
34
- isExecutionComplete,
35
- markPrePlanCompactPending,
36
- PLANNING_PLAN_WRITTEN_CUSTOM_TYPE,
37
- PLANNING_PREPLAN_COMPACT_HINT,
38
- PLANNING_PREPLAN_RESUME_CUSTOM_TYPE,
39
- PLANNING_RUN_START_CUSTOM_TYPE,
40
- refreshPlanningCompactionCooldown,
41
- requestPlanningCompaction,
17
+ loadExecutionFromCheckpoint,
18
+ persistTaskProgress,
19
+ registerExecutionTurnHandlers,
42
20
  restoreFromSession,
43
- sendPrePlanCompactResume,
44
- shouldTriggerPlanningCompaction,
45
21
  startExecution,
46
- recordExecutionTurn,
47
- registerExecutionTurnHandlers,
48
22
  stopExecution,
23
+ toggleDashboardExpanded,
49
24
  updateStatusWidget,
50
25
  } from "../src/exec.ts";
51
- import { buildPiPlansVccCompaction, loadVccSettings } from "../src/compaction.ts";
52
- import type { CheckItem } from "../src/plan.ts";
53
- import { initState, setGraphEnabled, setLanguage, setRunStatus, startRun } from "../src/state.ts";
26
+ import { setMessagingApi } from "../src/messaging.ts";
27
+ import { applyTaskUpdate } from "../src/task-tool.ts";
28
+ import { flattenTaskViews } from "../src/tasks.ts";
29
+ import { parseChecklist, parsePlanTasks } from "../src/plan.ts";
30
+ import { initState, startRun } from "../src/state.ts";
31
+ import { createCheckpoint, loadCheckpoint, mutateCheckpoint, applyExecutionApproved, applyExecutionProgress, applyPlanWritten, planIdentityOf } from "../src/workflow-state.ts";
32
+ import { allTasksTerminal } from "../src/tasks.ts";
54
33
 
55
- interface Recorded {
56
- entries: { type: string; customType?: string; data?: unknown }[];
57
- messages: { customType: string; content: string; options?: { triggerTurn?: boolean } }[];
58
- status: string | undefined;
59
- statusCalls: number;
60
- colors: string[];
61
- models: { provider: string; id: string }[];
62
- thinkingLevels: (string | null)[];
63
- notifies: { message: string; severity: string }[];
64
- selects: { title: string; options: string[] }[];
65
- selectAnswer?: string;
66
- current: { provider: string; id: string } | null;
67
- thinking: string | null;
68
- userMessages: string[];
69
- userMessageOptions: Array<Record<string, unknown> | null>;
70
- compacts?: { customInstructions?: string }[];
71
- }
34
+ const PLAN = `# PLAN_v1 - demo
72
35
 
73
- interface Harness {
74
- pi: any;
75
- ctx: any;
76
- recorded: Recorded;
77
- emit: (eventName: string, event: unknown) => Promise<unknown[]>;
78
- }
36
+ ## Tasks
79
37
 
80
- function makeHarness(workdir: string): Harness {
81
- const recorded: Recorded = {
82
- entries: [],
83
- messages: [],
84
- status: undefined,
85
- statusCalls: 0,
86
- colors: [],
87
- models: [],
88
- thinkingLevels: [],
89
- notifies: [],
90
- selects: [],
91
- current: { provider: "p", id: "m" },
92
- thinking: "high",
93
- userMessages: [],
94
- userMessageOptions: [],
95
- };
96
- let contextPercent: number | null = 0;
97
- const registryModels = [
98
- { provider: "p", id: "m" },
99
- { provider: "prov", id: "other" },
100
- ];
101
- const handlers = new Map<string, Array<(event: unknown, ctx: any) => unknown>>();
102
- const emit = async (eventName: string, event: unknown): Promise<unknown[]> => {
103
- const results: unknown[] = [];
104
- for (const handler of handlers.get(eventName) ?? []) {
105
- results.push(await handler(event, ctx));
106
- }
107
- return results;
108
- };
109
- const pi = {
110
- on: (eventName: string, handler: (event: unknown, ctx: any) => unknown) => {
111
- const registered = handlers.get(eventName) ?? [];
112
- registered.push(handler);
113
- handlers.set(eventName, registered);
114
- },
115
- registerTool: () => {},
116
- registerCommand: () => {},
117
- registerShortcut: () => {},
118
- registerFlag: () => {},
119
- appendEntry: (customType: string, data: unknown) => {
120
- recorded.entries.push({ type: "custom", customType, data });
121
- },
122
- sendMessage: (message: { customType: string; content: string }, options?: { triggerTurn?: boolean }) => {
123
- recorded.messages.push({ ...message, options });
124
- },
125
- sendUserMessage: async (content: string, options?: Record<string, unknown>) => {
126
- recorded.userMessages.push(content);
127
- recorded.userMessageOptions.push(options ?? null);
128
- },
129
- setModel: async (model: { provider: string; id: string }) => {
130
- recorded.models.push({ provider: model.provider, id: model.id });
131
- recorded.current = { provider: model.provider, id: model.id };
132
- return true;
133
- },
134
- setThinkingLevel: (level: string) => {
135
- recorded.thinkingLevels.push(level);
136
- recorded.thinking = level;
137
- },
138
- };
139
- const ui = {
140
- setStatus: (_key: string, value: string | undefined) => {
141
- recorded.statusCalls += 1;
142
- recorded.status = value;
143
- },
144
- notify: (message: string, severity: string) => {
145
- recorded.notifies.push({ message, severity });
146
- },
147
- select: async (title: string, options: string[]) => {
148
- recorded.selects.push({ title, options });
149
- return recorded.selectAnswer ?? options[0];
150
- },
151
- theme: {
152
- fg: (color: string, text: string) => {
153
- recorded.colors.push(color);
154
- return text;
155
- },
156
- strikethrough: (text: string) => `~~${text}~~`,
157
- },
158
- };
159
- const sessionManager: any = {};
160
- const ctx = {
161
- cwd: workdir,
162
- ui,
163
- isIdle: () => true,
164
- hasUI: true,
165
- scopedModels: [] as Array<{ model: { provider: string; id: string }; thinkingLevel?: string }>,
166
- get model() {
167
- return recorded.current;
168
- },
169
- get thinkingLevel() {
170
- return recorded.thinking;
171
- },
172
- modelRegistry: {
173
- find: (provider: string, modelId: string) =>
174
- registryModels.find((entry) => entry.provider === provider && entry.id === modelId),
175
- getAvailable: () => registryModels,
176
- },
177
- getContextUsage: () =>
178
- contextPercent === null
179
- ? undefined
180
- : { tokens: contextPercent * 1000, contextWindow: 100000, percent: contextPercent },
181
- compact: (options: { customInstructions?: string }) => {
182
- recorded.compacts = recorded.compacts ?? [];
183
- recorded.compacts.push(options);
184
- },
185
- sessionManager,
186
- setUsagePercent: (percent: number | null) => {
187
- contextPercent = percent;
188
- },
189
- };
190
- return { pi, ctx, recorded, emit, setUsagePercent: ctx.setUsagePercent } as Harness & { setUsagePercent: (percent: number | null) => void };
191
- }
38
+ - Task-1: parser — files: src/a.ts; wave: 1
39
+ - Task-2: tool — files: src/b.ts; wave: 1
40
+ - Task-3: core — deps: Task-1, Task-2; files: src/c.ts; wave: 2
192
41
 
193
- function items(...ids: string[]): CheckItem[] {
194
- return ids.map((id) => ({ id, text: `\`${id}\` demo item`, done: false }));
195
- }
42
+ ### Execution Waves
196
43
 
197
- function compactableBranchEntries(): any[] {
198
- return [
199
- { id: "u-1", type: "message", message: { role: "user", content: [{ type: "text", text: "start the work" }] } },
200
- { id: "a-1", type: "message", message: { role: "assistant", content: [{ type: "text", text: "made progress" }] } },
201
- { id: "u-2", type: "message", message: { role: "user", content: [{ type: "text", text: "continue from here" }] } },
202
- { id: "a-2", type: "message", message: { role: "assistant", content: [{ type: "text", text: "tail work" }] } },
203
- ];
204
- }
44
+ - wave 1: Task-1, Task-2 — parallel
45
+ - wave 2: Task-3 — serial
205
46
 
206
- function startActiveRun(workdir: string, status: "planning" | "executing" = "planning") {
207
- initState(workdir);
208
- const { run } = startRun(workdir, { topic: "compact", skill: "plan-normal", requestText: "x" });
209
- if (status !== "planning") setRunStatus(workdir, run.run_id, status);
210
- return run;
211
- }
47
+ ## Verification Checks
212
48
 
213
- describe("execution loop", () => {
214
- let tmpRoot: string;
215
- let counter = 0;
49
+ - [ ] \`VC-001\` covers \`Task-1\`; pass condition: parser tests green
50
+ - [ ] \`VC-002\` covers \`Task-2\` and \`Task-3\`; pass condition: core tests green
51
+ `;
216
52
 
217
- before(() => {
218
- tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-exec-"));
219
- });
220
-
221
- after(() => {
222
- fs.rmSync(tmpRoot, { recursive: true, force: true });
223
- });
224
-
225
- function freshWorkdir(): string {
226
- counter += 1;
227
- const workdir = path.join(tmpRoot, `repo-${counter}`);
228
- fs.mkdirSync(workdir, { recursive: true });
229
- return workdir;
230
- }
231
-
232
- it("tracks done markers and completes", async () => {
233
- const workdir = freshWorkdir();
234
- const { pi, ctx, recorded } = makeHarness(workdir);
235
- await startExecution(pi, ctx, path.join(workdir, "PLAN_v1.md"), items("VC-001", "VC-002"));
236
-
237
- assert.match(recorded.status ?? "", /plans: .* ▸ exec/);
238
- assert.match(recorded.status ?? "", /VC 0\/2/);
239
- assert.match(recorded.status ?? "", /next: Verify VC-001/);
240
-
241
- assert.ok(getExecution());
242
- const rules = executionContextMessage(ctx)!;
243
- assert.match(rules, /PI-PLANS EXECUTION/);
244
- assert.match(rules, /VC-001/);
245
- assert.match(rules, /subprocess-backed verification/);
246
- assert.match(rules, /waiting for/);
247
- assert.match(rules, /5s\s*->\s*10s\s*->\s*20s\s*->\s*40s\s*->\s*80s/);
248
- assert.match(rules, /keep polling at 80s/);
249
- assert.match(rules, /restart at 5s for each new subprocess/);
250
- // Representative anchors for the core execution rules (PLAN_v2 D-003/D-004).
251
- assert.match(rules, /for the long term/);
252
- assert.match(rules, /Simplest implementation/);
253
- assert.match(rules, /grow the change in layers/);
254
- assert.match(rules, /existing dependencies \(docs and types\)/);
255
- assert.match(rules, /well-maintained libraries/);
256
- assert.match(rules, /clearly separated concerns/);
257
- assert.match(rules, /no stopgaps/);
258
- assert.doesNotMatch(rules, /ponytail/i);
259
-
260
- assert.deepEqual(applyDoneMarkers("progress… [DONE:VC-001] done"), ["VC-001"]);
261
- assert.equal(isExecutionComplete(), false);
262
- assert.deepEqual(applyDoneMarkers("final: [DONE:VC-002]"), ["VC-002"]);
263
- assert.equal(isExecutionComplete(), true);
264
-
265
- await completeExecution(pi, ctx);
266
- assert.equal(getExecution(), null);
267
- assert.ok(recorded.messages.some((message) => message.customType === "pi-plans-complete"));
268
- });
53
+ let root: string;
54
+ let counter = 0;
269
55
 
270
- it("chrome language (issue #3): goal-wait segment follows config, refresh switches it live", async () => {
271
- const { formatExecutionStatusLine, refreshUiLanguage } = await import("../src/exec.ts");
272
- const workdir = freshWorkdir();
273
- const { spawnSync } = await import("node:child_process");
274
- spawnSync("git", ["init"], { cwd: workdir });
275
- spawnSync("git", ["config", "user.email", "t@e.com"], { cwd: workdir });
276
- spawnSync("git", ["config", "user.name", "T"], { cwd: workdir });
277
- initState(workdir);
278
- const harness = makeHarness(workdir);
279
- await startExecution(harness.pi, harness.ctx, path.join(workdir, "PLAN_v1.md"), items("VC-001"));
280
- const exec = getExecution()!;
281
-
282
- // Default config (tag unset) → English chrome.
283
- assert.equal(exec.uiLanguage, "en");
284
- exec.goalWait = { noProgressRounds: 1, waitRounds: 2, lastMarkers: null, paused: false };
285
- assert.match(formatExecutionStatusLine(exec), /no progress 1\/3 · waiting 2\/6/);
286
- assert.ok(!/[\u4e00-\u9fff]/.test(formatExecutionStatusLine(exec)));
287
-
288
- // zh-Hans config → verbatim 0.5.6 strings.
289
- setLanguage(workdir, "zh-Hans", "user");
290
- exec.uiLanguage = "zh";
291
- assert.match(formatExecutionStatusLine(exec), /无进展 1\/3 · 等待 2\/6/);
56
+ before(() => {
57
+ root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-exec-"));
58
+ });
292
59
 
293
- // D-008: refreshUiLanguage re-resolves from the live config and repaints.
294
- setLanguage(workdir, "en", "user");
295
- exec.uiLanguage = "zh";
296
- refreshUiLanguage(harness.ctx);
297
- assert.equal(exec.uiLanguage, "en", "refresh re-resolves the configured language");
298
- assert.match(formatExecutionStatusLine(exec), /no progress 1\/3/);
299
- await completeExecution(harness.pi, harness.ctx);
300
- });
60
+ after(() => {
61
+ fs.rmSync(root, { recursive: true, force: true });
62
+ __setAuditRunnerForTests(null);
63
+ });
301
64
 
302
- it("completion enters goal-running continuation and triggers a turn in interactive sessions", async () => {
303
- const workdir = freshWorkdir();
304
- const { pi, ctx, recorded } = makeHarness(workdir);
65
+ function freshWorkdir(withRun = true): { workdir: string; planPath: string; runId?: string } {
66
+ counter += 1;
67
+ const workdir = path.join(root, `repo-${counter}`);
68
+ fs.mkdirSync(workdir, { recursive: true });
69
+ initState(workdir);
70
+ if (!withRun) {
305
71
  const planPath = path.join(workdir, "PLAN_v1.md");
306
- await startExecution(pi, ctx, planPath, items("VC-001", "VC-002"));
307
- assert.deepEqual(applyDoneMarkers("[DONE:VC-001] [DONE:VC-002]"), ["VC-001", "VC-002"]);
72
+ fs.writeFileSync(planPath, PLAN, "utf8");
73
+ return { workdir, planPath };
74
+ }
75
+ const { run } = startRun(workdir, { topic: `t${counter}`, skill: "plan-small", requestText: "demo" });
76
+ createCheckpoint(workdir, { runId: run.run_id, originWorkdir: workdir, workdir });
77
+ const planPath = path.join(run.artifact_dir, "PLAN_v1.md");
78
+ fs.mkdirSync(run.artifact_dir, { recursive: true });
79
+ fs.writeFileSync(planPath, PLAN, "utf8");
80
+ mutateCheckpoint(workdir, run.run_id, (cp) =>
81
+ applyExecutionApproved(
82
+ applyPlanWritten({ ...cp, nextAction: "accept-execute" }, planIdentityOf(planPath, 1)),
83
+ { plan: planIdentityOf(planPath, 1), worktree: workdir, headAtApproval: null, approvedAt: cp.updatedAt },
84
+ ),
85
+ );
86
+ return { workdir, planPath, runId: run.run_id };
87
+ }
308
88
 
309
- await completeExecution(pi, ctx);
89
+ function makeCtx(workdir: string) {
90
+ const entries: Array<{ customType: string; data?: unknown; content?: string }> = [];
91
+ const ctx = {
92
+ cwd: workdir,
93
+ sessionManager: {},
94
+ hasUI: true,
95
+ mode: "print" as const,
96
+ entries,
97
+ ui: {
98
+ notify: () => {},
99
+ setStatus: () => {},
100
+ setWidget: () => {},
101
+ theme: { fg: (_c: string, t: string) => t, bold: (t: string) => t },
102
+ },
103
+ isIdle: () => true,
104
+ hasPendingMessages: () => false,
105
+ } as never;
106
+ setMessagingApi({
107
+ appendEntry: (customType: string, data: unknown) => entries.push({ customType, data }),
108
+ sendMessage: (message: { customType: string; content: string }) => entries.push({ customType: message.customType, content: message.content }),
109
+ sendUserMessage: async () => {},
110
+ });
111
+ return ctx;
112
+ }
310
113
 
311
- const completeMessage = recorded.messages.find((message) => message.customType === "pi-plans-complete");
312
- assert.ok(completeMessage);
313
- assert.match(completeMessage.content, /Goal-running continuation/);
314
- assert.match(completeMessage.content, /How should the implementation-review loop terminate\?/);
315
- assert.match(completeMessage.content, /goal wait: continue until no unpassed VCs remain/);
316
- assert.doesNotMatch(completeMessage.content, /Run a post-execution amelioration round/);
317
- assert.equal(completeMessage.options?.triggerTurn, true);
318
- const ameliorateEntry = recorded.entries.find((entry) => entry.customType === "pi-plans-ameliorate");
319
- assert.ok(ameliorateEntry);
320
- const data = ameliorateEntry.data as Record<string, unknown>;
321
- assert.equal(data.phase, "goal-started");
322
- assert.equal(data.rounds, null);
323
- assert.equal(data.currentRound, 0);
324
- assert.equal(data.planPath, planPath);
114
+ async function start(planPath: string, workdir: string, planText: string = PLAN) {
115
+ const ctx = makeCtx(workdir) as { entries: Array<{ customType: string; data?: unknown; content?: string }> };
116
+ await startExecution(ctx, {
117
+ planPath,
118
+ planTasks: parsePlanTasks(planText),
119
+ items: parseChecklist(planText),
325
120
  });
121
+ return { ctx };
122
+ }
326
123
 
327
- it("completion stays silent in headless sessions", async () => {
328
- const workdir = freshWorkdir();
329
- const { pi, ctx, recorded } = makeHarness(workdir);
330
- await startExecution(pi, ctx, path.join(workdir, "PLAN_v1.md"), items("VC-001"));
331
- assert.deepEqual(applyDoneMarkers("[DONE:VC-001]"), ["VC-001"]);
332
- (ctx as any).hasUI = false;
333
-
334
- await completeExecution(pi, ctx);
335
-
336
- const completeMessage = recorded.messages.find((message) => message.customType === "pi-plans-complete");
337
- assert.ok(completeMessage);
338
- assert.doesNotMatch(completeMessage.content, /Goal-running continuation/);
339
- assert.equal(completeMessage.options?.triggerTurn, false);
340
- assert.equal(
341
- recorded.entries.some((entry) => entry.customType === "pi-plans-ameliorate"),
342
- false,
343
- "headless completion must not append the ameliorate entry",
124
+ describe("task-tree execution core", () => {
125
+ it("startExecution seeds the task tree and emits the task-tool contract", async () => {
126
+ const { workdir, planPath } = freshWorkdir();
127
+ const { ctx } = await start(planPath, workdir);
128
+ const exec = getExecution()!;
129
+ assert.equal(exec.tasks.length, 3);
130
+ assert.equal(exec.legacyPlan, false);
131
+ assert.ok(ctx.entries.some((e) => e.customType === "pi-plans-exec-start" && String(e.content).includes("plans_update_task")));
132
+ const injection = executionContextMessage(makeCtx(workdir))!;
133
+ assert.match(injection, /Current wave 1 open tasks:/);
134
+ assert.match(injection, /Task-1: parser/);
135
+ assert.match(injection, /plans_update_task/);
136
+ assert.match(injection, /VC-001, VC-002/);
137
+ await stopExecution(makeCtx(workdir), "test teardown");
138
+ });
139
+
140
+ it("task updates persist into the checkpoint task map", async () => {
141
+ const { workdir, planPath, runId } = freshWorkdir();
142
+ const { ctx } = await start(planPath, workdir);
143
+ applyTaskUpdate(getExecution()!.tasks, "Task-1", "complete", "a-tests green");
144
+ persistTaskProgress(ctx);
145
+ const load = loadCheckpoint(workdir, runId!);
146
+ assert.ok(load.status === "ok");
147
+ assert.equal(load.checkpoint.execution?.tasks?.["Task-1"]?.status, "complete");
148
+ assert.equal(load.checkpoint.execution?.tasks?.["Task-1"]?.evidence, "a-tests green");
149
+ await stopExecution(ctx, "test teardown");
150
+ });
151
+
152
+ it("audit pass completes the run; VCs flip done and the phase is completed", async () => {
153
+ __setAuditRunnerForTests(async ({ checklist }) => ({
154
+ round: 1,
155
+ passed: checklist.map((item) => {
156
+ item.done = true; // mirrors applyAuditOutcome's mutation
157
+ return item.id;
158
+ }),
159
+ failed: [],
160
+ rolledBack: [],
161
+ report: "all pass",
162
+ }));
163
+ const { workdir, planPath, runId } = freshWorkdir();
164
+ const { ctx } = await start(planPath, workdir);
165
+ for (const id of ["Task-1", "Task-2", "Task-3"]) {
166
+ applyTaskUpdate(getExecution()!.tasks, id, "complete", `${id} evidence`);
167
+ persistTaskProgress(ctx);
168
+ }
169
+ assert.ok(allTasksTerminal(getExecution()!.tasks));
170
+ // The restore path runs the audit flow when tasks are terminal but
171
+ // checks are still open (the same trigger turn_end uses).
172
+ const snapshot = getExecution();
173
+ assert.ok(snapshot);
174
+ await restoreFromSession(ctx, [{ type: "custom", customType: "pi-plans-exec", data: snapshot }]);
175
+ const load = loadCheckpoint(workdir, runId!);
176
+ assert.ok(load.status === "ok");
177
+ assert.equal(load.checkpoint.phase, "completed");
178
+ const doneVc = load.checkpoint.execution?.doneVcIds ?? [];
179
+ assert.ok(doneVc.includes("VC-001") && doneVc.includes("VC-002"));
180
+ __setAuditRunnerForTests(null);
181
+ });
182
+
183
+ it("audit failure rolls covered tasks back; three rounds stop the run", async () => {
184
+ let round = 0;
185
+ __setAuditRunnerForTests(async ({ checklist }) => {
186
+ round += 1;
187
+ const failed = checklist.filter((item) => item.id === "VC-002").map((item) => item.id);
188
+ // Simulate the pure outcome: VC-002 fails; its covered tasks roll back.
189
+ return { round, passed: ["VC-001"], failed, rolledBack: [], report: "VC-002 fails" };
190
+ });
191
+ const { workdir, planPath, runId } = freshWorkdir();
192
+ const { ctx } = await start(planPath, workdir);
193
+ const exec = getExecution()!;
194
+ for (const id of ["Task-1", "Task-2", "Task-3"]) {
195
+ applyTaskUpdate(exec.tasks, id, "complete", `${id} evidence`);
196
+ persistTaskProgress(ctx);
197
+ }
198
+ // Apply the failed-audit rollback three times (round cap), mirroring
199
+ // the audit-flow loop without driving real turn events.
200
+ for (let i = 0; i < 3; i++) {
201
+ exec.audit.rounds += 1;
202
+ exec.audit.failed = ["VC-002"];
203
+ // rollback via tasks API directly (the flow does this via auditRollbackSet)
204
+ for (const task of flattenTaskViews(exec.tasks)) {
205
+ if (task.id !== "Task-1" && task.status !== "pending") {
206
+ task.status = "pending";
207
+ task.evidence = undefined;
208
+ }
209
+ }
210
+ }
211
+ assert.equal(exec.audit.rounds, 3);
212
+ // The stop path at the cap: emulate what runAuditFlow does on cap.
213
+ await stopExecution(ctx, "completion audit exhausted 3 rounds");
214
+ const load = loadCheckpoint(workdir, runId!);
215
+ assert.ok(load.status === "ok");
216
+ assert.equal(load.checkpoint.execution?.pausedReason, "completion audit exhausted 3 rounds");
217
+ __setAuditRunnerForTests(null);
218
+ });
219
+
220
+ it("stall watchdog pauses after three settled rounds without task change", async () => {
221
+ const { workdir, planPath } = freshWorkdir();
222
+ const { ctx } = await start(planPath, workdir);
223
+ const exec = getExecution()!;
224
+ const snapshot = JSON.stringify(exec.stall.lastSnapshot);
225
+ for (let i = 0; i < 3; i++) {
226
+ exec.stall.lastSnapshot = snapshot; // force no-change
227
+ exec.stall.rounds += 1;
228
+ }
229
+ assert.equal(exec.stall.rounds, 3);
230
+ // Direct pause-path assertion via the exported resume contract:
231
+ exec.stall.paused = true;
232
+ exec.stall.pausedReason = "no task-status change in 3 rounds";
233
+ assert.match(
234
+ formatStatus(workdir),
235
+ /paused/,
344
236
  );
237
+ await stopExecution(ctx, "test teardown");
345
238
  });
346
239
 
347
- it("restoreFromSession completion triggers the same goal-running continuation in interactive sessions", async () => {
348
- const workdir = freshWorkdir();
349
- const { pi, ctx, recorded } = makeHarness(workdir);
240
+ it("legacy I-### plans parse through the fallback with the upgrade notice", async () => {
241
+ const workdir = fs.mkdtempSync(path.join(root, "legacy-"));
242
+ initState(workdir);
350
243
  const planPath = path.join(workdir, "PLAN_v1.md");
351
- fs.writeFileSync(planPath, "# plan");
352
- const snapshot = {
244
+ fs.writeFileSync(
353
245
  planPath,
354
- items: items("VC-001", "VC-002"),
355
- startedAt: "2026-08-25T00:00:00Z",
356
- usage: { inToks: 0, outToks: 0 },
357
- implItems: [],
358
- implStatus: {},
359
- compaction: {
360
- inFlight: true,
361
- resumeGuard: true,
362
- cooldownActive: true,
363
- lastAttemptReason: "threshold",
364
- lastSuccessfulUsagePercent: 100,
365
- lastSuccessfulAt: "2026-08-25T00:01:00Z",
366
- },
367
- };
368
- const entries = [
369
- { type: "custom", customType: "pi-plans-exec", data: snapshot },
370
- {
371
- type: "message",
372
- message: { role: "assistant", content: [{ type: "text", text: "did [DONE:VC-001] [DONE:VC-002]" }] },
373
- },
374
- ];
375
- await restoreFromSession(pi, ctx, entries as any);
376
- const completeMessage = recorded.messages.find((message) => message.customType === "pi-plans-complete");
377
- assert.ok(completeMessage, "restore path must fire completeExecution");
378
- assert.match(completeMessage.content, /Goal-running continuation/);
379
- assert.equal(completeMessage.options?.triggerTurn, true);
380
- assert.ok(recorded.entries.some((entry) => entry.customType === "pi-plans-ameliorate"));
246
+ "# PLAN\n\n## Implementation Items\n\n- `I-001`: First.\n- `I-002`: Second.\n\n## Verifier Checklist\n\n- [ ] `VC-001` covers `I-001`; pass condition: x.\n",
247
+ "utf8",
248
+ );
249
+ const legacyText = fs.readFileSync(planPath, "utf8");
250
+ await start(planPath, workdir, legacyText);
251
+ const exec = getExecution()!;
252
+ assert.equal(exec.legacyPlan, true);
253
+ assert.deepEqual(exec.tasks.map((t) => t.id), ["Task-1", "Task-2"]);
254
+ await stopExecution(makeCtx(workdir), "test teardown");
381
255
  });
382
256
 
383
- it("restores progress from session entries and rescans messages", async () => {
384
- const workdir = freshWorkdir();
385
- const { pi, ctx, recorded } = makeHarness(workdir);
386
- const snapshot = {
387
- planPath: path.join(workdir, "PLAN_v1.md"),
388
- items: items("VC-001", "VC-002"),
389
- startedAt: "2026-08-25T00:00:00Z",
390
- compaction: {
391
- inFlight: true,
392
- resumeGuard: true,
393
- cooldownActive: true,
394
- lastAttemptReason: "threshold",
395
- lastSuccessfulUsagePercent: 100,
396
- lastSuccessfulAt: "2026-08-25T00:01:00Z",
397
- },
398
- };
399
- fs.writeFileSync(snapshot.planPath, "# plan");
257
+ it("restoreFromSession rebuilds task progress from the snapshot", async () => {
258
+ const { workdir, planPath } = freshWorkdir();
259
+ const { ctx } = await start(planPath, workdir);
260
+ applyTaskUpdate(getExecution()!.tasks, "Task-1", "complete", "e1");
261
+ persistTaskProgress(ctx);
262
+ const snapshot = getExecution()!;
400
263
  const entries = [
401
264
  { type: "custom", customType: "pi-plans-exec", data: snapshot },
402
- {
403
- type: "message",
404
- message: { role: "assistant", content: [{ type: "text", text: "did [DONE:VC-001]" }] },
405
- },
406
265
  ];
407
- await restoreFromSession(pi, ctx, entries as any);
408
- const execution = getExecution();
409
- assert.ok(execution);
410
- assert.equal("compaction" in execution!, false, "legacy scheduler state must not reactivate on restore");
411
-
412
- const clearedEntries = [...entries, { type: "custom", customType: "pi-plans-exec-cleared", data: {} }];
413
- await restoreFromSession(pi, ctx, clearedEntries as any);
266
+ // Simulate a restart: stop clears state; restore rebuilds it.
267
+ await stopExecution(ctx, "restart");
414
268
  assert.equal(getExecution(), null);
269
+ const ctx2 = makeCtx(workdir);
270
+ await restoreFromSession(ctx2, entries as never);
271
+ const restored = getExecution();
272
+ assert.ok(restored, "execution restored");
273
+ assert.equal(restored!.tasks.find((t) => t.id === "Task-1")?.status, "complete");
274
+ assert.equal(restored!.tasks.find((t) => t.id === "Task-2")?.status, "pending");
275
+ await stopExecution(makeCtx(workdir), "test teardown");
276
+ });
277
+
278
+ it("loadExecutionFromCheckpoint restores task progress and keeps authorization on same HEAD", async () => {
279
+ const { workdir, planPath, runId } = freshWorkdir();
280
+ const { ctx } = await start(planPath, workdir);
281
+ applyTaskUpdate(getExecution()!.tasks, "Task-1", "complete", "e1");
282
+ persistTaskProgress(ctx);
283
+ await stopExecution(ctx, "restart-sim");
284
+ const load = loadExecutionFromCheckpoint(makeCtx(workdir), runId!);
285
+ // Stopped runs keep phase executing in the checkpoint (pausedReason set)
286
+ // so the load path applies. The approval recorded an unverifiable HEAD
287
+ // (no commits in the fixture repo): D-023 keeps the authorization but
288
+ // re-opens closed tasks for re-verification.
289
+ if (load.status === "loaded") {
290
+ assert.equal(load.reverifyAll, true);
291
+ const exec = getExecution()!;
292
+ assert.equal(exec.tasks.find((t) => t.id === "Task-1")?.status, "pending");
293
+ }
415
294
  });
416
295
 
417
- it("ignores restore when the plan file vanished", async () => {
418
- const workdir = freshWorkdir();
419
- const { pi, ctx } = makeHarness(workdir);
420
- const entries = [
421
- {
422
- type: "custom",
423
- customType: "pi-plans-exec",
424
- data: {
425
- planPath: path.join(workdir, "missing-plan.md"),
426
- items: items("VC-001"),
427
- startedAt: "2026-08-25T00:00:00Z",
428
- },
429
- },
430
- ];
431
- await restoreFromSession(pi, ctx, entries as any);
432
- assert.equal(getExecution(), null);
433
- });
434
-
435
- it("stop clears execution", async () => {
436
- const workdir = freshWorkdir();
437
- const { pi, ctx } = makeHarness(workdir);
438
- await startExecution(pi, ctx, path.join(workdir, "PLAN_v1.md"), items("VC-001"));
439
- await stopExecution(pi, ctx, "test");
440
- assert.equal(getExecution(), null);
441
- });
442
-
443
- it("starts execution without switching models", async () => {
444
- const workdir = freshWorkdir();
445
- const { pi, ctx, recorded } = makeHarness(workdir);
446
- await startExecution(pi, ctx, path.join(workdir, "PLAN_v6.md"), items("VC-001"));
447
-
448
- assert.deepEqual(recorded.models, []);
449
- assert.equal(recorded.thinking, "high");
450
- assert.match(recorded.status ?? "", /plans: .* ▸ exec/);
451
- assert.match(recorded.status ?? "", /VC 0\/1/);
452
-
453
- await stopExecution(pi, ctx, "restore-check");
454
- assert.deepEqual(recorded.models, []);
455
- assert.equal(recorded.thinking, "high");
456
- });
457
-
458
- it("keeps the execution status bar current while executing", async () => {
459
- const workdir = freshWorkdir();
460
- const { pi, ctx, recorded } = makeHarness(workdir);
461
- await startExecution(pi, ctx, path.join(workdir, "PLAN_v2.md"), items("VC-001", "VC-002"));
462
-
463
- // Bottom status bar carries the count — the same layer as ⛔/⌛ — so both
464
- // execution states read from one consistent place.
465
- assert.match(recorded.status ?? "", /plans: .* ▸ exec/);
466
- assert.match(recorded.status ?? "", /VC 0\/2/);
467
-
468
- const start = recorded.messages.find((message) => message.customType === "pi-plans-exec-start");
469
- assert.ok(start);
470
- assert.match(start.content, /Progress appears in the bottom status bar/);
471
- assert.doesNotMatch(start.content, /footer/);
472
-
473
- applyDoneMarkers("[DONE:VC-001]");
474
- await completeExecution(pi, ctx);
475
- assert.equal(getExecution(), null);
476
-
477
- });
478
-
479
- it("renders the idle indicator by run status", () => {
480
- const workdir = freshWorkdir();
481
- const { ctx, recorded } = makeHarness(workdir);
482
- initState(workdir);
483
- const { run } = startRun(workdir, { topic: "demo", skill: "plan-small", requestText: "x" });
484
-
485
- // planning before any PLAN draft exists: 💬 (Q&A phase).
486
- updateStatusWidget(ctx);
487
- assert.match(recorded.status ?? "", /💬 plans: /);
488
- assert.equal(recorded.colors.at(-1), "muted");
489
-
490
- // Once a draft lands: 📝, kept until execution starts.
491
- fs.writeFileSync(path.join(run.artifact_dir, "PLAN_v1.md"), "# plan");
492
- updateStatusWidget(ctx);
493
- assert.match(recorded.status ?? "", /📝 plans: /);
494
- assert.equal(recorded.colors.at(-1), "muted");
495
-
496
- setRunStatus(workdir, run.run_id, "accepted");
497
- updateStatusWidget(ctx);
498
- assert.match(recorded.status ?? "", /⌛ plans: /);
499
- assert.equal(recorded.colors.at(-1), "warning");
500
-
501
- setRunStatus(workdir, run.run_id, "stopped");
502
- updateStatusWidget(ctx);
503
- assert.match(recorded.status ?? "", /⛔ plans: /);
504
- assert.equal(recorded.colors.at(-1), "warning");
505
-
506
- setRunStatus(workdir, run.run_id, "done");
507
- updateStatusWidget(ctx);
508
- assert.match(recorded.status ?? "", /🎯 plans: .*\(done\)/);
509
- assert.equal(recorded.colors.at(-1), "success");
510
-
511
- setRunStatus(workdir, run.run_id, "abandoned");
512
- updateStatusWidget(ctx);
513
- assert.match(recorded.status ?? "", /🚫 plans: /);
514
- assert.equal(recorded.colors.at(-1), "error");
515
- });
516
-
517
- it("accumulates token usage on token-only and completion turns", async () => {
518
- const workdir = freshWorkdir();
519
- const { pi, ctx, recorded } = makeHarness(workdir);
520
- await startExecution(pi, ctx, path.join(workdir, "PLAN_v5.md"), items("VC-001", "VC-002"));
521
-
522
- recordExecutionTurn(pi, ctx, [], { input: 100, output: 40 });
523
- updateStatusWidget(ctx);
524
- assert.equal(getExecution()?.usage.inToks, 100);
525
- assert.equal(getExecution()?.usage.outToks, 40);
526
- assert.match(recorded.status ?? "", /plans: .* ▸ exec/);
527
- assert.match(recorded.status ?? "", /VC 0\/2/);
528
-
529
- assert.deepEqual(applyDoneMarkers("[DONE:VC-001]"), ["VC-001"]);
530
- recordExecutionTurn(pi, ctx, ["VC-001"], { input: 20, output: 10 });
531
- updateStatusWidget(ctx);
532
- assert.equal(getExecution()?.usage.inToks, 120);
533
- assert.equal(getExecution()?.usage.outToks, 50);
534
- assert.equal(getExecution()?.items[0].done, true);
535
- assert.match(recorded.status ?? "", /plans: .* ▸ exec/);
536
-
537
- await stopExecution(pi, ctx, "test-done");
538
- assert.equal(getExecution(), null);
539
- });
540
-
541
- it("returns control to Pi core without active execution or planning state", async () => {
542
- const workdir = freshWorkdir();
543
- const { pi, ctx } = makeHarness(workdir);
544
-
545
- await startExecution(pi, ctx, path.join(workdir, "PLAN_v0.md"), items("VC-001"));
546
- const executionResult = handleExecutionBeforeCompact(pi, ctx, {
547
- type: "session_before_compact",
548
- preparation: makePreparation("threshold", null),
549
- branchEntries: compactableBranchEntries(),
550
- reason: "threshold",
551
- willRetry: false,
552
- signal: new AbortController().signal,
553
- });
554
- assert.equal(executionResult, undefined);
555
- await stopExecution(pi, ctx, "no-active-run");
556
-
557
- const planningResult = handlePlanningBeforeCompact(pi as any, ctx as any, {
558
- type: "session_before_compact",
559
- preparation: makePreparation("threshold", null),
560
- branchEntries: compactableBranchEntries(),
561
- reason: "threshold",
562
- willRetry: false,
563
- signal: new AbortController().signal,
564
- });
565
- assert.equal(planningResult, undefined);
566
- });
567
-
568
- it("lets Pi core own execution compaction scheduling and customizes safe reasons", async () => {
569
- const workdir = freshWorkdir();
570
- startActiveRun(workdir);
571
- const { pi, ctx, recorded } = makeHarness(workdir);
572
- await startExecution(pi, ctx, path.join(workdir, "PLAN_v8.md"), items("VC-001", "VC-002"));
573
-
574
- const threshold = handleExecutionBeforeCompact(pi, ctx, {
575
- type: "session_before_compact",
576
- preparation: makePreparation("threshold", null),
577
- branchEntries: compactableBranchEntries(),
578
- reason: "threshold",
579
- willRetry: false,
580
- signal: new AbortController().signal,
581
- });
582
- assert.equal(threshold?.cancel, undefined);
583
- assert.ok(threshold?.compaction, "threshold compaction should use the VCC summary when a legal cut exists");
584
-
585
- const overflowRetry = handleExecutionBeforeCompact(pi, ctx, {
586
- type: "session_before_compact",
587
- preparation: makePreparation("overflow", null),
588
- branchEntries: [],
589
- reason: "overflow",
590
- willRetry: true,
591
- signal: new AbortController().signal,
592
- });
593
- assert.equal(overflowRetry, undefined, "overflow retry falls back to Pi core when VCC has no safe cut");
594
-
595
- const manual = handleExecutionBeforeCompact(pi, ctx, {
596
- type: "session_before_compact",
597
- preparation: makePreparation("manual", null),
598
- branchEntries: [],
599
- reason: "manual",
600
- willRetry: false,
601
- signal: new AbortController().signal,
602
- });
603
- assert.equal(manual?.cancel, true, "manual compaction cancels instead of discarding unsafe context");
604
- assert.ok(recorded.notifies.some((note) => note.message.includes("Nothing to compact")));
605
-
606
- await stopExecution(pi, ctx, "test-done");
607
- });
608
-
609
- it("does not proactively request execution compaction", async () => {
610
- const workdir = freshWorkdir();
611
- startActiveRun(workdir);
612
- const { pi, ctx, recorded, setUsagePercent } = makeHarness(workdir);
613
- await startExecution(pi, ctx, path.join(workdir, "PLAN_v9.md"), items("VC-001", "VC-002"));
614
-
615
- setUsagePercent(99);
616
- handleExecutionTurnCompaction(ctx);
617
- handleExecutionTurnCompaction(ctx);
618
- assert.equal(recorded.compacts?.length ?? 0, 0, "execution scheduling is owned by Pi core");
619
-
620
- await stopExecution(pi, ctx, "test-done");
621
- });
622
-
623
- it("ignores current-I growth for proactive execution compaction", async () => {
624
- const workdir = freshWorkdir();
625
- startActiveRun(workdir);
626
- const { pi, ctx, recorded } = makeHarness(workdir);
627
- await startExecution(pi, ctx, path.join(workdir, "PLAN_v22.md"), items("VC-001"), [
628
- { id: "I-001", text: "First item." },
629
- ]);
630
- (ctx.sessionManager as any).getBranch = () => [
631
- { id: "only", type: "message", tokens: 30000, message: { role: "assistant", content: [{ type: "text", text: "[I-001:current] one oversized turn" }] } },
632
- ];
633
- handleExecutionTurnCompaction(ctx);
634
- assert.equal(recorded.compacts?.length ?? 0, 0);
635
- await stopExecution(pi, ctx, "no-prefix");
636
- });
637
- it("wires execution compaction without restoring proactive requests or model helpers", () => {
638
- const indexSource = fs.readFileSync(path.join(process.cwd(), "index.ts"), "utf8");
639
- const execSource = fs.readFileSync(path.join(process.cwd(), "src/exec.ts"), "utf8");
640
- assert.match(indexSource, /handleExecutionTurnCompaction/);
641
- assert.match(execSource, /shouldTriggerExecutionCompaction/);
642
- assert.doesNotMatch(execSource, /ctx\.compact\(/);
643
- assert.doesNotMatch(
644
- indexSource,
645
- /setExecutionModel|chooseExecutionModelSelection|snapshotCurrentModelSelector|ensureExecutionModelActive|restorePlanningModel/,
646
- );
647
- assert.doesNotMatch(
648
- execSource,
649
- /setExecutionModel|chooseExecutionModelSelection|snapshotCurrentModelSelector|ensureExecutionModelActive|restorePlanningModel|buildModelExecutionCompactionResult|modelRegistry\.complete/,
650
- );
651
- });
652
-
653
- it("auto-continues execution only for threshold/overflow and honors manual follow-up prompts", async () => {
654
- const workdir = freshWorkdir();
655
- startActiveRun(workdir);
656
- const { pi, ctx, recorded } = makeHarness(workdir);
657
- (ctx as any).piVersion = "0.84.3";
658
- await startExecution(pi, ctx, path.join(workdir, "PLAN_v9.md"), items("VC-001"));
659
-
660
- const threshold = handleExecutionBeforeCompact(pi, ctx, {
661
- type: "session_before_compact",
662
- preparation: makePreparation("threshold", null),
663
- branchEntries: compactableBranchEntries(),
664
- reason: "threshold",
665
- willRetry: false,
666
- signal: new AbortController().signal,
667
- });
668
- assert.ok(threshold?.compaction);
669
- await handleExecutionCompact(pi, ctx, {
670
- type: "session_compact",
671
- compactionEntry: { type: "compaction" } as never,
672
- fromExtension: false,
673
- reason: "threshold",
674
- willRetry: false,
675
- });
676
- assert.equal(recorded.messages.filter((message) => message.customType === "pi-plans-exec-resume").length, 1);
677
- assert.ok(recorded.notifies.some((note) => note.message.startsWith("pi-vcc: kept")));
678
-
679
- handleExecutionBeforeCompact(pi, ctx, {
680
- type: "session_before_compact",
681
- preparation: makePreparation("manual", null),
682
- branchEntries: compactableBranchEntries(),
683
- reason: "manual",
684
- willRetry: false,
685
- signal: new AbortController().signal,
686
- });
687
- await handleExecutionCompact(pi, ctx, {
688
- type: "session_compact",
689
- compactionEntry: { type: "compaction" } as never,
690
- fromExtension: false,
691
- reason: "manual",
692
- willRetry: false,
693
- });
694
- assert.equal(recorded.messages.filter((message) => message.customType === "pi-plans-exec-resume").length, 1);
695
-
696
- handleExecutionBeforeCompact(pi, ctx, {
697
- type: "session_before_compact",
698
- preparation: makePreparation("manual", null),
699
- branchEntries: compactableBranchEntries(),
700
- customInstructions: "Run focused tests keep:1",
701
- reason: "manual",
702
- willRetry: false,
703
- signal: new AbortController().signal,
704
- });
705
- await handleExecutionCompact(pi, ctx, {
706
- type: "session_compact",
707
- compactionEntry: { type: "compaction" } as never,
708
- fromExtension: false,
709
- reason: "manual",
710
- willRetry: false,
711
- });
712
- assert.deepEqual(recorded.userMessages, ["Run focused tests"]);
713
-
714
- handleExecutionCompactFailed(pi, ctx, {
715
- type: "session_compact_failed",
716
- reason: "manual",
717
- aborted: false,
718
- willRetry: false,
719
- fromExtension: true,
720
- errorMessage: "boom",
721
- });
722
- assert.ok(getExecution(), "compaction failure must keep execution active");
723
- assert.ok(recorded.notifies.some((note) => note.severity === "warning" && note.message.includes("execution remains active")));
724
-
725
- await stopExecution(pi, ctx, "test-done");
726
- });
727
-
728
- it("builds a VCC execution summary with phase context and previous summary", async () => {
729
- const workdir = freshWorkdir();
730
- startActiveRun(workdir);
731
- const { pi, ctx } = makeHarness(workdir);
732
- await startExecution(pi, ctx, path.join(workdir, "PLAN_v10.md"), items("VC-001", "VC-002"), [
733
- { id: "I-001", text: "First item." },
734
- { id: "I-002", text: "Second item." },
735
- ]);
736
-
737
- const previousSummary = "## Legacy Summary\nDeliver auto-compact in execution phase.";
738
- applyCurrentIMarker("[I-002:current]");
739
- const preparation = makePreparation("threshold", previousSummary);
740
- const branchEntries: any[] = [
741
- { id: "u-1", type: "message", message: { role: "user", content: [{ type: "text", text: "implement VC-001" }] } },
742
- { id: "a-1", type: "message", message: { role: "assistant", content: [{ type: "text", text: "wrote helper [I-002:current]" }] } },
743
- { id: "u-2", type: "message", message: { role: "user", content: [{ type: "text", text: "implement VC-002" }] } },
744
- { id: "a-2", type: "message", message: { role: "assistant", content: [{ type: "text", text: "almost done" }] } },
745
- ];
746
- const result = buildExecutionCompactionResult(
747
- {
748
- type: "session_before_compact",
749
- preparation,
750
- branchEntries,
751
- customInstructions: "keep:1",
752
- reason: "threshold",
753
- willRetry: false,
754
- signal: new AbortController().signal,
755
- },
756
- ctx,
757
- );
758
- assert.ok(result);
759
- assert.equal(result!.firstKeptEntryId, "u-2");
760
- assert.match(result!.summary, /\[Session Goal\]/);
761
- assert.match(result!.summary, /Execute accepted plan/);
762
- assert.match(result!.summary, /\[Outstanding Context\]/);
763
- assert.match(result!.summary, /Current implementation item: I-002/);
764
- assert.match(result!.summary, /Remaining verifier items: VC-001, VC-002/);
765
- assert.match(result!.summary, /Previous compact summary: Legacy Summary Deliver auto-compact/);
766
- assert.equal((result!.details as any).compactor, "pi-vcc");
767
- assert.equal((result!.details as any).phase, "execution");
768
-
769
- await stopExecution(pi, ctx, "test-done");
770
- });
771
-
772
- it("does not call model helpers and respects overrideDefaultCompaction=false", async () => {
773
- const workdir = freshWorkdir();
774
- startActiveRun(workdir);
775
- const { pi, ctx } = makeHarness(workdir);
776
- await startExecution(pi, ctx, path.join(workdir, "PLAN_v21.md"), items("VC-001"), [
777
- { id: "I-001", text: "First item." },
778
- ]);
779
- let completeCalled = false;
780
- (ctx.modelRegistry as any).complete = async () => {
781
- completeCalled = true;
782
- return {};
783
- };
784
- const event: any = {
785
- type: "session_before_compact",
786
- preparation: makePreparation("threshold", null),
787
- branchEntries: compactableBranchEntries(),
788
- reason: "threshold",
789
- willRetry: false,
790
- signal: new AbortController().signal,
791
- };
792
- const valid = handleExecutionBeforeCompact(pi, ctx, event);
793
- assert.ok(valid?.compaction);
794
- assert.equal(completeCalled, false);
795
-
796
- const configPath = path.join(workdir, ".git", "pi_plans", "pi-vcc-config.json");
797
- fs.writeFileSync(configPath, JSON.stringify({ overrideDefaultCompaction: false }), "utf8");
798
- const fallback = handleExecutionBeforeCompact(pi, ctx, { ...event, signal: new AbortController().signal });
799
- assert.equal(fallback, undefined, "override-disabled should return control to Pi default compaction");
800
-
801
- await stopExecution(pi, ctx, "model-test");
802
- });
803
- it("filters the hidden resume message out of the model context payload", () => {
804
- const messages = [
805
- { customType: "user", content: "real prompt" },
806
- { customType: "pi-plans-exec-resume", content: "Continue execution." },
807
- { customType: "pi-plans-plan-resume", content: "Continue planning." },
808
- { customType: "assistant", content: "ok" },
809
- ];
810
- assert.equal(filterExecutionResumeMessages(messages).length, 3);
811
- assert.equal(filterPlanningResumeMessages(messages).length, 3);
812
- });
813
-
814
- it("builds a VCC planning summary from active-run session context", () => {
815
- const workdir = freshWorkdir();
816
- const { ctx } = makeHarness(workdir);
817
- initState(workdir);
818
- const { run } = startRun(workdir, { topic: "planning compact", skill: "plan-normal", requestText: "demo" });
819
- const planPath = path.join(run.artifact_dir, "PLAN_v1.md");
820
- fs.writeFileSync(planPath, "# plan");
821
-
822
- const branchEntries = [
823
- { id: "rs", type: "custom", customType: PLANNING_RUN_START_CUSTOM_TYPE, data: { runId: run.run_id, artifactDir: run.artifact_dir } },
824
- { id: "u-1", type: "message", message: { role: "user", content: [{ type: "text", text: "background question?" }] } },
825
- { id: "a-1", type: "message", message: { role: "assistant", content: [{ type: "text", text: "some context [I-004:current]" }] } },
826
- { id: "pw", type: "custom", customType: PLANNING_PLAN_WRITTEN_CUSTOM_TYPE, data: { runId: run.run_id, planPath } },
827
- { id: "u-2", type: "message", message: { role: "user", content: [{ type: "text", text: "review please" }] } },
828
- ] as any;
829
- const withPlan = buildPlanningCompactionResult(
830
- {
831
- type: "session_before_compact",
832
- preparation: makePreparation("threshold", "## Previous\nEarlier summary."),
833
- branchEntries,
834
- customInstructions: "keep:1",
835
- reason: "threshold",
836
- willRetry: false,
837
- signal: new AbortController().signal,
838
- },
839
- ctx,
296
+ it("a failing audit (real flow) rolls covered tasks back and persists the outcome", async () => {
297
+ // F-006/F-010: drive the real runAuditFlow via restoreFromSession with
298
+ // an injected failing runner — no manual audit-state mutation.
299
+ __setAuditRunnerForTests(async ({ checklist }) => {
300
+ // Mirror applyAuditOutcome: passed checks are marked done.
301
+ const vc1 = checklist.find((item) => item.id === "VC-001");
302
+ if (vc1) vc1.done = true;
303
+ return { round: 1, passed: ["VC-001"], failed: ["VC-002"], rolledBack: [], report: "VC-002 fails: core tests missing" };
304
+ });
305
+ const { workdir, planPath, runId } = freshWorkdir();
306
+ const { ctx } = await start(planPath, workdir);
307
+ for (const id of ["Task-1", "Task-2", "Task-3"]) {
308
+ applyTaskUpdate(getExecution()!.tasks, id, "complete", `${id} evidence`);
309
+ persistTaskProgress(ctx);
310
+ }
311
+ const snapshot = getExecution();
312
+ await restoreFromSession(ctx, [{ type: "custom", customType: "pi-plans-exec", data: snapshot }]);
313
+ const ex = getExecution()!;
314
+ // Fail-closed: the audit rolled VC-002's covered tasks back to pending.
315
+ assert.equal(ex.tasks.find((t) => t.id === "Task-1")?.status, "complete");
316
+ assert.equal(ex.tasks.find((t) => t.id === "Task-2")?.status, "pending");
317
+ assert.equal(ex.tasks.find((t) => t.id === "Task-3")?.status, "pending");
318
+ const load = loadCheckpoint(workdir, runId!);
319
+ assert.ok(load.status === "ok");
320
+ assert.equal(load.checkpoint.execution?.audit?.rounds, 1);
321
+ assert.equal(load.checkpoint.execution?.audit?.lastResult, "VC-002");
322
+ __setAuditRunnerForTests(null);
323
+ await stopExecution(ctx, "test teardown");
324
+ });
325
+
326
+ it("audit round cap: interactive sessions pause, headless stops", async () => {
327
+ __setAuditRunnerForTests(async ({ checklist }) => {
328
+ const failed = checklist.filter((item) => !["VC-001"].includes(item.id)).map((item) => item.id);
329
+ return { round: 1, passed: ["VC-001"], failed, rolledBack: [], report: "still failing" };
330
+ });
331
+ const interactive = await (async () => {
332
+ const { workdir, planPath, runId } = freshWorkdir();
333
+ const { ctx } = await start(planPath, workdir);
334
+ for (const id of ["Task-1", "Task-2", "Task-3"]) {
335
+ applyTaskUpdate(getExecution()!.tasks, id, "complete", `${id} evidence`);
336
+ persistTaskProgress(ctx);
337
+ }
338
+ // Simulate three exhausted rounds persisted from earlier attempts.
339
+ getExecution()!.audit.rounds = 3;
340
+ getExecution()!.audit.failed = ["VC-002"];
341
+ mutateCheckpoint(workdir, runId!, (cp) => applyExecutionProgress(cp, { audit: { rounds: 3, lastResult: "VC-002" } }));
342
+ const snapshot = getExecution();
343
+ const ctxUi = { ...makeCtx(workdir), mode: "tui" as const };
344
+ await restoreFromSession(ctxUi, [{ type: "custom", customType: "pi-plans-exec", data: snapshot }]);
345
+ const ex = getExecution()!;
346
+ // hasUI ctx → interactive pause, execution state kept.
347
+ assert.equal(ex.stall.paused, true, "interactive cap pauses");
348
+ assert.match(ex.stall.pausedReason ?? "", /exhausted 3 rounds/);
349
+ await stopExecution(ctxUi, "test teardown");
350
+ return loadCheckpoint(workdir, runId!);
351
+ })();
352
+ assert.ok(interactive.status === "ok");
353
+ // Headless: no UI → bounded stop.
354
+ const { workdir: wd2, planPath: pp2, runId: r2 } = freshWorkdir();
355
+ const { ctx: ctx3 } = await start(pp2, wd2);
356
+ for (const id of ["Task-1", "Task-2", "Task-3"]) {
357
+ applyTaskUpdate(getExecution()!.tasks, id, "complete", `${id} evidence`);
358
+ persistTaskProgress(ctx3);
359
+ }
360
+ getExecution()!.audit.rounds = 3;
361
+ getExecution()!.audit.failed = ["VC-002"];
362
+ const headlessCtx = { ...makeCtx(wd2), hasUI: false } as never;
363
+ await restoreFromSession(headlessCtx, [{ type: "custom", customType: "pi-plans-exec", data: getExecution() }]);
364
+ assert.equal(getExecution(), null, "headless cap stops and clears execution");
365
+ const stopped = loadCheckpoint(wd2, r2!);
366
+ assert.ok(stopped.status === "ok");
367
+ assert.match(stopped.checkpoint.execution?.pausedReason ?? "", /exhausted 3 rounds/);
368
+ __setAuditRunnerForTests(null);
369
+ });
370
+
371
+ it("a checkpoint carrying a 0.6.0 delegated executor refuses the direct load", async () => {
372
+ const { workdir, planPath, runId } = freshWorkdir();
373
+ const { ctx } = await start(planPath, workdir);
374
+ applyTaskUpdate(getExecution()!.tasks, "Task-1", "complete", "e1");
375
+ persistTaskProgress(ctx);
376
+ await stopExecution(ctx, "restart-sim");
377
+ mutateCheckpoint(workdir, runId!, (cp) =>
378
+ applyExecutionProgress(cp as never, { delegate: { modelSelector: "x/y", startedAt: "2026-01-01T00:00:00Z" } } as never),
840
379
  );
841
- assert.ok(withPlan);
842
- assert.deepEqual(withPlan!.firstKeptEntryId, "u-2");
843
- assert.match(withPlan!.summary, /\[Session Goal\]/);
844
- assert.match(withPlan!.summary, new RegExp(run.run_id));
845
- assert.match(withPlan!.summary, /\[Outstanding Context\]/);
846
- assert.match(withPlan!.summary, /Latest plan path from session/);
847
- assert.match(withPlan!.summary, /Planning artifact directory from session/);
848
- assert.match(withPlan!.summary, /Current implementation marker observed during planning: I-004/);
849
- assert.match(withPlan!.summary, /Previous compact summary: Previous Earlier summary\./);
850
- assert.equal((withPlan!.details as any).compactor, "pi-vcc");
851
- assert.equal((withPlan!.details as any).phase, "planning");
852
- });
853
-
854
- it("planning hook is gated by run.status=planning and defers while execution is running", async () => {
855
- const workdir = freshWorkdir();
856
- const { pi, ctx } = makeHarness(workdir);
857
- initState(workdir);
858
- const { run } = startRun(workdir, { topic: "planning gate", skill: "plan-small", requestText: "x" });
859
-
860
- const resultPlanning = handlePlanningBeforeCompact(pi as any, ctx as any, {
861
- type: "session_before_compact",
862
- preparation: makePreparation("threshold", null),
863
- branchEntries: compactableBranchEntries(),
864
- customInstructions: "keep:1",
865
- reason: "threshold",
866
- willRetry: false,
867
- signal: new AbortController().signal,
868
- });
869
- assert.ok(resultPlanning?.compaction);
870
-
871
- setRunStatus(workdir, run.run_id, "done");
872
- const resultDone = handlePlanningBeforeCompact(pi as any, ctx as any, {
873
- type: "session_before_compact",
874
- preparation: makePreparation("threshold", null),
875
- branchEntries: compactableBranchEntries(),
876
- reason: "threshold",
877
- willRetry: false,
878
- signal: new AbortController().signal,
879
- });
880
- assert.equal(resultDone, undefined);
881
-
882
- setRunStatus(workdir, run.run_id, "planning");
883
- await startExecution(pi, ctx, path.join(workdir, "PLAN_v14.md"), items("VC-001"));
884
- const duringExecution = handlePlanningBeforeCompact(pi as any, ctx as any, {
885
- type: "session_before_compact",
886
- preparation: makePreparation("threshold", null),
887
- branchEntries: compactableBranchEntries(),
888
- reason: "threshold",
889
- willRetry: false,
890
- signal: new AbortController().signal,
891
- });
892
- assert.equal(duringExecution, undefined);
893
- await stopExecution(pi, ctx, "planning-gate");
894
- });
895
-
896
- it("turn_end writes are unconditionally deferred even when isIdle reads true", async () => {
897
- const workdir = freshWorkdir();
898
- const { pi, ctx, recorded, setUsagePercent } = makeHarness(workdir);
899
- await startExecution(pi, ctx, path.join(workdir, "PLAN_v11.md"), items("VC-001", "VC-002"));
900
- const snapshotCount = () => recorded.entries.filter((e) => e.customType === "pi-plans-exec").length;
901
- const baseline = snapshotCount();
902
-
903
- // No isIdle override: the harness default (() => true) IS the real
904
- // turn_end reading — this encodes the field regression (60 writes in
905
- // 23 minutes) as a permanent zero-write assertion.
906
- recordExecutionTurn(pi, ctx, [], { input: 10, output: 5 });
907
- setUsagePercent(50);
908
- handleExecutionBeforeCompact(pi, ctx, {
909
- type: "session_before_compact",
910
- preparation: makePreparation("threshold", null),
911
- branchEntries: [],
912
- reason: "threshold",
913
- willRetry: false,
914
- signal: new AbortController().signal,
915
- });
916
- assert.equal(snapshotCount(), baseline, "turn_end wrote session entries despite deferral");
917
- // Status line stays real-time while the write is deferred.
918
- assert.match(recorded.status ?? "", /plans: .* ▸ exec/);
919
-
920
- drainExecutionFlush(pi, ctx);
921
- assert.equal(snapshotCount(), baseline + 1, "settle flush did not write exactly one snapshot");
922
- const last = (recorded.entries.filter((e) => e.customType === "pi-plans-exec").at(-1)?.data ?? {}) as { usage?: { inToks: number } };
923
- assert.equal(last.usage?.inToks, 10, "flushed snapshot missing busy-turn usage");
924
- drainExecutionFlush(pi, ctx);
925
- assert.equal(snapshotCount(), baseline + 1, "second drain wrote again");
926
- assert.equal(consumePendingExecutionFlush(), false);
927
-
928
- await stopExecution(pi, ctx, "done");
929
- });
930
-
931
- it("stop and complete drain pending writes synchronously with final state", async () => {
932
- const workdir = freshWorkdir();
933
- const { pi, ctx, recorded } = makeHarness(workdir);
934
- await startExecution(pi, ctx, path.join(workdir, "PLAN_v12.md"), items("VC-001", "VC-002"));
935
- const snapshotCount = () => recorded.entries.filter((e) => e.customType === "pi-plans-exec").length;
936
-
937
- ctx.isIdle = () => false;
938
- recordExecutionTurn(pi, ctx, [], { input: 7, output: 3 });
939
- const busyCount = snapshotCount();
940
-
941
- await stopExecution(pi, ctx, "force");
942
- assert.ok(snapshotCount() > busyCount, "stop did not write the final snapshot");
943
- assert.ok(recorded.entries.some((e) => e.customType === "pi-plans-exec-cleared"));
944
- const lastStop = (recorded.entries.filter((e) => e.customType === "pi-plans-exec").at(-1)?.data ?? {}) as { usage?: { inToks: number } };
945
- assert.equal(lastStop.usage?.inToks, 7, "stop lost the busy-turn usage");
946
-
947
- await startExecution(pi, ctx, path.join(workdir, "PLAN_v13.md"), items("VC-001"));
948
- ctx.isIdle = () => false;
949
- applyDoneMarkers("[DONE:VC-001]");
950
- recordExecutionTurn(pi, ctx, ["VC-001"], { input: 1, output: 1 });
951
- await completeExecution(pi, ctx);
952
- assert.equal(getExecution(), null);
953
- const lastComplete = (recorded.entries.filter((e) => e.customType === "pi-plans-exec").at(-1)?.data ?? {}) as { items?: Array<{ done: boolean }> };
954
- assert.equal(lastComplete.items?.[0]?.done, true, "completion snapshot missing final done state");
955
- });
956
-
957
- it("updates the status bar in real time on every turn without session writes", async () => {
958
- const workdir = freshWorkdir();
959
- const { pi, ctx, recorded, setUsagePercent } = makeHarness(workdir);
960
- await startExecution(pi, ctx, path.join(workdir, "PLAN_v15.md"), items("VC-001", "VC-002"), [
961
- { id: "I-001", text: "First item." },
962
- { id: "I-002", text: "Second item." },
963
- ]);
964
- const snapshotCount = () => recorded.entries.filter((e) => e.customType === "pi-plans-exec").length;
965
- const baseline = snapshotCount();
966
-
967
- recordExecutionTurn(pi, ctx, [], { input: 33, output: 11 });
968
- // Real-time: status line already reflects the turn's usage...
969
- assert.match(recorded.status ?? "", /plans: .* ▸ exec/);
970
- assert.match(recorded.status ?? "", /I 0\/2/);
971
- assert.match(recorded.status ?? "", /VC 0\/2/);
972
- // ...without any session write (anti-jitter preserved).
973
- assert.equal(snapshotCount(), baseline);
974
-
975
- setUsagePercent(null);
976
- drainExecutionFlush(pi, ctx);
977
- assert.equal(snapshotCount(), baseline + 1);
978
-
979
- await stopExecution(pi, ctx, "done");
980
- });
981
-
982
- it("syncs progress through the registered message_end and turn_end handlers", async () => {
983
- const workdir = freshWorkdir();
984
- const { pi, ctx, recorded, emit } = makeHarness(workdir);
985
- registerExecutionTurnHandlers(pi);
986
- await startExecution(pi, ctx, path.join(workdir, "PLAN_v19.md"), items("VC-001", "VC-002", "VC-003"));
987
-
988
- // The usage is delivered by message_end and consumed by the following
989
- // turn_end, matching Pi's lifecycle contract.
990
- await emit("message_end", {
991
- message: { role: "assistant", usage: { input: 12, output: 5 } },
992
- });
993
- await emit("turn_end", {
994
- message: { role: "assistant", content: [{ type: "text", text: "verified [DONE:VC-001]" }] },
995
- });
996
- assert.match(recorded.status ?? "", /VC 1\/3/);
997
- assert.match(recorded.status ?? "", /next: Verify VC-002/);
998
-
999
- await emit("message_end", {
1000
- message: { role: "assistant", usage: { input: 8, output: 3 } },
1001
- });
1002
- await emit("turn_end", {
1003
- message: { role: "assistant", content: [{ type: "text", text: "verified [DONE:VC-002]" }] },
1004
- });
1005
- assert.match(recorded.status ?? "", /VC 2\/3/);
1006
-
1007
- await stopExecution(pi, ctx, "event-chain-test");
1008
- });
1009
- it("applies impl markers with silent unknown ids and later-overwrite semantics", async () => {
1010
- const workdir = freshWorkdir();
1011
- const { pi, ctx } = makeHarness(workdir);
1012
- await startExecution(pi, ctx, path.join(workdir, "PLAN_v16.md"), items("VC-001"), [
1013
- { id: "I-001", text: "First item." },
1014
- ]);
1015
-
1016
- assert.deepEqual(applyImplMarkers("[I-999:implemented]"), []); // unknown id silently ignored
1017
- assert.deepEqual(applyImplMarkers("[I-001:implemented]"), ["I-001"]);
1018
- assert.deepEqual(applyImplMarkers("[I-001:validating]"), ["I-001"]); // later overwrites
1019
- assert.equal(getExecution()?.implStatus?.["I-001"], "validating");
1020
-
1021
- await stopExecution(pi, ctx, "done");
1022
- });
1023
-
1024
- it("applies current-I markers to the live progress bar and snapshot", async () => {
1025
- const workdir = freshWorkdir();
1026
- const { pi, ctx, recorded, emit } = makeHarness(workdir);
1027
- registerExecutionTurnHandlers(pi);
1028
- await startExecution(pi, ctx, path.join(workdir, "PLAN_v20.md"), items("VC-001", "VC-002", "VC-003"), [
1029
- { id: "I-001", text: "First item." },
1030
- { id: "I-002", text: "Second item." },
1031
- { id: "I-003", text: "Third item." },
1032
- ]);
1033
-
1034
- await emit("turn_end", {
1035
- message: { role: "assistant", content: [{ type: "text", text: "starting [I-003:current]" }] },
1036
- });
1037
- assert.match(recorded.status ?? "", /I 0\/3/);
1038
- assert.match(recorded.status ?? "", /next: Implement I-003/);
1039
- assert.equal(getExecution()?.currentI, "I-003");
1040
- drainExecutionFlush(pi, ctx);
1041
- const snapshot = recorded.entries.filter((entry) => entry.customType === "pi-plans-exec").at(-1)?.data as { currentI?: string };
1042
- assert.equal(snapshot.currentI, "I-003");
1043
- assert.equal(applyCurrentIMarker("[I-999:current]"), false);
1044
- await stopExecution(pi, ctx, "current-I-test");
1045
- });
1046
- it("replays impl markers from post-snapshot messages on restore", async () => {
1047
- const workdir = freshWorkdir();
1048
- const { pi, ctx } = makeHarness(workdir);
1049
- const snapshot = {
1050
- planPath: path.join(workdir, "PLAN_v17.md"),
1051
- items: items("VC-001"),
1052
- startedAt: "2026-08-29T00:00:00Z",
1053
- implItems: [{ id: "I-001", text: "First item." }],
1054
- implStatus: {},
1055
- };
1056
- // The restore path re-parses the plan file (stale-snapshot distrust), so
1057
- // the file must actually carry the Implementation Items section.
1058
- fs.writeFileSync(snapshot.planPath, "# plan\n\n## Implementation Items\n\n- `I-001`: First item.\n");
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
-
1068
- it("keeps the injection rules teaching the impl markers", async () => {
1069
- const workdir = freshWorkdir();
1070
- const { pi, ctx } = makeHarness(workdir);
1071
- await startExecution(pi, ctx, path.join(workdir, "PLAN_v18.md"), items("VC-001"), [
1072
- { id: "I-001", text: "First item." },
1073
- ]);
1074
- const rules = executionContextMessage(ctx)!;
1075
- assert.match(rules, /\[I-001:implemented\]/);
1076
- assert.match(rules, /\[I-001:validating\]/);
1077
- assert.match(rules, /subprocess-backed verification/);
1078
- assert.match(rules, /waiting for/);
1079
- assert.match(rules, /5s\s*->\s*10s\s*->\s*20s\s*->\s*40s\s*->\s*80s/);
1080
- assert.match(rules, /keep polling at 80s/);
1081
- assert.match(rules, /restart at 5s for each new subprocess/);
1082
- await stopExecution(pi, ctx, "done");
1083
- });
1084
-
1085
- it("does not proactively request planning compaction", () => {
1086
- const workdir = freshWorkdir();
1087
- const { ctx, setUsagePercent, recorded } = makeHarness(workdir);
1088
- initState(workdir);
1089
- startRun(workdir, { topic: "planning no-op", skill: "plan-normal", requestText: "x" });
1090
-
1091
- setUsagePercent(120);
1092
- assert.equal(shouldTriggerPlanningCompaction(ctx as any), false);
1093
- requestPlanningCompaction(ctx as any);
1094
- refreshPlanningCompactionCooldown(ctx as any);
1095
- assert.equal(recorded.compacts?.length ?? 0, 0, "planning scheduling is owned by Pi core");
1096
- });
1097
-
1098
- it("auto-continues planning only for threshold/overflow and honors manual follow-up prompts", async () => {
1099
- const workdir = freshWorkdir();
1100
- const { pi, ctx, recorded } = makeHarness(workdir);
1101
- (ctx as any).piVersion = "0.84.3";
1102
- initState(workdir);
1103
- startRun(workdir, { topic: "planning success", skill: "plan-normal", requestText: "x" });
1104
-
1105
- const threshold = handlePlanningBeforeCompact(pi as any, ctx as any, {
1106
- type: "session_before_compact",
1107
- preparation: makePreparation("threshold", null),
1108
- branchEntries: compactableBranchEntries(),
1109
- customInstructions: "keep:1",
1110
- reason: "threshold",
1111
- willRetry: false,
1112
- signal: new AbortController().signal,
1113
- });
1114
- assert.ok(threshold?.compaction);
1115
- await handlePlanningCompact(pi as any, ctx as any, {
1116
- type: "session_compact",
1117
- compactionEntry: { type: "compaction" } as never,
1118
- fromExtension: false,
1119
- reason: "threshold",
1120
- willRetry: false,
1121
- });
1122
- assert.equal(recorded.messages.filter((message) => message.customType === "pi-plans-plan-resume").length, 1);
1123
- assert.equal(consumePlanningCompactionResumeGuard(ctx as any), true);
1124
-
1125
- handlePlanningBeforeCompact(pi as any, ctx as any, {
1126
- type: "session_before_compact",
1127
- preparation: makePreparation("manual", null),
1128
- branchEntries: compactableBranchEntries(),
1129
- customInstructions: "Ask the next question keep:1",
1130
- reason: "manual",
1131
- willRetry: false,
1132
- signal: new AbortController().signal,
1133
- });
1134
- await handlePlanningCompact(pi as any, ctx as any, {
1135
- type: "session_compact",
1136
- compactionEntry: { type: "compaction" } as never,
1137
- fromExtension: false,
1138
- reason: "manual",
1139
- willRetry: false,
1140
- });
1141
- assert.deepEqual(recorded.userMessages, ["Ask the next question"]);
1142
- assert.equal(recorded.messages.filter((message) => message.customType === "pi-plans-plan-resume").length, 1);
1143
- });
1144
-
1145
- it("planning request helper remains a no-op regardless of idleness", () => {
1146
- const workdir = freshWorkdir();
1147
- const { ctx, recorded, setUsagePercent } = makeHarness(workdir);
1148
- initState(workdir);
1149
- startRun(workdir, { topic: "idle no-op", skill: "plan-normal", requestText: "x" });
1150
- setUsagePercent(120);
1151
-
1152
- (ctx as any).isIdle = () => false;
1153
- requestPlanningCompaction(ctx as any);
1154
- (ctx as any).isIdle = () => true;
1155
- (ctx as any).hasPendingMessages = () => false;
1156
- requestPlanningCompaction(ctx as any);
1157
- assert.equal(recorded.compacts?.length ?? 0, 0);
1158
- assert.equal((ctx as any).sessionManager.__planningCompaction, undefined);
1159
- });
1160
-
1161
- it("planning compact failures notify but do not re-request proactively", () => {
1162
- const workdir = freshWorkdir();
1163
- const { pi, ctx, recorded } = makeHarness(workdir);
1164
- initState(workdir);
1165
- startRun(workdir, { topic: "failure notify", skill: "plan-normal", requestText: "x" });
1166
- handlePlanningBeforeCompact(pi as any, ctx as any, {
1167
- type: "session_before_compact",
1168
- preparation: makePreparation("threshold", null),
1169
- branchEntries: compactableBranchEntries(),
1170
- customInstructions: "keep:1",
1171
- reason: "threshold",
1172
- willRetry: false,
1173
- signal: new AbortController().signal,
1174
- });
1175
-
1176
- handlePlanningCompactFailed(pi as any, ctx as any, {
1177
- type: "session_compact_failed",
1178
- reason: "manual",
1179
- errorMessage: "Compaction failed: Nothing to compact (session too small)",
1180
- aborted: false,
1181
- willRetry: false,
1182
- fromExtension: false,
1183
- });
1184
- assert.ok(recorded.notifies.some((note) => note.message.includes("nothing to summarize")));
1185
- requestPlanningCompaction(ctx as any);
1186
- assert.equal(recorded.compacts?.length ?? 0, 0);
1187
-
1188
- handlePlanningBeforeCompact(pi as any, ctx as any, {
1189
- type: "session_before_compact",
1190
- preparation: makePreparation("threshold", null),
1191
- branchEntries: compactableBranchEntries(),
1192
- customInstructions: "keep:1",
1193
- reason: "threshold",
1194
- willRetry: false,
1195
- signal: new AbortController().signal,
1196
- });
1197
- handlePlanningCompactFailed(pi as any, ctx as any, {
1198
- type: "session_compact_failed",
1199
- reason: "manual",
1200
- errorMessage: "network down",
1201
- aborted: false,
1202
- willRetry: false,
1203
- fromExtension: false,
1204
- });
1205
- assert.ok(recorded.notifies.some((note) => note.message.includes("will try again")));
1206
- });
1207
-
1208
- it("execution turn compaction helper remains a no-op and failures keep execution active", async () => {
1209
- const workdir = freshWorkdir();
1210
- startActiveRun(workdir);
1211
- const { pi, ctx, recorded, setUsagePercent } = makeHarness(workdir);
1212
- await startExecution(pi, ctx, path.join(workdir, "PLAN_v23.md"), items("VC-001"), [
1213
- { id: "I-001", text: "First item." },
1214
- ]);
1215
-
1216
- setUsagePercent(96);
1217
- handleExecutionTurnCompaction(ctx);
1218
- assert.equal(recorded.compacts?.length ?? 0, 0, "execution scheduling is owned by Pi core");
1219
-
1220
- handleExecutionBeforeCompact(pi, ctx, {
1221
- type: "session_before_compact",
1222
- preparation: makePreparation("threshold", null),
1223
- branchEntries: compactableBranchEntries(),
1224
- customInstructions: "keep:1",
1225
- reason: "threshold",
1226
- willRetry: false,
1227
- signal: new AbortController().signal,
1228
- });
1229
- handleExecutionCompactFailed(pi, ctx, {
1230
- type: "session_compact_failed",
1231
- reason: "manual",
1232
- errorMessage: "Compaction failed: Already compacted",
1233
- aborted: false,
1234
- willRetry: false,
1235
- fromExtension: false,
1236
- });
1237
- assert.ok(getExecution());
1238
- assert.ok(recorded.notifies.some((note) => note.message.includes("nothing to summarize")));
1239
-
1240
- await stopExecution(pi, ctx, "test-done");
1241
- });
1242
-
1243
- it("treats planning abort/stream compact failures as terminal and notifies explicitly", () => {
1244
- const workdir = freshWorkdir();
1245
- const { pi, ctx, recorded } = makeHarness(workdir);
1246
- initState(workdir);
1247
- startRun(workdir, { topic: "abort-stream classification", skill: "plan-normal", requestText: "x" });
1248
-
1249
- const abortMessages = [
1250
- "Auto-compaction failed: Turn prefix summarization failed: This operation was aborted",
1251
- "Error: OpenAI Responses stream ended before a terminal response event",
1252
- "Auto-compaction failed: context overflow recovery failed",
1253
- "this operation was aborted",
1254
- "aborted",
1255
- ];
1256
- for (const errorMessage of abortMessages) {
1257
- recorded.notifies.length = 0;
1258
- handlePlanningBeforeCompact(pi as any, ctx as any, {
1259
- type: "session_before_compact",
1260
- preparation: makePreparation("threshold", null),
1261
- branchEntries: compactableBranchEntries(),
1262
- customInstructions: "keep:1",
1263
- reason: "threshold",
1264
- willRetry: false,
1265
- signal: new AbortController().signal,
1266
- });
1267
- handlePlanningCompactFailed(pi as any, ctx as any, {
1268
- type: "session_compact_failed",
1269
- reason: "threshold",
1270
- errorMessage,
1271
- aborted: errorMessage.includes("aborted"),
1272
- willRetry: false,
1273
- fromExtension: false,
1274
- });
1275
- const backoffNote = recorded.notifies.find((n) => n.message.includes("was aborted"));
1276
- assert.ok(backoffNote, `expected abort-class notify for: ${errorMessage}`);
1277
- assert.match(backoffNote!.message, /provider interruption|competing manual/);
1278
- const before = recorded.compacts?.length ?? 0;
1279
- requestPlanningCompaction(ctx as any);
1280
- assert.equal((recorded.compacts?.length ?? 0) - before, 0);
380
+ const load = loadExecutionFromCheckpoint(makeCtx(workdir), runId!);
381
+ assert.equal(load.legacyDelegate, true);
382
+ assert.equal(load.status, "no-execution");
383
+ assert.equal(getExecution(), null, "delegate orphans never resume without a fresh handoff");
384
+ });
385
+
386
+ it("resuming an audit-cap pause grants a fresh audit budget and completes", async () => {
387
+ // Round 2 F-001 regression nail: cap → pause → resume resets rounds →
388
+ // the audit runs again and can now complete the run.
389
+ let runnerCalls = 0;
390
+ __setAuditRunnerForTests(async ({ checklist }) => {
391
+ runnerCalls += 1;
392
+ return {
393
+ round: runnerCalls,
394
+ passed: checklist.map((item) => {
395
+ item.done = true;
396
+ return item.id;
397
+ }),
398
+ failed: [],
399
+ rolledBack: [],
400
+ report: "all pass",
401
+ };
402
+ });
403
+ const { workdir, planPath, runId } = freshWorkdir();
404
+ const { ctx } = await start(planPath, workdir);
405
+ for (const id of ["Task-1", "Task-2", "Task-3"]) {
406
+ applyTaskUpdate(getExecution()!.tasks, id, "complete", `${id} evidence`);
407
+ persistTaskProgress(ctx);
1281
408
  }
1282
- });
1283
-
1284
- it("event.aborted=true with empty errorMessage still enters abort-class handling", () => {
1285
- const workdir = freshWorkdir();
1286
- const { pi, ctx, recorded } = makeHarness(workdir);
1287
- initState(workdir);
1288
- startRun(workdir, { topic: "aborted-no-message", skill: "plan-normal", requestText: "x" });
1289
- handlePlanningBeforeCompact(pi as any, ctx as any, {
1290
- type: "session_before_compact",
1291
- preparation: makePreparation("threshold", null),
1292
- branchEntries: compactableBranchEntries(),
1293
- customInstructions: "keep:1",
1294
- reason: "threshold",
1295
- willRetry: false,
1296
- signal: new AbortController().signal,
1297
- });
1298
-
1299
- handlePlanningCompactFailed(pi as any, ctx as any, {
1300
- type: "session_compact_failed",
1301
- reason: "threshold",
1302
- errorMessage: undefined,
1303
- aborted: true,
1304
- willRetry: false,
1305
- fromExtension: false,
1306
- });
1307
- const backoffNote = recorded.notifies.find((n) => n.message.includes("was aborted"));
1308
- assert.ok(backoffNote);
1309
- const before = recorded.compacts?.length ?? 0;
1310
- requestPlanningCompaction(ctx as any);
1311
- assert.equal((recorded.compacts?.length ?? 0) - before, 0);
1312
- });
1313
-
1314
- it("network-style planning failures stay retryable without proactive requests", () => {
1315
- const workdir = freshWorkdir();
1316
- const { pi, ctx, recorded } = makeHarness(workdir);
1317
- initState(workdir);
1318
- startRun(workdir, { topic: "network-retryable", skill: "plan-normal", requestText: "x" });
1319
- handlePlanningBeforeCompact(pi as any, ctx as any, {
1320
- type: "session_before_compact",
1321
- preparation: makePreparation("threshold", null),
1322
- branchEntries: compactableBranchEntries(),
1323
- customInstructions: "keep:1",
1324
- reason: "threshold",
1325
- willRetry: false,
1326
- signal: new AbortController().signal,
1327
- });
1328
-
1329
- handlePlanningCompactFailed(pi as any, ctx as any, {
1330
- type: "session_compact_failed",
1331
- reason: "threshold",
1332
- errorMessage: "network down",
1333
- aborted: false,
1334
- willRetry: false,
1335
- fromExtension: false,
1336
- });
1337
- const abortNote = recorded.notifies.find((n) => n.message.includes("was aborted"));
1338
- assert.equal(abortNote, undefined, "network down must not be classified as abort-class");
1339
- const retryNote = recorded.notifies.find((n) => n.message.includes("will try again"));
1340
- assert.ok(retryNote, "network down must keep the retryable path");
1341
- const before = recorded.compacts?.length ?? 0;
1342
- requestPlanningCompaction(ctx as any);
1343
- assert.equal((recorded.compacts?.length ?? 0) - before, 0);
1344
- });
1345
-
1346
- it("lifecycle flags distinguish unhinted and phase-attributed compactions", () => {
1347
- const workdir = freshWorkdir();
1348
- const { ctx } = makeHarness(workdir);
1349
-
1350
- noteCompactionStarted(ctx as any, undefined);
1351
- assert.equal(compactionInFlight(ctx as any, "planning"), true, "unhinted compaction marks planning inFlight");
1352
- assert.equal(compactionInFlight(ctx as any, "execution"), true, "unhinted compaction marks execution inFlight");
1353
- noteCompactionEnded(ctx as any, undefined);
1354
- assert.equal(compactionInFlight(ctx as any, "planning"), false);
1355
- assert.equal(compactionInFlight(ctx as any, "execution"), false);
1356
-
1357
- noteCompactionStarted(ctx as any, "pi-plans planning auto compact");
1358
- assert.equal(compactionInFlight(ctx as any, "planning"), true);
1359
- assert.equal(compactionInFlight(ctx as any, "execution"), false);
1360
- noteCompactionEnded(ctx as any, "pi-plans planning auto compact");
1361
- assert.equal(compactionInFlight(ctx as any, "planning"), false);
1362
-
1363
- noteCompactionStarted(ctx as any, "pi-plans execution auto compact");
1364
- assert.equal(compactionInFlight(ctx as any, "planning"), false);
1365
- assert.equal(compactionInFlight(ctx as any, "execution"), true);
1366
- noteCompactionEnded(ctx as any, "pi-plans execution auto compact");
1367
- assert.equal(compactionInFlight(ctx as any, "execution"), false);
1368
- });
1369
-
1370
- it("execution handler classifies abort/stream failures as terminal", async () => {
1371
- const workdir = freshWorkdir();
1372
- startActiveRun(workdir);
1373
- const { pi, ctx, recorded } = makeHarness(workdir);
1374
- await startExecution(pi, ctx, path.join(workdir, "PLAN_v4.md"), items("VC-001"));
1375
-
1376
- const abortMessages = [
1377
- "Auto-compaction failed: Turn prefix summarization failed: This operation was aborted",
1378
- "Error: OpenAI Responses stream ended before a terminal response event",
1379
- "Auto-compaction failed: context overflow recovery failed",
1380
- "aborted",
1381
- ];
1382
- for (const errorMessage of abortMessages) {
1383
- recorded.notifies.length = 0;
1384
- handleExecutionBeforeCompact(pi, ctx, {
1385
- type: "session_before_compact",
1386
- preparation: makePreparation("threshold", null),
1387
- branchEntries: compactableBranchEntries(),
1388
- customInstructions: "keep:1",
1389
- reason: "threshold",
1390
- willRetry: false,
1391
- signal: new AbortController().signal,
1392
- });
1393
- handleExecutionCompactFailed(pi, ctx, {
1394
- type: "session_compact_failed",
1395
- reason: "threshold",
1396
- errorMessage,
1397
- aborted: errorMessage.includes("aborted"),
1398
- willRetry: false,
1399
- fromExtension: false,
1400
- });
1401
- const note = recorded.notifies.find((n) => n.message.includes("was aborted"));
1402
- assert.ok(note, `expected abort-class notify for: ${errorMessage}`);
1403
- assert.match(note!.message, /provider interruption|competing manual/);
409
+ // Exhaust the budget, then pause at the cap exactly like runAuditFlow.
410
+ getExecution()!.audit.rounds = 3;
411
+ getExecution()!.audit.failed = ["VC-001", "VC-002"];
412
+ const ctxTui = { ...makeCtx(workdir), mode: "tui" as const };
413
+ const snapshot = getExecution();
414
+ await restoreFromSession(ctxTui, [{ type: "custom", customType: "pi-plans-exec", data: snapshot }]);
415
+ const ex = getExecution()!;
416
+ assert.equal(ex.stall.paused, true, "cap pauses interactively");
417
+ assert.match(ex.stall.pausedReason ?? "", /^completion audit exhausted/);
418
+ // User resumes: the budget resets and the next audit flow completes.
419
+ const { resumeActiveExecution } = await import("../src/exec.ts");
420
+ const resumed = resumeActiveExecution(ctxTui);
421
+ assert.equal(resumed, true);
422
+ assert.equal(getExecution()!.audit.rounds, 0, "resume grants a fresh audit budget");
423
+ const snapshot2 = getExecution();
424
+ await restoreFromSession(ctxTui, [{ type: "custom", customType: "pi-plans-exec", data: snapshot2 }]);
425
+ assert.equal(runnerCalls, 1, "audit re-ran after the resume (fresh budget)");
426
+ const final = loadCheckpoint(workdir, runId!);
427
+ assert.ok(final.status === "ok");
428
+ assert.equal(final.checkpoint.phase, "completed");
429
+ __setAuditRunnerForTests(null);
430
+ });
431
+
432
+ it("a null audit outcome (infra failure) fails every pending check closed", async () => {
433
+ __setAuditRunnerForTests(async () => null);
434
+ const { workdir, planPath, runId } = freshWorkdir();
435
+ const { ctx } = await start(planPath, workdir);
436
+ for (const id of ["Task-1", "Task-2", "Task-3"]) {
437
+ applyTaskUpdate(getExecution()!.tasks, id, "complete", `${id} evidence`);
438
+ persistTaskProgress(ctx);
1404
439
  }
1405
- handleExecutionBeforeCompact(pi, ctx, {
1406
- type: "session_before_compact",
1407
- preparation: makePreparation("threshold", null),
1408
- branchEntries: compactableBranchEntries(),
1409
- customInstructions: "keep:1",
1410
- reason: "threshold",
1411
- willRetry: false,
1412
- signal: new AbortController().signal,
1413
- });
1414
- handleExecutionCompactFailed(pi, ctx, {
1415
- type: "session_compact_failed",
1416
- reason: "threshold",
1417
- errorMessage: undefined,
1418
- aborted: true,
1419
- willRetry: false,
1420
- fromExtension: false,
1421
- });
1422
- const cleanNote = recorded.notifies.find((n) => n.message.includes("was aborted"));
1423
- assert.ok(cleanNote, "aborted-without-message must enter abort-class handling on execution side too");
1424
- await stopExecution(pi, ctx, "test-done");
440
+ const snapshot = getExecution();
441
+ await restoreFromSession(ctx, [{ type: "custom", customType: "pi-plans-exec", data: snapshot }]);
442
+ const ex = getExecution()!;
443
+ assert.deepEqual(ex.audit.failed, ["VC-001", "VC-002"], "unreported checks fail closed");
444
+ for (const id of ["Task-1", "Task-2", "Task-3"]) {
445
+ assert.equal(ex.tasks.find((t) => t.id === id)?.status, "pending", `${id} rolled back`);
446
+ }
447
+ const load = loadCheckpoint(workdir, runId!);
448
+ assert.ok(load.status === "ok");
449
+ assert.equal(load.checkpoint.execution?.audit?.rounds, 1);
450
+ assert.match(load.checkpoint.execution?.audit?.lastResult ?? "", /VC-001/);
451
+ __setAuditRunnerForTests(null);
452
+ await stopExecution(ctx, "test teardown");
453
+ });
454
+
455
+ it("a runner passing a strict subset fails the unreported checks closed", async () => {
456
+ // The runner reports VC-001 passed and claims zero failures — VC-002
457
+ // is simply missing from its report and must NOT complete the run.
458
+ __setAuditRunnerForTests(async ({ checklist }) => {
459
+ const vc1 = checklist.find((item) => item.id === "VC-001");
460
+ if (vc1) vc1.done = true;
461
+ return { round: 1, passed: ["VC-001"], failed: [], rolledBack: [], report: "partial report" };
462
+ });
463
+ const { workdir, planPath } = freshWorkdir();
464
+ const { ctx } = await start(planPath, workdir);
465
+ for (const id of ["Task-1", "Task-2", "Task-3"]) {
466
+ applyTaskUpdate(getExecution()!.tasks, id, "complete", `${id} evidence`);
467
+ persistTaskProgress(ctx);
468
+ }
469
+ const snapshot = getExecution();
470
+ await restoreFromSession(ctx, [{ type: "custom", customType: "pi-plans-exec", data: snapshot }]);
471
+ const ex = getExecution()!;
472
+ assert.deepEqual(ex.audit.failed, ["VC-002"], "unreported check fails despite failed: []");
473
+ assert.equal(ex.tasks.find((t) => t.id === "Task-3")?.status, "pending");
474
+ __setAuditRunnerForTests(null);
475
+ await stopExecution(ctx, "test teardown");
1425
476
  });
1426
477
 
1427
- it("index.ts wires session_compact/session_compact_failed to clear inFlight flags (F-003/F-004 coverage)", () => {
1428
- const indexSource = fs.readFileSync(path.join(process.cwd(), "index.ts"), "utf8");
1429
- assert.match(indexSource, /await handleExecutionCompact\(pi, ctx, event\)/);
1430
- assert.match(indexSource, /await handlePlanningCompact\(pi, ctx, event\)/);
478
+ it("toggleDashboardExpanded flips the expanded mode", () => {
479
+ const before = toggleEnabled();
480
+ toggleDashboardExpanded(makeCtx(freshWorkdir(false).workdir));
481
+ assert.equal(toggleEnabled(), !before);
482
+ toggleDashboardExpanded(makeCtx(freshWorkdir(false).workdir));
483
+ });
484
+ });
1431
485
 
1432
- const workdir = freshWorkdir();
1433
- // Use a minimal harness that exposes sessionManager so we can inspect the
1434
- // in-flight store after dispatching the registered event handlers.
1435
- const ctx: any = {
486
+ /**
487
+ * v0.7.1 regression suite (root causes A and B). These drive the REAL lifecycle
488
+ * events (`agent_before_settle` / `turn_end` / `tool_result`) instead of
489
+ * mutating state directly, because the stranded-state bug only exists in the
490
+ * event ORDER — a test that calls restoreFromSession cannot see it.
491
+ */
492
+ describe("v0.7.1 execution-loop fixes (event-driven)", () => {
493
+ /** A ctx in TUI mode: `canWakeExecution` requires mode tui/rpc, and a
494
+ * print-mode ctx would make every wake/audit assertion silently vacuous. */
495
+ function makeTuiCtx(workdir: string) {
496
+ const entries: Array<{ customType: string; data?: unknown; content?: string; triggerTurn?: boolean }> = [];
497
+ const ctx = {
1436
498
  cwd: workdir,
1437
- hasUI: false,
1438
499
  sessionManager: {},
500
+ hasUI: true,
501
+ mode: "tui" as const,
502
+ entries,
503
+ ui: {
504
+ notify: () => {},
505
+ setStatus: () => {},
506
+ setWidget: () => {},
507
+ theme: { fg: (_c: string, t: string) => t, bold: (t: string) => t },
508
+ },
509
+ isIdle: () => true,
510
+ hasPendingMessages: () => false,
511
+ } as never;
512
+ setMessagingApi({
513
+ appendEntry: (customType: string, data: unknown) => entries.push({ customType, data }),
514
+ sendMessage: (message: { customType: string; content: string; details?: unknown }, opts?: { triggerTurn?: boolean }) =>
515
+ entries.push({ customType: message.customType, content: message.content, triggerTurn: opts?.triggerTurn }),
516
+ sendUserMessage: async () => {},
517
+ });
518
+ return ctx as { entries: typeof entries; cwd: string };
519
+ }
520
+
521
+ /** Register the production turn handlers and return a driver for the events. */
522
+ function wireEvents(ctx: never) {
523
+ const handlers = new Map<string, Array<(event: unknown, c: never) => unknown>>();
524
+ const ext = {
525
+ on: (name: string, fn: (event: unknown, c: never) => unknown) => {
526
+ const list = handlers.get(name) ?? [];
527
+ list.push(fn);
528
+ handlers.set(name, list);
529
+ },
530
+ } as never;
531
+ registerExecutionTurnHandlers(ext);
532
+ return {
533
+ async fire(name: string, event: unknown = {}) {
534
+ for (const fn of handlers.get(name) ?? []) await fn(event, ctx);
535
+ },
1439
536
  };
1440
- // Simulate the lifecycle that index.ts wires in production:
1441
- noteCompactionStarted(ctx, undefined);
1442
- assert.equal(compactionInFlight(ctx, "planning"), true);
1443
- assert.equal(compactionInFlight(ctx, "execution"), true);
1444
- noteCompactionEnded(ctx, undefined);
1445
- assert.equal(compactionInFlight(ctx, "planning"), false);
1446
- assert.equal(compactionInFlight(ctx, "execution"), false);
1447
- // And a planning-attributed start + end still clears both (end has no hint).
1448
- noteCompactionStarted(ctx, "pi-plans planning auto compact");
1449
- assert.equal(compactionInFlight(ctx, "planning"), true);
1450
- noteCompactionEnded(ctx, "pi-plans planning auto compact");
1451
- assert.equal(compactionInFlight(ctx, "planning"), false);
1452
- });
1453
- });
537
+ }
1454
538
 
1455
- describe("execution injection reads graph config live", () => {
1456
- it("reflects graph_enabled flips on the next assembly and surfaces config failures", async (t) => {
1457
- const workdir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-exec-graph-"));
1458
- t.after(() => {
1459
- try {
1460
- fs.rmSync(workdir, { recursive: true, force: true });
1461
- } catch {
1462
- /* ignore */
1463
- }
539
+ async function startTui(planPath: string, workdir: string) {
540
+ const ctx = makeTuiCtx(workdir);
541
+ await startExecution(ctx as never, {
542
+ planPath,
543
+ planTasks: parsePlanTasks(PLAN),
544
+ items: parseChecklist(PLAN),
1464
545
  });
1465
- const { pi, ctx } = makeHarness(workdir);
1466
- setGraphEnabled(workdir, true);
1467
- await startExecution(pi, ctx, path.join(workdir, "PLAN_v1.md"), items("VC-001"));
1468
-
1469
- const enabledRules = executionContextMessage(ctx)!;
1470
- assert.match(enabledRules, /Code graph loop: indexed code files read as a function digest/);
1471
- assert.doesNotMatch(enabledRules, /Code graph disabled/);
1472
-
1473
- setGraphEnabled(workdir, false);
1474
- assert.match(executionContextMessage(ctx)!, /Code graph disabled/);
1475
-
1476
- fs.writeFileSync(path.join(workdir, ".git", "pi_plans", "config.json"), "{broken");
1477
- const brokenRules = executionContextMessage(ctx)!;
1478
- assert.match(brokenRules, /Code graph disabled/);
1479
- assert.match(brokenRules, /config unreadable this turn/);
1480
- });
1481
-
1482
- it("keeps no graphEnabled snapshot in the execution state source", () => {
1483
- const source = fs.readFileSync(path.join(process.cwd(), "src", "exec.ts"), "utf8");
1484
- assert.doesNotMatch(source, /graphEnabled/);
1485
- assert.match(source, /resolveGraphMode/);
1486
- });
1487
- });
546
+ return ctx;
547
+ }
1488
548
 
1489
- function makePreparation(reason: "manual" | "threshold" | "overflow", previousSummary: string | null): any { return {
1490
- firstKeptEntryId: "a-2",
1491
- messagesToSummarize: [],
1492
- turnPrefixMessages: [],
1493
- isSplitTurn: false,
1494
- tokensBefore: 12345,
1495
- previousSummary,
1496
- fileOps: { read: [], written: [], edited: [] },
1497
- settings: { enabled: true, reserveTokens: 16384, keepRecentTokens: 20000 },
549
+ const closeAll = (ids: string[]) => {
550
+ for (const id of ids) applyTaskUpdate(getExecution()!.tasks, id, "complete", `${id} evidence`);
1498
551
  };
1499
- }
1500
-
1501
- describe("amelioration termination prompt", () => {
1502
- it("recommends goal-wait first and keeps the round options", () => {
1503
- const text = ameliorationPromptText("plan-normal");
1504
- assert.match(text, /goal wait: continue until no unpassed VCs remain/);
1505
- assert.match(text, /until no high-severity finding \(hard cap 5 rounds\)/);
1506
- assert.match(text, /How should the implementation-review loop terminate\?/);
1507
- });
1508
-
1509
- it("asks the reviewer-count question with a skill-aware recommended default (D-1/D-2)", () => {
1510
- const big = ameliorationPromptText("plan-big");
1511
- assert.match(big, /impl-review-reviewer-count/);
1512
- assert.match(big, /How many concurrent reviewers should each implementation-review round use\?/);
1513
- assert.match(big, /3 \(recommended\)/);
1514
- assert.match(big, /reviewers: <configured reviewerCount>/);
1515
- const small = ameliorationPromptText("plan-small");
1516
- assert.match(small, /1 \(recommended\)/);
1517
- assert.doesNotMatch(small, /3 \(recommended\)/);
1518
- });
1519
-
1520
- it("defers persistence to ONE combined record-checkpoint carrying both answers (D-5)", () => {
1521
- const text = ameliorationPromptText(undefined);
1522
- assert.match(text, /Do not persist yet/);
1523
- assert.match(text, /ONE call: plans record-checkpoint/);
1524
- assert.match(text, /terminationCondition: "<termination answer>", reviewerCount: <chosen integer>/);
1525
- });
1526
- });
1527
-
1528
- describe("checkpoint-backed execution restore (I-005)", () => {
1529
- it("startExecution records approval (plan digest + HEAD) and progress in the checkpoint", async () => {
1530
- const { loadExecutionFromCheckpoint } = await import("../src/exec.ts");
1531
- const { createCheckpoint, loadCheckpoint } = await import("../src/workflow-state.ts");
1532
- const { resetRunBindingForTests } = await import("../src/run-context.ts");
1533
- resetRunBindingForTests();
1534
- const workdir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-exec-cp-"));
1535
- try {
1536
- const { spawnSync } = await import("node:child_process");
1537
- spawnSync("git", ["init"], { cwd: workdir });
1538
- spawnSync("git", ["config", "user.email", "t@e.com"], { cwd: workdir });
1539
- spawnSync("git", ["config", "user.name", "T"], { cwd: workdir });
1540
- initState(workdir);
1541
- const { run } = startRun(workdir, { topic: "exec-cp", skill: "plan-normal", requestText: "t" });
1542
- createCheckpoint(workdir, { runId: run.run_id, originWorkdir: workdir, workdir });
1543
- const artifactDir = run.artifact_dir;
1544
- fs.mkdirSync(artifactDir, { recursive: true });
1545
- const planPath = path.join(artifactDir, "PLAN_v1.md");
1546
- fs.writeFileSync(
1547
- planPath,
1548
- "# Plan\n\n## Verifier Checklist\n\n- [ ] `VC-001` covers `I-001`; pass condition: x.\n- [ ] `VC-002` covers `I-002`; pass condition: y.\n",
1549
- "utf8",
1550
- );
1551
- spawnSync("git", ["add", "-A"], { cwd: workdir });
1552
- spawnSync("git", ["commit", "-m", "init"], { cwd: workdir });
1553
-
1554
- const harness = makeHarness(workdir);
1555
- const items: CheckItem[] = [
1556
- { id: "VC-001", text: "covers I-001", done: false },
1557
- { id: "VC-002", text: "covers I-002", done: false },
1558
- ];
1559
- await startExecution(harness.pi, harness.ctx, planPath, items);
1560
-
1561
- const loaded = loadCheckpoint(workdir, run.run_id);
1562
- assert.equal(loaded.status, "ok");
1563
- if (loaded.status === "ok") {
1564
- assert.equal(loaded.checkpoint.phase, "executing");
1565
- const approval = loaded.checkpoint.execution?.approval;
1566
- assert.ok(approval, "approval recorded");
1567
- assert.match(approval!.headAtApproval ?? "", /^[0-9a-f]{40}$/, "HEAD at approval");
1568
- assert.equal(approval!.plan.path, planPath);
1569
- }
1570
-
1571
- // Progress lands in the checkpoint.
1572
- applyDoneMarkers("[DONE:VC-001]");
1573
- recordExecutionTurn(harness.pi, harness.ctx, ["VC-001"], { input: 5, output: 2 });
1574
- const afterProgress = loadCheckpoint(workdir, run.run_id);
1575
- if (afterProgress.status === "ok") {
1576
- assert.deepEqual(afterProgress.checkpoint.execution?.doneVcIds, ["VC-001"]);
1577
- assert.equal(afterProgress.checkpoint.execution?.usage.inToks, 5);
1578
- }
1579
-
1580
- // Cross-session restore: fresh harness (new session), load from checkpoint.
1581
- resetRunBindingForTests();
1582
- const harness2 = makeHarness(workdir);
1583
- const restore = loadExecutionFromCheckpoint(harness2.pi, harness2.ctx, run.run_id);
1584
- assert.equal(restore.status, "loaded");
1585
- assert.deepEqual(restore.doneVcIds, ["VC-001"]);
1586
- assert.equal(restore.reverifyAll, false, "same HEAD keeps verified VCs");
1587
- assert.equal(getExecution()?.items.find((item) => item.id === "VC-001")?.done, true);
1588
- // F-002: an immediate session snapshot was appended.
1589
- const snapshots = harness2.recorded.entries.filter((entry) => entry.customType === "pi-plans-exec");
1590
- assert.ok(snapshots.length >= 1, "session snapshot written on load");
1591
552
 
1592
- // Stop keeps progress with pausedReason (D-008).
1593
- await stopExecution(harness2.pi, harness2.ctx, "stopped by user");
1594
- const stopped = loadCheckpoint(workdir, run.run_id);
1595
- if (stopped.status === "ok") {
1596
- assert.equal(stopped.checkpoint.execution?.pausedReason, "stopped by user");
1597
- assert.deepEqual(stopped.checkpoint.execution?.doneVcIds, ["VC-001"]);
553
+ it("root cause A: a terminal-but-unaudited run self-heals via agent_before_settle (zero input)", async () => {
554
+ // First audit round fails -> tasks roll back to pending (0/3).
555
+ let round = 0;
556
+ __setAuditRunnerForTests(async ({ checklist }) => {
557
+ round += 1;
558
+ if (round === 1) {
559
+ const failed = checklist.filter((i) => i.id === "VC-002").map((i) => i.id);
560
+ return { round, passed: ["VC-001"], failed, rolledBack: [], report: "VC-002 fails" };
1598
561
  }
1599
- } finally {
1600
- fs.rmSync(workdir, { recursive: true, force: true });
1601
- }
562
+ for (const item of checklist) item.done = true;
563
+ return { round, passed: checklist.map((i) => i.id), failed: [], rolledBack: [], report: "all pass" };
564
+ });
565
+ const { workdir, planPath, runId } = freshWorkdir();
566
+ const ctx = await startTui(planPath, workdir);
567
+ const drive = wireEvents(ctx as never);
568
+
569
+ // Agent closes every task; the turn ends -> audit round 1 runs and fails.
570
+ closeAll(["Task-1", "Task-2", "Task-3"]);
571
+ persistTaskProgress(ctx as never);
572
+ await drive.fire("turn_end", { message: { role: "assistant", stopReason: "stop" } });
573
+ assert.equal(round, 1, "round 1 audit ran");
574
+ assert.equal(getExecution()!.tasks.find((t) => t.id === "Task-2")?.status, "pending", "VC-002 rolled its tasks back");
575
+
576
+ // The agent fixes the failures and re-closes the rolled-back tasks. A new
577
+ // agent run opens a new settle window (agent_start resets the latch).
578
+ await drive.fire("agent_start", {});
579
+ closeAll(["Task-1", "Task-2", "Task-3"]);
580
+ persistTaskProgress(ctx as never);
581
+ assert.ok(allTasksTerminal(getExecution()!.tasks), "task tree is terminal again");
582
+
583
+ // The regression: fire ONLY the actionable pre-settle boundary, i.e. the
584
+ // case where the turn_end trigger is missed (the observed strand). Before
585
+ // v0.7.1 nothing else could start the audit, so the run sat in
586
+ // `executing` until a manual /plans-execute.
587
+ await drive.fire("agent_before_settle", {});
588
+ assert.equal(round, 2, "the owed audit reran automatically with zero user input");
589
+
590
+ const load = loadCheckpoint(workdir, runId!);
591
+ assert.ok(load.status === "ok");
592
+ assert.equal(load.checkpoint.phase, "completed", "run reached completed without a manual /plans-execute");
593
+ __setAuditRunnerForTests(null);
594
+ });
595
+
596
+ it("root cause A: the audit is not run twice within one settle (per-settle latch)", async () => {
597
+ let calls = 0;
598
+ __setAuditRunnerForTests(async ({ checklist }) => {
599
+ calls += 1;
600
+ for (const item of checklist) item.done = true;
601
+ return { round: 1, passed: checklist.map((i) => i.id), failed: [], rolledBack: [], report: "all pass" };
602
+ });
603
+ const { workdir, planPath } = freshWorkdir();
604
+ const ctx = await startTui(planPath, workdir);
605
+ const drive = wireEvents(ctx as never);
606
+ closeAll(["Task-1", "Task-2", "Task-3"]);
607
+ persistTaskProgress(ctx as never);
608
+ // Both entry points land in the same settle.
609
+ await drive.fire("turn_end", { message: { role: "assistant", stopReason: "stop" } });
610
+ await drive.fire("agent_before_settle", {});
611
+ assert.equal(calls, 1, "exactly one audit call per settle");
612
+ __setAuditRunnerForTests(null);
613
+ });
614
+
615
+ it("root cause A: a fully terminal run emits no EXECUTION_CONTINUE wake", async () => {
616
+ const { workdir, planPath } = freshWorkdir();
617
+ const ctx = await startTui(planPath, workdir);
618
+ const drive = wireEvents(ctx as never);
619
+ closeAll(["Task-1", "Task-2", "Task-3"]);
620
+ persistTaskProgress(ctx as never);
621
+ await drive.fire("agent_settled", {});
622
+ assert.equal(
623
+ ctx.entries.filter((e) => e.customType === "pi-plans-exec-continue").length,
624
+ 0,
625
+ "no continuation wake when every task is already terminal",
626
+ );
627
+ await stopExecution(ctx as never, "test teardown");
628
+ });
629
+
630
+ it("root cause A: an audit failure wakes the agent exactly once", async () => {
631
+ __setAuditRunnerForTests(async ({ checklist }) => {
632
+ const failed = checklist.filter((i) => i.id === "VC-002").map((i) => i.id);
633
+ return { round: 1, passed: ["VC-001"], failed, rolledBack: [], report: "VC-002 fails" };
634
+ });
635
+ const { workdir, planPath } = freshWorkdir();
636
+ const ctx = await startTui(planPath, workdir);
637
+ const drive = wireEvents(ctx as never);
638
+ closeAll(["Task-1", "Task-2", "Task-3"]);
639
+ persistTaskProgress(ctx as never);
640
+ await drive.fire("turn_end", { message: { role: "assistant", stopReason: "stop" } });
641
+ const wakes = ctx.entries.filter((e) => e.customType === "pi-plans-audit-failed" || e.customType === "pi-plans-exec-continue");
642
+ assert.equal(wakes.length, 1, `one wake per settle, got ${wakes.map((w) => w.customType).join(",")}`);
643
+ assert.equal(wakes[0]?.triggerTurn, true, "the audit-failure notice drives the fix turn");
644
+ __setAuditRunnerForTests(null);
645
+ await stopExecution(ctx as never, "test teardown");
646
+ });
647
+
648
+ it("root cause B: a successful tool result counts as progress and rebases the watchdog", async () => {
649
+ const { workdir, planPath } = freshWorkdir();
650
+ const ctx = await startTui(planPath, workdir);
651
+ const drive = wireEvents(ctx as never);
652
+ const exec = getExecution()!;
653
+ exec.stall.rounds = 2;
654
+ // A successful tool result during a cross-round investigation.
655
+ await drive.fire("tool_result", { isError: false });
656
+ assert.equal(exec.stall.rounds, 0, "legitimate work rebased the no-progress counter");
657
+ await stopExecution(ctx as never, "test teardown");
1602
658
  });
1603
659
 
1604
- it("HEAD change after approval keeps authorization but re-verifies VCs (D-011)", async () => {
1605
- const { loadExecutionFromCheckpoint } = await import("../src/exec.ts");
1606
- const { createCheckpoint, loadCheckpoint } = await import("../src/workflow-state.ts");
1607
- const { resetRunBindingForTests } = await import("../src/run-context.ts");
1608
- resetRunBindingForTests();
1609
- const workdir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-exec-head-"));
1610
- try {
1611
- const { spawnSync } = await import("node:child_process");
1612
- spawnSync("git", ["init"], { cwd: workdir });
1613
- spawnSync("git", ["config", "user.email", "t@e.com"], { cwd: workdir });
1614
- spawnSync("git", ["config", "user.name", "T"], { cwd: workdir });
1615
- initState(workdir);
1616
- const { run } = startRun(workdir, { topic: "exec-head", skill: "plan-normal", requestText: "t" });
1617
- createCheckpoint(workdir, { runId: run.run_id, originWorkdir: workdir, workdir });
1618
- fs.mkdirSync(run.artifact_dir, { recursive: true });
1619
- const planPath = path.join(run.artifact_dir, "PLAN_v1.md");
1620
- fs.writeFileSync(
1621
- planPath,
1622
- "# Plan\n\n## Verifier Checklist\n\n- [ ] `VC-001` covers `I-001`; pass condition: x.\n",
1623
- "utf8",
1624
- );
1625
- spawnSync("git", ["add", "-A"], { cwd: workdir });
1626
- spawnSync("git", ["commit", "-m", "one"], { cwd: workdir });
1627
-
1628
- const harness = makeHarness(workdir);
1629
- await startExecution(harness.pi, harness.ctx, planPath, [
1630
- { id: "VC-001", text: "covers I-001", done: false },
1631
- ]);
1632
- applyDoneMarkers("[DONE:VC-001]");
1633
- recordExecutionTurn(harness.pi, harness.ctx, ["VC-001"]);
1634
-
1635
- // Simulate a branch switch / reset under the unchanged plan.
1636
- spawnSync("git", ["commit", "--allow-empty", "-m", "two"], { cwd: workdir });
1637
-
1638
- resetRunBindingForTests();
1639
- const harness2 = makeHarness(workdir);
1640
- const restore = loadExecutionFromCheckpoint(harness2.pi, harness2.ctx, run.run_id);
1641
- assert.equal(restore.status, "loaded");
1642
- assert.deepEqual(restore.doneVcIds, ["VC-001"], "historical evidence kept");
1643
- assert.equal(restore.reverifyAll, true, "HEAD change forces re-verification");
1644
- assert.equal(getExecution()?.items[0]?.done, false, "old VC not auto-passed");
1645
- // Authorization survives.
1646
- const cp = loadCheckpoint(workdir, run.run_id);
1647
- if (cp.status === "ok") {
1648
- assert.ok(cp.checkpoint.execution?.approval, "authorization kept");
1649
- assert.equal(cp.checkpoint.execution?.reverifyAll, true);
1650
- }
1651
- } finally {
1652
- fs.rmSync(workdir, { recursive: true, force: true });
1653
- }
660
+ it("root cause B (negative): an error-only tool loop still trips the cap", async () => {
661
+ const { workdir, planPath } = freshWorkdir();
662
+ const ctx = await startTui(planPath, workdir);
663
+ const drive = wireEvents(ctx as never);
664
+ const exec = getExecution()!;
665
+ const base = exec.stall.lastSnapshot;
666
+ // Tools that all fail: repeated retries must NOT look like progress.
667
+ for (let i = 0; i < 5; i++) await drive.fire("tool_result", { isError: true });
668
+ assert.equal(exec.stall.rounds, 0, "a failed tool does not rebase the counter on its own");
669
+ assert.equal(exec.stall.lastSnapshot, base, "snapshot unchanged by failed tools");
670
+ await stopExecution(ctx as never, "test teardown");
1654
671
  });
1655
672
  });
1656
673
 
1657
- describe("pre-plan compaction (start-run trigger)", () => {
1658
- let tmpRoot: string;
1659
- let counter = 0;
1660
-
1661
- before(() => {
1662
- tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-preplan-"));
1663
- });
1664
-
1665
- after(() => {
1666
- fs.rmSync(tmpRoot, { recursive: true, force: true });
1667
- });
1668
-
1669
- async function preplanHarness() {
1670
- const piPlansExtension = (await import("../index.ts")).default;
1671
- const harness = makeHarness(path.join(tmpRoot, `ws-${++counter}`));
1672
- fs.mkdirSync(harness.ctx.cwd, { recursive: true });
1673
- piPlansExtension(harness.pi as never);
1674
- return harness;
1675
- }
1676
-
1677
- it("routes the pre-plan hint through the planning VCC path for a fresh run", () => {
1678
- // Pure-builder assertion (order-independent): the getExecution() gate on
1679
- // buildPlanningCompactionResult is covered by the planning-hook tests.
1680
- const workdir = path.join(tmpRoot, `hint-${++counter}`);
1681
- fs.mkdirSync(workdir, { recursive: true });
1682
- initState(workdir);
1683
- const { run } = startRun(workdir, { topic: "preplan hint", skill: "plan-normal", requestText: "x" });
1684
- const built = buildPiPlansVccCompaction({
1685
- branchEntries: compactableBranchEntries(),
1686
- preparation: makePreparation("manual", null),
1687
- customInstructions: PLANNING_PREPLAN_COMPACT_HINT,
1688
- reason: "manual",
1689
- willRetry: false,
1690
- settings: loadVccSettings(path.join(workdir, ".git", "pi_plans")),
1691
- phaseContext: { phase: "planning", runId: run.run_id, artifactDir: run.artifact_dir },
1692
- });
1693
- assert.equal(built.kind, "compaction", "pre-plan hint must produce a VCC compaction, not a cancel/fallback");
1694
- if (built.kind !== "compaction") return;
1695
- assert.equal((built.compaction.details as { compactor: string }).compactor, "pi-vcc");
1696
- assert.equal((built.compaction.details as { phase: string }).phase, "planning");
1697
- assert.match(built.compaction.summary, /\[Session Goal\]/);
1698
- });
1699
-
1700
- it("marks and consumes the pending flag exactly once", () => {
1701
- const harness = makeHarness(path.join(tmpRoot, `flag-${++counter}`));
1702
- assert.equal(consumePrePlanCompactPending(harness.ctx), null);
1703
- markPrePlanCompactPending(harness.ctx, "run-1");
1704
- assert.deepEqual(consumePrePlanCompactPending(harness.ctx), { runId: "run-1" });
1705
- assert.equal(consumePrePlanCompactPending(harness.ctx), null);
1706
- });
1707
-
1708
- it("compacts once from the plans tool_result and resumes exactly once on success", async () => {
1709
- const harness = await preplanHarness();
1710
- const { ctx, recorded, emit } = harness;
1711
- markPrePlanCompactPending(ctx, "run-a");
1712
-
1713
- // Non-plans tool results never consume the pending flag.
1714
- await emit("tool_result", { type: "tool_result", toolName: "edit", input: { path: "x" }, isError: false });
1715
- assert.equal(recorded.compacts?.length ?? 0, 0);
1716
- assert.equal(recorded.messages.length, 0);
1717
-
1718
- await emit("tool_result", { type: "tool_result", toolName: "plans", input: { action: "start-run" }, isError: false });
1719
- assert.equal(recorded.compacts?.length, 1);
1720
- assert.equal(recorded.compacts?.[0]?.customInstructions, PLANNING_PREPLAN_COMPACT_HINT);
1721
- assert.equal(recorded.messages.length, 0, "no resume before the compaction settles");
1722
-
1723
- recorded.compacts?.[0]?.onComplete?.({} as never);
1724
- assert.equal(recorded.messages.length, 1);
1725
- assert.equal(recorded.messages[0]?.customType, PLANNING_PREPLAN_RESUME_CUSTOM_TYPE);
1726
- assert.equal(recorded.messages[0]?.content, "Continue planning.");
1727
- assert.equal((recorded.messages[0] as { display?: boolean }).display, false);
1728
- assert.equal((recorded.messages[0] as { options?: { triggerTurn?: boolean } }).options?.triggerTurn, true);
1729
-
1730
- // Pending flag consumed: a second plans result neither compacts nor resumes.
1731
- await emit("tool_result", { type: "tool_result", toolName: "plans", input: { action: "record-decision" }, isError: false });
1732
- assert.equal(recorded.compacts?.length, 1);
1733
- recorded.compacts?.[0]?.onComplete?.({} as never);
1734
- assert.equal(recorded.messages.length, 1);
1735
- });
1736
-
1737
- it("resumes exactly once with an info notice when compaction fails", async () => {
1738
- const harness = await preplanHarness();
1739
- const { ctx, recorded } = harness;
1740
- (ctx as { compact?: unknown }).compact = (options: { onError?: (error: Error) => void }) => {
1741
- options.onError?.(new Error("Nothing to compact (session too small)"));
1742
- };
1743
- markPrePlanCompactPending(ctx, "run-b");
1744
- await harness.emit("tool_result", { type: "tool_result", toolName: "plans", input: { action: "start-run" }, isError: false });
1745
- assert.equal(recorded.messages.length, 1, "resume exactly once despite the failure");
1746
- assert.equal(recorded.messages[0]?.customType, PLANNING_PREPLAN_RESUME_CUSTOM_TYPE);
1747
- assert.deepEqual(recorded.notifies.at(-1), {
1748
- message: "pi-plans: pre-plan compaction skipped; continuing planning.",
1749
- severity: "info",
1750
- });
1751
- });
1752
-
1753
- it("skips silently and still resumes when ctx.compact is unavailable (older Pi)", async () => {
1754
- const harness = await preplanHarness();
1755
- (harness.ctx as { compact?: unknown }).compact = undefined;
1756
- markPrePlanCompactPending(harness.ctx, "run-c");
1757
- await harness.emit("tool_result", { type: "tool_result", toolName: "plans", input: { action: "start-run" }, isError: false });
1758
- assert.equal(harness.recorded.messages.length, 1);
1759
- assert.equal(harness.recorded.messages[0]?.customType, PLANNING_PREPLAN_RESUME_CUSTOM_TYPE);
1760
- });
674
+ function formatStatus(workdir: string): string {
675
+ const exec = getExecution();
676
+ assert.ok(exec);
677
+ void workdir;
678
+ return exec.stall.paused ? "paused" : "running";
679
+ }
1761
680
 
1762
- it("filters the pre-plan resume message out of the model context payload", () => {
1763
- const messages = [
1764
- { customType: "user", content: "real prompt" },
1765
- { customType: PLANNING_PREPLAN_RESUME_CUSTOM_TYPE, content: "Continue planning." },
1766
- { customType: "assistant", content: "ok" },
1767
- ];
1768
- assert.equal(filterPlanningResumeMessages(messages).length, 2);
1769
- });
1770
- });
681
+ import { isDashboardExpanded as toggleEnabled } from "../src/exec.ts";