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.
@@ -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
+ });
@@ -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
+ });
@@ -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
+ });
@@ -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}` }],