pi-plans 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -22
- package/docs/assets/pi-plans-logo.svg +2 -2
- package/index.ts +116 -28
- package/package.json +1 -1
- package/references/pi-planning-workflow.md +11 -17
- package/references/plan-artifact-template.md +1 -1
- package/references/state-and-config.md +1 -1
- package/skills/debug-and-plan/SKILL.md +1 -1
- package/skills/plan-big/SKILL.md +1 -1
- package/skills/plan-normal/SKILL.md +1 -1
- package/skills/plan-small/SKILL.md +1 -1
- package/skills/plan-with-refs/SKILL.md +1 -1
- package/src/exec.ts +861 -28
- package/src/execution-panel.ts +159 -23
- package/src/plan.ts +106 -0
- package/src/state.ts +36 -0
- package/tests/exec.test.ts +919 -44
- package/tests/execution-panel.test.ts +44 -8
- package/tests/plan.test.ts +84 -1
- package/tests/plans.test.ts +36 -0
- package/tests/state.test.ts +60 -0
- package/tools/execute-plan.ts +46 -6
- package/tools/plans.ts +34 -1
package/tests/exec.test.ts
CHANGED
|
@@ -7,44 +7,126 @@ import * as path from "node:path";
|
|
|
7
7
|
import { after, before, describe, it } from "node:test";
|
|
8
8
|
import {
|
|
9
9
|
applyDoneMarkers,
|
|
10
|
+
applyImplMarkers,
|
|
11
|
+
buildExecutionCompactionResult,
|
|
12
|
+
buildPlanningCompactionResult,
|
|
10
13
|
completeExecution,
|
|
14
|
+
consumePendingExecutionFlush,
|
|
15
|
+
consumePlanningCompactionResumeGuard,
|
|
11
16
|
consumePendingPanelSync,
|
|
17
|
+
drainExecutionFlush,
|
|
12
18
|
executionContextMessage,
|
|
19
|
+
filterExecutionResumeMessages,
|
|
20
|
+
filterPlanningResumeMessages,
|
|
13
21
|
getExecution,
|
|
22
|
+
handleExecutionBeforeCompact,
|
|
23
|
+
handleExecutionCompact,
|
|
24
|
+
handleExecutionCompactFailed,
|
|
25
|
+
handlePlanningBeforeCompact,
|
|
26
|
+
handlePlanningCompact,
|
|
27
|
+
handlePlanningCompactFailed,
|
|
14
28
|
isExecutionComplete,
|
|
29
|
+
PLANNING_PLAN_WRITTEN_CUSTOM_TYPE,
|
|
30
|
+
PLANNING_RUN_START_CUSTOM_TYPE,
|
|
31
|
+
refreshPlanningCompactionCooldown,
|
|
32
|
+
requestPlanningCompaction,
|
|
15
33
|
restoreFromSession,
|
|
34
|
+
shouldTriggerPlanningCompaction,
|
|
35
|
+
ensureExecutionModelActive,
|
|
16
36
|
startExecution,
|
|
37
|
+
recordExecutionTurn,
|
|
38
|
+
registerExecutionTurnHandlers,
|
|
17
39
|
stopExecution,
|
|
40
|
+
chooseExecutionModelSelection,
|
|
18
41
|
toggleExecutionPanelView,
|
|
42
|
+
updateStatusWidget,
|
|
19
43
|
} from "../src/exec.ts";
|
|
20
44
|
import type { CheckItem } from "../src/plan.ts";
|
|
45
|
+
import { initState, setExecutionModel, setRunStatus, showConfig, startRun } from "../src/state.ts";
|
|
21
46
|
|
|
22
47
|
interface Recorded {
|
|
23
48
|
entries: { type: string; customType?: string; data?: unknown }[];
|
|
24
|
-
messages: { customType: string; content: string }[];
|
|
49
|
+
messages: { customType: string; content: string; options?: { triggerTurn?: boolean } }[];
|
|
25
50
|
status: string | undefined;
|
|
51
|
+
statusCalls: number;
|
|
52
|
+
colors: string[];
|
|
26
53
|
widget?: { key: string; options?: unknown; factory: any };
|
|
27
54
|
widgetCalls: number;
|
|
55
|
+
models: { provider: string; id: string }[];
|
|
56
|
+
thinkingLevels: (string | null)[];
|
|
57
|
+
notifies: { message: string; severity: string }[];
|
|
58
|
+
selects: { title: string; options: string[] }[];
|
|
59
|
+
selectAnswer?: string;
|
|
60
|
+
current: { provider: string; id: string } | null;
|
|
61
|
+
thinking: string | null;
|
|
62
|
+
compacts?: { customInstructions?: string }[];
|
|
28
63
|
}
|
|
29
64
|
|
|
30
65
|
interface Harness {
|
|
31
66
|
pi: any;
|
|
32
67
|
ctx: any;
|
|
33
68
|
recorded: Recorded;
|
|
69
|
+
emit: (eventName: string, event: unknown) => Promise<unknown[]>;
|
|
34
70
|
}
|
|
35
71
|
|
|
36
72
|
function makeHarness(workdir: string): Harness {
|
|
37
|
-
const recorded: Recorded = {
|
|
73
|
+
const recorded: Recorded = {
|
|
74
|
+
entries: [],
|
|
75
|
+
messages: [],
|
|
76
|
+
status: undefined,
|
|
77
|
+
statusCalls: 0,
|
|
78
|
+
colors: [],
|
|
79
|
+
widgetCalls: 0,
|
|
80
|
+
models: [],
|
|
81
|
+
thinkingLevels: [],
|
|
82
|
+
notifies: [],
|
|
83
|
+
selects: [],
|
|
84
|
+
current: { provider: "p", id: "m" },
|
|
85
|
+
thinking: "high",
|
|
86
|
+
};
|
|
87
|
+
let contextPercent: number | null = 0;
|
|
88
|
+
const registryModels = [
|
|
89
|
+
{ provider: "p", id: "m" },
|
|
90
|
+
{ provider: "prov", id: "other" },
|
|
91
|
+
];
|
|
92
|
+
const handlers = new Map<string, Array<(event: unknown, ctx: any) => unknown>>();
|
|
93
|
+
const emit = async (eventName: string, event: unknown): Promise<unknown[]> => {
|
|
94
|
+
const results: unknown[] = [];
|
|
95
|
+
for (const handler of handlers.get(eventName) ?? []) {
|
|
96
|
+
results.push(await handler(event, ctx));
|
|
97
|
+
}
|
|
98
|
+
return results;
|
|
99
|
+
};
|
|
38
100
|
const pi = {
|
|
101
|
+
on: (eventName: string, handler: (event: unknown, ctx: any) => unknown) => {
|
|
102
|
+
const registered = handlers.get(eventName) ?? [];
|
|
103
|
+
registered.push(handler);
|
|
104
|
+
handlers.set(eventName, registered);
|
|
105
|
+
},
|
|
106
|
+
registerTool: () => {},
|
|
107
|
+
registerCommand: () => {},
|
|
108
|
+
registerShortcut: () => {},
|
|
109
|
+
registerFlag: () => {},
|
|
39
110
|
appendEntry: (customType: string, data: unknown) => {
|
|
40
111
|
recorded.entries.push({ type: "custom", customType, data });
|
|
41
112
|
},
|
|
42
|
-
sendMessage: (message: { customType: string; content: string }) => {
|
|
43
|
-
recorded.messages.push(message);
|
|
113
|
+
sendMessage: (message: { customType: string; content: string }, options?: { triggerTurn?: boolean }) => {
|
|
114
|
+
recorded.messages.push({ ...message, options });
|
|
115
|
+
},
|
|
116
|
+
sendUserMessage: async () => {},
|
|
117
|
+
setModel: async (model: { provider: string; id: string }) => {
|
|
118
|
+
recorded.models.push({ provider: model.provider, id: model.id });
|
|
119
|
+
recorded.current = { provider: model.provider, id: model.id };
|
|
120
|
+
return true;
|
|
121
|
+
},
|
|
122
|
+
setThinkingLevel: (level: string) => {
|
|
123
|
+
recorded.thinkingLevels.push(level);
|
|
124
|
+
recorded.thinking = level;
|
|
44
125
|
},
|
|
45
126
|
};
|
|
46
127
|
const ui = {
|
|
47
128
|
setStatus: (_key: string, value: string | undefined) => {
|
|
129
|
+
recorded.statusCalls += 1;
|
|
48
130
|
recorded.status = value;
|
|
49
131
|
},
|
|
50
132
|
setWidget: (key: string, factory: any, options?: unknown) => {
|
|
@@ -55,17 +137,53 @@ function makeHarness(workdir: string): Harness {
|
|
|
55
137
|
}
|
|
56
138
|
recorded.widget = { key, options, factory };
|
|
57
139
|
},
|
|
140
|
+
notify: (message: string, severity: string) => {
|
|
141
|
+
recorded.notifies.push({ message, severity });
|
|
142
|
+
},
|
|
143
|
+
select: async (title: string, options: string[]) => {
|
|
144
|
+
recorded.selects.push({ title, options });
|
|
145
|
+
return recorded.selectAnswer ?? options[0];
|
|
146
|
+
},
|
|
58
147
|
theme: {
|
|
59
|
-
fg: (
|
|
148
|
+
fg: (color: string, text: string) => {
|
|
149
|
+
recorded.colors.push(color);
|
|
150
|
+
return text;
|
|
151
|
+
},
|
|
60
152
|
strikethrough: (text: string) => `~~${text}~~`,
|
|
61
153
|
},
|
|
62
154
|
};
|
|
155
|
+
const sessionManager: any = {};
|
|
63
156
|
const ctx = {
|
|
64
157
|
cwd: workdir,
|
|
65
158
|
ui,
|
|
66
159
|
isIdle: () => true,
|
|
160
|
+
hasUI: true,
|
|
161
|
+
scopedModels: [] as Array<{ model: { provider: string; id: string }; thinkingLevel?: string }>,
|
|
162
|
+
get model() {
|
|
163
|
+
return recorded.current;
|
|
164
|
+
},
|
|
165
|
+
get thinkingLevel() {
|
|
166
|
+
return recorded.thinking;
|
|
167
|
+
},
|
|
168
|
+
modelRegistry: {
|
|
169
|
+
find: (provider: string, modelId: string) =>
|
|
170
|
+
registryModels.find((entry) => entry.provider === provider && entry.id === modelId),
|
|
171
|
+
getAvailable: () => registryModels,
|
|
172
|
+
},
|
|
173
|
+
getContextUsage: () =>
|
|
174
|
+
contextPercent === null
|
|
175
|
+
? undefined
|
|
176
|
+
: { tokens: contextPercent * 1000, contextWindow: 100000, percent: contextPercent },
|
|
177
|
+
compact: (options: { customInstructions?: string }) => {
|
|
178
|
+
recorded.compacts = recorded.compacts ?? [];
|
|
179
|
+
recorded.compacts.push(options);
|
|
180
|
+
},
|
|
181
|
+
sessionManager,
|
|
182
|
+
setUsagePercent: (percent: number | null) => {
|
|
183
|
+
contextPercent = percent;
|
|
184
|
+
},
|
|
67
185
|
};
|
|
68
|
-
return { pi, ctx, recorded };
|
|
186
|
+
return { pi, ctx, recorded, emit, setUsagePercent: ctx.setUsagePercent } as Harness & { setUsagePercent: (percent: number | null) => void };
|
|
69
187
|
}
|
|
70
188
|
|
|
71
189
|
function items(...ids: string[]): CheckItem[] {
|
|
@@ -91,47 +209,61 @@ describe("execution loop", () => {
|
|
|
91
209
|
return workdir;
|
|
92
210
|
}
|
|
93
211
|
|
|
94
|
-
it("tracks done markers and completes", () => {
|
|
212
|
+
it("tracks done markers and completes", async () => {
|
|
95
213
|
const workdir = freshWorkdir();
|
|
96
214
|
const { pi, ctx, recorded } = makeHarness(workdir);
|
|
97
|
-
startExecution(pi, ctx, path.join(workdir, "PLAN_v1.md"), items("VC-001", "VC-002"));
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
);
|
|
105
|
-
assert.ok(widget);
|
|
106
|
-
const rendered = widget.render(80);
|
|
107
|
-
assert.match(rendered[0] ?? "", /alt\+o/);
|
|
108
|
-
assert.match(rendered[0] ?? "", /📋 plans 0\/2/);
|
|
215
|
+
await startExecution(pi, ctx, path.join(workdir, "PLAN_v1.md"), items("VC-001", "VC-002"));
|
|
216
|
+
|
|
217
|
+
// Collapsed by default: progress lives in the bottom status bar (same
|
|
218
|
+
// layer as the ⛔/⌛ indicators); no panel widget is registered yet.
|
|
219
|
+
assert.equal(recorded.widget, undefined);
|
|
220
|
+
assert.match(recorded.status ?? "", /⌛ plans 0\/2: spent \d{2}:\d{2}:\d{2}/);
|
|
221
|
+
assert.match(recorded.status ?? "", /in-toks/);
|
|
222
|
+
assert.match(recorded.status ?? "", /out-toks/);
|
|
109
223
|
|
|
110
224
|
toggleExecutionPanelView(pi, ctx);
|
|
111
225
|
assert.ok(recorded.widget);
|
|
226
|
+
assert.equal(recorded.widget?.key, "pi-plans-execution");
|
|
227
|
+
assert.deepEqual(recorded.widget?.options, { placement: "belowEditor" });
|
|
228
|
+
// Expanded: footer ⌛ line is cleared — the panel renders its own header.
|
|
229
|
+
assert.equal(recorded.status, undefined);
|
|
112
230
|
const expandedWidget = recorded.widget?.factory(
|
|
113
231
|
{} as any,
|
|
114
232
|
{ fg: (_color: string, text: string) => text, strikethrough: (text: string) => `~~${text}~~` },
|
|
115
233
|
);
|
|
116
234
|
assert.ok(expandedWidget);
|
|
117
235
|
const expandedLines = expandedWidget.render(80);
|
|
236
|
+
// Header line first, then the legacy item list.
|
|
237
|
+
assert.match(expandedLines[0] ?? "", /⌛ plans 0\/2: spent/);
|
|
118
238
|
assert.match(expandedLines.join("\n"), /☐/);
|
|
239
|
+
// Detail view never repeats the keyboard hint.
|
|
240
|
+
assert.doesNotMatch(expandedLines.join("\n"), /alt\+o/);
|
|
119
241
|
|
|
120
242
|
assert.ok(getExecution());
|
|
121
|
-
|
|
122
|
-
assert.match(
|
|
243
|
+
const rules = executionContextMessage()!;
|
|
244
|
+
assert.match(rules, /PI-PLANS EXECUTION/);
|
|
245
|
+
assert.match(rules, /VC-001/);
|
|
246
|
+
// Seven-principle rule set: representative anchors (PLAN_v2 D-003/D-004).
|
|
247
|
+
assert.match(rules, /for the long term/);
|
|
248
|
+
assert.match(rules, /Simplest implementation/);
|
|
249
|
+
assert.match(rules, /grow the change in layers/);
|
|
250
|
+
assert.match(rules, /existing dependencies \(docs and types\)/);
|
|
251
|
+
assert.match(rules, /well-maintained libraries/);
|
|
252
|
+
assert.match(rules, /clearly separated concerns/);
|
|
253
|
+
assert.match(rules, /no stopgaps/);
|
|
254
|
+
assert.doesNotMatch(rules, /ponytail/i);
|
|
123
255
|
|
|
124
256
|
assert.deepEqual(applyDoneMarkers("progress… [DONE:VC-001] done"), ["VC-001"]);
|
|
125
257
|
assert.equal(isExecutionComplete(), false);
|
|
126
258
|
assert.deepEqual(applyDoneMarkers("final: [DONE:VC-002]"), ["VC-002"]);
|
|
127
259
|
assert.equal(isExecutionComplete(), true);
|
|
128
260
|
|
|
129
|
-
completeExecution(pi, ctx);
|
|
261
|
+
await completeExecution(pi, ctx);
|
|
130
262
|
assert.equal(getExecution(), null);
|
|
131
263
|
assert.ok(recorded.messages.some((message) => message.customType === "pi-plans-complete"));
|
|
132
264
|
});
|
|
133
265
|
|
|
134
|
-
it("restores progress from session entries and rescans messages", () => {
|
|
266
|
+
it("restores progress from session entries and rescans messages", async () => {
|
|
135
267
|
const workdir = freshWorkdir();
|
|
136
268
|
const { pi, ctx, recorded } = makeHarness(workdir);
|
|
137
269
|
const snapshot = {
|
|
@@ -147,7 +279,15 @@ describe("execution loop", () => {
|
|
|
147
279
|
"VC-001": {
|
|
148
280
|
summary: { added: 1, removed: 0, files: 1, paths: ["src/exec.ts"] },
|
|
149
281
|
},
|
|
150
|
-
|
|
282
|
+
},
|
|
283
|
+
},
|
|
284
|
+
compaction: {
|
|
285
|
+
inFlight: true,
|
|
286
|
+
resumeGuard: true,
|
|
287
|
+
cooldownActive: true,
|
|
288
|
+
lastAttemptReason: "threshold",
|
|
289
|
+
lastSuccessfulUsagePercent: 100,
|
|
290
|
+
lastSuccessfulAt: "2026-08-25T00:01:00Z",
|
|
151
291
|
},
|
|
152
292
|
};
|
|
153
293
|
fs.writeFileSync(snapshot.planPath, "# plan");
|
|
@@ -158,9 +298,10 @@ describe("execution loop", () => {
|
|
|
158
298
|
message: { role: "assistant", content: [{ type: "text", text: "did [DONE:VC-001]" }] },
|
|
159
299
|
},
|
|
160
300
|
];
|
|
161
|
-
restoreFromSession(pi, ctx, entries as any);
|
|
301
|
+
await restoreFromSession(pi, ctx, entries as any);
|
|
162
302
|
const execution = getExecution();
|
|
163
303
|
assert.ok(execution);
|
|
304
|
+
assert.equal("compaction" in execution!, false, "legacy scheduler state must not reactivate on restore");
|
|
164
305
|
assert.ok(recorded.widget);
|
|
165
306
|
const widget = recorded.widget?.factory(
|
|
166
307
|
{} as any,
|
|
@@ -172,11 +313,11 @@ describe("execution loop", () => {
|
|
|
172
313
|
assert.match(rendered.join("\n"), /\+1/);
|
|
173
314
|
|
|
174
315
|
const clearedEntries = [...entries, { type: "custom", customType: "pi-plans-exec-cleared", data: {} }];
|
|
175
|
-
restoreFromSession(pi, ctx, clearedEntries as any);
|
|
316
|
+
await restoreFromSession(pi, ctx, clearedEntries as any);
|
|
176
317
|
assert.equal(getExecution(), null);
|
|
177
318
|
});
|
|
178
319
|
|
|
179
|
-
it("ignores restore when the plan file vanished", () => {
|
|
320
|
+
it("ignores restore when the plan file vanished", async () => {
|
|
180
321
|
const workdir = freshWorkdir();
|
|
181
322
|
const { pi, ctx } = makeHarness(workdir);
|
|
182
323
|
const entries = [
|
|
@@ -190,41 +331,198 @@ describe("execution loop", () => {
|
|
|
190
331
|
},
|
|
191
332
|
},
|
|
192
333
|
];
|
|
193
|
-
restoreFromSession(pi, ctx, entries as any);
|
|
334
|
+
await restoreFromSession(pi, ctx, entries as any);
|
|
194
335
|
assert.equal(getExecution(), null);
|
|
195
336
|
});
|
|
196
337
|
|
|
197
|
-
it("stop clears execution", () => {
|
|
338
|
+
it("stop clears execution", async () => {
|
|
198
339
|
const workdir = freshWorkdir();
|
|
199
340
|
const { pi, ctx } = makeHarness(workdir);
|
|
200
|
-
startExecution(pi, ctx, path.join(workdir, "PLAN_v1.md"), items("VC-001"));
|
|
201
|
-
stopExecution(pi, ctx, "test");
|
|
341
|
+
await startExecution(pi, ctx, path.join(workdir, "PLAN_v1.md"), items("VC-001"));
|
|
342
|
+
await stopExecution(pi, ctx, "test");
|
|
202
343
|
assert.equal(getExecution(), null);
|
|
203
344
|
});
|
|
204
345
|
|
|
205
|
-
it("
|
|
346
|
+
it("switches to the execution model and restores the planning model", async () => {
|
|
206
347
|
const workdir = freshWorkdir();
|
|
207
348
|
const { pi, ctx, recorded } = makeHarness(workdir);
|
|
208
|
-
startExecution(pi, ctx, path.join(workdir, "
|
|
349
|
+
await startExecution(pi, ctx, path.join(workdir, "PLAN_v6.md"), items("VC-001"), {
|
|
350
|
+
planningSelector: "p/m:high",
|
|
351
|
+
executionSelector: "prov/other:xhigh",
|
|
352
|
+
});
|
|
209
353
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
assert.
|
|
354
|
+
assert.deepEqual(recorded.models, [{ provider: "prov", id: "other" }]);
|
|
355
|
+
assert.equal(recorded.thinking, "xhigh");
|
|
356
|
+
assert.match(recorded.status ?? "", /⌛ plans 0\/1/);
|
|
357
|
+
|
|
358
|
+
await stopExecution(pi, ctx, "restore-check");
|
|
359
|
+
assert.equal(recorded.models.at(-1)?.provider, "p");
|
|
360
|
+
assert.equal(recorded.thinking, "high");
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it("defers execution model restore until the first agent turn and restores planning model on completion", async () => {
|
|
364
|
+
const workdir = freshWorkdir();
|
|
365
|
+
const { pi, ctx, recorded } = makeHarness(workdir);
|
|
366
|
+
const snapshot = {
|
|
367
|
+
planPath: path.join(workdir, "PLAN_v7.md"),
|
|
368
|
+
items: items("VC-001"),
|
|
369
|
+
startedAt: "2026-08-25T00:00:00Z",
|
|
370
|
+
modelState: { planningSelector: "p/m:high", executionSelector: "prov/other:xhigh" },
|
|
371
|
+
};
|
|
372
|
+
fs.writeFileSync(snapshot.planPath, "# plan");
|
|
373
|
+
const entries = [{ type: "custom", customType: "pi-plans-exec", data: snapshot }];
|
|
374
|
+
|
|
375
|
+
await restoreFromSession(pi, ctx, entries as any);
|
|
376
|
+
assert.ok(getExecution());
|
|
377
|
+
assert.deepEqual(recorded.models, []);
|
|
378
|
+
|
|
379
|
+
await ensureExecutionModelActive(pi, ctx);
|
|
380
|
+
assert.deepEqual(recorded.models, [{ provider: "prov", id: "other" }]);
|
|
381
|
+
assert.equal(recorded.thinking, "xhigh");
|
|
382
|
+
|
|
383
|
+
const doneEntries = [
|
|
384
|
+
...entries,
|
|
385
|
+
{ type: "message", message: { role: "assistant", content: [{ type: "text", text: "did [DONE:VC-001]" }] } },
|
|
386
|
+
];
|
|
387
|
+
await restoreFromSession(pi, ctx, doneEntries as any);
|
|
388
|
+
assert.equal(getExecution(), null);
|
|
389
|
+
assert.equal(recorded.models.at(-1)?.provider, "p");
|
|
390
|
+
assert.equal(recorded.thinking, "high");
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it("prompts for the execution model when unset and confirms inherit current session model", async () => {
|
|
394
|
+
const workdir = freshWorkdir();
|
|
395
|
+
const { ctx, recorded } = makeHarness(workdir);
|
|
396
|
+
initState(workdir);
|
|
397
|
+
recorded.selectAnswer = "Inherit current session model";
|
|
398
|
+
const selection = await chooseExecutionModelSelection(ctx as any, "Execution model", {
|
|
399
|
+
model_selector: null,
|
|
400
|
+
source: "unset",
|
|
401
|
+
updated_at: null,
|
|
402
|
+
}, async (selector) => {
|
|
403
|
+
setExecutionModel(workdir, { modelSelector: selector, source: "user" });
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
assert.equal(recorded.selects.length, 1);
|
|
407
|
+
assert.equal(recorded.selects[0]?.options[0], "Inherit current session model");
|
|
408
|
+
assert.equal(selection?.selector, "p/m:high");
|
|
409
|
+
assert.equal(showConfig(workdir).execution.source, "user");
|
|
410
|
+
assert.equal(showConfig(workdir).execution.model_selector, null);
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
it("does not prompt when the execution model is already confirmed as inherit", async () => {
|
|
414
|
+
const workdir = freshWorkdir();
|
|
415
|
+
const { ctx, recorded } = makeHarness(workdir);
|
|
416
|
+
initState(workdir);
|
|
417
|
+
setExecutionModel(workdir, { modelSelector: "inherit", source: "user" });
|
|
418
|
+
const selection = await chooseExecutionModelSelection(ctx as any, "Execution model", showConfig(workdir).execution);
|
|
419
|
+
|
|
420
|
+
assert.equal(recorded.selects.length, 0);
|
|
421
|
+
assert.equal(selection?.selector, "p/m:high");
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it("continues on the current model with a warning when the selector is unavailable", async () => {
|
|
425
|
+
const workdir = freshWorkdir();
|
|
426
|
+
const { ctx, recorded } = makeHarness(workdir);
|
|
427
|
+
(ctx as any).hasUI = false;
|
|
428
|
+
const selection = await chooseExecutionModelSelection(ctx as any, "Execution model", {
|
|
429
|
+
model_selector: "prov/missing:xhigh",
|
|
430
|
+
source: "user",
|
|
431
|
+
updated_at: null,
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
assert.equal(selection?.provider, "p");
|
|
435
|
+
assert.ok(recorded.notifies.some((n) => n.severity === "warning" && n.message.includes("prov/missing")));
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it("keeps the status bar count-free while executing and points at the panel", async () => {
|
|
439
|
+
const workdir = freshWorkdir();
|
|
440
|
+
const { pi, ctx, recorded } = makeHarness(workdir);
|
|
441
|
+
await startExecution(pi, ctx, path.join(workdir, "PLAN_v2.md"), items("VC-001", "VC-002"));
|
|
442
|
+
|
|
443
|
+
// Bottom status bar carries the count — the same layer as ⛔/⌛ — so both
|
|
444
|
+
// execution states read from one consistent place.
|
|
445
|
+
assert.match(recorded.status ?? "", /⌛ plans 0\/2: spent \d{2}:\d{2}:\d{2}/);
|
|
446
|
+
assert.match(recorded.status ?? "", /in-toks/);
|
|
213
447
|
|
|
214
448
|
const start = recorded.messages.find((message) => message.customType === "pi-plans-exec-start");
|
|
215
449
|
assert.ok(start);
|
|
216
|
-
assert.match(start.content, /Progress appears
|
|
450
|
+
assert.match(start.content, /Progress appears in the bottom status bar/);
|
|
217
451
|
assert.doesNotMatch(start.content, /footer/);
|
|
218
452
|
|
|
219
453
|
applyDoneMarkers("[DONE:VC-001]");
|
|
220
|
-
completeExecution(pi, ctx);
|
|
454
|
+
await completeExecution(pi, ctx);
|
|
221
455
|
assert.equal(getExecution(), null);
|
|
456
|
+
|
|
222
457
|
});
|
|
223
458
|
|
|
224
|
-
it("
|
|
459
|
+
it("renders the idle indicator by run status", () => {
|
|
460
|
+
const workdir = freshWorkdir();
|
|
461
|
+
const { ctx, recorded } = makeHarness(workdir);
|
|
462
|
+
initState(workdir);
|
|
463
|
+
const { run } = startRun(workdir, { topic: "demo", skill: "plan-small", requestText: "x" });
|
|
464
|
+
|
|
465
|
+
// planning before any PLAN draft exists: 💬 (Q&A phase).
|
|
466
|
+
updateStatusWidget(ctx);
|
|
467
|
+
assert.match(recorded.status ?? "", /💬 plans: /);
|
|
468
|
+
assert.equal(recorded.colors.at(-1), "muted");
|
|
469
|
+
|
|
470
|
+
// Once a draft lands: 📝, kept until execution starts.
|
|
471
|
+
fs.writeFileSync(path.join(run.artifact_dir, "PLAN_v1.md"), "# plan");
|
|
472
|
+
updateStatusWidget(ctx);
|
|
473
|
+
assert.match(recorded.status ?? "", /📝 plans: /);
|
|
474
|
+
assert.equal(recorded.colors.at(-1), "muted");
|
|
475
|
+
|
|
476
|
+
setRunStatus(workdir, run.run_id, "accepted");
|
|
477
|
+
updateStatusWidget(ctx);
|
|
478
|
+
assert.match(recorded.status ?? "", /⌛ plans: /);
|
|
479
|
+
assert.equal(recorded.colors.at(-1), "warning");
|
|
480
|
+
|
|
481
|
+
setRunStatus(workdir, run.run_id, "stopped");
|
|
482
|
+
updateStatusWidget(ctx);
|
|
483
|
+
assert.match(recorded.status ?? "", /⛔ plans: /);
|
|
484
|
+
assert.equal(recorded.colors.at(-1), "warning");
|
|
485
|
+
|
|
486
|
+
setRunStatus(workdir, run.run_id, "done");
|
|
487
|
+
updateStatusWidget(ctx);
|
|
488
|
+
assert.match(recorded.status ?? "", /🎯 plans: .*\(done\)/);
|
|
489
|
+
assert.equal(recorded.colors.at(-1), "success");
|
|
490
|
+
|
|
491
|
+
setRunStatus(workdir, run.run_id, "abandoned");
|
|
492
|
+
updateStatusWidget(ctx);
|
|
493
|
+
assert.match(recorded.status ?? "", /🚫 plans: /);
|
|
494
|
+
assert.equal(recorded.colors.at(-1), "error");
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
it("accumulates token usage on token-only and completion turns", async () => {
|
|
225
498
|
const workdir = freshWorkdir();
|
|
226
499
|
const { pi, ctx, recorded } = makeHarness(workdir);
|
|
227
|
-
startExecution(pi, ctx, path.join(workdir, "
|
|
500
|
+
await startExecution(pi, ctx, path.join(workdir, "PLAN_v5.md"), items("VC-001", "VC-002"));
|
|
501
|
+
|
|
502
|
+
recordExecutionTurn(pi, ctx, [], { input: 100, output: 40 });
|
|
503
|
+
updateStatusWidget(ctx);
|
|
504
|
+
assert.equal(getExecution()?.usage.inToks, 100);
|
|
505
|
+
assert.equal(getExecution()?.usage.outToks, 40);
|
|
506
|
+
assert.match(recorded.status ?? "", /100 in-toks/);
|
|
507
|
+
assert.match(recorded.status ?? "", /40 out-toks/);
|
|
508
|
+
|
|
509
|
+
assert.deepEqual(applyDoneMarkers("[DONE:VC-001]"), ["VC-001"]);
|
|
510
|
+
recordExecutionTurn(pi, ctx, ["VC-001"], { input: 20, output: 10 });
|
|
511
|
+
updateStatusWidget(ctx);
|
|
512
|
+
assert.equal(getExecution()?.usage.inToks, 120);
|
|
513
|
+
assert.equal(getExecution()?.usage.outToks, 50);
|
|
514
|
+
assert.equal(getExecution()?.items[0].done, true);
|
|
515
|
+
assert.match(recorded.status ?? "", /120 in-toks/);
|
|
516
|
+
assert.match(recorded.status ?? "", /50 out-toks/);
|
|
517
|
+
|
|
518
|
+
await stopExecution(pi, ctx, "test-done");
|
|
519
|
+
assert.equal(getExecution(), null);
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it("defers persistence and widget churn when toggling mid-turn", async () => {
|
|
523
|
+
const workdir = freshWorkdir();
|
|
524
|
+
const { pi, ctx, recorded } = makeHarness(workdir);
|
|
525
|
+
await startExecution(pi, ctx, path.join(workdir, "PLAN_v3.md"), items("VC-001", "VC-002"));
|
|
228
526
|
|
|
229
527
|
const entriesBefore = recorded.entries.length;
|
|
230
528
|
const factoriesBefore = recorded.widgetCalls;
|
|
@@ -237,13 +535,590 @@ describe("execution loop", () => {
|
|
|
237
535
|
assert.equal(consumePendingPanelSync(), true, "expected a pending panel sync marker");
|
|
238
536
|
assert.equal(consumePendingPanelSync(), false, "marker should be consumed exactly once");
|
|
239
537
|
|
|
240
|
-
// Back to idle: the next toggle persists and syncs
|
|
241
|
-
//
|
|
538
|
+
// Back to idle: the next toggle persists and syncs without touching the
|
|
539
|
+
// widget slot because the panel is collapsed again.
|
|
242
540
|
ctx.isIdle = () => true;
|
|
243
541
|
assert.equal(toggleExecutionPanelView(pi, ctx), false);
|
|
244
542
|
assert.ok(recorded.entries.length > entriesBefore, "idle toggle did not persist");
|
|
245
|
-
assert.equal(recorded.widgetCalls, factoriesBefore, "
|
|
543
|
+
assert.equal(recorded.widgetCalls, factoriesBefore, "collapsed toggle churned the widget registration");
|
|
246
544
|
|
|
247
|
-
stopExecution(pi, ctx, "test-done");
|
|
545
|
+
await stopExecution(pi, ctx, "test-done");
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
it("lets Pi core own execution compaction scheduling and customizes every reason", async () => {
|
|
549
|
+
const workdir = freshWorkdir();
|
|
550
|
+
const { pi, ctx, setUsagePercent } = makeHarness(workdir);
|
|
551
|
+
await startExecution(pi, ctx, path.join(workdir, "PLAN_v8.md"), items("VC-001", "VC-002"));
|
|
552
|
+
|
|
553
|
+
setUsagePercent(50);
|
|
554
|
+
const below = handleExecutionBeforeCompact(pi, ctx, {
|
|
555
|
+
type: "session_before_compact",
|
|
556
|
+
preparation: makePreparation("threshold", null),
|
|
557
|
+
branchEntries: [],
|
|
558
|
+
reason: "threshold",
|
|
559
|
+
willRetry: false,
|
|
560
|
+
signal: new AbortController().signal,
|
|
561
|
+
});
|
|
562
|
+
assert.equal(below?.cancel, undefined);
|
|
563
|
+
assert.ok(below?.compaction, "threshold compaction should remain Pi-core-owned but use the custom summary");
|
|
564
|
+
|
|
565
|
+
setUsagePercent(105);
|
|
566
|
+
const above = handleExecutionBeforeCompact(pi, ctx, {
|
|
567
|
+
type: "session_before_compact",
|
|
568
|
+
preparation: makePreparation("threshold", null),
|
|
569
|
+
branchEntries: [],
|
|
570
|
+
reason: "threshold",
|
|
571
|
+
willRetry: false,
|
|
572
|
+
signal: new AbortController().signal,
|
|
573
|
+
});
|
|
574
|
+
assert.equal(above?.cancel, undefined);
|
|
575
|
+
assert.ok(above?.compaction);
|
|
576
|
+
|
|
577
|
+
const overflow = handleExecutionBeforeCompact(pi, ctx, {
|
|
578
|
+
type: "session_before_compact",
|
|
579
|
+
preparation: makePreparation("overflow", null),
|
|
580
|
+
branchEntries: [],
|
|
581
|
+
reason: "overflow",
|
|
582
|
+
willRetry: true,
|
|
583
|
+
signal: new AbortController().signal,
|
|
584
|
+
});
|
|
585
|
+
assert.equal(overflow?.cancel, undefined);
|
|
586
|
+
assert.ok(overflow?.compaction);
|
|
587
|
+
|
|
588
|
+
const manual = handleExecutionBeforeCompact(pi, ctx, {
|
|
589
|
+
type: "session_before_compact",
|
|
590
|
+
preparation: makePreparation("manual", null),
|
|
591
|
+
branchEntries: [],
|
|
592
|
+
reason: "manual",
|
|
593
|
+
willRetry: false,
|
|
594
|
+
signal: new AbortController().signal,
|
|
595
|
+
});
|
|
596
|
+
assert.equal(manual?.cancel, undefined);
|
|
597
|
+
assert.ok(manual?.compaction);
|
|
598
|
+
|
|
599
|
+
await stopExecution(pi, ctx, "test-done");
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
it("does not install an execution threshold trigger in turn_end", () => {
|
|
603
|
+
const indexSource = fs.readFileSync(path.join(process.cwd(), "index.ts"), "utf8");
|
|
604
|
+
const execSource = fs.readFileSync(path.join(process.cwd(), "src/exec.ts"), "utf8");
|
|
605
|
+
const turnEndStart = execSource.indexOf('pi.on("turn_end"');
|
|
606
|
+
const nextSection = execSource.indexOf("type CompactBranchEntry", turnEndStart);
|
|
607
|
+
assert.match(indexSource, /registerExecutionTurnHandlers\(pi/);
|
|
608
|
+
assert.ok(turnEndStart >= 0);
|
|
609
|
+
assert.ok(nextSection > turnEndStart);
|
|
610
|
+
assert.doesNotMatch(execSource.slice(turnEndStart, nextSection), /requestExecutionCompaction|shouldTriggerExecutionCompaction|ctx\.compact/);
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
it("queues one non-retry resume, skips overflow retry, and keeps execution after failure", async () => {
|
|
614
|
+
const workdir = freshWorkdir();
|
|
615
|
+
const { pi, ctx, recorded } = makeHarness(workdir);
|
|
616
|
+
await startExecution(pi, ctx, path.join(workdir, "PLAN_v9.md"), items("VC-001"));
|
|
617
|
+
|
|
618
|
+
const beforeRequests = recorded.compacts?.length ?? 0;
|
|
619
|
+
handleExecutionCompact(pi, ctx, {
|
|
620
|
+
type: "session_compact",
|
|
621
|
+
compactionEntry: { type: "compaction" } as never,
|
|
622
|
+
fromExtension: true,
|
|
623
|
+
reason: "threshold",
|
|
624
|
+
willRetry: false,
|
|
625
|
+
});
|
|
626
|
+
const resumes = recorded.messages.filter((message) => message.customType === "pi-plans-exec-resume");
|
|
627
|
+
assert.equal(resumes.length, 1, "non-retry compaction should queue one hidden resume");
|
|
628
|
+
assert.equal(resumes[0]?.options?.triggerTurn, true);
|
|
629
|
+
assert.equal(recorded.compacts?.length ?? 0, beforeRequests, "compaction hook must not invoke ctx.compact");
|
|
630
|
+
|
|
631
|
+
handleExecutionCompact(pi, ctx, {
|
|
632
|
+
type: "session_compact",
|
|
633
|
+
compactionEntry: { type: "compaction" } as never,
|
|
634
|
+
fromExtension: true,
|
|
635
|
+
reason: "overflow",
|
|
636
|
+
willRetry: true,
|
|
637
|
+
});
|
|
638
|
+
assert.equal(
|
|
639
|
+
recorded.messages.filter((message) => message.customType === "pi-plans-exec-resume").length,
|
|
640
|
+
1,
|
|
641
|
+
"overflow retry is owned by Pi core",
|
|
642
|
+
);
|
|
643
|
+
|
|
644
|
+
handleExecutionCompactFailed(pi, ctx, {
|
|
645
|
+
type: "session_compact_failed",
|
|
646
|
+
reason: "manual",
|
|
647
|
+
aborted: false,
|
|
648
|
+
willRetry: false,
|
|
649
|
+
fromExtension: true,
|
|
650
|
+
errorMessage: "boom",
|
|
651
|
+
});
|
|
652
|
+
assert.ok(getExecution(), "compaction failure must keep execution active");
|
|
653
|
+
assert.ok(recorded.notifies.some((note) => note.severity === "warning" && note.message.includes("execution remains active")));
|
|
654
|
+
|
|
655
|
+
await stopExecution(pi, ctx, "test-done");
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
it("builds a plan-aware summary with per-item sections and chains the previous summary", async () => {
|
|
659
|
+
const workdir = freshWorkdir();
|
|
660
|
+
const { pi, ctx, setUsagePercent } = makeHarness(workdir);
|
|
661
|
+
await startExecution(pi, ctx, path.join(workdir, "PLAN_v10.md"), items("VC-001", "VC-002"));
|
|
662
|
+
|
|
663
|
+
applyDoneMarkers("[DONE:VC-001]");
|
|
664
|
+
|
|
665
|
+
const previousSummary = "## Goal\nDeliver auto-compact in execution phase.\n\n## Finished Items\n- legacy VC-000 summary";
|
|
666
|
+
const preparation = makePreparation("threshold", previousSummary);
|
|
667
|
+
const branchEntries: any[] = [
|
|
668
|
+
{ id: "exec-start", type: "custom", customType: "pi-plans-exec-start" },
|
|
669
|
+
{ id: "u-1", type: "message", message: { role: "user", content: [{ type: "text", text: "implement VC-001" }] } },
|
|
670
|
+
{ id: "a-1", type: "message", message: { role: "assistant", content: [{ type: "text", text: "wrote helper [DONE:VC-001]" }] } },
|
|
671
|
+
{ id: "u-2", type: "message", message: { role: "user", content: [{ type: "text", text: "implement VC-002" }] } },
|
|
672
|
+
{ id: "a-2", type: "message", message: { role: "assistant", content: [{ type: "text", text: "almost done" }] } },
|
|
673
|
+
{ id: "exec-ctx", type: "custom", customType: "pi-plans-exec-context" },
|
|
674
|
+
];
|
|
675
|
+
const result = buildExecutionCompactionResult(
|
|
676
|
+
{
|
|
677
|
+
type: "session_before_compact",
|
|
678
|
+
preparation,
|
|
679
|
+
branchEntries,
|
|
680
|
+
customInstructions: "keep current task visible",
|
|
681
|
+
reason: "threshold",
|
|
682
|
+
willRetry: false,
|
|
683
|
+
signal: new AbortController().signal,
|
|
684
|
+
},
|
|
685
|
+
ctx,
|
|
686
|
+
);
|
|
687
|
+
assert.ok(result);
|
|
688
|
+
assert.match(result!.summary, /## Compact Instructions/);
|
|
689
|
+
assert.match(result!.summary, /keep current task visible/);
|
|
690
|
+
assert.match(result!.summary, /## Plan Before This Run/);
|
|
691
|
+
assert.match(result!.summary, /## Previous Compact Summary/);
|
|
692
|
+
assert.match(result!.summary, /## Finished VC Items/);
|
|
693
|
+
assert.match(result!.summary, /### `VC-001`/);
|
|
694
|
+
assert.match(result!.summary, /## Current Work/);
|
|
695
|
+
assert.match(result!.summary, /Raw tail preserved from `u-2`/);
|
|
696
|
+
assert.deepEqual(result!.firstKeptEntryId, "u-2");
|
|
697
|
+
|
|
698
|
+
setUsagePercent(110);
|
|
699
|
+
handleExecutionBeforeCompact(pi, ctx, {
|
|
700
|
+
type: "session_before_compact",
|
|
701
|
+
preparation,
|
|
702
|
+
branchEntries,
|
|
703
|
+
reason: "threshold",
|
|
704
|
+
willRetry: false,
|
|
705
|
+
signal: new AbortController().signal,
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
await stopExecution(pi, ctx, "test-done");
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
it("filters the hidden resume message out of the LLM context payload", () => {
|
|
712
|
+
const messages = [
|
|
713
|
+
{ customType: "user", content: "real prompt" },
|
|
714
|
+
{ customType: "pi-plans-exec-resume", content: "Continue execution." },
|
|
715
|
+
{ customType: "pi-plans-plan-resume", content: "Continue planning." },
|
|
716
|
+
{ customType: "assistant", content: "ok" },
|
|
717
|
+
];
|
|
718
|
+
assert.equal(filterExecutionResumeMessages(messages).length, 3);
|
|
719
|
+
assert.equal(filterPlanningResumeMessages(messages).length, 3);
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
it("planning compaction cuts at plan-written when present and falls back to run-start", () => {
|
|
723
|
+
const workdir = freshWorkdir();
|
|
724
|
+
const { ctx } = makeHarness(workdir);
|
|
725
|
+
initState(workdir);
|
|
726
|
+
const { run } = startRun(workdir, { topic: "planning compact", skill: "plan-normal", requestText: "demo" });
|
|
727
|
+
fs.writeFileSync(path.join(run.artifact_dir, "PLAN_v1.md"), "# plan");
|
|
728
|
+
|
|
729
|
+
const makePlanningPreparation = (previousSummary: string | null) => ({
|
|
730
|
+
firstKeptEntryId: "fallback",
|
|
731
|
+
messagesToSummarize: [],
|
|
732
|
+
turnPrefixMessages: [],
|
|
733
|
+
isSplitTurn: false,
|
|
734
|
+
tokensBefore: 50000,
|
|
735
|
+
previousSummary,
|
|
736
|
+
fileOps: { read: [], written: [], edited: [] },
|
|
737
|
+
settings: { enabled: true, reserveTokens: 16384, keepRecentTokens: 20000 },
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
// Case A: plan written → cut at next non-internal entry after plan-written; QA section included.
|
|
741
|
+
const branchEntriesWithPlan = [
|
|
742
|
+
{ id: "rs", type: "custom", customType: PLANNING_RUN_START_CUSTOM_TYPE, data: { runId: run.run_id, artifactDir: run.artifact_dir } },
|
|
743
|
+
{ id: "u-1", type: "message", message: { role: "user", content: [{ type: "text", text: "background question?" }] } },
|
|
744
|
+
{ id: "a-1", type: "message", message: { role: "assistant", content: [{ type: "text", text: "some context" }] } },
|
|
745
|
+
{ id: "pw", type: "custom", customType: PLANNING_PLAN_WRITTEN_CUSTOM_TYPE, data: { runId: run.run_id, planPath: path.join(run.artifact_dir, "PLAN_v1.md") } },
|
|
746
|
+
{ id: "u-2", type: "message", message: { role: "user", content: [{ type: "text", text: "review please" }] } },
|
|
747
|
+
] as any;
|
|
748
|
+
const withPlan = buildPlanningCompactionResult(
|
|
749
|
+
{
|
|
750
|
+
type: "session_before_compact",
|
|
751
|
+
preparation: makePlanningPreparation(null),
|
|
752
|
+
branchEntries: branchEntriesWithPlan,
|
|
753
|
+
reason: "threshold",
|
|
754
|
+
willRetry: false,
|
|
755
|
+
signal: new AbortController().signal,
|
|
756
|
+
},
|
|
757
|
+
ctx,
|
|
758
|
+
);
|
|
759
|
+
assert.ok(withPlan);
|
|
760
|
+
assert.deepEqual(withPlan!.firstKeptEntryId, "u-2");
|
|
761
|
+
assert.match(withPlan!.summary, /## Q&A During Planning/);
|
|
762
|
+
assert.match(withPlan!.summary, /background question?/);
|
|
763
|
+
|
|
764
|
+
// Case B: only run-start → cut at first non-internal entry after marker; no QA section.
|
|
765
|
+
const branchEntriesWithoutPlan = [
|
|
766
|
+
{ id: "rs", type: "custom", customType: PLANNING_RUN_START_CUSTOM_TYPE, data: { runId: run.run_id, artifactDir: run.artifact_dir } },
|
|
767
|
+
{ id: "u-1", type: "message", message: { role: "user", content: [{ type: "text", text: "open question" }] } },
|
|
768
|
+
{ id: "a-1", type: "message", message: { role: "assistant", content: [{ type: "text", text: "thinking out loud" }] } },
|
|
769
|
+
] as any;
|
|
770
|
+
const withoutPlan = buildPlanningCompactionResult(
|
|
771
|
+
{
|
|
772
|
+
type: "session_before_compact",
|
|
773
|
+
preparation: makePlanningPreparation(null),
|
|
774
|
+
branchEntries: branchEntriesWithoutPlan,
|
|
775
|
+
reason: "manual",
|
|
776
|
+
willRetry: false,
|
|
777
|
+
signal: new AbortController().signal,
|
|
778
|
+
},
|
|
779
|
+
ctx,
|
|
780
|
+
);
|
|
781
|
+
assert.ok(withoutPlan);
|
|
782
|
+
assert.deepEqual(withoutPlan!.firstKeptEntryId, "u-1");
|
|
783
|
+
assert.doesNotMatch(withoutPlan!.summary, /## Q&A During Planning/);
|
|
784
|
+
|
|
785
|
+
// Case C: no markers → fallback to preparation.firstKeptEntryId and no QA section.
|
|
786
|
+
const fallback = buildPlanningCompactionResult(
|
|
787
|
+
{
|
|
788
|
+
type: "session_before_compact",
|
|
789
|
+
preparation: makePlanningPreparation("## Previous\nEarlier summary."),
|
|
790
|
+
branchEntries: [],
|
|
791
|
+
reason: "threshold",
|
|
792
|
+
willRetry: false,
|
|
793
|
+
signal: new AbortController().signal,
|
|
794
|
+
},
|
|
795
|
+
ctx,
|
|
796
|
+
);
|
|
797
|
+
assert.ok(fallback);
|
|
798
|
+
assert.deepEqual(fallback!.firstKeptEntryId, "fallback");
|
|
799
|
+
assert.doesNotMatch(fallback!.summary, /## Q&A During Planning/);
|
|
800
|
+
assert.match(fallback!.summary, /## Previous Compact Summary/);
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
it("planning hook is gated by run.status=planning and defers to execution hook when execution is running", async () => {
|
|
804
|
+
const workdir = freshWorkdir();
|
|
805
|
+
const { ctx, setUsagePercent } = makeHarness(workdir);
|
|
806
|
+
initState(workdir);
|
|
807
|
+
const { run } = startRun(workdir, { topic: "planning gate", skill: "plan-small", requestText: "x" });
|
|
808
|
+
|
|
809
|
+
setUsagePercent(110);
|
|
810
|
+
const resultPlanning = handlePlanningBeforeCompact({} as any, ctx as any, {
|
|
811
|
+
type: "session_before_compact",
|
|
812
|
+
preparation: {
|
|
813
|
+
firstKeptEntryId: "fb",
|
|
814
|
+
messagesToSummarize: [],
|
|
815
|
+
turnPrefixMessages: [],
|
|
816
|
+
isSplitTurn: false,
|
|
817
|
+
tokensBefore: 1,
|
|
818
|
+
previousSummary: null,
|
|
819
|
+
fileOps: { read: [], written: [], edited: [] },
|
|
820
|
+
settings: { enabled: true, reserveTokens: 16384, keepRecentTokens: 20000 },
|
|
821
|
+
},
|
|
822
|
+
branchEntries: [],
|
|
823
|
+
reason: "threshold",
|
|
824
|
+
willRetry: false,
|
|
825
|
+
signal: new AbortController().signal,
|
|
826
|
+
});
|
|
827
|
+
assert.ok(resultPlanning?.compaction || resultPlanning === undefined);
|
|
828
|
+
|
|
829
|
+
// Flip status to done; planning hook should refuse.
|
|
830
|
+
setRunStatus(workdir, run.run_id, "done");
|
|
831
|
+
setUsagePercent(110);
|
|
832
|
+
const resultDone = handlePlanningBeforeCompact({} as any, ctx as any, {
|
|
833
|
+
type: "session_before_compact",
|
|
834
|
+
preparation: {
|
|
835
|
+
firstKeptEntryId: "fb",
|
|
836
|
+
messagesToSummarize: [],
|
|
837
|
+
turnPrefixMessages: [],
|
|
838
|
+
isSplitTurn: false,
|
|
839
|
+
tokensBefore: 1,
|
|
840
|
+
previousSummary: null,
|
|
841
|
+
fileOps: { read: [], written: [], edited: [] },
|
|
842
|
+
settings: { enabled: true, reserveTokens: 16384, keepRecentTokens: 20000 },
|
|
843
|
+
},
|
|
844
|
+
branchEntries: [],
|
|
845
|
+
reason: "threshold",
|
|
846
|
+
willRetry: false,
|
|
847
|
+
signal: new AbortController().signal,
|
|
848
|
+
});
|
|
849
|
+
assert.equal(resultDone, undefined);
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
it("turn_end writes are unconditionally deferred even when isIdle reads true", async () => {
|
|
853
|
+
const workdir = freshWorkdir();
|
|
854
|
+
const { pi, ctx, recorded, setUsagePercent } = makeHarness(workdir);
|
|
855
|
+
await startExecution(pi, ctx, path.join(workdir, "PLAN_v11.md"), items("VC-001", "VC-002"));
|
|
856
|
+
const snapshotCount = () => recorded.entries.filter((e) => e.customType === "pi-plans-exec").length;
|
|
857
|
+
const baseline = snapshotCount();
|
|
858
|
+
|
|
859
|
+
// No isIdle override: the harness default (() => true) IS the real
|
|
860
|
+
// turn_end reading — this encodes the field regression (60 writes in
|
|
861
|
+
// 23 minutes) as a permanent zero-write assertion.
|
|
862
|
+
recordExecutionTurn(pi, ctx, [], { input: 10, output: 5 });
|
|
863
|
+
setUsagePercent(50);
|
|
864
|
+
handleExecutionBeforeCompact(pi, ctx, {
|
|
865
|
+
type: "session_before_compact",
|
|
866
|
+
preparation: makePreparation("threshold", null),
|
|
867
|
+
branchEntries: [],
|
|
868
|
+
reason: "threshold",
|
|
869
|
+
willRetry: false,
|
|
870
|
+
signal: new AbortController().signal,
|
|
871
|
+
});
|
|
872
|
+
assert.equal(snapshotCount(), baseline, "turn_end wrote session entries despite deferral");
|
|
873
|
+
// Status line stays real-time while the write is deferred.
|
|
874
|
+
assert.match(recorded.status ?? "", /10 in-toks/);
|
|
875
|
+
|
|
876
|
+
drainExecutionFlush(pi, ctx);
|
|
877
|
+
assert.equal(snapshotCount(), baseline + 1, "settle flush did not write exactly one snapshot");
|
|
878
|
+
const last = (recorded.entries.filter((e) => e.customType === "pi-plans-exec").at(-1)?.data ?? {}) as { usage?: { inToks: number } };
|
|
879
|
+
assert.equal(last.usage?.inToks, 10, "flushed snapshot missing busy-turn usage");
|
|
880
|
+
drainExecutionFlush(pi, ctx);
|
|
881
|
+
assert.equal(snapshotCount(), baseline + 1, "second drain wrote again");
|
|
882
|
+
assert.equal(consumePendingExecutionFlush(), false);
|
|
883
|
+
|
|
884
|
+
await stopExecution(pi, ctx, "done");
|
|
885
|
+
});
|
|
886
|
+
|
|
887
|
+
it("stop and complete drain pending writes synchronously with final state", async () => {
|
|
888
|
+
const workdir = freshWorkdir();
|
|
889
|
+
const { pi, ctx, recorded } = makeHarness(workdir);
|
|
890
|
+
await startExecution(pi, ctx, path.join(workdir, "PLAN_v12.md"), items("VC-001", "VC-002"));
|
|
891
|
+
const snapshotCount = () => recorded.entries.filter((e) => e.customType === "pi-plans-exec").length;
|
|
892
|
+
|
|
893
|
+
ctx.isIdle = () => false;
|
|
894
|
+
recordExecutionTurn(pi, ctx, [], { input: 7, output: 3 });
|
|
895
|
+
const busyCount = snapshotCount();
|
|
896
|
+
|
|
897
|
+
await stopExecution(pi, ctx, "force");
|
|
898
|
+
assert.ok(snapshotCount() > busyCount, "stop did not write the final snapshot");
|
|
899
|
+
assert.ok(recorded.entries.some((e) => e.customType === "pi-plans-exec-cleared"));
|
|
900
|
+
const lastStop = (recorded.entries.filter((e) => e.customType === "pi-plans-exec").at(-1)?.data ?? {}) as { usage?: { inToks: number } };
|
|
901
|
+
assert.equal(lastStop.usage?.inToks, 7, "stop lost the busy-turn usage");
|
|
902
|
+
|
|
903
|
+
await startExecution(pi, ctx, path.join(workdir, "PLAN_v13.md"), items("VC-001"));
|
|
904
|
+
ctx.isIdle = () => false;
|
|
905
|
+
applyDoneMarkers("[DONE:VC-001]");
|
|
906
|
+
recordExecutionTurn(pi, ctx, ["VC-001"], { input: 1, output: 1 });
|
|
907
|
+
await completeExecution(pi, ctx);
|
|
908
|
+
assert.equal(getExecution(), null);
|
|
909
|
+
const lastComplete = (recorded.entries.filter((e) => e.customType === "pi-plans-exec").at(-1)?.data ?? {}) as { items?: Array<{ done: boolean }> };
|
|
910
|
+
assert.equal(lastComplete.items?.[0]?.done, true, "completion snapshot missing final done state");
|
|
911
|
+
});
|
|
912
|
+
|
|
913
|
+
it("plans-list groups by implementation item with status tags and strikethrough", async () => {
|
|
914
|
+
const workdir = freshWorkdir();
|
|
915
|
+
const { pi, ctx, recorded } = makeHarness(workdir);
|
|
916
|
+
const implItems = [
|
|
917
|
+
{ id: "I-001", text: "Add state helpers; evaluate percent." },
|
|
918
|
+
{ id: "I-002", text: "Wire the turn_end trigger and status bar." },
|
|
919
|
+
{ id: "I-003", text: "Zero coverage orphan item." },
|
|
920
|
+
];
|
|
921
|
+
const vcItems: CheckItem[] = [
|
|
922
|
+
{ id: "VC-001", text: "`VC-001` covers `I-001`; pass condition: tests", done: false },
|
|
923
|
+
{ id: "VC-002", text: "`VC-002` covers `I-001` and `I-002`; pass condition: lint", done: false },
|
|
924
|
+
];
|
|
925
|
+
await startExecution(pi, ctx, path.join(workdir, "PLAN_v14.md"), vcItems, undefined, implItems);
|
|
926
|
+
|
|
927
|
+
// Status bar counts by I, excluding the zero-coverage orphan.
|
|
928
|
+
updateStatusWidget(ctx);
|
|
929
|
+
assert.match(recorded.status ?? "", /⌛ plans 0\/2: spent/);
|
|
930
|
+
|
|
931
|
+
toggleExecutionPanelView(pi, ctx);
|
|
932
|
+
assert.ok(recorded.widget);
|
|
933
|
+
// Expanded: footer ⌛ line cleared — the panel renders its own header.
|
|
934
|
+
assert.equal(recorded.status, undefined);
|
|
935
|
+
const widget = recorded.widget!.factory(
|
|
936
|
+
{} as any,
|
|
937
|
+
{ fg: (_c: string, t: string) => t, strikethrough: (t: string) => `~~${t}~~` },
|
|
938
|
+
);
|
|
939
|
+
const lines = widget.render(120).join("\n");
|
|
940
|
+
assert.match(lines, /⌛ plans 0\/2: spent/);
|
|
941
|
+
assert.match(lines, /\[Implementing\] I-001: Add state helpers/);
|
|
942
|
+
assert.match(lines, /\[Pending\] I-002: Wire the turn_end trigger/);
|
|
943
|
+
assert.match(lines, /\[Pending\] I-003: Zero coverage orphan item/);
|
|
944
|
+
// Only I status rows — no VC sub-rows in grouped mode.
|
|
945
|
+
assert.doesNotMatch(lines, /☐/);
|
|
946
|
+
assert.doesNotMatch(lines, /`VC-001` covers/);
|
|
947
|
+
|
|
948
|
+
// Markers move the states; fan-out advances both covered I items.
|
|
949
|
+
applyImplMarkers("[I-001:implemented]");
|
|
950
|
+
applyDoneMarkers("[DONE:VC-001]");
|
|
951
|
+
applyDoneMarkers("[DONE:VC-002]");
|
|
952
|
+
widget.invalidate();
|
|
953
|
+
const rendered = widget.render(120).join("\n");
|
|
954
|
+
assert.match(rendered, /~~\[VC passed\] I-001: Add state helpers~~/);
|
|
955
|
+
assert.match(rendered, /~~\[VC passed\] I-002: Wire the turn_end trigger and status bar\.~~/);
|
|
956
|
+
|
|
957
|
+
// Status bar now counts both I items as passed.
|
|
958
|
+
updateStatusWidget(ctx);
|
|
959
|
+
assert.equal(recorded.status, undefined, "expanded panel keeps the footer ⌛ line cleared");
|
|
960
|
+
|
|
961
|
+
await stopExecution(pi, ctx, "done");
|
|
962
|
+
// Panel cleared and execution over: the ⌛ line must not linger.
|
|
963
|
+
assert.doesNotMatch(recorded.status ?? "", /⌛ plans/);
|
|
964
|
+
});
|
|
965
|
+
|
|
966
|
+
it("updates the status bar in real time on every turn without session writes", async () => {
|
|
967
|
+
const workdir = freshWorkdir();
|
|
968
|
+
const { pi, ctx, recorded, setUsagePercent } = makeHarness(workdir);
|
|
969
|
+
await startExecution(pi, ctx, path.join(workdir, "PLAN_v15.md"), items("VC-001", "VC-002"), undefined, [
|
|
970
|
+
{ id: "I-001", text: "First item." },
|
|
971
|
+
{ id: "I-002", text: "Second item." },
|
|
972
|
+
]);
|
|
973
|
+
const snapshotCount = () => recorded.entries.filter((e) => e.customType === "pi-plans-exec").length;
|
|
974
|
+
const baseline = snapshotCount();
|
|
975
|
+
|
|
976
|
+
recordExecutionTurn(pi, ctx, [], { input: 33, output: 11 });
|
|
977
|
+
// Real-time: status line already reflects the turn's usage...
|
|
978
|
+
assert.match(recorded.status ?? "", /33 in-toks/);
|
|
979
|
+
assert.match(recorded.status ?? "", /⌛ plans 0\/2: spent/);
|
|
980
|
+
// ...without any session write (anti-jitter preserved).
|
|
981
|
+
assert.equal(snapshotCount(), baseline);
|
|
982
|
+
|
|
983
|
+
setUsagePercent(null);
|
|
984
|
+
drainExecutionFlush(pi, ctx);
|
|
985
|
+
assert.equal(snapshotCount(), baseline + 1);
|
|
986
|
+
|
|
987
|
+
await stopExecution(pi, ctx, "done");
|
|
988
|
+
});
|
|
989
|
+
|
|
990
|
+
it("syncs progress through the registered message_end and turn_end handlers", async () => {
|
|
991
|
+
const workdir = freshWorkdir();
|
|
992
|
+
const { pi, ctx, recorded, emit } = makeHarness(workdir);
|
|
993
|
+
registerExecutionTurnHandlers(pi);
|
|
994
|
+
await startExecution(pi, ctx, path.join(workdir, "PLAN_v19.md"), items("VC-001", "VC-002", "VC-003"));
|
|
995
|
+
|
|
996
|
+
// The usage is delivered by message_end and consumed by the following
|
|
997
|
+
// turn_end, matching Pi's lifecycle contract.
|
|
998
|
+
await emit("message_end", {
|
|
999
|
+
message: { role: "assistant", usage: { input: 12, output: 5 } },
|
|
1000
|
+
});
|
|
1001
|
+
await emit("turn_end", {
|
|
1002
|
+
message: { role: "assistant", content: [{ type: "text", text: "verified [DONE:VC-001]" }] },
|
|
1003
|
+
});
|
|
1004
|
+
assert.match(recorded.status ?? "", /⌛ plans 1\/3: spent/);
|
|
1005
|
+
assert.match(recorded.status ?? "", /12 in-toks/);
|
|
1006
|
+
assert.equal(recorded.widgetCalls, 0, "collapsed progress must not touch the widget slot");
|
|
1007
|
+
|
|
1008
|
+
// Expanded progress is updated through the same event handler without
|
|
1009
|
+
// re-registering the live widget.
|
|
1010
|
+
toggleExecutionPanelView(pi, ctx);
|
|
1011
|
+
const renderRequests = { count: 0 };
|
|
1012
|
+
const widget = recorded.widget?.factory(
|
|
1013
|
+
{ requestRender: () => { renderRequests.count += 1; } },
|
|
1014
|
+
{ fg: (_color: string, text: string) => text, strikethrough: (text: string) => text },
|
|
1015
|
+
);
|
|
1016
|
+
assert.ok(widget);
|
|
1017
|
+
assert.match(widget.render(100)[0] ?? "", /⌛ plans 1\/3: spent/);
|
|
1018
|
+
const widgetCalls = recorded.widgetCalls;
|
|
1019
|
+
|
|
1020
|
+
await emit("message_end", {
|
|
1021
|
+
message: { role: "assistant", usage: { input: 8, output: 3 } },
|
|
1022
|
+
});
|
|
1023
|
+
await emit("turn_end", {
|
|
1024
|
+
message: { role: "assistant", content: [{ type: "text", text: "verified [DONE:VC-002]" }] },
|
|
1025
|
+
});
|
|
1026
|
+
assert.equal(recorded.status, undefined, "expanded execution keeps the footer indicator cleared");
|
|
1027
|
+
assert.equal(recorded.widgetCalls, widgetCalls, "progress refresh must reuse the live widget");
|
|
1028
|
+
assert.equal(renderRequests.count, 1, "expanded progress must request a TUI render");
|
|
1029
|
+
assert.match(widget.render(100)[0] ?? "", /⌛ plans 2\/3: spent/);
|
|
1030
|
+
|
|
1031
|
+
await stopExecution(pi, ctx, "event-chain-test");
|
|
1032
|
+
});
|
|
1033
|
+
it("applies impl markers with silent unknown ids and later-overwrite semantics", async () => {
|
|
1034
|
+
const workdir = freshWorkdir();
|
|
1035
|
+
const { pi, ctx } = makeHarness(workdir);
|
|
1036
|
+
await startExecution(pi, ctx, path.join(workdir, "PLAN_v16.md"), items("VC-001"), undefined, [
|
|
1037
|
+
{ id: "I-001", text: "First item." },
|
|
1038
|
+
]);
|
|
1039
|
+
|
|
1040
|
+
assert.deepEqual(applyImplMarkers("[I-999:implemented]"), []); // unknown id silently ignored
|
|
1041
|
+
assert.deepEqual(applyImplMarkers("[I-001:implemented]"), ["I-001"]);
|
|
1042
|
+
assert.deepEqual(applyImplMarkers("[I-001:validating]"), ["I-001"]); // later overwrites
|
|
1043
|
+
assert.equal(getExecution()?.implStatus?.["I-001"], "validating");
|
|
1044
|
+
|
|
1045
|
+
await stopExecution(pi, ctx, "done");
|
|
1046
|
+
});
|
|
1047
|
+
|
|
1048
|
+
it("replays impl markers from post-snapshot messages on restore", async () => {
|
|
1049
|
+
const workdir = freshWorkdir();
|
|
1050
|
+
const { pi, ctx } = makeHarness(workdir);
|
|
1051
|
+
const snapshot = {
|
|
1052
|
+
planPath: path.join(workdir, "PLAN_v17.md"),
|
|
1053
|
+
items: items("VC-001"),
|
|
1054
|
+
startedAt: "2026-08-29T00:00:00Z",
|
|
1055
|
+
implItems: [{ id: "I-001", text: "First item." }],
|
|
1056
|
+
implStatus: {},
|
|
1057
|
+
};
|
|
1058
|
+
fs.writeFileSync(snapshot.planPath, "# plan");
|
|
1059
|
+
const entries = [
|
|
1060
|
+
{ type: "custom", customType: "pi-plans-exec", data: snapshot },
|
|
1061
|
+
{ type: "message", message: { role: "assistant", content: [{ type: "text", text: "work done [I-001:implemented]" }] } },
|
|
1062
|
+
];
|
|
1063
|
+
await restoreFromSession(pi, ctx, entries as any);
|
|
1064
|
+
assert.equal(getExecution()?.implStatus?.["I-001"], "implemented");
|
|
1065
|
+
});
|
|
1066
|
+
|
|
1067
|
+
it("keeps the injection rules teaching the impl markers", async () => {
|
|
1068
|
+
const workdir = freshWorkdir();
|
|
1069
|
+
const { pi, ctx } = makeHarness(workdir);
|
|
1070
|
+
await startExecution(pi, ctx, path.join(workdir, "PLAN_v18.md"), items("VC-001"), undefined, [
|
|
1071
|
+
{ id: "I-001", text: "First item." },
|
|
1072
|
+
]);
|
|
1073
|
+
const rules = executionContextMessage()!;
|
|
1074
|
+
assert.match(rules, /\[I-001:implemented\]/);
|
|
1075
|
+
assert.match(rules, /\[I-001:validating\]/);
|
|
1076
|
+
await stopExecution(pi, ctx, "done");
|
|
1077
|
+
});
|
|
1078
|
+
|
|
1079
|
+
it("planning compaction honors cooldown + resume guard and survives manual /compact", () => {
|
|
1080
|
+
const workdir = freshWorkdir();
|
|
1081
|
+
const { pi, ctx, setUsagePercent, recorded } = makeHarness(workdir);
|
|
1082
|
+
initState(workdir);
|
|
1083
|
+
startRun(workdir, { topic: "planning cooldown", skill: "plan-normal", requestText: "x" });
|
|
1084
|
+
|
|
1085
|
+
setUsagePercent(120);
|
|
1086
|
+
assert.equal(shouldTriggerPlanningCompaction(ctx as any), true);
|
|
1087
|
+
requestPlanningCompaction(ctx as any);
|
|
1088
|
+
// In flight, second trigger ignored.
|
|
1089
|
+
requestPlanningCompaction(ctx as any);
|
|
1090
|
+
assert.equal(shouldTriggerPlanningCompaction(ctx as any), false);
|
|
1091
|
+
|
|
1092
|
+
setUsagePercent(50);
|
|
1093
|
+
handlePlanningCompact(pi as any, ctx as any, {
|
|
1094
|
+
type: "session_compact",
|
|
1095
|
+
compactionEntry: { type: "compaction" } as never,
|
|
1096
|
+
fromExtension: true,
|
|
1097
|
+
reason: "manual",
|
|
1098
|
+
willRetry: false,
|
|
1099
|
+
});
|
|
1100
|
+
const resume = recorded.messages.find((message) => message.customType === "pi-plans-plan-resume");
|
|
1101
|
+
assert.ok(resume);
|
|
1102
|
+
assert.equal(consumePlanningCompactionResumeGuard(ctx as any), true);
|
|
1103
|
+
// Cooldown blocks retrigger while usage is still mid-band.
|
|
1104
|
+
setUsagePercent(95);
|
|
1105
|
+
assert.equal(shouldTriggerPlanningCompaction(ctx as any), false);
|
|
1106
|
+
setUsagePercent(50);
|
|
1107
|
+
refreshPlanningCompactionCooldown(ctx as any);
|
|
1108
|
+
setUsagePercent(120);
|
|
1109
|
+
assert.equal(shouldTriggerPlanningCompaction(ctx as any), true);
|
|
248
1110
|
});
|
|
249
1111
|
});
|
|
1112
|
+
|
|
1113
|
+
function makePreparation(reason: "manual" | "threshold" | "overflow", previousSummary: string | null): any {
|
|
1114
|
+
return {
|
|
1115
|
+
firstKeptEntryId: "a-2",
|
|
1116
|
+
messagesToSummarize: [],
|
|
1117
|
+
turnPrefixMessages: [],
|
|
1118
|
+
isSplitTurn: false,
|
|
1119
|
+
tokensBefore: 12345,
|
|
1120
|
+
previousSummary,
|
|
1121
|
+
fileOps: { read: [], written: [], edited: [] },
|
|
1122
|
+
settings: { enabled: true, reserveTokens: 16384, keepRecentTokens: 20000 },
|
|
1123
|
+
};
|
|
1124
|
+
}
|