pi-plans 0.1.2 → 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 +43 -17
- package/index.ts +34 -59
- 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 +557 -333
- package/src/plan.ts +37 -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 +9 -27
- 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 +153 -248
- package/tests/execute-plan.test.ts +65 -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-ui.test.ts +127 -0
- package/tests/state.test.ts +12 -15
- package/tests/subagent.test.ts +114 -0
- package/tools/ask-choice.ts +22 -0
- package/tools/execute-plan.ts +7 -39
- package/tools/plans.ts +1 -18
- package/tools/refine.ts +125 -71
- package/src/execution-panel.ts +0 -633
- package/tests/execution-panel.test.ts +0 -234
package/tests/plans.test.ts
CHANGED
|
@@ -22,15 +22,16 @@ describe("plans tool source", () => {
|
|
|
22
22
|
|
|
23
23
|
const params = source.slice(start, end);
|
|
24
24
|
assert.match(params, /artifactRootSource:\s*Type\.Optional/);
|
|
25
|
-
assert.
|
|
26
|
-
assert.
|
|
25
|
+
assert.doesNotMatch(params, /executionModelSelector/);
|
|
26
|
+
assert.doesNotMatch(params, /executionModelSource/);
|
|
27
27
|
});
|
|
28
28
|
|
|
29
|
-
it("keeps the plans handler wired to artifact root and
|
|
29
|
+
it("keeps the plans handler wired to artifact root and no separate model-selection action", () => {
|
|
30
30
|
const source = readPlansSource();
|
|
31
|
-
assert.match(source, /import \{[\s\S]*setArtifactRoot,[\s\S]
|
|
31
|
+
assert.match(source, /import \{[\s\S]*setArtifactRoot,[\s\S]*\} from "\.\.\/src\/state\.ts";/);
|
|
32
32
|
assert.match(source, /case "set-artifact-root"/);
|
|
33
|
-
assert.
|
|
33
|
+
assert.doesNotMatch(source, /setExecutionModel/);
|
|
34
|
+
assert.doesNotMatch(source, /case "set-execution-model"/);
|
|
34
35
|
assert.match(source, /params\.artifactRootSource/);
|
|
35
36
|
});
|
|
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
|
@@ -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,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}` }],
|
package/tools/execute-plan.ts
CHANGED
|
@@ -9,25 +9,12 @@ import { Type } from "typebox";
|
|
|
9
9
|
import * as fs from "node:fs";
|
|
10
10
|
import * as path from "node:path";
|
|
11
11
|
import {
|
|
12
|
-
chooseExecutionModelSelection,
|
|
13
|
-
snapshotCurrentModelSelector,
|
|
14
12
|
startExecution,
|
|
15
|
-
type ExecutionModelState,
|
|
16
13
|
} from "../src/exec.ts";
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
14
|
+
import { disableAutoComplete } from "../src/autocomplete.ts";
|
|
15
|
+
import { latestPlanVersion, parseChecklist, parseImplItems } from "../src/plan.ts";
|
|
16
|
+
import { normalizeWorkdir, readActive } from "../src/state.ts";
|
|
19
17
|
|
|
20
|
-
function loadExecutionModelConfig(workdir: string): ExecutionConfig {
|
|
21
|
-
const root = resolveStateRootOrNull(workdir);
|
|
22
|
-
if (!root) {
|
|
23
|
-
return { model_selector: null, source: "unset", updated_at: null };
|
|
24
|
-
}
|
|
25
|
-
try {
|
|
26
|
-
return loadConfig(root).execution;
|
|
27
|
-
} catch {
|
|
28
|
-
return { model_selector: null, source: "unset", updated_at: null };
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
18
|
|
|
32
19
|
const ExecutePlanParams = Type.Object({
|
|
33
20
|
planPath: Type.Optional(
|
|
@@ -82,6 +69,7 @@ export async function executeHandoff(
|
|
|
82
69
|
}
|
|
83
70
|
const implItems = parseImplItems(planText);
|
|
84
71
|
|
|
72
|
+
disableAutoComplete(ctx, "execution handoff");
|
|
85
73
|
if (!ctx.hasUI) {
|
|
86
74
|
return {
|
|
87
75
|
status: "error",
|
|
@@ -99,33 +87,13 @@ export async function executeHandoff(
|
|
|
99
87
|
return { status: "declined", message: "User declined execution. Stay in planning; ask how to proceed.", planPath };
|
|
100
88
|
}
|
|
101
89
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
const planningSelector = snapshotCurrentModelSelector(ctx);
|
|
105
|
-
const selection = await chooseExecutionModelSelection(
|
|
106
|
-
ctx,
|
|
107
|
-
"Execution model",
|
|
108
|
-
configuredExecution,
|
|
109
|
-
async (selector) => {
|
|
110
|
-
await setExecutionModel(workdir, { modelSelector: selector, source: "user" });
|
|
111
|
-
},
|
|
112
|
-
);
|
|
113
|
-
if (!selection) {
|
|
114
|
-
return { status: "error", message: "No usable execution model; execution cancelled." };
|
|
115
|
-
}
|
|
116
|
-
if (configuredExecution.source === "unset" || configuredExecution.model_selector !== null) {
|
|
117
|
-
modelState = { planningSelector, executionSelector: selection.selector };
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
await startExecution(getCurrentApi(), ctx, planPath, items, modelState, implItems);
|
|
121
|
-
const countedImpls = implItems.filter((impl) => items.some((item) => extractCoverage(item.text).includes(impl.id)));
|
|
122
|
-
const modelNote = modelState ? ` Execution model: ${modelState.executionSelector}.` : "";
|
|
123
|
-
const scopeNote = countedImpls.length ? ` Tracking ${countedImpls.length} implementation item(s).` : "";
|
|
90
|
+
await startExecution(getCurrentApi(), ctx, planPath, items, implItems);
|
|
91
|
+
const scopeNote = implItems.length ? ` Tracking ${implItems.length} implementation item(s).` : "";
|
|
124
92
|
return {
|
|
125
93
|
status: "executing",
|
|
126
94
|
planPath,
|
|
127
95
|
itemCount: items.length,
|
|
128
|
-
message: `Execution approved. ${items.length} verifier item(s) queued; implement in dependency order and mark verified items with [DONE:VC-xxx].${scopeNote}
|
|
96
|
+
message: `Execution approved. ${items.length} verifier item(s) queued; implement in dependency order and mark verified items with [DONE:VC-xxx].${scopeNote}`,
|
|
129
97
|
};
|
|
130
98
|
}
|
|
131
99
|
|
package/tools/plans.ts
CHANGED
|
@@ -15,7 +15,6 @@ import {
|
|
|
15
15
|
recordSubagent,
|
|
16
16
|
resolveStateRootOrNull,
|
|
17
17
|
setArtifactRoot,
|
|
18
|
-
setExecutionModel,
|
|
19
18
|
setLanguage,
|
|
20
19
|
setRunStatus,
|
|
21
20
|
setRole,
|
|
@@ -32,7 +31,6 @@ const PlansParams = Type.Object({
|
|
|
32
31
|
"show",
|
|
33
32
|
"set-language",
|
|
34
33
|
"set-artifact-root",
|
|
35
|
-
"set-execution-model",
|
|
36
34
|
"set-role",
|
|
37
35
|
"start-run",
|
|
38
36
|
"set-status",
|
|
@@ -52,10 +50,6 @@ const PlansParams = Type.Object({
|
|
|
52
50
|
languageSource: Type.Optional(StringEnum(["user", "auto"] as const)),
|
|
53
51
|
artifactRoot: Type.Optional(Type.String({ description: "set-artifact-root: planning docs root, e.g. ./docs/pi-plans" })),
|
|
54
52
|
artifactRootSource: Type.Optional(StringEnum(["user", "auto"] as const)),
|
|
55
|
-
executionModelSelector: Type.Optional(
|
|
56
|
-
Type.String({ description: "set-execution-model: exact provider/model selector, or 'inherit' to clear" }),
|
|
57
|
-
),
|
|
58
|
-
executionModelSource: Type.Optional(StringEnum(["user", "auto"] as const)),
|
|
59
53
|
role: Type.Optional(StringEnum(["reviewer", "criticizer"] as const)),
|
|
60
54
|
mode: Type.Optional(StringEnum(["delegated-subagent", "current-session"] as const)),
|
|
61
55
|
modelSelector: Type.Optional(
|
|
@@ -120,7 +114,7 @@ export function registerPlansTool(pi: ExtensionAPI): void {
|
|
|
120
114
|
name: "plans",
|
|
121
115
|
label: "Plans",
|
|
122
116
|
description:
|
|
123
|
-
"Manage pi-plans planning state in the target workspace: init/show config, set language and planning docs root plus
|
|
117
|
+
"Manage pi-plans planning state in the target workspace: init/show config, set language and planning docs root plus reviewer/criticizer roles, start planning runs, record decisions/refs/subagents, and update run status. State lives in .git/pi_plans/ inside the resolved git common dir. Actions: init, show, set-language, set-artifact-root, set-role, start-run, set-status, record-decision, record-ref, record-subagent.",
|
|
124
118
|
promptSnippet: "Manage pi-plans planning state, runs, and ledgers",
|
|
125
119
|
parameters: PlansParams,
|
|
126
120
|
|
|
@@ -156,17 +150,6 @@ export function registerPlansTool(pi: ExtensionAPI): void {
|
|
|
156
150
|
result = { config: updated.config, stateRoot: updated.stateRoot, notices: updated.notices };
|
|
157
151
|
break;
|
|
158
152
|
}
|
|
159
|
-
case "set-execution-model": {
|
|
160
|
-
if (!params.executionModelSelector || !params.executionModelSource) {
|
|
161
|
-
throw new StateError("set-execution-model requires executionModelSelector and executionModelSource");
|
|
162
|
-
}
|
|
163
|
-
const updated = setExecutionModel(workdir, {
|
|
164
|
-
modelSelector: params.executionModelSelector,
|
|
165
|
-
source: params.executionModelSource,
|
|
166
|
-
});
|
|
167
|
-
result = { config: updated.config, stateRoot: updated.stateRoot, notices: updated.notices };
|
|
168
|
-
break;
|
|
169
|
-
}
|
|
170
153
|
case "set-role": {
|
|
171
154
|
if (!params.role) throw new StateError("set-role requires role");
|
|
172
155
|
const updated = setRole(workdir, {
|