@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,90 @@
1
+ /**
2
+ * Playbook diagnostic messages — clear errors when things diverge.
3
+ */
4
+
5
+ import type { PlaybookAction } from "./types.js";
6
+
7
+ function formatAction(action: PlaybookAction): string {
8
+ if (action.type === "say") {
9
+ return `says("${action.text?.slice(0, 60)}${(action.text?.length ?? 0) > 60 ? "..." : ""}")`;
10
+ }
11
+ if (action.type === "call") {
12
+ const params = typeof action.params === "function" ? "<late-bound>" : JSON.stringify(action.params);
13
+ const truncated = params.length > 80 ? params.slice(0, 80) + "..." : params;
14
+ return `calls("${action.toolName}", ${truncated})`;
15
+ }
16
+ return `unknown(${action.type})`;
17
+ }
18
+
19
+ export function formatPlaybookDiagnostic(
20
+ type: "exhausted" | "remaining",
21
+ state: { consumed: number; remaining: number; consumedActions: PlaybookAction[] },
22
+ remainingActions?: PlaybookAction[],
23
+ ): string {
24
+ if (type === "exhausted") {
25
+ const last = state.consumedActions[state.consumedActions.length - 1];
26
+ const lines = [
27
+ `Playbook exhausted unexpectedly.`,
28
+ ` Consumed ${state.consumed} action(s).`,
29
+ ];
30
+ if (last) {
31
+ lines.push(` Last consumed: ${formatAction(last)} at step ${state.consumed}`);
32
+ }
33
+ lines.push(
34
+ "",
35
+ " The agent loop called streamFn but no more playbook actions were available.",
36
+ " This usually means a tool call produced an unexpected result that caused",
37
+ " additional streamFn calls (retries, error handling).",
38
+ );
39
+ return lines.join("\n");
40
+ }
41
+
42
+ if (type === "remaining" && remainingActions) {
43
+ const lines = [
44
+ `Playbook not fully consumed after run() completed.`,
45
+ ` Consumed ${state.consumed} of ${state.consumed + remainingActions.length} action(s).`,
46
+ ` Remaining:`,
47
+ ];
48
+ for (const action of remainingActions.slice(0, 5)) {
49
+ lines.push(` - ${formatAction(action)}`);
50
+ }
51
+ if (remainingActions.length > 5) {
52
+ lines.push(` ... +${remainingActions.length - 5} more`);
53
+ }
54
+ lines.push(
55
+ "",
56
+ " The agent loop ended before all playbook actions were used.",
57
+ " This usually means a tool was blocked by a hook or returned early,",
58
+ " causing fewer streamFn calls than expected.",
59
+ );
60
+ return lines.join("\n");
61
+ }
62
+
63
+ return "Unknown playbook diagnostic.";
64
+ }
65
+
66
+ export function formatToolError(
67
+ step: number,
68
+ toolName: string,
69
+ error: unknown,
70
+ ): string {
71
+ const message = error instanceof Error ? error.message : String(error);
72
+ const stack = error instanceof Error ? error.stack : undefined;
73
+ const lines = [
74
+ `Error during tool execution at playbook step ${step} (call "${toolName}"):`,
75
+ ` ${message}`,
76
+ ];
77
+ if (stack) {
78
+ const stackLines = stack.split("\n").slice(1, 4);
79
+ for (const line of stackLines) {
80
+ lines.push(` ${line.trim()}`);
81
+ }
82
+ }
83
+ lines.push(
84
+ "",
85
+ "This error was thrown by the real tool execution, not by the playbook.",
86
+ "To capture errors as tool results instead of aborting, set:",
87
+ " createTestSession({ propagateErrors: false })",
88
+ );
89
+ return lines.join("\n");
90
+ }
package/src/events.ts ADDED
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Event collection — passively collects all events during a test run.
3
+ */
4
+
5
+ import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
6
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
7
+ import type { TestEvents, ToolCallRecord, ToolResultRecord, UICallRecord } from "./types.js";
8
+
9
+ export function createEventCollector(): TestEvents {
10
+ const all: AgentSessionEvent[] = [];
11
+ const toolCalls: ToolCallRecord[] = [];
12
+ const toolResults: ToolResultRecord[] = [];
13
+ const messages: AgentMessage[] = [];
14
+ const ui: UICallRecord[] = [];
15
+
16
+ return {
17
+ all,
18
+ toolCalls,
19
+ toolResults,
20
+ messages,
21
+ ui,
22
+
23
+ toolCallsFor(name: string): ToolCallRecord[] {
24
+ return toolCalls.filter((tc) => tc.toolName === name);
25
+ },
26
+
27
+ toolResultsFor(name: string): ToolResultRecord[] {
28
+ return toolResults.filter((tr) => tr.toolName === name);
29
+ },
30
+
31
+ blockedCalls(): ToolCallRecord[] {
32
+ return toolCalls.filter((tc) => tc.blocked);
33
+ },
34
+
35
+ uiCallsFor(method: string): UICallRecord[] {
36
+ return ui.filter((u) => u.method === method);
37
+ },
38
+
39
+ toolSequence(): string[] {
40
+ return toolCalls.map((tc) => tc.toolName);
41
+ },
42
+ };
43
+ }
package/src/index.ts ADDED
@@ -0,0 +1,42 @@
1
+ /**
2
+ * @abdwhb-png/pi-test-harness
3
+ *
4
+ * Test harness for pi extensions — playbook-based model mocking,
5
+ * session testing, sandbox install verification.
6
+ */
7
+
8
+ // DSL builders
9
+ export { when, calls, says } from "./playbook.js";
10
+
11
+ // Session
12
+ export { createTestSession } from "./session.js";
13
+
14
+ // Sandbox
15
+ export { verifySandboxInstall } from "./sandbox.js";
16
+
17
+ // Mock Pi
18
+ export { createMockPi } from "./mock-pi.js";
19
+
20
+ // Types
21
+ export type {
22
+ TestSession,
23
+ TestSessionOptions,
24
+ TestEvents,
25
+ ToolCallRecord,
26
+ ToolResultRecord,
27
+ UICallRecord,
28
+ MockToolHandler,
29
+ MockUIConfig,
30
+ SandboxOptions,
31
+ SandboxResult,
32
+ MockPi,
33
+ MockPiCall,
34
+ Turn,
35
+ PlaybookAction,
36
+ } from "./types.js";
37
+
38
+ // Errors
39
+ export { ToolBlockedError } from "./mock-tools.js";
40
+
41
+ // Utilities
42
+ export { safeRmSync } from "./utils.js";
@@ -0,0 +1,176 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Mock pi CLI for integration tests.
4
+ *
5
+ * Reads response queue from MOCK_PI_QUEUE_DIR (set by the shim created by createMockPi).
6
+ * Each invocation consumes the next entry from the queue. When the queue is exhausted,
7
+ * the last entry repeats. If no entries are queued, outputs a default echo response.
8
+ *
9
+ * Queue protocol:
10
+ * {queueDir}/queue.json — JSON array of MockPiCall objects
11
+ * {queueDir}/counter — integer: current call index (auto-incremented)
12
+ */
13
+
14
+ import * as fs from "node:fs";
15
+ import * as path from "node:path";
16
+
17
+ // Safety timeout — prevent hanging tests if something goes wrong
18
+ const TIMEOUT_MS = 30_000;
19
+ setTimeout(() => {
20
+ process.stderr.write("mock-pi-script: timeout after 30s\n");
21
+ process.exit(124);
22
+ }, TIMEOUT_MS).unref();
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Queue directory (set by the shim script)
26
+ // ---------------------------------------------------------------------------
27
+ const queueDir = process.env.MOCK_PI_QUEUE_DIR;
28
+ if (!queueDir) {
29
+ process.stderr.write("mock-pi-script: MOCK_PI_QUEUE_DIR not set\n");
30
+ process.exit(99);
31
+ }
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Parse CLI arguments (matches what pi-subagents passes to pi)
35
+ // ---------------------------------------------------------------------------
36
+ const args = process.argv.slice(2);
37
+ let task = "";
38
+ let sessionDir = null;
39
+
40
+ let i = 0;
41
+ while (i < args.length) {
42
+ const arg = args[i];
43
+
44
+ // Flags with a value — skip the value
45
+ if (
46
+ arg === "--session-dir" ||
47
+ arg === "--mode" ||
48
+ arg === "--models" ||
49
+ arg === "--tools" ||
50
+ arg === "--extension" ||
51
+ arg === "--append-system-prompt"
52
+ ) {
53
+ if (arg === "--session-dir") sessionDir = args[i + 1] ?? null;
54
+ i += 2;
55
+ continue;
56
+ }
57
+
58
+ // Flags without a value
59
+ if (arg === "-p" || arg === "--no-session" || arg === "--no-extensions") {
60
+ i++;
61
+ continue;
62
+ }
63
+
64
+ // @file — read task from file
65
+ if (arg?.startsWith("@")) {
66
+ try {
67
+ task = fs.readFileSync(arg.slice(1), "utf-8");
68
+ } catch {
69
+ task = "(could not read " + arg.slice(1) + ")";
70
+ }
71
+ i++;
72
+ continue;
73
+ }
74
+
75
+ // Positional — treat as task text
76
+ if (arg && !arg.startsWith("-")) {
77
+ task = arg;
78
+ }
79
+ i++;
80
+ }
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // Read queue and counter
84
+ // ---------------------------------------------------------------------------
85
+ let queue = [];
86
+ const queueFile = path.join(queueDir, "queue.json");
87
+ if (fs.existsSync(queueFile)) {
88
+ try {
89
+ queue = JSON.parse(fs.readFileSync(queueFile, "utf-8"));
90
+ } catch {
91
+ // Malformed queue — treat as empty
92
+ }
93
+ }
94
+
95
+ const counterFile = path.join(queueDir, "counter");
96
+ let counter = 0;
97
+ if (fs.existsSync(counterFile)) {
98
+ try {
99
+ counter = parseInt(fs.readFileSync(counterFile, "utf-8").trim(), 10) || 0;
100
+ } catch {
101
+ // Missing or unreadable — start at 0
102
+ }
103
+ }
104
+
105
+ // Increment counter for next invocation
106
+ fs.writeFileSync(counterFile, String(counter + 1));
107
+
108
+ // Get the current entry (repeat last when exhausted, null if no queue)
109
+ const entry =
110
+ queue.length > 0 ? queue[Math.min(counter, queue.length - 1)] : null;
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // Delay
114
+ // ---------------------------------------------------------------------------
115
+ if (entry?.delay > 0) {
116
+ await new Promise((r) => setTimeout(r, entry.delay));
117
+ }
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // Stderr
121
+ // ---------------------------------------------------------------------------
122
+ if (entry?.stderr) {
123
+ process.stderr.write(entry.stderr + "\n");
124
+ }
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // Write files (for chain_dir output simulation)
128
+ // ---------------------------------------------------------------------------
129
+ if (entry?.writeFiles) {
130
+ for (const [filePath, content] of Object.entries(entry.writeFiles)) {
131
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
132
+ fs.writeFileSync(filePath, content);
133
+ }
134
+ }
135
+
136
+ // ---------------------------------------------------------------------------
137
+ // JSONL output
138
+ // ---------------------------------------------------------------------------
139
+ if (entry?.jsonl) {
140
+ for (const event of entry.jsonl) {
141
+ console.log(typeof event === "string" ? event : JSON.stringify(event));
142
+ }
143
+ } else {
144
+ const taskClean = task.replace(/^Task:\s*/i, "").slice(0, 500);
145
+ const output = entry?.output ?? "Mock output for: " + taskClean;
146
+ console.log(
147
+ JSON.stringify({
148
+ type: "message_end",
149
+ message: {
150
+ role: "assistant",
151
+ content: [{ type: "text", text: output }],
152
+ model: "mock/test-model",
153
+ usage: {
154
+ input: 100,
155
+ output: 50,
156
+ cacheRead: 0,
157
+ cacheWrite: 0,
158
+ cost: { total: 0.001 },
159
+ },
160
+ },
161
+ }),
162
+ );
163
+ }
164
+
165
+ // ---------------------------------------------------------------------------
166
+ // Session file (if requested)
167
+ // ---------------------------------------------------------------------------
168
+ if (sessionDir) {
169
+ fs.mkdirSync(sessionDir, { recursive: true });
170
+ fs.writeFileSync(
171
+ path.join(sessionDir, "session-" + Date.now() + ".jsonl"),
172
+ JSON.stringify({ type: "session_start" }) + "\n",
173
+ );
174
+ }
175
+
176
+ process.exit(entry?.exitCode ?? 0);
package/src/mock-pi.ts ADDED
@@ -0,0 +1,169 @@
1
+ /**
2
+ * createMockPi — mock pi CLI for testing extensions that spawn pi as a subprocess.
3
+ *
4
+ * Creates a temp directory with a `pi` shim (`.cmd` on Windows, shell script on Linux)
5
+ * that prepends to PATH. The shim invokes a mock-pi-script.mjs that reads queued
6
+ * responses from a file-based queue. Responses are consumed in order; the last one
7
+ * repeats when the queue is exhausted.
8
+ *
9
+ * ```ts
10
+ * const mockPi = createMockPi();
11
+ * mockPi.install();
12
+ *
13
+ * mockPi.onCall({ output: "Hello from agent" });
14
+ * mockPi.onCall({ stderr: "crashed", exitCode: 1 });
15
+ *
16
+ * // ... test code that spawns pi ...
17
+ *
18
+ * mockPi.reset();
19
+ * mockPi.uninstall();
20
+ * ```
21
+ */
22
+
23
+ import * as fs from "node:fs";
24
+ import * as os from "node:os";
25
+ import * as path from "node:path";
26
+ import { fileURLToPath } from "node:url";
27
+ import type { MockPi, MockPiCall } from "./types.js";
28
+
29
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
30
+
31
+ /** Valid keys for MockPiCall — used for runtime validation. */
32
+ const VALID_MOCK_PI_CALL_KEYS = new Set([
33
+ "output",
34
+ "exitCode",
35
+ "stderr",
36
+ "delay",
37
+ "jsonl",
38
+ "writeFiles",
39
+ ]);
40
+
41
+ /**
42
+ * Resolve the mock-pi-script.mjs path.
43
+ *
44
+ * During dev (vitest): src/mock-pi.ts → sibling src/mock-pi-script.mjs
45
+ * After build (dist): dist/mock-pi.js → ../src/mock-pi-script.mjs
46
+ */
47
+ function findMockPiScript(): string {
48
+ // Direct sibling (running from src/ via vitest)
49
+ const sibling = path.join(__dirname, "mock-pi-script.mjs");
50
+ if (fs.existsSync(sibling)) return sibling;
51
+
52
+ // One level up to package root, then into src/ (running from dist/)
53
+ const fromDist = path.join(__dirname, "..", "src", "mock-pi-script.mjs");
54
+ if (fs.existsSync(fromDist)) return fromDist;
55
+
56
+ throw new Error(
57
+ "Could not find mock-pi-script.mjs. Searched:\n" +
58
+ ` ${sibling}\n` +
59
+ ` ${fromDist}`,
60
+ );
61
+ }
62
+
63
+ /**
64
+ * Create a mock pi CLI for testing extensions that spawn pi as a subprocess.
65
+ *
66
+ * **Concurrency constraint**: Designed for serial subprocess spawns within a single test.
67
+ * If your test spawns multiple pi processes concurrently, responses may be consumed
68
+ * out of order. Use separate `createMockPi()` instances for concurrent scenarios,
69
+ * or ensure your test logic doesn't depend on response ordering.
70
+ */
71
+ export function createMockPi(): MockPi {
72
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-mock-"));
73
+ let originalPath: string | undefined;
74
+ let installed = false;
75
+
76
+ const queueFile = path.join(dir, "queue.json");
77
+ const counterFile = path.join(dir, "counter");
78
+
79
+ // Initialize empty queue
80
+ fs.writeFileSync(queueFile, "[]");
81
+ fs.writeFileSync(counterFile, "0");
82
+
83
+ const scriptPath = findMockPiScript();
84
+ const nodeExe = process.execPath;
85
+
86
+ // Safety net: restore PATH if process exits without uninstall()
87
+ const exitHandler = () => {
88
+ if (originalPath !== undefined) {
89
+ process.env.PATH = originalPath;
90
+ }
91
+ };
92
+
93
+ return {
94
+ dir,
95
+
96
+ install() {
97
+ if (installed) return;
98
+ originalPath = process.env.PATH;
99
+
100
+ if (process.platform === "win32") {
101
+ // Windows: .cmd batch file shim
102
+ const cmd = [
103
+ "@echo off",
104
+ `set "MOCK_PI_QUEUE_DIR=${dir}"`,
105
+ `"${nodeExe}" "${scriptPath}" %*`,
106
+ ].join("\r\n") + "\r\n";
107
+ fs.writeFileSync(path.join(dir, "pi.cmd"), cmd);
108
+ } else {
109
+ // Linux/macOS: shell script shim
110
+ const sh = [
111
+ "#!/bin/sh",
112
+ `MOCK_PI_QUEUE_DIR="${dir}" exec "${nodeExe}" "${scriptPath}" "$@"`,
113
+ ].join("\n") + "\n";
114
+ const piPath = path.join(dir, "pi");
115
+ fs.writeFileSync(piPath, sh);
116
+ fs.chmodSync(piPath, 0o755);
117
+ }
118
+
119
+ process.env.PATH = `${dir}${path.delimiter}${originalPath}`;
120
+ process.on("exit", exitHandler);
121
+ installed = true;
122
+ },
123
+
124
+ uninstall() {
125
+ if (!installed) return;
126
+ process.removeListener("exit", exitHandler);
127
+ if (originalPath !== undefined) {
128
+ process.env.PATH = originalPath;
129
+ originalPath = undefined;
130
+ }
131
+ try {
132
+ fs.rmSync(dir, { recursive: true, force: true });
133
+ } catch {
134
+ // Best-effort cleanup
135
+ }
136
+ installed = false;
137
+ },
138
+
139
+ onCall(response: MockPiCall) {
140
+ // Validate keys to catch typos early
141
+ const unknown = Object.keys(response).filter(
142
+ (k) => !VALID_MOCK_PI_CALL_KEYS.has(k),
143
+ );
144
+ if (unknown.length > 0) {
145
+ throw new Error(
146
+ `Unknown MockPiCall key(s): ${unknown.join(", ")}. ` +
147
+ `Valid keys: ${[...VALID_MOCK_PI_CALL_KEYS].join(", ")}`,
148
+ );
149
+ }
150
+
151
+ const queue: MockPiCall[] = JSON.parse(fs.readFileSync(queueFile, "utf-8"));
152
+ queue.push(response);
153
+ fs.writeFileSync(queueFile, JSON.stringify(queue));
154
+ },
155
+
156
+ reset() {
157
+ fs.writeFileSync(queueFile, "[]");
158
+ fs.writeFileSync(counterFile, "0");
159
+ },
160
+
161
+ callCount(): number {
162
+ try {
163
+ return parseInt(fs.readFileSync(counterFile, "utf-8").trim(), 10) || 0;
164
+ } catch {
165
+ return 0;
166
+ }
167
+ },
168
+ };
169
+ }