@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
@@ -0,0 +1,176 @@
1
+ # Mock tools reference
2
+
3
+ `mockTools` is the mechanism that lets your tests stay deterministic without faking Pi itself. The key insight: it intercepts `tool.execute()` for specific tool names, but everything around it — the tool registry, the hook pipeline, the event bus — runs for real.
4
+
5
+ ## The three handler forms
6
+
7
+ `mockTools` is a `Record<string, MockToolHandler>`. Each value can be one of three shapes:
8
+
9
+ ```typescript
10
+ type MockToolHandler =
11
+ | string // 1. static text
12
+ | ToolResult // 2. full result object
13
+ | ((params: Record<string, unknown>) => string | ToolResult); // 3. dynamic function
14
+ ```
15
+
16
+ ### 1. Static string
17
+
18
+ The simplest. Becomes `{ content: [{ type: "text", text: "<string>" }] }`.
19
+
20
+ ```typescript
21
+ mockTools: {
22
+ bash: "command output here",
23
+ }
24
+ ```
25
+
26
+ Use this when the tool's params don't change the response, e.g. mocking `read` to a fixed string.
27
+
28
+ ### 2. Dynamic function
29
+
30
+ Receives the params object and returns a string or a full `ToolResult`.
31
+
32
+ ```typescript
33
+ mockTools: {
34
+ read: (params) => `contents of ${params.path}`,
35
+ bash: (params) => `$ ${params.command}\nfile1.txt\nfile2.txt`,
36
+ }
37
+ ```
38
+
39
+ Use this whenever the response should reflect what was asked. This is the shape that makes your mock feel realistic enough that test assertions on `result.text` are meaningful.
40
+
41
+ ### 3. Full ToolResult
42
+
43
+ For precise control over `content`, `details`, error responses, etc.
44
+
45
+ ```typescript
46
+ mockTools: {
47
+ write: {
48
+ content: [{ type: "text", text: "Written successfully" }],
49
+ details: { bytesWritten: 42 },
50
+ },
51
+ }
52
+ ```
53
+
54
+ Use this when your extension reads from `result.details` or when you need to return non-text content blocks.
55
+
56
+ ## What stays real, what gets faked
57
+
58
+ This is the part that surprises people: **everything except `tool.execute()` keeps running.**
59
+
60
+ | Layer | Mocked? | Notes |
61
+ | --------------------------- | --------------- | ----------------------------------------------------------------- |
62
+ | Tool registration | No | Real `wrapToolsWithExtensions` pipeline |
63
+ | `tool_call` hook | No | Fires through `AgentSession.beforeToolCall` — **including for mocked tools** |
64
+ | `tool_result` hook | No | Fires through `AgentSession.afterToolCall` |
65
+ | Tool's own `execute()` body | Yes (if listed) | The mock handler runs instead |
66
+ | Event collection | No | Real event bus |
67
+
68
+ Why this matters: if your extension registers a `tool_call` hook that blocks `bash` in plan mode, the block works correctly even when `bash` is mocked. The mock handler never runs (the block happens first), and the result will show `isError: true`.
69
+
70
+ ## Extension-registered tools run for real
71
+
72
+ A common confusion: extension-registered tools **execute for real** unless they appear in `mockTools`. This is by design — it's how you test your own tool logic while keeping built-ins deterministic.
73
+
74
+ So: if your extension registers `summarize_doc`, list only the *built-in* tools (`bash`, `read`, `write`, `edit`, etc.) in `mockTools`, and let `summarize_doc` execute its real implementation.
75
+
76
+ ```typescript
77
+ const t = await createTestSession({
78
+ extensions: ["./src/index.ts"], // registers summarize_doc
79
+ mockTools: {
80
+ // Built-ins: mocked so the test stays fast and offline
81
+ bash: (p) => `mock: ${p.command}`,
82
+ read: "mock contents",
83
+ write: "mock written",
84
+ edit: "mock edited",
85
+ // summarize_doc NOT listed → executes its real code
86
+ },
87
+ });
88
+ ```
89
+
90
+ ## What to mock: a decision tree
91
+
92
+ ```
93
+ Is this tool implemented by the extension under test?
94
+ Yes
95
+ -> DO NOT mock it. You want to test its real code.
96
+ No (it's a built-in or another extension)
97
+ -> Does the test depend on the tool's *output*?
98
+ Yes
99
+ -> Mock it with a realistic value (form 1 or 2).
100
+ No (test only depends on whether it was *called*)
101
+ -> Mock it with any placeholder; assert on
102
+ t.events.toolCallsFor(name) instead.
103
+ ```
104
+
105
+ ## Error propagation: `propagateErrors`
106
+
107
+ When a **real** tool execution throws (i.e. a tool that's *not* in `mockTools`), you control how the harness handles it.
108
+
109
+ | Value | Behavior |
110
+ | ---------------- | ---------------------------------------------------------------------------------------------- |
111
+ | `true` (default) | Aborts the test immediately, with a diagnostic pointing at the exact playbook step |
112
+ | `false` | Captures the error as a tool result with `isError: true`, allowing your extension to handle it |
113
+
114
+ ```typescript
115
+ const t = await createTestSession({
116
+ propagateErrors: false, // capture errors as results instead of aborting
117
+ // ...
118
+ });
119
+ ```
120
+
121
+ ### When to use `propagateErrors: false`
122
+
123
+ Use it whenever your extension is **supposed to** recover from tool failures. Examples:
124
+
125
+ - A retry-with-backoff wrapper around `bash`.
126
+ - A fallback path when `read` returns `ENOENT`.
127
+ - An error message the assistant should paraphrase to the user.
128
+
129
+ With `propagateErrors: false`, the playbook keeps running, and you can assert on `t.events.toolResultsFor("bash")[0].isError` to confirm the error reached your extension.
130
+
131
+ ### When to leave it true (default)
132
+
133
+ Use the default when **real** tool errors mean a bug. The diagnostic the harness produces is excellent:
134
+
135
+ ```
136
+ Error during tool execution at playbook step 3 (call "bash"):
137
+ ENOENT: no such file or directory '/foo/bar'
138
+ at Object.readFileSync (node:fs:...)
139
+
140
+ This error was thrown by the real tool execution, not by the playbook.
141
+ To capture errors as tool results instead of aborting, set:
142
+ createTestSession({ propagateErrors: false })
143
+ ```
144
+
145
+ ## Blocked tools: assert the event records
146
+
147
+ When an extension's `tool_call` hook blocks a tool call, the canonical signals are the collected event records: `blocked: true` + `blockReason` on the `ToolCallRecord`, and `isError: true` + result text on the `ToolResultRecord`. Assert those — the package still exports `ToolBlockedError` for source compatibility, but a normal Pi 0.84 run through `AgentSession` does not promise to throw it.
148
+
149
+ ### Pattern A: assert via events after the fact
150
+
151
+ ```typescript
152
+ await t.run(when("Try write", [
153
+ calls("bash", { command: "rm -rf /" }),
154
+ says("Done."), // consumed regardless of block — the block becomes a result that feeds back into streamFn
155
+ ]));
156
+
157
+ const result = t.events.toolResultsFor("bash")[0];
158
+ expect(result.isError).toBe(true);
159
+
160
+ // You can also confirm the block specifically via the call record:
161
+ const call = t.events.toolCallsFor("bash")[0];
162
+ expect(call.blocked).toBe(true);
163
+ expect(call.blockReason).toMatch(/blocked by/i);
164
+ ```
165
+
166
+ Note: the playbook **continues** after a block — the block surfaces as a tool result with `isError: true`, which feeds back into the next `streamFn` call, so subsequent `calls(...)` / `says(...)` still get consumed. Hook-block paths are classified as blocks (`blocked: true`) rather than test failures.
167
+
168
+ ### Combining `propagateErrors` with blocks
169
+
170
+ Regardless of `propagateErrors`, the call record carries `blocked: true` and `blockReason: "..."` so you can assert the hook fired. With `propagateErrors: false`, blocked calls also surface as `isError: true` in the result record and the playbook continues. Do not rely on a `ToolBlockedError` throw from normal Pi 0.84 runs.
171
+
172
+ ## Common pitfalls
173
+
174
+ - **Listing your extension's own tool in `mockTools`**. If you do, your tool's `execute()` never runs and you're testing nothing. Remove it.
175
+ - **Expecting `calls(...)` to throw `ToolBlockedError` on a block**. Normal Pi 0.84 runs do not promise that throw; assert the canonical event records (`blocked`, `blockReason`, `isError`, result text) instead. If you want the playbook to keep flowing, set `propagateErrors: false` and read `.isError`.
176
+ - **Mocking a tool the extension doesn't actually call**. The mock is harmless but adds noise; trim the list to what the test exercises.
@@ -0,0 +1,170 @@
1
+ # Mock UI reference
2
+
3
+ Pi extensions interact with users via `ctx.ui.*` methods (`confirm`, `select`, `input`, `editor`, `notify`). In tests, those calls would block waiting for real input. `mockUI` controls what they "return" so you can drive extensions through their interactive code paths deterministically.
4
+
5
+ Every UI call is also recorded for assertions afterwards via `t.events.uiCallsFor(name)`.
6
+
7
+ ## The full interface
8
+
9
+ ```typescript
10
+ interface MockUIConfig {
11
+ confirm?: boolean | ((title: string, message: string) => boolean);
12
+ select?: number | string | ((title: string, items: string[]) => string | undefined);
13
+ input?: string | ((title: string, placeholder?: string) => string | undefined);
14
+ editor?: string | ((title: string, prefilled?: string) => string | undefined);
15
+ }
16
+ ```
17
+
18
+ Each field can be either a **static value** (returned for every call) or a **function** (computed per call). Static values are great for "always say yes" / "always pick the first option" defaults. Functions let you make different decisions based on the prompt context.
19
+
20
+ ## Defaults
21
+
22
+ If you don't pass `mockUI` at all, these defaults apply:
23
+
24
+ | Method | Default |
25
+ |----------|--------------------------------------|
26
+ | `confirm`| `true` (accept all) |
27
+ | `select` | `0` (first item) |
28
+ | `input` | `""` (empty string) |
29
+ | `editor` | `""` (empty string) |
30
+
31
+ In practice: if your extension calls `confirm` and you don't mock it, it returns `true` and the operation proceeds. This is convenient but can mask bugs where your extension shouldn't have called `confirm` at all.
32
+
33
+ **Tip**: in tests that *shouldn't* trigger UI calls, explicitly set empty configs and assert `t.events.uiCallsFor("confirm")` has length 0 — that catches accidental prompts.
34
+
35
+ ## The four methods in detail
36
+
37
+ ### `confirm(title, message) → boolean`
38
+
39
+ Yes/no confirmations.
40
+
41
+ **Static form** — same answer every time:
42
+
43
+ ```typescript
44
+ mockUI: {
45
+ confirm: false, // deny all confirmations
46
+ }
47
+ ```
48
+
49
+ **Dynamic form** — different answer depending on the prompt:
50
+
51
+ ```typescript
52
+ mockUI: {
53
+ confirm: (title, _message) => {
54
+ if (title.includes("Delete")) return false;
55
+ if (title.includes("Overwrite")) return false;
56
+ return true;
57
+ },
58
+ }
59
+ ```
60
+
61
+ ### `select(title, items) → string`
62
+
63
+ Picking from a list.
64
+
65
+ **Static forms**: index or the item itself.
66
+
67
+ ```typescript
68
+ mockUI: {
69
+ select: 0, // always pick first item
70
+ // or:
71
+ select: "staging", // pick the matching item (must be exact)
72
+ }
73
+ ```
74
+
75
+ **Dynamic form**:
76
+
77
+ ```typescript
78
+ mockUI: {
79
+ select: (_title, items) => items.find(i => i.includes("staging")),
80
+ }
81
+ ```
82
+
83
+ Returning `undefined` from a dynamic handler simulates "user selected nothing".
84
+
85
+ ### `input(title, placeholder?) → string`
86
+
87
+ Free-text input.
88
+
89
+ ```typescript
90
+ mockUI: {
91
+ input: "user input text", // static
92
+ // or:
93
+ input: (title, _placeholder) => title.includes("name") ? "bob" : "x",
94
+ }
95
+ ```
96
+
97
+ ### `editor(title, prefilled?) → string`
98
+
99
+ Multi-line editor (typically opens `$EDITOR` in real Pi). Returns the edited content.
100
+
101
+ ```typescript
102
+ mockUI: {
103
+ editor: "edited content", // static
104
+ // or:
105
+ editor: (_title, prefilled) => `${prefilled}\n# Appended section\n`,
106
+ }
107
+ ```
108
+
109
+ ## Pi 0.84 fire-and-forget methods
110
+
111
+ Pi 0.84's `ExtensionUIContext` adds indicator/autocomplete methods that fire and forget — they return nothing, and the mock records their arguments instead of answering:
112
+
113
+ - `setWorkingVisible(visible: boolean)` — show/hide the working indicator
114
+ - `setWorkingIndicator(options?: { frames?: string[]; intervalMs?: number })` — set the working indicator animation; `frames: []` hides the indicator entirely, `frames: ["●"]` renders a static indicator, and omitting the argument restores the default spinner
115
+ - `setHiddenThinkingLabel(label?: string)` — set the hidden-thinking label
116
+ - `addAutocompleteProvider(provider)` — records the registration call; the provider is never invoked
117
+ - `getEditorComponent()` — returns `undefined` by default; unlike mutation methods, this query is not recorded
118
+
119
+ ```typescript
120
+ await t.run(when("Do work", [calls("start_task", {}), says("Working.")]));
121
+
122
+ expect(t.events.uiCallsFor("setWorkingVisible")[0].args).toEqual([true]);
123
+ expect(t.events.uiCallsFor("setWorkingIndicator")[0].args[0]).toEqual({ frames: ["●"], intervalMs: 500 });
124
+ expect(t.events.uiCallsFor("setHiddenThinkingLabel")).toHaveLength(1);
125
+ expect(t.events.uiCallsFor("addAutocompleteProvider")).toHaveLength(1);
126
+ ```
127
+
128
+ The mock is typed as Pi 0.84's `ExtensionUIContext`, so a future Pi minor that adds a mandatory member breaks the typecheck instead of failing at runtime.
129
+
130
+ ## Asserting on UI calls
131
+
132
+ All UI interactions are recorded in `t.events`:
133
+
134
+ ```typescript
135
+ t.events.uiCallsFor("confirm") // UICallRecord[]
136
+ t.events.uiCallsFor("select")
137
+ t.events.uiCallsFor("input")
138
+ t.events.uiCallsFor("editor")
139
+ t.events.uiCallsFor("notify") // also collected, even though it's not mocked
140
+ ```
141
+
142
+ Each record contains the call's title, the other arguments, and the `returnValue` the mock produced. This is how you prove your extension asked the right question and got the right shape of response.
143
+
144
+ ### Common assertions
145
+
146
+ ```typescript
147
+ // My extension should ask for confirmation exactly once
148
+ expect(t.events.uiCallsFor("confirm")).toHaveLength(1);
149
+
150
+ // And the user answered with the expected response
151
+ expect(t.events.uiCallsFor("confirm")[0].returnValue).toBe(false);
152
+
153
+ // My extension should NOT have prompted for free-form input
154
+ expect(t.events.uiCallsFor("input")).toHaveLength(0);
155
+ ```
156
+
157
+ ## When to override defaults
158
+
159
+ | Situation | Recommendation |
160
+ |----------------------------------------------------------------------------|--------------------------------------------------|
161
+ | Test should never hit a UI prompt | Assert `uiCallsFor(X)` len 0; don't bother with `mockUI` |
162
+ | Test passes only if user denies a destructive op | `mockUI: { confirm: false }` + assert hit count |
163
+ | Extension branch depends on which option was selected | `mockUI: { select: "value" }` or dynamic handler |
164
+ | Test exercises an `input` form | `mockUI: { input: "value" }` — never accept the empty default for an actual input test |
165
+
166
+ ## Pitfalls
167
+
168
+ - **Forgetting `notify` is recorded**. `notify` calls don't need mocking (they're outbound), but they show up in `uiCallsFor("notify")` and are very useful for asserting the user was warned about something.
169
+ - **Static `select: 0` masking missing-item bugs**. If your extension's `select` didn't list the expected option, the mock silently returns whatever item is first. Assert on the *full call arguments* (`uiCallsFor("select")[0]`) if you care which options were presented.
170
+ - **`input: ""` default hiding "did the extension read user input?" bugs**. If your extension is supposed to read input but accidentally skips it, the default-empty mock won't trip. Explicit non-empty input + subsequent assertion is the safer pattern.
@@ -0,0 +1,209 @@
1
+ # Playbook DSL reference
2
+
3
+ The playbook replaces the LLM. Instead of calling a model, the Pi agent loop consumes scripted actions in order. The DSL is small — three builders and a couple of compositional tricks — but it's the heart of `pi-test-harness`, so this file is the most important reference.
4
+
5
+ ## Builders
6
+
7
+ ### `when(prompt, actions)`
8
+
9
+ Defines a single conversation turn: the prompt you'll send and what the model does in response.
10
+
11
+ ```typescript
12
+ when("Deploy the app", [
13
+ calls("bash", { command: "npm run build" }),
14
+ calls("bash", { command: "gcloud run deploy" }),
15
+ says("Deployed successfully."),
16
+ ])
17
+ ```
18
+
19
+ - `prompt` is the user-side message that begins the turn.
20
+ - `actions` is an ordered list of `calls(...)` and `says(...)`.
21
+ - The harness serves actions to the agent loop one per `streamFn` call, in array order.
22
+ - A turn ends when `says(...)` is consumed — `says` produces a final assistant message and signals turn end.
23
+
24
+ ### `calls(tool, params)`
25
+
26
+ The model calls a tool. Pi's hooks fire normally, the tool executes (real or mocked per `mockTools`), and the result feeds back into the next `streamFn` call.
27
+
28
+ ```typescript
29
+ calls("plan_mode", { enable: true })
30
+ calls("bash", { command: "ls -la" })
31
+ ```
32
+
33
+ `params` may be either an object literal or a function `() => params` for late binding (see "Late-bound params" below).
34
+
35
+ Each `calls(...)` returns a builder you can chain `.then()` onto.
36
+
37
+ ### `says(text)`
38
+
39
+ The model emits text. The agent turn ends.
40
+
41
+ ```typescript
42
+ says("All done. The deployment is complete.")
43
+ ```
44
+
45
+ `says` must be the last action in a turn — it produces the terminating assistant message.
46
+
47
+ ## Multi-turn conversations
48
+
49
+ Pass multiple turns to `run()`:
50
+
51
+ ```typescript
52
+ await t.run(
53
+ when("What files are in the project?", [
54
+ calls("bash", { command: "ls" }),
55
+ says("Found 3 files."),
56
+ ]),
57
+ when("Now read the README", [
58
+ calls("read", { path: "README.md" }),
59
+ says("Here's what it says..."),
60
+ ]),
61
+ );
62
+ ```
63
+
64
+ Each new `when(...)` is treated as a fresh user message; the model's prior text and tool calls remain in the session state for the next turn.
65
+
66
+ ## Late-bound params and `.then()`
67
+
68
+ When one tool call produces a value that the next call needs, use `.then()` to capture the result, and pass a `() => params` function so the params are resolved at call time (after `.then()` has fired).
69
+
70
+ The canonical pattern: a tool returns a generated ID in its text output, and the next tool needs that ID:
71
+
72
+ ```typescript
73
+ let planId = "";
74
+
75
+ await t.run(
76
+ when("Create and approve a plan", [
77
+ calls("plan_propose", {
78
+ title: "Send invoice",
79
+ steps: [
80
+ { description: "Send email", tool: "go-easy", operation: "send" },
81
+ ],
82
+ }).then((result) => {
83
+ // Extract the plan ID from the tool result text
84
+ planId = result.text.match(/PLAN-[a-f0-9]+/)![0];
85
+ }),
86
+ // Late-bound: params resolved at call time, after .then() has fired
87
+ calls("plan_approve", () => ({ id: planId })),
88
+ says("Plan approved and executing."),
89
+ ]),
90
+ );
91
+
92
+ expect(planId).toMatch(/^PLAN-/);
93
+ ```
94
+
95
+ How it works:
96
+
97
+ 1. `plan_propose` runs, returns a result whose `.text` contains something like `Plan PLAN-a3f9c2 created`.
98
+ 2. `.then()` fires with the `ToolResultRecord`. Save the value to a test variable.
99
+ 3. The next `calls(...)` is given a function. The harness invokes it at call time — so `planId` is now populated, and the params object is what actually gets passed.
100
+
101
+ This is the supported way to chain dependent calls. Without `.then()` + a function-returning params, you'd be stuck with whatever you pre-declared.
102
+
103
+ ## Real-world example: testing pi-planner
104
+
105
+ A non-trivial extension that registers 8 tools, blocks writes in plan mode, and manages plan lifecycle:
106
+
107
+ ```typescript
108
+ import {
109
+ createTestSession, when, calls, says, type TestSession,
110
+ } from "@abdwhb-png/pi-test-harness";
111
+ import * as path from "node:path";
112
+
113
+ const EXTENSION = path.resolve(__dirname, "../../src/index.ts");
114
+ const MOCKS = {
115
+ bash: (p: Record<string, unknown>) => `mock: ${p.command}`,
116
+ read: "mock contents",
117
+ write: "mock written",
118
+ edit: "mock edited",
119
+ };
120
+
121
+ describe("pi-planner", () => {
122
+ let t: TestSession;
123
+ afterEach(() => t?.dispose());
124
+
125
+ it("enters plan mode and proposes a plan", async () => {
126
+ t = await createTestSession({
127
+ extensions: [EXTENSION],
128
+ mockTools: MOCKS,
129
+ });
130
+
131
+ let planId = "";
132
+
133
+ await t.run(
134
+ when("Plan the deployment", [
135
+ calls("plan_mode", { enable: true }),
136
+ calls("plan_propose", {
137
+ title: "Deploy v2",
138
+ steps: [
139
+ { description: "Build", tool: "bash", operation: "build" },
140
+ { description: "Deploy", tool: "gcloud", operation: "deploy" },
141
+ ],
142
+ }).then((r) => {
143
+ planId = r.text.match(/PLAN-[a-f0-9]+/)![0];
144
+ }),
145
+ says("Plan proposed."),
146
+ ]),
147
+ );
148
+
149
+ expect(planId).toMatch(/^PLAN-/);
150
+ expect(t.events.toolResultsFor("plan_mode")[0].text).toContain("enabled");
151
+ expect(t.events.uiCallsFor("notify")).toHaveLength(1);
152
+ });
153
+ });
154
+ ```
155
+
156
+ ## Diagnostics: when the playbook doesn't match reality
157
+
158
+ The harness auto-asserts that all playbook actions are consumed after `run()` completes. If script and reality disagree, you get one of two diagnostic shapes.
159
+
160
+ ### 1. Playbook exhausted unexpectedly
161
+
162
+ ```
163
+ Playbook exhausted unexpectedly.
164
+ Consumed 2 action(s).
165
+ Last consumed: calls("bash", {"command":"ls"}) at step 2
166
+
167
+ The agent loop called streamFn but no more playbook actions were available.
168
+ This usually means a tool call produced an unexpected result that caused
169
+ additional streamFn calls (retries, error handling).
170
+ ```
171
+
172
+ **What it means**: the agent loop called `streamFn` more times than your script expected. The most common causes:
173
+
174
+ - A tool call produced an error, triggering automatic retry inside Pi.
175
+ - A hook blocked a tool and Pi retried with a different action.
176
+ - An extension spawned an extra `streamFn` call you didn't anticipate.
177
+
178
+ **Fix**: read the diagnostic's "Last consumed" line to find where reality diverged. Either add the missing actions to your script, or change `mockTools` so the tool doesn't error / the hook doesn't fire.
179
+
180
+ > **Do not confuse** this exhaustion diagnostic with the `propagateErrors: true` error diagnostic, which looks like:
181
+ > ```
182
+ > Error during tool execution at playbook step 3 (call "bash"):
183
+ > ENOENT: no such file or directory '/foo/bar'
184
+ > ```
185
+ > That one fires when a **real** (non-mocked) tool throws and `propagateErrors` is on. The exhaustion diagnostic above fires when the **script** and the agent loop disagree on how many actions exist. Different cause, different fix.
186
+
187
+ ### 2. Playbook not fully consumed
188
+
189
+ ```
190
+ Playbook not fully consumed after run() completed.
191
+ Consumed 1 of 3 action(s).
192
+ Remaining:
193
+ - calls("write", {"path":"out.txt","content":"hello"})
194
+ - says("Done writing.")
195
+
196
+ The agent loop ended before all playbook actions were used.
197
+ This usually means a tool was blocked by a hook or returned early,
198
+ causing fewer streamFn calls than expected.
199
+ ```
200
+
201
+ **What it means**: a tool was blocked by an extension hook, or returned early, so Pi ended the turn before consuming everything you queued.
202
+
203
+ **Fix**: if the block is *expected*, restructure your script so the unconsumed actions reflect what reality did (or wrap the call in a try/catch on `ToolBlockedError`). If the block is *unexpected*, the test just surfaced a real bug — investigate the hook.
204
+
205
+ ## Parallel tool calls (known intentional gap)
206
+
207
+ Today the playbook emits one tool call per assistant message, which is deterministic and good for most extension tests. True Pi-side parallelism (multiple `toolCall` blocks in one assistant message) is not yet modeled. To test parallel tool execution you'd need a grouped/batched call action — flagged in the package's Testing Scope notes as a future addition, with results asserted by `toolCallId` rather than completion order.
208
+
209
+ For now: if your extension's correctness depends on *parallel* rather than *sequential* tool execution, write a focused test that calls the extension's handler directly rather than driving it through the playbook.
@@ -0,0 +1,113 @@
1
+ # Sandbox install verification reference
2
+
3
+ `verifySandboxInstall` is the layer for **pre-publish** validation. It does a full real install of your package into a temp directory (no mocking) and confirms the package actually loads, registers its extensions/tools/skills, and (optionally) executes its tools end-to-end inside that real install.
4
+
5
+ This catches a class of bugs that `createTestSession` cannot: broken `package.json` `exports` maps, missing `peerDependencies`, wrong `main`/`types`/`module` fields, files missing from `"files"`, ESM/CJS dual-publish issues. All of these can pass local tests while breaking the moment someone runs `npm install your-package`.
6
+
7
+ ## When to use it
8
+
9
+ | Test layer | What it catches |
10
+ | -------------------------- | ---------------------------------------------------------- |
11
+ | `createTestSession` | Extension logic bugs (hook firing, tool behavior, events) |
12
+ | **`verifySandboxInstall`** | **Publishable-package bugs (broken install, bad exports)** |
13
+ | `createMockPi` | Subprocess-spawning extension bugs |
14
+
15
+ Run `verifySandboxInstall` as a CI step before every publish, or locally as `npm run prepack` / `npm publish --dry-run`. At least [`marcfargas/pi-mf-extensions`](https://github.com/marcfargas/pi-mf-extensions) (pi-planner) uses this layer to test the plan lifecycle; check its CI for a concrete release-gate example.
16
+
17
+ ## Basic usage
18
+
19
+ ```typescript
20
+ import { verifySandboxInstall } from "@abdwhb-png/pi-test-harness";
21
+
22
+ const result = await verifySandboxInstall({
23
+ packageDir: "./packages/my-extension",
24
+ expect: {
25
+ extensions: 1,
26
+ tools: ["my_tool", "my_other_tool"],
27
+ skills: 0,
28
+ },
29
+ });
30
+
31
+ expect(result.loaded.extensionErrors).toEqual([]);
32
+ expect(result.loaded.tools).toContain("my_tool");
33
+ ```
34
+
35
+ What it does, step by step:
36
+
37
+ 1. Reads `package.json` from `packageDir`.
38
+ 2. Runs `npm pack` to produce a tarball exactly as `npm publish` would.
39
+ 3. Creates a temp directory.
40
+ 4. Runs `npm install <tarball>` inside the temp dir — a real install with real peer-dep resolution.
41
+ 5. Dynamically imports the installed package.
42
+ 6. Hands you the loaded state so you can assert extensions/tools/skills were registered.
43
+
44
+ ## Options
45
+
46
+ | Option | Type | Purpose |
47
+ | ------------------- | --------------------------------- | ------------------------------------------------- |
48
+ | `packageDir` | `string` | Path to the package (must contain `package.json`) |
49
+ | `expect.extensions` | `number` | How many extensions should have loaded |
50
+ | `expect.tools` | `string[]` | Required tool names |
51
+ | `expect.skills` | `number` | How many skills should have loaded |
52
+ | `smoke` | `{ mockTools, script }` | Optional in-sandbox playbook smoke test |
53
+ | `smoke.mockTools` | `Record<string, MockToolHandler>` | Same as `createTestSession`'s `mockTools` |
54
+ | `smoke.script` | `Turn[]` | Playbook turns (built with `when / calls / says`) |
55
+ | `npmCommand` | `string[]` | npm argv for `pack`/`install` (default `["npm"]`; use `["sfw", "npm"]` to route through Socket Firewall) |
56
+
57
+ `npmCommand` lets a wrapper intercept the real release-gate commands: `verifySandboxInstall({ packageDir, npmCommand: ["sfw", "npm"] })` runs `sfw npm pack` / `sfw npm install`, keeping supply-chain checks on the actual `npm pack`/`npm install` calls.
58
+
59
+ ## Smoke test inside the sandbox
60
+
61
+ Once the package is installed and loaded, you can drive it through a real playbook against the in-sandbox instance. This is the strongest possible signal short of a beta release: not only does the package install and load, its tools actually execute when called.
62
+
63
+ ```typescript
64
+ import {
65
+ verifySandboxInstall,
66
+ when, calls, says,
67
+ } from "@abdwhb-png/pi-test-harness";
68
+
69
+ const result = await verifySandboxInstall({
70
+ packageDir: "./packages/my-extension",
71
+ expect: { extensions: 1 },
72
+ smoke: {
73
+ mockTools: {
74
+ bash: "ok",
75
+ read: "contents",
76
+ write: "written",
77
+ edit: "edited",
78
+ },
79
+ script: [
80
+ when("Test", [
81
+ calls("my_tool", { value: "test" }),
82
+ says("Works."),
83
+ ]),
84
+ ],
85
+ },
86
+ });
87
+
88
+ expect(result.loaded.extensionErrors).toEqual([]);
89
+ expect(result.smoke.events.toolResultsFor("my_tool")).toHaveLength(1);
90
+ ```
91
+
92
+ `result.smoke.events` exposes the same `TestEvents` API as `createTestSession`, so you can assert against events exactly as you would in an in-process test.
93
+
94
+ ## How this differs from `createTestSession`
95
+
96
+ It's worth being explicit because the layers overlap conceptually but solve different problems:
97
+
98
+ | Diagnostic question | Use |
99
+ | -------------------------------------------------- | -------------------------- |
100
+ | Does my extension's hook fire and block correctly? | `createTestSession` |
101
+ | Does the tool's logic do the right thing? | `createTestSession` |
102
+ | **Does the package I'm about to publish install?** | **`verifySandboxInstall`** |
103
+ | **Are the right files included in the tarball?** | **`verifySandboxInstall`** |
104
+ | **Do peer deps resolve in a clean install?** | **`verifySandboxInstall`** |
105
+
106
+ In practice, the natural split is: use `createTestSession` for the bulk of behavioral tests (it's fast, in-process, and exercises the extension under test directly), and reserve `verifySandboxInstall` for a single release-gate CI test (it's slower because it runs a real `npm pack` + `npm install`). The release gate catches things like forgetting to add a new tool's source file to the package's `"files"` list (which would make local `createTestSession` pass but a published package throw).
107
+
108
+ ## Common pitfalls
109
+
110
+ - **Expecting `verifySandboxInstall` to mock anything by default.** It doesn't — only the optional `smoke` step uses mocks. The install + load itself is fully real.
111
+ - **Putting `packageDir` as the package name instead of a path.** It's a directory path, not an npm spec.
112
+ - **Expecting `tools` to be exhaustive.** If `expect.tools` lists `["my_tool", "my_other_tool"]` and your package also registers `my_third_tool`, the test still passes — but `my_third_tool` not being listed usually means you forgot to update the spec when you added a tool. Assert **all** tools.
113
+ - **CI cost.** A real `npm pack` + `npm install` per test file is slow. Most projects run `verifySandboxInstall` exactly once, in a release-gate test, not in every test file.