@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,252 @@
1
+ /**
2
+ * Tool execution interceptor — wraps tool.execute() for tools in mockTools.
3
+ *
4
+ * For mocked tools, the mock replaces tool.execute() and returns controlled
5
+ * values. Extension hooks (tool_call / tool_result) are handled by
6
+ * AgentSession 0.83's internal beforeToolCall/afterToolCall — the mock
7
+ * must NOT re-emit them.
8
+ *
9
+ * For non-mocked tools, the real execute() is called and results are
10
+ * collected for event queries.
11
+ */
12
+
13
+ import type { AgentTool } from "@earendil-works/pi-agent-core";
14
+ import type { MockToolHandler, ToolResult, ToolResultRecord } from "./types.js";
15
+ import type { PlaybookState } from "./playbook.js";
16
+ import { formatToolError } from "./diagnostics.js";
17
+
18
+ /**
19
+ * Thrown when an extension hook blocks a tool call.
20
+ * Exported for test assertions — no longer thrown by the mock itself since
21
+ * AgentSession 0.83's beforeToolCall handles blocking before execute().
22
+ */
23
+ export class ToolBlockedError extends Error {
24
+ readonly toolBlocked = true as const;
25
+
26
+ constructor(reason: string) {
27
+ super(reason);
28
+ this.name = "ToolBlockedError";
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Returns true if `err` represents a hook-based tool block.
34
+ *
35
+ * Kept for consumers that catch errors from tool execution flows, though
36
+ * AgentSession 0.83's beforeToolCall blocks before execute() is reached.
37
+ */
38
+ export function isBlockedError(err: unknown): boolean {
39
+ if (err instanceof ToolBlockedError) return true;
40
+ if (err instanceof Error) {
41
+ const msg = err.message;
42
+ return (
43
+ msg.includes("blocked") ||
44
+ msg.includes("Plan mode") ||
45
+ msg.includes("WRITE operation")
46
+ );
47
+ }
48
+ return false;
49
+ }
50
+
51
+ function normalizeMockResult(
52
+ handler: MockToolHandler,
53
+ params: Record<string, unknown>,
54
+ ): ToolResult {
55
+ let raw: string | ToolResult;
56
+
57
+ if (typeof handler === "string") {
58
+ raw = handler;
59
+ } else if (typeof handler === "function") {
60
+ raw = handler(params);
61
+ } else {
62
+ raw = handler;
63
+ }
64
+
65
+ if (typeof raw === "string") {
66
+ return {
67
+ content: [{ type: "text", text: raw }],
68
+ details: {},
69
+ };
70
+ }
71
+
72
+ return raw;
73
+ }
74
+
75
+ /**
76
+ * Intercept tool execution for mocked tools.
77
+ *
78
+ * Unlike the old approach, this does NOT emit tool_call/tool_result hooks
79
+ * manually — AgentSession 0.83's beforeToolCall/afterToolCall handles that.
80
+ * The mock only replaces execute() to return controlled values. Result
81
+ * recording is handled by the session subscriber from tool_execution_end
82
+ * events (which carry the final afterToolCall-modified result).
83
+ *
84
+ * Returns a Set of mocked tool names for the session subscriber to set the
85
+ * mocked flag on recorded results, plus a Set of toolCallIds whose mock
86
+ * returned a ToolResult with isError:true. Pi 0.84's agent loop hardcodes
87
+ * successful execute() as non-error (isError:false), so the subscriber must
88
+ * consult this set to preserve the mock's error intent in collected records.
89
+ */
90
+ export function interceptToolExecution(
91
+ tools: AgentTool[],
92
+ mockTools: Record<string, MockToolHandler>,
93
+ playbookState: PlaybookState,
94
+ propagateErrors: boolean,
95
+ ): {
96
+ tools: AgentTool[];
97
+ mockedNames: ReadonlySet<string>;
98
+ mockedErrorToolCallIds: ReadonlySet<string>;
99
+ } {
100
+ const mockedNames = new Set(Object.keys(mockTools));
101
+ const mockedErrorToolCallIds = new Set<string>();
102
+
103
+ const wrapped = tools.map((tool) => {
104
+ const mockHandler = mockTools[tool.name];
105
+ if (!mockHandler) {
106
+ return wrapForCollection(tool, playbookState, propagateErrors);
107
+ }
108
+
109
+ return {
110
+ ...tool,
111
+ execute: async (
112
+ toolCallId: string,
113
+ params: Record<string, unknown>,
114
+ _signal?: AbortSignal,
115
+ _onUpdate?: any,
116
+ ) => {
117
+ const result = normalizeMockResult(mockHandler, params);
118
+ const text = result.content
119
+ .filter((c) => c.type === "text")
120
+ .map((c) => c.text)
121
+ .join("\n");
122
+ if (result.isError) {
123
+ // Pi 0.84 hardcodes successful execute() as non-error; remember the
124
+ // toolCallId so the session subscriber can flag the record.
125
+ mockedErrorToolCallIds.add(toolCallId);
126
+ }
127
+ // fireThenCallback fires synchronously so .then() sees real data
128
+ fireThenCallback(playbookState, toolCallId, {
129
+ step: playbookState.consumed,
130
+ toolName: tool.name,
131
+ toolCallId,
132
+ text,
133
+ content: result.content,
134
+ isError: result.isError ?? false,
135
+ details: result.details,
136
+ mocked: true,
137
+ });
138
+
139
+ return {
140
+ content: result.content,
141
+ details: result.details ?? {},
142
+ };
143
+ },
144
+ } as AgentTool;
145
+ });
146
+
147
+ return { tools: wrapped, mockedNames, mockedErrorToolCallIds };
148
+ }
149
+
150
+ /**
151
+ * Wrap a real tool for event collection (non-mocked tools).
152
+ * Does not push to toolResults — the session subscriber handles recording
153
+ * from tool_execution_end events.
154
+ */
155
+ function wrapForCollection(
156
+ tool: AgentTool,
157
+ playbookState: PlaybookState,
158
+ propagateErrors: boolean,
159
+ ): AgentTool {
160
+ const originalExecute = tool.execute;
161
+
162
+ return {
163
+ ...tool,
164
+ execute: async (
165
+ toolCallId: string,
166
+ params: Record<string, unknown>,
167
+ signal?: AbortSignal,
168
+ onUpdate?: any,
169
+ ) => {
170
+ const step = playbookState.consumed;
171
+
172
+ try {
173
+ const result = await originalExecute.call(
174
+ tool,
175
+ toolCallId,
176
+ params,
177
+ signal,
178
+ onUpdate,
179
+ );
180
+
181
+ const text = (result.content ?? [])
182
+ .filter((c: any) => c.type === "text")
183
+ .map((c: any) => c.text)
184
+ .join("\n");
185
+
186
+ fireThenCallback(playbookState, toolCallId, {
187
+ step,
188
+ toolName: tool.name,
189
+ toolCallId,
190
+ text,
191
+ content: result.content ?? [],
192
+ isError: !!(result as any).isError,
193
+ details: result.details,
194
+ mocked: false,
195
+ });
196
+
197
+ return result;
198
+ } catch (err) {
199
+ const errMsg = err instanceof Error ? err.message : String(err);
200
+
201
+ try {
202
+ fireThenCallback(playbookState, toolCallId, {
203
+ step,
204
+ toolName: tool.name,
205
+ toolCallId,
206
+ text: errMsg,
207
+ content: [{ type: "text", text: errMsg }],
208
+ isError: true,
209
+ details: undefined,
210
+ mocked: false,
211
+ });
212
+ } catch {
213
+ /* best-effort callback */
214
+ }
215
+
216
+ if (propagateErrors) {
217
+ const diagnostic = formatToolError(step, tool.name, err);
218
+ throw new Error(diagnostic, { cause: err });
219
+ }
220
+
221
+ return {
222
+ content: [{ type: "text", text: errMsg }],
223
+ details: {},
224
+ isError: true,
225
+ };
226
+ }
227
+ },
228
+ } as AgentTool;
229
+ }
230
+
231
+ function fireThenCallback(
232
+ state: PlaybookState,
233
+ toolCallId: string,
234
+ record: ToolResultRecord,
235
+ ): void {
236
+ const callback =
237
+ state.pendingCallbacks.get(toolCallId) ??
238
+ state.pendingCallbacks.get(record.toolName);
239
+ const key = state.pendingCallbacks.has(toolCallId)
240
+ ? toolCallId
241
+ : record.toolName;
242
+ if (callback) {
243
+ state.pendingCallbacks.delete(key);
244
+ try {
245
+ callback(record);
246
+ } catch (err) {
247
+ console.warn(
248
+ `[pi-test-harness] .then() callback error for ${record.toolName}: ${err}`,
249
+ );
250
+ }
251
+ }
252
+ }
package/src/mock-ui.ts ADDED
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Mock UI context — intercepts ctx.ui.* calls from extensions.
3
+ * All calls are collected for assertions. Interactive methods return
4
+ * configured mock responses.
5
+ */
6
+
7
+ import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
8
+ import type { MockUIConfig, UICallRecord } from "./types.js";
9
+
10
+ /**
11
+ * Create a mock ExtensionUIContext that records all calls and returns
12
+ * configured responses.
13
+ */
14
+ export function createMockUIContext(
15
+ config: MockUIConfig = {},
16
+ uiLog: UICallRecord[],
17
+ ): ExtensionUIContext {
18
+ function record(
19
+ method: string,
20
+ args: unknown[],
21
+ returnValue?: unknown,
22
+ ): void {
23
+ uiLog.push({ method, args, returnValue });
24
+ }
25
+
26
+ const mockUI: ExtensionUIContext = {
27
+ async select(
28
+ title: string,
29
+ options: string[],
30
+ _opts?: any,
31
+ ): Promise<string | undefined> {
32
+ let result: string | undefined;
33
+ const handler = config.select;
34
+ if (handler === undefined || handler === null) {
35
+ result = options[0]; // default: first item
36
+ } else if (typeof handler === "number") {
37
+ result = options[handler];
38
+ } else if (typeof handler === "string") {
39
+ result = options.find((o) => o === handler) ?? options[0];
40
+ } else if (typeof handler === "function") {
41
+ result = handler(title, options);
42
+ }
43
+ record("select", [title, options], result);
44
+ return result;
45
+ },
46
+
47
+ async confirm(
48
+ title: string,
49
+ message: string,
50
+ _opts?: any,
51
+ ): Promise<boolean> {
52
+ let result: boolean;
53
+ const handler = config.confirm;
54
+ if (handler === undefined || handler === null) {
55
+ result = true; // default: approve
56
+ } else if (typeof handler === "boolean") {
57
+ result = handler;
58
+ } else if (typeof handler === "function") {
59
+ result = handler(title, message);
60
+ } else {
61
+ result = true;
62
+ }
63
+ record("confirm", [title, message], result);
64
+ return result;
65
+ },
66
+
67
+ async input(
68
+ title: string,
69
+ placeholder?: string,
70
+ _opts?: any,
71
+ ): Promise<string | undefined> {
72
+ let result: string | undefined;
73
+ const handler = config.input;
74
+ if (handler === undefined || handler === null) {
75
+ result = "";
76
+ } else if (typeof handler === "string") {
77
+ result = handler;
78
+ } else if (typeof handler === "function") {
79
+ result = handler(title, placeholder);
80
+ }
81
+ record("input", [title, placeholder], result);
82
+ return result;
83
+ },
84
+
85
+ async editor(title: string, prefill?: string): Promise<string | undefined> {
86
+ let result: string | undefined;
87
+ const handler = config.editor;
88
+ if (handler === undefined || handler === null) {
89
+ result = "";
90
+ } else if (typeof handler === "string") {
91
+ result = handler;
92
+ } else if (typeof handler === "function") {
93
+ result = handler(title, prefill);
94
+ }
95
+ record("editor", [title, prefill], result);
96
+ return result;
97
+ },
98
+
99
+ notify(message: string, type?: "info" | "warning" | "error"): void {
100
+ record("notify", [message, type]);
101
+ },
102
+
103
+ onTerminalInput(): () => void {
104
+ return () => {};
105
+ },
106
+
107
+ setStatus(key: string, text: string | undefined): void {
108
+ record("setStatus", [key, text]);
109
+ },
110
+
111
+ setWorkingMessage(message?: string): void {
112
+ record("setWorkingMessage", [message]);
113
+ },
114
+
115
+ setWorkingVisible(visible: boolean): void {
116
+ record("setWorkingVisible", [visible]);
117
+ },
118
+
119
+ setWorkingIndicator(options?: {
120
+ frames?: string[];
121
+ intervalMs?: number;
122
+ }): void {
123
+ record("setWorkingIndicator", [options]);
124
+ },
125
+
126
+ setHiddenThinkingLabel(label?: string): void {
127
+ record("setHiddenThinkingLabel", [label]);
128
+ },
129
+
130
+ setWidget(key: string, content: any, _options?: any): void {
131
+ record("setWidget", [key, content]);
132
+ },
133
+
134
+ setFooter(...args: unknown[]): void {
135
+ record("setFooter", args);
136
+ },
137
+ setHeader(...args: unknown[]): void {
138
+ record("setHeader", args);
139
+ },
140
+
141
+ setTitle(title: string): void {
142
+ record("setTitle", [title]);
143
+ },
144
+
145
+ async custom<T>(): Promise<T> {
146
+ return undefined as never;
147
+ },
148
+
149
+ pasteToEditor(text: string): void {
150
+ record("pasteToEditor", [text]);
151
+ },
152
+ setEditorText(text: string): void {
153
+ record("setEditorText", [text]);
154
+ },
155
+ getEditorText(): string {
156
+ return "";
157
+ },
158
+
159
+ setEditorComponent(factory: any): void {
160
+ record("setEditorComponent", [factory]);
161
+ },
162
+
163
+ addAutocompleteProvider(factory: any): void {
164
+ record("addAutocompleteProvider", [factory]);
165
+ },
166
+
167
+ getEditorComponent(): any | undefined {
168
+ return undefined;
169
+ },
170
+
171
+ get theme(): any {
172
+ return {
173
+ fg: (_color: string, text: string) => text,
174
+ bold: (text: string) => text,
175
+ italic: (text: string) => text,
176
+ strikethrough: (text: string) => text,
177
+ };
178
+ },
179
+
180
+ getAllThemes(): any[] {
181
+ return [];
182
+ },
183
+ getTheme(): any {
184
+ return undefined;
185
+ },
186
+ setTheme(): any {
187
+ return { success: false, error: "Test mode" };
188
+ },
189
+ getToolsExpanded(): boolean {
190
+ return false;
191
+ },
192
+ setToolsExpanded(): void {},
193
+ };
194
+
195
+ return mockUI;
196
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Extension-loader parity with Pi's shipped runtimes.
3
+ */
4
+
5
+ /**
6
+ * Environment variable jiti reads when it decides whether to try the runtime's
7
+ * native require/import before applying its own transform.
8
+ * @internal
9
+ */
10
+ const JITI_TRY_NATIVE = "JITI_TRY_NATIVE";
11
+
12
+ /** Nesting depth of `withoutJitiNativeImport` calls in this process. */
13
+ let suppressionDepth = 0;
14
+
15
+ /**
16
+ * Run `fn` with jiti's native import/require fast-path disabled.
17
+ *
18
+ * **Why this exists**: Pi loads every extension entrypoint through jiti
19
+ * (`loadExtensionModule` in `core/extensions/loader.ts`). Which jiti options it
20
+ * passes depends on how Pi itself is running:
21
+ *
22
+ * ```ts
23
+ * isBunBinary || isNodeSeaBinary || isBundledNode
24
+ * ? { virtualModules, tryNative: false } // compiled binary / bundled Node
25
+ * : isTypeScriptSourceRuntime
26
+ * ? { virtualModules, tsconfigPaths: true }
27
+ * : { alias: getAliases() } // unbundled Node build
28
+ * ```
29
+ *
30
+ * The last branch — the one an in-process harness always takes, because it
31
+ * imports the package's compiled entrypoint — leaves `tryNative` at jiti's
32
+ * default, and that default is "enabled if Bun is detected". Under a Bun test
33
+ * runner this combination hands Pi a factory for a module whose body has not
34
+ * finished evaluating: an entrypoint with a module-level `await import(...)`
35
+ * reports as loaded before its top-level `const` is initialized, so the hoisted
36
+ * default export Pi calls throws `Cannot access 'x' before initialization`.
37
+ *
38
+ * Pi's shipped runtimes never take that path, so the harness pins the choice
39
+ * they make (`tryNative: false`) for the duration of the load. This keeps
40
+ * `extensionFactories`-style and path-loaded extensions in exactly the loader
41
+ * configuration the real CLI uses, instead of one that varies by test runner.
42
+ *
43
+ * Nesting is reference-counted so two sessions loading concurrently in one
44
+ * process cannot have one restore the variable while the other is mid-load.
45
+ */
46
+ export async function withoutJitiNativeImport<T>(
47
+ fn: () => Promise<T>,
48
+ ): Promise<T> {
49
+ const previous = process.env[JITI_TRY_NATIVE];
50
+ suppressionDepth += 1;
51
+ process.env[JITI_TRY_NATIVE] = "0";
52
+ try {
53
+ return await fn();
54
+ } finally {
55
+ suppressionDepth -= 1;
56
+ if (suppressionDepth === 0) {
57
+ if (previous === undefined) delete process.env[JITI_TRY_NATIVE];
58
+ else process.env[JITI_TRY_NATIVE] = previous;
59
+ }
60
+ }
61
+ }
@@ -0,0 +1,189 @@
1
+ /**
2
+ * PlaybookStreamFn — replaces the model with scripted responses.
3
+ *
4
+ * The playbook is a queue of actions. Each streamFn call dequeues the next
5
+ * action and returns it as an AssistantMessageEventStream.
6
+ */
7
+
8
+ import {
9
+ createAssistantMessageEventStream,
10
+ type AssistantMessage,
11
+ type AssistantMessageEventStream,
12
+ type Context,
13
+ type Model,
14
+ type SimpleStreamOptions,
15
+ } from "@earendil-works/pi-ai";
16
+ import type { PlaybookAction, Turn, ToolResultRecord } from "./types.js";
17
+ import { formatPlaybookDiagnostic } from "./diagnostics.js";
18
+
19
+ // ── DSL builders ────────────────────────────────────────────
20
+
21
+ /** Chainable call action builder */
22
+ class CallAction {
23
+ readonly action: PlaybookAction;
24
+
25
+ constructor(toolName: string, params: Record<string, unknown> | (() => Record<string, unknown>)) {
26
+ this.action = { type: "call", toolName, params };
27
+ }
28
+
29
+ then(callback: (result: ToolResultRecord) => void): CallAction {
30
+ this.action.thenCallback = callback;
31
+ return this;
32
+ }
33
+ }
34
+
35
+ /**
36
+ * The model calls a tool.
37
+ * @param toolName Tool to call
38
+ * @param params Static params or function for late binding
39
+ */
40
+ export function calls(
41
+ toolName: string,
42
+ params: Record<string, unknown> | (() => Record<string, unknown>) = {},
43
+ ): CallAction {
44
+ return new CallAction(toolName, params);
45
+ }
46
+
47
+ /**
48
+ * The model emits text. Agent loop ends for this turn.
49
+ */
50
+ export function says(text: string): PlaybookAction {
51
+ return { type: "say", text };
52
+ }
53
+
54
+ /**
55
+ * Define one user→model turn.
56
+ * @param prompt The actual user prompt text
57
+ * @param actions What the model does in response (call/say sequence)
58
+ */
59
+ export function when(prompt: string, actions: Array<CallAction | PlaybookAction>): Turn {
60
+ return {
61
+ prompt,
62
+ actions: actions.map((a) => (a instanceof CallAction ? a.action : a)),
63
+ };
64
+ }
65
+
66
+ // ── PlaybookStreamFn ────────────────────────────────────────
67
+
68
+ function resolveParams(params: Record<string, unknown> | (() => Record<string, unknown>) | undefined): Record<string, unknown> {
69
+ if (!params) return {};
70
+ if (typeof params === "function") return params();
71
+ return params;
72
+ }
73
+
74
+ function createAssistantMessage(action: PlaybookAction, toolCallCounter: number): AssistantMessage {
75
+ const content: AssistantMessage["content"] = [];
76
+
77
+ if (action.type === "say") {
78
+ content.push({ type: "text", text: action.text ?? "" });
79
+ } else if (action.type === "call") {
80
+ const resolvedParams = resolveParams(action.params);
81
+ content.push({
82
+ type: "toolCall",
83
+ id: `playbook-tc-${toolCallCounter}`,
84
+ name: action.toolName!,
85
+ arguments: resolvedParams,
86
+ });
87
+ }
88
+
89
+ return {
90
+ role: "assistant",
91
+ content,
92
+ api: "openai-responses",
93
+ provider: "test",
94
+ model: "playbook",
95
+ usage: {
96
+ input: 0,
97
+ output: 0,
98
+ cacheRead: 0,
99
+ cacheWrite: 0,
100
+ totalTokens: 0,
101
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
102
+ },
103
+ stopReason: action.type === "call" ? "toolUse" : "stop",
104
+ timestamp: Date.now(),
105
+ };
106
+ }
107
+
108
+ export interface PlaybookState {
109
+ consumed: number;
110
+ remaining: number;
111
+ /** The action objects for each consumed step (for diagnostics) */
112
+ consumedActions: PlaybookAction[];
113
+ /** Callbacks pending for completed tool calls */
114
+ pendingCallbacks: Map<string, (result: ToolResultRecord) => void>;
115
+ }
116
+
117
+ export function createPlaybookStreamFn(turns: Turn[]): {
118
+ streamFn: (model: Model<any>, context: Context, options?: SimpleStreamOptions) => AssistantMessageEventStream;
119
+ state: PlaybookState;
120
+ } {
121
+ // Flatten all turns into a single action queue
122
+ const queue: PlaybookAction[] = [];
123
+ for (const turn of turns) {
124
+ queue.push(...turn.actions);
125
+ }
126
+
127
+ const state: PlaybookState = {
128
+ consumed: 0,
129
+ remaining: queue.length,
130
+ consumedActions: [],
131
+ pendingCallbacks: new Map(),
132
+ };
133
+
134
+ let toolCallCounter = 0;
135
+
136
+ const streamFn = (
137
+ _model: Model<any>,
138
+ _context: Context,
139
+ _options?: SimpleStreamOptions,
140
+ ): AssistantMessageEventStream => {
141
+ const stream = createAssistantMessageEventStream();
142
+ const action = queue.shift();
143
+
144
+ if (!action) {
145
+ // Playbook exhausted
146
+ const diagnostic = formatPlaybookDiagnostic("exhausted", state);
147
+ const fallback: AssistantMessage = {
148
+ role: "assistant",
149
+ content: [{ type: "text", text: `[PLAYBOOK EXHAUSTED] ${diagnostic}` }],
150
+ api: "openai-responses",
151
+ provider: "test",
152
+ model: "playbook",
153
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
154
+ stopReason: "stop",
155
+ timestamp: Date.now(),
156
+ };
157
+ queueMicrotask(() => {
158
+ stream.push({ type: "done", reason: "stop", message: fallback });
159
+ });
160
+ return stream;
161
+ }
162
+
163
+ state.consumed++;
164
+ state.remaining = queue.length;
165
+ state.consumedActions.push(action);
166
+
167
+ if (action.type === "call") toolCallCounter++;
168
+ const message = createAssistantMessage(action, toolCallCounter);
169
+
170
+ // Register callback if present (keyed by tool call ID for uniqueness)
171
+ if (action.type === "call" && action.thenCallback) {
172
+ const tcContent = message.content.find((c) => c.type === "toolCall");
173
+ const tcId = tcContent && "id" in tcContent ? (tcContent as any).id : action.toolName!;
174
+ state.pendingCallbacks.set(tcId, action.thenCallback);
175
+ }
176
+
177
+ queueMicrotask(() => {
178
+ stream.push({
179
+ type: "done",
180
+ reason: message.stopReason === "toolUse" ? "toolUse" : "stop",
181
+ message,
182
+ });
183
+ });
184
+
185
+ return stream;
186
+ };
187
+
188
+ return { streamFn, state };
189
+ }