@abdwhb-png/pi-test-harness 0.7.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.
Files changed (74) hide show
  1. package/CHANGELOG.md +161 -0
  2. package/LICENSE +21 -0
  3. package/README.md +673 -0
  4. package/dist/diagnostics.d.ts +11 -0
  5. package/dist/diagnostics.d.ts.map +1 -0
  6. package/dist/diagnostics.js +61 -0
  7. package/dist/diagnostics.js.map +1 -0
  8. package/dist/events.d.ts +6 -0
  9. package/dist/events.d.ts.map +1 -0
  10. package/dist/events.js +33 -0
  11. package/dist/events.js.map +1 -0
  12. package/dist/index.d.ts +14 -0
  13. package/dist/index.d.ts.map +1 -0
  14. package/dist/index.js +19 -0
  15. package/dist/index.js.map +1 -0
  16. package/dist/mock-pi-script.mjs +176 -0
  17. package/dist/mock-pi.d.ts +32 -0
  18. package/dist/mock-pi.d.ts.map +1 -0
  19. package/dist/mock-pi.js +150 -0
  20. package/dist/mock-pi.js.map +1 -0
  21. package/dist/mock-tools.d.ts +51 -0
  22. package/dist/mock-tools.d.ts.map +1 -0
  23. package/dist/mock-tools.js +192 -0
  24. package/dist/mock-tools.js.map +1 -0
  25. package/dist/mock-ui.d.ts +13 -0
  26. package/dist/mock-ui.d.ts.map +1 -0
  27. package/dist/mock-ui.js +159 -0
  28. package/dist/mock-ui.js.map +1 -0
  29. package/dist/pi-loader-parity.d.ts +36 -0
  30. package/dist/pi-loader-parity.d.ts.map +1 -0
  31. package/dist/pi-loader-parity.js +60 -0
  32. package/dist/pi-loader-parity.js.map +1 -0
  33. package/dist/playbook.d.ts +44 -0
  34. package/dist/playbook.d.ts.map +1 -0
  35. package/dist/playbook.js +143 -0
  36. package/dist/playbook.js.map +1 -0
  37. package/dist/sandbox.d.ts +27 -0
  38. package/dist/sandbox.d.ts.map +1 -0
  39. package/dist/sandbox.js +269 -0
  40. package/dist/sandbox.js.map +1 -0
  41. package/dist/session.d.ts +13 -0
  42. package/dist/session.d.ts.map +1 -0
  43. package/dist/session.js +187 -0
  44. package/dist/session.js.map +1 -0
  45. package/dist/types.d.ts +171 -0
  46. package/dist/types.d.ts.map +1 -0
  47. package/dist/types.js +5 -0
  48. package/dist/types.js.map +1 -0
  49. package/dist/utils.d.ts +32 -0
  50. package/dist/utils.d.ts.map +1 -0
  51. package/dist/utils.js +46 -0
  52. package/dist/utils.js.map +1 -0
  53. package/package.json +84 -0
  54. package/skills/pi-test-harness/SKILL.md +451 -0
  55. package/skills/pi-test-harness/evals/evals.json +26 -0
  56. package/skills/pi-test-harness/references/api-reference.md +480 -0
  57. package/skills/pi-test-harness/references/mock-pi-cli.md +135 -0
  58. package/skills/pi-test-harness/references/mock-tools.md +176 -0
  59. package/skills/pi-test-harness/references/mock-ui.md +170 -0
  60. package/skills/pi-test-harness/references/playbook-dsl.md +209 -0
  61. package/skills/pi-test-harness/references/sandbox-install.md +113 -0
  62. package/src/diagnostics.ts +90 -0
  63. package/src/events.ts +43 -0
  64. package/src/index.ts +42 -0
  65. package/src/mock-pi-script.mjs +176 -0
  66. package/src/mock-pi.ts +169 -0
  67. package/src/mock-tools.ts +252 -0
  68. package/src/mock-ui.ts +196 -0
  69. package/src/pi-loader-parity.ts +61 -0
  70. package/src/playbook.ts +189 -0
  71. package/src/sandbox.ts +334 -0
  72. package/src/session.ts +249 -0
  73. package/src/types.ts +203 -0
  74. package/src/utils.ts +46 -0
package/src/sandbox.ts ADDED
@@ -0,0 +1,334 @@
1
+ /**
2
+ * Sandbox install verification — verifies npm packages work when installed clean.
3
+ *
4
+ * 1. npm pack → tarball
5
+ * 2. Install in temp dir
6
+ * 3. DefaultResourceLoader discovers extensions/skills
7
+ * 4. Verify resources load without errors
8
+ * 5. Optional smoke test
9
+ */
10
+
11
+ import * as fs from "node:fs";
12
+ import * as path from "node:path";
13
+ import * as os from "node:os";
14
+ import { execFileSync } from "node:child_process";
15
+ import {
16
+ DefaultResourceLoader,
17
+ SettingsManager,
18
+ } from "@earendil-works/pi-coding-agent";
19
+ import { withoutJitiNativeImport } from "./pi-loader-parity.js";
20
+ import type { SandboxOptions, SandboxResult } from "./types.js";
21
+ import { createTestSession } from "./session.js";
22
+
23
+ /** Resolve the npm command array. Defaults to platform-aware npm. */
24
+ function resolveNpmCommand(npmCommand?: string[]): string[] {
25
+ if (npmCommand && npmCommand.length > 0) return npmCommand;
26
+ return [process.platform === "win32" ? "npm.cmd" : "npm"];
27
+ }
28
+
29
+ /**
30
+ * Resolve `command` to something execFileSync can actually start.
31
+ *
32
+ * **Why this exists**: on Windows a bare `sfw` (or `npm`) is the `.cmd` shim, and
33
+ * child_process applies no PATHEXT when creating the process, so spawning it
34
+ * fails with ENOENT — which broke the documented `npmCommand: ["sfw", "npm"]`
35
+ * preset on the Windows integration matrix. Candidate extensions come from
36
+ * PATHEXT (or the usual Windows default) and are matched against PATH in the
37
+ * same order the platform itself would. An unmatched command is returned
38
+ * unchanged, so a genuinely missing executable still raises a clear ENOENT.
39
+ *
40
+ * Exported for unit testing — not part of the public API contract.
41
+ * @internal
42
+ */
43
+ export function _resolveExecutable(
44
+ command: string,
45
+ platform: NodeJS.Platform = process.platform,
46
+ env: NodeJS.ProcessEnv = process.env,
47
+ ): string {
48
+ if (platform !== "win32") return command;
49
+ if (path.extname(command) !== "") return command;
50
+
51
+ const extensions = (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD")
52
+ .split(";")
53
+ .filter(Boolean);
54
+
55
+ // An explicit path never goes through PATH resolution — only PATHEXT applies.
56
+ if (command.includes("/") || command.includes("\\")) {
57
+ for (const extension of extensions) {
58
+ const candidate = `${command}${extension}`;
59
+ if (fs.existsSync(candidate)) return candidate;
60
+ }
61
+ return command;
62
+ }
63
+
64
+ const directories = (env.PATH ?? "").split(path.delimiter).filter(Boolean);
65
+ for (const directory of directories) {
66
+ for (const extension of extensions) {
67
+ const candidate = path.join(directory, `${command}${extension}`);
68
+ if (fs.existsSync(candidate)) return candidate;
69
+ }
70
+ }
71
+
72
+ return command;
73
+ }
74
+
75
+ /**
76
+ * Run a command via execFileSync with safe string conversion for the full
77
+ * command line in the error message.
78
+ */
79
+ function run(args: string[], cwd: string, label: string): string {
80
+ const [cmd, ...cmdArgs] = args;
81
+ try {
82
+ return execFileSync(_resolveExecutable(cmd), cmdArgs, {
83
+ cwd,
84
+ encoding: "utf-8",
85
+ stdio: ["pipe", "pipe", "pipe"],
86
+ }).trim();
87
+ } catch (err: any) {
88
+ // Enhance the error message with context
89
+ const stderr = err.stderr?.toString().trim() ?? "";
90
+ const enhanced = new Error(
91
+ `${label} failed: ${err.message}${stderr ? `\nstderr: ${stderr}` : ""}`,
92
+ );
93
+ // Preserve the original error code (ENOENT, etc.)
94
+ (enhanced as any).code = err.code;
95
+ throw enhanced;
96
+ }
97
+ }
98
+
99
+ /** Manifest fields the sandbox install path needs. */
100
+ interface PackageManifest {
101
+ name?: string;
102
+ pi?: {
103
+ extensions?: string[];
104
+ };
105
+ }
106
+
107
+ /**
108
+ * Read a package.json, reporting the offending path instead of surfacing a bare
109
+ * SyntaxError. A malformed manifest is fatal for both call sites below, so the
110
+ * failure is rethrown with the path that caused it.
111
+ */
112
+ function readPackageJson(filePath: string): PackageManifest {
113
+ let raw: string;
114
+ try {
115
+ raw = fs.readFileSync(filePath, "utf-8");
116
+ } catch (err) {
117
+ throw new Error(
118
+ `Could not read ${filePath}: ${err instanceof Error ? err.message : String(err)}`,
119
+ { cause: err },
120
+ );
121
+ }
122
+ try {
123
+ return JSON.parse(raw) as PackageManifest;
124
+ } catch (err) {
125
+ throw new Error(
126
+ `Invalid JSON in ${filePath}: ${err instanceof Error ? err.message : String(err)}`,
127
+ { cause: err },
128
+ );
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Read the package name from a package.json, decoded here rather than at the
134
+ * call site: every consumer of this value needs it to be a non-empty string.
135
+ */
136
+ function readPackageName(filePath: string): string {
137
+ const manifest = readPackageJson(filePath);
138
+ const name = manifest.name;
139
+ if (typeof name !== "string" || name.length === 0) {
140
+ throw new Error(`package.json at ${filePath} has no "name" field`);
141
+ }
142
+ return name;
143
+ }
144
+
145
+ export async function verifySandboxInstall(
146
+ options: SandboxOptions,
147
+ ): Promise<SandboxResult> {
148
+ const packageDir = path.resolve(options.packageDir);
149
+ const npmCmd = resolveNpmCommand(options.npmCommand);
150
+
151
+ // Validate package directory
152
+ const pkgJsonPath = path.join(packageDir, "package.json");
153
+ if (!fs.existsSync(pkgJsonPath)) {
154
+ throw new Error(`No package.json found at ${pkgJsonPath}`);
155
+ }
156
+ const pkgName = readPackageName(pkgJsonPath);
157
+
158
+ // Create sandbox temp dir
159
+ const sandboxDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-sandbox-"));
160
+
161
+ try {
162
+ // 1. npm pack → tarball
163
+ const packArgs = [...npmCmd.slice(1), "pack", "--pack-destination", "."];
164
+ const packOutput = run(
165
+ [...npmCmd.slice(0, 1), ...packArgs],
166
+ packageDir,
167
+ `npm pack in ${packageDir}`,
168
+ );
169
+
170
+ // The output is the tarball filename
171
+ const tarballName = packOutput.split("\n").pop()!.trim();
172
+ const tarballSrc = path.join(packageDir, tarballName);
173
+ const tarballDest = path.join(sandboxDir, tarballName);
174
+ try {
175
+ fs.copyFileSync(tarballSrc, tarballDest);
176
+ } finally {
177
+ // Always clean up tarball from source (even if copy fails)
178
+ try {
179
+ if (fs.existsSync(tarballSrc)) fs.unlinkSync(tarballSrc);
180
+ } catch {
181
+ /* best-effort */
182
+ }
183
+ }
184
+
185
+ // 2. Create minimal package.json in sandbox
186
+ const sandboxPkg = {
187
+ name: "pi-test-sandbox",
188
+ private: true,
189
+ type: "module",
190
+ dependencies: {
191
+ [pkgName]: `file:./${tarballName}`,
192
+ },
193
+ };
194
+ fs.writeFileSync(
195
+ path.join(sandboxDir, "package.json"),
196
+ JSON.stringify(sandboxPkg, null, 2),
197
+ );
198
+
199
+ // 3. npm install
200
+ const installArgs = [...npmCmd.slice(1), "install", "--ignore-scripts=false"];
201
+ run(
202
+ [...npmCmd.slice(0, 1), ...installArgs],
203
+ sandboxDir,
204
+ `npm install in ${sandboxDir}`,
205
+ );
206
+
207
+ // 4. Find the installed package and use DefaultResourceLoader
208
+ const installedPkgDir = path.join(
209
+ sandboxDir,
210
+ "node_modules",
211
+ ...pkgName.split("/"),
212
+ );
213
+
214
+ if (!fs.existsSync(installedPkgDir)) {
215
+ throw new Error(`Package not found after install: ${installedPkgDir}`);
216
+ }
217
+
218
+ // Read installed package.json for pi manifest
219
+ const installedPkgJson = readPackageJson(
220
+ path.join(installedPkgDir, "package.json"),
221
+ );
222
+ const piManifest = installedPkgJson.pi;
223
+
224
+ // Resolve extension paths from the installed package
225
+ const extensionPaths: string[] = [];
226
+ if (piManifest?.extensions) {
227
+ for (const ext of piManifest.extensions) {
228
+ const resolved = path.resolve(installedPkgDir, ext);
229
+ if (fs.existsSync(resolved)) {
230
+ extensionPaths.push(resolved);
231
+ } else {
232
+ // Try as glob/directory
233
+ const dir = path.resolve(installedPkgDir, ext);
234
+ if (fs.existsSync(dir) && fs.statSync(dir).isDirectory()) {
235
+ const files = fs
236
+ .readdirSync(dir)
237
+ .filter((f) => f.endsWith(".ts") || f.endsWith(".js"));
238
+ extensionPaths.push(...files.map((f) => path.join(dir, f)));
239
+ }
240
+ }
241
+ }
242
+ }
243
+
244
+ // Load extensions via DefaultResourceLoader
245
+ const settingsManager = SettingsManager.inMemory();
246
+ const loader = new DefaultResourceLoader({
247
+ cwd: sandboxDir,
248
+ agentDir: sandboxDir,
249
+ settingsManager,
250
+ additionalExtensionPaths: extensionPaths,
251
+ });
252
+ // Extension modules evaluate here; keep the loader configuration in parity
253
+ // with Pi's shipped runtimes (see withoutJitiNativeImport).
254
+ await withoutJitiNativeImport(() => loader.reload());
255
+
256
+ const extensionsResult = loader.getExtensions();
257
+ const skillsResult = loader.getSkills();
258
+
259
+ // Collect tool names from loaded extensions (no cast needed)
260
+ const toolNames: string[] = [];
261
+ for (const ext of extensionsResult.extensions) {
262
+ for (const [name] of ext.tools ?? new Map()) {
263
+ toolNames.push(name);
264
+ }
265
+ }
266
+
267
+ const result: SandboxResult = {
268
+ loaded: {
269
+ extensions: extensionsResult.extensions.length,
270
+ extensionErrors: extensionsResult.errors.map(
271
+ (e) => `${e.path}: ${e.error}`,
272
+ ),
273
+ tools: toolNames,
274
+ skills: skillsResult.skills.length,
275
+ },
276
+ };
277
+
278
+ // 5. Verify expectations
279
+ if (options.expect) {
280
+ if (options.expect.extensions !== undefined) {
281
+ if (extensionsResult.extensions.length !== options.expect.extensions) {
282
+ throw new Error(
283
+ `Expected ${options.expect.extensions} extension(s), got ${extensionsResult.extensions.length}`,
284
+ );
285
+ }
286
+ }
287
+ if (options.expect.tools) {
288
+ for (const expectedTool of options.expect.tools) {
289
+ if (!toolNames.includes(expectedTool)) {
290
+ throw new Error(
291
+ `Expected tool "${expectedTool}" not found. Available: ${toolNames.join(", ")}`,
292
+ );
293
+ }
294
+ }
295
+ }
296
+ if (options.expect.skills !== undefined) {
297
+ if (skillsResult.skills.length !== options.expect.skills) {
298
+ throw new Error(
299
+ `Expected ${options.expect.skills} skill(s), got ${skillsResult.skills.length}`,
300
+ );
301
+ }
302
+ }
303
+ }
304
+
305
+ // 6. Optional smoke test
306
+ if (options.smoke) {
307
+ const t = await createTestSession({
308
+ extensions: extensionPaths,
309
+ cwd: sandboxDir,
310
+ mockTools: options.smoke.mockTools,
311
+ });
312
+
313
+ await t.run(...options.smoke.script);
314
+ result.smoke = { events: t.events };
315
+ t.dispose();
316
+ }
317
+
318
+ return result;
319
+ } finally {
320
+ // Clean up sandbox (retry for Windows EBUSY on open handles)
321
+ if (fs.existsSync(sandboxDir)) {
322
+ try {
323
+ fs.rmSync(sandboxDir, {
324
+ recursive: true,
325
+ force: true,
326
+ maxRetries: 3,
327
+ retryDelay: 200,
328
+ });
329
+ } catch {
330
+ // Best-effort cleanup — temp dir will be cleaned by OS
331
+ }
332
+ }
333
+ }
334
+ }
package/src/session.ts ADDED
@@ -0,0 +1,249 @@
1
+ /**
2
+ * TestSession — orchestrates a test run with playbook, mock tools, and mock UI.
3
+ *
4
+ * 1. Creates a real pi environment (extensions, tools, hooks, session)
5
+ * 2. Replaces streamFn with playbook
6
+ * 3. Intercepts tool.execute() for mockTools
7
+ * 4. Injects mock UI context
8
+ * 5. Collects events
9
+ * 6. Runs conversation script
10
+ */
11
+
12
+ import * as fs from "node:fs";
13
+ import * as path from "node:path";
14
+ import * as os from "node:os";
15
+ import {
16
+ createAgentSession,
17
+ DefaultResourceLoader,
18
+ SessionManager,
19
+ SettingsManager,
20
+ type AgentSessionEvent,
21
+ ModelRuntime,
22
+ } from "@earendil-works/pi-coding-agent";
23
+ import type { AgentTool } from "@earendil-works/pi-agent-core";
24
+ import { createPlaybookStreamFn, type PlaybookState } from "./playbook.js";
25
+ import { interceptToolExecution } from "./mock-tools.js";
26
+ import { createMockUIContext } from "./mock-ui.js";
27
+ import { createEventCollector } from "./events.js";
28
+ import { formatPlaybookDiagnostic } from "./diagnostics.js";
29
+ import { withoutJitiNativeImport } from "./pi-loader-parity.js";
30
+ import type {
31
+ TestSessionOptions,
32
+ TestSession,
33
+ Turn,
34
+ ToolCallRecord,
35
+ } from "./types.js";
36
+
37
+ export async function createTestSession(
38
+ options: TestSessionOptions = {},
39
+ ): Promise<TestSession> {
40
+ const propagateErrors = options.propagateErrors ?? true;
41
+ const ownsTmpDir = !options.cwd;
42
+ const cwd =
43
+ options.cwd ?? fs.mkdtempSync(path.join(os.tmpdir(), "pi-test-harness-"));
44
+
45
+ if (!fs.existsSync(cwd)) {
46
+ fs.mkdirSync(cwd, { recursive: true });
47
+ }
48
+
49
+ const settingsManager = SettingsManager.inMemory();
50
+ const loader = new DefaultResourceLoader({
51
+ cwd,
52
+ agentDir: cwd, // Use cwd as agent dir to avoid touching real ~/.pi
53
+ settingsManager,
54
+ additionalExtensionPaths:
55
+ options.extensions?.map((p) => path.resolve(cwd, p)) ?? [],
56
+ extensionFactories: options.extensionFactories,
57
+ systemPromptOverride: options.systemPrompt
58
+ ? () => options.systemPrompt!
59
+ : undefined,
60
+ });
61
+ // Extensions load here. Pinned to the loader configuration Pi's shipped
62
+ // runtimes use — see withoutJitiNativeImport for why that matters.
63
+ await withoutJitiNativeImport(() => loader.reload());
64
+
65
+ // Create isolated ModelRuntime with auth under cwd and no persisted model catalog
66
+ const modelRuntime = await ModelRuntime.create({
67
+ authPath: path.join(cwd, "auth.json"),
68
+ modelsPath: null,
69
+ });
70
+
71
+ // Use a builtin model as placeholder (never actually called — playbook replaces streamFn)
72
+ const playbookModel = modelRuntime.getModel("openai", "gpt-4o");
73
+ if (!playbookModel) {
74
+ throw new Error(
75
+ "Model openai/gpt-4o not found in isolated ModelRuntime. " +
76
+ "This should not happen — builtin providers are always registered. " +
77
+ "Check that @earendil-works/pi-ai is installed.",
78
+ );
79
+ }
80
+
81
+ // Provide a dummy API key so AgentSession.prompt does not reject before
82
+ // the playbook replaces streamFunction. The key is never sent to any LLM.
83
+ // Pi 0.84 synchronizes runtime credentials with an offline model refresh.
84
+ await modelRuntime.setRuntimeApiKey("openai", "sk-test-harness-dummy");
85
+
86
+ const { session, extensionsResult } = await withoutJitiNativeImport(() =>
87
+ createAgentSession({
88
+ cwd,
89
+ agentDir: cwd,
90
+ model: playbookModel,
91
+ modelRuntime,
92
+ sessionManager: SessionManager.inMemory(),
93
+ settingsManager,
94
+ resourceLoader: loader,
95
+ }),
96
+ );
97
+
98
+ if (extensionsResult.errors.length > 0) {
99
+ session.dispose();
100
+ if (ownsTmpDir && fs.existsSync(cwd)) {
101
+ fs.rmSync(cwd, { recursive: true, force: true });
102
+ }
103
+ const errors = extensionsResult.errors
104
+ .map((e) => ` ${e.path}: ${e.error}`)
105
+ .join("\n");
106
+ throw new Error(`Extension load errors:\n${errors}`);
107
+ }
108
+
109
+ const events = createEventCollector();
110
+ let currentStep = 0;
111
+ let mockedToolNames: ReadonlySet<string> = new Set();
112
+ // toolCallIds whose mock returned a ToolResult with isError:true — Pi 0.84
113
+ // hardcodes successful execute() as non-error, so records must consult this.
114
+ let mockedErrorToolCallIds: ReadonlySet<string> = new Set();
115
+
116
+ session.subscribe((event: AgentSessionEvent) => {
117
+ events.all.push(event);
118
+
119
+ if (event.type === "tool_execution_start") {
120
+ const record: ToolCallRecord = {
121
+ step: currentStep,
122
+ toolName: event.toolName,
123
+ input: (event as any).args ?? {},
124
+ blocked: false,
125
+ };
126
+ events.toolCalls.push(record);
127
+ }
128
+
129
+ if (event.type === "tool_execution_end") {
130
+ const resultText =
131
+ event.result?.content
132
+ ?.filter((c: any) => c.type === "text")
133
+ ?.map((c: any) => c.text)
134
+ ?.join("\n") ?? "";
135
+
136
+ if (event.isError) {
137
+ const lastCall = events.toolCalls[events.toolCalls.length - 1];
138
+ if (lastCall && lastCall.toolName === event.toolName) {
139
+ if (resultText.includes("blocked") || resultText.includes("Plan mode")) {
140
+ lastCall.blocked = true;
141
+ lastCall.blockReason = resultText;
142
+ }
143
+ }
144
+ }
145
+
146
+ // Record the final result (after afterToolCall modifications).
147
+ // Always push — each tool_execution_end has a unique toolCallId
148
+ // within a run, and the subscriber is the only source of results.
149
+ const isMocked = mockedToolNames.has(event.toolName);
150
+ events.toolResults.push({
151
+ step: currentStep,
152
+ toolName: event.toolName,
153
+ toolCallId: event.toolCallId,
154
+ text: resultText,
155
+ content: event.result?.content ?? [],
156
+ isError: event.isError || mockedErrorToolCallIds.has(event.toolCallId),
157
+ details: event.result?.details,
158
+ mocked: isMocked,
159
+ });
160
+ }
161
+
162
+ if (event.type === "message_end") {
163
+ events.messages.push(event.message);
164
+ }
165
+ });
166
+
167
+ let playbookState: PlaybookState | null = null;
168
+
169
+ const mockUI = createMockUIContext(options.mockUI, events.ui);
170
+
171
+ await session.bindExtensions({
172
+ uiContext: mockUI,
173
+ onError: (err) => {
174
+ console.error(
175
+ `[pi-test-harness] Extension error: ${err.event} — ${err.error}`,
176
+ );
177
+ },
178
+ });
179
+
180
+ const originalTools: AgentTool[] = [...session.agent.state.tools];
181
+
182
+ const testSession: TestSession = {
183
+ session,
184
+ cwd,
185
+ events,
186
+
187
+ get playbook() {
188
+ return {
189
+ consumed: playbookState?.consumed ?? 0,
190
+ remaining: playbookState?.remaining ?? 0,
191
+ };
192
+ },
193
+
194
+ async run(...turns: Turn[]): Promise<void> {
195
+ const { streamFn, state } = createPlaybookStreamFn(turns);
196
+ playbookState = state;
197
+
198
+ // Assign playbook streamFn to the public Agent.streamFunction
199
+ session.agent.streamFunction = streamFn;
200
+
201
+ const effectiveMockTools = options.mockTools ?? {};
202
+ const currentTools = originalTools;
203
+ const {
204
+ tools: interceptedTools,
205
+ mockedNames,
206
+ mockedErrorToolCallIds: errorIds,
207
+ } = interceptToolExecution(
208
+ currentTools,
209
+ effectiveMockTools,
210
+ state,
211
+ propagateErrors,
212
+ );
213
+ mockedToolNames = mockedNames;
214
+ mockedErrorToolCallIds = errorIds;
215
+ session.agent.state.tools = interceptedTools;
216
+
217
+ for (const turn of turns) {
218
+ currentStep = state.consumed;
219
+ await session.prompt(turn.prompt);
220
+ await session.agent.waitForIdle();
221
+ }
222
+
223
+ if (state.remaining > 0) {
224
+ const allActions = turns.flatMap((t) => t.actions);
225
+ const remaining = allActions.slice(state.consumed);
226
+ const diagnostic = formatPlaybookDiagnostic("remaining", state, remaining);
227
+ throw new Error(diagnostic);
228
+ }
229
+ },
230
+
231
+ /**
232
+ * Dispose the test session and clean up the temp directory (if owned).
233
+ *
234
+ * Note: `session.dispose()` does NOT fire `session_shutdown`. That event is
235
+ * dispatched by pi at Node.js process exit. Extensions that open resources in
236
+ * `session_start` (e.g., SQLite databases) keep those resources open until the
237
+ * process exit. Use `safeRmSync` when cleaning up extension-owned files in
238
+ * afterEach hooks on Windows to avoid EPERM errors.
239
+ */
240
+ dispose(): void {
241
+ session.dispose();
242
+ if (ownsTmpDir && fs.existsSync(cwd)) {
243
+ fs.rmSync(cwd, { recursive: true, force: true });
244
+ }
245
+ },
246
+ };
247
+
248
+ return testSession;
249
+ }