pi-plans 0.3.1 → 0.3.3
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 +7 -5
- package/index.ts +100 -22
- package/package.json +1 -1
- package/references/pi-planning-workflow.md +28 -1
- package/references/plan-artifact-template.md +4 -0
- package/references/state-and-config.md +19 -0
- package/src/autocomplete.ts +2 -1
- package/src/code-graph/commands.ts +21 -6
- package/src/compaction.ts +10 -0
- package/src/config-command.ts +2 -0
- package/src/exec.ts +394 -61
- package/src/guard.ts +7 -1
- package/src/resume-command.ts +450 -0
- package/src/resume.ts +205 -0
- package/src/run-context.ts +97 -0
- package/src/run-ownership.ts +310 -0
- package/src/state.ts +24 -1
- package/src/termination-prompt.ts +8 -0
- package/src/workflow-state.ts +1159 -0
- package/tests/ask-choice.test.ts +113 -0
- package/tests/compaction.test.ts +39 -0
- package/tests/exec-lifecycle.test.ts +137 -0
- package/tests/exec.test.ts +237 -117
- package/tests/goal-wait.test.ts +269 -0
- package/tests/guard.test.ts +46 -0
- package/tests/plans.test.ts +71 -0
- package/tests/refine-resume.test.ts +324 -0
- package/tests/resume-lifecycle.test.ts +385 -0
- package/tests/resume.test.ts +384 -0
- package/tests/run-context.test.ts +119 -0
- package/tests/run-ownership.test.ts +170 -0
- package/tests/state.test.ts +16 -0
- package/tests/workflow-state.test.ts +432 -0
- package/tools/analyze-refs.ts +2 -1
- package/tools/ask-choice.ts +61 -3
- package/tools/code-graph.ts +5 -1
- package/tools/execute-plan.ts +20 -1
- package/tools/plans.ts +119 -0
- package/tools/refine.ts +91 -7
package/tests/ask-choice.test.ts
CHANGED
|
@@ -262,3 +262,116 @@ describe("ask_choice panel fitting", () => {
|
|
|
262
262
|
assert.ok(totalLines < rows - STATUS_BAR_HEIGHT - PANEL_SAFETY_MARGIN);
|
|
263
263
|
});
|
|
264
264
|
});
|
|
265
|
+
|
|
266
|
+
describe("ask_choice checkpoint question lifecycle (I-003)", () => {
|
|
267
|
+
it("records pending before the panel opens and the answer before it returns", async () => {
|
|
268
|
+
const { spawnSync } = await import("node:child_process");
|
|
269
|
+
const { initState, startRun } = await import("../src/state.ts");
|
|
270
|
+
const { createCheckpoint, loadCheckpoint } = await import("../src/workflow-state.ts");
|
|
271
|
+
const workdir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-ask-cp-"));
|
|
272
|
+
try {
|
|
273
|
+
spawnSync("git", ["init"], { cwd: workdir });
|
|
274
|
+
initState(workdir);
|
|
275
|
+
const { run } = startRun(workdir, { topic: "askcp", skill: "plan-small", requestText: "t" });
|
|
276
|
+
createCheckpoint(workdir, { runId: run.run_id, originWorkdir: workdir, workdir });
|
|
277
|
+
|
|
278
|
+
let pendingDuringPanel: unknown = null;
|
|
279
|
+
const tool = loadTool();
|
|
280
|
+
const ctx = makeCtx({
|
|
281
|
+
select: async (_question, labels) => {
|
|
282
|
+
pendingDuringPanel = loadCheckpoint(workdir, run.run_id);
|
|
283
|
+
return labels[0];
|
|
284
|
+
},
|
|
285
|
+
});
|
|
286
|
+
const result = await tool.execute("t1", {
|
|
287
|
+
question: "Scope ok?",
|
|
288
|
+
options: OPTIONS,
|
|
289
|
+
workdir,
|
|
290
|
+
questionId: "scope-confirm",
|
|
291
|
+
purpose: "scope",
|
|
292
|
+
}, undefined, undefined, ctx);
|
|
293
|
+
assert.match(result.content[0].text, /User selected: 1/);
|
|
294
|
+
|
|
295
|
+
// During the panel: the pending question is already durable.
|
|
296
|
+
const during = pendingDuringPanel as { status: string; checkpoint?: { pendingQuestion: unknown } };
|
|
297
|
+
assert.equal(during.status, "ok");
|
|
298
|
+
assert.ok(during.checkpoint?.pendingQuestion, "pending recorded before display");
|
|
299
|
+
|
|
300
|
+
// After the answer: cleared + answered with source user.
|
|
301
|
+
const after = loadCheckpoint(workdir, run.run_id);
|
|
302
|
+
assert.equal(after.status, "ok");
|
|
303
|
+
if (after.status === "ok") {
|
|
304
|
+
assert.equal(after.checkpoint.pendingQuestion, null);
|
|
305
|
+
assert.equal(after.checkpoint.answeredQuestions.length, 1);
|
|
306
|
+
assert.equal(after.checkpoint.answeredQuestions[0]?.questionId, "scope-confirm");
|
|
307
|
+
assert.equal(after.checkpoint.answeredQuestions[0]?.source, "user");
|
|
308
|
+
}
|
|
309
|
+
} finally {
|
|
310
|
+
fs.rmSync(workdir, { recursive: true, force: true });
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("keeps the pending question when the user cancels", async () => {
|
|
315
|
+
const { spawnSync } = await import("node:child_process");
|
|
316
|
+
const { initState, startRun } = await import("../src/state.ts");
|
|
317
|
+
const { createCheckpoint, loadCheckpoint } = await import("../src/workflow-state.ts");
|
|
318
|
+
const workdir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-ask-cancel-"));
|
|
319
|
+
try {
|
|
320
|
+
spawnSync("git", ["init"], { cwd: workdir });
|
|
321
|
+
initState(workdir);
|
|
322
|
+
const { run } = startRun(workdir, { topic: "askcancel", skill: "plan-small", requestText: "t" });
|
|
323
|
+
createCheckpoint(workdir, { runId: run.run_id, originWorkdir: workdir, workdir });
|
|
324
|
+
const tool = loadTool();
|
|
325
|
+
const ctx = makeCtx({ select: async () => undefined });
|
|
326
|
+
const result = await tool.execute("t1", {
|
|
327
|
+
question: "Scope ok?",
|
|
328
|
+
options: OPTIONS,
|
|
329
|
+
workdir,
|
|
330
|
+
questionId: "scope-confirm",
|
|
331
|
+
}, undefined, undefined, ctx);
|
|
332
|
+
assert.match(result.content[0].text, /cancelled/i);
|
|
333
|
+
const loaded = loadCheckpoint(workdir, run.run_id);
|
|
334
|
+
if (loaded.status === "ok") {
|
|
335
|
+
assert.ok(loaded.checkpoint.pendingQuestion, "cancelled question stays pending for resume");
|
|
336
|
+
assert.equal(loaded.checkpoint.answeredQuestions.length, 0);
|
|
337
|
+
}
|
|
338
|
+
} finally {
|
|
339
|
+
fs.rmSync(workdir, { recursive: true, force: true });
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("skips checkpoint writes without questionId or without a checkpoint", async () => {
|
|
344
|
+
const { spawnSync } = await import("node:child_process");
|
|
345
|
+
const { initState, startRun } = await import("../src/state.ts");
|
|
346
|
+
const { createCheckpoint, loadCheckpoint } = await import("../src/workflow-state.ts");
|
|
347
|
+
const workdir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-ask-skip-"));
|
|
348
|
+
try {
|
|
349
|
+
spawnSync("git", ["init"], { cwd: workdir });
|
|
350
|
+
initState(workdir);
|
|
351
|
+
const withCp = startRun(workdir, { topic: "withcp", skill: "plan-small", requestText: "t" }).run;
|
|
352
|
+
createCheckpoint(workdir, { runId: withCp.run_id, originWorkdir: workdir, workdir });
|
|
353
|
+
const noCp = startRun(workdir, { topic: "nocp", skill: "plan-small", requestText: "t" }).run;
|
|
354
|
+
|
|
355
|
+
const tool = loadTool();
|
|
356
|
+
const ctx = makeCtx({ select: async (_question, labels) => labels[0] });
|
|
357
|
+
// No questionId → no checkpoint mutation.
|
|
358
|
+
await tool.execute("t1", { question: "Q?", options: OPTIONS, workdir }, undefined, undefined, ctx);
|
|
359
|
+
let loaded = loadCheckpoint(workdir, withCp.run_id);
|
|
360
|
+
if (loaded.status === "ok") {
|
|
361
|
+
assert.equal(loaded.checkpoint.pendingQuestion, null);
|
|
362
|
+
assert.equal(loaded.checkpoint.answeredQuestions.length, 0);
|
|
363
|
+
}
|
|
364
|
+
// questionId but the active run has no checkpoint → silent skip.
|
|
365
|
+
const result = await tool.execute("t2", {
|
|
366
|
+
question: "Q?",
|
|
367
|
+
options: OPTIONS,
|
|
368
|
+
workdir,
|
|
369
|
+
questionId: "q-no-cp",
|
|
370
|
+
}, undefined, undefined, ctx);
|
|
371
|
+
assert.match(result.content[0].text, /User selected/);
|
|
372
|
+
assert.equal(loadCheckpoint(workdir, noCp.run_id).status, "missing");
|
|
373
|
+
} finally {
|
|
374
|
+
fs.rmSync(workdir, { recursive: true, force: true });
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
});
|
package/tests/compaction.test.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
DEFAULT_VCC_SETTINGS,
|
|
11
11
|
loadVccSettings,
|
|
12
12
|
parseCompactionInstructions,
|
|
13
|
+
PLANNING_PREPLAN_COMPACT_HINT,
|
|
13
14
|
PI_VCC_COMPACT_INSTRUCTION,
|
|
14
15
|
scaffoldVccSettings,
|
|
15
16
|
shouldScheduleAutoContinue,
|
|
@@ -386,3 +387,41 @@ describe("pi-vcc compaction", () => {
|
|
|
386
387
|
}
|
|
387
388
|
});
|
|
388
389
|
});
|
|
390
|
+
describe("pre-plan compaction settings and hint", () => {
|
|
391
|
+
let tmpRoot: string;
|
|
392
|
+
|
|
393
|
+
before(() => {
|
|
394
|
+
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-preplan-vcc-"));
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
after(() => {
|
|
398
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it("parses the pre-plan compaction hint as an internal pi-plans instruction", () => {
|
|
402
|
+
assert.equal(DEFAULT_VCC_SETTINGS.prePlanCompact, true);
|
|
403
|
+
assert.deepEqual(parseCompactionInstructions(PLANNING_PREPLAN_COMPACT_HINT), {
|
|
404
|
+
isPiVcc: false,
|
|
405
|
+
isInternalPiPlans: true,
|
|
406
|
+
keepUserTurns: 1,
|
|
407
|
+
keepUserTurnsExplicit: false,
|
|
408
|
+
followUpPrompt: null,
|
|
409
|
+
});
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it("fills a missing prePlanCompact key and honors an explicit false", () => {
|
|
413
|
+
const stateRoot = path.join(tmpRoot, "state");
|
|
414
|
+
fs.mkdirSync(stateRoot, { recursive: true });
|
|
415
|
+
fs.writeFileSync(vccSettingsPath(stateRoot), JSON.stringify({ debug: true }), "utf8");
|
|
416
|
+
scaffoldVccSettings(stateRoot);
|
|
417
|
+
assert.deepEqual(loadVccSettings(stateRoot), { ...DEFAULT_VCC_SETTINGS, debug: true });
|
|
418
|
+
|
|
419
|
+
fs.writeFileSync(
|
|
420
|
+
vccSettingsPath(stateRoot),
|
|
421
|
+
JSON.stringify({ ...DEFAULT_VCC_SETTINGS, prePlanCompact: false }),
|
|
422
|
+
"utf8",
|
|
423
|
+
);
|
|
424
|
+
scaffoldVccSettings(stateRoot);
|
|
425
|
+
assert.equal(loadVccSettings(stateRoot).prePlanCompact, false);
|
|
426
|
+
});
|
|
427
|
+
});
|
|
@@ -0,0 +1,137 @@
|
|
|
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, describe, it } from "node:test";
|
|
6
|
+
import { InMemoryCredentialStore, createAssistantMessageEventStream } from "@earendil-works/pi-ai";
|
|
7
|
+
import { createAgentSession, DefaultResourceLoader, initTheme, ModelRuntime, SessionManager, SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { Type } from "typebox";
|
|
9
|
+
import piPlansExtension from "../index.ts";
|
|
10
|
+
import { GOAL_WAIT_CUSTOM_TYPE, getExecution, startExecution } from "../src/exec.ts";
|
|
11
|
+
|
|
12
|
+
initTheme("dark", false);
|
|
13
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-lifecycle-"));
|
|
14
|
+
after(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
15
|
+
let serial = 0;
|
|
16
|
+
|
|
17
|
+
async function exercise(mode: "tui" | "rpc" | "print" | "json", needsWake: boolean, commandResume = false) {
|
|
18
|
+
const cwd = path.join(root, String(++serial));
|
|
19
|
+
fs.mkdirSync(cwd);
|
|
20
|
+
const settingsManager = SettingsManager.inMemory({ compaction: { enabled: false }, retry: { enabled: false } });
|
|
21
|
+
const modelRuntime = await ModelRuntime.create({
|
|
22
|
+
credentials: new InMemoryCredentialStore(), modelsPath: null,
|
|
23
|
+
modelsStorePath: path.join(cwd, "models-store.json"), allowModelNetwork: false, refreshOnCreate: false,
|
|
24
|
+
});
|
|
25
|
+
const inputs: any[] = [];
|
|
26
|
+
let toolCalls = 0;
|
|
27
|
+
modelRuntime.registerProvider("local-lifecycle-test", {
|
|
28
|
+
baseUrl: "http://unused.invalid", api: "openai-completions", apiKey: "not-a-real-key",
|
|
29
|
+
models: [{ id: "fixture", name: "fixture", reasoning: false, input: ["text"],
|
|
30
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 100000, maxTokens: 1024 }],
|
|
31
|
+
streamSimple: (model: any, context: any) => {
|
|
32
|
+
inputs.push(structuredClone({ messages: context.messages, systemPrompt: context.systemPrompt }));
|
|
33
|
+
const call = inputs.length;
|
|
34
|
+
assert.ok(call <= 5, "unexpected extra model invocation");
|
|
35
|
+
const tool = call <= 2;
|
|
36
|
+
const text = call === 3
|
|
37
|
+
? needsWake ? "[DONE:VC-001] More verification remains." : "[DONE:VC-001] [DONE:VC-002]"
|
|
38
|
+
: call === 4 && needsWake ? "[DONE:VC-002]" : "Review awaits explicit user approval.";
|
|
39
|
+
const message: any = {
|
|
40
|
+
role: "assistant", api: model.api, provider: model.provider, model: model.id,
|
|
41
|
+
content: tool ? [
|
|
42
|
+
...(commandResume && call === 2 ? [{ type: "text", text: "[DONE:VC-001]" }] : []),
|
|
43
|
+
{ type: "toolCall", id: `call-${call}`, name: "probe", arguments: {} },
|
|
44
|
+
] : [{ type: "text", text: commandResume && call === 3 ? "Interrupted." : text }],
|
|
45
|
+
stopReason: tool ? "toolUse" : commandResume && call === 3 ? "aborted" : "stop", timestamp: Date.now(),
|
|
46
|
+
usage: { input: 10, output: 5, cacheRead: 0, cacheWrite: 0, totalTokens: 15,
|
|
47
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
|
|
48
|
+
};
|
|
49
|
+
const stream = createAssistantMessageEventStream();
|
|
50
|
+
stream.push({ type: "start", partial: message });
|
|
51
|
+
if (message.stopReason === "aborted") stream.push({ type: "error", reason: "aborted", error: message });
|
|
52
|
+
else stream.push({ type: "done", reason: message.stopReason, message });
|
|
53
|
+
stream.end(message);
|
|
54
|
+
return stream;
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
let beforeAgentStarts = 0;
|
|
58
|
+
const events: string[] = [];
|
|
59
|
+
const errors: string[] = [];
|
|
60
|
+
const loader = new DefaultResourceLoader({
|
|
61
|
+
cwd, agentDir: path.join(cwd, "agent"), settingsManager,
|
|
62
|
+
noExtensions: true, noSkills: true, noPromptTemplates: true, noThemes: true,
|
|
63
|
+
agentsFilesOverride: () => ({ agentsFiles: [] }), systemPromptOverride: () => "Deterministic test.",
|
|
64
|
+
extensionFactories: [piPlansExtension, pi => {
|
|
65
|
+
pi.on("before_agent_start", () => { beforeAgentStarts++; });
|
|
66
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
67
|
+
await startExecution(pi, ctx, path.join(cwd, "PLAN_v1.md"), [
|
|
68
|
+
{ id: "VC-001", text: "first", done: false }, { id: "VC-002", text: "second", done: false },
|
|
69
|
+
]);
|
|
70
|
+
});
|
|
71
|
+
}],
|
|
72
|
+
});
|
|
73
|
+
await loader.reload();
|
|
74
|
+
assert.deepEqual(loader.getExtensions().errors, []);
|
|
75
|
+
const { session } = await createAgentSession({
|
|
76
|
+
cwd, agentDir: path.join(cwd, "agent"), modelRuntime,
|
|
77
|
+
model: modelRuntime.getModel("local-lifecycle-test", "fixture")!, thinkingLevel: "off",
|
|
78
|
+
resourceLoader: loader, settingsManager, sessionManager: SessionManager.inMemory(cwd), tools: ["probe"],
|
|
79
|
+
customTools: [{ name: "probe", label: "Probe", description: "Local test probe", parameters: Type.Object({}),
|
|
80
|
+
execute: async () => { toolCalls++; return { content: [{ type: "text", text: "ok" }], details: {} }; } }],
|
|
81
|
+
});
|
|
82
|
+
const unsubscribe = session.subscribe(event => events.push(event.type));
|
|
83
|
+
try {
|
|
84
|
+
await session.bindExtensions({
|
|
85
|
+
mode,
|
|
86
|
+
...(mode === "tui" || mode === "rpc" ? { uiContext: {
|
|
87
|
+
setStatus: () => {}, notify: () => {}, theme: { fg: (_c: string, s: string) => s },
|
|
88
|
+
} as any } : {}),
|
|
89
|
+
onError: error => errors.push(error.error),
|
|
90
|
+
});
|
|
91
|
+
await session.prompt("Implement the test plan.");
|
|
92
|
+
await session.waitForIdle();
|
|
93
|
+
if (commandResume) {
|
|
94
|
+
assert.equal(getExecution()?.goalWait?.paused, true);
|
|
95
|
+
assert.deepEqual(getExecution()?.items.map(item => item.done), [true, false]);
|
|
96
|
+
assert.equal(inputs.length, 3);
|
|
97
|
+
await session.prompt("/plans-execute");
|
|
98
|
+
}
|
|
99
|
+
// SDK callers, unlike print mode, own the runtime until all nested wakes settle.
|
|
100
|
+
await session.waitForIdle();
|
|
101
|
+
assert.deepEqual(errors, []);
|
|
102
|
+
assert.deepEqual(session.messages.filter((m: any) => m.role === "assistant" && m.stopReason === "error"), [], "fixture model must run successfully");
|
|
103
|
+
const wakes = session.messages.filter((m: any) => m.customType === GOAL_WAIT_CUSTOM_TYPE) as any[];
|
|
104
|
+
assert.equal(toolCalls, 2);
|
|
105
|
+
assert.equal(beforeAgentStarts, 1, "custom wake must work without before_agent_start");
|
|
106
|
+
const interactive = mode === "tui" || mode === "rpc";
|
|
107
|
+
assert.equal(wakes.length, interactive && needsWake ? 1 : 0);
|
|
108
|
+
assert.equal(inputs.length, interactive ? needsWake ? 5 : 4 : 3);
|
|
109
|
+
if (interactive && needsWake) {
|
|
110
|
+
assert.equal(wakes[0].display, false);
|
|
111
|
+
assert.match(JSON.stringify(inputs[3].messages), /1\/2 verifier items done/);
|
|
112
|
+
assert.match(wakes[0].content, /- `VC-002` second/);
|
|
113
|
+
assert.doesNotMatch(wakes[0].content, /- `VC-001` first/);
|
|
114
|
+
}
|
|
115
|
+
if (interactive || !needsWake) assert.equal(getExecution(), null);
|
|
116
|
+
else assert.deepEqual(getExecution()?.items.map(item => item.done), [true, false]);
|
|
117
|
+
assert.equal(session.pendingMessageCount, 0);
|
|
118
|
+
assert.equal(events.at(-1), "agent_settled");
|
|
119
|
+
return { events, calls: inputs.length, wakes: wakes.length };
|
|
120
|
+
} finally {
|
|
121
|
+
unsubscribe();
|
|
122
|
+
session.dispose();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
describe("goal-wait on the real Pi host", () => {
|
|
127
|
+
it("dispatches the registered /plans-execute command and preserves completed VCs", { timeout: 15000 }, async () => {
|
|
128
|
+
await exercise("rpc", true, true);
|
|
129
|
+
});
|
|
130
|
+
for (const mode of ["tui", "rpc", "print", "json"] as const) {
|
|
131
|
+
for (const needsWake of [false, true]) {
|
|
132
|
+
it(`${mode}: tools then ${needsWake ? "incomplete stop" : "completion"}`, { timeout: 15000 }, async () => {
|
|
133
|
+
await exercise(mode, needsWake);
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
});
|