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