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
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
1
|
+
import * as assert from "node:assert/strict";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
2
4
|
import { describe, it } from "node:test";
|
|
3
|
-
import { buildCriticizerTask, buildReviewerTask, reviewerLanes } from "../src/refine-prompts.ts";
|
|
5
|
+
import { buildCriticizerTask, buildImplementationCriticizerTask, buildImplementationReviewerTask, buildReviewerTask, reviewerLanes } from "../src/refine-prompts.ts";
|
|
4
6
|
|
|
5
7
|
describe("reviewerLanes", () => {
|
|
6
8
|
it("uses stable lane ids for the big-plan fanout", () => {
|
|
@@ -47,3 +49,66 @@ describe("buildCriticizerTask", () => {
|
|
|
47
49
|
assert.match(text, /never rewrite the plan/);
|
|
48
50
|
});
|
|
49
51
|
});
|
|
52
|
+
|
|
53
|
+
describe("buildImplementationReviewerTask", () => {
|
|
54
|
+
it("anchors findings to the plan and explicitly assesses delivery maturity", () => {
|
|
55
|
+
const text = buildImplementationReviewerTask({
|
|
56
|
+
planText: "# plan",
|
|
57
|
+
planPath: "/tmp/PLAN_v1.md",
|
|
58
|
+
lens: "correctness",
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
assert.match(text, /Goal: review the implemented result in the worktree against the plan\./);
|
|
62
|
+
assert.match(text, /the IMPLEMENTATION in the worktree is under review/);
|
|
63
|
+
assert.match(text, /Judge the implementation against the plan's goals/);
|
|
64
|
+
assert.match(text, /did the executor ship a minimal MVP only, or refine for long-term growth/);
|
|
65
|
+
assert.match(text, /Out-of-scope improvement ideas are low severity by default/);
|
|
66
|
+
assert.match(text, /Review lens: correctness\./);
|
|
67
|
+
assert.match(text, /Surface at most five high-priority findings/);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("buildImplementationCriticizerTask", () => {
|
|
72
|
+
it("asks implementation-focused adversarial questions without rewriting the implementation", () => {
|
|
73
|
+
const text = buildImplementationCriticizerTask({
|
|
74
|
+
planText: "# plan",
|
|
75
|
+
planPath: "/tmp/PLAN_v1.md",
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
assert.match(text, /Goal: stress-test the implemented result's assumptions\./);
|
|
79
|
+
assert.match(text, /the IMPLEMENTATION in the worktree is under review/);
|
|
80
|
+
assert.match(text, /never rewrite the plan or the implementation/);
|
|
81
|
+
assert.match(text, /at most five adaptive questions/);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("plan-mode builders are unchanged by the implementation-mode addition", () => {
|
|
86
|
+
it("buildReviewerTask output is byte-identical to its prior contract", () => {
|
|
87
|
+
// Snapshot regression guard: changing the plan-mode brief would silently
|
|
88
|
+
// break existing reviewer subagents. Keep this stable.
|
|
89
|
+
const before = buildReviewerTask({ planText: "PLAN", planPath: "/p/PLAN_v1.md" });
|
|
90
|
+
assert.match(before, /Goal: review the plan against the repository\./);
|
|
91
|
+
assert.doesNotMatch(before, /IMPLEMENTATION in the worktree/);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("refine tool wires target to the right builder", () => {
|
|
96
|
+
it("forwards target=implementation to the implementation builders", () => {
|
|
97
|
+
const source = fs.readFileSync(path.join(process.cwd(), "tools", "refine.ts"), "utf8");
|
|
98
|
+
assert.match(source, /buildImplementationReviewerTask/);
|
|
99
|
+
assert.match(source, /buildImplementationCriticizerTask/);
|
|
100
|
+
assert.match(source, /target\s*===\s*"implementation"/);
|
|
101
|
+
assert.match(source, /params\.target\s*\?\?\s*"plan"/);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("criticizer spawn gets the graph tools and prompt like the reviewer", () => {
|
|
105
|
+
const source = fs.readFileSync(path.join(process.cwd(), "tools", "refine.ts"), "utf8");
|
|
106
|
+
const criticizerBlock = source.slice(
|
|
107
|
+
source.indexOf('params.role === "criticizer"'),
|
|
108
|
+
source.indexOf("const count = Math.min"),
|
|
109
|
+
);
|
|
110
|
+
assert.ok(criticizerBlock.length > 0, "criticizer block not found");
|
|
111
|
+
assert.match(criticizerBlock, /tools: subagentTools/);
|
|
112
|
+
assert.match(criticizerBlock, /graphPrompt/);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import * as fs from "node:fs";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { RefineOverlayComponent, RefineOverlayController } from "../src/refine-ui.ts";
|
|
6
|
+
import { visibleWidth } from "../src/refine-ui-helpers.ts";
|
|
7
|
+
import { applyRefineProgress, applyRefineResult, statusLabel, type RefineLaneState } from "../src/refine-ui-state.ts";
|
|
8
|
+
|
|
9
|
+
function lane(id = "lane-1", label = "reviewer-1", text = ""): RefineLaneState {
|
|
10
|
+
return {
|
|
11
|
+
id,
|
|
12
|
+
label,
|
|
13
|
+
status: "queued",
|
|
14
|
+
phase: "queued",
|
|
15
|
+
detail: "",
|
|
16
|
+
transcript: text ? [{ id: "0:content:0", type: "assistant-text", text, streaming: true }] : [],
|
|
17
|
+
currentTurnIndex: 0,
|
|
18
|
+
scrollOffset: 0,
|
|
19
|
+
followTranscript: true,
|
|
20
|
+
viewportHeight: 1,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function readyLane(id: string, label: string, text: string): RefineLaneState {
|
|
25
|
+
return {
|
|
26
|
+
...lane(id, label),
|
|
27
|
+
status: "running",
|
|
28
|
+
phase: "responding",
|
|
29
|
+
transcript: [{ id: "0:content:0", type: "assistant-text", text, streaming: false }],
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function streamingToolLane(id: string, label: string, text: string): RefineLaneState {
|
|
34
|
+
return {
|
|
35
|
+
...lane(id, label),
|
|
36
|
+
status: "running",
|
|
37
|
+
phase: "tool result",
|
|
38
|
+
transcript: [{ id: "0:tool:0", type: "tool-result", text, streaming: true, toolName: "read", isError: false }],
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function streamingAssistantLane(id: string, label: string, text: string): RefineLaneState {
|
|
43
|
+
return {
|
|
44
|
+
...lane(id, label),
|
|
45
|
+
status: "running",
|
|
46
|
+
phase: "responding",
|
|
47
|
+
transcript: [{ id: "0:content:0", type: "assistant-text", text, streaming: true }],
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const fakeTheme = {
|
|
52
|
+
fg: (_color: string, text: string) => text,
|
|
53
|
+
bold: (text: string) => text,
|
|
54
|
+
} as never;
|
|
55
|
+
|
|
56
|
+
describe("refine overlay state", () => {
|
|
57
|
+
it("merges streaming deltas and keeps complete tool output without clipping it", () => {
|
|
58
|
+
const state = lane();
|
|
59
|
+
applyRefineProgress(state, { type: "turn", phase: "start", turnIndex: 1 });
|
|
60
|
+
applyRefineProgress(state, {
|
|
61
|
+
type: "transcript",
|
|
62
|
+
phase: "update",
|
|
63
|
+
entryType: "assistant-text",
|
|
64
|
+
key: "content:0",
|
|
65
|
+
text: "first ",
|
|
66
|
+
update: "append",
|
|
67
|
+
streaming: true,
|
|
68
|
+
});
|
|
69
|
+
applyRefineProgress(state, {
|
|
70
|
+
type: "transcript",
|
|
71
|
+
phase: "update",
|
|
72
|
+
entryType: "assistant-text",
|
|
73
|
+
key: "content:0",
|
|
74
|
+
text: "second",
|
|
75
|
+
update: "append",
|
|
76
|
+
streaming: true,
|
|
77
|
+
});
|
|
78
|
+
const output = "x".repeat(500);
|
|
79
|
+
applyRefineProgress(state, {
|
|
80
|
+
type: "transcript",
|
|
81
|
+
phase: "end",
|
|
82
|
+
entryType: "tool-result",
|
|
83
|
+
key: "tool:call-1",
|
|
84
|
+
text: output,
|
|
85
|
+
update: "replace",
|
|
86
|
+
streaming: false,
|
|
87
|
+
toolCallId: "call-1",
|
|
88
|
+
toolName: "read",
|
|
89
|
+
isError: false,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
assert.equal(state.transcript.find((entry) => entry.type === "assistant-text")?.text, "first second");
|
|
93
|
+
assert.equal(state.transcript.find((entry) => entry.type === "tool-result")?.text, output);
|
|
94
|
+
assert.equal(state.detail, output);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("keeps terminal lane states stable after completion", () => {
|
|
98
|
+
const state = lane();
|
|
99
|
+
applyRefineResult(state, { ok: true, output: "final conclusion", stderr: "", turns: 1 });
|
|
100
|
+
applyRefineProgress(state, { type: "turn", phase: "start" });
|
|
101
|
+
|
|
102
|
+
assert.equal(state.status, "complete");
|
|
103
|
+
assert.equal(state.phase, "complete");
|
|
104
|
+
assert.equal(state.transcript.at(-1)?.text, "final conclusion");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("distinguishes cancelled and timed out child results", () => {
|
|
108
|
+
const cancelled = lane();
|
|
109
|
+
applyRefineResult(cancelled, { ok: false, output: "", stderr: "", turns: 0, cancelled: true, errorMessage: "Esc" });
|
|
110
|
+
assert.equal(cancelled.status, "cancelled");
|
|
111
|
+
assert.equal(cancelled.phase, "cancelled");
|
|
112
|
+
|
|
113
|
+
const timedOut = lane();
|
|
114
|
+
applyRefineResult(timedOut, { ok: false, output: "", stderr: "", turns: 0, timedOut: true, errorMessage: "timeout" });
|
|
115
|
+
assert.equal(timedOut.status, "failed");
|
|
116
|
+
assert.equal(timedOut.phase, "timed out");
|
|
117
|
+
assert.equal(statusLabel(timedOut.status), "failed");
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("refine overlay viewport", () => {
|
|
122
|
+
it("renders the resolved overlay width without a legacy 96-column cap", () => {
|
|
123
|
+
const component = new RefineOverlayComponent(fakeTheme, "reviewer", [readyLane("lane-1", "reviewer-1", "output")], () => {});
|
|
124
|
+
const lines = component.render(120);
|
|
125
|
+
assert.ok(lines.some((line) => line.startsWith("┌")));
|
|
126
|
+
assert.ok(lines.every((line) => visibleWidth(line) <= 120));
|
|
127
|
+
assert.ok(lines.some((line) => line.includes("output")));
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("renders model-aware titles and footer hints", () => {
|
|
131
|
+
const lanes = [
|
|
132
|
+
readyLane("lane-1", "correctness", "correctness output"),
|
|
133
|
+
readyLane("lane-2", "ordering", "ordering output"),
|
|
134
|
+
readyLane("lane-3", "verification", "verification output"),
|
|
135
|
+
];
|
|
136
|
+
const component = new RefineOverlayComponent(
|
|
137
|
+
fakeTheme,
|
|
138
|
+
"reviewer",
|
|
139
|
+
lanes,
|
|
140
|
+
() => {},
|
|
141
|
+
undefined,
|
|
142
|
+
"zai/glm-5.3-flash:high",
|
|
143
|
+
);
|
|
144
|
+
const lines = component.render(120);
|
|
145
|
+
assert.ok(lines.some((line) => line.includes("Reviewer (zai/glm-5.3-flash:high)")));
|
|
146
|
+
assert.ok(lines.some((line) => line.includes("Esc 关闭")));
|
|
147
|
+
assert.ok(lines.some((line) => line.includes("Tab & Shift + Tab")));
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("renders only the last three wrapped lines for streaming transcript previews", () => {
|
|
151
|
+
const linesText = ["line 1", "line 2", "line 3", "line 4", "line 5"].join("\n");
|
|
152
|
+
const component = new RefineOverlayComponent(
|
|
153
|
+
fakeTheme,
|
|
154
|
+
"criticizer",
|
|
155
|
+
[streamingToolLane("lane-1", "criticizer", linesText)],
|
|
156
|
+
() => {},
|
|
157
|
+
undefined,
|
|
158
|
+
"singularity-gpt/gpt-5.5:xhigh",
|
|
159
|
+
);
|
|
160
|
+
const lines = component.render(90);
|
|
161
|
+
assert.ok(lines.some((line) => line.includes("Criticizer (singularity-gpt/gpt-5.5:xhigh)")));
|
|
162
|
+
assert.ok(lines.some((line) => line.includes("…")));
|
|
163
|
+
assert.ok(lines.some((line) => line.includes("line 3")));
|
|
164
|
+
assert.ok(lines.some((line) => line.includes("line 4")));
|
|
165
|
+
assert.ok(lines.some((line) => line.includes("line 5")));
|
|
166
|
+
assert.equal(lines.some((line) => line.includes("line 1")), false);
|
|
167
|
+
assert.equal(lines.some((line) => line.includes("line 2")), false);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("renders only the last three wrapped lines for streaming assistant previews", () => {
|
|
171
|
+
const linesText = ["line 1", "line 2", "line 3", "line 4", "line 5"].join("\n");
|
|
172
|
+
const component = new RefineOverlayComponent(
|
|
173
|
+
fakeTheme,
|
|
174
|
+
"reviewer",
|
|
175
|
+
[streamingAssistantLane("lane-1", "reviewer", linesText)],
|
|
176
|
+
() => {},
|
|
177
|
+
undefined,
|
|
178
|
+
"zai/glm-5.3-flash:high",
|
|
179
|
+
);
|
|
180
|
+
const lines = component.render(90);
|
|
181
|
+
assert.ok(lines.some((line) => line.includes("Reviewer (zai/glm-5.3-flash:high)")));
|
|
182
|
+
assert.ok(lines.some((line) => line.includes("…")));
|
|
183
|
+
assert.ok(lines.some((line) => line.includes("line 3")));
|
|
184
|
+
assert.ok(lines.some((line) => line.includes("line 4")));
|
|
185
|
+
assert.ok(lines.some((line) => line.includes("line 5")));
|
|
186
|
+
assert.equal(lines.some((line) => line.includes("line 1")), false);
|
|
187
|
+
assert.equal(lines.some((line) => line.includes("line 2")), false);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("renders a single pane with pi-btw-style transcript badges", () => {
|
|
191
|
+
const component = new RefineOverlayComponent(
|
|
192
|
+
fakeTheme,
|
|
193
|
+
"criticizer",
|
|
194
|
+
[readyLane("lane-1", "criticizer", "assistant output")],
|
|
195
|
+
() => {},
|
|
196
|
+
);
|
|
197
|
+
const lines = component.render(90);
|
|
198
|
+
assert.equal(lines.filter((line) => line.startsWith("┌")).length, 2);
|
|
199
|
+
assert.equal(lines.filter((line) => line.startsWith("└")).length, 2);
|
|
200
|
+
assert.ok(lines.some((line) => line.includes("assistant")));
|
|
201
|
+
assert.ok(lines.some((line) => line.includes("assistant output")));
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("renders three vertical equal-height independent panes", () => {
|
|
205
|
+
const lanes = [
|
|
206
|
+
readyLane("lane-1", "correctness", "correctness output"),
|
|
207
|
+
readyLane("lane-2", "ordering", "ordering output"),
|
|
208
|
+
readyLane("lane-3", "verification", "verification output"),
|
|
209
|
+
];
|
|
210
|
+
const component = new RefineOverlayComponent(fakeTheme, "reviewer", lanes, () => {});
|
|
211
|
+
const lines = component.render(120);
|
|
212
|
+
assert.equal(lines.filter((line) => line.startsWith("┌")).length, 4);
|
|
213
|
+
assert.equal(lines.filter((line) => line.startsWith("└")).length, 4);
|
|
214
|
+
assert.ok(lines.some((line) => line.includes("correctness")));
|
|
215
|
+
assert.ok(lines.some((line) => line.includes("ordering")));
|
|
216
|
+
assert.ok(lines.some((line) => line.includes("verification")));
|
|
217
|
+
const topIndexes = lines.flatMap((line, index) => line.startsWith("┌") ? [index] : []);
|
|
218
|
+
const bottomIndexes = lines.flatMap((line, index) => line.startsWith("└") ? [index] : []);
|
|
219
|
+
const paneHeights = topIndexes.slice(1).map((value, index) => bottomIndexes[index]! - value);
|
|
220
|
+
assert.ok(paneHeights.every((height) => height === paneHeights[0]));
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("follows the bottom, pauses on upward scroll, and switches independently with Tab", () => {
|
|
224
|
+
const long = Array.from({ length: 30 }, (_, index) => `line-${index + 1}`).join("\n");
|
|
225
|
+
const lanes = [readyLane("lane-1", "one", long), readyLane("lane-2", "two", long)];
|
|
226
|
+
const component = new RefineOverlayComponent(fakeTheme, "reviewer", lanes, () => {});
|
|
227
|
+
component.render(100);
|
|
228
|
+
const initialOffset = lanes[0]!.scrollOffset;
|
|
229
|
+
assert.ok(initialOffset > 0);
|
|
230
|
+
component.handleInput("\x1b[A");
|
|
231
|
+
component.render(100);
|
|
232
|
+
assert.equal(lanes[0]!.followTranscript, false);
|
|
233
|
+
assert.ok(lanes[0]!.scrollOffset < initialOffset);
|
|
234
|
+
const laneOneOffset = lanes[1]!.scrollOffset;
|
|
235
|
+
component.handleInput("\t");
|
|
236
|
+
component.handleInput("\x1b[A");
|
|
237
|
+
component.render(100);
|
|
238
|
+
assert.ok(lanes[1]!.scrollOffset < laneOneOffset);
|
|
239
|
+
assert.equal(lanes[0]!.scrollOffset, initialOffset - 1);
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
describe("refine overlay wiring", () => {
|
|
244
|
+
it("uses pi-btw geometry, no input box, and no pi-btw runtime dependency", () => {
|
|
245
|
+
const source = fs.readFileSync(path.join(process.cwd(), "src", "refine-ui.ts"), "utf8");
|
|
246
|
+
assert.match(source, /custom<void>/);
|
|
247
|
+
assert.match(source, /overlay:\s*true/);
|
|
248
|
+
assert.match(source, /width: "78%"/);
|
|
249
|
+
assert.match(source, /minWidth: OVERLAY_MIN_WIDTH/);
|
|
250
|
+
assert.match(source, /maxHeight: "78%"/);
|
|
251
|
+
assert.match(source, /anchor: "top-center"/);
|
|
252
|
+
assert.match(source, /margin: \{ top: 1, left: 2, right: 2 \}/);
|
|
253
|
+
assert.match(source, /previewTranscriptText/);
|
|
254
|
+
assert.match(source, /footerText/);
|
|
255
|
+
assert.match(source, /Tab|tab/);
|
|
256
|
+
assert.match(source, /scrollOffset|followTranscript/);
|
|
257
|
+
assert.doesNotMatch(source, /new Input|inputFrameLine|onSubmit/);
|
|
258
|
+
assert.doesNotMatch(source, /pi-btw|createAgentSession|AgentSession/);
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
describe("refine overlay lifecycle", () => {
|
|
262
|
+
it("treats Esc as close-only and never relays the abort hook", () => {
|
|
263
|
+
const calls: string[] = [];
|
|
264
|
+
const controller = new RefineOverlayController("reviewer", [{ id: "lane-1", label: "one" }], () => {
|
|
265
|
+
calls.push("abort");
|
|
266
|
+
});
|
|
267
|
+
controller.cancel();
|
|
268
|
+
assert.deepEqual(calls, [], "running Esc must not invoke the abort hook");
|
|
269
|
+
assert.equal(controller.isClosed(), true);
|
|
270
|
+
|
|
271
|
+
const another = new RefineOverlayController("reviewer", [{ id: "lane-1", label: "one" }], () => {
|
|
272
|
+
calls.push("abort");
|
|
273
|
+
});
|
|
274
|
+
another.cancel();
|
|
275
|
+
assert.deepEqual(calls, [], "repeated Esc must still not invoke the abort hook");
|
|
276
|
+
assert.equal(another.isClosed(), true);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("keeps close idempotent across repeated calls", () => {
|
|
280
|
+
const controller = new RefineOverlayController("reviewer", [{ id: "lane-1", label: "one" }], () => undefined);
|
|
281
|
+
return controller.close().then(() => controller.close()).then(() => {
|
|
282
|
+
assert.equal(controller.isClosed(), true);
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("pairs mouse-mode enable with disable through the controller lifecycle", () => {
|
|
287
|
+
const writes: string[] = [];
|
|
288
|
+
const fakeTui = {
|
|
289
|
+
terminal: { write: (data: string) => { writes.push(data); } },
|
|
290
|
+
requestRender: () => undefined,
|
|
291
|
+
} as never;
|
|
292
|
+
const controller = new RefineOverlayController(
|
|
293
|
+
"reviewer",
|
|
294
|
+
[{ id: "lane-1", label: "one" }],
|
|
295
|
+
() => undefined,
|
|
296
|
+
);
|
|
297
|
+
// Simulate the factory hand-off path that open() runs.
|
|
298
|
+
(controller as unknown as { tui: unknown }).tui = fakeTui;
|
|
299
|
+
(controller as unknown as {
|
|
300
|
+
component: { dispose: () => void; tui?: unknown } | undefined;
|
|
301
|
+
}).component = new RefineOverlayComponent(
|
|
302
|
+
{ fg: (_color: string, text: string) => text, bold: (text: string) => text } as never,
|
|
303
|
+
"reviewer",
|
|
304
|
+
controller.lanes,
|
|
305
|
+
() => undefined,
|
|
306
|
+
fakeTui,
|
|
307
|
+
);
|
|
308
|
+
assert.ok(writes.some((entry) => entry.includes("\x1b[?1000h")), "mouse-enable must be emitted");
|
|
309
|
+
return controller.close().then(() => {
|
|
310
|
+
assert.ok(writes.some((entry) => entry.includes("\x1b[?1000l")), "mouse-disable must be emitted");
|
|
311
|
+
assert.equal(controller.isClosed(), true);
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it("closes refinement overlays on completion instead of retaining them", () => {
|
|
316
|
+
const source = fs.readFileSync(path.join(process.cwd(), "tools", "refine.ts"), "utf8");
|
|
317
|
+
assert.match(source, /await execution\.close\(\)/);
|
|
318
|
+
assert.doesNotMatch(source, /await execution\.retain\(\)|retainedRefineOverlay|closeRetainedRefineOverlay|markFinished|isFinished/);
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
describe("overlay frame colors and ANSI width", () => {
|
|
323
|
+
// Theme stub that emits REAL CSI sequences so frame-color and width behavior
|
|
324
|
+
// can be observed on the rendered output (fakeTheme strips colors entirely).
|
|
325
|
+
const ansiCodes: Record<string, number> = {
|
|
326
|
+
border: 31,
|
|
327
|
+
borderAccent: 32,
|
|
328
|
+
accent: 33,
|
|
329
|
+
dim: 90,
|
|
330
|
+
muted: 93,
|
|
331
|
+
success: 92,
|
|
332
|
+
error: 91,
|
|
333
|
+
warning: 93,
|
|
334
|
+
};
|
|
335
|
+
const ansiTheme = {
|
|
336
|
+
fg: (color: string, text: string) => `\x1b[${ansiCodes[color] ?? 39}m${text}\x1b[0m`,
|
|
337
|
+
bold: (text: string) => `\x1b[1m${text}\x1b[0m`,
|
|
338
|
+
} as never;
|
|
339
|
+
|
|
340
|
+
it("keeps the right wall intact on ANSI-heavy rows (every row exactly frame-wide)", () => {
|
|
341
|
+
const heavy = `\x1b[90m[${"payload".repeat(30)}]\x1b[0m styled \x1b[1mbold\x1b[0m tail`;
|
|
342
|
+
const component = new RefineOverlayComponent(ansiTheme, "reviewer", [readyLane("lane-1", "reviewer-1", heavy)], () => {});
|
|
343
|
+
const lines = component.render(120);
|
|
344
|
+
for (const line of lines) {
|
|
345
|
+
assert.equal(visibleWidth(line), 120, `row must be exactly frame-wide: ${JSON.stringify(line.slice(0, 80))}`);
|
|
346
|
+
}
|
|
347
|
+
const wallRows = lines.filter((line) => line.includes("│"));
|
|
348
|
+
assert.ok(wallRows.length >= 3, "title, body, and footer rows must carry side walls");
|
|
349
|
+
for (const row of wallRows) {
|
|
350
|
+
assert.match(row, /\x1b\[3[12]m│\x1b\[0m$/); // right wall survives fitLine
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it("renders border for the outer frame and borderAccent only for the selected lane", () => {
|
|
355
|
+
const lanes = [
|
|
356
|
+
readyLane("lane-1", "correctness", "out-1"),
|
|
357
|
+
readyLane("lane-2", "ordering", "out-2"),
|
|
358
|
+
readyLane("lane-3", "verification", "out-3"),
|
|
359
|
+
];
|
|
360
|
+
const component = new RefineOverlayComponent(ansiTheme, "reviewer", lanes, () => {});
|
|
361
|
+
const lines = component.render(120);
|
|
362
|
+
const outerTop = lines[0]!;
|
|
363
|
+
assert.match(outerTop, /^\x1b\[31m┌/);
|
|
364
|
+
const topBorders = lines.filter((line) => /\x1b\[31m┌/.test(line));
|
|
365
|
+
const selectedTopBorders = lines.filter((line) => /\x1b\[32m┌/.test(line));
|
|
366
|
+
assert.equal(topBorders.length, 3, "outer frame + two unselected panes use border color");
|
|
367
|
+
assert.equal(selectedTopBorders.length, 1, "exactly the selected pane uses borderAccent");
|
|
368
|
+
// Title row walls follow the outer frame color; content keeps accent.
|
|
369
|
+
assert.match(lines[1]!, /\x1b\[31m│/);
|
|
370
|
+
assert.match(lines[1]!, /\x1b\[33m/);
|
|
371
|
+
// Footer row walls also follow the outer frame color.
|
|
372
|
+
assert.match(lines[lines.length - 2]!, /\x1b\[31m│/);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it("keeps ANSI sequences atomic through truncateToWidth and wrapTextWithAnsi hard splits", async () => {
|
|
376
|
+
const { truncateToWidth, wrapTextWithAnsi } = await import("../src/refine-ui-helpers.ts");
|
|
377
|
+
const styled = `\x1b[90m${"ab".repeat(30)}\x1b[0m tail`;
|
|
378
|
+
const cut = truncateToWidth(styled, 10);
|
|
379
|
+
assert.equal(visibleWidth(cut), 10);
|
|
380
|
+
assert.doesNotMatch(cut, /\x1b\[[^\x40-\x7e]*$/); // no dangling CSI at the cut point
|
|
381
|
+
|
|
382
|
+
const longToken = `\x1b[90m${"w".repeat(80)}\x1b[0m`;
|
|
383
|
+
const wrapped = wrapTextWithAnsi(longToken, 20);
|
|
384
|
+
assert.ok(wrapped.length >= 4);
|
|
385
|
+
for (const line of wrapped) {
|
|
386
|
+
assert.ok(visibleWidth(line) <= 20);
|
|
387
|
+
assert.doesNotMatch(line, /\x1b\[[^\x40-\x7e]*$/); // every sequence stays whole
|
|
388
|
+
}
|
|
389
|
+
// Zero-width accounting: pure escape payload measures nothing.
|
|
390
|
+
assert.equal(visibleWidth("\x1b[38;5;244m\x1b[0m"), 0);
|
|
391
|
+
});
|
|
392
|
+
});
|
package/tests/state.test.ts
CHANGED
|
@@ -13,7 +13,6 @@ import {
|
|
|
13
13
|
recordDecision,
|
|
14
14
|
setLanguage,
|
|
15
15
|
setArtifactRoot,
|
|
16
|
-
setExecutionModel,
|
|
17
16
|
setRole,
|
|
18
17
|
setRunStatus,
|
|
19
18
|
showConfig,
|
|
@@ -69,7 +68,7 @@ describe("init", () => {
|
|
|
69
68
|
assert.equal(config.reviewer.mode, "delegated-subagent");
|
|
70
69
|
assert.equal(config.reviewer.confirmed_at, null);
|
|
71
70
|
assert.equal(config.criticizer.confirmed_at, null);
|
|
72
|
-
assert.equal(config
|
|
71
|
+
assert.equal("execution" in config, false);
|
|
73
72
|
assert.equal(config.artifact_root, "./docs/pi-plans");
|
|
74
73
|
assert.equal(config.artifact_root_source, "unset");
|
|
75
74
|
assert.equal(config.artifact_root_updated_at, null);
|
|
@@ -238,21 +237,19 @@ describe("runs", () => {
|
|
|
238
237
|
});
|
|
239
238
|
});
|
|
240
239
|
|
|
241
|
-
describe("execution
|
|
242
|
-
it("
|
|
243
|
-
const workdir = mkWorkdir("exec
|
|
240
|
+
describe("legacy execution config", () => {
|
|
241
|
+
it("strips legacy execution config on load and rewrite", () => {
|
|
242
|
+
const workdir = mkWorkdir("legacy-exec");
|
|
244
243
|
git(workdir, "init", "-q");
|
|
245
244
|
initState(workdir);
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
assert.equal(
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
assert.equal(readConfig(workdir).execution.model_selector, null);
|
|
255
|
-
assert.equal(readConfig(workdir).execution.source, "user");
|
|
245
|
+
const configPath = path.join(commonDir(workdir), "pi_plans", "config.json");
|
|
246
|
+
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
247
|
+
config.execution = { model_selector: "zai/glm-5.3-flash:high", source: "user", updated_at: null };
|
|
248
|
+
fs.writeFileSync(configPath, `${JSON.stringify(config, null, "\t")}\n`, "utf8");
|
|
249
|
+
const shown = showConfig(workdir) as any;
|
|
250
|
+
assert.equal(shown.execution, undefined);
|
|
251
|
+
setLanguage(workdir, "zh-Hans", "user");
|
|
252
|
+
assert.equal(readConfig(workdir).execution, undefined);
|
|
256
253
|
});
|
|
257
254
|
});
|
|
258
255
|
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import * as fs from "node:fs";
|
|
4
|
+
import * as os from "node:os";
|
|
5
|
+
import * as path from "node:path";
|
|
6
|
+
import { normalizeSubagentEvent, runPiSubagent, type SubagentProgressEvent } from "../src/subagent.ts";
|
|
7
|
+
|
|
8
|
+
async function withFakePi(
|
|
9
|
+
task: string,
|
|
10
|
+
options: { signal?: AbortSignal; timeoutMs?: number; onProgress?: (event: SubagentProgressEvent) => void } = {},
|
|
11
|
+
) {
|
|
12
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-fake-pi-"));
|
|
13
|
+
const script = path.join(dir, "fake-pi.mjs");
|
|
14
|
+
fs.writeFileSync(
|
|
15
|
+
script,
|
|
16
|
+
[
|
|
17
|
+
'const emit = (event) => process.stdout.write(JSON.stringify(event) + "\\n");',
|
|
18
|
+
'emit({ type: "turn_start" });',
|
|
19
|
+
'if (process.argv.includes("Task: slow")) await new Promise((resolve) => setTimeout(resolve, 5000));',
|
|
20
|
+
'else {',
|
|
21
|
+
' emit({ type: "message_start", message: { role: "assistant", content: [] } });',
|
|
22
|
+
' emit({ type: "message_update", assistantMessageEvent: { type: "text_delta", delta: "working" } });',
|
|
23
|
+
' emit({ type: "tool_execution_start", toolCallId: "call-1", toolName: "read", args: { path: "PLAN_v1.md" } });',
|
|
24
|
+
' emit({ type: "message_end", message: { role: "assistant", model: "fake/model", content: [{ type: "text", text: "final conclusion" }] } });',
|
|
25
|
+
'}',
|
|
26
|
+
].join("\n"),
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
const previousScript = process.argv[1];
|
|
30
|
+
process.argv[1] = script;
|
|
31
|
+
try {
|
|
32
|
+
return await runPiSubagent({
|
|
33
|
+
systemPrompt: "test system prompt",
|
|
34
|
+
task,
|
|
35
|
+
cwd: process.cwd(),
|
|
36
|
+
signal: options.signal,
|
|
37
|
+
timeoutMs: options.timeoutMs ?? 2000,
|
|
38
|
+
onProgress: options.onProgress,
|
|
39
|
+
});
|
|
40
|
+
} finally {
|
|
41
|
+
process.argv[1] = previousScript;
|
|
42
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
describe("subagent runner lifecycle", () => {
|
|
48
|
+
it("waits for JSONL completion and forwards progress", async () => {
|
|
49
|
+
const progress: string[] = [];
|
|
50
|
+
const result = await withFakePi("normal", {
|
|
51
|
+
timeoutMs: 2000,
|
|
52
|
+
onProgress: (event) => progress.push(`${event.type}:${event.phase ?? ""}`),
|
|
53
|
+
});
|
|
54
|
+
assert.equal(result.ok, true);
|
|
55
|
+
assert.equal(result.output, "final conclusion");
|
|
56
|
+
assert.equal(result.model, "fake/model");
|
|
57
|
+
assert.ok(progress.includes("process:started"));
|
|
58
|
+
assert.ok(progress.includes("transcript:update"));
|
|
59
|
+
assert.ok(progress.includes("process:exited"));
|
|
60
|
+
assert.equal(result.turns, 1);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("returns a cancelled result after aborting the child", async () => {
|
|
64
|
+
const abort = new AbortController();
|
|
65
|
+
const promise = withFakePi("slow", { signal: abort.signal, timeoutMs: 2000 });
|
|
66
|
+
setTimeout(() => abort.abort(), 40);
|
|
67
|
+
const result = await promise;
|
|
68
|
+
assert.equal(result.ok, false);
|
|
69
|
+
assert.equal(result.cancelled, true);
|
|
70
|
+
assert.equal(result.timedOut, undefined);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("returns a timed out result after terminating a slow child", async () => {
|
|
74
|
+
const result = await withFakePi("slow", { timeoutMs: 40 });
|
|
75
|
+
assert.equal(result.ok, false);
|
|
76
|
+
assert.equal(result.timedOut, true);
|
|
77
|
+
assert.equal(result.cancelled, undefined);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("subagent progress events", () => {
|
|
82
|
+
it("normalizes turn and message lifecycle events", () => {
|
|
83
|
+
assert.deepEqual(normalizeSubagentEvent({ type: "turn_start", turnIndex: 2 }), [{ type: "turn", phase: "start", turnIndex: 2 }]);
|
|
84
|
+
assert.deepEqual(
|
|
85
|
+
normalizeSubagentEvent({
|
|
86
|
+
type: "message_update",
|
|
87
|
+
assistantMessageEvent: { type: "text_delta", contentIndex: 1, delta: "checking the plan" },
|
|
88
|
+
}),
|
|
89
|
+
[{ type: "transcript", phase: "update", entryType: "assistant-text", key: "content:1", text: "checking the plan", update: "append", streaming: true }],
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("normalizes tool progress without exposing unbounded arguments", () => {
|
|
94
|
+
assert.deepEqual(
|
|
95
|
+
normalizeSubagentEvent({
|
|
96
|
+
type: "tool_execution_start",
|
|
97
|
+
toolCallId: "call-1",
|
|
98
|
+
toolName: "read",
|
|
99
|
+
args: { path: "/tmp/plan.md" },
|
|
100
|
+
}),
|
|
101
|
+
[{
|
|
102
|
+
type: "transcript",
|
|
103
|
+
phase: "start",
|
|
104
|
+
entryType: "tool-call",
|
|
105
|
+
key: "tool:call-1",
|
|
106
|
+
text: '{\n "path": "/tmp/plan.md"\n}',
|
|
107
|
+
update: "replace",
|
|
108
|
+
streaming: true,
|
|
109
|
+
toolCallId: "call-1",
|
|
110
|
+
toolName: "read",
|
|
111
|
+
}],
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("ignores unknown or malformed events", () => {
|
|
116
|
+
assert.deepEqual(normalizeSubagentEvent(undefined), []);
|
|
117
|
+
assert.deepEqual(normalizeSubagentEvent({ type: "future_event" }), []);
|
|
118
|
+
assert.deepEqual(normalizeSubagentEvent({ type: "message_end" }), []);
|
|
119
|
+
});
|
|
120
|
+
});
|