pi-plans 0.3.2 → 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.
@@ -0,0 +1,385 @@
1
+ /**
2
+ * Real-Pi-host /resume-plans lifecycle tests (I-007): full extension
3
+ * dispatch via session.prompt("/resume-plans"), cross-session execution
4
+ * resume, linked-worktree discovery, concurrent-owner refusal, and print
5
+ * mode staying silent.
6
+ */
7
+
8
+ import * as assert from "node:assert/strict";
9
+ import { spawn, spawnSync } from "node:child_process";
10
+ import * as fs from "node:fs";
11
+ import * as os from "node:os";
12
+ import * as path from "node:path";
13
+ import { after, describe, it } from "node:test";
14
+ import { InMemoryCredentialStore, createAssistantMessageEventStream } from "@earendil-works/pi-ai";
15
+ import {
16
+ createAgentSession,
17
+ DefaultResourceLoader,
18
+ initTheme,
19
+ ModelRuntime,
20
+ SessionManager,
21
+ SettingsManager,
22
+ } from "@earendil-works/pi-coding-agent";
23
+ import piPlansExtension from "../index.ts";
24
+ import { startExecution } from "../src/exec.ts";
25
+ import { resetRunBindingForTests } from "../src/run-context.ts";
26
+ import { processStartOf } from "../src/run-ownership.ts";
27
+ import { createCheckpoint, loadCheckpoint, mutateCheckpoint, applyQuestionAsked } from "../src/workflow-state.ts";
28
+ import { getRun, initState, startRun } from "../src/state.ts";
29
+
30
+ initTheme("dark", false);
31
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-resume-life-"));
32
+ after(() => fs.rmSync(root, { recursive: true, force: true }));
33
+ let serial = 0;
34
+
35
+ const PLAN_TEXT = `# Plan
36
+
37
+ ## Verifier Checklist
38
+
39
+ - [ ] \`VC-001\` covers \`I-001\`; pass condition: first.
40
+ - [ ] \`VC-002\` covers \`I-002\`; pass condition: second.
41
+ `;
42
+
43
+ interface Fixture {
44
+ /** Deterministic script of assistant replies (text or toolUse). */
45
+ script: Array<{ kind: "text"; text: string } | { kind: "tool" }>;
46
+ calls: { messages: unknown; systemPrompt: string }[];
47
+ }
48
+
49
+ function fixtureRuntime(cwd: string, fixture: Fixture) {
50
+ return ModelRuntime.create({
51
+ credentials: new InMemoryCredentialStore(),
52
+ modelsPath: null,
53
+ modelsStorePath: path.join(cwd, "models-store.json"),
54
+ allowModelNetwork: false,
55
+ refreshOnCreate: false,
56
+ }).then(async (modelRuntime) => {
57
+ modelRuntime.registerProvider("local-resume-test", {
58
+ baseUrl: "http://unused.invalid",
59
+ api: "openai-completions",
60
+ apiKey: "not-a-real-key",
61
+ models: [
62
+ {
63
+ id: "fixture",
64
+ name: "fixture",
65
+ reasoning: false,
66
+ input: ["text"],
67
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
68
+ contextWindow: 100000,
69
+ maxTokens: 1024,
70
+ },
71
+ ],
72
+ streamSimple: (model: unknown, context: { messages: unknown; systemPrompt: string }) => {
73
+ fixture.calls.push(structuredClone({ messages: context.messages, systemPrompt: context.systemPrompt }));
74
+ const step = fixture.script[Math.min(fixture.calls.length, fixture.script.length) - 1] ?? { kind: "text", text: "done" } as const;
75
+ const tool = step.kind === "tool";
76
+ const message = {
77
+ role: "assistant",
78
+ api: "openai-completions",
79
+ provider: "local-resume-test",
80
+ model: "fixture",
81
+ content: tool
82
+ ? [{ type: "toolCall", id: `call-${fixture.calls.length}`, name: "probe", arguments: {} }]
83
+ : [{ type: "text", text: (step as { text: string }).text }],
84
+ stopReason: tool ? "toolUse" : "stop",
85
+ timestamp: Date.now(),
86
+ usage: { input: 10, output: 5, cacheRead: 0, cacheWrite: 0, totalTokens: 15, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
87
+ };
88
+ const stream = createAssistantMessageEventStream();
89
+ stream.push({ type: "start", partial: message });
90
+ stream.push({ type: "done", reason: message.stopReason, message });
91
+ stream.end(message);
92
+ return stream;
93
+ },
94
+ });
95
+ return modelRuntime;
96
+ });
97
+ }
98
+
99
+ function uiContext(notifies: string[]) {
100
+ return {
101
+ setStatus: () => {},
102
+ notify: (message: string) => {
103
+ notifies.push(message);
104
+ },
105
+ confirm: async () => true,
106
+ select: async (_title: string, options: string[]) => options[0],
107
+ input: async () => "typed",
108
+ theme: { fg: (_c: string, s: string) => s },
109
+ } as never;
110
+ }
111
+
112
+ async function makeSession(options: {
113
+ cwd: string;
114
+ fixture: Fixture;
115
+ mode: "tui" | "rpc" | "print" | "json";
116
+ extraExtension?: (pi: never) => void;
117
+ notifies?: string[];
118
+ }) {
119
+ const { cwd, fixture, mode } = options;
120
+ const settingsManager = SettingsManager.inMemory({ compaction: { enabled: false }, retry: { enabled: false } });
121
+ const modelRuntime = await fixtureRuntime(cwd, fixture);
122
+ const loader = new DefaultResourceLoader({
123
+ cwd,
124
+ agentDir: path.join(cwd, "agent"),
125
+ settingsManager,
126
+ noExtensions: true,
127
+ noSkills: true,
128
+ noPromptTemplates: true,
129
+ noThemes: true,
130
+ agentsFilesOverride: () => ({ agentsFiles: [] }),
131
+ systemPromptOverride: () => "Deterministic test.",
132
+ extensionFactories: [piPlansExtension as never, ...(options.extraExtension ? [options.extraExtension] : [])],
133
+ });
134
+ await loader.reload();
135
+ assert.deepEqual(loader.getExtensions().errors, []);
136
+ const { session } = await createAgentSession({
137
+ cwd,
138
+ agentDir: path.join(cwd, "agent"),
139
+ modelRuntime,
140
+ model: modelRuntime.getModel("local-resume-test", "fixture")!,
141
+ thinkingLevel: "off",
142
+ resourceLoader: loader,
143
+ settingsManager,
144
+ sessionManager: SessionManager.inMemory(cwd),
145
+ tools: ["probe"],
146
+ customTools: [
147
+ {
148
+ name: "probe",
149
+ label: "Probe",
150
+ description: "Local test probe",
151
+ parameters: { type: "object", properties: {} },
152
+ execute: async () => ({ content: [{ type: "text", text: "ok" }], details: {} }),
153
+ } as never,
154
+ ],
155
+ });
156
+ const errors: string[] = [];
157
+ await session.bindExtensions({
158
+ mode,
159
+ ...(mode === "tui" || mode === "rpc" ? { uiContext: uiContext(options.notifies ?? []) } : {}),
160
+ onError: (error: { error: string }) => errors.push(error.error),
161
+ });
162
+ return { session, errors };
163
+ }
164
+
165
+ function setupRun(name: string): { cwd: string; runId: string; artifactDir: string } {
166
+ const cwd = path.join(root, `${String(++serial)}-${name}`);
167
+ fs.mkdirSync(cwd, { recursive: true });
168
+ spawnSync("git", ["init"], { cwd });
169
+ spawnSync("git", ["config", "user.email", "t@e.com"], { cwd });
170
+ spawnSync("git", ["config", "user.name", "T"], { cwd });
171
+ initState(cwd);
172
+ const { run } = startRun(cwd, { topic: name, skill: "plan-normal", requestText: "Lifecycle resume test" });
173
+ createCheckpoint(cwd, { runId: run.run_id, originWorkdir: cwd, workdir: cwd });
174
+ fs.mkdirSync(run.artifact_dir, { recursive: true });
175
+ fs.writeFileSync(path.join(run.artifact_dir, "PLAN_v1.md"), PLAN_TEXT, "utf8");
176
+ spawnSync("git", ["add", "-A"], { cwd });
177
+ spawnSync("git", ["commit", "-m", "seed"], { cwd });
178
+ return { cwd, runId: run.run_id, artifactDir: run.artifact_dir };
179
+ }
180
+
181
+ describe("/resume-plans on the real Pi host", () => {
182
+ it(
183
+ "resumes an interrupted execution across sessions via command dispatch",
184
+ { timeout: 30000 },
185
+ async () => {
186
+ resetRunBindingForTests();
187
+ const { cwd, runId } = setupRun("cross-session");
188
+ // Session 1: execution starts, VC-001 lands, then the session ends.
189
+ const fixture1: Fixture = {
190
+ script: [
191
+ { kind: "tool" },
192
+ { kind: "text", text: "[DONE:VC-001] Partial progress; stopping here." },
193
+ ],
194
+ calls: [],
195
+ };
196
+ const session1 = await makeSession({
197
+ cwd,
198
+ fixture: fixture1,
199
+ mode: "rpc",
200
+ extraExtension: ((pi: never) => {
201
+ const realPi = pi as unknown as import("@earendil-works/pi-coding-agent").ExtensionAPI;
202
+ realPi.on("session_start", async (_event: unknown, ctx: never) => {
203
+ await startExecution(realPi, ctx as never, path.join(artifactDirRelative(cwd), "PLAN_v1.md"), [
204
+ { id: "VC-001", text: "first", done: false },
205
+ { id: "VC-002", text: "second", done: false },
206
+ ]);
207
+ });
208
+ }) as never,
209
+ });
210
+ try {
211
+ await session1.session.prompt("Begin execution.");
212
+ await session1.session.waitForIdle();
213
+ assert.deepEqual(session1.errors, []);
214
+ const mid = loadCheckpoint(cwd, runId);
215
+ assert.equal(mid.status, "ok");
216
+ if (mid.status === "ok") {
217
+ assert.deepEqual(mid.checkpoint.execution?.doneVcIds, ["VC-001"], "progress persisted by session 1");
218
+ assert.ok(mid.checkpoint.execution?.approval?.headAtApproval, "approval evidence persisted");
219
+ }
220
+ } finally {
221
+ session1.session.dispose();
222
+ }
223
+ // The next session rebuilds execution from the checkpoint; the
224
+ // in-memory module state is irrelevant across sessions.
225
+
226
+ // Session 2: fresh session, resume via the command.
227
+ const fixture2: Fixture = {
228
+ script: [{ kind: "text", text: "[DONE:VC-002] All items verified." }],
229
+ calls: [],
230
+ };
231
+ const notifies: string[] = [];
232
+ const session2 = await makeSession({ cwd, fixture: fixture2, mode: "rpc", notifies });
233
+ try {
234
+ await session2.session.prompt("/resume-plans");
235
+ await waitForModelCalls(fixture2, session2.session, 1);
236
+ assert.deepEqual(session2.errors, []);
237
+ // The kickoff brief reached the model as the user message.
238
+ assert.ok(fixture2.calls.length >= 1);
239
+ const firstPayload = JSON.stringify(fixture2.calls[0]);
240
+ assert.match(firstPayload, /PI-PLANS RESUME/);
241
+ assert.match(firstPayload, /VC-001/);
242
+ assert.ok(notifies.some((message) => /Resumed/.test(message)), "resume notification shown");
243
+ // Both VCs are done; the run advanced to the review phase.
244
+ const final = loadCheckpoint(cwd, runId);
245
+ assert.equal(final.status, "ok");
246
+ if (final.status === "ok") {
247
+ assert.deepEqual(final.checkpoint.execution?.doneVcIds.sort(), ["VC-001", "VC-002"]);
248
+ assert.equal(final.checkpoint.phase, "implementation-review");
249
+ }
250
+ assert.equal(getRun(cwd, runId)?.status, "done");
251
+ } finally {
252
+ session2.session.dispose();
253
+ }
254
+ },
255
+ );
256
+
257
+ it(
258
+ "resumes a planning run with a pending question (command dispatch)",
259
+ { timeout: 30000 },
260
+ async () => {
261
+ resetRunBindingForTests();
262
+ const { cwd, runId } = setupRun("planning");
263
+ mutateCheckpoint(cwd, runId, (cp) =>
264
+ applyQuestionAsked(cp, { questionId: "q-depth", question: "How deep?", options: ["shallow", "deep"] }),
265
+ );
266
+ const fixture: Fixture = { script: [{ kind: "text", text: "Answering the pending question next." }], calls: [] };
267
+ const notifies: string[] = [];
268
+ const session = await makeSession({ cwd, fixture, mode: "rpc", notifies });
269
+ try {
270
+ await session.session.prompt("/resume-plans");
271
+ await waitForModelCalls(fixture, session.session, 1);
272
+ assert.deepEqual(session.errors, []);
273
+ const payload = JSON.stringify(fixture.calls[0]);
274
+ assert.match(payload, /PI-PLANS RESUME/);
275
+ assert.match(payload, /q-depth/);
276
+ assert.match(payload, /PENDING question/);
277
+ } finally {
278
+ session.session.dispose();
279
+ }
280
+ },
281
+ );
282
+
283
+ it(
284
+ "tui mode dispatch resumes the same brief",
285
+ { timeout: 30000 },
286
+ async () => {
287
+ resetRunBindingForTests();
288
+ const { cwd, runId } = setupRun("tui-planning");
289
+ mutateCheckpoint(cwd, runId, (cp) =>
290
+ applyQuestionAsked(cp, { questionId: "q-scope", question: "Which scope?", options: ["A", "B"] }),
291
+ );
292
+ const fixture: Fixture = { script: [{ kind: "text", text: "Continuing in the TUI session." }], calls: [] };
293
+ const session = await makeSession({ cwd, fixture, mode: "tui", notifies: [] });
294
+ try {
295
+ await session.session.prompt("/resume-plans");
296
+ await waitForModelCalls(fixture, session.session, 1);
297
+ assert.deepEqual(session.errors, []);
298
+ const payload = JSON.stringify(fixture.calls[0]);
299
+ assert.match(payload, /PI-PLANS RESUME/);
300
+ assert.match(payload, /q-scope/);
301
+ } finally {
302
+ session.session.dispose();
303
+ }
304
+ },
305
+ );
306
+
307
+ it(
308
+ "refuses when another live owner holds the run",
309
+ { timeout: 30000 },
310
+ async () => {
311
+ resetRunBindingForTests();
312
+ const { cwd, runId } = setupRun("owned");
313
+ const child = spawn("sleep", ["30"], { stdio: "ignore" });
314
+ try {
315
+ fs.writeFileSync(
316
+ path.join(cwd, ".git", "pi_plans", "runs", runId, "owner.json"),
317
+ JSON.stringify({
318
+ schema: 1,
319
+ host: os.hostname(),
320
+ pid: child.pid,
321
+ pidStart: processStartOf(child.pid!),
322
+ sessionId: null,
323
+ processToken: "foreign-live",
324
+ generation: 1,
325
+ acquiredAt: "2026-09-07T00:00:00Z",
326
+ }),
327
+ "utf8",
328
+ );
329
+ const fixture: Fixture = { script: [{ kind: "text", text: "must not run" }], calls: [] };
330
+ const notifies: string[] = [];
331
+ const session = await makeSession({ cwd, fixture, mode: "rpc", notifies });
332
+ try {
333
+ await session.session.prompt("/resume-plans");
334
+ await session.session.waitForIdle();
335
+ assert.equal(fixture.calls.length, 0, "no model call without ownership");
336
+ assert.ok(notifies.some((message) => /actively owned/.test(message)));
337
+ } finally {
338
+ session.session.dispose();
339
+ }
340
+ } finally {
341
+ child.kill("SIGKILL");
342
+ }
343
+ },
344
+ );
345
+
346
+ it(
347
+ "print mode stays silent (no resume flow)",
348
+ { timeout: 30000 },
349
+ async () => {
350
+ resetRunBindingForTests();
351
+ const { cwd } = setupRun("print-mode");
352
+ const fixture: Fixture = { script: [{ kind: "text", text: "idle" }], calls: [] };
353
+ const session = await makeSession({ cwd, fixture, mode: "print" });
354
+ try {
355
+ await session.session.prompt("/resume-plans");
356
+ await session.session.waitForIdle();
357
+ // Command refused before any model call: the only input is the
358
+ // raw command turn the host itself ran (print mode has no UI).
359
+ const payloads = fixture.calls.map((call) => JSON.stringify(call));
360
+ assert.ok(
361
+ payloads.every((payload) => !payload.includes("PI-PLANS RESUME")),
362
+ "no resume brief in print mode",
363
+ );
364
+ } finally {
365
+ session.session.dispose();
366
+ }
367
+ },
368
+ );
369
+ });
370
+
371
+ function artifactDirRelative(cwd: string): string {
372
+ const root = path.join(cwd, "docs", "pi-plans");
373
+ return path.join(root, fs.readdirSync(root)[0]!);
374
+ }
375
+
376
+ /** The command's kickoff message starts after prompt() returns; poll until
377
+ * the fixture model actually ran (or fail after a deadline). */
378
+ async function waitForModelCalls(fixture: Fixture, session: { waitForIdle: () => Promise<void> }, minimum: number): Promise<void> {
379
+ const deadline = Date.now() + 10000;
380
+ while (fixture.calls.length < minimum && Date.now() < deadline) {
381
+ await session.waitForIdle();
382
+ if (fixture.calls.length < minimum) await new Promise((resolve) => setTimeout(resolve, 50));
383
+ }
384
+ await session.waitForIdle();
385
+ }