pi-plans 0.2.0 → 0.3.1
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 +90 -26
- package/agents/ref-analyst.md +18 -0
- package/index.ts +121 -9
- package/package.json +16 -1
- package/references/pi-planning-workflow.md +21 -6
- package/references/state-and-config.md +52 -5
- package/scripts/validate.ts +5 -0
- package/skills/plan-with-refs/SKILL.md +3 -3
- package/src/code-graph/commands.ts +483 -0
- package/src/code-graph/discovery.ts +118 -0
- package/src/code-graph/git.ts +108 -0
- package/src/code-graph/identity.ts +59 -0
- package/src/code-graph/indexer.ts +281 -0
- package/src/code-graph/materialize.ts +166 -0
- package/src/code-graph/mode.ts +28 -0
- package/src/code-graph/mutations.ts +160 -0
- package/src/code-graph/parser.ts +51 -0
- package/src/code-graph/parsers/javascript.ts +35 -0
- package/src/code-graph/parsers/python.ts +160 -0
- package/src/code-graph/parsers/tree-sitter.ts +316 -0
- package/src/code-graph/paths.ts +85 -0
- package/src/code-graph/prompts.ts +18 -0
- package/src/code-graph/resolver.ts +69 -0
- package/src/code-graph/runtime.ts +158 -0
- package/src/code-graph/schema.ts +135 -0
- package/src/code-graph/screening.ts +82 -0
- package/src/code-graph/store.ts +278 -0
- package/src/code-graph/summary.ts +435 -0
- package/src/code-graph/types.ts +163 -0
- package/src/compaction.ts +1125 -371
- package/src/config-command.ts +361 -0
- package/src/exec.ts +508 -693
- package/src/guard.ts +14 -1
- package/src/refine-prompts.ts +109 -0
- package/src/refine-ui-helpers.ts +71 -18
- package/src/refine-ui-state.ts +88 -22
- package/src/refine-ui.ts +210 -102
- package/src/state.ts +36 -7
- package/src/subagent.ts +164 -61
- package/src/termination-prompt.ts +22 -0
- package/tests/analyze-refs.test.ts +265 -0
- package/tests/ask-choice.test.ts +264 -0
- package/tests/autocomplete.test.ts +6 -1
- package/tests/code-graph-apply-action.test.ts +173 -0
- package/tests/code-graph-apply.test.ts +185 -0
- package/tests/code-graph-commands.test.ts +211 -0
- package/tests/code-graph-db.test.ts +166 -0
- package/tests/code-graph-discovery.test.ts +38 -0
- package/tests/code-graph-git.test.ts +94 -0
- package/tests/code-graph-index.test.ts +175 -0
- package/tests/code-graph-loop.e2e.test.ts +159 -0
- package/tests/code-graph-mutations.test.ts +117 -0
- package/tests/code-graph-parser.test.ts +85 -0
- package/tests/code-graph-rollback.test.ts +100 -0
- package/tests/code-graph-summary-batching.test.ts +518 -0
- package/tests/code-graph-summary.test.ts +148 -0
- package/tests/compaction.test.ts +371 -57
- package/tests/config-command.test.ts +263 -0
- package/tests/exec.test.ts +808 -241
- package/tests/fixtures/code-graph/sample.js +36 -0
- package/tests/fixtures/code-graph/sample.py +20 -0
- package/tests/fixtures/code-graph/sample.ts +15 -0
- package/tests/graph-aware-file-tools.test.ts +411 -0
- package/tests/guard.test.ts +27 -1
- package/tests/plans.test.ts +10 -0
- package/tests/refine-prompts.test.ts +101 -2
- package/tests/refine-ui.test.ts +371 -72
- package/tests/state.test.ts +32 -0
- package/tests/subagent.test.ts +48 -20
- package/tools/analyze-refs.ts +263 -0
- package/tools/ask-choice.ts +159 -11
- package/tools/code-graph.ts +277 -0
- package/tools/graph-aware-file-tools.ts +392 -0
- package/tools/plans.ts +97 -2
- package/tools/refine.ts +61 -15
package/tests/exec.test.ts
CHANGED
|
@@ -6,6 +6,7 @@ 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
8
|
import {
|
|
9
|
+
AMELIORATION_PROMPT_TEXT,
|
|
9
10
|
applyDoneMarkers,
|
|
10
11
|
applyImplMarkers,
|
|
11
12
|
applyCurrentIMarker,
|
|
@@ -19,10 +20,14 @@ import {
|
|
|
19
20
|
filterExecutionResumeMessages,
|
|
20
21
|
filterPlanningResumeMessages,
|
|
21
22
|
getExecution,
|
|
23
|
+
resumeGoalWaitIfPaused,
|
|
22
24
|
handleExecutionBeforeCompact,
|
|
23
25
|
handleExecutionCompact,
|
|
24
26
|
handleExecutionTurnCompaction,
|
|
25
27
|
handleExecutionCompactFailed,
|
|
28
|
+
compactionInFlight,
|
|
29
|
+
noteCompactionStarted,
|
|
30
|
+
noteCompactionEnded,
|
|
26
31
|
handlePlanningBeforeCompact,
|
|
27
32
|
handlePlanningCompact,
|
|
28
33
|
handlePlanningCompactFailed,
|
|
@@ -32,6 +37,7 @@ import {
|
|
|
32
37
|
refreshPlanningCompactionCooldown,
|
|
33
38
|
requestPlanningCompaction,
|
|
34
39
|
restoreFromSession,
|
|
40
|
+
resetGoalWaitTurnFlags,
|
|
35
41
|
shouldTriggerPlanningCompaction,
|
|
36
42
|
startExecution,
|
|
37
43
|
recordExecutionTurn,
|
|
@@ -40,7 +46,7 @@ import {
|
|
|
40
46
|
updateStatusWidget,
|
|
41
47
|
} from "../src/exec.ts";
|
|
42
48
|
import type { CheckItem } from "../src/plan.ts";
|
|
43
|
-
import { initState, setRunStatus, startRun } from "../src/state.ts";
|
|
49
|
+
import { initState, setGraphEnabled, setRunStatus, startRun } from "../src/state.ts";
|
|
44
50
|
|
|
45
51
|
interface Recorded {
|
|
46
52
|
entries: { type: string; customType?: string; data?: unknown }[];
|
|
@@ -55,6 +61,8 @@ interface Recorded {
|
|
|
55
61
|
selectAnswer?: string;
|
|
56
62
|
current: { provider: string; id: string } | null;
|
|
57
63
|
thinking: string | null;
|
|
64
|
+
userMessages: string[];
|
|
65
|
+
userMessageOptions: Array<Record<string, unknown> | null>;
|
|
58
66
|
compacts?: { customInstructions?: string }[];
|
|
59
67
|
}
|
|
60
68
|
|
|
@@ -78,6 +86,8 @@ function makeHarness(workdir: string): Harness {
|
|
|
78
86
|
selects: [],
|
|
79
87
|
current: { provider: "p", id: "m" },
|
|
80
88
|
thinking: "high",
|
|
89
|
+
userMessages: [],
|
|
90
|
+
userMessageOptions: [],
|
|
81
91
|
};
|
|
82
92
|
let contextPercent: number | null = 0;
|
|
83
93
|
const registryModels = [
|
|
@@ -108,7 +118,10 @@ function makeHarness(workdir: string): Harness {
|
|
|
108
118
|
sendMessage: (message: { customType: string; content: string }, options?: { triggerTurn?: boolean }) => {
|
|
109
119
|
recorded.messages.push({ ...message, options });
|
|
110
120
|
},
|
|
111
|
-
sendUserMessage: async () => {
|
|
121
|
+
sendUserMessage: async (content: string, options?: Record<string, unknown>) => {
|
|
122
|
+
recorded.userMessages.push(content);
|
|
123
|
+
recorded.userMessageOptions.push(options ?? null);
|
|
124
|
+
},
|
|
112
125
|
setModel: async (model: { provider: string; id: string }) => {
|
|
113
126
|
recorded.models.push({ provider: model.provider, id: model.id });
|
|
114
127
|
recorded.current = { provider: model.provider, id: model.id };
|
|
@@ -177,6 +190,22 @@ function items(...ids: string[]): CheckItem[] {
|
|
|
177
190
|
return ids.map((id) => ({ id, text: `\`${id}\` demo item`, done: false }));
|
|
178
191
|
}
|
|
179
192
|
|
|
193
|
+
function compactableBranchEntries(): any[] {
|
|
194
|
+
return [
|
|
195
|
+
{ id: "u-1", type: "message", message: { role: "user", content: [{ type: "text", text: "start the work" }] } },
|
|
196
|
+
{ id: "a-1", type: "message", message: { role: "assistant", content: [{ type: "text", text: "made progress" }] } },
|
|
197
|
+
{ id: "u-2", type: "message", message: { role: "user", content: [{ type: "text", text: "continue from here" }] } },
|
|
198
|
+
{ id: "a-2", type: "message", message: { role: "assistant", content: [{ type: "text", text: "tail work" }] } },
|
|
199
|
+
];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function startActiveRun(workdir: string, status: "planning" | "executing" = "planning") {
|
|
203
|
+
initState(workdir);
|
|
204
|
+
const { run } = startRun(workdir, { topic: "compact", skill: "plan-normal", requestText: "x" });
|
|
205
|
+
if (status !== "planning") setRunStatus(workdir, run.run_id, status);
|
|
206
|
+
return run;
|
|
207
|
+
}
|
|
208
|
+
|
|
180
209
|
describe("execution loop", () => {
|
|
181
210
|
let tmpRoot: string;
|
|
182
211
|
let counter = 0;
|
|
@@ -206,7 +235,7 @@ describe("execution loop", () => {
|
|
|
206
235
|
assert.match(recorded.status ?? "", /out-toks/);
|
|
207
236
|
|
|
208
237
|
assert.ok(getExecution());
|
|
209
|
-
const rules = executionContextMessage()!;
|
|
238
|
+
const rules = executionContextMessage(ctx)!;
|
|
210
239
|
assert.match(rules, /PI-PLANS EXECUTION/);
|
|
211
240
|
assert.match(rules, /VC-001/);
|
|
212
241
|
assert.match(rules, /subprocess-backed verification/);
|
|
@@ -234,6 +263,87 @@ describe("execution loop", () => {
|
|
|
234
263
|
assert.ok(recorded.messages.some((message) => message.customType === "pi-plans-complete"));
|
|
235
264
|
});
|
|
236
265
|
|
|
266
|
+
it("completion enters goal-running continuation and triggers a turn in interactive sessions", async () => {
|
|
267
|
+
const workdir = freshWorkdir();
|
|
268
|
+
const { pi, ctx, recorded } = makeHarness(workdir);
|
|
269
|
+
const planPath = path.join(workdir, "PLAN_v1.md");
|
|
270
|
+
await startExecution(pi, ctx, planPath, items("VC-001", "VC-002"));
|
|
271
|
+
assert.deepEqual(applyDoneMarkers("[DONE:VC-001] [DONE:VC-002]"), ["VC-001", "VC-002"]);
|
|
272
|
+
|
|
273
|
+
await completeExecution(pi, ctx);
|
|
274
|
+
|
|
275
|
+
const completeMessage = recorded.messages.find((message) => message.customType === "pi-plans-complete");
|
|
276
|
+
assert.ok(completeMessage);
|
|
277
|
+
assert.match(completeMessage.content, /Goal-running continuation/);
|
|
278
|
+
assert.match(completeMessage.content, /How should the implementation-review loop terminate\?/);
|
|
279
|
+
assert.match(completeMessage.content, /goal wait: continue until no unpassed VCs remain/);
|
|
280
|
+
assert.doesNotMatch(completeMessage.content, /Run a post-execution amelioration round/);
|
|
281
|
+
assert.equal(completeMessage.options?.triggerTurn, true);
|
|
282
|
+
const ameliorateEntry = recorded.entries.find((entry) => entry.customType === "pi-plans-ameliorate");
|
|
283
|
+
assert.ok(ameliorateEntry);
|
|
284
|
+
const data = ameliorateEntry.data as Record<string, unknown>;
|
|
285
|
+
assert.equal(data.phase, "goal-started");
|
|
286
|
+
assert.equal(data.rounds, null);
|
|
287
|
+
assert.equal(data.currentRound, 0);
|
|
288
|
+
assert.equal(data.planPath, planPath);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("completion stays silent in headless sessions", async () => {
|
|
292
|
+
const workdir = freshWorkdir();
|
|
293
|
+
const { pi, ctx, recorded } = makeHarness(workdir);
|
|
294
|
+
await startExecution(pi, ctx, path.join(workdir, "PLAN_v1.md"), items("VC-001"));
|
|
295
|
+
assert.deepEqual(applyDoneMarkers("[DONE:VC-001]"), ["VC-001"]);
|
|
296
|
+
(ctx as any).hasUI = false;
|
|
297
|
+
|
|
298
|
+
await completeExecution(pi, ctx);
|
|
299
|
+
|
|
300
|
+
const completeMessage = recorded.messages.find((message) => message.customType === "pi-plans-complete");
|
|
301
|
+
assert.ok(completeMessage);
|
|
302
|
+
assert.doesNotMatch(completeMessage.content, /Goal-running continuation/);
|
|
303
|
+
assert.equal(completeMessage.options?.triggerTurn, false);
|
|
304
|
+
assert.equal(
|
|
305
|
+
recorded.entries.some((entry) => entry.customType === "pi-plans-ameliorate"),
|
|
306
|
+
false,
|
|
307
|
+
"headless completion must not append the ameliorate entry",
|
|
308
|
+
);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("restoreFromSession completion triggers the same goal-running continuation in interactive sessions", async () => {
|
|
312
|
+
const workdir = freshWorkdir();
|
|
313
|
+
const { pi, ctx, recorded } = makeHarness(workdir);
|
|
314
|
+
const planPath = path.join(workdir, "PLAN_v1.md");
|
|
315
|
+
fs.writeFileSync(planPath, "# plan");
|
|
316
|
+
const snapshot = {
|
|
317
|
+
planPath,
|
|
318
|
+
items: items("VC-001", "VC-002"),
|
|
319
|
+
startedAt: "2026-08-25T00:00:00Z",
|
|
320
|
+
usage: { inToks: 0, outToks: 0 },
|
|
321
|
+
implItems: [],
|
|
322
|
+
implStatus: {},
|
|
323
|
+
compaction: {
|
|
324
|
+
inFlight: true,
|
|
325
|
+
resumeGuard: true,
|
|
326
|
+
cooldownActive: true,
|
|
327
|
+
lastAttemptReason: "threshold",
|
|
328
|
+
lastSuccessfulUsagePercent: 100,
|
|
329
|
+
lastSuccessfulAt: "2026-08-25T00:01:00Z",
|
|
330
|
+
},
|
|
331
|
+
};
|
|
332
|
+
const entries = [
|
|
333
|
+
{ type: "custom", customType: "pi-plans-exec", data: snapshot },
|
|
334
|
+
{
|
|
335
|
+
type: "message",
|
|
336
|
+
message: { role: "assistant", content: [{ type: "text", text: "did [DONE:VC-001] [DONE:VC-002]" }] },
|
|
337
|
+
},
|
|
338
|
+
];
|
|
339
|
+
await restoreFromSession(pi, ctx, entries as any);
|
|
340
|
+
const completeMessage = recorded.messages.find((message) => message.customType === "pi-plans-complete");
|
|
341
|
+
assert.ok(completeMessage, "restore path must fire completeExecution");
|
|
342
|
+
assert.match(completeMessage.content, /Goal-running continuation/);
|
|
343
|
+
assert.equal(completeMessage.options?.triggerTurn, true);
|
|
344
|
+
assert.ok(recorded.entries.some((entry) => entry.customType === "pi-plans-ameliorate"));
|
|
345
|
+
});
|
|
346
|
+
|
|
237
347
|
it("restores progress from session entries and rescans messages", async () => {
|
|
238
348
|
const workdir = freshWorkdir();
|
|
239
349
|
const { pi, ctx, recorded } = makeHarness(workdir);
|
|
@@ -392,36 +502,51 @@ describe("execution loop", () => {
|
|
|
392
502
|
assert.equal(getExecution(), null);
|
|
393
503
|
});
|
|
394
504
|
|
|
395
|
-
it("
|
|
505
|
+
it("returns control to Pi core without active execution or planning state", async () => {
|
|
396
506
|
const workdir = freshWorkdir();
|
|
397
|
-
const { pi, ctx
|
|
398
|
-
await startExecution(pi, ctx, path.join(workdir, "PLAN_v8.md"), items("VC-001", "VC-002"));
|
|
507
|
+
const { pi, ctx } = makeHarness(workdir);
|
|
399
508
|
|
|
400
|
-
|
|
401
|
-
const
|
|
509
|
+
await startExecution(pi, ctx, path.join(workdir, "PLAN_v0.md"), items("VC-001"));
|
|
510
|
+
const executionResult = handleExecutionBeforeCompact(pi, ctx, {
|
|
402
511
|
type: "session_before_compact",
|
|
403
512
|
preparation: makePreparation("threshold", null),
|
|
404
|
-
branchEntries:
|
|
513
|
+
branchEntries: compactableBranchEntries(),
|
|
405
514
|
reason: "threshold",
|
|
406
515
|
willRetry: false,
|
|
407
516
|
signal: new AbortController().signal,
|
|
408
517
|
});
|
|
409
|
-
assert.equal(
|
|
410
|
-
|
|
518
|
+
assert.equal(executionResult, undefined);
|
|
519
|
+
await stopExecution(pi, ctx, "no-active-run");
|
|
411
520
|
|
|
412
|
-
|
|
413
|
-
const above = handleExecutionBeforeCompact(pi, ctx, {
|
|
521
|
+
const planningResult = handlePlanningBeforeCompact(pi as any, ctx as any, {
|
|
414
522
|
type: "session_before_compact",
|
|
415
523
|
preparation: makePreparation("threshold", null),
|
|
416
|
-
branchEntries:
|
|
524
|
+
branchEntries: compactableBranchEntries(),
|
|
525
|
+
reason: "threshold",
|
|
526
|
+
willRetry: false,
|
|
527
|
+
signal: new AbortController().signal,
|
|
528
|
+
});
|
|
529
|
+
assert.equal(planningResult, undefined);
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
it("lets Pi core own execution compaction scheduling and customizes safe reasons", async () => {
|
|
533
|
+
const workdir = freshWorkdir();
|
|
534
|
+
startActiveRun(workdir);
|
|
535
|
+
const { pi, ctx, recorded } = makeHarness(workdir);
|
|
536
|
+
await startExecution(pi, ctx, path.join(workdir, "PLAN_v8.md"), items("VC-001", "VC-002"));
|
|
537
|
+
|
|
538
|
+
const threshold = handleExecutionBeforeCompact(pi, ctx, {
|
|
539
|
+
type: "session_before_compact",
|
|
540
|
+
preparation: makePreparation("threshold", null),
|
|
541
|
+
branchEntries: compactableBranchEntries(),
|
|
417
542
|
reason: "threshold",
|
|
418
543
|
willRetry: false,
|
|
419
544
|
signal: new AbortController().signal,
|
|
420
545
|
});
|
|
421
|
-
assert.equal(
|
|
422
|
-
assert.ok(
|
|
546
|
+
assert.equal(threshold?.cancel, undefined);
|
|
547
|
+
assert.ok(threshold?.compaction, "threshold compaction should use the VCC summary when a legal cut exists");
|
|
423
548
|
|
|
424
|
-
const
|
|
549
|
+
const overflowRetry = handleExecutionBeforeCompact(pi, ctx, {
|
|
425
550
|
type: "session_before_compact",
|
|
426
551
|
preparation: makePreparation("overflow", null),
|
|
427
552
|
branchEntries: [],
|
|
@@ -429,8 +554,7 @@ describe("execution loop", () => {
|
|
|
429
554
|
willRetry: true,
|
|
430
555
|
signal: new AbortController().signal,
|
|
431
556
|
});
|
|
432
|
-
assert.equal(
|
|
433
|
-
assert.ok(overflow?.compaction);
|
|
557
|
+
assert.equal(overflowRetry, undefined, "overflow retry falls back to Pi core when VCC has no safe cut");
|
|
434
558
|
|
|
435
559
|
const manual = handleExecutionBeforeCompact(pi, ctx, {
|
|
436
560
|
type: "session_before_compact",
|
|
@@ -440,54 +564,29 @@ describe("execution loop", () => {
|
|
|
440
564
|
willRetry: false,
|
|
441
565
|
signal: new AbortController().signal,
|
|
442
566
|
});
|
|
443
|
-
assert.equal(manual?.cancel,
|
|
444
|
-
assert.ok(
|
|
567
|
+
assert.equal(manual?.cancel, true, "manual compaction cancels instead of discarding unsafe context");
|
|
568
|
+
assert.ok(recorded.notifies.some((note) => note.message.includes("Nothing to compact")));
|
|
445
569
|
|
|
446
570
|
await stopExecution(pi, ctx, "test-done");
|
|
447
571
|
});
|
|
448
572
|
|
|
449
|
-
it("
|
|
573
|
+
it("does not proactively request execution compaction", async () => {
|
|
450
574
|
const workdir = freshWorkdir();
|
|
575
|
+
startActiveRun(workdir);
|
|
451
576
|
const { pi, ctx, recorded, setUsagePercent } = makeHarness(workdir);
|
|
452
577
|
await startExecution(pi, ctx, path.join(workdir, "PLAN_v9.md"), items("VC-001", "VC-002"));
|
|
453
578
|
|
|
454
|
-
setUsagePercent(
|
|
579
|
+
setUsagePercent(99);
|
|
455
580
|
handleExecutionTurnCompaction(ctx);
|
|
456
|
-
assert.equal(recorded.compacts?.length ?? 0, 1, "first high-watermark turn should request one compact");
|
|
457
|
-
|
|
458
581
|
handleExecutionTurnCompaction(ctx);
|
|
459
|
-
assert.equal(recorded.compacts?.length ?? 0,
|
|
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");
|
|
582
|
+
assert.equal(recorded.compacts?.length ?? 0, 0, "execution scheduling is owned by Pi core");
|
|
485
583
|
|
|
486
584
|
await stopExecution(pi, ctx, "test-done");
|
|
487
585
|
});
|
|
488
586
|
|
|
489
|
-
it("
|
|
587
|
+
it("ignores current-I growth for proactive execution compaction", async () => {
|
|
490
588
|
const workdir = freshWorkdir();
|
|
589
|
+
startActiveRun(workdir);
|
|
491
590
|
const { pi, ctx, recorded } = makeHarness(workdir);
|
|
492
591
|
await startExecution(pi, ctx, path.join(workdir, "PLAN_v22.md"), items("VC-001"), [
|
|
493
592
|
{ id: "I-001", text: "First item." },
|
|
@@ -499,52 +598,82 @@ describe("execution loop", () => {
|
|
|
499
598
|
assert.equal(recorded.compacts?.length ?? 0, 0);
|
|
500
599
|
await stopExecution(pi, ctx, "no-prefix");
|
|
501
600
|
});
|
|
502
|
-
it("wires execution compaction without restoring
|
|
601
|
+
it("wires execution compaction without restoring proactive requests or model helpers", () => {
|
|
503
602
|
const indexSource = fs.readFileSync(path.join(process.cwd(), "index.ts"), "utf8");
|
|
504
603
|
const execSource = fs.readFileSync(path.join(process.cwd(), "src/exec.ts"), "utf8");
|
|
505
604
|
assert.match(indexSource, /handleExecutionTurnCompaction/);
|
|
506
605
|
assert.match(execSource, /shouldTriggerExecutionCompaction/);
|
|
507
|
-
assert.
|
|
606
|
+
assert.doesNotMatch(execSource, /ctx\.compact\(/);
|
|
508
607
|
assert.doesNotMatch(
|
|
509
608
|
indexSource,
|
|
510
609
|
/setExecutionModel|chooseExecutionModelSelection|snapshotCurrentModelSelector|ensureExecutionModelActive|restorePlanningModel/,
|
|
511
610
|
);
|
|
512
611
|
assert.doesNotMatch(
|
|
513
612
|
execSource,
|
|
514
|
-
/setExecutionModel|chooseExecutionModelSelection|snapshotCurrentModelSelector|ensureExecutionModelActive|restorePlanningModel/,
|
|
613
|
+
/setExecutionModel|chooseExecutionModelSelection|snapshotCurrentModelSelector|ensureExecutionModelActive|restorePlanningModel|buildModelExecutionCompactionResult|modelRegistry\.complete/,
|
|
515
614
|
);
|
|
516
615
|
});
|
|
517
616
|
|
|
518
|
-
it("
|
|
617
|
+
it("auto-continues execution only for threshold/overflow and honors manual follow-up prompts", async () => {
|
|
519
618
|
const workdir = freshWorkdir();
|
|
619
|
+
startActiveRun(workdir);
|
|
520
620
|
const { pi, ctx, recorded } = makeHarness(workdir);
|
|
621
|
+
(ctx as any).piVersion = "0.84.3";
|
|
521
622
|
await startExecution(pi, ctx, path.join(workdir, "PLAN_v9.md"), items("VC-001"));
|
|
522
623
|
|
|
523
|
-
const
|
|
524
|
-
|
|
624
|
+
const threshold = handleExecutionBeforeCompact(pi, ctx, {
|
|
625
|
+
type: "session_before_compact",
|
|
626
|
+
preparation: makePreparation("threshold", null),
|
|
627
|
+
branchEntries: compactableBranchEntries(),
|
|
628
|
+
reason: "threshold",
|
|
629
|
+
willRetry: false,
|
|
630
|
+
signal: new AbortController().signal,
|
|
631
|
+
});
|
|
632
|
+
assert.ok(threshold?.compaction);
|
|
633
|
+
await handleExecutionCompact(pi, ctx, {
|
|
525
634
|
type: "session_compact",
|
|
526
635
|
compactionEntry: { type: "compaction" } as never,
|
|
527
|
-
fromExtension:
|
|
636
|
+
fromExtension: false,
|
|
528
637
|
reason: "threshold",
|
|
529
638
|
willRetry: false,
|
|
530
639
|
});
|
|
531
|
-
|
|
532
|
-
assert.
|
|
533
|
-
assert.equal(resumes[0]?.options?.triggerTurn, true);
|
|
534
|
-
assert.equal(recorded.compacts?.length ?? 0, beforeRequests, "compaction hook must not invoke ctx.compact");
|
|
640
|
+
assert.equal(recorded.messages.filter((message) => message.customType === "pi-plans-exec-resume").length, 1);
|
|
641
|
+
assert.ok(recorded.notifies.some((note) => note.message.startsWith("pi-vcc: kept")));
|
|
535
642
|
|
|
536
|
-
|
|
643
|
+
handleExecutionBeforeCompact(pi, ctx, {
|
|
644
|
+
type: "session_before_compact",
|
|
645
|
+
preparation: makePreparation("manual", null),
|
|
646
|
+
branchEntries: compactableBranchEntries(),
|
|
647
|
+
reason: "manual",
|
|
648
|
+
willRetry: false,
|
|
649
|
+
signal: new AbortController().signal,
|
|
650
|
+
});
|
|
651
|
+
await handleExecutionCompact(pi, ctx, {
|
|
537
652
|
type: "session_compact",
|
|
538
653
|
compactionEntry: { type: "compaction" } as never,
|
|
539
|
-
fromExtension:
|
|
540
|
-
reason: "
|
|
541
|
-
willRetry:
|
|
654
|
+
fromExtension: false,
|
|
655
|
+
reason: "manual",
|
|
656
|
+
willRetry: false,
|
|
542
657
|
});
|
|
543
|
-
assert.equal(
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
658
|
+
assert.equal(recorded.messages.filter((message) => message.customType === "pi-plans-exec-resume").length, 1);
|
|
659
|
+
|
|
660
|
+
handleExecutionBeforeCompact(pi, ctx, {
|
|
661
|
+
type: "session_before_compact",
|
|
662
|
+
preparation: makePreparation("manual", null),
|
|
663
|
+
branchEntries: compactableBranchEntries(),
|
|
664
|
+
customInstructions: "Run focused tests keep:1",
|
|
665
|
+
reason: "manual",
|
|
666
|
+
willRetry: false,
|
|
667
|
+
signal: new AbortController().signal,
|
|
668
|
+
});
|
|
669
|
+
await handleExecutionCompact(pi, ctx, {
|
|
670
|
+
type: "session_compact",
|
|
671
|
+
compactionEntry: { type: "compaction" } as never,
|
|
672
|
+
fromExtension: false,
|
|
673
|
+
reason: "manual",
|
|
674
|
+
willRetry: false,
|
|
675
|
+
});
|
|
676
|
+
assert.deepEqual(recorded.userMessages, ["Run focused tests"]);
|
|
548
677
|
|
|
549
678
|
handleExecutionCompactFailed(pi, ctx, {
|
|
550
679
|
type: "session_compact_failed",
|
|
@@ -560,29 +689,30 @@ describe("execution loop", () => {
|
|
|
560
689
|
await stopExecution(pi, ctx, "test-done");
|
|
561
690
|
});
|
|
562
691
|
|
|
563
|
-
it("builds a
|
|
692
|
+
it("builds a VCC execution summary with phase context and previous summary", async () => {
|
|
564
693
|
const workdir = freshWorkdir();
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
694
|
+
startActiveRun(workdir);
|
|
695
|
+
const { pi, ctx } = makeHarness(workdir);
|
|
696
|
+
await startExecution(pi, ctx, path.join(workdir, "PLAN_v10.md"), items("VC-001", "VC-002"), [
|
|
697
|
+
{ id: "I-001", text: "First item." },
|
|
698
|
+
{ id: "I-002", text: "Second item." },
|
|
699
|
+
]);
|
|
569
700
|
|
|
570
|
-
const previousSummary = "##
|
|
701
|
+
const previousSummary = "## Legacy Summary\nDeliver auto-compact in execution phase.";
|
|
702
|
+
applyCurrentIMarker("[I-002:current]");
|
|
571
703
|
const preparation = makePreparation("threshold", previousSummary);
|
|
572
704
|
const branchEntries: any[] = [
|
|
573
|
-
{ id: "exec-start", type: "custom", customType: "pi-plans-exec-start" },
|
|
574
705
|
{ 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 [
|
|
706
|
+
{ id: "a-1", type: "message", message: { role: "assistant", content: [{ type: "text", text: "wrote helper [I-002:current]" }] } },
|
|
576
707
|
{ id: "u-2", type: "message", message: { role: "user", content: [{ type: "text", text: "implement VC-002" }] } },
|
|
577
708
|
{ 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
709
|
];
|
|
580
710
|
const result = buildExecutionCompactionResult(
|
|
581
711
|
{
|
|
582
712
|
type: "session_before_compact",
|
|
583
713
|
preparation,
|
|
584
714
|
branchEntries,
|
|
585
|
-
customInstructions: "keep
|
|
715
|
+
customInstructions: "keep:1",
|
|
586
716
|
reason: "threshold",
|
|
587
717
|
willRetry: false,
|
|
588
718
|
signal: new AbortController().signal,
|
|
@@ -590,71 +720,51 @@ describe("execution loop", () => {
|
|
|
590
720
|
ctx,
|
|
591
721
|
);
|
|
592
722
|
assert.ok(result);
|
|
593
|
-
assert.
|
|
594
|
-
assert.match(result!.summary,
|
|
595
|
-
assert.match(result!.summary,
|
|
596
|
-
assert.match(result!.summary,
|
|
597
|
-
assert.match(result!.summary,
|
|
598
|
-
assert.match(result!.summary,
|
|
599
|
-
assert.match(result!.summary,
|
|
600
|
-
assert.
|
|
601
|
-
assert.
|
|
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
|
-
});
|
|
723
|
+
assert.equal(result!.firstKeptEntryId, "u-2");
|
|
724
|
+
assert.match(result!.summary, /\[Session Goal\]/);
|
|
725
|
+
assert.match(result!.summary, /Execute accepted plan/);
|
|
726
|
+
assert.match(result!.summary, /\[Outstanding Context\]/);
|
|
727
|
+
assert.match(result!.summary, /Current implementation item: I-002/);
|
|
728
|
+
assert.match(result!.summary, /Remaining verifier items: VC-001, VC-002/);
|
|
729
|
+
assert.match(result!.summary, /Previous compact summary: Legacy Summary Deliver auto-compact/);
|
|
730
|
+
assert.equal((result!.details as any).compactor, "pi-vcc");
|
|
731
|
+
assert.equal((result!.details as any).phase, "execution");
|
|
612
732
|
|
|
613
733
|
await stopExecution(pi, ctx, "test-done");
|
|
614
734
|
});
|
|
615
735
|
|
|
616
|
-
it("
|
|
736
|
+
it("does not call model helpers and respects overrideDefaultCompaction=false", async () => {
|
|
617
737
|
const workdir = freshWorkdir();
|
|
618
|
-
|
|
738
|
+
startActiveRun(workdir);
|
|
739
|
+
const { pi, ctx } = makeHarness(workdir);
|
|
619
740
|
await startExecution(pi, ctx, path.join(workdir, "PLAN_v21.md"), items("VC-001"), [
|
|
620
741
|
{ id: "I-001", text: "First item." },
|
|
621
742
|
]);
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
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;
|
|
743
|
+
let completeCalled = false;
|
|
744
|
+
(ctx.modelRegistry as any).complete = async () => {
|
|
745
|
+
completeCalled = true;
|
|
746
|
+
return {};
|
|
632
747
|
};
|
|
633
748
|
const event: any = {
|
|
634
749
|
type: "session_before_compact",
|
|
635
750
|
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
|
-
],
|
|
751
|
+
branchEntries: compactableBranchEntries(),
|
|
640
752
|
reason: "threshold",
|
|
641
753
|
willRetry: false,
|
|
642
|
-
signal,
|
|
754
|
+
signal: new AbortController().signal,
|
|
643
755
|
};
|
|
644
|
-
const valid =
|
|
756
|
+
const valid = handleExecutionBeforeCompact(pi, ctx, event);
|
|
645
757
|
assert.ok(valid?.compaction);
|
|
646
|
-
assert.equal(
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
assert.equal(
|
|
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");
|
|
758
|
+
assert.equal(completeCalled, false);
|
|
759
|
+
|
|
760
|
+
const configPath = path.join(workdir, ".git", "pi_plans", "pi-vcc-config.json");
|
|
761
|
+
fs.writeFileSync(configPath, JSON.stringify({ overrideDefaultCompaction: false }), "utf8");
|
|
762
|
+
const fallback = handleExecutionBeforeCompact(pi, ctx, { ...event, signal: new AbortController().signal });
|
|
763
|
+
assert.equal(fallback, undefined, "override-disabled should return control to Pi default compaction");
|
|
764
|
+
|
|
656
765
|
await stopExecution(pi, ctx, "model-test");
|
|
657
|
-
});
|
|
766
|
+
});
|
|
767
|
+
it("filters the hidden resume message out of the model context payload", () => {
|
|
658
768
|
const messages = [
|
|
659
769
|
{ customType: "user", content: "real prompt" },
|
|
660
770
|
{ customType: "pi-plans-exec-resume", content: "Continue execution." },
|
|
@@ -665,37 +775,27 @@ describe("execution loop", () => {
|
|
|
665
775
|
assert.equal(filterPlanningResumeMessages(messages).length, 3);
|
|
666
776
|
});
|
|
667
777
|
|
|
668
|
-
it("
|
|
778
|
+
it("builds a VCC planning summary from active-run session context", () => {
|
|
669
779
|
const workdir = freshWorkdir();
|
|
670
780
|
const { ctx } = makeHarness(workdir);
|
|
671
781
|
initState(workdir);
|
|
672
782
|
const { run } = startRun(workdir, { topic: "planning compact", skill: "plan-normal", requestText: "demo" });
|
|
673
|
-
|
|
783
|
+
const planPath = path.join(run.artifact_dir, "PLAN_v1.md");
|
|
784
|
+
fs.writeFileSync(planPath, "# plan");
|
|
674
785
|
|
|
675
|
-
const
|
|
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 = [
|
|
786
|
+
const branchEntries = [
|
|
688
787
|
{ id: "rs", type: "custom", customType: PLANNING_RUN_START_CUSTOM_TYPE, data: { runId: run.run_id, artifactDir: run.artifact_dir } },
|
|
689
788
|
{ 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
|
|
789
|
+
{ id: "a-1", type: "message", message: { role: "assistant", content: [{ type: "text", text: "some context [I-004:current]" }] } },
|
|
790
|
+
{ id: "pw", type: "custom", customType: PLANNING_PLAN_WRITTEN_CUSTOM_TYPE, data: { runId: run.run_id, planPath } },
|
|
692
791
|
{ id: "u-2", type: "message", message: { role: "user", content: [{ type: "text", text: "review please" }] } },
|
|
693
792
|
] as any;
|
|
694
793
|
const withPlan = buildPlanningCompactionResult(
|
|
695
794
|
{
|
|
696
795
|
type: "session_before_compact",
|
|
697
|
-
preparation:
|
|
698
|
-
branchEntries
|
|
796
|
+
preparation: makePreparation("threshold", "## Previous\nEarlier summary."),
|
|
797
|
+
branchEntries,
|
|
798
|
+
customInstructions: "keep:1",
|
|
699
799
|
reason: "threshold",
|
|
700
800
|
willRetry: false,
|
|
701
801
|
signal: new AbortController().signal,
|
|
@@ -704,95 +804,57 @@ describe("execution loop", () => {
|
|
|
704
804
|
);
|
|
705
805
|
assert.ok(withPlan);
|
|
706
806
|
assert.deepEqual(withPlan!.firstKeptEntryId, "u-2");
|
|
707
|
-
assert.match(withPlan!.summary,
|
|
708
|
-
assert.match(withPlan!.summary,
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
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/);
|
|
807
|
+
assert.match(withPlan!.summary, /\[Session Goal\]/);
|
|
808
|
+
assert.match(withPlan!.summary, new RegExp(run.run_id));
|
|
809
|
+
assert.match(withPlan!.summary, /\[Outstanding Context\]/);
|
|
810
|
+
assert.match(withPlan!.summary, /Latest plan path from session/);
|
|
811
|
+
assert.match(withPlan!.summary, /Planning artifact directory from session/);
|
|
812
|
+
assert.match(withPlan!.summary, /Current implementation marker observed during planning: I-004/);
|
|
813
|
+
assert.match(withPlan!.summary, /Previous compact summary: Previous Earlier summary\./);
|
|
814
|
+
assert.equal((withPlan!.details as any).compactor, "pi-vcc");
|
|
815
|
+
assert.equal((withPlan!.details as any).phase, "planning");
|
|
747
816
|
});
|
|
748
817
|
|
|
749
|
-
it("planning hook is gated by run.status=planning and defers
|
|
818
|
+
it("planning hook is gated by run.status=planning and defers while execution is running", async () => {
|
|
750
819
|
const workdir = freshWorkdir();
|
|
751
|
-
const {
|
|
820
|
+
const { pi, ctx } = makeHarness(workdir);
|
|
752
821
|
initState(workdir);
|
|
753
822
|
const { run } = startRun(workdir, { topic: "planning gate", skill: "plan-small", requestText: "x" });
|
|
754
823
|
|
|
755
|
-
|
|
756
|
-
const resultPlanning = handlePlanningBeforeCompact({} as any, ctx as any, {
|
|
824
|
+
const resultPlanning = handlePlanningBeforeCompact(pi as any, ctx as any, {
|
|
757
825
|
type: "session_before_compact",
|
|
758
|
-
preparation:
|
|
759
|
-
|
|
760
|
-
|
|
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: [],
|
|
826
|
+
preparation: makePreparation("threshold", null),
|
|
827
|
+
branchEntries: compactableBranchEntries(),
|
|
828
|
+
customInstructions: "keep:1",
|
|
769
829
|
reason: "threshold",
|
|
770
830
|
willRetry: false,
|
|
771
831
|
signal: new AbortController().signal,
|
|
772
832
|
});
|
|
773
|
-
assert.ok(resultPlanning?.compaction
|
|
833
|
+
assert.ok(resultPlanning?.compaction);
|
|
774
834
|
|
|
775
|
-
// Flip status to done; planning hook should refuse.
|
|
776
835
|
setRunStatus(workdir, run.run_id, "done");
|
|
777
|
-
|
|
778
|
-
const resultDone = handlePlanningBeforeCompact({} as any, ctx as any, {
|
|
836
|
+
const resultDone = handlePlanningBeforeCompact(pi as any, ctx as any, {
|
|
779
837
|
type: "session_before_compact",
|
|
780
|
-
preparation:
|
|
781
|
-
|
|
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: [],
|
|
838
|
+
preparation: makePreparation("threshold", null),
|
|
839
|
+
branchEntries: compactableBranchEntries(),
|
|
791
840
|
reason: "threshold",
|
|
792
841
|
willRetry: false,
|
|
793
842
|
signal: new AbortController().signal,
|
|
794
843
|
});
|
|
795
844
|
assert.equal(resultDone, undefined);
|
|
845
|
+
|
|
846
|
+
setRunStatus(workdir, run.run_id, "planning");
|
|
847
|
+
await startExecution(pi, ctx, path.join(workdir, "PLAN_v14.md"), items("VC-001"));
|
|
848
|
+
const duringExecution = handlePlanningBeforeCompact(pi as any, ctx as any, {
|
|
849
|
+
type: "session_before_compact",
|
|
850
|
+
preparation: makePreparation("threshold", null),
|
|
851
|
+
branchEntries: compactableBranchEntries(),
|
|
852
|
+
reason: "threshold",
|
|
853
|
+
willRetry: false,
|
|
854
|
+
signal: new AbortController().signal,
|
|
855
|
+
});
|
|
856
|
+
assert.equal(duringExecution, undefined);
|
|
857
|
+
await stopExecution(pi, ctx, "planning-gate");
|
|
796
858
|
});
|
|
797
859
|
|
|
798
860
|
it("turn_end writes are unconditionally deferred even when isIdle reads true", async () => {
|
|
@@ -970,7 +1032,7 @@ describe("execution loop", () => {
|
|
|
970
1032
|
await startExecution(pi, ctx, path.join(workdir, "PLAN_v18.md"), items("VC-001"), [
|
|
971
1033
|
{ id: "I-001", text: "First item." },
|
|
972
1034
|
]);
|
|
973
|
-
const rules = executionContextMessage()!;
|
|
1035
|
+
const rules = executionContextMessage(ctx)!;
|
|
974
1036
|
assert.match(rules, /\[I-001:implemented\]/);
|
|
975
1037
|
assert.match(rules, /\[I-001:validating\]/);
|
|
976
1038
|
assert.match(rules, /subprocess-backed verification/);
|
|
@@ -981,42 +1043,411 @@ describe("execution loop", () => {
|
|
|
981
1043
|
await stopExecution(pi, ctx, "done");
|
|
982
1044
|
});
|
|
983
1045
|
|
|
984
|
-
it("
|
|
1046
|
+
it("does not proactively request planning compaction", () => {
|
|
985
1047
|
const workdir = freshWorkdir();
|
|
986
|
-
const {
|
|
1048
|
+
const { ctx, setUsagePercent, recorded } = makeHarness(workdir);
|
|
987
1049
|
initState(workdir);
|
|
988
|
-
startRun(workdir, { topic: "planning
|
|
1050
|
+
startRun(workdir, { topic: "planning no-op", skill: "plan-normal", requestText: "x" });
|
|
989
1051
|
|
|
990
1052
|
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
1053
|
assert.equal(shouldTriggerPlanningCompaction(ctx as any), false);
|
|
1054
|
+
requestPlanningCompaction(ctx as any);
|
|
1055
|
+
refreshPlanningCompactionCooldown(ctx as any);
|
|
1056
|
+
assert.equal(recorded.compacts?.length ?? 0, 0, "planning scheduling is owned by Pi core");
|
|
1057
|
+
});
|
|
996
1058
|
|
|
997
|
-
|
|
998
|
-
|
|
1059
|
+
it("auto-continues planning only for threshold/overflow and honors manual follow-up prompts", async () => {
|
|
1060
|
+
const workdir = freshWorkdir();
|
|
1061
|
+
const { pi, ctx, recorded } = makeHarness(workdir);
|
|
1062
|
+
(ctx as any).piVersion = "0.84.3";
|
|
1063
|
+
initState(workdir);
|
|
1064
|
+
startRun(workdir, { topic: "planning success", skill: "plan-normal", requestText: "x" });
|
|
1065
|
+
|
|
1066
|
+
const threshold = handlePlanningBeforeCompact(pi as any, ctx as any, {
|
|
1067
|
+
type: "session_before_compact",
|
|
1068
|
+
preparation: makePreparation("threshold", null),
|
|
1069
|
+
branchEntries: compactableBranchEntries(),
|
|
1070
|
+
customInstructions: "keep:1",
|
|
1071
|
+
reason: "threshold",
|
|
1072
|
+
willRetry: false,
|
|
1073
|
+
signal: new AbortController().signal,
|
|
1074
|
+
});
|
|
1075
|
+
assert.ok(threshold?.compaction);
|
|
1076
|
+
await handlePlanningCompact(pi as any, ctx as any, {
|
|
999
1077
|
type: "session_compact",
|
|
1000
1078
|
compactionEntry: { type: "compaction" } as never,
|
|
1001
|
-
fromExtension:
|
|
1002
|
-
reason: "
|
|
1079
|
+
fromExtension: false,
|
|
1080
|
+
reason: "threshold",
|
|
1003
1081
|
willRetry: false,
|
|
1004
1082
|
});
|
|
1005
|
-
|
|
1006
|
-
assert.ok(resume);
|
|
1083
|
+
assert.equal(recorded.messages.filter((message) => message.customType === "pi-plans-plan-resume").length, 1);
|
|
1007
1084
|
assert.equal(consumePlanningCompactionResumeGuard(ctx as any), true);
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1085
|
+
|
|
1086
|
+
handlePlanningBeforeCompact(pi as any, ctx as any, {
|
|
1087
|
+
type: "session_before_compact",
|
|
1088
|
+
preparation: makePreparation("manual", null),
|
|
1089
|
+
branchEntries: compactableBranchEntries(),
|
|
1090
|
+
customInstructions: "Ask the next question keep:1",
|
|
1091
|
+
reason: "manual",
|
|
1092
|
+
willRetry: false,
|
|
1093
|
+
signal: new AbortController().signal,
|
|
1094
|
+
});
|
|
1095
|
+
await handlePlanningCompact(pi as any, ctx as any, {
|
|
1096
|
+
type: "session_compact",
|
|
1097
|
+
compactionEntry: { type: "compaction" } as never,
|
|
1098
|
+
fromExtension: false,
|
|
1099
|
+
reason: "manual",
|
|
1100
|
+
willRetry: false,
|
|
1101
|
+
});
|
|
1102
|
+
assert.deepEqual(recorded.userMessages, ["Ask the next question"]);
|
|
1103
|
+
assert.equal(recorded.messages.filter((message) => message.customType === "pi-plans-plan-resume").length, 1);
|
|
1104
|
+
});
|
|
1105
|
+
|
|
1106
|
+
it("planning request helper remains a no-op regardless of idleness", () => {
|
|
1107
|
+
const workdir = freshWorkdir();
|
|
1108
|
+
const { ctx, recorded, setUsagePercent } = makeHarness(workdir);
|
|
1109
|
+
initState(workdir);
|
|
1110
|
+
startRun(workdir, { topic: "idle no-op", skill: "plan-normal", requestText: "x" });
|
|
1013
1111
|
setUsagePercent(120);
|
|
1014
|
-
|
|
1112
|
+
|
|
1113
|
+
(ctx as any).isIdle = () => false;
|
|
1114
|
+
requestPlanningCompaction(ctx as any);
|
|
1115
|
+
(ctx as any).isIdle = () => true;
|
|
1116
|
+
(ctx as any).hasPendingMessages = () => false;
|
|
1117
|
+
requestPlanningCompaction(ctx as any);
|
|
1118
|
+
assert.equal(recorded.compacts?.length ?? 0, 0);
|
|
1119
|
+
assert.equal((ctx as any).sessionManager.__planningCompaction, undefined);
|
|
1120
|
+
});
|
|
1121
|
+
|
|
1122
|
+
it("planning compact failures notify but do not re-request proactively", () => {
|
|
1123
|
+
const workdir = freshWorkdir();
|
|
1124
|
+
const { pi, ctx, recorded } = makeHarness(workdir);
|
|
1125
|
+
initState(workdir);
|
|
1126
|
+
startRun(workdir, { topic: "failure notify", skill: "plan-normal", requestText: "x" });
|
|
1127
|
+
handlePlanningBeforeCompact(pi as any, ctx as any, {
|
|
1128
|
+
type: "session_before_compact",
|
|
1129
|
+
preparation: makePreparation("threshold", null),
|
|
1130
|
+
branchEntries: compactableBranchEntries(),
|
|
1131
|
+
customInstructions: "keep:1",
|
|
1132
|
+
reason: "threshold",
|
|
1133
|
+
willRetry: false,
|
|
1134
|
+
signal: new AbortController().signal,
|
|
1135
|
+
});
|
|
1136
|
+
|
|
1137
|
+
handlePlanningCompactFailed(pi as any, ctx as any, {
|
|
1138
|
+
type: "session_compact_failed",
|
|
1139
|
+
reason: "manual",
|
|
1140
|
+
errorMessage: "Compaction failed: Nothing to compact (session too small)",
|
|
1141
|
+
aborted: false,
|
|
1142
|
+
willRetry: false,
|
|
1143
|
+
fromExtension: false,
|
|
1144
|
+
});
|
|
1145
|
+
assert.ok(recorded.notifies.some((note) => note.message.includes("nothing to summarize")));
|
|
1146
|
+
requestPlanningCompaction(ctx as any);
|
|
1147
|
+
assert.equal(recorded.compacts?.length ?? 0, 0);
|
|
1148
|
+
|
|
1149
|
+
handlePlanningBeforeCompact(pi as any, ctx as any, {
|
|
1150
|
+
type: "session_before_compact",
|
|
1151
|
+
preparation: makePreparation("threshold", null),
|
|
1152
|
+
branchEntries: compactableBranchEntries(),
|
|
1153
|
+
customInstructions: "keep:1",
|
|
1154
|
+
reason: "threshold",
|
|
1155
|
+
willRetry: false,
|
|
1156
|
+
signal: new AbortController().signal,
|
|
1157
|
+
});
|
|
1158
|
+
handlePlanningCompactFailed(pi as any, ctx as any, {
|
|
1159
|
+
type: "session_compact_failed",
|
|
1160
|
+
reason: "manual",
|
|
1161
|
+
errorMessage: "network down",
|
|
1162
|
+
aborted: false,
|
|
1163
|
+
willRetry: false,
|
|
1164
|
+
fromExtension: false,
|
|
1165
|
+
});
|
|
1166
|
+
assert.ok(recorded.notifies.some((note) => note.message.includes("will try again")));
|
|
1167
|
+
});
|
|
1168
|
+
|
|
1169
|
+
it("execution turn compaction helper remains a no-op and failures keep execution active", async () => {
|
|
1170
|
+
const workdir = freshWorkdir();
|
|
1171
|
+
startActiveRun(workdir);
|
|
1172
|
+
const { pi, ctx, recorded, setUsagePercent } = makeHarness(workdir);
|
|
1173
|
+
await startExecution(pi, ctx, path.join(workdir, "PLAN_v23.md"), items("VC-001"), [
|
|
1174
|
+
{ id: "I-001", text: "First item." },
|
|
1175
|
+
]);
|
|
1176
|
+
|
|
1177
|
+
setUsagePercent(96);
|
|
1178
|
+
handleExecutionTurnCompaction(ctx);
|
|
1179
|
+
assert.equal(recorded.compacts?.length ?? 0, 0, "execution scheduling is owned by Pi core");
|
|
1180
|
+
|
|
1181
|
+
handleExecutionBeforeCompact(pi, ctx, {
|
|
1182
|
+
type: "session_before_compact",
|
|
1183
|
+
preparation: makePreparation("threshold", null),
|
|
1184
|
+
branchEntries: compactableBranchEntries(),
|
|
1185
|
+
customInstructions: "keep:1",
|
|
1186
|
+
reason: "threshold",
|
|
1187
|
+
willRetry: false,
|
|
1188
|
+
signal: new AbortController().signal,
|
|
1189
|
+
});
|
|
1190
|
+
handleExecutionCompactFailed(pi, ctx, {
|
|
1191
|
+
type: "session_compact_failed",
|
|
1192
|
+
reason: "manual",
|
|
1193
|
+
errorMessage: "Compaction failed: Already compacted",
|
|
1194
|
+
aborted: false,
|
|
1195
|
+
willRetry: false,
|
|
1196
|
+
fromExtension: false,
|
|
1197
|
+
});
|
|
1198
|
+
assert.ok(getExecution());
|
|
1199
|
+
assert.ok(recorded.notifies.some((note) => note.message.includes("nothing to summarize")));
|
|
1200
|
+
|
|
1201
|
+
await stopExecution(pi, ctx, "test-done");
|
|
1202
|
+
});
|
|
1203
|
+
|
|
1204
|
+
it("treats planning abort/stream compact failures as terminal and notifies explicitly", () => {
|
|
1205
|
+
const workdir = freshWorkdir();
|
|
1206
|
+
const { pi, ctx, recorded } = makeHarness(workdir);
|
|
1207
|
+
initState(workdir);
|
|
1208
|
+
startRun(workdir, { topic: "abort-stream classification", skill: "plan-normal", requestText: "x" });
|
|
1209
|
+
|
|
1210
|
+
const abortMessages = [
|
|
1211
|
+
"Auto-compaction failed: Turn prefix summarization failed: This operation was aborted",
|
|
1212
|
+
"Error: OpenAI Responses stream ended before a terminal response event",
|
|
1213
|
+
"Auto-compaction failed: context overflow recovery failed",
|
|
1214
|
+
"this operation was aborted",
|
|
1215
|
+
"aborted",
|
|
1216
|
+
];
|
|
1217
|
+
for (const errorMessage of abortMessages) {
|
|
1218
|
+
recorded.notifies.length = 0;
|
|
1219
|
+
handlePlanningBeforeCompact(pi as any, ctx as any, {
|
|
1220
|
+
type: "session_before_compact",
|
|
1221
|
+
preparation: makePreparation("threshold", null),
|
|
1222
|
+
branchEntries: compactableBranchEntries(),
|
|
1223
|
+
customInstructions: "keep:1",
|
|
1224
|
+
reason: "threshold",
|
|
1225
|
+
willRetry: false,
|
|
1226
|
+
signal: new AbortController().signal,
|
|
1227
|
+
});
|
|
1228
|
+
handlePlanningCompactFailed(pi as any, ctx as any, {
|
|
1229
|
+
type: "session_compact_failed",
|
|
1230
|
+
reason: "threshold",
|
|
1231
|
+
errorMessage,
|
|
1232
|
+
aborted: errorMessage.includes("aborted"),
|
|
1233
|
+
willRetry: false,
|
|
1234
|
+
fromExtension: false,
|
|
1235
|
+
});
|
|
1236
|
+
const backoffNote = recorded.notifies.find((n) => n.message.includes("was aborted"));
|
|
1237
|
+
assert.ok(backoffNote, `expected abort-class notify for: ${errorMessage}`);
|
|
1238
|
+
assert.match(backoffNote!.message, /provider interruption|competing manual/);
|
|
1239
|
+
const before = recorded.compacts?.length ?? 0;
|
|
1240
|
+
requestPlanningCompaction(ctx as any);
|
|
1241
|
+
assert.equal((recorded.compacts?.length ?? 0) - before, 0);
|
|
1242
|
+
}
|
|
1243
|
+
});
|
|
1244
|
+
|
|
1245
|
+
it("event.aborted=true with empty errorMessage still enters abort-class handling", () => {
|
|
1246
|
+
const workdir = freshWorkdir();
|
|
1247
|
+
const { pi, ctx, recorded } = makeHarness(workdir);
|
|
1248
|
+
initState(workdir);
|
|
1249
|
+
startRun(workdir, { topic: "aborted-no-message", skill: "plan-normal", requestText: "x" });
|
|
1250
|
+
handlePlanningBeforeCompact(pi as any, ctx as any, {
|
|
1251
|
+
type: "session_before_compact",
|
|
1252
|
+
preparation: makePreparation("threshold", null),
|
|
1253
|
+
branchEntries: compactableBranchEntries(),
|
|
1254
|
+
customInstructions: "keep:1",
|
|
1255
|
+
reason: "threshold",
|
|
1256
|
+
willRetry: false,
|
|
1257
|
+
signal: new AbortController().signal,
|
|
1258
|
+
});
|
|
1259
|
+
|
|
1260
|
+
handlePlanningCompactFailed(pi as any, ctx as any, {
|
|
1261
|
+
type: "session_compact_failed",
|
|
1262
|
+
reason: "threshold",
|
|
1263
|
+
errorMessage: undefined,
|
|
1264
|
+
aborted: true,
|
|
1265
|
+
willRetry: false,
|
|
1266
|
+
fromExtension: false,
|
|
1267
|
+
});
|
|
1268
|
+
const backoffNote = recorded.notifies.find((n) => n.message.includes("was aborted"));
|
|
1269
|
+
assert.ok(backoffNote);
|
|
1270
|
+
const before = recorded.compacts?.length ?? 0;
|
|
1271
|
+
requestPlanningCompaction(ctx as any);
|
|
1272
|
+
assert.equal((recorded.compacts?.length ?? 0) - before, 0);
|
|
1273
|
+
});
|
|
1274
|
+
|
|
1275
|
+
it("network-style planning failures stay retryable without proactive requests", () => {
|
|
1276
|
+
const workdir = freshWorkdir();
|
|
1277
|
+
const { pi, ctx, recorded } = makeHarness(workdir);
|
|
1278
|
+
initState(workdir);
|
|
1279
|
+
startRun(workdir, { topic: "network-retryable", skill: "plan-normal", requestText: "x" });
|
|
1280
|
+
handlePlanningBeforeCompact(pi as any, ctx as any, {
|
|
1281
|
+
type: "session_before_compact",
|
|
1282
|
+
preparation: makePreparation("threshold", null),
|
|
1283
|
+
branchEntries: compactableBranchEntries(),
|
|
1284
|
+
customInstructions: "keep:1",
|
|
1285
|
+
reason: "threshold",
|
|
1286
|
+
willRetry: false,
|
|
1287
|
+
signal: new AbortController().signal,
|
|
1288
|
+
});
|
|
1289
|
+
|
|
1290
|
+
handlePlanningCompactFailed(pi as any, ctx as any, {
|
|
1291
|
+
type: "session_compact_failed",
|
|
1292
|
+
reason: "threshold",
|
|
1293
|
+
errorMessage: "network down",
|
|
1294
|
+
aborted: false,
|
|
1295
|
+
willRetry: false,
|
|
1296
|
+
fromExtension: false,
|
|
1297
|
+
});
|
|
1298
|
+
const abortNote = recorded.notifies.find((n) => n.message.includes("was aborted"));
|
|
1299
|
+
assert.equal(abortNote, undefined, "network down must not be classified as abort-class");
|
|
1300
|
+
const retryNote = recorded.notifies.find((n) => n.message.includes("will try again"));
|
|
1301
|
+
assert.ok(retryNote, "network down must keep the retryable path");
|
|
1302
|
+
const before = recorded.compacts?.length ?? 0;
|
|
1303
|
+
requestPlanningCompaction(ctx as any);
|
|
1304
|
+
assert.equal((recorded.compacts?.length ?? 0) - before, 0);
|
|
1305
|
+
});
|
|
1306
|
+
|
|
1307
|
+
it("lifecycle flags distinguish unhinted and phase-attributed compactions", () => {
|
|
1308
|
+
const workdir = freshWorkdir();
|
|
1309
|
+
const { ctx } = makeHarness(workdir);
|
|
1310
|
+
|
|
1311
|
+
noteCompactionStarted(ctx as any, undefined);
|
|
1312
|
+
assert.equal(compactionInFlight(ctx as any, "planning"), true, "unhinted compaction marks planning inFlight");
|
|
1313
|
+
assert.equal(compactionInFlight(ctx as any, "execution"), true, "unhinted compaction marks execution inFlight");
|
|
1314
|
+
noteCompactionEnded(ctx as any, undefined);
|
|
1315
|
+
assert.equal(compactionInFlight(ctx as any, "planning"), false);
|
|
1316
|
+
assert.equal(compactionInFlight(ctx as any, "execution"), false);
|
|
1317
|
+
|
|
1318
|
+
noteCompactionStarted(ctx as any, "pi-plans planning auto compact");
|
|
1319
|
+
assert.equal(compactionInFlight(ctx as any, "planning"), true);
|
|
1320
|
+
assert.equal(compactionInFlight(ctx as any, "execution"), false);
|
|
1321
|
+
noteCompactionEnded(ctx as any, "pi-plans planning auto compact");
|
|
1322
|
+
assert.equal(compactionInFlight(ctx as any, "planning"), false);
|
|
1323
|
+
|
|
1324
|
+
noteCompactionStarted(ctx as any, "pi-plans execution auto compact");
|
|
1325
|
+
assert.equal(compactionInFlight(ctx as any, "planning"), false);
|
|
1326
|
+
assert.equal(compactionInFlight(ctx as any, "execution"), true);
|
|
1327
|
+
noteCompactionEnded(ctx as any, "pi-plans execution auto compact");
|
|
1328
|
+
assert.equal(compactionInFlight(ctx as any, "execution"), false);
|
|
1329
|
+
});
|
|
1330
|
+
|
|
1331
|
+
it("execution handler classifies abort/stream failures as terminal", async () => {
|
|
1332
|
+
const workdir = freshWorkdir();
|
|
1333
|
+
startActiveRun(workdir);
|
|
1334
|
+
const { pi, ctx, recorded } = makeHarness(workdir);
|
|
1335
|
+
await startExecution(pi, ctx, path.join(workdir, "PLAN_v4.md"), items("VC-001"));
|
|
1336
|
+
|
|
1337
|
+
const abortMessages = [
|
|
1338
|
+
"Auto-compaction failed: Turn prefix summarization failed: This operation was aborted",
|
|
1339
|
+
"Error: OpenAI Responses stream ended before a terminal response event",
|
|
1340
|
+
"Auto-compaction failed: context overflow recovery failed",
|
|
1341
|
+
"aborted",
|
|
1342
|
+
];
|
|
1343
|
+
for (const errorMessage of abortMessages) {
|
|
1344
|
+
recorded.notifies.length = 0;
|
|
1345
|
+
handleExecutionBeforeCompact(pi, ctx, {
|
|
1346
|
+
type: "session_before_compact",
|
|
1347
|
+
preparation: makePreparation("threshold", null),
|
|
1348
|
+
branchEntries: compactableBranchEntries(),
|
|
1349
|
+
customInstructions: "keep:1",
|
|
1350
|
+
reason: "threshold",
|
|
1351
|
+
willRetry: false,
|
|
1352
|
+
signal: new AbortController().signal,
|
|
1353
|
+
});
|
|
1354
|
+
handleExecutionCompactFailed(pi, ctx, {
|
|
1355
|
+
type: "session_compact_failed",
|
|
1356
|
+
reason: "threshold",
|
|
1357
|
+
errorMessage,
|
|
1358
|
+
aborted: errorMessage.includes("aborted"),
|
|
1359
|
+
willRetry: false,
|
|
1360
|
+
fromExtension: false,
|
|
1361
|
+
});
|
|
1362
|
+
const note = recorded.notifies.find((n) => n.message.includes("was aborted"));
|
|
1363
|
+
assert.ok(note, `expected abort-class notify for: ${errorMessage}`);
|
|
1364
|
+
assert.match(note!.message, /provider interruption|competing manual/);
|
|
1365
|
+
}
|
|
1366
|
+
handleExecutionBeforeCompact(pi, ctx, {
|
|
1367
|
+
type: "session_before_compact",
|
|
1368
|
+
preparation: makePreparation("threshold", null),
|
|
1369
|
+
branchEntries: compactableBranchEntries(),
|
|
1370
|
+
customInstructions: "keep:1",
|
|
1371
|
+
reason: "threshold",
|
|
1372
|
+
willRetry: false,
|
|
1373
|
+
signal: new AbortController().signal,
|
|
1374
|
+
});
|
|
1375
|
+
handleExecutionCompactFailed(pi, ctx, {
|
|
1376
|
+
type: "session_compact_failed",
|
|
1377
|
+
reason: "threshold",
|
|
1378
|
+
errorMessage: undefined,
|
|
1379
|
+
aborted: true,
|
|
1380
|
+
willRetry: false,
|
|
1381
|
+
fromExtension: false,
|
|
1382
|
+
});
|
|
1383
|
+
const cleanNote = recorded.notifies.find((n) => n.message.includes("was aborted"));
|
|
1384
|
+
assert.ok(cleanNote, "aborted-without-message must enter abort-class handling on execution side too");
|
|
1385
|
+
await stopExecution(pi, ctx, "test-done");
|
|
1386
|
+
});
|
|
1387
|
+
|
|
1388
|
+
it("index.ts wires session_compact/session_compact_failed to clear inFlight flags (F-003/F-004 coverage)", () => {
|
|
1389
|
+
const indexSource = fs.readFileSync(path.join(process.cwd(), "index.ts"), "utf8");
|
|
1390
|
+
assert.match(indexSource, /await handleExecutionCompact\(pi, ctx, event\)/);
|
|
1391
|
+
assert.match(indexSource, /await handlePlanningCompact\(pi, ctx, event\)/);
|
|
1392
|
+
|
|
1393
|
+
const workdir = freshWorkdir();
|
|
1394
|
+
// Use a minimal harness that exposes sessionManager so we can inspect the
|
|
1395
|
+
// in-flight store after dispatching the registered event handlers.
|
|
1396
|
+
const ctx: any = {
|
|
1397
|
+
cwd: workdir,
|
|
1398
|
+
hasUI: false,
|
|
1399
|
+
sessionManager: {},
|
|
1400
|
+
};
|
|
1401
|
+
// Simulate the lifecycle that index.ts wires in production:
|
|
1402
|
+
noteCompactionStarted(ctx, undefined);
|
|
1403
|
+
assert.equal(compactionInFlight(ctx, "planning"), true);
|
|
1404
|
+
assert.equal(compactionInFlight(ctx, "execution"), true);
|
|
1405
|
+
noteCompactionEnded(ctx, undefined);
|
|
1406
|
+
assert.equal(compactionInFlight(ctx, "planning"), false);
|
|
1407
|
+
assert.equal(compactionInFlight(ctx, "execution"), false);
|
|
1408
|
+
// And a planning-attributed start + end still clears both (end has no hint).
|
|
1409
|
+
noteCompactionStarted(ctx, "pi-plans planning auto compact");
|
|
1410
|
+
assert.equal(compactionInFlight(ctx, "planning"), true);
|
|
1411
|
+
noteCompactionEnded(ctx, "pi-plans planning auto compact");
|
|
1412
|
+
assert.equal(compactionInFlight(ctx, "planning"), false);
|
|
1015
1413
|
});
|
|
1016
1414
|
});
|
|
1017
1415
|
|
|
1018
|
-
|
|
1019
|
-
|
|
1416
|
+
describe("execution injection reads graph config live", () => {
|
|
1417
|
+
it("reflects graph_enabled flips on the next assembly and surfaces config failures", async (t) => {
|
|
1418
|
+
const workdir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-exec-graph-"));
|
|
1419
|
+
t.after(() => {
|
|
1420
|
+
try {
|
|
1421
|
+
fs.rmSync(workdir, { recursive: true, force: true });
|
|
1422
|
+
} catch {
|
|
1423
|
+
/* ignore */
|
|
1424
|
+
}
|
|
1425
|
+
});
|
|
1426
|
+
const { pi, ctx } = makeHarness(workdir);
|
|
1427
|
+
setGraphEnabled(workdir, true);
|
|
1428
|
+
await startExecution(pi, ctx, path.join(workdir, "PLAN_v1.md"), items("VC-001"));
|
|
1429
|
+
|
|
1430
|
+
const enabledRules = executionContextMessage(ctx)!;
|
|
1431
|
+
assert.match(enabledRules, /Code graph loop: indexed code files read as a function digest/);
|
|
1432
|
+
assert.doesNotMatch(enabledRules, /Code graph disabled/);
|
|
1433
|
+
|
|
1434
|
+
setGraphEnabled(workdir, false);
|
|
1435
|
+
assert.match(executionContextMessage(ctx)!, /Code graph disabled/);
|
|
1436
|
+
|
|
1437
|
+
fs.writeFileSync(path.join(workdir, ".git", "pi_plans", "config.json"), "{broken");
|
|
1438
|
+
const brokenRules = executionContextMessage(ctx)!;
|
|
1439
|
+
assert.match(brokenRules, /Code graph disabled/);
|
|
1440
|
+
assert.match(brokenRules, /config unreadable this turn/);
|
|
1441
|
+
});
|
|
1442
|
+
|
|
1443
|
+
it("keeps no graphEnabled snapshot in the execution state source", () => {
|
|
1444
|
+
const source = fs.readFileSync(path.join(process.cwd(), "src", "exec.ts"), "utf8");
|
|
1445
|
+
assert.doesNotMatch(source, /graphEnabled/);
|
|
1446
|
+
assert.match(source, /resolveGraphMode/);
|
|
1447
|
+
});
|
|
1448
|
+
});
|
|
1449
|
+
|
|
1450
|
+
function makePreparation(reason: "manual" | "threshold" | "overflow", previousSummary: string | null): any { return {
|
|
1020
1451
|
firstKeptEntryId: "a-2",
|
|
1021
1452
|
messagesToSummarize: [],
|
|
1022
1453
|
turnPrefixMessages: [],
|
|
@@ -1027,3 +1458,139 @@ function makePreparation(reason: "manual" | "threshold" | "overflow", previousSu
|
|
|
1027
1458
|
settings: { enabled: true, reserveTokens: 16384, keepRecentTokens: 20000 },
|
|
1028
1459
|
};
|
|
1029
1460
|
}
|
|
1461
|
+
|
|
1462
|
+
describe("execution goal-wait continuation", () => {
|
|
1463
|
+
// Fresh per-turn continuation flags, mirroring the before_agent_start reset.
|
|
1464
|
+
const setup = (workdir: string) => {
|
|
1465
|
+
resetGoalWaitTurnFlags();
|
|
1466
|
+
const harness = makeHarness(workdir);
|
|
1467
|
+
registerExecutionTurnHandlers(harness.pi);
|
|
1468
|
+
return harness;
|
|
1469
|
+
};
|
|
1470
|
+
|
|
1471
|
+
it("sends a goal-wait followUp when a turn ends with unpassed VCs", async () => {
|
|
1472
|
+
const workdir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-goal-wait-"));
|
|
1473
|
+
const { pi, ctx, recorded, emit } = setup(workdir);
|
|
1474
|
+
await startExecution(pi, ctx, path.join(workdir, "PLAN_v1.md"), items("VC-001"));
|
|
1475
|
+
recorded.userMessages.length = 0;
|
|
1476
|
+
await emit("turn_end", { message: { role: "assistant", content: [{ type: "text", text: "still working" }] } });
|
|
1477
|
+
assert.equal(recorded.userMessages.length, 1);
|
|
1478
|
+
assert.match(recorded.userMessages[0], /Goal wait: 1\/1 verifier items still open/);
|
|
1479
|
+
assert.match(recorded.userMessages[0], /\`VC-001\`/);
|
|
1480
|
+
assert.equal(recorded.userMessageOptions.at(-1)?.deliverAs, "followUp");
|
|
1481
|
+
assert.match(recorded.status ?? "", /goal-wait · 无进展 1\/3 · 等待 0\/6/);
|
|
1482
|
+
});
|
|
1483
|
+
|
|
1484
|
+
it("sends the goal-wait followUp in headless sessions too", async () => {
|
|
1485
|
+
const workdir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-goal-wait-"));
|
|
1486
|
+
const { pi, ctx, recorded, emit } = setup(workdir);
|
|
1487
|
+
(ctx as any).hasUI = false;
|
|
1488
|
+
await startExecution(pi, ctx, path.join(workdir, "PLAN_v1.md"), items("VC-001"));
|
|
1489
|
+
recorded.userMessages.length = 0;
|
|
1490
|
+
await emit("turn_end", { message: { role: "assistant", content: [{ type: "text", text: "still working" }] } });
|
|
1491
|
+
assert.equal(recorded.userMessages.length, 1);
|
|
1492
|
+
assert.match(recorded.userMessages[0], /Goal wait: 1\/1 verifier items still open/);
|
|
1493
|
+
});
|
|
1494
|
+
|
|
1495
|
+
it("does not goal-wait when every VC is done (completion path)", async () => {
|
|
1496
|
+
const workdir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-goal-wait-"));
|
|
1497
|
+
const { pi, ctx, recorded, emit } = setup(workdir);
|
|
1498
|
+
await startExecution(pi, ctx, path.join(workdir, "PLAN_v1.md"), items("VC-001"));
|
|
1499
|
+
recorded.userMessages.length = 0;
|
|
1500
|
+
await emit("turn_end", { message: { role: "assistant", content: [{ type: "text", text: "done [DONE:VC-001]" }] } });
|
|
1501
|
+
assert.equal(recorded.userMessages.length, 0);
|
|
1502
|
+
assert.ok(recorded.messages.some((message) => message.customType === "pi-plans-complete"));
|
|
1503
|
+
});
|
|
1504
|
+
|
|
1505
|
+
it("skips goal-wait while any compaction continuation flag is active", async () => {
|
|
1506
|
+
const variants = [
|
|
1507
|
+
{ inFlight: true, resumeGuard: false, pendingFollowUpPrompt: null },
|
|
1508
|
+
{ inFlight: false, resumeGuard: true, pendingFollowUpPrompt: null },
|
|
1509
|
+
{ inFlight: false, resumeGuard: false, pendingFollowUpPrompt: "compaction follow-up" },
|
|
1510
|
+
];
|
|
1511
|
+
for (const flags of variants) {
|
|
1512
|
+
const workdir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-goal-wait-"));
|
|
1513
|
+
const { pi, ctx, recorded, emit } = setup(workdir);
|
|
1514
|
+
await startExecution(pi, ctx, path.join(workdir, "PLAN_v1.md"), items("VC-001"));
|
|
1515
|
+
(ctx.sessionManager as any).__executionCompaction = { ...flags, cooldownActive: false };
|
|
1516
|
+
recorded.userMessages.length = 0;
|
|
1517
|
+
await emit("turn_end", { message: { role: "assistant", content: [{ type: "text", text: "working" }] } });
|
|
1518
|
+
assert.equal(recorded.userMessages.length, 0, `flags ${JSON.stringify(flags)} must skip goal-wait`);
|
|
1519
|
+
}
|
|
1520
|
+
});
|
|
1521
|
+
|
|
1522
|
+
it("pauses after 3 no-progress rounds and resumes on kick", async () => {
|
|
1523
|
+
const workdir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-goal-wait-"));
|
|
1524
|
+
const { pi, ctx, recorded, emit } = setup(workdir);
|
|
1525
|
+
await startExecution(pi, ctx, path.join(workdir, "PLAN_v1.md"), items("VC-001"));
|
|
1526
|
+
recorded.userMessages.length = 0;
|
|
1527
|
+
for (let round = 0; round < 3; round++) {
|
|
1528
|
+
await emit("turn_end", { message: { role: "assistant", content: [{ type: "text", text: "still working" }] } });
|
|
1529
|
+
}
|
|
1530
|
+
assert.equal(getExecution()?.goalWait?.paused, true);
|
|
1531
|
+
assert.equal(recorded.userMessages.length, 2, "third quiet round must not queue another followUp");
|
|
1532
|
+
assert.ok(recorded.notifies.some((entry) => /goal-wait paused/.test(entry.message)));
|
|
1533
|
+
assert.match(recorded.status ?? "", /⏸ goal-wait paused/);
|
|
1534
|
+
|
|
1535
|
+
resumeGoalWaitIfPaused(pi, ctx);
|
|
1536
|
+
assert.equal(getExecution()?.goalWait?.paused, false);
|
|
1537
|
+
await emit("turn_end", { message: { role: "assistant", content: [{ type: "text", text: "progress [DONE:VC-001]" }] } });
|
|
1538
|
+
assert.match(recorded.userMessages.at(-1) ?? "", /Goal wait/);
|
|
1539
|
+
});
|
|
1540
|
+
|
|
1541
|
+
it("waiting rounds are exempt until the sixth quiet waiting round", async () => {
|
|
1542
|
+
const workdir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-goal-wait-"));
|
|
1543
|
+
const { pi, ctx, recorded, emit } = setup(workdir);
|
|
1544
|
+
await startExecution(pi, ctx, path.join(workdir, "PLAN_v1.md"), items("VC-001"));
|
|
1545
|
+
for (let round = 1; round <= 5; round++) {
|
|
1546
|
+
await emit("turn_end", { message: { role: "assistant", content: [{ type: "text", text: `waiting for CI (${round})` }] } });
|
|
1547
|
+
assert.equal(getExecution()?.goalWait?.paused, false, `round ${round} must not pause`);
|
|
1548
|
+
}
|
|
1549
|
+
await emit("turn_end", { message: { role: "assistant", content: [{ type: "text", text: "waiting for CI (6)" }] } });
|
|
1550
|
+
assert.equal(getExecution()?.goalWait?.paused, true);
|
|
1551
|
+
assert.ok(recorded.notifies.some((entry) => /waiting without progress for 6 rounds/.test(entry.message)));
|
|
1552
|
+
});
|
|
1553
|
+
|
|
1554
|
+
it("progress resets both guard counters", async () => {
|
|
1555
|
+
const workdir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-goal-wait-"));
|
|
1556
|
+
const { pi, ctx, emit } = makeHarness(workdir);
|
|
1557
|
+
await startExecution(pi, ctx, path.join(workdir, "PLAN_v1.md"), items("VC-001", "VC-002"));
|
|
1558
|
+
await emit("turn_end", { message: { role: "assistant", content: [{ type: "text", text: "working" }] } });
|
|
1559
|
+
await emit("turn_end", { message: { role: "assistant", content: [{ type: "text", text: "working" }] } });
|
|
1560
|
+
await emit("turn_end", { message: { role: "assistant", content: [{ type: "text", text: "progress [DONE:VC-001]" }] } });
|
|
1561
|
+
assert.equal(getExecution()?.goalWait?.noProgressRounds, 0);
|
|
1562
|
+
assert.equal(getExecution()?.goalWait?.waitRounds, 0);
|
|
1563
|
+
});
|
|
1564
|
+
|
|
1565
|
+
it("keeps goal-wait counters across restore", async () => {
|
|
1566
|
+
const workdir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-goal-wait-"));
|
|
1567
|
+
const { pi, ctx } = setup(workdir);
|
|
1568
|
+
const planPath = path.join(workdir, "PLAN_v1.md");
|
|
1569
|
+
fs.writeFileSync(planPath, "# plan");
|
|
1570
|
+
const snapshot = {
|
|
1571
|
+
planPath,
|
|
1572
|
+
items: items("VC-001"),
|
|
1573
|
+
startedAt: "2026-08-25T00:00:00Z",
|
|
1574
|
+
usage: { inToks: 0, outToks: 0 },
|
|
1575
|
+
implItems: [],
|
|
1576
|
+
implStatus: {},
|
|
1577
|
+
goalWait: { noProgressRounds: 2, waitRounds: 1, lastMarkers: null, paused: false },
|
|
1578
|
+
};
|
|
1579
|
+
const entries = [
|
|
1580
|
+
{ type: "custom", customType: "pi-plans-exec", data: snapshot },
|
|
1581
|
+
{ type: "message", message: { role: "assistant", content: [{ type: "text", text: "no new progress this turn" }] } },
|
|
1582
|
+
];
|
|
1583
|
+
await restoreFromSession(pi, ctx, entries as any);
|
|
1584
|
+
// Replay advanced the marker snapshot past the persisted baseline → counters reset (D-010).
|
|
1585
|
+
assert.equal(getExecution()?.goalWait?.noProgressRounds, 0);
|
|
1586
|
+
assert.equal(getExecution()?.goalWait?.waitRounds, 0);
|
|
1587
|
+
});
|
|
1588
|
+
});
|
|
1589
|
+
|
|
1590
|
+
describe("amelioration termination prompt", () => {
|
|
1591
|
+
it("recommends goal-wait first and keeps the round options", () => {
|
|
1592
|
+
assert.match(AMELIORATION_PROMPT_TEXT, /goal wait: continue until no unpassed VCs remain/);
|
|
1593
|
+
assert.match(AMELIORATION_PROMPT_TEXT, /until no high-severity finding \(hard cap 5 rounds\)/);
|
|
1594
|
+
assert.match(AMELIORATION_PROMPT_TEXT, /How should the implementation-review loop terminate\?/);
|
|
1595
|
+
});
|
|
1596
|
+
});
|