@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.
- package/CHANGELOG.md +161 -0
- package/LICENSE +21 -0
- package/README.md +673 -0
- package/dist/diagnostics.d.ts +11 -0
- package/dist/diagnostics.d.ts.map +1 -0
- package/dist/diagnostics.js +61 -0
- package/dist/diagnostics.js.map +1 -0
- package/dist/events.d.ts +6 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +33 -0
- package/dist/events.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/dist/mock-pi-script.mjs +176 -0
- package/dist/mock-pi.d.ts +32 -0
- package/dist/mock-pi.d.ts.map +1 -0
- package/dist/mock-pi.js +150 -0
- package/dist/mock-pi.js.map +1 -0
- package/dist/mock-tools.d.ts +51 -0
- package/dist/mock-tools.d.ts.map +1 -0
- package/dist/mock-tools.js +192 -0
- package/dist/mock-tools.js.map +1 -0
- package/dist/mock-ui.d.ts +13 -0
- package/dist/mock-ui.d.ts.map +1 -0
- package/dist/mock-ui.js +159 -0
- package/dist/mock-ui.js.map +1 -0
- package/dist/pi-loader-parity.d.ts +36 -0
- package/dist/pi-loader-parity.d.ts.map +1 -0
- package/dist/pi-loader-parity.js +60 -0
- package/dist/pi-loader-parity.js.map +1 -0
- package/dist/playbook.d.ts +44 -0
- package/dist/playbook.d.ts.map +1 -0
- package/dist/playbook.js +143 -0
- package/dist/playbook.js.map +1 -0
- package/dist/sandbox.d.ts +27 -0
- package/dist/sandbox.d.ts.map +1 -0
- package/dist/sandbox.js +269 -0
- package/dist/sandbox.js.map +1 -0
- package/dist/session.d.ts +13 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +187 -0
- package/dist/session.js.map +1 -0
- package/dist/types.d.ts +171 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +5 -0
- package/dist/types.js.map +1 -0
- package/dist/utils.d.ts +32 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +46 -0
- package/dist/utils.js.map +1 -0
- package/package.json +84 -0
- package/skills/pi-test-harness/SKILL.md +451 -0
- package/skills/pi-test-harness/evals/evals.json +26 -0
- package/skills/pi-test-harness/references/api-reference.md +480 -0
- package/skills/pi-test-harness/references/mock-pi-cli.md +135 -0
- package/skills/pi-test-harness/references/mock-tools.md +176 -0
- package/skills/pi-test-harness/references/mock-ui.md +170 -0
- package/skills/pi-test-harness/references/playbook-dsl.md +209 -0
- package/skills/pi-test-harness/references/sandbox-install.md +113 -0
- package/src/diagnostics.ts +90 -0
- package/src/events.ts +43 -0
- package/src/index.ts +42 -0
- package/src/mock-pi-script.mjs +176 -0
- package/src/mock-pi.ts +169 -0
- package/src/mock-tools.ts +252 -0
- package/src/mock-ui.ts +196 -0
- package/src/pi-loader-parity.ts +61 -0
- package/src/playbook.ts +189 -0
- package/src/sandbox.ts +334 -0
- package/src/session.ts +249 -0
- package/src/types.ts +203 -0
- 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.
|