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