pi-plans 0.1.1 → 0.2.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 +47 -15
- package/index.ts +143 -95
- package/package.json +1 -1
- package/references/pi-planning-workflow.md +6 -3
- package/references/state-and-config.md +1 -1
- package/src/autocomplete.ts +163 -0
- package/src/compaction.ts +502 -0
- package/src/exec.ts +1098 -95
- package/src/plan.ts +143 -0
- package/src/query-hook.ts +82 -0
- package/src/refine-ui-helpers.ts +89 -0
- package/src/refine-ui-state.ts +78 -0
- package/src/refine-ui.ts +322 -0
- package/src/state.ts +19 -1
- package/src/subagent.ts +196 -69
- package/tests/autocomplete.test.ts +142 -0
- package/tests/compaction.test.ts +74 -0
- package/tests/exec.test.ts +803 -101
- package/tests/execute-plan.test.ts +65 -0
- package/tests/plan.test.ts +94 -1
- package/tests/plans.test.ts +37 -0
- package/tests/query-hook.test.ts +82 -0
- package/tests/refine-ui.test.ts +127 -0
- package/tests/state.test.ts +57 -0
- package/tests/subagent.test.ts +114 -0
- package/tools/ask-choice.ts +22 -0
- package/tools/execute-plan.ts +13 -5
- package/tools/plans.ts +16 -0
- package/tools/refine.ts +125 -71
- package/src/execution-panel.ts +0 -499
- package/tests/execution-panel.test.ts +0 -205
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import * as assert from "node:assert/strict";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { after, before, describe, it } from "node:test";
|
|
6
|
+
|
|
7
|
+
import { getExecution, startExecution, stopExecution } from "../src/exec.ts";
|
|
8
|
+
|
|
9
|
+
let tmpRoot: string;
|
|
10
|
+
|
|
11
|
+
before(() => {
|
|
12
|
+
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-execute-test-"));
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
after(() => {
|
|
16
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
function mkWorkdir(name: string): string {
|
|
20
|
+
const dir = path.join(tmpRoot, name);
|
|
21
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
22
|
+
return dir;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("execute handoff", () => {
|
|
26
|
+
it("starts execution without switching models", async () => {
|
|
27
|
+
const workdir = mkWorkdir("handoff");
|
|
28
|
+
const recorded = {
|
|
29
|
+
messages: [] as string[],
|
|
30
|
+
entries: [] as Array<{ customType: string; data: unknown }>,
|
|
31
|
+
setModelCalls: 0,
|
|
32
|
+
};
|
|
33
|
+
const pi = {
|
|
34
|
+
appendEntry: (customType: string, data: unknown) => {
|
|
35
|
+
recorded.entries.push({ customType, data });
|
|
36
|
+
},
|
|
37
|
+
sendMessage: (message: { customType: string; content: string }) => {
|
|
38
|
+
recorded.messages.push(message.content);
|
|
39
|
+
},
|
|
40
|
+
setModel: async () => {
|
|
41
|
+
recorded.setModelCalls += 1;
|
|
42
|
+
return true;
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
const ctx = {
|
|
46
|
+
cwd: workdir,
|
|
47
|
+
ui: {
|
|
48
|
+
setStatus: () => {},
|
|
49
|
+
theme: {
|
|
50
|
+
fg: (_kind: string, text: string) => text,
|
|
51
|
+
bold: (text: string) => text,
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
} as any;
|
|
55
|
+
|
|
56
|
+
await startExecution(pi as any, ctx, path.join(workdir, "PLAN_v1.md"), [
|
|
57
|
+
{ id: "VC-001", text: "first item", done: false },
|
|
58
|
+
] as any);
|
|
59
|
+
assert.equal(recorded.setModelCalls, 0);
|
|
60
|
+
assert.ok(getExecution());
|
|
61
|
+
|
|
62
|
+
await stopExecution(pi as any, ctx, "cleanup");
|
|
63
|
+
assert.equal(getExecution(), null);
|
|
64
|
+
});
|
|
65
|
+
});
|
package/tests/plan.test.ts
CHANGED
|
@@ -5,7 +5,7 @@ import * as fs from "node:fs";
|
|
|
5
5
|
import * as os from "node:os";
|
|
6
6
|
import * as path from "node:path";
|
|
7
7
|
import { after, before, describe, it } from "node:test";
|
|
8
|
-
import { latestPlanVersion, nextPlanVersionPath, parseChecklist, scanDoneMarkers } from "../src/plan.ts";
|
|
8
|
+
import { latestPlanVersion, nextPlanVersionPath, parseChecklist, parseImplItems, resolveImplStatuses, scanCurrentIMarkers, scanDoneMarkers, scanImplMarkers, shortImplDescription, extractCoverage, resolveCurrentI, inferCurrentI } from "../src/plan.ts";
|
|
9
9
|
|
|
10
10
|
const PLAN = `# PLAN_v1 - demo
|
|
11
11
|
|
|
@@ -103,3 +103,96 @@ describe("nextPlanVersionPath", () => {
|
|
|
103
103
|
assert.equal(next.path, path.join(empty, "PLAN_v1.md"));
|
|
104
104
|
});
|
|
105
105
|
});
|
|
106
|
+
|
|
107
|
+
describe("implementation items", () => {
|
|
108
|
+
const IMPL_PLAN = `# PLAN_v1 - demo
|
|
109
|
+
|
|
110
|
+
## Implementation Items
|
|
111
|
+
|
|
112
|
+
- \`I-001\`: Add state helpers in src/exec.ts; evaluate percent and coverage. Details follow:
|
|
113
|
+
- nested sub-bullet ignored
|
|
114
|
+
- \`I-002\`: Wire the turn_end trigger and status bar.
|
|
115
|
+
- not an item line
|
|
116
|
+
- \`I-001\`: duplicate id ignored
|
|
117
|
+
|
|
118
|
+
## Verifier Checklist
|
|
119
|
+
|
|
120
|
+
- [ ] \`VC-001\` covers \`I-001\` and \`I-002\`; pass condition: tests green; metric: 0 fail.
|
|
121
|
+
- [ ] \`VC-002\` covers \`I-002\`; pass condition: lint clean; metric: zero findings.
|
|
122
|
+
`;
|
|
123
|
+
|
|
124
|
+
it("parses strict top-level single-line items and ignores continuations", () => {
|
|
125
|
+
const items = parseImplItems(IMPL_PLAN);
|
|
126
|
+
assert.equal(items.length, 2);
|
|
127
|
+
assert.equal(items[0]?.id, "I-001");
|
|
128
|
+
// First line only: the multi-line body and nested sub-bullet are ignored.
|
|
129
|
+
assert.match(items[0]!.text, /^Add state helpers/);
|
|
130
|
+
assert.doesNotMatch(items[0]!.text, /nested/);
|
|
131
|
+
assert.equal(items[1]?.id, "I-002");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("returns empty without an Implementation Items section", () => {
|
|
135
|
+
assert.equal(parseImplItems("# no items here\n- `I-001`: orphan\n").length, 0);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("shortens descriptions at sentence or semicolon boundaries and caps length", () => {
|
|
139
|
+
assert.equal(shortImplDescription("Add helpers; evaluate percent. Then more."), "Add helpers");
|
|
140
|
+
assert.equal(shortImplDescription("First sentence. Second one."), "First sentence.");
|
|
141
|
+
const long = "x".repeat(120);
|
|
142
|
+
assert.equal(shortImplDescription(long).length, 80);
|
|
143
|
+
assert.match(shortImplDescription(long), /…$/);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("extracts coverage refs before the first semicolon only", () => {
|
|
147
|
+
assert.deepEqual(extractCoverage("`VC-001` covers `I-001` and `I-002`; pass: `I-003` mentioned late"), ["I-001", "I-002"]);
|
|
148
|
+
assert.deepEqual(extractCoverage("no coverage clause here"), []);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("parses and resolves current-I markers without changing progress marker states", () => {
|
|
152
|
+
const implItems = [{ id: "I-001", text: "first" }, { id: "I-002", text: "second" }];
|
|
153
|
+
assert.deepEqual(scanCurrentIMarkers("[I-001:current] [I-999:current] [I-002:current]"), [
|
|
154
|
+
{ id: "I-001" },
|
|
155
|
+
{ id: "I-999" },
|
|
156
|
+
{ id: "I-002" },
|
|
157
|
+
]);
|
|
158
|
+
assert.equal(resolveCurrentI(implItems, scanCurrentIMarkers("[I-999:current] [I-002:current]")), "I-002");
|
|
159
|
+
assert.equal(inferCurrentI(implItems, [], undefined), "I-001");
|
|
160
|
+
});
|
|
161
|
+
it("scans impl markers", () => {
|
|
162
|
+
assert.deepEqual(scanImplMarkers("[I-001:implemented] then [I-002:validating] and [I-003:done]"), [
|
|
163
|
+
{ id: "I-001", state: "implemented" },
|
|
164
|
+
{ id: "I-002", state: "validating" },
|
|
165
|
+
]);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("resolves the status matrix with vc-passed > marker > derivation", () => {
|
|
169
|
+
const items = parseChecklist(IMPL_PLAN);
|
|
170
|
+
const implItems = parseImplItems(IMPL_PLAN);
|
|
171
|
+
|
|
172
|
+
// No markers, nothing done: frontier I-001 implementing, I-002 pending.
|
|
173
|
+
assert.deepEqual(resolveImplStatuses(implItems, items, undefined), {
|
|
174
|
+
"I-001": "implementing",
|
|
175
|
+
"I-002": "pending",
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// Explicit marker wins over derivation.
|
|
179
|
+
assert.deepEqual(resolveImplStatuses(implItems, items, { "I-002": "implemented" }), {
|
|
180
|
+
"I-001": "implementing",
|
|
181
|
+
"I-002": "implemented",
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// Some covering VC done without a marker: deriving validating.
|
|
185
|
+
items[1]!.done = true;
|
|
186
|
+
assert.deepEqual(resolveImplStatuses(implItems, items, {}), {
|
|
187
|
+
"I-001": "implementing",
|
|
188
|
+
"I-002": "validating",
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// All covering VCs done → vc-passed beats the marker.
|
|
192
|
+
items[0]!.done = true;
|
|
193
|
+
assert.deepEqual(resolveImplStatuses(implItems, items, { "I-002": "validating" }), {
|
|
194
|
+
"I-001": "vc-passed",
|
|
195
|
+
"I-002": "vc-passed",
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** Tests for the plans tool source wiring. */
|
|
2
|
+
|
|
3
|
+
import * as assert from "node:assert/strict";
|
|
4
|
+
import * as fs from "node:fs";
|
|
5
|
+
import * as path from "node:path";
|
|
6
|
+
import * as url from "node:url";
|
|
7
|
+
import { describe, it } from "node:test";
|
|
8
|
+
|
|
9
|
+
const ROOT = path.dirname(path.dirname(url.fileURLToPath(import.meta.url)));
|
|
10
|
+
|
|
11
|
+
function readPlansSource(): string {
|
|
12
|
+
return fs.readFileSync(path.join(ROOT, "tools", "plans.ts"), "utf8");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe("plans tool source", () => {
|
|
16
|
+
it("declares artifactRootSource in PlansParams", () => {
|
|
17
|
+
const source = readPlansSource();
|
|
18
|
+
const start = source.indexOf("const PlansParams = Type.Object({");
|
|
19
|
+
const end = source.indexOf("});", start);
|
|
20
|
+
assert.ok(start >= 0, "missing PlansParams block start");
|
|
21
|
+
assert.ok(end >= 0, "missing PlansParams block end");
|
|
22
|
+
|
|
23
|
+
const params = source.slice(start, end);
|
|
24
|
+
assert.match(params, /artifactRootSource:\s*Type\.Optional/);
|
|
25
|
+
assert.doesNotMatch(params, /executionModelSelector/);
|
|
26
|
+
assert.doesNotMatch(params, /executionModelSource/);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("keeps the plans handler wired to artifact root and no separate model-selection action", () => {
|
|
30
|
+
const source = readPlansSource();
|
|
31
|
+
assert.match(source, /import \{[\s\S]*setArtifactRoot,[\s\S]*\} from "\.\.\/src\/state\.ts";/);
|
|
32
|
+
assert.match(source, /case "set-artifact-root"/);
|
|
33
|
+
assert.doesNotMatch(source, /setExecutionModel/);
|
|
34
|
+
assert.doesNotMatch(source, /case "set-execution-model"/);
|
|
35
|
+
assert.match(source, /params\.artifactRootSource/);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import * as assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
consumeOrdinaryQuery,
|
|
5
|
+
isOrdinaryExternalQuery,
|
|
6
|
+
QUERY_INTERVIEW_MESSAGE,
|
|
7
|
+
QUERY_INTERVIEW_MESSAGE_CUSTOM_TYPE,
|
|
8
|
+
recordOrdinaryQuery,
|
|
9
|
+
registerQueryInterviewHooks,
|
|
10
|
+
resetOrdinaryQueryState,
|
|
11
|
+
} from "../src/query-hook.ts";
|
|
12
|
+
|
|
13
|
+
function context(sessionManager: object): any {
|
|
14
|
+
return { cwd: process.cwd(), sessionManager };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function registeredHooks(): { pi: any; handlers: Map<string, Function[]> } {
|
|
18
|
+
const handlers = new Map<string, Function[]>();
|
|
19
|
+
const pi = {
|
|
20
|
+
on(name: string, handler: Function) {
|
|
21
|
+
handlers.set(name, [...(handlers.get(name) ?? []), handler]);
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
return { pi, handlers };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const ordinaryInput = { text: "implement the requested change", source: "interactive" as const };
|
|
28
|
+
|
|
29
|
+
describe("query interview hook", () => {
|
|
30
|
+
it("accepts only idle external non-slash input", () => {
|
|
31
|
+
assert.equal(isOrdinaryExternalQuery(ordinaryInput), true);
|
|
32
|
+
assert.equal(isOrdinaryExternalQuery({ ...ordinaryInput, source: "rpc" }), true);
|
|
33
|
+
assert.equal(isOrdinaryExternalQuery({ ...ordinaryInput, source: "extension" }), false);
|
|
34
|
+
assert.equal(isOrdinaryExternalQuery({ ...ordinaryInput, streamingBehavior: "followUp" }), false);
|
|
35
|
+
assert.equal(isOrdinaryExternalQuery({ ...ordinaryInput, streamingBehavior: "steer" }), false);
|
|
36
|
+
assert.equal(isOrdinaryExternalQuery({ ...ordinaryInput, text: " " }), false);
|
|
37
|
+
assert.equal(isOrdinaryExternalQuery({ ...ordinaryInput, text: "/planning clarify this" }), false);
|
|
38
|
+
assert.equal(isOrdinaryExternalQuery({ ...ordinaryInput, text: "/skill:plan-small implement this" }), false);
|
|
39
|
+
assert.equal(isOrdinaryExternalQuery({ ...ordinaryInput, text: "/unknown-command" }), false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("queues multiple queries, consumes one at a time, and isolates sessions", () => {
|
|
43
|
+
const first = context({});
|
|
44
|
+
const second = context({});
|
|
45
|
+
assert.equal(recordOrdinaryQuery(first, ordinaryInput), true);
|
|
46
|
+
assert.equal(recordOrdinaryQuery(first, { ...ordinaryInput, text: "verify the change" }), true);
|
|
47
|
+
assert.equal(consumeOrdinaryQuery(second), false);
|
|
48
|
+
assert.equal(consumeOrdinaryQuery(first), true);
|
|
49
|
+
assert.equal(consumeOrdinaryQuery(first), true);
|
|
50
|
+
assert.equal(consumeOrdinaryQuery(first), false);
|
|
51
|
+
resetOrdinaryQueryState(first);
|
|
52
|
+
assert.equal(consumeOrdinaryQuery(first), false);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("registers one hidden message per ordinary query and consumes suppressed queries", async () => {
|
|
56
|
+
const { pi, handlers } = registeredHooks();
|
|
57
|
+
let suppressed = false;
|
|
58
|
+
registerQueryInterviewHooks(pi, () => suppressed);
|
|
59
|
+
const session = {};
|
|
60
|
+
const ctx = context(session);
|
|
61
|
+
const input = handlers.get("input")![0]!;
|
|
62
|
+
const beforeAgentStart = handlers.get("before_agent_start")![0]!;
|
|
63
|
+
const sessionStart = handlers.get("session_start")![0]!;
|
|
64
|
+
|
|
65
|
+
await input({ ...ordinaryInput }, ctx);
|
|
66
|
+
const first = await beforeAgentStart({ type: "before_agent_start", prompt: ordinaryInput.text }, ctx);
|
|
67
|
+
assert.equal(first?.message.customType, QUERY_INTERVIEW_MESSAGE_CUSTOM_TYPE);
|
|
68
|
+
assert.equal(first?.message.display, false);
|
|
69
|
+
assert.equal(first?.message.content, QUERY_INTERVIEW_MESSAGE);
|
|
70
|
+
assert.equal(await beforeAgentStart({ type: "before_agent_start", prompt: ordinaryInput.text }, ctx), undefined);
|
|
71
|
+
|
|
72
|
+
await input({ ...ordinaryInput, text: "a query inside the planning workflow" }, ctx);
|
|
73
|
+
suppressed = true;
|
|
74
|
+
assert.equal(await beforeAgentStart({ type: "before_agent_start", prompt: "expanded workflow prompt" }, ctx), undefined);
|
|
75
|
+
suppressed = false;
|
|
76
|
+
assert.equal(await beforeAgentStart({ type: "before_agent_start", prompt: "later ordinary prompt" }, ctx), undefined);
|
|
77
|
+
|
|
78
|
+
await input({ ...ordinaryInput, text: "stale input" }, ctx);
|
|
79
|
+
await sessionStart({ type: "session_start" }, ctx);
|
|
80
|
+
assert.equal(await beforeAgentStart({ type: "before_agent_start", prompt: "new session" }, ctx), undefined);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
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, chunkDetailForOverlay } from "../src/refine-ui.ts";
|
|
6
|
+
import { applyRefineProgress, applyRefineResult, statusLabel, type RefineLaneState } from "../src/refine-ui-state.ts";
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
function lane(): RefineLaneState {
|
|
10
|
+
return { id: "lane-1", label: "reviewer-1", status: "queued", phase: "queued", detail: "" };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe("refine overlay state", () => {
|
|
14
|
+
it("tracks tool progress and bounds the displayed detail", () => {
|
|
15
|
+
const state = lane();
|
|
16
|
+
applyRefineProgress(state, {
|
|
17
|
+
type: "tool",
|
|
18
|
+
phase: "update",
|
|
19
|
+
toolCallId: "call-1",
|
|
20
|
+
toolName: "read",
|
|
21
|
+
detail: "x".repeat(500),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
assert.equal(state.status, "running");
|
|
25
|
+
assert.equal(state.phase, "tool: read");
|
|
26
|
+
assert.ok(state.detail.length <= 180);
|
|
27
|
+
assert.match(state.detail, /\.\.\.$/);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("keeps terminal lane states stable after completion", () => {
|
|
31
|
+
const state = lane();
|
|
32
|
+
applyRefineResult(state, { ok: true, output: "final conclusion", stderr: "", turns: 1 });
|
|
33
|
+
applyRefineProgress(state, { type: "turn", phase: "start" });
|
|
34
|
+
|
|
35
|
+
assert.deepEqual(state, {
|
|
36
|
+
id: "lane-1",
|
|
37
|
+
label: "reviewer-1",
|
|
38
|
+
status: "complete",
|
|
39
|
+
phase: "complete",
|
|
40
|
+
detail: "final conclusion",
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("distinguishes cancelled and timed out child results", () => {
|
|
45
|
+
const cancelled = lane();
|
|
46
|
+
applyRefineResult(cancelled, { ok: false, output: "", stderr: "", turns: 0, cancelled: true, errorMessage: "Esc" });
|
|
47
|
+
assert.equal(cancelled.status, "cancelled");
|
|
48
|
+
assert.equal(cancelled.phase, "cancelled");
|
|
49
|
+
|
|
50
|
+
const timedOut = lane();
|
|
51
|
+
applyRefineResult(timedOut, { ok: false, output: "", stderr: "", turns: 0, timedOut: true, errorMessage: "timeout" });
|
|
52
|
+
assert.equal(timedOut.status, "failed");
|
|
53
|
+
assert.equal(timedOut.phase, "timed out");
|
|
54
|
+
assert.equal(statusLabel(timedOut.status), "failed");
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
describe("refine overlay viewport", () => {
|
|
60
|
+
const fakeTheme = {
|
|
61
|
+
fg: (_color: string, text: string) => text,
|
|
62
|
+
bold: (text: string) => text,
|
|
63
|
+
} as never;
|
|
64
|
+
|
|
65
|
+
function makeLane(): RefineLaneState {
|
|
66
|
+
return { id: "lane-1", label: "reviewer-1", status: "running", phase: "responding", detail: "" };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function makeLanes(detail: string): RefineLaneState[] {
|
|
70
|
+
return [{ id: "lane-1", label: "reviewer-1", status: "running", phase: "responding", detail }];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
it("clamps overlay width between min and max regardless of host width", () => {
|
|
74
|
+
const component = new RefineOverlayComponent(fakeTheme, "reviewer", makeLanes(""), () => {});
|
|
75
|
+
const narrow = component.render(20);
|
|
76
|
+
const wide = component.render(240);
|
|
77
|
+
assert.ok(narrow.some((line) => line.startsWith("┌")));
|
|
78
|
+
assert.ok(wide.some((line) => line.startsWith("┌")));
|
|
79
|
+
assert.ok(narrow.every((line) => line.length <= 20));
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("draws top and bottom borders with rounded corners and frame lines", () => {
|
|
83
|
+
const component = new RefineOverlayComponent(fakeTheme, "criticizer", makeLanes(""), () => {});
|
|
84
|
+
const lines = component.render(80);
|
|
85
|
+
const first = lines[0] ?? "";
|
|
86
|
+
const last = lines.at(-1) ?? "";
|
|
87
|
+
assert.match(first, /^┌─+┐$/);
|
|
88
|
+
assert.match(last, /^└─+┘$/);
|
|
89
|
+
assert.ok(lines.some((line) => line.startsWith("├") && line.endsWith("┤")));
|
|
90
|
+
assert.ok(lines.some((line) => line.startsWith("│") && line.endsWith("│")));
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("chunk sentence-sized detail lines so streaming fragments stay readable", () => {
|
|
94
|
+
const lines = chunkDetailForOverlay(
|
|
95
|
+
"Reading the file. Found three findings. The fourth line is unrelated.",
|
|
96
|
+
40,
|
|
97
|
+
6,
|
|
98
|
+
);
|
|
99
|
+
assert.ok(lines.length >= 3);
|
|
100
|
+
assert.ok(lines.every((line) => line.length <= 40));
|
|
101
|
+
assert.match(lines[0] ?? "", /Reading the file\./);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("limits chunk length and refuses to overflow the maxLines budget", () => {
|
|
105
|
+
const text = Array.from({ length: 30 }, (_, i) => `Sentence number ${i + 1}.`).join(" ");
|
|
106
|
+
const lines = chunkDetailForOverlay(text, 60, 3);
|
|
107
|
+
assert.equal(lines.length, 3);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("refine overlay wiring", () => {
|
|
112
|
+
it("uses named public overlays, guards lifecycle edges, and does not depend on pi-btw", () => {
|
|
113
|
+
const source = fs.readFileSync(path.join(process.cwd(), "src", "refine-ui.ts"), "utf8");
|
|
114
|
+
assert.match(source, /custom<void>/);
|
|
115
|
+
assert.match(source, /overlay:\s*true/);
|
|
116
|
+
assert.match(source, /"Reviewer"/);
|
|
117
|
+
assert.match(source, /"Criticizer"/);
|
|
118
|
+
assert.match(source, /truncateToWidth/);
|
|
119
|
+
assert.match(source, /chunkDetailForOverlay/);
|
|
120
|
+
assert.match(source, /pickOverlayHeight|pickOverlayWidth/);
|
|
121
|
+
assert.match(source, /renderBorderLine|renderHorizontalRule|renderRow/);
|
|
122
|
+
assert.match(source, /if \(this\.closed\) return;/);
|
|
123
|
+
assert.match(source, /await this\.overlayPromise/);
|
|
124
|
+
assert.match(source, /if \(!ctx\.hasUI \|\| this\.overlayPromise\) return/);
|
|
125
|
+
assert.doesNotMatch(source, /pi-btw|createAgentSession|AgentSession/);
|
|
126
|
+
});
|
|
127
|
+
});
|
package/tests/state.test.ts
CHANGED
|
@@ -68,6 +68,7 @@ describe("init", () => {
|
|
|
68
68
|
assert.equal(config.reviewer.mode, "delegated-subagent");
|
|
69
69
|
assert.equal(config.reviewer.confirmed_at, null);
|
|
70
70
|
assert.equal(config.criticizer.confirmed_at, null);
|
|
71
|
+
assert.equal("execution" in config, false);
|
|
71
72
|
assert.equal(config.artifact_root, "./docs/pi-plans");
|
|
72
73
|
assert.equal(config.artifact_root_source, "unset");
|
|
73
74
|
assert.equal(config.artifact_root_updated_at, null);
|
|
@@ -194,6 +195,62 @@ describe("runs", () => {
|
|
|
194
195
|
assert.equal(getRun(workdir, run.run_id)?.status, "accepted");
|
|
195
196
|
assert.throws(() => setRunStatus(workdir, run.run_id, "bogus"), StateError);
|
|
196
197
|
});
|
|
198
|
+
|
|
199
|
+
it("invokes the onStart callback exactly once when startRun succeeds", () => {
|
|
200
|
+
const workdir = mkWorkdir("onstart");
|
|
201
|
+
git(workdir, "init", "-q");
|
|
202
|
+
const calls: string[] = [];
|
|
203
|
+
const { run } = startRun(workdir, {
|
|
204
|
+
topic: "marker demo",
|
|
205
|
+
skill: "plan-normal",
|
|
206
|
+
requestText: "x",
|
|
207
|
+
onStart: (current) => {
|
|
208
|
+
calls.push(current.run_id);
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
assert.deepEqual(calls, [run.run_id]);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("does not invoke the onStart callback when startRun throws", () => {
|
|
215
|
+
const workdir = mkWorkdir("onstart-throw");
|
|
216
|
+
git(workdir, "init", "-q");
|
|
217
|
+
const calls: string[] = [];
|
|
218
|
+
assert.throws(() =>
|
|
219
|
+
startRun({
|
|
220
|
+
workdir: path.resolve(workdir),
|
|
221
|
+
artifact_root: "./docs/pi-plans",
|
|
222
|
+
artifact_root_source: "user",
|
|
223
|
+
artifact_root_updated_at: new Date().toISOString(),
|
|
224
|
+
schema: 1,
|
|
225
|
+
language: { tag: null, source: "unset", updated_at: null },
|
|
226
|
+
reviewer: { mode: "delegated-subagent", model_selector: null, name_prefix: "pi-plans-reviewer", confirmed_at: null },
|
|
227
|
+
criticizer: { mode: "delegated-subagent", model_selector: null, name_prefix: "pi-plans-criticizer", confirmed_at: null },
|
|
228
|
+
execution: { model_selector: null, source: "unset", updated_at: null },
|
|
229
|
+
}, {
|
|
230
|
+
topic: "" as string,
|
|
231
|
+
skill: "plan-normal",
|
|
232
|
+
requestText: "x",
|
|
233
|
+
onStart: () => calls.push("called"),
|
|
234
|
+
} as any),
|
|
235
|
+
);
|
|
236
|
+
assert.equal(calls.length, 0);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
describe("legacy execution config", () => {
|
|
241
|
+
it("strips legacy execution config on load and rewrite", () => {
|
|
242
|
+
const workdir = mkWorkdir("legacy-exec");
|
|
243
|
+
git(workdir, "init", "-q");
|
|
244
|
+
initState(workdir);
|
|
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);
|
|
253
|
+
});
|
|
197
254
|
});
|
|
198
255
|
|
|
199
256
|
describe("set-role invariants", () => {
|
|
@@ -0,0 +1,114 @@
|
|
|
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("tool:start"));
|
|
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" }), { type: "turn", phase: "start" });
|
|
84
|
+
assert.deepEqual(
|
|
85
|
+
normalizeSubagentEvent({
|
|
86
|
+
type: "message_update",
|
|
87
|
+
assistantMessageEvent: { type: "text_delta", delta: "checking the plan" },
|
|
88
|
+
}),
|
|
89
|
+
{ type: "message", phase: "update", role: "assistant", text: "checking the plan" },
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("normalizes tool progress without exposing unbounded arguments", () => {
|
|
94
|
+
const event = normalizeSubagentEvent({
|
|
95
|
+
type: "tool_execution_start",
|
|
96
|
+
toolCallId: "call-1",
|
|
97
|
+
toolName: "read",
|
|
98
|
+
args: { path: "/tmp/plan.md" },
|
|
99
|
+
});
|
|
100
|
+
assert.deepEqual(event, {
|
|
101
|
+
type: "tool",
|
|
102
|
+
phase: "start",
|
|
103
|
+
toolCallId: "call-1",
|
|
104
|
+
toolName: "read",
|
|
105
|
+
detail: '{"path":"/tmp/plan.md"}',
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("ignores unknown or malformed events", () => {
|
|
110
|
+
assert.equal(normalizeSubagentEvent(undefined), undefined);
|
|
111
|
+
assert.equal(normalizeSubagentEvent({ type: "future_event" }), undefined);
|
|
112
|
+
assert.equal(normalizeSubagentEvent({ type: "message_end" }), undefined);
|
|
113
|
+
});
|
|
114
|
+
});
|
package/tools/ask-choice.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { StringEnum } from "@earendil-works/pi-ai";
|
|
|
14
14
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
15
15
|
import { Text } from "@earendil-works/pi-tui";
|
|
16
16
|
import { Type } from "typebox";
|
|
17
|
+
import { disableAutoComplete, enableAutoComplete, isAutoCompleteEnabled, recordAskChoice } from "../src/autocomplete.ts";
|
|
17
18
|
import { normalizeWorkdir, readActive, recordDecision } from "../src/state.ts";
|
|
18
19
|
|
|
19
20
|
const Option = Type.Object({
|
|
@@ -85,14 +86,28 @@ export function registerAskChoiceTool(pi: ExtensionAPI): void {
|
|
|
85
86
|
source,
|
|
86
87
|
});
|
|
87
88
|
|
|
89
|
+
// Once enabled for this planning run, eligible questions answer with the
|
|
90
|
+
// recommendation without opening another UI prompt.
|
|
91
|
+
if (autoComplete && isAutoCompleteEnabled(ctx)) {
|
|
92
|
+
recordAskChoice(ctx, true);
|
|
93
|
+
record(recommended.label, "auto-complete");
|
|
94
|
+
return {
|
|
95
|
+
content: [{ type: "text", text: `Auto-complete selected the recommended option: ${recommended.label}` }],
|
|
96
|
+
details: details(recommended.label, "auto-complete"),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
88
100
|
// No UI (print/json mode): planning questions may auto-complete;
|
|
89
101
|
// questions without Auto-complete must stop and wait for the user.
|
|
90
102
|
if (!ctx.hasUI) {
|
|
91
103
|
if (!autoComplete) {
|
|
104
|
+
disableAutoComplete(ctx, "non-interactive boundary");
|
|
92
105
|
throw new Error(
|
|
93
106
|
"No UI available and this question must not be auto-completed (execution handoff or external-state change). Stop and wait for the user.",
|
|
94
107
|
);
|
|
95
108
|
}
|
|
109
|
+
enableAutoComplete(ctx);
|
|
110
|
+
recordAskChoice(ctx, true);
|
|
96
111
|
record(recommended.label, "auto-complete");
|
|
97
112
|
return {
|
|
98
113
|
content: [
|
|
@@ -116,6 +131,7 @@ export function registerAskChoiceTool(pi: ExtensionAPI): void {
|
|
|
116
131
|
|
|
117
132
|
const selected = await ctx.ui.select(params.question, displayLabels);
|
|
118
133
|
if (selected === undefined) {
|
|
134
|
+
disableAutoComplete(ctx, "question cancelled");
|
|
119
135
|
return {
|
|
120
136
|
content: [
|
|
121
137
|
{
|
|
@@ -128,6 +144,8 @@ export function registerAskChoiceTool(pi: ExtensionAPI): void {
|
|
|
128
144
|
}
|
|
129
145
|
|
|
130
146
|
if (autoComplete && selected.startsWith("Auto-complete")) {
|
|
147
|
+
enableAutoComplete(ctx);
|
|
148
|
+
recordAskChoice(ctx, true);
|
|
131
149
|
record(recommended.label, "auto-complete");
|
|
132
150
|
return {
|
|
133
151
|
content: [
|
|
@@ -143,11 +161,13 @@ export function registerAskChoiceTool(pi: ExtensionAPI): void {
|
|
|
143
161
|
if (allowOther && selected.startsWith("Other…")) {
|
|
144
162
|
const typed = await ctx.ui.input(`${params.question} — your answer:`);
|
|
145
163
|
if (typed === undefined || !typed.trim()) {
|
|
164
|
+
disableAutoComplete(ctx, "free-form answer cancelled");
|
|
146
165
|
return {
|
|
147
166
|
content: [{ type: "text", text: "User cancelled the free-form answer. Ask again or stop." }],
|
|
148
167
|
details: details(null, "cancelled"),
|
|
149
168
|
};
|
|
150
169
|
}
|
|
170
|
+
recordAskChoice(ctx, false);
|
|
151
171
|
const answer = typed.trim();
|
|
152
172
|
record(answer, "user");
|
|
153
173
|
return {
|
|
@@ -159,11 +179,13 @@ export function registerAskChoiceTool(pi: ExtensionAPI): void {
|
|
|
159
179
|
const index = displayLabels.indexOf(selected);
|
|
160
180
|
const option = index >= 0 && index < options.length ? options[index] : undefined;
|
|
161
181
|
if (!option) {
|
|
182
|
+
recordAskChoice(ctx, false);
|
|
162
183
|
return {
|
|
163
184
|
content: [{ type: "text", text: `User selected: ${selected}` }],
|
|
164
185
|
details: details(selected, "user"),
|
|
165
186
|
};
|
|
166
187
|
}
|
|
188
|
+
recordAskChoice(ctx, false);
|
|
167
189
|
record(option.label, "user");
|
|
168
190
|
return {
|
|
169
191
|
content: [{ type: "text", text: `User selected: ${index + 1}. ${option.label}` }],
|