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,263 @@
|
|
|
1
|
+
/** Behavior tests for the ask_choice trailing option (post-execution
|
|
2
|
+
* amelioration prompt): Auto-refine loop replaces Auto-complete, the
|
|
3
|
+
* auto-answer mode is suppressed, and headless sessions stop instead of
|
|
4
|
+
* auto-answering. */
|
|
5
|
+
|
|
6
|
+
import * as assert from "node:assert/strict";
|
|
7
|
+
import * as fs from "node:fs";
|
|
8
|
+
import * as os from "node:os";
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
import { after, before, describe, it } from "node:test";
|
|
11
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import { fitAskChoicePanel, OPTION_MAX_LINES, PANEL_CHROME_LINES, PANEL_SAFETY_MARGIN, SELECTOR_WIDTH_OVERHEAD, STATUS_BAR_HEIGHT, registerAskChoiceTool } from "../tools/ask-choice.ts";
|
|
13
|
+
import { visibleWidth } from "../src/refine-ui-helpers.ts";
|
|
14
|
+
|
|
15
|
+
type ToolDef = {
|
|
16
|
+
execute: (id: string, params: any, signal: undefined, update: undefined, ctx: any) => Promise<any>;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
let root: string;
|
|
20
|
+
|
|
21
|
+
before(() => {
|
|
22
|
+
root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-ask-choice-"));
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
after(() => {
|
|
26
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
function loadTool(): ToolDef {
|
|
30
|
+
let tool: ToolDef | undefined;
|
|
31
|
+
const pi = {
|
|
32
|
+
registerTool: (definition: ToolDef) => {
|
|
33
|
+
tool = definition;
|
|
34
|
+
},
|
|
35
|
+
} as unknown as ExtensionAPI;
|
|
36
|
+
registerAskChoiceTool(pi);
|
|
37
|
+
if (!tool) throw new Error("ask_choice tool not registered");
|
|
38
|
+
return tool;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let notifyCalls: Array<{ message: string; severity?: string }> = [];
|
|
42
|
+
|
|
43
|
+
function makeCtx(opts: {
|
|
44
|
+
hasUI?: boolean;
|
|
45
|
+
select: (question: string, labels: string[]) => Promise<string | undefined>;
|
|
46
|
+
}) {
|
|
47
|
+
notifyCalls = [];
|
|
48
|
+
return {
|
|
49
|
+
cwd: root,
|
|
50
|
+
hasUI: opts.hasUI ?? true,
|
|
51
|
+
sessionManager: {},
|
|
52
|
+
ui: {
|
|
53
|
+
select: opts.select,
|
|
54
|
+
input: async () => "typed",
|
|
55
|
+
notify: (message: string, severity?: string) => {
|
|
56
|
+
notifyCalls.push({ message, severity });
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const OPTIONS = [
|
|
63
|
+
{ label: "Run one Reviewer round", recommended: true },
|
|
64
|
+
{ label: "Run a Criticizer round" },
|
|
65
|
+
{ label: "Finish here" },
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
describe("ask_choice trailing option", () => {
|
|
69
|
+
it("renders Auto-refine loop as the trailing option and suppresses Auto-complete", async () => {
|
|
70
|
+
const tool = loadTool();
|
|
71
|
+
let seenLabels: string[] = [];
|
|
72
|
+
const ctx = makeCtx({
|
|
73
|
+
select: async (_question, labels) => {
|
|
74
|
+
seenLabels = labels;
|
|
75
|
+
return labels.find((label) => label.startsWith("Auto-refine loop"));
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
const result = await tool.execute("t1", {
|
|
79
|
+
question: "Ameliorate?",
|
|
80
|
+
options: OPTIONS,
|
|
81
|
+
autoComplete: true, // deliberately erroneous: trailing must suppress it
|
|
82
|
+
trailing: "auto-refine-loop",
|
|
83
|
+
}, undefined, undefined, ctx);
|
|
84
|
+
|
|
85
|
+
assert.equal(seenLabels.at(-1)?.startsWith("Auto-refine loop"), true, "Auto-refine loop must be last");
|
|
86
|
+
assert.equal(seenLabels.some((label) => label.startsWith("Auto-complete")), false, "Auto-complete must be absent");
|
|
87
|
+
const text = result.content[0].text as string;
|
|
88
|
+
assert.match(text, /User selected Auto-refine loop/);
|
|
89
|
+
assert.match(text, /until no high-severity finding \(hard cap 5 rounds\)/);
|
|
90
|
+
assert.match(text, /refine \(role: "reviewer", target: "implementation"\)/);
|
|
91
|
+
assert.equal(result.details.source, "user");
|
|
92
|
+
assert.equal(result.details.answer, "Auto-refine loop");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("keeps Auto-complete as the trailing option without the trailing param", async () => {
|
|
96
|
+
const tool = loadTool();
|
|
97
|
+
let seenLabels: string[] = [];
|
|
98
|
+
const ctx = makeCtx({
|
|
99
|
+
select: async (_question, labels) => {
|
|
100
|
+
seenLabels = labels;
|
|
101
|
+
return labels[0];
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
await tool.execute("t2", {
|
|
105
|
+
question: "Planning question?",
|
|
106
|
+
options: OPTIONS,
|
|
107
|
+
autoComplete: true,
|
|
108
|
+
}, undefined, undefined, ctx);
|
|
109
|
+
assert.equal(seenLabels.at(-1)?.startsWith("Auto-complete"), true);
|
|
110
|
+
assert.equal(seenLabels.some((label) => label.startsWith("Auto-refine loop")), false);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("never auto-answers a trailing question in headless sessions", async () => {
|
|
114
|
+
const tool = loadTool();
|
|
115
|
+
const ctx = makeCtx({ hasUI: false, select: async () => undefined });
|
|
116
|
+
await assert.rejects(
|
|
117
|
+
tool.execute("t3", {
|
|
118
|
+
question: "Ameliorate?",
|
|
119
|
+
options: OPTIONS,
|
|
120
|
+
autoComplete: true,
|
|
121
|
+
trailing: "auto-refine-loop",
|
|
122
|
+
}, undefined, undefined, ctx),
|
|
123
|
+
/No UI available/,
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe("ask_choice panel fitting", () => {
|
|
129
|
+
it("sanitizes newlines in the question and labels", () => {
|
|
130
|
+
const fitted = fitAskChoicePanel("Q line1\nQ line2", [{ core: "1. a\nb", display: "1. a\nb — desc" }], 100, 30);
|
|
131
|
+
assert.doesNotMatch(fitted.question, /\r?\n/);
|
|
132
|
+
for (const label of fitted.labels) assert.doesNotMatch(label, /\r?\n/);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("caps each label at the 3-line budget with a .. marker (CJK included)", () => {
|
|
136
|
+
const columns = 100;
|
|
137
|
+
const budget = OPTION_MAX_LINES * Math.max(20, columns - SELECTOR_WIDTH_OVERHEAD);
|
|
138
|
+
const cjkLabel = `1. ${"选项".repeat(200)}`; // 800 visible columns, far over the budget
|
|
139
|
+
const fitted = fitAskChoicePanel("q", [{ core: cjkLabel, display: cjkLabel }], columns, 60);
|
|
140
|
+
assert.equal(fitted.labels.length, 1);
|
|
141
|
+
assert.ok(visibleWidth(fitted.labels[0]) <= budget, `label exceeds 3-line budget: ${visibleWidth(fitted.labels[0])}`);
|
|
142
|
+
assert.match(fitted.labels[0], /\.\.$/);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("degrades: stage 1 strips descriptions before touching labels", () => {
|
|
146
|
+
const columns = 100;
|
|
147
|
+
const rows = 30; // rowBudget = 27 → content budget 17 lines after chrome
|
|
148
|
+
const items = Array.from({ length: 12 }, (_, i) => ({
|
|
149
|
+
core: `${i + 1}. option-${i}`,
|
|
150
|
+
display: `${i + 1}. option-${i} — ${"detail ".repeat(40)}`,
|
|
151
|
+
}));
|
|
152
|
+
const fitted = fitAskChoicePanel("q", items, columns, rows);
|
|
153
|
+
for (const label of fitted.labels) assert.doesNotMatch(label, /detail/);
|
|
154
|
+
assert.match(fitted.labels[0] ?? "", /^1\. option-0/);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("degrades: stage 2 collapses labels to one line before truncating the question", () => {
|
|
158
|
+
const columns = 100;
|
|
159
|
+
const rows = 30;
|
|
160
|
+
const items = Array.from({ length: 15 }, (_, i) => ({
|
|
161
|
+
core: `${i + 1}. ${"x".repeat(170)}`,
|
|
162
|
+
display: `${i + 1}. ${"x".repeat(170)} — description`,
|
|
163
|
+
}));
|
|
164
|
+
const fitted = fitAskChoicePanel("the question", items, columns, rows);
|
|
165
|
+
for (const label of fitted.labels) assert.ok(visibleWidth(label) <= Math.max(20, columns - SELECTOR_WIDTH_OVERHEAD));
|
|
166
|
+
assert.equal(fitted.question, "the question"); // question untouched at stage 2
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("warns once when even the minimal form exceeds a tiny terminal", () => {
|
|
170
|
+
const columns = 60;
|
|
171
|
+
const rows = 12; // rowBudget = 9; 30 options can never fit
|
|
172
|
+
const items = Array.from({ length: 30 }, (_, i) => ({ core: `${i + 1}. opt-${i}`, display: `${i + 1}. opt-${i}` }));
|
|
173
|
+
const fitted = fitAskChoicePanel("q", items, columns, rows);
|
|
174
|
+
assert.equal(fitted.overflowWarned, true);
|
|
175
|
+
for (const label of fitted.labels) assert.ok(visibleWidth(label) <= 20);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("degrades: stage 3 truncates the question only after labels are single-lined", () => {
|
|
179
|
+
const columns = 100;
|
|
180
|
+
const rows = 32; // rowBudget = 29; chrome 9 → content budget 20 lines
|
|
181
|
+
const longQuestion = "q".repeat(300);
|
|
182
|
+
const items = Array.from({ length: 18 }, (_, i) => ({ core: `${i + 1}. opt-${i}`, display: `${i + 1}. opt-${i}` }));
|
|
183
|
+
const fitted = fitAskChoicePanel(longQuestion, items, columns, rows);
|
|
184
|
+
assert.equal(fitted.overflowWarned, false); // stops at stage 3, not the minimal form
|
|
185
|
+
assert.ok(visibleWidth(fitted.question) <= Math.max(20, columns - SELECTOR_WIDTH_OVERHEAD));
|
|
186
|
+
assert.match(fitted.question, /\.\.$/);
|
|
187
|
+
assert.match(fitted.labels[0] ?? "", /^1\. opt-0$/); // labels stay single-line and untruncated
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("keeps Other/Auto-complete/Auto-refine prefixes alive even on tiny terminals", () => {
|
|
191
|
+
const columns = 26;
|
|
192
|
+
const rows = 12; // minimal form: 20-column floor
|
|
193
|
+
const items = [
|
|
194
|
+
{ core: "1. keep-current", display: "1. keep-current" },
|
|
195
|
+
{ core: "Other… (type your own answer)", display: "Other… (type your own answer)", fixed: true },
|
|
196
|
+
{ core: "Auto-complete (take the recommended option)", display: "Auto-complete (take the recommended option)", fixed: true },
|
|
197
|
+
{ core: "Auto-refine loop (run refinement rounds until no high-severity finding or the 5-round cap)", display: "Auto-refine loop (run refinement rounds until no high-severity finding or the 5-round cap)", fixed: true },
|
|
198
|
+
];
|
|
199
|
+
const fitted = fitAskChoicePanel("q", items, columns, rows);
|
|
200
|
+
assert.match(fitted.labels[1] ?? "", /^Other…/);
|
|
201
|
+
assert.match(fitted.labels[2] ?? "", /^Auto-complete/);
|
|
202
|
+
assert.match(fitted.labels[3] ?? "", /^Auto-refine loop/);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("execute notifies once when the minimal form still overflows a tiny terminal", async () => {
|
|
206
|
+
const tool = loadTool();
|
|
207
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-ask-choice-overflow-"));
|
|
208
|
+
const rowsDesc = Object.getOwnPropertyDescriptor(process.stdout, "rows");
|
|
209
|
+
const colsDesc = Object.getOwnPropertyDescriptor(process.stdout, "columns");
|
|
210
|
+
Object.defineProperty(process.stdout, "rows", { value: 12, configurable: true });
|
|
211
|
+
Object.defineProperty(process.stdout, "columns", { value: 60, configurable: true });
|
|
212
|
+
try {
|
|
213
|
+
const ctx = makeCtx({
|
|
214
|
+
select: async (_question, labels) => labels[0],
|
|
215
|
+
});
|
|
216
|
+
// makeCtx binds cwd at construction; point it at the temp dir via workdir param instead.
|
|
217
|
+
await tool.execute(
|
|
218
|
+
"id",
|
|
219
|
+
{
|
|
220
|
+
question: "Pick one",
|
|
221
|
+
options: Array.from({ length: 30 }, (_, i) => ({ label: `opt-${i}`, description: `${"detail ".repeat(20)}` })),
|
|
222
|
+
autoComplete: false,
|
|
223
|
+
allowOther: false,
|
|
224
|
+
workdir: dir,
|
|
225
|
+
},
|
|
226
|
+
undefined,
|
|
227
|
+
undefined,
|
|
228
|
+
ctx,
|
|
229
|
+
);
|
|
230
|
+
assert.equal(notifyCalls.length, 1);
|
|
231
|
+
assert.match(notifyCalls[0]!.message, /Terminal too small/);
|
|
232
|
+
} finally {
|
|
233
|
+
if (rowsDesc) Object.defineProperty(process.stdout, "rows", rowsDesc);
|
|
234
|
+
if (colsDesc) Object.defineProperty(process.stdout, "columns", colsDesc);
|
|
235
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("pi-tui Text renders every fitted label within 3 lines at real panel width", async () => {
|
|
240
|
+
let Text: any;
|
|
241
|
+
try {
|
|
242
|
+
({ Text } = await import("@earendil-works/pi-tui"));
|
|
243
|
+
} catch {
|
|
244
|
+
return; // D-007 sanctioned fallback: pi-tui not resolvable in this layout
|
|
245
|
+
}
|
|
246
|
+
const columns = 100;
|
|
247
|
+
const rows = 60;
|
|
248
|
+
const realWidth = columns - 4; // border + padding + marker, per extension-selector.js
|
|
249
|
+
const items = Array.from({ length: 8 }, (_, i) => ({
|
|
250
|
+
core: `${i + 1}. option-${i}`,
|
|
251
|
+
display: `${i + 1}. option-${i} — ${"中文描述 " .repeat(30)}mixed ascii tail ${"x".repeat(60)}`,
|
|
252
|
+
}));
|
|
253
|
+
const fitted = fitAskChoicePanel("q", items, columns, rows);
|
|
254
|
+
let totalLines = PANEL_CHROME_LINES + 1; // chrome + question line
|
|
255
|
+
for (const label of fitted.labels) {
|
|
256
|
+
const component = new Text(label, 1, 0);
|
|
257
|
+
const rendered = component.render(realWidth);
|
|
258
|
+
assert.ok(rendered.length <= OPTION_MAX_LINES, `label renders ${rendered.length} lines: ${JSON.stringify(label.slice(0, 60))}`);
|
|
259
|
+
totalLines += rendered.length;
|
|
260
|
+
}
|
|
261
|
+
assert.ok(totalLines < rows - STATUS_BAR_HEIGHT - PANEL_SAFETY_MARGIN);
|
|
262
|
+
});
|
|
263
|
+
});
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/** Tests for run-scoped Auto-complete and planning continuation. */
|
|
2
|
+
|
|
3
|
+
import * as assert from "node:assert/strict";
|
|
4
|
+
import * as fs from "node:fs";
|
|
5
|
+
import * as os from "node:os";
|
|
6
|
+
import * as path from "node:path";
|
|
7
|
+
import { after, before, describe, it } from "node:test";
|
|
8
|
+
import {
|
|
9
|
+
autoCompleteStatus,
|
|
10
|
+
disableAutoComplete,
|
|
11
|
+
enableAutoComplete,
|
|
12
|
+
isAutoCompleteEnabled,
|
|
13
|
+
markPlanWritten,
|
|
14
|
+
recordAskChoice,
|
|
15
|
+
registerAutoCompleteTurnHandlers,
|
|
16
|
+
restoreAutoCompleteFromSession,
|
|
17
|
+
setAutoCompleteApi,
|
|
18
|
+
} from "../src/autocomplete.ts";
|
|
19
|
+
import { initState, setRunStatus, startRun } from "../src/state.ts";
|
|
20
|
+
|
|
21
|
+
let root: string;
|
|
22
|
+
|
|
23
|
+
function makeRun(name: string): { workdir: string; runId: string } {
|
|
24
|
+
const workdir = path.join(root, name);
|
|
25
|
+
fs.mkdirSync(workdir, { recursive: true });
|
|
26
|
+
initState(workdir);
|
|
27
|
+
const { run } = startRun(workdir, { topic: name, skill: "plan-small", requestText: "test" });
|
|
28
|
+
return { workdir, runId: run.run_id };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function makeContext(workdir: string, sessionManager: any = {}): any {
|
|
32
|
+
return {
|
|
33
|
+
cwd: workdir,
|
|
34
|
+
hasUI: true,
|
|
35
|
+
mode: "tui",
|
|
36
|
+
sessionManager,
|
|
37
|
+
ui: {
|
|
38
|
+
select: async (_question: string, options: string[]) => options.at(-1),
|
|
39
|
+
input: async () => "typed",
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
before(() => {
|
|
45
|
+
root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-autocomplete-"));
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
after(() => {
|
|
49
|
+
setAutoCompleteApi(null);
|
|
50
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("Auto-complete state", () => {
|
|
54
|
+
it("enables only for the active planning run and restores only while planning", () => {
|
|
55
|
+
const { workdir, runId } = makeRun("restore");
|
|
56
|
+
const entries: any[] = [];
|
|
57
|
+
setAutoCompleteApi({ appendEntry: (customType: string, data: unknown) => entries.push({ type: "custom", customType, data }) } as any);
|
|
58
|
+
const ctx = makeContext(workdir);
|
|
59
|
+
|
|
60
|
+
assert.equal(enableAutoComplete(ctx), true);
|
|
61
|
+
assert.equal(autoCompleteStatus(ctx), "enabled");
|
|
62
|
+
assert.equal(entries.at(-1)?.data.runId, runId);
|
|
63
|
+
|
|
64
|
+
const restored = makeContext(workdir);
|
|
65
|
+
restoreAutoCompleteFromSession(restored, entries);
|
|
66
|
+
assert.equal(isAutoCompleteEnabled(restored), true);
|
|
67
|
+
|
|
68
|
+
setRunStatus(workdir, runId, "done");
|
|
69
|
+
const finished = makeContext(workdir);
|
|
70
|
+
restoreAutoCompleteFromSession(finished, entries);
|
|
71
|
+
assert.equal(autoCompleteStatus(finished), "disabled");
|
|
72
|
+
|
|
73
|
+
const stoppedEntries = [...entries, { type: "custom", customType: "pi-plans-autocomplete", data: { runId, enabled: false } }];
|
|
74
|
+
const stopped = makeContext(workdir);
|
|
75
|
+
restoreAutoCompleteFromSession(stopped, stoppedEntries);
|
|
76
|
+
assert.equal(autoCompleteStatus(stopped), "disabled");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("clears explicitly and marks plan writes as a continuation boundary", () => {
|
|
80
|
+
const { workdir } = makeRun("clear");
|
|
81
|
+
const entries: any[] = [];
|
|
82
|
+
setAutoCompleteApi({ appendEntry: (customType: string, data: unknown) => entries.push({ customType, data }) } as any);
|
|
83
|
+
const ctx = makeContext(workdir);
|
|
84
|
+
enableAutoComplete(ctx);
|
|
85
|
+
markPlanWritten(ctx);
|
|
86
|
+
recordAskChoice(ctx, true);
|
|
87
|
+
assert.equal(disableAutoComplete(ctx, "test"), true);
|
|
88
|
+
assert.equal(autoCompleteStatus(ctx), "disabled");
|
|
89
|
+
assert.equal(entries.at(-1)?.data.enabled, false);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("Auto-complete continuation", () => {
|
|
94
|
+
it("queues one follow-up after an early stop and none after a natural next question", async () => {
|
|
95
|
+
const { workdir } = makeRun("continuation");
|
|
96
|
+
const session = {};
|
|
97
|
+
const sent: any[] = [];
|
|
98
|
+
const handlers = new Map<string, Function[]>();
|
|
99
|
+
const pi: any = {
|
|
100
|
+
on: (name: string, handler: Function) => handlers.set(name, [...(handlers.get(name) ?? []), handler]),
|
|
101
|
+
appendEntry: () => {},
|
|
102
|
+
sendUserMessage: async (content: string, options: unknown) => sent.push({ content, options }),
|
|
103
|
+
};
|
|
104
|
+
setAutoCompleteApi(pi);
|
|
105
|
+
registerAutoCompleteTurnHandlers(pi);
|
|
106
|
+
const ctx = makeContext(workdir, session);
|
|
107
|
+
enableAutoComplete(ctx);
|
|
108
|
+
await handlers.get("turn_start")?.[0]?.({}, ctx);
|
|
109
|
+
recordAskChoice(ctx, true);
|
|
110
|
+
await handlers.get("turn_end")?.[0]?.({ message: { role: "assistant" } }, ctx);
|
|
111
|
+
await handlers.get("turn_end")?.[0]?.({ message: { role: "assistant" } }, ctx);
|
|
112
|
+
assert.equal(sent.length, 1);
|
|
113
|
+
assert.equal((sent[0]!.options as any).deliverAs, "followUp");
|
|
114
|
+
|
|
115
|
+
await handlers.get("turn_start")?.[0]?.({}, ctx);
|
|
116
|
+
recordAskChoice(ctx, true);
|
|
117
|
+
markPlanWritten(ctx);
|
|
118
|
+
await handlers.get("turn_end")?.[0]?.({ message: { role: "assistant" } }, ctx);
|
|
119
|
+
assert.equal(sent.length, 1);
|
|
120
|
+
|
|
121
|
+
await handlers.get("turn_start")?.[0]?.({}, ctx);
|
|
122
|
+
recordAskChoice(ctx, true);
|
|
123
|
+
recordAskChoice(ctx, false);
|
|
124
|
+
await handlers.get("turn_end")?.[0]?.({ message: { role: "assistant" } }, ctx);
|
|
125
|
+
assert.equal(sent.length, 1);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("ask_choice Auto-complete wiring", () => {
|
|
130
|
+
it("routes eligible questions through the run-scoped mode", () => {
|
|
131
|
+
const source = fs.readFileSync(path.join(process.cwd(), "tools", "ask-choice.ts"), "utf8");
|
|
132
|
+
assert.match(source, /isAutoCompleteEnabled/);
|
|
133
|
+
assert.match(source, /recordAskChoice\(ctx, true\)/);
|
|
134
|
+
assert.match(source, /autoComplete && selected\.startsWith\("Auto-complete"\)/);
|
|
135
|
+
// Trailing option: Auto-refine loop replaces Auto-complete and is suppressed in headless sessions.
|
|
136
|
+
assert.match(source, /trailing === undefined/);
|
|
137
|
+
assert.match(source, /AUTO_REFINE_LOOP_LABEL/);
|
|
138
|
+
assert.match(source, /trailing && selected\.startsWith\("Auto-refine loop"\)/);
|
|
139
|
+
const indexSource = fs.readFileSync(path.join(process.cwd(), "index.ts"), "utf8");
|
|
140
|
+
assert.match(indexSource, /plans-autocomplete-stop/);
|
|
141
|
+
assert.match(indexSource, /autoCompleteStatus\(ctx\)/);
|
|
142
|
+
assert.match(indexSource, /restoreAutoCompleteFromSession/);
|
|
143
|
+
const execSource = fs.readFileSync(path.join(process.cwd(), "src", "exec.ts"), "utf8");
|
|
144
|
+
assert.doesNotMatch(execSource, /customType === AUTOCOMPLETE_ENTRY/);
|
|
145
|
+
assert.match(execSource, /shouldTriggerPlanningCompaction\(_ctx: ExtensionContext\): boolean \{\n\s+return false;/);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verifies the DB-to-source materializer refuses stale source unless force,
|
|
3
|
+
* and roundtrips bytes for unchanged content.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { test } from "node:test";
|
|
7
|
+
import * as assert from "node:assert/strict";
|
|
8
|
+
import * as fs from "node:fs";
|
|
9
|
+
import * as os from "node:os";
|
|
10
|
+
import * as path from "node:path";
|
|
11
|
+
import { Store } from "../src/code-graph/store.ts";
|
|
12
|
+
import { materialize, materializeFile } from "../src/code-graph/materialize.ts";
|
|
13
|
+
import { runIndex } from "../src/code-graph/indexer.ts";
|
|
14
|
+
import { makeBackend } from "../src/code-graph/parsers/javascript.ts";
|
|
15
|
+
import { PythonBackend } from "../src/code-graph/parsers/python.ts";
|
|
16
|
+
import { loadGraphRuntime } from "../src/code-graph/runtime.ts";
|
|
17
|
+
import type { ParserBackend } from "../src/code-graph/parser.ts";
|
|
18
|
+
import type { Language } from "../src/code-graph/types.ts";
|
|
19
|
+
|
|
20
|
+
async function setup(): Promise<{ store: Store; cleanup: () => void } | null> {
|
|
21
|
+
try {
|
|
22
|
+
const sqlite = await import("node:sqlite");
|
|
23
|
+
const worktreeRoot = fs.realpathSync(os.tmpdir());
|
|
24
|
+
const dbPath = path.join(os.tmpdir(), `code-graph-apply-${process.pid}-${Date.now()}.db`);
|
|
25
|
+
const store = new Store({ dbPath, worktreeRoot, gitCommonDir: worktreeRoot }, sqlite);
|
|
26
|
+
store.close();
|
|
27
|
+
const reopened = new Store({ dbPath, worktreeRoot, gitCommonDir: worktreeRoot }, sqlite);
|
|
28
|
+
return { store: reopened, cleanup: () => {
|
|
29
|
+
reopened.close();
|
|
30
|
+
try { fs.unlinkSync(dbPath); } catch { /* ignore */ }
|
|
31
|
+
} };
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function hashText(text: string): string {
|
|
38
|
+
let h = 0x811c9dc5;
|
|
39
|
+
for (let i = 0; i < text.length; i++) {
|
|
40
|
+
h ^= text.charCodeAt(i);
|
|
41
|
+
h = Math.imul(h, 0x01000193);
|
|
42
|
+
}
|
|
43
|
+
return (h >>> 0).toString(16).padStart(8, "0");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
test("materialize refuses stale source unless force is set", async (t) => {
|
|
47
|
+
const opened = await setup();
|
|
48
|
+
if (!opened) return;
|
|
49
|
+
t.after(() => opened.cleanup());
|
|
50
|
+
const { store } = opened;
|
|
51
|
+
const worktreeRoot = fs.realpathSync(os.tmpdir());
|
|
52
|
+
const fileDir = "graph-apply-fixture";
|
|
53
|
+
const fileName = "demo.txt";
|
|
54
|
+
const absoluteDir = path.join(worktreeRoot, fileDir);
|
|
55
|
+
fs.mkdirSync(absoluteDir, { recursive: true });
|
|
56
|
+
const original = "hello graph";
|
|
57
|
+
fs.writeFileSync(path.join(absoluteDir, fileName), original);
|
|
58
|
+
const sourceHash = hashText(original);
|
|
59
|
+
store.tx(() => {
|
|
60
|
+
store.db
|
|
61
|
+
.prepare(
|
|
62
|
+
`INSERT INTO files (file_dir, file_name, language, source_hash, source_text, updated_at)
|
|
63
|
+
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
64
|
+
)
|
|
65
|
+
.run(fileDir, fileName, "javascript", sourceHash, original, new Date().toISOString());
|
|
66
|
+
store.db
|
|
67
|
+
.prepare(
|
|
68
|
+
`INSERT INTO file_entries (file_dir, file_name, ordinal, kind, function_name, start_byte, end_byte, text)
|
|
69
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
70
|
+
)
|
|
71
|
+
.run(fileDir, fileName, 0, "raw", null, 0, original.length, original);
|
|
72
|
+
});
|
|
73
|
+
// File matches DB hash: write succeeds.
|
|
74
|
+
const okResult = materializeFile(store, worktreeRoot, fileDir, fileName);
|
|
75
|
+
assert.equal(okResult.status, "ok");
|
|
76
|
+
// Now mutate the source on disk to be stale.
|
|
77
|
+
fs.writeFileSync(path.join(absoluteDir, fileName), "external change");
|
|
78
|
+
const staleResult = materializeFile(store, worktreeRoot, fileDir, fileName);
|
|
79
|
+
assert.equal(staleResult.status, "stale");
|
|
80
|
+
const forceResult = materializeFile(store, worktreeRoot, fileDir, fileName, { force: true });
|
|
81
|
+
assert.equal(forceResult.status, "ok");
|
|
82
|
+
assert.equal(fs.readFileSync(path.join(absoluteDir, fileName), "utf8"), original);
|
|
83
|
+
fs.rmSync(absoluteDir, { recursive: true, force: true });
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("pending_kind drives convergence: update writes/creates, delete purges, missing skips", async (t) => {
|
|
87
|
+
const sqliteAvailable = (async () => { try { await import("node:sqlite"); return true; } catch { return false; } })();
|
|
88
|
+
if (!(await sqliteAvailable)) return;
|
|
89
|
+
const sqlite = await import("node:sqlite");
|
|
90
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "code-graph-pending-"));
|
|
91
|
+
t.after(() => { try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } });
|
|
92
|
+
const store = new Store({ dbPath: path.join(dir, "g.db"), worktreeRoot: dir, gitCommonDir: path.join(dir, ".git") }, sqlite);
|
|
93
|
+
t.after(() => store.close());
|
|
94
|
+
const now = new Date().toISOString();
|
|
95
|
+
|
|
96
|
+
// 1. pending update creates a missing file (new-file path).
|
|
97
|
+
store.tx(() => {
|
|
98
|
+
store.db.prepare(
|
|
99
|
+
`INSERT INTO files (file_dir, file_name, language, source_hash, source_text, pending_kind, updated_at)
|
|
100
|
+
VALUES ('.', 'new.js', 'javascript', 'x', 'function n() { return 1; }\n', 'update', ?)`,
|
|
101
|
+
).run(now);
|
|
102
|
+
});
|
|
103
|
+
const created = materializeFile(store, dir, ".", "new.js");
|
|
104
|
+
assert.equal(created.status, "ok");
|
|
105
|
+
assert.equal(fs.readFileSync(path.join(dir, "new.js"), "utf8"), "function n() { return 1; }\n");
|
|
106
|
+
const cleared = store.read(() => store.db.prepare(`SELECT pending_kind FROM files WHERE file_name = 'new.js'`).get()) as { pending_kind: string | null };
|
|
107
|
+
assert.equal(cleared.pending_kind, null, "pending cleared after apply");
|
|
108
|
+
|
|
109
|
+
// 2. pending delete removes disk file and DB rows.
|
|
110
|
+
fs.writeFileSync(path.join(dir, "doomed.js"), "function d() {}\n");
|
|
111
|
+
store.tx(() => {
|
|
112
|
+
store.db.prepare(
|
|
113
|
+
`INSERT INTO files (file_dir, file_name, language, source_hash, source_text, pending_kind, updated_at)
|
|
114
|
+
VALUES ('.', 'doomed.js', 'javascript', 'x', 'function d() {}\n', 'delete', ?)`,
|
|
115
|
+
).run(now);
|
|
116
|
+
store.db.prepare(
|
|
117
|
+
`INSERT INTO functions (file_dir, file_name, function_name, language, kind, full_code, full_code_hash, render_code, render_code_hash,
|
|
118
|
+
move_supported, is_primary, provenance_start_byte, provenance_end_byte, provenance_start_line, provenance_start_col,
|
|
119
|
+
provenance_end_line, provenance_end_col, version)
|
|
120
|
+
VALUES ('.', 'doomed.js', 'd', 'javascript', 'declaration', 'function d() {}', 'h', 'function d() {}', 'h', 1, 1, 0, 18, 1, 1, 1, 19, 1)`,
|
|
121
|
+
).run();
|
|
122
|
+
});
|
|
123
|
+
const deleted = materializeFile(store, dir, ".", "doomed.js");
|
|
124
|
+
assert.equal(deleted.status, "deleted");
|
|
125
|
+
assert.equal(fs.existsSync(path.join(dir, "doomed.js")), false, "disk file removed");
|
|
126
|
+
const rows = store.read(() => store.db.prepare(`SELECT COUNT(*) AS c FROM files WHERE file_name = 'doomed.js'`).get()) as { c: number };
|
|
127
|
+
assert.equal(rows.c, 0, "DB rows purged");
|
|
128
|
+
|
|
129
|
+
// 3. pending-NULL + missing on disk: skipped, not resurrected.
|
|
130
|
+
store.tx(() => {
|
|
131
|
+
store.db.prepare(
|
|
132
|
+
`INSERT INTO files (file_dir, file_name, language, source_hash, source_text, pending_kind, updated_at)
|
|
133
|
+
VALUES ('.', 'ghost.js', 'javascript', 'x', 'function g() {}\n', NULL, ?)`,
|
|
134
|
+
).run(now);
|
|
135
|
+
});
|
|
136
|
+
const skipped = materializeFile(store, dir, ".", "ghost.js");
|
|
137
|
+
assert.equal(skipped.status, "skipped-missing");
|
|
138
|
+
assert.equal(fs.existsSync(path.join(dir, "ghost.js")), false, "ghost not resurrected");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("materialize never rewrites pending-NULL files from real-index manifests", async (t) => {
|
|
142
|
+
let sqlite: typeof import("node:sqlite");
|
|
143
|
+
try {
|
|
144
|
+
sqlite = await import("node:sqlite");
|
|
145
|
+
} catch {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const runtime = await loadGraphRuntime();
|
|
149
|
+
if (!runtime.status.parserAvailable || !runtime.status.sqliteAvailable) return;
|
|
150
|
+
const ParserCtor = runtime.runtime.parser.Parser as unknown as new () => {
|
|
151
|
+
parse(input: string | Buffer): unknown;
|
|
152
|
+
setLanguage(language: unknown): void;
|
|
153
|
+
};
|
|
154
|
+
const parsers: Record<Language, ParserBackend> = {
|
|
155
|
+
javascript: makeBackend("javascript", ParserCtor, runtime.runtime.parser.javascript),
|
|
156
|
+
typescript: makeBackend("typescript", ParserCtor, runtime.runtime.parser.typescript),
|
|
157
|
+
tsx: makeBackend("tsx", ParserCtor, runtime.runtime.parser.tsx),
|
|
158
|
+
python: new PythonBackend(ParserCtor, runtime.runtime.parser.python),
|
|
159
|
+
};
|
|
160
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "code-graph-apply-real-"));
|
|
161
|
+
t.after(() => {
|
|
162
|
+
try {
|
|
163
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
164
|
+
} catch {
|
|
165
|
+
/* ignore */
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
const source = "export function add(a: number, b: number): number { return a + b; }\n";
|
|
169
|
+
fs.writeFileSync(path.join(dir, "math.ts"), source);
|
|
170
|
+
const store = new Store({ dbPath: path.join(dir, "g.db"), worktreeRoot: dir, gitCommonDir: path.join(dir, ".git") }, sqlite);
|
|
171
|
+
t.after(() => store.close());
|
|
172
|
+
await runIndex({ store, worktreeRoot: dir, parsers });
|
|
173
|
+
|
|
174
|
+
// Real indexes store empty/overlapping render-unit texts; a pending-NULL
|
|
175
|
+
// apply must treat source_text as canonical and not rewrite the file.
|
|
176
|
+
const report = materialize({ store, worktreeRoot: dir });
|
|
177
|
+
assert.ok(report.files.every((file) => file.status === "ok"), JSON.stringify(report.files));
|
|
178
|
+
assert.equal(fs.readFileSync(path.join(dir, "math.ts"), "utf8"), source);
|
|
179
|
+
|
|
180
|
+
// force converges from source_text, never from manifest reassembly.
|
|
181
|
+
fs.writeFileSync(path.join(dir, "math.ts"), "tampered\n");
|
|
182
|
+
const forced = materialize({ store, worktreeRoot: dir, force: true });
|
|
183
|
+
assert.ok(forced.files.every((file) => file.status === "ok"), JSON.stringify(forced.files));
|
|
184
|
+
assert.equal(fs.readFileSync(path.join(dir, "math.ts"), "utf8"), source);
|
|
185
|
+
});
|