pi-plans 0.1.2 → 0.3.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 +98 -19
- package/index.ts +147 -66
- package/package.json +7 -1
- package/references/pi-planning-workflow.md +21 -3
- package/references/state-and-config.md +35 -3
- package/scripts/validate.ts +4 -0
- package/src/autocomplete.ts +163 -0
- package/src/code-graph/commands.ts +437 -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 +1256 -0
- package/src/config-command.ts +326 -0
- package/src/exec.ts +519 -625
- package/src/plan.ts +37 -0
- package/src/query-hook.ts +82 -0
- package/src/refine-prompts.ts +50 -0
- package/src/refine-ui-helpers.ts +142 -0
- package/src/refine-ui-state.ts +144 -0
- package/src/refine-ui.ts +430 -0
- package/src/state.ts +24 -29
- package/src/subagent.ts +299 -70
- package/tests/ask-choice.test.ts +263 -0
- package/tests/autocomplete.test.ts +147 -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 +388 -0
- package/tests/config-command.test.ts +255 -0
- package/tests/exec.test.ts +751 -422
- package/tests/execute-plan.test.ts +65 -0
- 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/plan.test.ts +11 -1
- package/tests/plans.test.ts +6 -5
- package/tests/query-hook.test.ts +82 -0
- package/tests/refine-prompts.test.ts +67 -2
- package/tests/refine-ui.test.ts +392 -0
- package/tests/state.test.ts +12 -15
- package/tests/subagent.test.ts +120 -0
- package/tools/ask-choice.ts +180 -11
- package/tools/code-graph.ts +254 -0
- package/tools/execute-plan.ts +7 -39
- package/tools/graph-aware-file-tools.ts +392 -0
- package/tools/plans.ts +84 -18
- package/tools/refine.ts +180 -80
- package/src/execution-panel.ts +0 -633
- package/tests/execution-panel.test.ts +0 -234
|
@@ -0,0 +1,388 @@
|
|
|
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
|
+
import {
|
|
7
|
+
buildOwnCut,
|
|
8
|
+
buildPiPlansVccCompaction,
|
|
9
|
+
compactionCurrentI,
|
|
10
|
+
DEFAULT_VCC_SETTINGS,
|
|
11
|
+
loadVccSettings,
|
|
12
|
+
parseCompactionInstructions,
|
|
13
|
+
PI_VCC_COMPACT_INSTRUCTION,
|
|
14
|
+
scaffoldVccSettings,
|
|
15
|
+
shouldScheduleAutoContinue,
|
|
16
|
+
vccSettingsPath,
|
|
17
|
+
type CompactionEntryLike,
|
|
18
|
+
} from "../src/compaction.ts";
|
|
19
|
+
|
|
20
|
+
function textEntry(id: string, role: string, text: string): CompactionEntryLike {
|
|
21
|
+
return { id, type: "message", message: { role, content: [{ type: "text", text }] } };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function toolCallEntry(id: string, name: string, args: Record<string, unknown>): CompactionEntryLike {
|
|
25
|
+
return { id, type: "message", message: { role: "assistant", content: [{ type: "toolCall", name, arguments: args }] } };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function toolResultEntry(id: string, name: string, text: string): CompactionEntryLike {
|
|
29
|
+
return { id, type: "message", message: { role: "toolResult", toolName: name, content: [{ type: "text", text }] } };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("pi-vcc compaction", () => {
|
|
33
|
+
let tmpRoot: string;
|
|
34
|
+
|
|
35
|
+
before(() => {
|
|
36
|
+
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-vcc-"));
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
after(() => {
|
|
40
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("builds an own cut that keeps the requested recent user turns", () => {
|
|
44
|
+
const entries = [
|
|
45
|
+
textEntry("u-1", "user", "implement compact support"),
|
|
46
|
+
textEntry("a-1", "assistant", "started the helper"),
|
|
47
|
+
textEntry("u-2", "user", "continue from the current task"),
|
|
48
|
+
textEntry("a-2", "assistant", "working on tests"),
|
|
49
|
+
];
|
|
50
|
+
const cut = buildOwnCut(entries, 1);
|
|
51
|
+
assert.equal(cut.ok, true);
|
|
52
|
+
assert.equal(cut.ok ? cut.firstKeptEntryId : undefined, "u-2");
|
|
53
|
+
assert.deepEqual(cut.ok ? cut.messages.map((message) => message.role) : [], ["user", "assistant"]);
|
|
54
|
+
|
|
55
|
+
const compactAll = buildOwnCut(entries, 0);
|
|
56
|
+
assert.equal(compactAll.ok, true);
|
|
57
|
+
assert.equal(compactAll.ok ? compactAll.compactAll : false, true);
|
|
58
|
+
assert.equal(compactAll.ok ? compactAll.firstKeptEntryId : "not-ok", "");
|
|
59
|
+
assert.deepEqual(buildOwnCut([textEntry("u", "user", "only one turn")], 1), { ok: false, reason: "too_few_live_messages" });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("reads legacy compaction details without writing the old schema", () => {
|
|
63
|
+
const legacyCompaction: CompactionEntryLike = {
|
|
64
|
+
id: "old-compact",
|
|
65
|
+
type: "compaction",
|
|
66
|
+
details: {
|
|
67
|
+
kind: "pi-plans-execution-compaction",
|
|
68
|
+
readRecords: [
|
|
69
|
+
{ path: "src/legacy.ts", lineStart: 5, lineEnd: 9, range: "5-9", summary: "legacy facts", key: "src/legacy.ts|5-9" },
|
|
70
|
+
],
|
|
71
|
+
metrics: {
|
|
72
|
+
currentI: "I-007",
|
|
73
|
+
firstKeptEntryId: "u-2",
|
|
74
|
+
targetMet: false,
|
|
75
|
+
hardFloorReason: "single oversized tool result",
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
assert.equal(compactionCurrentI(legacyCompaction), "I-007");
|
|
80
|
+
|
|
81
|
+
const entries = [
|
|
82
|
+
textEntry("u-1", "user", "old prefix"),
|
|
83
|
+
textEntry("a-1", "assistant", "old answer"),
|
|
84
|
+
legacyCompaction,
|
|
85
|
+
textEntry("u-2", "user", "retained user from old boundary"),
|
|
86
|
+
textEntry("a-2", "assistant", "live answer"),
|
|
87
|
+
textEntry("u-3", "user", "latest user"),
|
|
88
|
+
textEntry("a-3", "assistant", "latest answer"),
|
|
89
|
+
];
|
|
90
|
+
const cut = buildOwnCut(entries, 1);
|
|
91
|
+
assert.equal(cut.ok, true);
|
|
92
|
+
assert.deepEqual(cut.ok ? cut.messages.map((message) => message.content) : [], [
|
|
93
|
+
[{ type: "text", text: "retained user from old boundary" }],
|
|
94
|
+
[{ type: "text", text: "live answer" }],
|
|
95
|
+
]);
|
|
96
|
+
|
|
97
|
+
const result = buildPiPlansVccCompaction({
|
|
98
|
+
branchEntries: entries,
|
|
99
|
+
preparation: { tokensBefore: 20_000 },
|
|
100
|
+
reason: "threshold",
|
|
101
|
+
willRetry: false,
|
|
102
|
+
settings: DEFAULT_VCC_SETTINGS,
|
|
103
|
+
phaseContext: { phase: "planning" },
|
|
104
|
+
});
|
|
105
|
+
assert.equal(result.kind, "compaction");
|
|
106
|
+
if (result.kind !== "compaction") return;
|
|
107
|
+
assert.match(result.compaction.summary, /Read: src\/legacy\.ts line 5-9 Extracted information summary: legacy facts/);
|
|
108
|
+
assert.match(result.compaction.summary, /Previous compaction hard floor: single oversized tool result/);
|
|
109
|
+
assert.equal((result.compaction.details as any).kind, undefined);
|
|
110
|
+
assert.equal((result.compaction.details as any).readRecords, undefined);
|
|
111
|
+
assert.equal((result.compaction.details as any).metrics?.currentI, undefined);
|
|
112
|
+
assert.equal(result.compaction.details.compactor, "pi-vcc");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("applies smart keep, explicit keep, budget cuts, and tool-result-safe boundaries", () => {
|
|
116
|
+
const smallTurns = [
|
|
117
|
+
textEntry("u-1", "user", "first task"),
|
|
118
|
+
textEntry("a-1", "assistant", "first answer"),
|
|
119
|
+
textEntry("u-2", "user", "second task"),
|
|
120
|
+
textEntry("a-2", "assistant", "second answer"),
|
|
121
|
+
textEntry("u-3", "user", "third task"),
|
|
122
|
+
textEntry("a-3", "assistant", "third answer"),
|
|
123
|
+
];
|
|
124
|
+
const smart = buildPiPlansVccCompaction({
|
|
125
|
+
branchEntries: smallTurns,
|
|
126
|
+
preparation: { tokensBefore: 10_000 },
|
|
127
|
+
reason: "threshold",
|
|
128
|
+
willRetry: false,
|
|
129
|
+
settings: DEFAULT_VCC_SETTINGS,
|
|
130
|
+
phaseContext: { phase: "planning" },
|
|
131
|
+
});
|
|
132
|
+
assert.equal(smart.kind, "compaction");
|
|
133
|
+
if (smart.kind !== "compaction") return;
|
|
134
|
+
assert.equal(smart.stats.smartKeepAdjusted, true);
|
|
135
|
+
assert.equal(smart.stats.smartFromKeep, 1);
|
|
136
|
+
assert.equal(smart.stats.requestedKeepUserTurns, 2);
|
|
137
|
+
assert.equal(smart.compaction.firstKeptEntryId, "u-2");
|
|
138
|
+
|
|
139
|
+
const explicit = buildPiPlansVccCompaction({
|
|
140
|
+
branchEntries: smallTurns,
|
|
141
|
+
preparation: { tokensBefore: 10_000 },
|
|
142
|
+
customInstructions: `${PI_VCC_COMPACT_INSTRUCTION} keep:1`,
|
|
143
|
+
reason: "threshold",
|
|
144
|
+
willRetry: false,
|
|
145
|
+
settings: DEFAULT_VCC_SETTINGS,
|
|
146
|
+
phaseContext: { phase: "planning" },
|
|
147
|
+
});
|
|
148
|
+
assert.equal(explicit.kind, "compaction");
|
|
149
|
+
if (explicit.kind !== "compaction") return;
|
|
150
|
+
assert.equal(explicit.stats.smartKeepAdjusted, false);
|
|
151
|
+
assert.equal(explicit.stats.requestedKeepUserTurns, 1);
|
|
152
|
+
assert.equal(explicit.compaction.firstKeptEntryId, "u-3");
|
|
153
|
+
|
|
154
|
+
const huge = "x".repeat(300_000);
|
|
155
|
+
const noAnchor = buildPiPlansVccCompaction({
|
|
156
|
+
branchEntries: [
|
|
157
|
+
textEntry("u-1", "user", "single user turn"),
|
|
158
|
+
textEntry("a-1", "assistant", huge),
|
|
159
|
+
textEntry("a-2", "assistant", "safe assistant boundary"),
|
|
160
|
+
],
|
|
161
|
+
preparation: { tokensBefore: 200_000 },
|
|
162
|
+
reason: "threshold",
|
|
163
|
+
willRetry: false,
|
|
164
|
+
settings: DEFAULT_VCC_SETTINGS,
|
|
165
|
+
phaseContext: { phase: "planning" },
|
|
166
|
+
});
|
|
167
|
+
assert.equal(noAnchor.kind, "compaction");
|
|
168
|
+
if (noAnchor.kind !== "compaction") return;
|
|
169
|
+
assert.equal(noAnchor.stats.budgetCut, "no_anchor");
|
|
170
|
+
assert.equal(noAnchor.compaction.firstKeptEntryId, "a-1");
|
|
171
|
+
|
|
172
|
+
const oversizedTail = buildPiPlansVccCompaction({
|
|
173
|
+
branchEntries: [
|
|
174
|
+
textEntry("u-1", "user", "one"),
|
|
175
|
+
textEntry("a-1", "assistant", "done"),
|
|
176
|
+
textEntry("u-2", "user", "two"),
|
|
177
|
+
textEntry("a-2", "assistant", "done"),
|
|
178
|
+
textEntry("u-3", "user", "three"),
|
|
179
|
+
textEntry("a-3", "assistant", huge),
|
|
180
|
+
],
|
|
181
|
+
preparation: { tokensBefore: 250_000 },
|
|
182
|
+
reason: "threshold",
|
|
183
|
+
willRetry: false,
|
|
184
|
+
settings: { ...DEFAULT_VCC_SETTINGS, smartKeepTail: false },
|
|
185
|
+
phaseContext: { phase: "planning" },
|
|
186
|
+
});
|
|
187
|
+
assert.equal(oversizedTail.kind, "compaction");
|
|
188
|
+
if (oversizedTail.kind !== "compaction") return;
|
|
189
|
+
assert.equal(oversizedTail.stats.budgetCut, "oversized_tail");
|
|
190
|
+
assert.equal(oversizedTail.compaction.firstKeptEntryId, "a-3");
|
|
191
|
+
|
|
192
|
+
const toolBoundary = buildPiPlansVccCompaction({
|
|
193
|
+
branchEntries: [
|
|
194
|
+
textEntry("u-1", "user", "single user turn"),
|
|
195
|
+
toolCallEntry("tc-1", "read", { path: "src/exec.ts" }),
|
|
196
|
+
toolResultEntry("tr-1", "read", huge),
|
|
197
|
+
textEntry("a-after", "assistant", "safe boundary after the tool result"),
|
|
198
|
+
],
|
|
199
|
+
preparation: { tokensBefore: 250_000 },
|
|
200
|
+
reason: "threshold",
|
|
201
|
+
willRetry: false,
|
|
202
|
+
settings: DEFAULT_VCC_SETTINGS,
|
|
203
|
+
phaseContext: { phase: "planning" },
|
|
204
|
+
});
|
|
205
|
+
assert.equal(toolBoundary.kind, "compaction");
|
|
206
|
+
if (toolBoundary.kind !== "compaction") return;
|
|
207
|
+
assert.equal(toolBoundary.stats.budgetCut, "no_anchor");
|
|
208
|
+
assert.equal(toolBoundary.compaction.firstKeptEntryId, "a-after");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("compiles a deterministic five-section summary with phase context and file activity", () => {
|
|
212
|
+
const result = buildPiPlansVccCompaction({
|
|
213
|
+
branchEntries: [
|
|
214
|
+
textEntry("u-1", "user", "Please implement repo-private VCC compaction. Always keep ASCII output."),
|
|
215
|
+
toolCallEntry("tc-1", "edit", { path: "src/compaction.ts" }),
|
|
216
|
+
textEntry("a-1", "assistant", "Updated src/compaction.ts and ran npm test."),
|
|
217
|
+
textEntry("u-2", "user", "Continue execution."),
|
|
218
|
+
textEntry("a-2", "assistant", "Current work is in the retained tail."),
|
|
219
|
+
],
|
|
220
|
+
preparation: {
|
|
221
|
+
firstKeptEntryId: "fallback",
|
|
222
|
+
tokensBefore: 40_000,
|
|
223
|
+
previousSummary: "## Legacy Summary\nEarlier compact facts.",
|
|
224
|
+
fileOps: { read: ["src/exec.ts"], written: ["src/compaction.ts"], edited: [] },
|
|
225
|
+
},
|
|
226
|
+
customInstructions: `${PI_VCC_COMPACT_INSTRUCTION} keep:1`,
|
|
227
|
+
reason: "threshold",
|
|
228
|
+
willRetry: false,
|
|
229
|
+
settings: DEFAULT_VCC_SETTINGS,
|
|
230
|
+
phaseContext: {
|
|
231
|
+
phase: "execution",
|
|
232
|
+
planPath: "/repo/PLAN_v3.md",
|
|
233
|
+
currentI: "I-002",
|
|
234
|
+
remainingVerifierIds: ["VC-002"],
|
|
235
|
+
implementationIds: ["I-001", "I-002"],
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
assert.equal(result.kind, "compaction");
|
|
239
|
+
if (result.kind !== "compaction") return;
|
|
240
|
+
assert.equal(result.compaction.firstKeptEntryId, "u-2");
|
|
241
|
+
assert.match(result.compaction.summary, /\[Session Goal\]/);
|
|
242
|
+
assert.match(result.compaction.summary, /Execute accepted plan \/repo\/PLAN_v3\.md/);
|
|
243
|
+
assert.match(result.compaction.summary, /\[Files And Changes\]/);
|
|
244
|
+
assert.match(result.compaction.summary, /Modified: src\/compaction\.ts/);
|
|
245
|
+
assert.match(result.compaction.summary, /Read: src\/exec\.ts/);
|
|
246
|
+
assert.match(result.compaction.summary, /\[Outstanding Context\]/);
|
|
247
|
+
assert.match(result.compaction.summary, /Current implementation item: I-002/);
|
|
248
|
+
assert.match(result.compaction.summary, /Previous compact summary: Legacy Summary Earlier compact facts\./);
|
|
249
|
+
assert.match(result.compaction.summary, /\[User Preferences\]/);
|
|
250
|
+
assert.match(result.compaction.summary, /Always keep ASCII output/);
|
|
251
|
+
assert.equal(result.compaction.details.compactor, "pi-vcc");
|
|
252
|
+
assert.equal(result.compaction.details.phase, "execution");
|
|
253
|
+
assert.equal(result.stats.keptUserTurns, 1);
|
|
254
|
+
assert.equal(result.followUpPrompt, null);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("cancels unsafe manual cuts but falls back to Pi core for overflow retry", () => {
|
|
258
|
+
const manual = buildPiPlansVccCompaction({
|
|
259
|
+
branchEntries: [],
|
|
260
|
+
preparation: { firstKeptEntryId: "fallback", tokensBefore: 100 },
|
|
261
|
+
reason: "manual",
|
|
262
|
+
willRetry: false,
|
|
263
|
+
settings: DEFAULT_VCC_SETTINGS,
|
|
264
|
+
phaseContext: { phase: "planning" },
|
|
265
|
+
});
|
|
266
|
+
assert.equal(manual.kind, "cancel");
|
|
267
|
+
|
|
268
|
+
const overflowRetry = buildPiPlansVccCompaction({
|
|
269
|
+
branchEntries: [],
|
|
270
|
+
preparation: { firstKeptEntryId: "fallback", tokensBefore: 100 },
|
|
271
|
+
reason: "overflow",
|
|
272
|
+
willRetry: true,
|
|
273
|
+
settings: DEFAULT_VCC_SETTINGS,
|
|
274
|
+
phaseContext: { phase: "planning" },
|
|
275
|
+
});
|
|
276
|
+
assert.deepEqual(overflowRetry, { kind: "fallback", reason: "no_live_messages" });
|
|
277
|
+
|
|
278
|
+
const overrideDisabled = buildPiPlansVccCompaction({
|
|
279
|
+
branchEntries: [
|
|
280
|
+
textEntry("u-1", "user", "old"),
|
|
281
|
+
textEntry("a-1", "assistant", "old answer"),
|
|
282
|
+
textEntry("u-2", "user", "new"),
|
|
283
|
+
],
|
|
284
|
+
preparation: { firstKeptEntryId: "fallback", tokensBefore: 100 },
|
|
285
|
+
settings: { ...DEFAULT_VCC_SETTINGS, overrideDefaultCompaction: false },
|
|
286
|
+
phaseContext: { phase: "planning" },
|
|
287
|
+
});
|
|
288
|
+
assert.deepEqual(overrideDisabled, { kind: "fallback", reason: "override-disabled" });
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("parses manual keep/follow-up instructions and gates auto-continue by version", () => {
|
|
292
|
+
assert.deepEqual(parseCompactionInstructions("pi-plans execution auto compact"), {
|
|
293
|
+
isPiVcc: false,
|
|
294
|
+
isInternalPiPlans: true,
|
|
295
|
+
keepUserTurns: 1,
|
|
296
|
+
keepUserTurnsExplicit: false,
|
|
297
|
+
followUpPrompt: null,
|
|
298
|
+
});
|
|
299
|
+
assert.deepEqual(parseCompactionInstructions("keep:2 Continue with tests"), {
|
|
300
|
+
isPiVcc: false,
|
|
301
|
+
isInternalPiPlans: false,
|
|
302
|
+
keepUserTurns: 2,
|
|
303
|
+
keepUserTurnsExplicit: true,
|
|
304
|
+
followUpPrompt: "Continue with tests",
|
|
305
|
+
});
|
|
306
|
+
assert.deepEqual(parseCompactionInstructions(`${PI_VCC_COMPACT_INSTRUCTION} keep:3`), {
|
|
307
|
+
isPiVcc: true,
|
|
308
|
+
isInternalPiPlans: false,
|
|
309
|
+
keepUserTurns: 3,
|
|
310
|
+
keepUserTurnsExplicit: true,
|
|
311
|
+
followUpPrompt: null,
|
|
312
|
+
});
|
|
313
|
+
assert.deepEqual(parseCompactionInstructions("Continue with tests keep:2"), {
|
|
314
|
+
isPiVcc: false,
|
|
315
|
+
isInternalPiPlans: false,
|
|
316
|
+
keepUserTurns: 2,
|
|
317
|
+
keepUserTurnsExplicit: true,
|
|
318
|
+
followUpPrompt: "Continue with tests",
|
|
319
|
+
});
|
|
320
|
+
assert.equal(shouldScheduleAutoContinue(true, "0.84.3"), true);
|
|
321
|
+
assert.equal(shouldScheduleAutoContinue(true, "0.84.4"), false);
|
|
322
|
+
assert.equal(shouldScheduleAutoContinue(false, "0.84.3"), false);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("scaffolds and loads repo-private VCC settings", () => {
|
|
326
|
+
const stateRoot = path.join(tmpRoot, "state");
|
|
327
|
+
scaffoldVccSettings(stateRoot);
|
|
328
|
+
assert.equal(fs.existsSync(vccSettingsPath(stateRoot)), true);
|
|
329
|
+
assert.deepEqual(loadVccSettings(stateRoot), DEFAULT_VCC_SETTINGS);
|
|
330
|
+
|
|
331
|
+
fs.writeFileSync(vccSettingsPath(stateRoot), JSON.stringify({ overrideDefaultCompaction: false }), "utf8");
|
|
332
|
+
scaffoldVccSettings(stateRoot);
|
|
333
|
+
assert.deepEqual(loadVccSettings(stateRoot), {
|
|
334
|
+
...DEFAULT_VCC_SETTINGS,
|
|
335
|
+
overrideDefaultCompaction: false,
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
const invalidRoot = path.join(tmpRoot, "invalid-state");
|
|
339
|
+
fs.mkdirSync(invalidRoot, { recursive: true });
|
|
340
|
+
const invalidPath = vccSettingsPath(invalidRoot);
|
|
341
|
+
fs.writeFileSync(invalidPath, "{ invalid json", "utf8");
|
|
342
|
+
scaffoldVccSettings(invalidRoot);
|
|
343
|
+
assert.equal(fs.readFileSync(invalidPath, "utf8"), "{ invalid json");
|
|
344
|
+
assert.deepEqual(loadVccSettings(invalidRoot), DEFAULT_VCC_SETTINGS);
|
|
345
|
+
|
|
346
|
+
const envRoot = path.join(tmpRoot, "env-state");
|
|
347
|
+
const envConfig = path.join(tmpRoot, "external-pi-vcc-config.json");
|
|
348
|
+
const previousEnv = process.env.PI_VCC_CONFIG_PATH;
|
|
349
|
+
try {
|
|
350
|
+
process.env.PI_VCC_CONFIG_PATH = envConfig;
|
|
351
|
+
fs.writeFileSync(envConfig, JSON.stringify({ smartKeepTail: false, continueAfterThresholdCompact: false }), "utf8");
|
|
352
|
+
assert.deepEqual(loadVccSettings(envRoot), DEFAULT_VCC_SETTINGS);
|
|
353
|
+
} finally {
|
|
354
|
+
if (previousEnv === undefined) delete process.env.PI_VCC_CONFIG_PATH;
|
|
355
|
+
else process.env.PI_VCC_CONFIG_PATH = previousEnv;
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it("writes debug snapshots only when enabled", () => {
|
|
360
|
+
const debugPath = "/tmp/pi-vcc-debug.json";
|
|
361
|
+
const previous = fs.existsSync(debugPath) ? fs.readFileSync(debugPath, "utf8") : null;
|
|
362
|
+
try {
|
|
363
|
+
fs.rmSync(debugPath, { force: true });
|
|
364
|
+
const event = {
|
|
365
|
+
branchEntries: [
|
|
366
|
+
textEntry("u-1", "user", "implement debug test"),
|
|
367
|
+
textEntry("a-1", "assistant", "working"),
|
|
368
|
+
textEntry("u-2", "user", "continue"),
|
|
369
|
+
textEntry("a-2", "assistant", "tail"),
|
|
370
|
+
],
|
|
371
|
+
preparation: { tokensBefore: 10_000 },
|
|
372
|
+
reason: "threshold" as const,
|
|
373
|
+
willRetry: false,
|
|
374
|
+
phaseContext: { phase: "planning" as const },
|
|
375
|
+
};
|
|
376
|
+
buildPiPlansVccCompaction({ ...event, settings: DEFAULT_VCC_SETTINGS });
|
|
377
|
+
assert.equal(fs.existsSync(debugPath), false);
|
|
378
|
+
|
|
379
|
+
buildPiPlansVccCompaction({ ...event, settings: { ...DEFAULT_VCC_SETTINGS, debug: true } });
|
|
380
|
+
const debug = JSON.parse(fs.readFileSync(debugPath, "utf8"));
|
|
381
|
+
assert.equal(debug.usedOwnCut, true);
|
|
382
|
+
assert.equal(debug.phase, "planning");
|
|
383
|
+
} finally {
|
|
384
|
+
if (previous === null) fs.rmSync(debugPath, { force: true });
|
|
385
|
+
else fs.writeFileSync(debugPath, previous, "utf8");
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
});
|
|
@@ -0,0 +1,255 @@
|
|
|
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 * as url from "node:url";
|
|
6
|
+
import { after, before, describe, it } from "node:test";
|
|
7
|
+
|
|
8
|
+
import { configPiPlansCommand } from "../src/config-command.ts";
|
|
9
|
+
import { startRun } from "../src/state.ts";
|
|
10
|
+
|
|
11
|
+
const ROOT = path.dirname(path.dirname(url.fileURLToPath(import.meta.url)));
|
|
12
|
+
|
|
13
|
+
interface Recorded {
|
|
14
|
+
selects: Array<{ question: string; labels: string[] }>;
|
|
15
|
+
inputs: Array<{ prompt: string }>;
|
|
16
|
+
notifies: Array<{ message: string; severity: string }>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface HarnessOptions {
|
|
20
|
+
select: (question: string, labels: string[]) => string | undefined | Promise<string | undefined>;
|
|
21
|
+
input?: (prompt: string) => string | undefined | Promise<string | undefined>;
|
|
22
|
+
model?: { provider: string; id: string } | null;
|
|
23
|
+
scopedModels?: Array<{ model: { provider: string; id: string } }>;
|
|
24
|
+
availableModels?: Array<{ provider: string; id: string }>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let root: string;
|
|
28
|
+
|
|
29
|
+
before(() => {
|
|
30
|
+
root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-config-command-"));
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
after(() => {
|
|
34
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
function workdir(name: string): string {
|
|
38
|
+
const dir = path.join(root, name);
|
|
39
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
40
|
+
return dir;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function configPath(workdirPath: string): string {
|
|
44
|
+
return path.join(workdirPath, ".git", "pi_plans", "config.json");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function runPath(workdirPath: string, runId: string): string {
|
|
48
|
+
return path.join(workdirPath, ".git", "pi_plans", "runs", runId, "run.json");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function readJson(filePath: string): any {
|
|
52
|
+
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function makeContext(workdirPath: string, options: HarnessOptions) {
|
|
56
|
+
const recorded: Recorded = {
|
|
57
|
+
selects: [],
|
|
58
|
+
inputs: [],
|
|
59
|
+
notifies: [],
|
|
60
|
+
};
|
|
61
|
+
const ctx = {
|
|
62
|
+
cwd: workdirPath,
|
|
63
|
+
hasUI: true,
|
|
64
|
+
model: options.model ?? null,
|
|
65
|
+
scopedModels: options.scopedModels ?? [],
|
|
66
|
+
modelRegistry: {
|
|
67
|
+
getAvailable: () => options.availableModels ?? [],
|
|
68
|
+
},
|
|
69
|
+
ui: {
|
|
70
|
+
notify: (message: string, severity: string) => {
|
|
71
|
+
recorded.notifies.push({ message, severity });
|
|
72
|
+
},
|
|
73
|
+
select: async (question: string, labels: string[]) => {
|
|
74
|
+
recorded.selects.push({ question, labels });
|
|
75
|
+
return options.select(question, labels);
|
|
76
|
+
},
|
|
77
|
+
input: async (prompt: string) => {
|
|
78
|
+
recorded.inputs.push({ prompt });
|
|
79
|
+
return options.input?.(prompt);
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
} as unknown as Parameters<typeof configPiPlansCommand>[1];
|
|
83
|
+
return { ctx, recorded };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
describe("config-pi-plans command", () => {
|
|
87
|
+
it("registers the slash command in index.ts and delegates to the helper", () => {
|
|
88
|
+
const source = fs.readFileSync(path.join(ROOT, "index.ts"), "utf8");
|
|
89
|
+
assert.match(source, /import \{ configPiPlansCommand \} from "\.\/src\/config-command\.ts";/);
|
|
90
|
+
assert.match(source, /registerCommand\("config-pi-plans"/);
|
|
91
|
+
assert.match(source, /await configPiPlansCommand\(args, ctx\);/);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("writes the full wizard into config.json", async () => {
|
|
95
|
+
const dir = workdir("full-wizard");
|
|
96
|
+
const { ctx } = makeContext(dir, {
|
|
97
|
+
model: { provider: "live", id: "pro" },
|
|
98
|
+
availableModels: [{ provider: "registry", id: "critic" }],
|
|
99
|
+
select: (question, labels) => {
|
|
100
|
+
if (question === "Language?") return labels.find((label) => label.includes("en"));
|
|
101
|
+
if (question === "Artifact root?") return labels.find((label) => label.includes("./.git/pi_plans/plans"));
|
|
102
|
+
if (question === "Code graph?") return labels.find((label) => label.includes("Disable code graph"));
|
|
103
|
+
if (question === "Reviewer mode?") return labels.find((label) => label.includes("Switch to current-session"));
|
|
104
|
+
if (question === "Reviewer model?") return labels.find((label) => label.includes("Use current session model (live/pro)"));
|
|
105
|
+
if (question === "Criticizer mode?") return labels.find((label) => label.includes("Keep delegated-subagent"));
|
|
106
|
+
if (question === "Criticizer model?") return labels.find((label) => label.includes("Other..."));
|
|
107
|
+
return undefined;
|
|
108
|
+
},
|
|
109
|
+
input: (prompt) => {
|
|
110
|
+
if (prompt === "Criticizer model selector:") return "custom/critic-model";
|
|
111
|
+
return undefined;
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
await configPiPlansCommand("", ctx);
|
|
116
|
+
|
|
117
|
+
assert.ok(fs.existsSync(configPath(dir)));
|
|
118
|
+
const config = readJson(configPath(dir));
|
|
119
|
+
assert.equal(config.language.tag, "en");
|
|
120
|
+
assert.equal(config.language.source, "user");
|
|
121
|
+
assert.equal(config.artifact_root, "./.git/pi_plans/plans");
|
|
122
|
+
assert.equal(config.artifact_root_source, "user");
|
|
123
|
+
assert.equal(config.graph_enabled, false);
|
|
124
|
+
assert.equal(config.reviewer.mode, "current-session");
|
|
125
|
+
assert.equal(config.reviewer.model_selector, "live/pro");
|
|
126
|
+
assert.equal(config.criticizer.mode, "delegated-subagent");
|
|
127
|
+
assert.equal(config.criticizer.model_selector, "custom/critic-model");
|
|
128
|
+
assert.ok(config.reviewer.confirmed_at);
|
|
129
|
+
assert.ok(config.criticizer.confirmed_at);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("dedupes model choices and falls back to the live model when no default is stored", async () => {
|
|
133
|
+
const dir = workdir("model-menu");
|
|
134
|
+
const { ctx, recorded } = makeContext(dir, {
|
|
135
|
+
model: { provider: "live", id: "pro" },
|
|
136
|
+
scopedModels: [{ model: { provider: "scoped", id: "model-a" } }],
|
|
137
|
+
availableModels: [
|
|
138
|
+
{ provider: "scoped", id: "model-a" },
|
|
139
|
+
{ provider: "registry", id: "model-b" },
|
|
140
|
+
],
|
|
141
|
+
select: (question, labels) => {
|
|
142
|
+
if (question === "Language?") return labels[0];
|
|
143
|
+
if (question === "Artifact root?") return labels[0];
|
|
144
|
+
if (question === "Code graph?") return labels[0];
|
|
145
|
+
if (question === "Reviewer mode?") return labels[0];
|
|
146
|
+
if (question === "Reviewer model?") return labels[0];
|
|
147
|
+
if (question === "Criticizer mode?") return undefined;
|
|
148
|
+
return undefined;
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
await configPiPlansCommand("", ctx);
|
|
153
|
+
|
|
154
|
+
const reviewerModelPrompt = recorded.selects.find((entry) => entry.question === "Reviewer model?");
|
|
155
|
+
assert.ok(reviewerModelPrompt);
|
|
156
|
+
assert.deepEqual(reviewerModelPrompt?.labels, [
|
|
157
|
+
"1. Keep current default (inherit live model: live/pro)",
|
|
158
|
+
"2. Use current session model (live/pro)",
|
|
159
|
+
"3. Use available model (scoped/model-a)",
|
|
160
|
+
"4. Use available model (registry/model-b)",
|
|
161
|
+
"5. Other...",
|
|
162
|
+
]);
|
|
163
|
+
assert.equal(fs.existsSync(configPath(dir)), false);
|
|
164
|
+
assert.equal(recorded.notifies.at(-1)?.severity, "warning");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("rejects an invalid model selector entered through Other and leaves config untouched", async () => {
|
|
168
|
+
const dir = workdir("invalid-model");
|
|
169
|
+
const { ctx, recorded } = makeContext(dir, {
|
|
170
|
+
select: (question, labels) => {
|
|
171
|
+
if (question === "Language?") return labels[0];
|
|
172
|
+
if (question === "Artifact root?") return labels[0];
|
|
173
|
+
if (question === "Code graph?") return labels[0];
|
|
174
|
+
if (question === "Reviewer mode?") return labels[0];
|
|
175
|
+
if (question === "Reviewer model?") return labels.find((label) => label.includes("Other..."));
|
|
176
|
+
return undefined;
|
|
177
|
+
},
|
|
178
|
+
input: () => "bad selector",
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
await configPiPlansCommand("", ctx);
|
|
182
|
+
|
|
183
|
+
assert.equal(fs.existsSync(configPath(dir)), false);
|
|
184
|
+
assert.equal(recorded.notifies.some((entry) => entry.message.includes("Model selector must be an exact provider/model string.")), true);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("does not write when the wizard is cancelled", async () => {
|
|
188
|
+
const dir = workdir("cancelled");
|
|
189
|
+
const { ctx } = makeContext(dir, {
|
|
190
|
+
select: (question, labels) => {
|
|
191
|
+
if (question === "Language?") return undefined;
|
|
192
|
+
return labels[0];
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
await configPiPlansCommand("", ctx);
|
|
197
|
+
|
|
198
|
+
assert.equal(fs.existsSync(configPath(dir)), false);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("renders a two-option inherit menu when no model sources exist", async () => {
|
|
202
|
+
const dir = workdir("empty-model-menu");
|
|
203
|
+
const { ctx, recorded } = makeContext(dir, {
|
|
204
|
+
model: null,
|
|
205
|
+
scopedModels: [],
|
|
206
|
+
availableModels: [],
|
|
207
|
+
select: (question, labels) => {
|
|
208
|
+
if (question === "Language?") return labels[0];
|
|
209
|
+
if (question === "Artifact root?") return labels[0];
|
|
210
|
+
if (question === "Code graph?") return labels[0];
|
|
211
|
+
if (question === "Reviewer mode?") return labels[0];
|
|
212
|
+
if (question === "Reviewer model?") return labels[0];
|
|
213
|
+
return undefined;
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
await configPiPlansCommand("", ctx);
|
|
218
|
+
|
|
219
|
+
const reviewerModelPrompt = recorded.selects.find((entry) => entry.question === "Reviewer model?");
|
|
220
|
+
assert.ok(reviewerModelPrompt);
|
|
221
|
+
assert.deepEqual(reviewerModelPrompt?.labels, [
|
|
222
|
+
"1. Keep current default (inherit)",
|
|
223
|
+
"2. Other...",
|
|
224
|
+
]);
|
|
225
|
+
assert.equal(fs.existsSync(configPath(dir)), false);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("keeps the active run snapshot unchanged", async () => {
|
|
229
|
+
const dir = workdir("active-run");
|
|
230
|
+
const { run } = startRun(dir, { topic: "config", skill: "plan-small", requestText: "x" });
|
|
231
|
+
const before = readJson(runPath(dir, run.run_id));
|
|
232
|
+
const { ctx } = makeContext(dir, {
|
|
233
|
+
select: (question, labels) => {
|
|
234
|
+
if (question === "Language?") return labels.find((label) => label.includes("en"));
|
|
235
|
+
if (question === "Artifact root?") return labels.find((label) => label.includes("./.git/pi_plans/plans"));
|
|
236
|
+
if (question === "Code graph?") return labels.find((label) => label.includes("Enable code graph"));
|
|
237
|
+
if (question === "Reviewer mode?") return labels.find((label) => label.includes("Keep delegated-subagent"));
|
|
238
|
+
if (question === "Reviewer model?") return labels[0];
|
|
239
|
+
if (question === "Criticizer mode?") return labels.find((label) => label.includes("Keep delegated-subagent"));
|
|
240
|
+
if (question === "Criticizer model?") return labels[0];
|
|
241
|
+
return undefined;
|
|
242
|
+
},
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
await configPiPlansCommand("", ctx);
|
|
246
|
+
|
|
247
|
+
const after = readJson(runPath(dir, run.run_id));
|
|
248
|
+
assert.deepEqual(after, before);
|
|
249
|
+
const config = readJson(configPath(dir));
|
|
250
|
+
assert.equal(config.language.tag, "en");
|
|
251
|
+
assert.equal(config.artifact_root, "./.git/pi_plans/plans");
|
|
252
|
+
assert.equal(config.reviewer.model_selector, null);
|
|
253
|
+
assert.ok(config.reviewer.confirmed_at);
|
|
254
|
+
});
|
|
255
|
+
});
|