pi-plans 0.5.7 → 0.7.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/CONTRIBUTING.md +126 -0
- package/README.md +49 -39
- package/agents/ref-analyst.md +7 -4
- package/agents/reviewer.md +12 -3
- package/index.ts +74 -40
- package/package.json +2 -1
- package/references/pi-planning-workflow.md +45 -58
- package/references/plan-artifact-template.md +71 -60
- package/references/state-and-config.md +63 -47
- package/scripts/bench/pi-adapter/pi_plans_bench.py +33 -22
- package/scripts/bench/pi-adapter/rpc_driver.mjs +4 -4
- package/scripts/run-tests.ts +12 -1
- package/scripts/validate.ts +22 -10
- package/skills/debug-and-plan/SKILL.md +4 -4
- package/skills/plan-big/SKILL.md +5 -5
- package/skills/plan-normal/SKILL.md +5 -5
- package/skills/plan-small/SKILL.md +5 -5
- package/skills/plan-with-refs/SKILL.md +8 -8
- package/skills/planning/SKILL.md +1 -1
- package/src/ask-form.ts +4 -4
- package/src/auditor.ts +126 -0
- package/src/auto-approve.ts +1 -1
- package/src/autocomplete.ts +19 -17
- package/src/code-graph/commands.ts +2 -2
- package/src/code-graph/community.ts +1 -1
- package/src/code-graph/paths.ts +1 -1
- package/src/code-graph/watch.ts +2 -2
- package/src/compaction.ts +3 -3
- package/src/config-command.ts +154 -76
- package/src/dashboard.ts +257 -0
- package/src/exec.ts +709 -705
- package/src/global-state.ts +304 -0
- package/src/guard.ts +16 -3
- package/src/messaging.ts +44 -0
- package/src/plan.ts +421 -112
- package/src/query-hook.ts +4 -4
- package/src/refine-prompts.ts +14 -72
- package/src/refine-ui-helpers.ts +24 -5
- package/src/refine-ui-state.ts +1 -1
- package/src/refine-ui.ts +1 -1
- package/src/resume-command.ts +40 -130
- package/src/resume.ts +15 -17
- package/src/role-panels.ts +542 -0
- package/src/run-context.ts +5 -4
- package/src/run-picker.ts +98 -0
- package/src/state.ts +380 -77
- package/src/subagent.ts +32 -1
- package/src/task-tool.ts +100 -0
- package/src/tasks.ts +189 -0
- package/src/thinking-levels.ts +67 -0
- package/src/ui-language.ts +3 -54
- package/src/workflow-state.ts +78 -57
- package/tests/analyze-refs.test.ts +35 -18
- package/tests/ask-choice-pros-cons.test.ts +147 -0
- package/tests/ask-choice-schema.test.ts +0 -12
- package/tests/ask-choice.test.ts +2 -49
- package/tests/ask-form-tool.test.ts +4 -5
- package/tests/ask-form.test.ts +2 -2
- package/tests/auditor.test.ts +111 -0
- package/tests/auto-approve.test.ts +7 -10
- package/tests/autocomplete.test.ts +8 -11
- package/tests/code-graph-apply-action.test.ts +2 -2
- package/tests/code-graph-commands.test.ts +2 -2
- package/tests/code-graph-index.test.ts +2 -2
- package/tests/code-graph-loop.e2e.test.ts +1 -1
- package/tests/code-graph-mutations.test.ts +1 -1
- package/tests/code-graph-rollback.test.ts +1 -1
- package/tests/code-graph-v05.test.ts +2 -2
- package/tests/compaction.test.ts +1 -1
- package/tests/config-command.test.ts +103 -100
- package/tests/dashboard.test.ts +268 -0
- package/tests/exec-lifecycle.test.ts +181 -115
- package/tests/exec-panel-lifecycle.test.ts +106 -251
- package/tests/exec.test.ts +617 -1706
- package/tests/execute-plan.test.ts +44 -19
- package/tests/extension-load.test.ts +48 -0
- package/tests/global-state.test.ts +371 -0
- package/tests/graph-aware-file-tools.test.ts +5 -5
- package/tests/guard.test.ts +1 -1
- package/tests/multi-run.test.ts +184 -0
- package/tests/plan.test.ts +139 -62
- package/tests/plans.test.ts +7 -79
- package/tests/refine-prompts.test.ts +20 -71
- package/tests/refine-resume.test.ts +27 -22
- package/tests/refine-ui.test.ts +6 -15
- package/tests/resume-lifecycle.test.ts +37 -22
- package/tests/resume.test.ts +43 -88
- package/tests/role-panels.test.ts +391 -0
- package/tests/run-context.test.ts +1 -1
- package/tests/run-ownership.test.ts +1 -1
- package/tests/stale-ctx.test.ts +218 -0
- package/tests/state.test.ts +151 -32
- package/tests/subagent-thinking.test.ts +65 -0
- package/tests/subagent-usage.test.ts +1 -1
- package/tests/task-tool.test.ts +61 -0
- package/tests/thinking-levels.test.ts +77 -0
- package/tests/ui-language.test.ts +2 -17
- package/tests/workflow-state.test.ts +17 -99
- package/tools/analyze-refs.ts +67 -32
- package/tools/ask-choice.ts +19 -49
- package/tools/code-graph.ts +2 -2
- package/tools/execute-plan.ts +63 -33
- package/tools/graph-aware-file-tools.ts +6 -4
- package/tools/plans.ts +40 -66
- package/tools/refine.ts +101 -164
- package/agents/criticizer.md +0 -18
- package/scripts/bench/pi-adapter/__pycache__/pi_plans_bench.cpython-312.pyc +0 -0
- package/src/panel.ts +0 -473
- package/src/termination-prompt.ts +0 -73
- package/tests/goal-wait.test.ts +0 -269
- package/tests/panel-i-zero.test.ts +0 -420
- package/tests/panel.test.ts +0 -355
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
/** First-use reviewer model/effort panel tests (v0.7.0): runtime adapter
|
|
2
|
+
* priority (public facade first, F-010), effort panel rows, flow outcomes
|
|
3
|
+
* (confirm / cancel / menu fallback / text guidance), and the cancel-error
|
|
4
|
+
* contract (F-005). Panel wiring uses pi's own ModelSelectorComponent — the
|
|
5
|
+
* "no menu fallback" assertion proves the panel was actually constructed. */
|
|
6
|
+
|
|
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 { after, before, describe, it } from "node:test";
|
|
12
|
+
import { ModelSelectorComponent, initTheme } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import {
|
|
14
|
+
BorderedPanel,
|
|
15
|
+
EffortPanelComponent,
|
|
16
|
+
adapterModelRuntime,
|
|
17
|
+
availableModels,
|
|
18
|
+
borderColorFrom,
|
|
19
|
+
effortItems,
|
|
20
|
+
findModel,
|
|
21
|
+
firstUseCancelledError,
|
|
22
|
+
firstUseTextGuidance,
|
|
23
|
+
runFirstUseFlow,
|
|
24
|
+
type RolePanelHost,
|
|
25
|
+
} from "../src/role-panels.ts";
|
|
26
|
+
import { loadGlobalConfig } from "../src/global-state.ts";
|
|
27
|
+
import { visibleWidth } from "../src/refine-ui-helpers.ts";
|
|
28
|
+
|
|
29
|
+
const fakeModel = {
|
|
30
|
+
provider: "devin",
|
|
31
|
+
id: "glm-5.3",
|
|
32
|
+
reasoning: true,
|
|
33
|
+
thinkingLevelMap: { off: null, low: "low", high: "high" },
|
|
34
|
+
} as never;
|
|
35
|
+
|
|
36
|
+
let globalDir: string;
|
|
37
|
+
let previousGlobalDir: string | undefined;
|
|
38
|
+
|
|
39
|
+
before(() => {
|
|
40
|
+
// Panel components use themed lists at CONSTRUCTION time.
|
|
41
|
+
initTheme();
|
|
42
|
+
previousGlobalDir = process.env.PI_PLANS_GLOBAL_DIR;
|
|
43
|
+
globalDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-global-panels-"));
|
|
44
|
+
process.env.PI_PLANS_GLOBAL_DIR = globalDir;
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
after(() => {
|
|
48
|
+
if (previousGlobalDir === undefined) delete process.env.PI_PLANS_GLOBAL_DIR;
|
|
49
|
+
else process.env.PI_PLANS_GLOBAL_DIR = previousGlobalDir;
|
|
50
|
+
fs.rmSync(globalDir, { recursive: true, force: true });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
function registryHost(overrides: Partial<RolePanelHost> = {}): RolePanelHost {
|
|
54
|
+
return {
|
|
55
|
+
mode: "rpc",
|
|
56
|
+
hasUI: true,
|
|
57
|
+
model: null,
|
|
58
|
+
scopedModels: [],
|
|
59
|
+
modelRegistry: {
|
|
60
|
+
getAvailable: () => [fakeModel],
|
|
61
|
+
find: (provider: string, id: string) => (provider === "devin" && id === "glm-5.3" ? fakeModel : undefined),
|
|
62
|
+
getError: () => undefined,
|
|
63
|
+
refresh: async () => ({}),
|
|
64
|
+
},
|
|
65
|
+
ui: {},
|
|
66
|
+
...overrides,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
describe("adapterModelRuntime (F-010, Q-5=A)", () => {
|
|
71
|
+
it("prefers the public ModelRegistry facade over the private runtime field", () => {
|
|
72
|
+
const calls: string[] = [];
|
|
73
|
+
const host = registryHost({
|
|
74
|
+
modelRegistry: {
|
|
75
|
+
getAvailable: () => { calls.push("getAvailable"); return [fakeModel]; },
|
|
76
|
+
find: () => { calls.push("find"); return fakeModel; },
|
|
77
|
+
getError: () => { calls.push("getError"); return undefined; },
|
|
78
|
+
refresh: async () => { calls.push("refresh"); return {}; },
|
|
79
|
+
runtime: { getAvailableSnapshot: () => { calls.push("PRIVATE"); return []; } },
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
const runtime = adapterModelRuntime(host)!;
|
|
83
|
+
assert.ok(runtime);
|
|
84
|
+
runtime.getAvailableSnapshot();
|
|
85
|
+
(runtime.getModel as (p: string, i: string) => unknown)("devin", "glm-5.3");
|
|
86
|
+
runtime.getError();
|
|
87
|
+
void runtime.refresh();
|
|
88
|
+
assert.deepEqual(calls, ["getAvailable", "find", "getError", "refresh"], "public facade wins; private runtime untouched");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("falls back to the private runtime field when the facade is incomplete", () => {
|
|
92
|
+
const host = registryHost({
|
|
93
|
+
modelRegistry: { runtime: { getAvailableSnapshot: () => [fakeModel] } },
|
|
94
|
+
});
|
|
95
|
+
const runtime = adapterModelRuntime(host)!;
|
|
96
|
+
assert.deepEqual((runtime.getAvailableSnapshot as () => unknown[])(), [fakeModel]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("returns null when neither path is usable", () => {
|
|
100
|
+
assert.equal(adapterModelRuntime(registryHost({ modelRegistry: undefined })), null);
|
|
101
|
+
assert.equal(adapterModelRuntime(registryHost({ modelRegistry: { getAvailable: () => [] } })), null);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("availableModels / findModel tolerate missing or throwing registries", () => {
|
|
105
|
+
assert.deepEqual(availableModels(registryHost({ modelRegistry: undefined })), []);
|
|
106
|
+
assert.equal(findModel(registryHost({ modelRegistry: undefined }), "devin/glm-5.3"), null);
|
|
107
|
+
assert.equal(findModel(registryHost(), "devin/missing"), null);
|
|
108
|
+
assert.ok(findModel(registryHost(), "devin/glm-5.3"));
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("effort panel rows", () => {
|
|
113
|
+
it("Default sentinel first, then pi-ai-supported levels", () => {
|
|
114
|
+
const items = effortItems(fakeModel, null);
|
|
115
|
+
assert.equal(items[0]!.value, "default");
|
|
116
|
+
assert.match(items[0]!.description, /no --thinking flag/);
|
|
117
|
+
assert.deepEqual(items.slice(1).map((item) => item.value), ["minimal", "low", "medium", "high"]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("an unsupported stored level stays visible with a marker", () => {
|
|
121
|
+
const items = effortItems(fakeModel, "xhigh");
|
|
122
|
+
const last = items.at(-1)!;
|
|
123
|
+
assert.equal(last.value, "xhigh");
|
|
124
|
+
assert.match(last.description, /not supported/);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("the panel component renders the title and default row", () => {
|
|
128
|
+
const component = new EffortPanelComponent(effortItems(fakeModel, null), "default", () => {}, () => {});
|
|
129
|
+
const lines = component.render(100) as unknown as string[];
|
|
130
|
+
assert.ok(lines.some((line) => line.includes("Reviewer Thinking Level")));
|
|
131
|
+
assert.ok(lines.some((line) => line.includes("default")));
|
|
132
|
+
assert.ok(lines.some((line) => line.includes("Esc to cancel")));
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe("BorderedPanel (v0.7.1 full-box panels)", () => {
|
|
137
|
+
const plain = (s: string) => s;
|
|
138
|
+
function inner(lines: string[]) {
|
|
139
|
+
return {
|
|
140
|
+
rendered: [] as number[],
|
|
141
|
+
render(width: number) {
|
|
142
|
+
this.rendered.push(width);
|
|
143
|
+
return lines;
|
|
144
|
+
},
|
|
145
|
+
handleInput: (d: string) => { void d; },
|
|
146
|
+
invalidate: () => {},
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
const strip = (line: string) => line.replace(/\x1b\[[0-9;]*m/g, "");
|
|
150
|
+
|
|
151
|
+
it("draws left/right rules and strips the inner component's own rules", () => {
|
|
152
|
+
const panel = new BorderedPanel(inner(["─".repeat(20), "hello", "─".repeat(20)]), plain);
|
|
153
|
+
const lines = panel.render(22).map(strip);
|
|
154
|
+
assert.equal(lines[0], `┌${"─".repeat(20)}┐`);
|
|
155
|
+
assert.equal(lines[lines.length - 1], `└${"─".repeat(20)}┘`);
|
|
156
|
+
assert.equal(lines[1], `│hello${" ".repeat(15)}│`);
|
|
157
|
+
assert.equal(lines.length, 3, "two inner rules replaced by two box rules (height preserved)");
|
|
158
|
+
assert.equal(lines.filter((l) => l.includes("hello")).length, 1);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("renders the inner component two columns narrower", () => {
|
|
162
|
+
const innerCmp = inner(["body"]);
|
|
163
|
+
new BorderedPanel(innerCmp, plain).render(30);
|
|
164
|
+
assert.deepEqual(innerCmp.rendered, [28]);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("pads short lines and truncates overlong ones to the box width", () => {
|
|
168
|
+
const short = new BorderedPanel(inner(["ab"]), plain).render(10).map(strip);
|
|
169
|
+
assert.equal(short[1], `│ab${" ".repeat(6)}│`);
|
|
170
|
+
assert.equal(strip(short[1]!).length, 10);
|
|
171
|
+
const long = new BorderedPanel(inner(["x".repeat(50)]), plain).render(10).map(strip);
|
|
172
|
+
assert.equal(strip(long[1]!), `│${"x".repeat(8)}│`, "overlong content is clipped to the box width, never overflows");
|
|
173
|
+
assert.equal(strip(long[1]!).length, 10);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("relays focus, keyboard, invalidate and dispose to the inner component", () => {
|
|
177
|
+
const seen: string[] = [];
|
|
178
|
+
const target = {
|
|
179
|
+
focused: false,
|
|
180
|
+
render: () => ["x"],
|
|
181
|
+
handleInput: (d: string) => seen.push(`key:${d}`),
|
|
182
|
+
invalidate: () => seen.push("invalidate"),
|
|
183
|
+
dispose: () => seen.push("dispose"),
|
|
184
|
+
};
|
|
185
|
+
const panel = new BorderedPanel(target);
|
|
186
|
+
panel.focused = true;
|
|
187
|
+
assert.equal(target.focused, true, "TUI focus must reach the inner search input");
|
|
188
|
+
assert.equal(panel.focused, true);
|
|
189
|
+
panel.handleInput("j");
|
|
190
|
+
panel.invalidate();
|
|
191
|
+
panel.dispose();
|
|
192
|
+
assert.deepEqual(seen, ["key:j", "invalidate", "dispose"]);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("translates mouse coordinates into the inner component's space", () => {
|
|
196
|
+
let received: Record<string, number> | null = null;
|
|
197
|
+
const target = {
|
|
198
|
+
render: () => ["a", "b", "c"],
|
|
199
|
+
handleMouse: (e: { x: number; y: number; width: number; height: number }) => {
|
|
200
|
+
received = { x: e.x, y: e.y, width: e.width, height: e.height };
|
|
201
|
+
return undefined;
|
|
202
|
+
},
|
|
203
|
+
invalidate: () => {},
|
|
204
|
+
};
|
|
205
|
+
const panel = new BorderedPanel(target);
|
|
206
|
+
// Click the second content row (box row 0 = top rule, row 3 = first content).
|
|
207
|
+
panel.handleMouse({ x: 5, y: 3, width: 20, height: 5 } as never);
|
|
208
|
+
assert.deepEqual(received, { x: 4, y: 2, width: 18, height: 3 });
|
|
209
|
+
received = null;
|
|
210
|
+
panel.handleMouse({ x: 5, y: 0, width: 20, height: 5 } as never);
|
|
211
|
+
assert.equal(received, null, "the top rule row is not part of the inner component");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("paints the border with the host theme when one is supplied", () => {
|
|
215
|
+
const theme = { fg: (c: string, t: string) => `<${c}>${t}</${c}>` };
|
|
216
|
+
const colored = new BorderedPanel(inner(["body"]), borderColorFrom(theme)).render(10);
|
|
217
|
+
assert.ok(colored[0]!.startsWith("<border>┌"), "border uses the theme's border color");
|
|
218
|
+
const plainOut = new BorderedPanel(inner(["body"]), borderColorFrom(undefined)).render(10);
|
|
219
|
+
assert.equal(strip(plainOut[0]!), `┌${"─".repeat(8)}┐`, "a missing theme degrades to plain text");
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("the effort panel now renders inside a full box without double rules", () => {
|
|
223
|
+
const bare = new EffortPanelComponent(effortItems(fakeModel, null), "default", () => {}, () => {});
|
|
224
|
+
const boxed = new BorderedPanel(bare, plain).render(60).map(strip);
|
|
225
|
+
assert.ok(boxed[0]!.startsWith("┌"), "top rule");
|
|
226
|
+
assert.ok(boxed[boxed.length - 1]!.startsWith("└"), "bottom rule");
|
|
227
|
+
assert.equal(boxed.filter((l) => /^─+$/.test(l)).length, 0, "no leftover bare inner rules");
|
|
228
|
+
assert.equal(boxed.filter((l) => l.startsWith("│") && l.endsWith("│")).length, boxed.length - 2);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("every row is boxed and exactly the requested width, even when content wraps", () => {
|
|
232
|
+
// Narrow widths make the footer Text wrap onto a second line; that
|
|
233
|
+
// continuation row must still carry both side rules.
|
|
234
|
+
for (const width of [40, 46, 60, 80]) {
|
|
235
|
+
const bare = new EffortPanelComponent(effortItems(fakeModel, "high"), "default", () => {}, () => {});
|
|
236
|
+
const lines = new BorderedPanel(bare, plain).render(width).map(strip);
|
|
237
|
+
for (const [i, line] of lines.entries()) {
|
|
238
|
+
const isEdge = i === 0 || i === lines.length - 1;
|
|
239
|
+
if (isEdge) {
|
|
240
|
+
assert.ok(line.startsWith("┌") || line.startsWith("└"), `row ${i} @${width} is a rule`);
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
assert.ok(line.startsWith("│") && line.endsWith("│"), `row ${i} @${width} must have both rules`);
|
|
244
|
+
}
|
|
245
|
+
assert.equal(new Set(lines.map((l) => l.length)).size, 1, `all rows equal width @${width}`);
|
|
246
|
+
assert.equal(lines[0]!.length, width);
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("renders without a global pi theme (no bare DynamicBorder left in our panels)", () => {
|
|
251
|
+
// A no-arg DynamicBorder reads pi's global theme, which is undefined when
|
|
252
|
+
// the extension is loaded through jiti. The effort panel must not rely on it.
|
|
253
|
+
const bare = new EffortPanelComponent(effortItems(fakeModel, null), "default", () => {}, () => {});
|
|
254
|
+
assert.doesNotThrow(() => new BorderedPanel(bare, plain).render(60));
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("keeps the right border aligned on the focused search row (CURSOR_MARKER is zero-width)", () => {
|
|
258
|
+
// A focused Input emits pi's CURSOR_MARKER — an APC sequence (ESC _ … BEL).
|
|
259
|
+
// If width math counted its payload as text, that row would measure too
|
|
260
|
+
// wide and its right-hand rule would sit short of the other rows.
|
|
261
|
+
const marker = "\x1b_pi:c\x07";
|
|
262
|
+
assert.equal(visibleWidth(marker), 0, "CURSOR_MARKER must measure zero columns");
|
|
263
|
+
|
|
264
|
+
for (const focused of [false, true]) {
|
|
265
|
+
const bare = new EffortPanelComponent(effortItems(fakeModel, null), "default", () => {}, () => {});
|
|
266
|
+
(bare as unknown as { focused: boolean }).focused = focused;
|
|
267
|
+
const lines = new BorderedPanel(bare, plain).render(46);
|
|
268
|
+
const widths = new Set(lines.map((l) => visibleWidth(l)));
|
|
269
|
+
assert.deepEqual([...widths], [46], `every row is exactly the box width (focused=${focused})`);
|
|
270
|
+
for (const [i, line] of lines.entries()) {
|
|
271
|
+
if (i === 0 || i === lines.length - 1) continue;
|
|
272
|
+
assert.ok(line.startsWith("│") && line.endsWith("│"), `row ${i} keeps both rules (focused=${focused})`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("treats OSC/DCS/APC and simple escapes as zero-width, and SGR as atomic", () => {
|
|
278
|
+
assert.equal(visibleWidth("\x1b]0;window title\x07x"), 1, "OSC payload is zero-width");
|
|
279
|
+
assert.equal(visibleWidth("\x1bPi:c\x07x"), 1, "APC payload is zero-width");
|
|
280
|
+
assert.equal(visibleWidth("\x1bPx;y\x1b\\x"), 1, "DCS terminated by ST is zero-width");
|
|
281
|
+
assert.equal(visibleWidth("\x1b[31mred\x1b[0m"), 3, "SGR still measures its text only");
|
|
282
|
+
assert.equal(visibleWidth("\x1b(Bx"), 1, "charset-select escape is zero-width");
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
describe("runFirstUseFlow outcomes", () => {
|
|
287
|
+
it("menu path (hasUI non-TUI) confirms model + level and persists globally", async () => {
|
|
288
|
+
fs.rmSync(path.join(globalDir, "config.json"), { force: true });
|
|
289
|
+
const selections: string[] = [];
|
|
290
|
+
const host = registryHost({
|
|
291
|
+
ui: { select: async (_title: string, options: string[]) => { const pick = options.find((o) => o.startsWith("devin/glm-5.3")) ?? options[0]!; selections.push(pick); return pick; } },
|
|
292
|
+
});
|
|
293
|
+
const outcome = await runFirstUseFlow(host, null);
|
|
294
|
+
assert.equal(outcome.status, "confirmed");
|
|
295
|
+
if (outcome.status !== "confirmed") return;
|
|
296
|
+
assert.equal(outcome.modelSelector, "devin/glm-5.3");
|
|
297
|
+
assert.equal(outcome.thinkingLevel, null, "effort menu first row = default → null");
|
|
298
|
+
// The stored level is whatever the effort menu's first row (default) maps to: null.
|
|
299
|
+
assert.equal(outcome.role.model_selector, "devin/glm-5.3");
|
|
300
|
+
assert.equal(outcome.role.thinking_level, null);
|
|
301
|
+
assert.ok(outcome.role.confirmed_at);
|
|
302
|
+
assert.equal(loadGlobalConfig().config.reviewer.model_selector, "devin/glm-5.3");
|
|
303
|
+
assert.equal(selections.length, 2, "model menu then effort menu");
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("menu cancel (undefined) cancels the whole gate without persisting", async () => {
|
|
307
|
+
fs.rmSync(path.join(globalDir, "config.json"), { force: true });
|
|
308
|
+
const host = registryHost({ ui: { select: async () => undefined } });
|
|
309
|
+
const outcome = await runFirstUseFlow(host, null);
|
|
310
|
+
assert.deepEqual(outcome, { status: "cancelled", via: "menu" });
|
|
311
|
+
assert.equal(fs.existsSync(path.join(globalDir, "config.json")), false, "nothing persisted on cancel");
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("UI-less hosts return unavailable (caller falls back to text guidance)", async () => {
|
|
315
|
+
const host = registryHost({ hasUI: false, ui: {} });
|
|
316
|
+
const outcome = await runFirstUseFlow(host, null);
|
|
317
|
+
assert.equal(outcome.status, "unavailable");
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("TUI constructs pi's ModelSelectorComponent (no menu fallback) and Esc cancels", async () => {
|
|
321
|
+
fs.rmSync(path.join(globalDir, "config.json"), { force: true });
|
|
322
|
+
let sawPanel = false;
|
|
323
|
+
const fakeTui = { requestRender: () => {} };
|
|
324
|
+
const host = registryHost({
|
|
325
|
+
mode: "tui",
|
|
326
|
+
ui: {
|
|
327
|
+
custom: async (factory: (...a: unknown[]) => unknown) => {
|
|
328
|
+
const component = factory(fakeTui, {}, {}, () => {});
|
|
329
|
+
// v0.7.1: the panel is wrapped in a BorderedPanel; unwrap to
|
|
330
|
+
// assert pi's ModelSelectorComponent is still what we drive.
|
|
331
|
+
const inner = component instanceof BorderedPanel ? component.content : component;
|
|
332
|
+
if (inner instanceof ModelSelectorComponent) sawPanel = true;
|
|
333
|
+
// Simulate Esc: resolve the overlay promise with null (cancel).
|
|
334
|
+
return null;
|
|
335
|
+
},
|
|
336
|
+
},
|
|
337
|
+
});
|
|
338
|
+
const outcome = await runFirstUseFlow(host, null);
|
|
339
|
+
assert.ok(sawPanel, "the /model-style panel must be constructed, not the menu");
|
|
340
|
+
assert.deepEqual(outcome, { status: "cancelled", via: "panel" });
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("TUI with an unusable registry falls back to menus", async () => {
|
|
344
|
+
fs.rmSync(path.join(globalDir, "config.json"), { force: true });
|
|
345
|
+
const host = registryHost({
|
|
346
|
+
mode: "tui",
|
|
347
|
+
modelRegistry: undefined,
|
|
348
|
+
ui: {
|
|
349
|
+
custom: async () => undefined,
|
|
350
|
+
select: async (_t: string, options: string[]) => options[0],
|
|
351
|
+
},
|
|
352
|
+
});
|
|
353
|
+
const outcome = await runFirstUseFlow(host, null);
|
|
354
|
+
assert.equal(outcome.status, "unavailable", "no registry → no models for menus either");
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it("panel construction failure falls back to menus instead of throwing", async () => {
|
|
358
|
+
fs.rmSync(path.join(globalDir, "config.json"), { force: true });
|
|
359
|
+
let menuCalls = 0;
|
|
360
|
+
const host = registryHost({
|
|
361
|
+
mode: "tui",
|
|
362
|
+
ui: {
|
|
363
|
+
custom: async () => {
|
|
364
|
+
throw new Error("component drift");
|
|
365
|
+
},
|
|
366
|
+
select: async (_t: string, options: string[]) => {
|
|
367
|
+
menuCalls += 1;
|
|
368
|
+
return menuCalls === 1 ? options.find((o) => o.startsWith("devin/glm-5.3")) : options[0];
|
|
369
|
+
},
|
|
370
|
+
},
|
|
371
|
+
});
|
|
372
|
+
const outcome = await runFirstUseFlow(host, null);
|
|
373
|
+
assert.equal(outcome.status, "confirmed");
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
describe("gate error contracts", () => {
|
|
378
|
+
it("the cancelled error tells the agent not to re-ask or retry", () => {
|
|
379
|
+
const error = firstUseCancelledError("refine");
|
|
380
|
+
assert.match(error.message, /closed the refine/);
|
|
381
|
+
assert.match(error.message, /NOT re-ask/);
|
|
382
|
+
assert.match(error.message, /\/config-pi-plans/);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it("text guidance embeds model ids and the exact set-role call", () => {
|
|
386
|
+
const text = firstUseTextGuidance([fakeModel] as never, "/global/config.json");
|
|
387
|
+
assert.match(text, /devin\/glm-5\.3/);
|
|
388
|
+
assert.match(text, /modelSelector=<provider\/model>/);
|
|
389
|
+
assert.match(text, /\/global\/config\.json/);
|
|
390
|
+
});
|
|
391
|
+
});
|
|
@@ -74,7 +74,7 @@ describe("run binding", () => {
|
|
|
74
74
|
const session = { id: "s" };
|
|
75
75
|
bindRun(session, workdir, first.run_id);
|
|
76
76
|
// Delete the bound run's state directory; active.json still names `second`.
|
|
77
|
-
fs.rmSync(path.join(workdir, ".git", "
|
|
77
|
+
fs.rmSync(path.join(workdir, ".git", "pi-plans", "runs", first.run_id), { recursive: true, force: true });
|
|
78
78
|
const resolved = resolveActiveRun(session, workdir);
|
|
79
79
|
assert.equal(resolved?.run_id, second.run_id);
|
|
80
80
|
resetRunBindingForTests();
|
|
@@ -29,7 +29,7 @@ function setupRun(name: string): { workdir: string; runId: string } {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
function ownerPath(workdir: string, runId: string): string {
|
|
32
|
-
return path.join(workdir, ".git", "
|
|
32
|
+
return path.join(workdir, ".git", "pi-plans", "runs", runId, "owner.json");
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
function writeRawOwner(workdir: string, runId: string, record: Record<string, unknown>): void {
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stale-ctx regression (PLAN_v2 2026-09-30, revised after SDK source audit).
|
|
3
|
+
*
|
|
4
|
+
* SDK facts this encodes (pi 0.99.0, agent-session.js / extensions/runner.js):
|
|
5
|
+
* - The per-call ExtensionContext does NOT carry appendEntry/sendMessage/
|
|
6
|
+
* sendUserMessage — those live only on the ExtensionAPI ("pi") handed to
|
|
7
|
+
* the extension factory.
|
|
8
|
+
* - Session replacement (newSession/fork/switchSession) disposes the old
|
|
9
|
+
* session: every old ctx/pi channel throws ("stale"). The NEW session's
|
|
10
|
+
* runner RE-RUNS every extension factory, which is where index.ts refreshes
|
|
11
|
+
* the module-level messaging reference (setMessagingApi). Note: compaction
|
|
12
|
+
* itself is in-session and does NOT invalidate; only a replacement/reload
|
|
13
|
+
* that follows does.
|
|
14
|
+
*
|
|
15
|
+
* So the correct contract is: after a session swap, once the factory has
|
|
16
|
+
* re-run for the new session, all pi-plans paths complete using ONLY the new
|
|
17
|
+
* session's messaging surface; the old one is never touched. The narrow race
|
|
18
|
+
* window (a call dispatched before the new factory ran) surfaces the stale
|
|
19
|
+
* error — asserted here as documented behavior.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import * as assert from "node:assert/strict";
|
|
23
|
+
import * as fs from "node:fs";
|
|
24
|
+
import * as os from "node:os";
|
|
25
|
+
import * as path from "node:path";
|
|
26
|
+
import { after, before, describe, it } from "node:test";
|
|
27
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
28
|
+
import { registerExecutePlanTool, executeCommand } from "../tools/execute-plan.ts";
|
|
29
|
+
import { registerPlansTool } from "../tools/plans.ts";
|
|
30
|
+
import { getExecution, stopExecution } from "../src/exec.ts";
|
|
31
|
+
import { initState } from "../src/state.ts";
|
|
32
|
+
import { setMessagingApi } from "../src/messaging.ts";
|
|
33
|
+
import { AUTO_APPROVE_ENV } from "../src/auto-approve.ts";
|
|
34
|
+
|
|
35
|
+
const PLAN = `## Tasks
|
|
36
|
+
|
|
37
|
+
- Task-1: only — files: src/a.ts; wave: 1
|
|
38
|
+
|
|
39
|
+
### Execution Waves
|
|
40
|
+
|
|
41
|
+
- wave 1: Task-1
|
|
42
|
+
|
|
43
|
+
## Verification Checks
|
|
44
|
+
|
|
45
|
+
- [ ] \`VC-001\` covers \`Task-1\`; pass condition: x
|
|
46
|
+
`;
|
|
47
|
+
|
|
48
|
+
let root: string;
|
|
49
|
+
|
|
50
|
+
before(() => {
|
|
51
|
+
root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-stale-ctx-"));
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
after(() => {
|
|
55
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
56
|
+
delete process.env[AUTO_APPROVE_ENV];
|
|
57
|
+
setMessagingApi(null);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
interface Recorder {
|
|
61
|
+
appendEntry: number;
|
|
62
|
+
sendMessage: number;
|
|
63
|
+
sendUserMessage: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A per-call ctx with the REAL ExtensionContext shape: no messaging methods. */
|
|
67
|
+
function makeCallCtx(workdir: string): ExtensionContext {
|
|
68
|
+
return {
|
|
69
|
+
cwd: workdir,
|
|
70
|
+
sessionManager: {},
|
|
71
|
+
hasUI: false,
|
|
72
|
+
mode: "print",
|
|
73
|
+
ui: {
|
|
74
|
+
notify: () => {},
|
|
75
|
+
setStatus: () => {},
|
|
76
|
+
setWidget: () => {},
|
|
77
|
+
theme: { fg: (_c: string, t: string) => t, bold: (t: string) => t },
|
|
78
|
+
},
|
|
79
|
+
isIdle: () => true,
|
|
80
|
+
hasPendingMessages: () => false,
|
|
81
|
+
} as unknown as ExtensionContext;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The messaging surface of one session's pi. Throws on every channel once
|
|
85
|
+
* stale flips — exactly what the runner does to old ctx/pi after replacement. */
|
|
86
|
+
function makeSessionPi(): { calls: Recorder; setStale: () => void; api: ExtensionAPI } {
|
|
87
|
+
let stale = false;
|
|
88
|
+
const calls: Recorder = { appendEntry: 0, sendMessage: 0, sendUserMessage: 0 };
|
|
89
|
+
const guard = (name: keyof Recorder) => {
|
|
90
|
+
if (stale) throw new Error(`stale ctx: ${name} called after session replacement`);
|
|
91
|
+
calls[name] += 1;
|
|
92
|
+
};
|
|
93
|
+
const api = {
|
|
94
|
+
appendEntry: () => guard("appendEntry"),
|
|
95
|
+
sendMessage: () => guard("sendMessage"),
|
|
96
|
+
sendUserMessage: async () => guard("sendUserMessage"),
|
|
97
|
+
registerTool: (def: { name: string }) => def,
|
|
98
|
+
} as unknown as ExtensionAPI;
|
|
99
|
+
return { calls, setStale: () => { stale = true; }, api };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function writePlan(workdir: string): string {
|
|
103
|
+
const planPath = path.join(workdir, "PLAN_v1.md");
|
|
104
|
+
fs.writeFileSync(planPath, PLAN, "utf8");
|
|
105
|
+
return planPath;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
describe("stale ctx: messaging follows the factory re-run, never the registration capture", () => {
|
|
109
|
+
it("execute_plan tool path completes on the new session's pi only", async () => {
|
|
110
|
+
const workdir = path.join(root, "tool-path");
|
|
111
|
+
fs.mkdirSync(workdir, { recursive: true });
|
|
112
|
+
initState(workdir);
|
|
113
|
+
const planPath = writePlan(workdir);
|
|
114
|
+
|
|
115
|
+
// Session A: factory ran (messaging = pi A). Session replaced: pi A is stale.
|
|
116
|
+
const a = makeSessionPi();
|
|
117
|
+
setMessagingApi(a.api);
|
|
118
|
+
registerExecutePlanTool(a.api);
|
|
119
|
+
a.setStale();
|
|
120
|
+
|
|
121
|
+
// Session B: its runner re-runs the factory → messaging refreshed to pi B.
|
|
122
|
+
const b = makeSessionPi();
|
|
123
|
+
setMessagingApi(b.api);
|
|
124
|
+
const tools = new Map<string, { execute: (...args: never[]) => Promise<unknown> }>();
|
|
125
|
+
const extB = { registerTool: (def: { name: string } & { execute: (...args: never[]) => Promise<unknown> }) => tools.set(def.name, def) } as unknown as ExtensionAPI;
|
|
126
|
+
registerExecutePlanTool(extB);
|
|
127
|
+
|
|
128
|
+
process.env[AUTO_APPROVE_ENV] = "1";
|
|
129
|
+
try {
|
|
130
|
+
// The per-call ctx carries no messaging; all writes go through pi B.
|
|
131
|
+
await tools.get("execute_plan")!.execute("t1", { planPath } as never, undefined as never, undefined as never, makeCallCtx(workdir) as never);
|
|
132
|
+
} finally {
|
|
133
|
+
delete process.env[AUTO_APPROVE_ENV];
|
|
134
|
+
}
|
|
135
|
+
assert.ok(getExecution(), "execution started");
|
|
136
|
+
assert.ok(b.calls.appendEntry > 0, "pi-plans-exec persisted via pi B");
|
|
137
|
+
assert.ok(b.calls.sendMessage > 0, "exec-start message sent via pi B");
|
|
138
|
+
assert.equal(a.calls.appendEntry + a.calls.sendMessage + a.calls.sendUserMessage, 0, "stale pi A never touched");
|
|
139
|
+
await stopExecution(makeCallCtx(workdir), "test teardown");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("plans start-run appends pi-plans-run-start on the new session's pi only", async () => {
|
|
143
|
+
const workdir = path.join(root, "start-run-path");
|
|
144
|
+
fs.mkdirSync(workdir, { recursive: true });
|
|
145
|
+
initState(workdir);
|
|
146
|
+
|
|
147
|
+
const a = makeSessionPi();
|
|
148
|
+
setMessagingApi(a.api);
|
|
149
|
+
registerPlansTool(a.api);
|
|
150
|
+
a.setStale();
|
|
151
|
+
|
|
152
|
+
const b = makeSessionPi();
|
|
153
|
+
setMessagingApi(b.api);
|
|
154
|
+
|
|
155
|
+
const tools = new Map<string, { execute: (...args: never[]) => Promise<unknown> }>();
|
|
156
|
+
const extB = { registerTool: (def: { name: string } & { execute: (...args: never[]) => Promise<unknown> }) => tools.set(def.name, def) } as unknown as ExtensionAPI;
|
|
157
|
+
registerPlansTool(extB);
|
|
158
|
+
|
|
159
|
+
const result = (await tools.get("plans")!.execute(
|
|
160
|
+
"t1",
|
|
161
|
+
{ action: "start-run", topic: "stale-check", skill: "plan-small", requestText: "x" } as never,
|
|
162
|
+
undefined as never,
|
|
163
|
+
undefined as never,
|
|
164
|
+
makeCallCtx(workdir) as never,
|
|
165
|
+
)) as { content: Array<{ text: string }> };
|
|
166
|
+
const payload = JSON.parse(result.content[0]!.text) as { run: { run_id: string } };
|
|
167
|
+
assert.ok(payload.run.run_id);
|
|
168
|
+
assert.equal(b.calls.appendEntry, 1, "run-start entry appended via pi B");
|
|
169
|
+
assert.equal(a.calls.appendEntry + a.calls.sendMessage + a.calls.sendUserMessage, 0, "stale pi A never touched");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("documents the race window: before the factory re-runs, the stale error propagates", async () => {
|
|
173
|
+
const workdir = path.join(root, "race-window");
|
|
174
|
+
fs.mkdirSync(workdir, { recursive: true });
|
|
175
|
+
initState(workdir);
|
|
176
|
+
writePlan(workdir);
|
|
177
|
+
|
|
178
|
+
// Session A's pi is still the messaging surface and has just gone stale
|
|
179
|
+
// (the new session's factory has not re-run yet in this process). With
|
|
180
|
+
// auto-approve on, the handoff reaches startExecution, which creates the
|
|
181
|
+
// in-memory execution and then fails its first REQUIRED persistence write
|
|
182
|
+
// (pi-plans-exec appendEntry) on the stale surface — the exact incident.
|
|
183
|
+
const a = makeSessionPi();
|
|
184
|
+
setMessagingApi(a.api);
|
|
185
|
+
a.setStale();
|
|
186
|
+
|
|
187
|
+
process.env[AUTO_APPROVE_ENV] = "1";
|
|
188
|
+
try {
|
|
189
|
+
await assert.rejects(
|
|
190
|
+
() => executeCommand(makeCallCtx(workdir), path.join(workdir, "PLAN_v1.md")),
|
|
191
|
+
/stale/,
|
|
192
|
+
"a required persistence write dispatched before the new factory ran surfaces the stale error",
|
|
193
|
+
);
|
|
194
|
+
} finally {
|
|
195
|
+
delete process.env[AUTO_APPROVE_ENV];
|
|
196
|
+
}
|
|
197
|
+
// The crashed window leaves the in-memory execution created but unpersisted.
|
|
198
|
+
assert.ok(getExecution(), "crashed handoff left the in-memory execution created");
|
|
199
|
+
// The sanctioned recovery starts with the new session's factory re-run;
|
|
200
|
+
// from here on every write goes through the new pi only.
|
|
201
|
+
const b = makeSessionPi();
|
|
202
|
+
setMessagingApi(b.api);
|
|
203
|
+
await stopExecution(makeCallCtx(workdir), "test teardown");
|
|
204
|
+
|
|
205
|
+
// A full retry completes using only the new session's pi.
|
|
206
|
+
process.env[AUTO_APPROVE_ENV] = "1";
|
|
207
|
+
let outcome: Awaited<ReturnType<typeof executeCommand>>;
|
|
208
|
+
try {
|
|
209
|
+
outcome = await executeCommand(makeCallCtx(workdir), path.join(workdir, "PLAN_v1.md"));
|
|
210
|
+
} finally {
|
|
211
|
+
delete process.env[AUTO_APPROVE_ENV];
|
|
212
|
+
}
|
|
213
|
+
assert.equal(outcome.status, "executing");
|
|
214
|
+
assert.ok(b.calls.appendEntry > 0 && b.calls.sendMessage > 0, "retry persisted and announced via pi B");
|
|
215
|
+
assert.equal(a.calls.appendEntry + a.calls.sendMessage + a.calls.sendUserMessage, 0, "stale pi A never touched");
|
|
216
|
+
await stopExecution(makeCallCtx(workdir), "test teardown");
|
|
217
|
+
});
|
|
218
|
+
});
|