pi-plans 0.1.2 → 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 +43 -17
- package/index.ts +34 -59
- 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 +557 -333
- package/src/plan.ts +37 -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 +9 -27
- 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 +153 -248
- package/tests/execute-plan.test.ts +65 -0
- package/tests/plan.test.ts +11 -1
- package/tests/plans.test.ts +6 -5
- package/tests/query-hook.test.ts +82 -0
- package/tests/refine-ui.test.ts +127 -0
- package/tests/state.test.ts +12 -15
- package/tests/subagent.test.ts +114 -0
- package/tools/ask-choice.ts +22 -0
- package/tools/execute-plan.ts +7 -39
- package/tools/plans.ts +1 -18
- package/tools/refine.ts +125 -71
- package/src/execution-panel.ts +0 -633
- package/tests/execution-panel.test.ts +0 -234
package/tests/exec.test.ts
CHANGED
|
@@ -8,12 +8,12 @@ import { after, before, describe, it } from "node:test";
|
|
|
8
8
|
import {
|
|
9
9
|
applyDoneMarkers,
|
|
10
10
|
applyImplMarkers,
|
|
11
|
+
applyCurrentIMarker,
|
|
11
12
|
buildExecutionCompactionResult,
|
|
12
13
|
buildPlanningCompactionResult,
|
|
13
14
|
completeExecution,
|
|
14
15
|
consumePendingExecutionFlush,
|
|
15
16
|
consumePlanningCompactionResumeGuard,
|
|
16
|
-
consumePendingPanelSync,
|
|
17
17
|
drainExecutionFlush,
|
|
18
18
|
executionContextMessage,
|
|
19
19
|
filterExecutionResumeMessages,
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
getExecution,
|
|
22
22
|
handleExecutionBeforeCompact,
|
|
23
23
|
handleExecutionCompact,
|
|
24
|
+
handleExecutionTurnCompaction,
|
|
24
25
|
handleExecutionCompactFailed,
|
|
25
26
|
handlePlanningBeforeCompact,
|
|
26
27
|
handlePlanningCompact,
|
|
@@ -32,17 +33,14 @@ import {
|
|
|
32
33
|
requestPlanningCompaction,
|
|
33
34
|
restoreFromSession,
|
|
34
35
|
shouldTriggerPlanningCompaction,
|
|
35
|
-
ensureExecutionModelActive,
|
|
36
36
|
startExecution,
|
|
37
37
|
recordExecutionTurn,
|
|
38
38
|
registerExecutionTurnHandlers,
|
|
39
39
|
stopExecution,
|
|
40
|
-
chooseExecutionModelSelection,
|
|
41
|
-
toggleExecutionPanelView,
|
|
42
40
|
updateStatusWidget,
|
|
43
41
|
} from "../src/exec.ts";
|
|
44
42
|
import type { CheckItem } from "../src/plan.ts";
|
|
45
|
-
import { initState,
|
|
43
|
+
import { initState, setRunStatus, startRun } from "../src/state.ts";
|
|
46
44
|
|
|
47
45
|
interface Recorded {
|
|
48
46
|
entries: { type: string; customType?: string; data?: unknown }[];
|
|
@@ -50,8 +48,6 @@ interface Recorded {
|
|
|
50
48
|
status: string | undefined;
|
|
51
49
|
statusCalls: number;
|
|
52
50
|
colors: string[];
|
|
53
|
-
widget?: { key: string; options?: unknown; factory: any };
|
|
54
|
-
widgetCalls: number;
|
|
55
51
|
models: { provider: string; id: string }[];
|
|
56
52
|
thinkingLevels: (string | null)[];
|
|
57
53
|
notifies: { message: string; severity: string }[];
|
|
@@ -76,7 +72,6 @@ function makeHarness(workdir: string): Harness {
|
|
|
76
72
|
status: undefined,
|
|
77
73
|
statusCalls: 0,
|
|
78
74
|
colors: [],
|
|
79
|
-
widgetCalls: 0,
|
|
80
75
|
models: [],
|
|
81
76
|
thinkingLevels: [],
|
|
82
77
|
notifies: [],
|
|
@@ -129,14 +124,6 @@ function makeHarness(workdir: string): Harness {
|
|
|
129
124
|
recorded.statusCalls += 1;
|
|
130
125
|
recorded.status = value;
|
|
131
126
|
},
|
|
132
|
-
setWidget: (key: string, factory: any, options?: unknown) => {
|
|
133
|
-
recorded.widgetCalls += 1;
|
|
134
|
-
if (factory === undefined) {
|
|
135
|
-
recorded.widget = undefined;
|
|
136
|
-
return;
|
|
137
|
-
}
|
|
138
|
-
recorded.widget = { key, options, factory };
|
|
139
|
-
},
|
|
140
127
|
notify: (message: string, severity: string) => {
|
|
141
128
|
recorded.notifies.push({ message, severity });
|
|
142
129
|
},
|
|
@@ -214,36 +201,20 @@ describe("execution loop", () => {
|
|
|
214
201
|
const { pi, ctx, recorded } = makeHarness(workdir);
|
|
215
202
|
await startExecution(pi, ctx, path.join(workdir, "PLAN_v1.md"), items("VC-001", "VC-002"));
|
|
216
203
|
|
|
217
|
-
// Collapsed by default: progress lives in the bottom status bar (same
|
|
218
|
-
// layer as the ⛔/⌛ indicators); no panel widget is registered yet.
|
|
219
|
-
assert.equal(recorded.widget, undefined);
|
|
220
204
|
assert.match(recorded.status ?? "", /⌛ plans 0\/2: spent \d{2}:\d{2}:\d{2}/);
|
|
221
205
|
assert.match(recorded.status ?? "", /in-toks/);
|
|
222
206
|
assert.match(recorded.status ?? "", /out-toks/);
|
|
223
207
|
|
|
224
|
-
toggleExecutionPanelView(pi, ctx);
|
|
225
|
-
assert.ok(recorded.widget);
|
|
226
|
-
assert.equal(recorded.widget?.key, "pi-plans-execution");
|
|
227
|
-
assert.deepEqual(recorded.widget?.options, { placement: "belowEditor" });
|
|
228
|
-
// Expanded: footer ⌛ line is cleared — the panel renders its own header.
|
|
229
|
-
assert.equal(recorded.status, undefined);
|
|
230
|
-
const expandedWidget = recorded.widget?.factory(
|
|
231
|
-
{} as any,
|
|
232
|
-
{ fg: (_color: string, text: string) => text, strikethrough: (text: string) => `~~${text}~~` },
|
|
233
|
-
);
|
|
234
|
-
assert.ok(expandedWidget);
|
|
235
|
-
const expandedLines = expandedWidget.render(80);
|
|
236
|
-
// Header line first, then the legacy item list.
|
|
237
|
-
assert.match(expandedLines[0] ?? "", /⌛ plans 0\/2: spent/);
|
|
238
|
-
assert.match(expandedLines.join("\n"), /☐/);
|
|
239
|
-
// Detail view never repeats the keyboard hint.
|
|
240
|
-
assert.doesNotMatch(expandedLines.join("\n"), /alt\+o/);
|
|
241
|
-
|
|
242
208
|
assert.ok(getExecution());
|
|
243
209
|
const rules = executionContextMessage()!;
|
|
244
210
|
assert.match(rules, /PI-PLANS EXECUTION/);
|
|
245
211
|
assert.match(rules, /VC-001/);
|
|
246
|
-
|
|
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).
|
|
247
218
|
assert.match(rules, /for the long term/);
|
|
248
219
|
assert.match(rules, /Simplest implementation/);
|
|
249
220
|
assert.match(rules, /grow the change in layers/);
|
|
@@ -270,17 +241,6 @@ describe("execution loop", () => {
|
|
|
270
241
|
planPath: path.join(workdir, "PLAN_v1.md"),
|
|
271
242
|
items: items("VC-001", "VC-002"),
|
|
272
243
|
startedAt: "2026-08-25T00:00:00Z",
|
|
273
|
-
panel: {
|
|
274
|
-
expanded: true,
|
|
275
|
-
baseline: { added: 0, removed: 0, files: 0 },
|
|
276
|
-
lastSnapshot: { added: 1, removed: 0, files: 1 },
|
|
277
|
-
touchedPaths: ["src/exec.ts"],
|
|
278
|
-
itemSummaries: {
|
|
279
|
-
"VC-001": {
|
|
280
|
-
summary: { added: 1, removed: 0, files: 1, paths: ["src/exec.ts"] },
|
|
281
|
-
},
|
|
282
|
-
},
|
|
283
|
-
},
|
|
284
244
|
compaction: {
|
|
285
245
|
inFlight: true,
|
|
286
246
|
resumeGuard: true,
|
|
@@ -302,15 +262,6 @@ describe("execution loop", () => {
|
|
|
302
262
|
const execution = getExecution();
|
|
303
263
|
assert.ok(execution);
|
|
304
264
|
assert.equal("compaction" in execution!, false, "legacy scheduler state must not reactivate on restore");
|
|
305
|
-
assert.ok(recorded.widget);
|
|
306
|
-
const widget = recorded.widget?.factory(
|
|
307
|
-
{} as any,
|
|
308
|
-
{ fg: (_color: string, text: string) => text, strikethrough: (text: string) => `~~${text}~~` },
|
|
309
|
-
);
|
|
310
|
-
assert.ok(widget);
|
|
311
|
-
const rendered = widget.render(80);
|
|
312
|
-
assert.match(rendered.join("\n"), /☑/);
|
|
313
|
-
assert.match(rendered.join("\n"), /\+1/);
|
|
314
265
|
|
|
315
266
|
const clearedEntries = [...entries, { type: "custom", customType: "pi-plans-exec-cleared", data: {} }];
|
|
316
267
|
await restoreFromSession(pi, ctx, clearedEntries as any);
|
|
@@ -343,99 +294,21 @@ describe("execution loop", () => {
|
|
|
343
294
|
assert.equal(getExecution(), null);
|
|
344
295
|
});
|
|
345
296
|
|
|
346
|
-
it("
|
|
297
|
+
it("starts execution without switching models", async () => {
|
|
347
298
|
const workdir = freshWorkdir();
|
|
348
299
|
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
|
-
});
|
|
300
|
+
await startExecution(pi, ctx, path.join(workdir, "PLAN_v6.md"), items("VC-001"));
|
|
353
301
|
|
|
354
|
-
assert.deepEqual(recorded.models, [
|
|
355
|
-
assert.equal(recorded.thinking, "
|
|
302
|
+
assert.deepEqual(recorded.models, []);
|
|
303
|
+
assert.equal(recorded.thinking, "high");
|
|
356
304
|
assert.match(recorded.status ?? "", /⌛ plans 0\/1/);
|
|
357
305
|
|
|
358
306
|
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
307
|
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
308
|
assert.equal(recorded.thinking, "high");
|
|
391
309
|
});
|
|
392
310
|
|
|
393
|
-
it("
|
|
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 () => {
|
|
311
|
+
it("keeps the execution status bar current while executing", async () => {
|
|
439
312
|
const workdir = freshWorkdir();
|
|
440
313
|
const { pi, ctx, recorded } = makeHarness(workdir);
|
|
441
314
|
await startExecution(pi, ctx, path.join(workdir, "PLAN_v2.md"), items("VC-001", "VC-002"));
|
|
@@ -519,32 +392,6 @@ describe("execution loop", () => {
|
|
|
519
392
|
assert.equal(getExecution(), null);
|
|
520
393
|
});
|
|
521
394
|
|
|
522
|
-
it("defers persistence and widget churn when toggling mid-turn", async () => {
|
|
523
|
-
const workdir = freshWorkdir();
|
|
524
|
-
const { pi, ctx, recorded } = makeHarness(workdir);
|
|
525
|
-
await startExecution(pi, ctx, path.join(workdir, "PLAN_v3.md"), items("VC-001", "VC-002"));
|
|
526
|
-
|
|
527
|
-
const entriesBefore = recorded.entries.length;
|
|
528
|
-
const factoriesBefore = recorded.widgetCalls;
|
|
529
|
-
|
|
530
|
-
// Mid-turn: flip must be pure memory — no session writes, no re-register.
|
|
531
|
-
(ctx as any).isIdle = () => false;
|
|
532
|
-
assert.equal(toggleExecutionPanelView(pi, ctx), true);
|
|
533
|
-
assert.equal(recorded.entries.length, entriesBefore, "busy toggle appended a session entry");
|
|
534
|
-
assert.equal(recorded.widgetCalls, factoriesBefore, "busy toggle re-registered the widget");
|
|
535
|
-
assert.equal(consumePendingPanelSync(), true, "expected a pending panel sync marker");
|
|
536
|
-
assert.equal(consumePendingPanelSync(), false, "marker should be consumed exactly once");
|
|
537
|
-
|
|
538
|
-
// Back to idle: the next toggle persists and syncs without touching the
|
|
539
|
-
// widget slot because the panel is collapsed again.
|
|
540
|
-
ctx.isIdle = () => true;
|
|
541
|
-
assert.equal(toggleExecutionPanelView(pi, ctx), false);
|
|
542
|
-
assert.ok(recorded.entries.length > entriesBefore, "idle toggle did not persist");
|
|
543
|
-
assert.equal(recorded.widgetCalls, factoriesBefore, "collapsed toggle churned the widget registration");
|
|
544
|
-
|
|
545
|
-
await stopExecution(pi, ctx, "test-done");
|
|
546
|
-
});
|
|
547
|
-
|
|
548
395
|
it("lets Pi core own execution compaction scheduling and customizes every reason", async () => {
|
|
549
396
|
const workdir = freshWorkdir();
|
|
550
397
|
const { pi, ctx, setUsagePercent } = makeHarness(workdir);
|
|
@@ -599,15 +446,73 @@ describe("execution loop", () => {
|
|
|
599
446
|
await stopExecution(pi, ctx, "test-done");
|
|
600
447
|
});
|
|
601
448
|
|
|
602
|
-
it("
|
|
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", () => {
|
|
603
503
|
const indexSource = fs.readFileSync(path.join(process.cwd(), "index.ts"), "utf8");
|
|
604
504
|
const execSource = fs.readFileSync(path.join(process.cwd(), "src/exec.ts"), "utf8");
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
assert.match(
|
|
608
|
-
assert.
|
|
609
|
-
|
|
610
|
-
|
|
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
|
+
);
|
|
611
516
|
});
|
|
612
517
|
|
|
613
518
|
it("queues one non-retry resume, skips overflow retry, and keeps execution after failure", async () => {
|
|
@@ -708,7 +613,48 @@ describe("execution loop", () => {
|
|
|
708
613
|
await stopExecution(pi, ctx, "test-done");
|
|
709
614
|
});
|
|
710
615
|
|
|
711
|
-
it("
|
|
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", () => {
|
|
712
658
|
const messages = [
|
|
713
659
|
{ customType: "user", content: "real prompt" },
|
|
714
660
|
{ customType: "pi-plans-exec-resume", content: "Continue execution." },
|
|
@@ -910,63 +856,10 @@ describe("execution loop", () => {
|
|
|
910
856
|
assert.equal(lastComplete.items?.[0]?.done, true, "completion snapshot missing final done state");
|
|
911
857
|
});
|
|
912
858
|
|
|
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
859
|
it("updates the status bar in real time on every turn without session writes", async () => {
|
|
967
860
|
const workdir = freshWorkdir();
|
|
968
861
|
const { pi, ctx, recorded, setUsagePercent } = makeHarness(workdir);
|
|
969
|
-
await startExecution(pi, ctx, path.join(workdir, "PLAN_v15.md"), items("VC-001", "VC-002"),
|
|
862
|
+
await startExecution(pi, ctx, path.join(workdir, "PLAN_v15.md"), items("VC-001", "VC-002"), [
|
|
970
863
|
{ id: "I-001", text: "First item." },
|
|
971
864
|
{ id: "I-002", text: "Second item." },
|
|
972
865
|
]);
|
|
@@ -1003,19 +896,6 @@ describe("execution loop", () => {
|
|
|
1003
896
|
});
|
|
1004
897
|
assert.match(recorded.status ?? "", /⌛ plans 1\/3: spent/);
|
|
1005
898
|
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
899
|
|
|
1020
900
|
await emit("message_end", {
|
|
1021
901
|
message: { role: "assistant", usage: { input: 8, output: 3 } },
|
|
@@ -1023,17 +903,15 @@ describe("execution loop", () => {
|
|
|
1023
903
|
await emit("turn_end", {
|
|
1024
904
|
message: { role: "assistant", content: [{ type: "text", text: "verified [DONE:VC-002]" }] },
|
|
1025
905
|
});
|
|
1026
|
-
assert.
|
|
1027
|
-
assert.
|
|
1028
|
-
assert.equal(renderRequests.count, 1, "expanded progress must request a TUI render");
|
|
1029
|
-
assert.match(widget.render(100)[0] ?? "", /⌛ plans 2\/3: spent/);
|
|
906
|
+
assert.match(recorded.status ?? "", /⌛ plans 2\/3: spent/);
|
|
907
|
+
assert.match(recorded.status ?? "", /20 in-toks/);
|
|
1030
908
|
|
|
1031
909
|
await stopExecution(pi, ctx, "event-chain-test");
|
|
1032
910
|
});
|
|
1033
911
|
it("applies impl markers with silent unknown ids and later-overwrite semantics", async () => {
|
|
1034
912
|
const workdir = freshWorkdir();
|
|
1035
913
|
const { pi, ctx } = makeHarness(workdir);
|
|
1036
|
-
await startExecution(pi, ctx, path.join(workdir, "PLAN_v16.md"), items("VC-001"),
|
|
914
|
+
await startExecution(pi, ctx, path.join(workdir, "PLAN_v16.md"), items("VC-001"), [
|
|
1037
915
|
{ id: "I-001", text: "First item." },
|
|
1038
916
|
]);
|
|
1039
917
|
|
|
@@ -1045,6 +923,27 @@ describe("execution loop", () => {
|
|
|
1045
923
|
await stopExecution(pi, ctx, "done");
|
|
1046
924
|
});
|
|
1047
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
|
+
});
|
|
1048
947
|
it("replays impl markers from post-snapshot messages on restore", async () => {
|
|
1049
948
|
const workdir = freshWorkdir();
|
|
1050
949
|
const { pi, ctx } = makeHarness(workdir);
|
|
@@ -1064,15 +963,21 @@ describe("execution loop", () => {
|
|
|
1064
963
|
assert.equal(getExecution()?.implStatus?.["I-001"], "implemented");
|
|
1065
964
|
});
|
|
1066
965
|
|
|
966
|
+
|
|
1067
967
|
it("keeps the injection rules teaching the impl markers", async () => {
|
|
1068
968
|
const workdir = freshWorkdir();
|
|
1069
969
|
const { pi, ctx } = makeHarness(workdir);
|
|
1070
|
-
await startExecution(pi, ctx, path.join(workdir, "PLAN_v18.md"), items("VC-001"),
|
|
970
|
+
await startExecution(pi, ctx, path.join(workdir, "PLAN_v18.md"), items("VC-001"), [
|
|
1071
971
|
{ id: "I-001", text: "First item." },
|
|
1072
972
|
]);
|
|
1073
973
|
const rules = executionContextMessage()!;
|
|
1074
974
|
assert.match(rules, /\[I-001:implemented\]/);
|
|
1075
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/);
|
|
1076
981
|
await stopExecution(pi, ctx, "done");
|
|
1077
982
|
});
|
|
1078
983
|
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import * as assert from "node:assert/strict";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { after, before, describe, it } from "node:test";
|
|
6
|
+
|
|
7
|
+
import { getExecution, startExecution, stopExecution } from "../src/exec.ts";
|
|
8
|
+
|
|
9
|
+
let tmpRoot: string;
|
|
10
|
+
|
|
11
|
+
before(() => {
|
|
12
|
+
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-execute-test-"));
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
after(() => {
|
|
16
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
function mkWorkdir(name: string): string {
|
|
20
|
+
const dir = path.join(tmpRoot, name);
|
|
21
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
22
|
+
return dir;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("execute handoff", () => {
|
|
26
|
+
it("starts execution without switching models", async () => {
|
|
27
|
+
const workdir = mkWorkdir("handoff");
|
|
28
|
+
const recorded = {
|
|
29
|
+
messages: [] as string[],
|
|
30
|
+
entries: [] as Array<{ customType: string; data: unknown }>,
|
|
31
|
+
setModelCalls: 0,
|
|
32
|
+
};
|
|
33
|
+
const pi = {
|
|
34
|
+
appendEntry: (customType: string, data: unknown) => {
|
|
35
|
+
recorded.entries.push({ customType, data });
|
|
36
|
+
},
|
|
37
|
+
sendMessage: (message: { customType: string; content: string }) => {
|
|
38
|
+
recorded.messages.push(message.content);
|
|
39
|
+
},
|
|
40
|
+
setModel: async () => {
|
|
41
|
+
recorded.setModelCalls += 1;
|
|
42
|
+
return true;
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
const ctx = {
|
|
46
|
+
cwd: workdir,
|
|
47
|
+
ui: {
|
|
48
|
+
setStatus: () => {},
|
|
49
|
+
theme: {
|
|
50
|
+
fg: (_kind: string, text: string) => text,
|
|
51
|
+
bold: (text: string) => text,
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
} as any;
|
|
55
|
+
|
|
56
|
+
await startExecution(pi as any, ctx, path.join(workdir, "PLAN_v1.md"), [
|
|
57
|
+
{ id: "VC-001", text: "first item", done: false },
|
|
58
|
+
] as any);
|
|
59
|
+
assert.equal(recorded.setModelCalls, 0);
|
|
60
|
+
assert.ok(getExecution());
|
|
61
|
+
|
|
62
|
+
await stopExecution(pi as any, ctx, "cleanup");
|
|
63
|
+
assert.equal(getExecution(), null);
|
|
64
|
+
});
|
|
65
|
+
});
|
package/tests/plan.test.ts
CHANGED
|
@@ -5,7 +5,7 @@ import * as fs from "node:fs";
|
|
|
5
5
|
import * as os from "node:os";
|
|
6
6
|
import * as path from "node:path";
|
|
7
7
|
import { after, before, describe, it } from "node:test";
|
|
8
|
-
import { latestPlanVersion, nextPlanVersionPath, parseChecklist, parseImplItems, resolveImplStatuses, scanDoneMarkers, scanImplMarkers, shortImplDescription, extractCoverage } from "../src/plan.ts";
|
|
8
|
+
import { latestPlanVersion, nextPlanVersionPath, parseChecklist, parseImplItems, resolveImplStatuses, scanCurrentIMarkers, scanDoneMarkers, scanImplMarkers, shortImplDescription, extractCoverage, resolveCurrentI, inferCurrentI } from "../src/plan.ts";
|
|
9
9
|
|
|
10
10
|
const PLAN = `# PLAN_v1 - demo
|
|
11
11
|
|
|
@@ -148,6 +148,16 @@ describe("implementation items", () => {
|
|
|
148
148
|
assert.deepEqual(extractCoverage("no coverage clause here"), []);
|
|
149
149
|
});
|
|
150
150
|
|
|
151
|
+
it("parses and resolves current-I markers without changing progress marker states", () => {
|
|
152
|
+
const implItems = [{ id: "I-001", text: "first" }, { id: "I-002", text: "second" }];
|
|
153
|
+
assert.deepEqual(scanCurrentIMarkers("[I-001:current] [I-999:current] [I-002:current]"), [
|
|
154
|
+
{ id: "I-001" },
|
|
155
|
+
{ id: "I-999" },
|
|
156
|
+
{ id: "I-002" },
|
|
157
|
+
]);
|
|
158
|
+
assert.equal(resolveCurrentI(implItems, scanCurrentIMarkers("[I-999:current] [I-002:current]")), "I-002");
|
|
159
|
+
assert.equal(inferCurrentI(implItems, [], undefined), "I-001");
|
|
160
|
+
});
|
|
151
161
|
it("scans impl markers", () => {
|
|
152
162
|
assert.deepEqual(scanImplMarkers("[I-001:implemented] then [I-002:validating] and [I-003:done]"), [
|
|
153
163
|
{ id: "I-001", state: "implemented" },
|