@agentwonderland/mcp 0.1.53 → 0.1.55

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.
@@ -0,0 +1,2123 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ const mockApiGet = vi.fn();
3
+ const mockApiPost = vi.fn();
4
+ const mockApiPostWithApprovedLinkSpendRequest = vi.fn();
5
+ const mockApiPostWithPayment = vi.fn();
6
+ const mockUploadLocalFiles = vi.fn();
7
+ const mockHasWalletConfigured = vi.fn();
8
+ const mockGetConfiguredMethods = vi.fn();
9
+ const mockGetWalletAddress = vi.fn();
10
+ const mockGetLinkConfig = vi.fn();
11
+ const mockSetPendingLinkSpendRequest = vi.fn();
12
+ const mockRequiresSpendConfirmation = vi.fn();
13
+ const mockCanSpend = vi.fn();
14
+ const mockRecordSpend = vi.fn();
15
+ const mockRequiresPolicyConfirmation = vi.fn();
16
+ const mockStoreFeedbackToken = vi.fn();
17
+ const mockDecodeMppNetworkId = vi.fn();
18
+ const mockEnsureApprovedLinkSpendRequest = vi.fn();
19
+ class MockLinkApprovalRequiredError extends Error {
20
+ approvalUrl;
21
+ spendRequestId;
22
+ constructor(approvalUrl, spendRequestId = "lsrq_test_123") {
23
+ super(`Link approval required: ${approvalUrl}`);
24
+ this.name = "LinkApprovalRequiredError";
25
+ this.approvalUrl = approvalUrl;
26
+ this.spendRequestId = spendRequestId;
27
+ }
28
+ }
29
+ vi.mock("../../core/api-client.js", () => ({
30
+ apiGet: mockApiGet,
31
+ apiPost: mockApiPost,
32
+ apiPostWithApprovedLinkSpendRequest: mockApiPostWithApprovedLinkSpendRequest,
33
+ apiPostWithPayment: mockApiPostWithPayment,
34
+ }));
35
+ vi.mock("../../core/file-upload.js", () => ({
36
+ uploadLocalFiles: mockUploadLocalFiles,
37
+ }));
38
+ vi.mock("../../core/payments.js", () => ({
39
+ hasWalletConfigured: mockHasWalletConfigured,
40
+ getConfiguredMethods: mockGetConfiguredMethods,
41
+ getWalletAddress: mockGetWalletAddress,
42
+ }));
43
+ vi.mock("../../core/config.js", () => ({
44
+ getLinkConfig: mockGetLinkConfig,
45
+ setPendingLinkSpendRequest: mockSetPendingLinkSpendRequest,
46
+ requiresSpendConfirmation: mockRequiresSpendConfirmation,
47
+ }));
48
+ vi.mock("../../core/spend-policy.js", () => ({
49
+ canSpend: mockCanSpend,
50
+ recordSpend: mockRecordSpend,
51
+ requiresPolicyConfirmation: mockRequiresPolicyConfirmation,
52
+ }));
53
+ vi.mock("../_token-cache.js", () => ({
54
+ storeFeedbackToken: mockStoreFeedbackToken,
55
+ }));
56
+ vi.mock("../../core/link-cli.js", () => ({
57
+ decodeMppNetworkId: mockDecodeMppNetworkId,
58
+ ensureApprovedLinkSpendRequest: mockEnsureApprovedLinkSpendRequest,
59
+ LinkApprovalRequiredError: MockLinkApprovalRequiredError,
60
+ }));
61
+ function flattenToolText(result) {
62
+ const content = result?.content ?? [];
63
+ return content
64
+ .filter((item) => item?.type === "text")
65
+ .map((item) => item.text ?? "")
66
+ .join("\n\n");
67
+ }
68
+ function makeServerHarness() {
69
+ const handlers = new Map();
70
+ return {
71
+ handlers,
72
+ server: {
73
+ tool(name, _description, _schema, handler) {
74
+ handlers.set(name, handler);
75
+ },
76
+ },
77
+ };
78
+ }
79
+ const playbook = {
80
+ id: "pb_competitor_ads_v1",
81
+ slug: "competitor-ads",
82
+ version: 1,
83
+ name: "Competitor Ads",
84
+ persona: "gtm",
85
+ tags: ["gtm"],
86
+ description: "Competitor ad workflow",
87
+ outcome: "Ad intel",
88
+ support_status: "supported",
89
+ input_schema: {
90
+ type: "object",
91
+ properties: { domain: { type: "string", description: "Competitor domain" } },
92
+ required: ["domain"],
93
+ },
94
+ sample_input: { domain: "notion.so" },
95
+ budget_notes: ["Low fanout"],
96
+ risks: [],
97
+ stats: {
98
+ favorite_count: 3,
99
+ rating_count: 2,
100
+ average_rating: 4.5,
101
+ },
102
+ current_quote: {
103
+ estimated_cost_usd: 1.25,
104
+ step_count: 1,
105
+ blocking_issues: [],
106
+ steps: [
107
+ {
108
+ id: "ads",
109
+ index: 0,
110
+ node_type: "aw_agent",
111
+ agent_slug: "ad-strategy-intel",
112
+ agent_id: "11111111-1111-4111-8111-111111111111",
113
+ provider_id: null,
114
+ agent_name: "AdStrategyIntel",
115
+ description: "Ad intelligence",
116
+ quantity: 1,
117
+ unit_price_usd: 1.25,
118
+ quoted_cost_usd: 1.25,
119
+ support_status: "ready",
120
+ },
121
+ ],
122
+ },
123
+ };
124
+ describe("playbook MCP tools", () => {
125
+ beforeEach(() => {
126
+ vi.resetModules();
127
+ vi.clearAllMocks();
128
+ mockHasWalletConfigured.mockReturnValue(true);
129
+ mockGetConfiguredMethods.mockReturnValue(["card"]);
130
+ mockGetWalletAddress.mockResolvedValue("0xabc");
131
+ mockGetLinkConfig.mockReturnValue({
132
+ paymentMethodId: "csmrpd_test_123",
133
+ });
134
+ mockDecodeMppNetworkId.mockResolvedValue("profile_test");
135
+ mockEnsureApprovedLinkSpendRequest.mockResolvedValue("lsrq_test_123");
136
+ mockRequiresSpendConfirmation.mockReturnValue(true);
137
+ mockRequiresPolicyConfirmation.mockReturnValue(false);
138
+ mockCanSpend.mockReturnValue({ ok: true, message: "" });
139
+ mockUploadLocalFiles.mockResolvedValue({ input: { domain: "notion.so" }, uploads: [] });
140
+ });
141
+ it("searches playbooks through the gateway", async () => {
142
+ mockApiGet.mockResolvedValueOnce({ playbooks: [playbook] });
143
+ const { registerPlaybookTools } = await import("../playbooks.js");
144
+ const harness = makeServerHarness();
145
+ registerPlaybookTools(harness.server);
146
+ const result = await harness.handlers.get("search_playbooks")({ query: "competitor ads", limit: 5 });
147
+ const text = flattenToolText(result);
148
+ expect(mockApiGet).toHaveBeenCalledWith("/playbooks?q=competitor+ads&limit=5");
149
+ expect(text).toContain("Competitor Ads (competitor-ads)");
150
+ expect(text).toContain("Favorites: 3");
151
+ expect(text).toContain("Rating: 4.5/5 (2)");
152
+ expect(text).toContain("get_playbook");
153
+ });
154
+ it("passes playbook search filters and sorting to the gateway", async () => {
155
+ mockApiGet.mockResolvedValueOnce({ playbooks: [playbook] });
156
+ const { registerPlaybookTools } = await import("../playbooks.js");
157
+ const harness = makeServerHarness();
158
+ registerPlaybookTools(harness.server);
159
+ await harness.handlers.get("search_playbooks")({
160
+ query: "lead gen",
161
+ persona: "gtm",
162
+ tag: "prospecting",
163
+ max_budget: 5,
164
+ sort: "popularity",
165
+ limit: 3,
166
+ });
167
+ expect(mockApiGet).toHaveBeenCalledWith("/playbooks?q=lead+gen&persona=gtm&tag=prospecting&limit=3&max_budget=5&sort=popularity");
168
+ });
169
+ it("returns a useful search empty state", async () => {
170
+ mockApiGet.mockResolvedValueOnce({ playbooks: [] });
171
+ const { registerPlaybookTools } = await import("../playbooks.js");
172
+ const harness = makeServerHarness();
173
+ registerPlaybookTools(harness.server);
174
+ const result = await harness.handlers.get("search_playbooks")({ query: "quantum waffles", limit: 5 });
175
+ const text = flattenToolText(result);
176
+ expect(text).toContain('No playbooks found matching "quantum waffles"');
177
+ expect(text).toContain('search_playbooks({ persona: "gtm" })');
178
+ expect(text).toContain('search_playbooks({ tag: "research" })');
179
+ });
180
+ it("labels catalog-only playbooks clearly in search results", async () => {
181
+ mockApiGet.mockResolvedValueOnce({
182
+ playbooks: [{
183
+ ...playbook,
184
+ slug: "trend-radar",
185
+ name: "Trend Radar",
186
+ support_status: "catalog_only",
187
+ support_note: "Best as a future scheduling demo.",
188
+ }],
189
+ });
190
+ const { registerPlaybookTools } = await import("../playbooks.js");
191
+ const harness = makeServerHarness();
192
+ registerPlaybookTools(harness.server);
193
+ const result = await harness.handlers.get("search_playbooks")({ query: "trend", limit: 5 });
194
+ const text = flattenToolText(result);
195
+ expect(text).toContain("Trend Radar (trend-radar)");
196
+ expect(text).toContain("catalog-only: Best as a future scheduling demo.");
197
+ });
198
+ it("gets a playbook with live quote, graph, risks, and run suggestion", async () => {
199
+ mockApiGet.mockResolvedValueOnce({
200
+ ...playbook,
201
+ budget_notes: ["Low fanout", "Stops before exceeding the approved budget cap."],
202
+ risks: ["SERP step may need a derived query."],
203
+ });
204
+ const { registerPlaybookTools } = await import("../playbooks.js");
205
+ const harness = makeServerHarness();
206
+ registerPlaybookTools(harness.server);
207
+ const result = await harness.handlers.get("get_playbook")({ slug: "competitor-ads" });
208
+ const text = flattenToolText(result);
209
+ expect(mockApiGet).toHaveBeenCalledWith("/playbooks/competitor-ads");
210
+ expect(text).toContain("Competitor Ads (competitor-ads)");
211
+ expect(text).toContain("Support: supported");
212
+ expect(text).toContain("Estimated cost: $1.25 across 1 paid step");
213
+ expect(text).toContain("Inputs:");
214
+ expect(text).toContain("domain: string (required)");
215
+ expect(text).toContain("Execution plan:");
216
+ expect(text).toContain("1. AdStrategyIntel (ad-strategy-intel) — $1.25");
217
+ expect(text).toContain("Budget notes:");
218
+ expect(text).toContain("Stops before exceeding the approved budget cap.");
219
+ expect(text).toContain("Risks:");
220
+ expect(text).toContain("SERP step may need a derived query.");
221
+ expect(text).toContain("Sample input:");
222
+ expect(text).toContain('Run quote: run_playbook({ slug: "competitor-ads"');
223
+ });
224
+ it("shows blocking child-agent issues in get_playbook output", async () => {
225
+ mockApiGet.mockResolvedValueOnce({
226
+ ...playbook,
227
+ current_quote: {
228
+ ...playbook.current_quote,
229
+ blocking_issues: ["Inactive child agent: ad-strategy-intel"],
230
+ steps: [{
231
+ ...playbook.current_quote.steps[0],
232
+ support_status: "blocking",
233
+ agent_id: null,
234
+ }],
235
+ },
236
+ });
237
+ const { registerPlaybookTools } = await import("../playbooks.js");
238
+ const harness = makeServerHarness();
239
+ registerPlaybookTools(harness.server);
240
+ const result = await harness.handlers.get("get_playbook")({ playbook_id: "pb_competitor_ads_v1" });
241
+ const text = flattenToolText(result);
242
+ expect(mockApiGet).toHaveBeenCalledWith("/playbooks/pb_competitor_ads_v1");
243
+ expect(text).toContain("Blocking issues: Inactive child agent: ad-strategy-intel");
244
+ expect(text).toContain("[blocking]");
245
+ });
246
+ it("favorites a playbook with consumer principal headers", async () => {
247
+ mockApiPost.mockResolvedValueOnce({
248
+ ok: true,
249
+ favorite_id: "favorite-row-1",
250
+ playbook_slug: "competitor-ads",
251
+ });
252
+ const { registerPlaybookTools } = await import("../playbooks.js");
253
+ const harness = makeServerHarness();
254
+ registerPlaybookTools(harness.server);
255
+ const result = await harness.handlers.get("favorite_playbook")({ slug: "competitor-ads" });
256
+ const text = flattenToolText(result);
257
+ expect(mockApiPost).toHaveBeenCalledWith("/playbooks/competitor-ads/favorite", {}, { ensureConsumerPrincipal: true });
258
+ expect(text).toContain("Saved competitor-ads to favorites");
259
+ });
260
+ it("does not expose per-playbook feedback as an MCP tool", async () => {
261
+ const { registerPlaybookTools } = await import("../playbooks.js");
262
+ const harness = makeServerHarness();
263
+ registerPlaybookTools(harness.server);
264
+ const favoriteResult = await harness.handlers.get("favorite_playbook")({});
265
+ expect(flattenToolText(favoriteResult)).toContain("Provide slug or playbook_id.");
266
+ expect(harness.handlers.has("rate_playbook")).toBe(false);
267
+ expect(mockApiPost).not.toHaveBeenCalled();
268
+ });
269
+ it("quotes before confirmed execution", async () => {
270
+ mockApiGet.mockResolvedValueOnce({
271
+ ...playbook,
272
+ risks: ["SERP step may need a derived query."],
273
+ });
274
+ const { registerPlaybookTools } = await import("../playbooks.js");
275
+ const harness = makeServerHarness();
276
+ registerPlaybookTools(harness.server);
277
+ const result = await harness.handlers.get("run_playbook")({
278
+ slug: "competitor-ads",
279
+ input: { domain: "notion.so" },
280
+ budget: 5,
281
+ pay_with: "card",
282
+ });
283
+ const text = flattenToolText(result);
284
+ expect(text).toContain("Ready to run Competitor Ads");
285
+ expect(text).toContain("Budget and fanout notes:");
286
+ expect(text).toContain("Low fanout");
287
+ expect(text).toContain("Risks and limitations:");
288
+ expect(text).toContain("SERP step may need a derived query.");
289
+ expect(text).toContain('run_playbook({ slug: "competitor-ads", input: <same>, budget: 5, pay_with: "card", confirmed: true })');
290
+ expect(mockApiPostWithPayment).not.toHaveBeenCalled();
291
+ });
292
+ it("keeps reduced fanout limits in the confirmation command", async () => {
293
+ mockApiGet.mockResolvedValueOnce(playbook);
294
+ mockApiPost.mockResolvedValueOnce({
295
+ playbook,
296
+ budget_usd: 5,
297
+ budget_sufficient: true,
298
+ remaining_budget_usd: 3.75,
299
+ });
300
+ const { registerPlaybookTools } = await import("../playbooks.js");
301
+ const harness = makeServerHarness();
302
+ registerPlaybookTools(harness.server);
303
+ const result = await harness.handlers.get("run_playbook")({
304
+ slug: "competitor-ads",
305
+ input: { domain: "notion.so" },
306
+ budget: 5,
307
+ pay_with: "card",
308
+ limits: { prospects: 2 },
309
+ });
310
+ const text = flattenToolText(result);
311
+ expect(mockApiPost).toHaveBeenCalledWith("/playbook-quotes", expect.objectContaining({
312
+ limits: { prospects: 2 },
313
+ }));
314
+ expect(text).toContain('run_playbook({ slug: "competitor-ads", input: <same>, budget: 5, limits: {"prospects":2}, pay_with: "card", confirmed: true })');
315
+ expect(mockApiPostWithPayment).not.toHaveBeenCalled();
316
+ });
317
+ it("returns the first quote before wallet setup is complete", async () => {
318
+ mockHasWalletConfigured.mockReturnValue(false);
319
+ mockApiGet.mockResolvedValueOnce(playbook);
320
+ const { registerPlaybookTools } = await import("../playbooks.js");
321
+ const harness = makeServerHarness();
322
+ registerPlaybookTools(harness.server);
323
+ const result = await harness.handlers.get("run_playbook")({
324
+ slug: "competitor-ads",
325
+ input: { domain: "notion.so" },
326
+ budget: 5,
327
+ pay_with: "tempo",
328
+ });
329
+ const text = flattenToolText(result);
330
+ expect(text).toContain("Ready to run Competitor Ads");
331
+ expect(text).toContain("Estimated child-agent charges: $1.25");
332
+ expect(text).toContain('run_playbook({ slug: "competitor-ads", input: <same>, budget: 5, pay_with: "tempo", confirmed: true })');
333
+ expect(text).not.toContain("No payment method configured");
334
+ expect(mockApiPost).not.toHaveBeenCalledWith("/playbook-runs", expect.anything());
335
+ expect(mockApiPostWithPayment).not.toHaveBeenCalled();
336
+ });
337
+ it("requires a configured wallet before confirmed playbook execution", async () => {
338
+ mockHasWalletConfigured.mockReturnValue(false);
339
+ mockRequiresSpendConfirmation.mockReturnValue(false);
340
+ mockApiGet.mockResolvedValueOnce(playbook);
341
+ const { registerPlaybookTools } = await import("../playbooks.js");
342
+ const harness = makeServerHarness();
343
+ registerPlaybookTools(harness.server);
344
+ const result = await harness.handlers.get("run_playbook")({
345
+ slug: "competitor-ads",
346
+ input: { domain: "notion.so" },
347
+ budget: 5,
348
+ pay_with: "tempo",
349
+ confirmed: true,
350
+ });
351
+ const text = flattenToolText(result);
352
+ expect(text).toContain("No payment method configured");
353
+ expect(text).toContain('wallet_setup({ action: "start" })');
354
+ expect(mockApiPost).not.toHaveBeenCalledWith("/playbook-runs", expect.anything());
355
+ expect(mockApiPostWithPayment).not.toHaveBeenCalled();
356
+ });
357
+ it("quotes the direct IPO brief run before any paid execution", async () => {
358
+ const ipoBrief = {
359
+ ...playbook,
360
+ id: "pb_ipo_brief_v1",
361
+ slug: "ipo-brief",
362
+ name: "IPO Brief",
363
+ persona: "investor",
364
+ input_schema: {
365
+ type: "object",
366
+ properties: { ticker: { type: "string", description: "IPO ticker" } },
367
+ required: ["ticker"],
368
+ },
369
+ sample_input: { ticker: "CRCL" },
370
+ current_quote: {
371
+ ...playbook.current_quote,
372
+ estimated_cost_usd: 2.5,
373
+ step_count: 3,
374
+ },
375
+ };
376
+ mockApiGet.mockResolvedValueOnce(ipoBrief);
377
+ const { registerPlaybookTools } = await import("../playbooks.js");
378
+ const harness = makeServerHarness();
379
+ registerPlaybookTools(harness.server);
380
+ const result = await harness.handlers.get("run_playbook")({
381
+ slug: "ipo-brief",
382
+ input: { ticker: "CRCL" },
383
+ budget: 3,
384
+ pay_with: "card",
385
+ });
386
+ const text = flattenToolText(result);
387
+ expect(mockApiGet).toHaveBeenCalledWith("/playbooks/ipo-brief");
388
+ expect(text).toContain("Ready to run IPO Brief");
389
+ expect(text).toContain("Estimated child-agent charges: $2.50");
390
+ expect(text).toContain('run_playbook({ slug: "ipo-brief", input: <same>, budget: 3, pay_with: "card", confirmed: true })');
391
+ expect(mockApiPost).not.toHaveBeenCalledWith("/playbook-runs", expect.anything());
392
+ expect(mockApiPostWithPayment).not.toHaveBeenCalled();
393
+ });
394
+ it("returns no paid execution when the budget is below the live quote", async () => {
395
+ const cheaperPlaybook = {
396
+ ...playbook,
397
+ id: "pb_quick_ads_v1",
398
+ slug: "quick-ads-check",
399
+ name: "Quick Ads Check",
400
+ current_quote: {
401
+ ...playbook.current_quote,
402
+ estimated_cost_usd: 0.25,
403
+ step_count: 1,
404
+ blocking_issues: [],
405
+ },
406
+ };
407
+ mockApiGet
408
+ .mockResolvedValueOnce(playbook)
409
+ .mockResolvedValueOnce({ playbooks: [cheaperPlaybook] });
410
+ mockApiPost.mockResolvedValueOnce({
411
+ playbook,
412
+ budget_usd: 0.5,
413
+ budget_sufficient: false,
414
+ remaining_budget_usd: 0,
415
+ });
416
+ const { registerPlaybookTools } = await import("../playbooks.js");
417
+ const harness = makeServerHarness();
418
+ registerPlaybookTools(harness.server);
419
+ const result = await harness.handlers.get("run_playbook")({
420
+ slug: "competitor-ads",
421
+ input: { domain: "notion.so" },
422
+ budget: 0.5,
423
+ pay_with: "card",
424
+ });
425
+ const text = flattenToolText(result);
426
+ expect(text).toContain("quote exceeds the budget");
427
+ expect(text).toContain("Estimated cost: $1.25");
428
+ expect(text).toContain("Cheaper supported alternatives under this budget");
429
+ expect(text).toContain("Quick Ads Check (quick-ads-check) — $0.2500 est.");
430
+ expect(mockApiPost).toHaveBeenCalledWith("/playbook-quotes", expect.objectContaining({
431
+ slug: "competitor-ads",
432
+ budget: 0.5,
433
+ }));
434
+ expect(mockApiGet).toHaveBeenCalledWith("/playbooks?max_budget=0.5&limit=4&sort=price");
435
+ expect(mockApiPost).not.toHaveBeenCalledWith("/playbook-runs", expect.anything());
436
+ expect(mockApiPostWithPayment).not.toHaveBeenCalled();
437
+ });
438
+ it("creates a run, executes child agent, records step receipt, and completes", async () => {
439
+ mockRequiresSpendConfirmation.mockReturnValue(false);
440
+ mockApiGet
441
+ .mockResolvedValueOnce(playbook)
442
+ .mockResolvedValueOnce({
443
+ run_id: "22222222-2222-4222-8222-222222222222",
444
+ status: "completed",
445
+ playbook_id: playbook.id,
446
+ playbook_slug: playbook.slug,
447
+ playbook_version: 1,
448
+ budget_usd: 5,
449
+ quoted_cost_usd: 1.25,
450
+ charged_usd: 1.25,
451
+ refunded_usd: 0,
452
+ remaining_budget_usd: 3.75,
453
+ steps: [
454
+ {
455
+ playbook_step_id: "ads",
456
+ step_index: 0,
457
+ node_type: "aw_agent",
458
+ agent_slug: "ad-strategy-intel",
459
+ agent_id: "11111111-1111-4111-8111-111111111111",
460
+ provider_id: null,
461
+ job_id: "33333333-3333-4333-8333-333333333333",
462
+ status: "succeeded",
463
+ quoted_cost_usd: 1.25,
464
+ charged_usd: 1.25,
465
+ refunded_usd: 0,
466
+ },
467
+ ],
468
+ });
469
+ mockApiPost
470
+ .mockResolvedValueOnce({
471
+ run_id: "22222222-2222-4222-8222-222222222222",
472
+ steps: playbook.current_quote.steps,
473
+ })
474
+ .mockResolvedValueOnce({ ok: true })
475
+ .mockRejectedValueOnce(new Error("temporary receipt write failure"))
476
+ .mockResolvedValueOnce({ ok: true })
477
+ .mockRejectedValueOnce({ status: 402, message: "payment required" })
478
+ .mockResolvedValueOnce({ ok: true })
479
+ .mockResolvedValueOnce({ ok: true });
480
+ mockApiPostWithPayment.mockResolvedValueOnce({
481
+ status: "success",
482
+ job_id: "33333333-3333-4333-8333-333333333333",
483
+ agent_id: "11111111-1111-4111-8111-111111111111",
484
+ output: { rows: 3 },
485
+ cost: 1.25,
486
+ feedback_token: "token",
487
+ });
488
+ const { registerPlaybookTools } = await import("../playbooks.js");
489
+ const harness = makeServerHarness();
490
+ registerPlaybookTools(harness.server);
491
+ const result = await harness.handlers.get("run_playbook")({
492
+ slug: "competitor-ads",
493
+ input: { domain: "notion.so" },
494
+ budget: 5,
495
+ pay_with: "card",
496
+ confirmed: true,
497
+ });
498
+ const text = flattenToolText(result);
499
+ expect(mockApiPost).toHaveBeenCalledWith("/playbook-runs", expect.objectContaining({
500
+ slug: "competitor-ads",
501
+ budget: 5,
502
+ }));
503
+ expect(mockApiPostWithPayment).toHaveBeenCalledWith("/agents/11111111-1111-4111-8111-111111111111/run", expect.objectContaining({
504
+ input: expect.objectContaining({
505
+ startUrls: [{
506
+ url: "https://notion.so",
507
+ }],
508
+ maxAds: 1,
509
+ }),
510
+ playbook_context: {
511
+ playbook_run_id: "22222222-2222-4222-8222-222222222222",
512
+ playbook_id: "pb_competitor_ads_v1",
513
+ playbook_slug: "competitor-ads",
514
+ playbook_step_id: "ads",
515
+ playbook_step_index: 0,
516
+ },
517
+ }), "card");
518
+ expect(mockRecordSpend).toHaveBeenCalledWith("card", 1.25);
519
+ expect(mockApiPost).toHaveBeenCalledWith("/playbook-runs/22222222-2222-4222-8222-222222222222/steps/ads", expect.objectContaining({
520
+ status: "succeeded",
521
+ error_code: null,
522
+ failure_message: null,
523
+ }));
524
+ expect(mockApiPost).toHaveBeenCalledWith("/playbook-runs/22222222-2222-4222-8222-222222222222/status", expect.objectContaining({
525
+ status: "completed",
526
+ error_code: null,
527
+ failure_message: null,
528
+ }));
529
+ expect(mockStoreFeedbackToken).not.toHaveBeenCalled();
530
+ expect(text).toContain("Playbook run 22222222-2222-4222-8222-222222222222");
531
+ expect(text).toContain("Charged: $1.25");
532
+ expect(text).not.toContain("Was this playbook useful?");
533
+ expect(text).not.toContain("favorite_playbook({ slug: \"competitor-ads\" })");
534
+ expect(text).not.toContain("rate_playbook");
535
+ });
536
+ it("uses one approved Link spend request across paid playbook child steps", async () => {
537
+ mockRequiresSpendConfirmation.mockReturnValue(false);
538
+ mockGetConfiguredMethods.mockReturnValue(["link"]);
539
+ mockApiGet
540
+ .mockResolvedValueOnce(playbook)
541
+ .mockResolvedValueOnce({
542
+ run_id: "22222222-2222-4222-8222-222222222222",
543
+ status: "completed",
544
+ playbook_id: playbook.id,
545
+ playbook_slug: playbook.slug,
546
+ playbook_version: 1,
547
+ budget_usd: 5,
548
+ quoted_cost_usd: 1.25,
549
+ charged_usd: 1.25,
550
+ refunded_usd: 0,
551
+ remaining_budget_usd: 3.75,
552
+ steps: [{
553
+ playbook_step_id: "ads",
554
+ step_index: 0,
555
+ node_type: "aw_agent",
556
+ agent_slug: "ad-strategy-intel",
557
+ agent_id: "11111111-1111-4111-8111-111111111111",
558
+ provider_id: null,
559
+ job_id: "33333333-3333-4333-8333-333333333333",
560
+ status: "succeeded",
561
+ quoted_cost_usd: 1.25,
562
+ charged_usd: 1.25,
563
+ refunded_usd: 0,
564
+ }],
565
+ });
566
+ mockApiPost.mockImplementation(async (path) => {
567
+ if (path === "/playbook-runs") {
568
+ return {
569
+ run_id: "22222222-2222-4222-8222-222222222222",
570
+ steps: playbook.current_quote.steps,
571
+ };
572
+ }
573
+ if (path === "/agents/11111111-1111-4111-8111-111111111111/run") {
574
+ throw {
575
+ status: 402,
576
+ message: "payment required",
577
+ headers: new Headers({ "www-authenticate": "Payment challenge" }),
578
+ };
579
+ }
580
+ return { ok: true };
581
+ });
582
+ mockApiPostWithApprovedLinkSpendRequest.mockResolvedValueOnce({
583
+ status: "success",
584
+ job_id: "33333333-3333-4333-8333-333333333333",
585
+ agent_id: "11111111-1111-4111-8111-111111111111",
586
+ output: { rows: 3 },
587
+ cost: 1.25,
588
+ });
589
+ const { registerPlaybookTools } = await import("../playbooks.js");
590
+ const harness = makeServerHarness();
591
+ registerPlaybookTools(harness.server);
592
+ const result = await harness.handlers.get("run_playbook")({
593
+ slug: "competitor-ads",
594
+ input: { domain: "notion.so" },
595
+ budget: 5,
596
+ pay_with: "link",
597
+ confirmed: true,
598
+ });
599
+ const text = flattenToolText(result);
600
+ expect(mockDecodeMppNetworkId).toHaveBeenCalledWith("Payment challenge");
601
+ expect(mockEnsureApprovedLinkSpendRequest).toHaveBeenCalledWith(expect.objectContaining({
602
+ amount: "500",
603
+ currency: "usd",
604
+ networkId: "profile_test",
605
+ paymentMethodId: "csmrpd_test_123",
606
+ }));
607
+ expect(mockApiPostWithApprovedLinkSpendRequest).toHaveBeenCalledWith("/agents/11111111-1111-4111-8111-111111111111/run", expect.objectContaining({
608
+ input: expect.objectContaining({
609
+ startUrls: [{ url: "https://notion.so" }],
610
+ }),
611
+ }), "lsrq_test_123");
612
+ expect(mockApiPostWithPayment).not.toHaveBeenCalled();
613
+ expect(mockSetPendingLinkSpendRequest).toHaveBeenCalledWith(null);
614
+ expect(text).toContain("Playbook run 22222222-2222-4222-8222-222222222222");
615
+ });
616
+ it("uploads local file inputs before run creation and child execution", async () => {
617
+ mockRequiresSpendConfirmation.mockReturnValue(false);
618
+ mockUploadLocalFiles.mockResolvedValueOnce({
619
+ input: { domain: "https://files.example/notion.txt" },
620
+ uploads: [{
621
+ field: "domain",
622
+ originalPath: "/tmp/notion.txt",
623
+ url: "https://files.example/notion.txt",
624
+ }],
625
+ });
626
+ mockApiGet
627
+ .mockResolvedValueOnce(playbook)
628
+ .mockResolvedValueOnce({
629
+ run_id: "22222222-2222-4222-8222-222222222222",
630
+ status: "completed",
631
+ playbook_id: playbook.id,
632
+ playbook_slug: playbook.slug,
633
+ playbook_version: 1,
634
+ budget_usd: 5,
635
+ quoted_cost_usd: 1.25,
636
+ charged_usd: 1.25,
637
+ refunded_usd: 0,
638
+ remaining_budget_usd: 3.75,
639
+ steps: [{
640
+ playbook_step_id: "ads",
641
+ step_index: 0,
642
+ node_type: "aw_agent",
643
+ agent_slug: "ad-strategy-intel",
644
+ agent_id: "11111111-1111-4111-8111-111111111111",
645
+ provider_id: null,
646
+ job_id: "33333333-3333-4333-8333-333333333333",
647
+ status: "succeeded",
648
+ quoted_cost_usd: 1.25,
649
+ charged_usd: 1.25,
650
+ refunded_usd: 0,
651
+ }],
652
+ });
653
+ mockApiPost.mockImplementation(async (path) => {
654
+ if (path === "/playbook-runs") {
655
+ return {
656
+ run_id: "22222222-2222-4222-8222-222222222222",
657
+ steps: playbook.current_quote.steps,
658
+ };
659
+ }
660
+ if (path === "/agents/11111111-1111-4111-8111-111111111111/run") {
661
+ throw { status: 402, message: "payment required" };
662
+ }
663
+ return { ok: true };
664
+ });
665
+ mockApiPostWithPayment.mockResolvedValueOnce({
666
+ status: "success",
667
+ job_id: "33333333-3333-4333-8333-333333333333",
668
+ agent_id: "11111111-1111-4111-8111-111111111111",
669
+ output: { rows: 3 },
670
+ cost: 1.25,
671
+ });
672
+ const { registerPlaybookTools } = await import("../playbooks.js");
673
+ const harness = makeServerHarness();
674
+ registerPlaybookTools(harness.server);
675
+ const result = await harness.handlers.get("run_playbook")({
676
+ slug: "competitor-ads",
677
+ input: { domain: "/tmp/notion.txt" },
678
+ budget: 5,
679
+ pay_with: "base",
680
+ confirmed: true,
681
+ });
682
+ const text = flattenToolText(result);
683
+ expect(mockUploadLocalFiles).toHaveBeenCalledWith({ domain: "/tmp/notion.txt" });
684
+ expect(mockApiPost).toHaveBeenCalledWith("/playbook-runs", expect.objectContaining({
685
+ input: { domain: "https://files.example/notion.txt" },
686
+ }));
687
+ expect(mockApiPostWithPayment).toHaveBeenCalledWith("/agents/11111111-1111-4111-8111-111111111111/run", expect.objectContaining({
688
+ input: expect.objectContaining({
689
+ domain: "https://files.example/notion.txt",
690
+ }),
691
+ }), "base");
692
+ expect(text).toContain("Uploaded domain: /tmp/notion.txt -> https://files.example/notion.txt");
693
+ });
694
+ it("records credit-pack child consumption distinctly from direct charges", async () => {
695
+ mockRequiresSpendConfirmation.mockReturnValue(false);
696
+ mockApiGet
697
+ .mockResolvedValueOnce(playbook)
698
+ .mockResolvedValueOnce({
699
+ run_id: "22222222-2222-4222-8222-222222222222",
700
+ status: "completed",
701
+ playbook_id: playbook.id,
702
+ playbook_slug: playbook.slug,
703
+ playbook_version: 1,
704
+ budget_usd: 5,
705
+ quoted_cost_usd: 1.25,
706
+ charged_usd: 0,
707
+ refunded_usd: 0,
708
+ remaining_budget_usd: 5,
709
+ steps: [
710
+ {
711
+ playbook_step_id: "ads",
712
+ step_index: 0,
713
+ node_type: "aw_agent",
714
+ agent_slug: "ad-strategy-intel",
715
+ agent_id: "11111111-1111-4111-8111-111111111111",
716
+ provider_id: null,
717
+ job_id: "33333333-3333-4333-8333-333333333333",
718
+ consumption_mode: "credit_pack",
719
+ status: "succeeded",
720
+ quoted_cost_usd: 1.25,
721
+ charged_usd: 0,
722
+ refunded_usd: 0,
723
+ },
724
+ ],
725
+ });
726
+ mockApiPost.mockImplementation(async (path) => {
727
+ if (path === "/playbook-runs") {
728
+ return {
729
+ run_id: "22222222-2222-4222-8222-222222222222",
730
+ steps: playbook.current_quote.steps,
731
+ };
732
+ }
733
+ if (path === "/agents/11111111-1111-4111-8111-111111111111/run") {
734
+ return {
735
+ status: "success",
736
+ job_id: "33333333-3333-4333-8333-333333333333",
737
+ agent_id: "11111111-1111-4111-8111-111111111111",
738
+ output: { rows: 3 },
739
+ cost: 1.25,
740
+ consumption_mode: "credit_pack",
741
+ };
742
+ }
743
+ return { ok: true };
744
+ });
745
+ const { registerPlaybookTools } = await import("../playbooks.js");
746
+ const harness = makeServerHarness();
747
+ registerPlaybookTools(harness.server);
748
+ const result = await harness.handlers.get("run_playbook")({
749
+ slug: "competitor-ads",
750
+ input: { domain: "notion.so" },
751
+ budget: 5,
752
+ pay_with: "card",
753
+ confirmed: true,
754
+ });
755
+ const text = flattenToolText(result);
756
+ expect(mockApiPostWithPayment).not.toHaveBeenCalled();
757
+ expect(mockRecordSpend).not.toHaveBeenCalled();
758
+ expect(mockApiPost).toHaveBeenCalledWith("/playbook-runs/22222222-2222-4222-8222-222222222222/steps/ads", expect.objectContaining({
759
+ status: "succeeded",
760
+ consumption_mode: "credit_pack",
761
+ charged_usd: 0,
762
+ }));
763
+ expect(text).toContain("Charged: $0.00");
764
+ expect(text).toContain("credit pack");
765
+ });
766
+ it("URL-encodes fanout step ids when recording receipts", async () => {
767
+ mockRequiresSpendConfirmation.mockReturnValue(false);
768
+ const fanoutPlaybook = {
769
+ ...playbook,
770
+ current_quote: {
771
+ ...playbook.current_quote,
772
+ steps: [{
773
+ ...playbook.current_quote.steps[0],
774
+ id: "people:1",
775
+ }],
776
+ },
777
+ };
778
+ mockApiGet
779
+ .mockResolvedValueOnce(fanoutPlaybook)
780
+ .mockResolvedValueOnce({
781
+ run_id: "22222222-2222-4222-8222-222222222222",
782
+ status: "completed",
783
+ playbook_id: playbook.id,
784
+ playbook_slug: playbook.slug,
785
+ playbook_version: 1,
786
+ budget_usd: 5,
787
+ quoted_cost_usd: 1.25,
788
+ charged_usd: 1.25,
789
+ refunded_usd: 0,
790
+ remaining_budget_usd: 3.75,
791
+ steps: [{
792
+ playbook_step_id: "people:1",
793
+ step_index: 0,
794
+ node_type: "aw_agent",
795
+ agent_slug: "ad-strategy-intel",
796
+ agent_id: "11111111-1111-4111-8111-111111111111",
797
+ provider_id: null,
798
+ job_id: "33333333-3333-4333-8333-333333333333",
799
+ consumption_mode: "direct_charge",
800
+ status: "succeeded",
801
+ quoted_cost_usd: 1.25,
802
+ charged_usd: 1.25,
803
+ refunded_usd: 0,
804
+ }],
805
+ });
806
+ mockApiPost.mockImplementation(async (path) => {
807
+ if (path === "/playbook-runs") {
808
+ return {
809
+ run_id: "22222222-2222-4222-8222-222222222222",
810
+ steps: fanoutPlaybook.current_quote.steps,
811
+ };
812
+ }
813
+ if (path === "/agents/11111111-1111-4111-8111-111111111111/run") {
814
+ return {
815
+ status: "success",
816
+ job_id: "33333333-3333-4333-8333-333333333333",
817
+ agent_id: "11111111-1111-4111-8111-111111111111",
818
+ output: { rows: 3 },
819
+ cost: 1.25,
820
+ };
821
+ }
822
+ return { ok: true };
823
+ });
824
+ const { registerPlaybookTools } = await import("../playbooks.js");
825
+ const harness = makeServerHarness();
826
+ registerPlaybookTools(harness.server);
827
+ await harness.handlers.get("run_playbook")({
828
+ slug: "competitor-ads",
829
+ input: { domain: "notion.so" },
830
+ budget: 5,
831
+ pay_with: "base",
832
+ confirmed: true,
833
+ });
834
+ expect(mockApiPost).toHaveBeenCalledWith("/playbook-runs/22222222-2222-4222-8222-222222222222/steps/people%3A1", expect.objectContaining({ status: "running" }));
835
+ expect(mockApiPost).toHaveBeenCalledWith("/agents/11111111-1111-4111-8111-111111111111/run", expect.objectContaining({
836
+ playbook_context: expect.objectContaining({ playbook_step_id: "people:1" }),
837
+ }), expect.objectContaining({ ensureConsumerPrincipal: true }));
838
+ expect(mockApiPost).toHaveBeenCalledWith("/playbook-runs/22222222-2222-4222-8222-222222222222/steps/people%3A1", expect.objectContaining({ status: "succeeded" }));
839
+ });
840
+ it("resumes an existing run without creating a new run or rerunning completed steps", async () => {
841
+ mockRequiresSpendConfirmation.mockReturnValue(false);
842
+ const twoStepPlaybook = {
843
+ ...playbook,
844
+ current_quote: {
845
+ ...playbook.current_quote,
846
+ estimated_cost_usd: 2,
847
+ step_count: 2,
848
+ steps: [
849
+ playbook.current_quote.steps[0],
850
+ {
851
+ ...playbook.current_quote.steps[0],
852
+ id: "traffic",
853
+ index: 1,
854
+ agent_slug: "traffic-estimator",
855
+ agent_id: "44444444-4444-4444-8444-444444444444",
856
+ agent_name: "Traffic Estimator",
857
+ quoted_cost_usd: 0.75,
858
+ unit_price_usd: 0.75,
859
+ },
860
+ ],
861
+ },
862
+ };
863
+ mockApiGet
864
+ .mockResolvedValueOnce({
865
+ run_id: "22222222-2222-4222-8222-222222222222",
866
+ status: "paused",
867
+ playbook_id: playbook.id,
868
+ playbook_slug: playbook.slug,
869
+ playbook_version: 1,
870
+ input: { domain: "notion.so" },
871
+ budget_usd: 5,
872
+ quoted_cost_usd: 2,
873
+ charged_usd: 1.25,
874
+ refunded_usd: 0,
875
+ remaining_budget_usd: 3.75,
876
+ steps: [
877
+ {
878
+ playbook_step_id: "ads",
879
+ step_index: 0,
880
+ node_type: "aw_agent",
881
+ agent_slug: "ad-strategy-intel",
882
+ agent_id: "11111111-1111-4111-8111-111111111111",
883
+ provider_id: null,
884
+ job_id: "33333333-3333-4333-8333-333333333333",
885
+ status: "succeeded",
886
+ quoted_cost_usd: 1.25,
887
+ charged_usd: 1.25,
888
+ refunded_usd: 0,
889
+ output: { ads: 3 },
890
+ },
891
+ {
892
+ playbook_step_id: "traffic",
893
+ step_index: 1,
894
+ node_type: "aw_agent",
895
+ agent_slug: "traffic-estimator",
896
+ agent_id: "44444444-4444-4444-8444-444444444444",
897
+ provider_id: null,
898
+ job_id: null,
899
+ status: "pending",
900
+ quoted_cost_usd: 0.75,
901
+ charged_usd: 0,
902
+ refunded_usd: 0,
903
+ },
904
+ ],
905
+ })
906
+ .mockResolvedValueOnce(twoStepPlaybook)
907
+ .mockResolvedValueOnce({
908
+ run_id: "22222222-2222-4222-8222-222222222222",
909
+ status: "completed",
910
+ playbook_id: playbook.id,
911
+ playbook_slug: playbook.slug,
912
+ playbook_version: 1,
913
+ budget_usd: 5,
914
+ quoted_cost_usd: 2,
915
+ charged_usd: 2,
916
+ refunded_usd: 0,
917
+ remaining_budget_usd: 3,
918
+ steps: [
919
+ {
920
+ playbook_step_id: "ads",
921
+ step_index: 0,
922
+ node_type: "aw_agent",
923
+ agent_slug: "ad-strategy-intel",
924
+ agent_id: "11111111-1111-4111-8111-111111111111",
925
+ provider_id: null,
926
+ job_id: "33333333-3333-4333-8333-333333333333",
927
+ status: "succeeded",
928
+ quoted_cost_usd: 1.25,
929
+ charged_usd: 1.25,
930
+ refunded_usd: 0,
931
+ },
932
+ {
933
+ playbook_step_id: "traffic",
934
+ step_index: 1,
935
+ node_type: "aw_agent",
936
+ agent_slug: "traffic-estimator",
937
+ agent_id: "44444444-4444-4444-8444-444444444444",
938
+ provider_id: null,
939
+ job_id: "55555555-5555-4555-8555-555555555555",
940
+ status: "succeeded",
941
+ quoted_cost_usd: 0.75,
942
+ charged_usd: 0.75,
943
+ refunded_usd: 0,
944
+ },
945
+ ],
946
+ });
947
+ mockApiPost.mockImplementation(async (path) => {
948
+ if (path === "/playbook-runs") {
949
+ throw new Error("resume should not create a new run");
950
+ }
951
+ if (path === "/agents/44444444-4444-4444-8444-444444444444/run") {
952
+ throw { status: 402, message: "payment required" };
953
+ }
954
+ return { ok: true };
955
+ });
956
+ mockApiPostWithPayment.mockResolvedValueOnce({
957
+ status: "success",
958
+ job_id: "55555555-5555-4555-8555-555555555555",
959
+ agent_id: "44444444-4444-4444-8444-444444444444",
960
+ output: { traffic: "ok" },
961
+ cost: 0.75,
962
+ });
963
+ const { registerPlaybookTools } = await import("../playbooks.js");
964
+ const harness = makeServerHarness();
965
+ registerPlaybookTools(harness.server);
966
+ const result = await harness.handlers.get("run_playbook")({
967
+ resume_run_id: "22222222-2222-4222-8222-222222222222",
968
+ budget: 5,
969
+ pay_with: "card",
970
+ confirmed: true,
971
+ });
972
+ const text = flattenToolText(result);
973
+ expect(mockApiPost).not.toHaveBeenCalledWith("/playbook-runs", expect.anything());
974
+ expect(mockApiPost).not.toHaveBeenCalledWith("/agents/11111111-1111-4111-8111-111111111111/run", expect.anything(), expect.anything());
975
+ expect(mockApiPostWithPayment).toHaveBeenCalledTimes(1);
976
+ expect(mockApiPostWithPayment).toHaveBeenCalledWith("/agents/44444444-4444-4444-8444-444444444444/run", expect.objectContaining({
977
+ playbook_context: expect.objectContaining({
978
+ playbook_run_id: "22222222-2222-4222-8222-222222222222",
979
+ playbook_step_id: "traffic",
980
+ }),
981
+ }), "card");
982
+ expect(text).toContain("completed");
983
+ expect(text).toContain("traffic-estimator");
984
+ });
985
+ it("uses net charged amount after refunds when enforcing resume budget", async () => {
986
+ mockRequiresSpendConfirmation.mockReturnValue(false);
987
+ mockApiGet
988
+ .mockResolvedValueOnce({
989
+ run_id: "22222222-2222-4222-8222-222222222222",
990
+ status: "paused",
991
+ playbook_id: playbook.id,
992
+ playbook_slug: playbook.slug,
993
+ playbook_version: 1,
994
+ input: { domain: "notion.so" },
995
+ budget_usd: 1.25,
996
+ quoted_cost_usd: 1.25,
997
+ charged_usd: 1.25,
998
+ refunded_usd: 1.25,
999
+ remaining_budget_usd: 1.25,
1000
+ error_code: "UPSTREAM_FAILED",
1001
+ steps: [{
1002
+ playbook_step_id: "ads",
1003
+ step_index: 0,
1004
+ node_type: "aw_agent",
1005
+ agent_slug: "ad-strategy-intel",
1006
+ agent_id: "11111111-1111-4111-8111-111111111111",
1007
+ provider_id: null,
1008
+ job_id: "33333333-3333-4333-8333-333333333333",
1009
+ status: "failed",
1010
+ quoted_cost_usd: 1.25,
1011
+ charged_usd: 1.25,
1012
+ refunded_usd: 1.25,
1013
+ error_code: "UPSTREAM_FAILED",
1014
+ }],
1015
+ })
1016
+ .mockResolvedValueOnce(playbook)
1017
+ .mockResolvedValueOnce({
1018
+ run_id: "22222222-2222-4222-8222-222222222222",
1019
+ status: "completed",
1020
+ playbook_id: playbook.id,
1021
+ playbook_slug: playbook.slug,
1022
+ playbook_version: 1,
1023
+ budget_usd: 1.25,
1024
+ quoted_cost_usd: 1.25,
1025
+ charged_usd: 2.5,
1026
+ refunded_usd: 1.25,
1027
+ remaining_budget_usd: 0,
1028
+ steps: [{
1029
+ playbook_step_id: "ads",
1030
+ step_index: 0,
1031
+ node_type: "aw_agent",
1032
+ agent_slug: "ad-strategy-intel",
1033
+ agent_id: "11111111-1111-4111-8111-111111111111",
1034
+ provider_id: null,
1035
+ job_id: "55555555-5555-4555-8555-555555555555",
1036
+ status: "succeeded",
1037
+ quoted_cost_usd: 1.25,
1038
+ charged_usd: 1.25,
1039
+ refunded_usd: 0,
1040
+ }],
1041
+ });
1042
+ mockApiPost.mockImplementation(async (path) => {
1043
+ if (path === "/playbook-runs") {
1044
+ throw new Error("resume should not create a new run");
1045
+ }
1046
+ if (path === "/agents/11111111-1111-4111-8111-111111111111/run") {
1047
+ throw { status: 402, message: "payment required" };
1048
+ }
1049
+ return { ok: true };
1050
+ });
1051
+ mockApiPostWithPayment.mockResolvedValueOnce({
1052
+ status: "success",
1053
+ job_id: "55555555-5555-4555-8555-555555555555",
1054
+ agent_id: "11111111-1111-4111-8111-111111111111",
1055
+ output: { retry: "ok" },
1056
+ cost: 1.25,
1057
+ });
1058
+ const { registerPlaybookTools } = await import("../playbooks.js");
1059
+ const harness = makeServerHarness();
1060
+ registerPlaybookTools(harness.server);
1061
+ const result = await harness.handlers.get("run_playbook")({
1062
+ resume_run_id: "22222222-2222-4222-8222-222222222222",
1063
+ budget: 1.25,
1064
+ pay_with: "card",
1065
+ confirmed: true,
1066
+ });
1067
+ const text = flattenToolText(result);
1068
+ expect(mockApiPostWithPayment).toHaveBeenCalledWith("/agents/11111111-1111-4111-8111-111111111111/run", expect.anything(), "card");
1069
+ expect(mockApiPost).not.toHaveBeenCalledWith("/playbook-runs/22222222-2222-4222-8222-222222222222/steps/ads", expect.objectContaining({
1070
+ status: "skipped",
1071
+ error_code: "BUDGET_EXHAUSTED",
1072
+ }));
1073
+ expect(text).toContain("completed");
1074
+ });
1075
+ it("pauses without another payment when the next step would exceed the budget", async () => {
1076
+ mockRequiresSpendConfirmation.mockReturnValue(false);
1077
+ const twoStepPlaybook = {
1078
+ ...playbook,
1079
+ current_quote: {
1080
+ ...playbook.current_quote,
1081
+ estimated_cost_usd: 2,
1082
+ step_count: 2,
1083
+ steps: [
1084
+ playbook.current_quote.steps[0],
1085
+ {
1086
+ ...playbook.current_quote.steps[0],
1087
+ id: "traffic",
1088
+ index: 1,
1089
+ agent_slug: "traffic-estimator",
1090
+ agent_id: "44444444-4444-4444-8444-444444444444",
1091
+ agent_name: "Traffic Estimator",
1092
+ quoted_cost_usd: 1,
1093
+ unit_price_usd: 1,
1094
+ },
1095
+ ],
1096
+ },
1097
+ };
1098
+ mockApiGet
1099
+ .mockResolvedValueOnce(twoStepPlaybook)
1100
+ .mockResolvedValueOnce({
1101
+ run_id: "22222222-2222-4222-8222-222222222222",
1102
+ status: "paused",
1103
+ playbook_id: playbook.id,
1104
+ playbook_slug: playbook.slug,
1105
+ playbook_version: 1,
1106
+ budget_usd: 2.1,
1107
+ quoted_cost_usd: 2,
1108
+ charged_usd: 1.5,
1109
+ refunded_usd: 0,
1110
+ remaining_budget_usd: 0.6,
1111
+ error_code: "BUDGET_EXHAUSTED",
1112
+ steps: [
1113
+ {
1114
+ playbook_step_id: "ads",
1115
+ step_index: 0,
1116
+ node_type: "aw_agent",
1117
+ agent_slug: "ad-strategy-intel",
1118
+ agent_id: "11111111-1111-4111-8111-111111111111",
1119
+ provider_id: null,
1120
+ job_id: "33333333-3333-4333-8333-333333333333",
1121
+ status: "succeeded",
1122
+ quoted_cost_usd: 1,
1123
+ charged_usd: 1.5,
1124
+ refunded_usd: 0,
1125
+ },
1126
+ {
1127
+ playbook_step_id: "traffic",
1128
+ step_index: 1,
1129
+ node_type: "aw_agent",
1130
+ agent_slug: "traffic-estimator",
1131
+ agent_id: "44444444-4444-4444-8444-444444444444",
1132
+ provider_id: null,
1133
+ job_id: null,
1134
+ status: "skipped",
1135
+ quoted_cost_usd: 1,
1136
+ charged_usd: 0,
1137
+ refunded_usd: 0,
1138
+ error_code: "BUDGET_EXHAUSTED",
1139
+ },
1140
+ ],
1141
+ });
1142
+ mockApiPost.mockImplementation(async (path) => {
1143
+ if (path === "/playbook-runs") {
1144
+ return {
1145
+ run_id: "22222222-2222-4222-8222-222222222222",
1146
+ steps: twoStepPlaybook.current_quote.steps,
1147
+ };
1148
+ }
1149
+ if (path === "/agents/11111111-1111-4111-8111-111111111111/run") {
1150
+ throw { status: 402, message: "payment required" };
1151
+ }
1152
+ return { ok: true };
1153
+ });
1154
+ mockApiPostWithPayment.mockResolvedValueOnce({
1155
+ status: "success",
1156
+ job_id: "33333333-3333-4333-8333-333333333333",
1157
+ agent_id: "11111111-1111-4111-8111-111111111111",
1158
+ output: { ads: 3 },
1159
+ cost: 1.5,
1160
+ });
1161
+ const { registerPlaybookTools } = await import("../playbooks.js");
1162
+ const harness = makeServerHarness();
1163
+ registerPlaybookTools(harness.server);
1164
+ const result = await harness.handlers.get("run_playbook")({
1165
+ slug: "competitor-ads",
1166
+ input: { domain: "notion.so" },
1167
+ budget: 2.1,
1168
+ pay_with: "card",
1169
+ confirmed: true,
1170
+ });
1171
+ const text = flattenToolText(result);
1172
+ expect(mockApiPostWithPayment).toHaveBeenCalledTimes(1);
1173
+ expect(mockApiPost).toHaveBeenCalledWith("/playbook-runs/22222222-2222-4222-8222-222222222222/steps/traffic", expect.objectContaining({
1174
+ status: "skipped",
1175
+ consumption_mode: "not_charged",
1176
+ error_code: "BUDGET_EXHAUSTED",
1177
+ }));
1178
+ expect(text).toContain("BUDGET_EXHAUSTED");
1179
+ expect(text).toContain("Resume:");
1180
+ });
1181
+ it("records payment rejection as an uncharged skipped step", async () => {
1182
+ mockRequiresSpendConfirmation.mockReturnValue(false);
1183
+ mockApiGet
1184
+ .mockResolvedValueOnce(playbook)
1185
+ .mockResolvedValueOnce({
1186
+ run_id: "22222222-2222-4222-8222-222222222222",
1187
+ status: "paused",
1188
+ playbook_id: playbook.id,
1189
+ playbook_slug: playbook.slug,
1190
+ playbook_version: 1,
1191
+ budget_usd: 5,
1192
+ quoted_cost_usd: 1.25,
1193
+ charged_usd: 0,
1194
+ refunded_usd: 0,
1195
+ remaining_budget_usd: 5,
1196
+ error_code: "PAYMENT_REJECTED",
1197
+ steps: [{
1198
+ playbook_step_id: "ads",
1199
+ step_index: 0,
1200
+ node_type: "aw_agent",
1201
+ agent_slug: "ad-strategy-intel",
1202
+ agent_id: "11111111-1111-4111-8111-111111111111",
1203
+ provider_id: null,
1204
+ job_id: null,
1205
+ status: "skipped",
1206
+ quoted_cost_usd: 1.25,
1207
+ charged_usd: 0,
1208
+ refunded_usd: 0,
1209
+ error_code: "PAYMENT_REJECTED",
1210
+ }],
1211
+ });
1212
+ mockApiPost.mockImplementation(async (path) => {
1213
+ if (path === "/playbook-runs") {
1214
+ return { run_id: "22222222-2222-4222-8222-222222222222", steps: playbook.current_quote.steps };
1215
+ }
1216
+ if (path === "/agents/11111111-1111-4111-8111-111111111111/run") {
1217
+ throw { status: 402, message: "payment required" };
1218
+ }
1219
+ return { ok: true };
1220
+ });
1221
+ mockApiPostWithPayment.mockRejectedValueOnce({ status: 402, message: "wallet declined" });
1222
+ const { registerPlaybookTools } = await import("../playbooks.js");
1223
+ const harness = makeServerHarness();
1224
+ registerPlaybookTools(harness.server);
1225
+ const result = await harness.handlers.get("run_playbook")({
1226
+ slug: "competitor-ads",
1227
+ input: { domain: "notion.so" },
1228
+ budget: 5,
1229
+ pay_with: "card",
1230
+ confirmed: true,
1231
+ });
1232
+ const text = flattenToolText(result);
1233
+ expect(mockRecordSpend).not.toHaveBeenCalled();
1234
+ expect(mockApiPost).toHaveBeenCalledWith("/playbook-runs/22222222-2222-4222-8222-222222222222/steps/ads", expect.objectContaining({
1235
+ status: "skipped",
1236
+ consumption_mode: "not_charged",
1237
+ error_code: "PAYMENT_REJECTED",
1238
+ }));
1239
+ expect(text).toContain("wallet_status");
1240
+ });
1241
+ it("records schema mismatch before payment as an uncharged skipped step", async () => {
1242
+ mockRequiresSpendConfirmation.mockReturnValue(false);
1243
+ mockApiGet
1244
+ .mockResolvedValueOnce(playbook)
1245
+ .mockResolvedValueOnce({
1246
+ id: "11111111-1111-4111-8111-111111111111",
1247
+ schema: {
1248
+ input: {
1249
+ inputSchema: {
1250
+ type: "object",
1251
+ properties: { prompt: { type: "string" } },
1252
+ required: ["prompt"],
1253
+ },
1254
+ },
1255
+ },
1256
+ })
1257
+ .mockResolvedValueOnce({
1258
+ run_id: "22222222-2222-4222-8222-222222222222",
1259
+ status: "paused",
1260
+ playbook_id: playbook.id,
1261
+ playbook_slug: playbook.slug,
1262
+ playbook_version: 1,
1263
+ budget_usd: 5,
1264
+ quoted_cost_usd: 1.25,
1265
+ charged_usd: 0,
1266
+ refunded_usd: 0,
1267
+ remaining_budget_usd: 5,
1268
+ error_code: "SCHEMA_MISMATCH",
1269
+ steps: [{
1270
+ playbook_step_id: "ads",
1271
+ step_index: 0,
1272
+ node_type: "aw_agent",
1273
+ agent_slug: "ad-strategy-intel",
1274
+ agent_id: "11111111-1111-4111-8111-111111111111",
1275
+ provider_id: null,
1276
+ job_id: null,
1277
+ status: "skipped",
1278
+ quoted_cost_usd: 1.25,
1279
+ charged_usd: 0,
1280
+ refunded_usd: 0,
1281
+ error_code: "SCHEMA_MISMATCH",
1282
+ }],
1283
+ });
1284
+ mockApiPost.mockImplementation(async (path) => {
1285
+ if (path === "/playbook-runs") {
1286
+ return { run_id: "22222222-2222-4222-8222-222222222222", steps: playbook.current_quote.steps };
1287
+ }
1288
+ if (path === "/agents/11111111-1111-4111-8111-111111111111/run") {
1289
+ throw { status: 400, message: "validation failed: missing required field prompt" };
1290
+ }
1291
+ return { ok: true };
1292
+ });
1293
+ const { registerPlaybookTools } = await import("../playbooks.js");
1294
+ const harness = makeServerHarness();
1295
+ registerPlaybookTools(harness.server);
1296
+ const result = await harness.handlers.get("run_playbook")({
1297
+ slug: "competitor-ads",
1298
+ input: { domain: "notion.so" },
1299
+ budget: 5,
1300
+ pay_with: "card",
1301
+ confirmed: true,
1302
+ });
1303
+ const text = flattenToolText(result);
1304
+ expect(mockApiPostWithPayment).not.toHaveBeenCalled();
1305
+ expect(mockApiPost).toHaveBeenCalledWith("/playbook-runs/22222222-2222-4222-8222-222222222222/steps/ads", expect.objectContaining({
1306
+ status: "skipped",
1307
+ consumption_mode: "not_charged",
1308
+ error_code: "SCHEMA_MISMATCH",
1309
+ }));
1310
+ expect(text).toContain("get_agent");
1311
+ });
1312
+ it("uses live child schema metadata to safely repair a field name before payment", async () => {
1313
+ mockRequiresSpendConfirmation.mockReturnValue(false);
1314
+ mockApiGet
1315
+ .mockResolvedValueOnce(playbook)
1316
+ .mockResolvedValueOnce({
1317
+ id: "11111111-1111-4111-8111-111111111111",
1318
+ schema: {
1319
+ input: {
1320
+ inputSchema: {
1321
+ type: "object",
1322
+ properties: { q: { type: "string", description: "Search query" } },
1323
+ required: ["q"],
1324
+ },
1325
+ },
1326
+ },
1327
+ })
1328
+ .mockResolvedValueOnce({
1329
+ run_id: "22222222-2222-4222-8222-222222222222",
1330
+ status: "completed",
1331
+ playbook_id: playbook.id,
1332
+ playbook_slug: playbook.slug,
1333
+ playbook_version: 1,
1334
+ budget_usd: 5,
1335
+ quoted_cost_usd: 1.25,
1336
+ charged_usd: 1.25,
1337
+ refunded_usd: 0,
1338
+ remaining_budget_usd: 3.75,
1339
+ steps: [{
1340
+ playbook_step_id: "ads",
1341
+ step_index: 0,
1342
+ node_type: "aw_agent",
1343
+ agent_slug: "ad-strategy-intel",
1344
+ agent_id: "11111111-1111-4111-8111-111111111111",
1345
+ provider_id: null,
1346
+ job_id: "33333333-3333-4333-8333-333333333333",
1347
+ status: "succeeded",
1348
+ quoted_cost_usd: 1.25,
1349
+ charged_usd: 1.25,
1350
+ refunded_usd: 0,
1351
+ }],
1352
+ });
1353
+ let agentRunAttempts = 0;
1354
+ mockApiPost.mockImplementation(async (path, body) => {
1355
+ if (path === "/playbook-runs") {
1356
+ return { run_id: "22222222-2222-4222-8222-222222222222", steps: playbook.current_quote.steps };
1357
+ }
1358
+ if (path === "/agents/11111111-1111-4111-8111-111111111111/run") {
1359
+ agentRunAttempts += 1;
1360
+ if (agentRunAttempts === 1) {
1361
+ throw { status: 400, message: "validation failed: missing required field q" };
1362
+ }
1363
+ expect(body).toMatchObject({
1364
+ input: {
1365
+ q: "notion.so",
1366
+ },
1367
+ });
1368
+ expect(body.input).not.toHaveProperty("domain");
1369
+ return {
1370
+ status: "success",
1371
+ job_id: "33333333-3333-4333-8333-333333333333",
1372
+ output: { repaired: true },
1373
+ };
1374
+ }
1375
+ return { ok: true };
1376
+ });
1377
+ const { registerPlaybookTools } = await import("../playbooks.js");
1378
+ const harness = makeServerHarness();
1379
+ registerPlaybookTools(harness.server);
1380
+ const result = await harness.handlers.get("run_playbook")({
1381
+ slug: "competitor-ads",
1382
+ input: { domain: "notion.so" },
1383
+ budget: 5,
1384
+ pay_with: "card",
1385
+ confirmed: true,
1386
+ });
1387
+ const text = flattenToolText(result);
1388
+ expect(mockApiGet).toHaveBeenCalledWith("/agents/11111111-1111-4111-8111-111111111111");
1389
+ expect(agentRunAttempts).toBe(2);
1390
+ expect(mockApiPostWithPayment).not.toHaveBeenCalled();
1391
+ expect(mockApiPost).toHaveBeenCalledWith("/playbook-runs/22222222-2222-4222-8222-222222222222/steps/ads", expect.objectContaining({
1392
+ status: "succeeded",
1393
+ charged_usd: 1.25,
1394
+ }));
1395
+ expect(text).toContain("completed");
1396
+ });
1397
+ it("uses live child schema metadata to derive startUrls from a domain before payment", async () => {
1398
+ mockRequiresSpendConfirmation.mockReturnValue(false);
1399
+ mockApiGet
1400
+ .mockResolvedValueOnce(playbook)
1401
+ .mockResolvedValueOnce({
1402
+ id: "11111111-1111-4111-8111-111111111111",
1403
+ schema: {
1404
+ input: {
1405
+ inputSchema: {
1406
+ type: "object",
1407
+ properties: {
1408
+ startUrls: { type: "array", items: { type: "object" } },
1409
+ maxAds: { type: "number", default: 10 },
1410
+ },
1411
+ required: ["startUrls"],
1412
+ },
1413
+ },
1414
+ },
1415
+ })
1416
+ .mockResolvedValueOnce({
1417
+ run_id: "22222222-2222-4222-8222-222222222222",
1418
+ status: "completed",
1419
+ playbook_id: playbook.id,
1420
+ playbook_slug: playbook.slug,
1421
+ playbook_version: 1,
1422
+ budget_usd: 5,
1423
+ quoted_cost_usd: 1.25,
1424
+ charged_usd: 1.25,
1425
+ refunded_usd: 0,
1426
+ remaining_budget_usd: 3.75,
1427
+ steps: [{
1428
+ playbook_step_id: "ads",
1429
+ step_index: 0,
1430
+ node_type: "aw_agent",
1431
+ agent_slug: "ad-strategy-intel",
1432
+ agent_id: "11111111-1111-4111-8111-111111111111",
1433
+ provider_id: null,
1434
+ job_id: "33333333-3333-4333-8333-333333333333",
1435
+ status: "succeeded",
1436
+ quoted_cost_usd: 1.25,
1437
+ charged_usd: 1.25,
1438
+ refunded_usd: 0,
1439
+ }],
1440
+ });
1441
+ let agentRunAttempts = 0;
1442
+ mockApiPost.mockImplementation(async (path, body) => {
1443
+ if (path === "/playbook-runs") {
1444
+ return { run_id: "22222222-2222-4222-8222-222222222222", steps: playbook.current_quote.steps };
1445
+ }
1446
+ if (path === "/agents/11111111-1111-4111-8111-111111111111/run") {
1447
+ agentRunAttempts += 1;
1448
+ if (agentRunAttempts === 1) {
1449
+ throw { status: 400, message: "validation failed: missing required field startUrls; unknown field domain" };
1450
+ }
1451
+ expect(body).toMatchObject({
1452
+ input: {
1453
+ startUrls: [{ url: "https://notion.so" }],
1454
+ maxAds: 10,
1455
+ },
1456
+ });
1457
+ expect(body.input).not.toHaveProperty("domain");
1458
+ expect(body.input).not.toHaveProperty("platforms");
1459
+ return {
1460
+ status: "success",
1461
+ job_id: "33333333-3333-4333-8333-333333333333",
1462
+ output: { repaired: true },
1463
+ };
1464
+ }
1465
+ return { ok: true };
1466
+ });
1467
+ const { registerPlaybookTools } = await import("../playbooks.js");
1468
+ const harness = makeServerHarness();
1469
+ registerPlaybookTools(harness.server);
1470
+ const result = await harness.handlers.get("run_playbook")({
1471
+ slug: "competitor-ads",
1472
+ input: { domain: "notion.so" },
1473
+ budget: 5,
1474
+ pay_with: "card",
1475
+ confirmed: true,
1476
+ });
1477
+ const text = flattenToolText(result);
1478
+ expect(agentRunAttempts).toBe(2);
1479
+ expect(text).toContain("completed");
1480
+ });
1481
+ it("uses live child schema metadata to derive urls from a domain before payment", async () => {
1482
+ mockRequiresSpendConfirmation.mockReturnValue(false);
1483
+ mockApiGet
1484
+ .mockResolvedValueOnce(playbook)
1485
+ .mockResolvedValueOnce({
1486
+ id: "11111111-1111-4111-8111-111111111111",
1487
+ schema: {
1488
+ input: {
1489
+ inputSchema: {
1490
+ type: "object",
1491
+ properties: {
1492
+ urls: { type: "array", items: { type: "string" } },
1493
+ },
1494
+ required: ["urls"],
1495
+ },
1496
+ },
1497
+ },
1498
+ })
1499
+ .mockResolvedValueOnce({
1500
+ run_id: "22222222-2222-4222-8222-222222222222",
1501
+ status: "completed",
1502
+ playbook_id: playbook.id,
1503
+ playbook_slug: playbook.slug,
1504
+ playbook_version: 1,
1505
+ budget_usd: 5,
1506
+ quoted_cost_usd: 1.25,
1507
+ charged_usd: 1.25,
1508
+ refunded_usd: 0,
1509
+ remaining_budget_usd: 3.75,
1510
+ steps: [{
1511
+ playbook_step_id: "ads",
1512
+ step_index: 0,
1513
+ node_type: "aw_agent",
1514
+ agent_slug: "ad-strategy-intel",
1515
+ agent_id: "11111111-1111-4111-8111-111111111111",
1516
+ provider_id: null,
1517
+ job_id: "33333333-3333-4333-8333-333333333333",
1518
+ status: "succeeded",
1519
+ quoted_cost_usd: 1.25,
1520
+ charged_usd: 1.25,
1521
+ refunded_usd: 0,
1522
+ }],
1523
+ });
1524
+ let agentRunAttempts = 0;
1525
+ mockApiPost.mockImplementation(async (path, body) => {
1526
+ if (path === "/playbook-runs") {
1527
+ return { run_id: "22222222-2222-4222-8222-222222222222", steps: playbook.current_quote.steps };
1528
+ }
1529
+ if (path === "/agents/11111111-1111-4111-8111-111111111111/run") {
1530
+ agentRunAttempts += 1;
1531
+ if (agentRunAttempts === 1) {
1532
+ throw { status: 400, message: "validation failed: missing required field urls; unknown field domain" };
1533
+ }
1534
+ expect(body).toMatchObject({
1535
+ input: {
1536
+ urls: ["https://notion.so"],
1537
+ },
1538
+ });
1539
+ expect(body.input).not.toHaveProperty("domain");
1540
+ return {
1541
+ status: "success",
1542
+ job_id: "33333333-3333-4333-8333-333333333333",
1543
+ output: { repaired: true },
1544
+ };
1545
+ }
1546
+ return { ok: true };
1547
+ });
1548
+ const { registerPlaybookTools } = await import("../playbooks.js");
1549
+ const harness = makeServerHarness();
1550
+ registerPlaybookTools(harness.server);
1551
+ const result = await harness.handlers.get("run_playbook")({
1552
+ slug: "competitor-ads",
1553
+ input: { domain: "notion.so" },
1554
+ budget: 5,
1555
+ pay_with: "card",
1556
+ confirmed: true,
1557
+ });
1558
+ const text = flattenToolText(result);
1559
+ expect(agentRunAttempts).toBe(2);
1560
+ expect(text).toContain("completed");
1561
+ });
1562
+ it("uses live child schema metadata to coerce array fields into strings before payment", async () => {
1563
+ mockRequiresSpendConfirmation.mockReturnValue(false);
1564
+ const playbookWithArrayDefault = {
1565
+ ...playbook,
1566
+ current_quote: {
1567
+ ...playbook.current_quote,
1568
+ steps: playbook.current_quote.steps.map((step) => ({
1569
+ ...step,
1570
+ default_input: { queries: ["notion.so alternatives", "notion.so pricing"] },
1571
+ })),
1572
+ },
1573
+ };
1574
+ mockApiGet
1575
+ .mockResolvedValueOnce(playbookWithArrayDefault)
1576
+ .mockResolvedValueOnce({
1577
+ id: "11111111-1111-4111-8111-111111111111",
1578
+ schema: {
1579
+ input: {
1580
+ inputSchema: {
1581
+ type: "object",
1582
+ properties: {
1583
+ queries: { type: "string" },
1584
+ countryCode: { type: "string", default: "us" },
1585
+ },
1586
+ required: ["queries"],
1587
+ },
1588
+ },
1589
+ },
1590
+ })
1591
+ .mockResolvedValueOnce({
1592
+ run_id: "22222222-2222-4222-8222-222222222222",
1593
+ status: "completed",
1594
+ playbook_id: playbook.id,
1595
+ playbook_slug: playbook.slug,
1596
+ playbook_version: 1,
1597
+ budget_usd: 5,
1598
+ quoted_cost_usd: 1.25,
1599
+ charged_usd: 1.25,
1600
+ refunded_usd: 0,
1601
+ remaining_budget_usd: 3.75,
1602
+ steps: [{
1603
+ playbook_step_id: "ads",
1604
+ step_index: 0,
1605
+ node_type: "aw_agent",
1606
+ agent_slug: "ad-strategy-intel",
1607
+ agent_id: "11111111-1111-4111-8111-111111111111",
1608
+ provider_id: null,
1609
+ job_id: "33333333-3333-4333-8333-333333333333",
1610
+ status: "succeeded",
1611
+ quoted_cost_usd: 1.25,
1612
+ charged_usd: 1.25,
1613
+ refunded_usd: 0,
1614
+ }],
1615
+ });
1616
+ let agentRunAttempts = 0;
1617
+ mockApiPost.mockImplementation(async (path, body) => {
1618
+ if (path === "/playbook-runs") {
1619
+ return { run_id: "22222222-2222-4222-8222-222222222222", steps: playbookWithArrayDefault.current_quote.steps };
1620
+ }
1621
+ if (path === "/agents/11111111-1111-4111-8111-111111111111/run") {
1622
+ agentRunAttempts += 1;
1623
+ if (agentRunAttempts === 1) {
1624
+ throw { status: 400, message: "validation failed: queries expected string, got object" };
1625
+ }
1626
+ expect(body).toMatchObject({
1627
+ input: {
1628
+ queries: "notion.so alternatives; notion.so pricing",
1629
+ countryCode: "us",
1630
+ },
1631
+ });
1632
+ expect(body.input).not.toHaveProperty("domain");
1633
+ return {
1634
+ status: "success",
1635
+ job_id: "33333333-3333-4333-8333-333333333333",
1636
+ output: { repaired: true },
1637
+ };
1638
+ }
1639
+ return { ok: true };
1640
+ });
1641
+ const { registerPlaybookTools } = await import("../playbooks.js");
1642
+ const harness = makeServerHarness();
1643
+ registerPlaybookTools(harness.server);
1644
+ const result = await harness.handlers.get("run_playbook")({
1645
+ slug: "competitor-ads",
1646
+ input: { domain: "notion.so" },
1647
+ budget: 5,
1648
+ pay_with: "card",
1649
+ confirmed: true,
1650
+ });
1651
+ const text = flattenToolText(result);
1652
+ expect(agentRunAttempts).toBe(2);
1653
+ expect(text).toContain("completed");
1654
+ });
1655
+ it("uses company_name for company enrichment children", async () => {
1656
+ mockRequiresSpendConfirmation.mockReturnValue(false);
1657
+ mockUploadLocalFiles.mockResolvedValueOnce({ input: { leads: [{ company: "Kelvin" }] }, uploads: [] });
1658
+ const companyPlaybook = {
1659
+ ...playbook,
1660
+ input_schema: {
1661
+ type: "object",
1662
+ properties: { leads: { type: "array" } },
1663
+ required: ["leads"],
1664
+ },
1665
+ current_quote: {
1666
+ ...playbook.current_quote,
1667
+ steps: playbook.current_quote.steps.map((step) => ({
1668
+ ...step,
1669
+ agent_slug: "company-enrichment-deep",
1670
+ agent_name: "Company Enrichment",
1671
+ })),
1672
+ },
1673
+ };
1674
+ mockApiGet
1675
+ .mockResolvedValueOnce(companyPlaybook)
1676
+ .mockResolvedValueOnce({
1677
+ run_id: "22222222-2222-4222-8222-222222222222",
1678
+ status: "completed",
1679
+ playbook_id: playbook.id,
1680
+ playbook_slug: playbook.slug,
1681
+ playbook_version: 1,
1682
+ budget_usd: 5,
1683
+ quoted_cost_usd: 1.25,
1684
+ charged_usd: 1.25,
1685
+ refunded_usd: 0,
1686
+ remaining_budget_usd: 3.75,
1687
+ steps: [{
1688
+ playbook_step_id: "ads",
1689
+ step_index: 0,
1690
+ node_type: "aw_agent",
1691
+ agent_slug: "company-enrichment-deep",
1692
+ agent_id: "11111111-1111-4111-8111-111111111111",
1693
+ provider_id: null,
1694
+ job_id: "33333333-3333-4333-8333-333333333333",
1695
+ status: "succeeded",
1696
+ quoted_cost_usd: 1.25,
1697
+ charged_usd: 1.25,
1698
+ refunded_usd: 0,
1699
+ }],
1700
+ });
1701
+ mockApiPost.mockImplementation(async (path) => {
1702
+ if (path === "/playbook-runs") {
1703
+ return { run_id: "22222222-2222-4222-8222-222222222222", steps: companyPlaybook.current_quote.steps };
1704
+ }
1705
+ if (path === "/agents/11111111-1111-4111-8111-111111111111/run") {
1706
+ throw { status: 402, message: "payment required" };
1707
+ }
1708
+ return { ok: true };
1709
+ });
1710
+ mockApiPostWithPayment.mockResolvedValueOnce({
1711
+ status: "success",
1712
+ job_id: "33333333-3333-4333-8333-333333333333",
1713
+ agent_id: "11111111-1111-4111-8111-111111111111",
1714
+ output: { enriched: true },
1715
+ cost: 1.25,
1716
+ });
1717
+ const { registerPlaybookTools } = await import("../playbooks.js");
1718
+ const harness = makeServerHarness();
1719
+ registerPlaybookTools(harness.server);
1720
+ const result = await harness.handlers.get("run_playbook")({
1721
+ slug: "competitor-ads",
1722
+ input: { leads: [{ company: "Kelvin" }] },
1723
+ budget: 5,
1724
+ pay_with: "card",
1725
+ confirmed: true,
1726
+ });
1727
+ const text = flattenToolText(result);
1728
+ expect(mockApiPostWithPayment).toHaveBeenCalledWith("/agents/11111111-1111-4111-8111-111111111111/run", expect.objectContaining({
1729
+ input: expect.objectContaining({
1730
+ company: "Kelvin",
1731
+ company_name: "Kelvin",
1732
+ }),
1733
+ }), "card");
1734
+ expect(text).toContain("completed");
1735
+ });
1736
+ it("builds cold email council brief and email verification inputs from a lead", async () => {
1737
+ mockRequiresSpendConfirmation.mockReturnValue(false);
1738
+ mockUploadLocalFiles.mockResolvedValueOnce({ input: { leads: [{ name: "Dana Ko", company: "Kelvin", email: "dana@example.com" }] }, uploads: [] });
1739
+ const steps = [
1740
+ {
1741
+ ...playbook.current_quote.steps[0],
1742
+ id: "copy",
1743
+ index: 0,
1744
+ agent_slug: "cold-email-council",
1745
+ agent_name: "Cold Email Council",
1746
+ },
1747
+ {
1748
+ ...playbook.current_quote.steps[0],
1749
+ id: "verify",
1750
+ index: 1,
1751
+ agent_slug: "email-verification",
1752
+ agent_name: "Email Verification",
1753
+ },
1754
+ ];
1755
+ const coldPlaybook = {
1756
+ ...playbook,
1757
+ input_schema: {
1758
+ type: "object",
1759
+ properties: { leads: { type: "array" } },
1760
+ required: ["leads"],
1761
+ },
1762
+ current_quote: {
1763
+ ...playbook.current_quote,
1764
+ estimated_cost_usd: 2.5,
1765
+ step_count: 2,
1766
+ steps,
1767
+ },
1768
+ };
1769
+ mockApiGet
1770
+ .mockResolvedValueOnce(coldPlaybook)
1771
+ .mockResolvedValueOnce({
1772
+ run_id: "22222222-2222-4222-8222-222222222222",
1773
+ status: "completed",
1774
+ playbook_id: playbook.id,
1775
+ playbook_slug: playbook.slug,
1776
+ playbook_version: 1,
1777
+ budget_usd: 5,
1778
+ quoted_cost_usd: 2.5,
1779
+ charged_usd: 2.5,
1780
+ refunded_usd: 0,
1781
+ remaining_budget_usd: 2.5,
1782
+ steps: steps.map((step) => ({
1783
+ playbook_step_id: step.id,
1784
+ step_index: step.index,
1785
+ node_type: "aw_agent",
1786
+ agent_slug: step.agent_slug,
1787
+ agent_id: "11111111-1111-4111-8111-111111111111",
1788
+ provider_id: null,
1789
+ job_id: `33333333-3333-4333-8333-33333333333${step.index}`,
1790
+ status: "succeeded",
1791
+ quoted_cost_usd: 1.25,
1792
+ charged_usd: 1.25,
1793
+ refunded_usd: 0,
1794
+ })),
1795
+ });
1796
+ mockApiPost.mockImplementation(async (path) => {
1797
+ if (path === "/playbook-runs") {
1798
+ return { run_id: "22222222-2222-4222-8222-222222222222", steps };
1799
+ }
1800
+ if (path === "/agents/11111111-1111-4111-8111-111111111111/run") {
1801
+ throw { status: 402, message: "payment required" };
1802
+ }
1803
+ return { ok: true };
1804
+ });
1805
+ mockApiPostWithPayment
1806
+ .mockResolvedValueOnce({
1807
+ status: "success",
1808
+ job_id: "33333333-3333-4333-8333-333333333330",
1809
+ agent_id: "11111111-1111-4111-8111-111111111111",
1810
+ output: { email: "hello" },
1811
+ cost: 1.25,
1812
+ })
1813
+ .mockResolvedValueOnce({
1814
+ status: "success",
1815
+ job_id: "33333333-3333-4333-8333-333333333331",
1816
+ agent_id: "11111111-1111-4111-8111-111111111111",
1817
+ output: { valid: true },
1818
+ cost: 1.25,
1819
+ });
1820
+ const { registerPlaybookTools } = await import("../playbooks.js");
1821
+ const harness = makeServerHarness();
1822
+ registerPlaybookTools(harness.server);
1823
+ const result = await harness.handlers.get("run_playbook")({
1824
+ slug: "competitor-ads",
1825
+ input: { leads: [{ name: "Dana Ko", company: "Kelvin", email: "dana@example.com" }] },
1826
+ budget: 5,
1827
+ pay_with: "card",
1828
+ confirmed: true,
1829
+ });
1830
+ const text = flattenToolText(result);
1831
+ expect(mockApiPostWithPayment).toHaveBeenNthCalledWith(1, "/agents/11111111-1111-4111-8111-111111111111/run", expect.objectContaining({
1832
+ input: expect.objectContaining({
1833
+ brief: expect.stringContaining("Dana Ko"),
1834
+ context: expect.objectContaining({
1835
+ company: "Kelvin",
1836
+ email: "dana@example.com",
1837
+ }),
1838
+ num_outputs: 1,
1839
+ }),
1840
+ }), "card");
1841
+ expect(mockApiPostWithPayment).toHaveBeenNthCalledWith(2, "/agents/11111111-1111-4111-8111-111111111111/run", expect.objectContaining({
1842
+ input: expect.objectContaining({
1843
+ emails: ["dana@example.com"],
1844
+ }),
1845
+ }), "card");
1846
+ expect(text).toContain("completed");
1847
+ });
1848
+ it("marks still-processing child jobs as needs reconciliation on poll timeout", async () => {
1849
+ vi.useFakeTimers();
1850
+ try {
1851
+ mockRequiresSpendConfirmation.mockReturnValue(false);
1852
+ mockApiGet.mockImplementation(async (path) => {
1853
+ if (path === "/playbooks/competitor-ads")
1854
+ return playbook;
1855
+ if (path.startsWith("/jobs/33333333-3333-4333-8333-333333333333")) {
1856
+ return { status: "processing" };
1857
+ }
1858
+ if (path === "/playbook-runs/22222222-2222-4222-8222-222222222222") {
1859
+ return {
1860
+ run_id: "22222222-2222-4222-8222-222222222222",
1861
+ status: "needs_reconciliation",
1862
+ playbook_id: playbook.id,
1863
+ playbook_slug: playbook.slug,
1864
+ playbook_version: 1,
1865
+ budget_usd: 5,
1866
+ quoted_cost_usd: 1.25,
1867
+ charged_usd: 0,
1868
+ refunded_usd: 0,
1869
+ remaining_budget_usd: 5,
1870
+ error_code: "POLL_TIMEOUT",
1871
+ steps: [{
1872
+ playbook_step_id: "ads",
1873
+ step_index: 0,
1874
+ node_type: "aw_agent",
1875
+ agent_slug: "ad-strategy-intel",
1876
+ agent_id: "11111111-1111-4111-8111-111111111111",
1877
+ provider_id: null,
1878
+ job_id: "33333333-3333-4333-8333-333333333333",
1879
+ status: "running",
1880
+ quoted_cost_usd: 1.25,
1881
+ charged_usd: 0,
1882
+ refunded_usd: 0,
1883
+ error_code: "POLL_TIMEOUT",
1884
+ }],
1885
+ };
1886
+ }
1887
+ throw new Error(`Unexpected GET ${path}`);
1888
+ });
1889
+ mockApiPost.mockImplementation(async (path) => {
1890
+ if (path === "/playbook-runs") {
1891
+ return { run_id: "22222222-2222-4222-8222-222222222222", steps: playbook.current_quote.steps };
1892
+ }
1893
+ if (path === "/agents/11111111-1111-4111-8111-111111111111/run") {
1894
+ throw { status: 402, message: "payment required" };
1895
+ }
1896
+ return { ok: true };
1897
+ });
1898
+ mockApiPostWithPayment.mockResolvedValueOnce({
1899
+ status: "processing",
1900
+ job_id: "33333333-3333-4333-8333-333333333333",
1901
+ agent_id: "11111111-1111-4111-8111-111111111111",
1902
+ });
1903
+ const { registerPlaybookTools } = await import("../playbooks.js");
1904
+ const harness = makeServerHarness();
1905
+ registerPlaybookTools(harness.server);
1906
+ const resultPromise = harness.handlers.get("run_playbook")({
1907
+ slug: "competitor-ads",
1908
+ input: { domain: "notion.so" },
1909
+ budget: 5,
1910
+ pay_with: "card",
1911
+ confirmed: true,
1912
+ });
1913
+ await vi.advanceTimersByTimeAsync(305_000);
1914
+ const result = await resultPromise;
1915
+ const text = flattenToolText(result);
1916
+ expect(mockApiPost).toHaveBeenCalledWith("/playbook-runs/22222222-2222-4222-8222-222222222222/steps/ads", expect.objectContaining({
1917
+ status: "running",
1918
+ job_id: "33333333-3333-4333-8333-333333333333",
1919
+ error_code: "POLL_TIMEOUT",
1920
+ }));
1921
+ expect(mockApiPost).not.toHaveBeenCalledWith("/playbook-runs/22222222-2222-4222-8222-222222222222/steps/ads", expect.objectContaining({
1922
+ status: "refunded",
1923
+ }));
1924
+ expect(text).toContain("needs_reconciliation");
1925
+ expect(text).toContain("still processing");
1926
+ }
1927
+ finally {
1928
+ vi.useRealTimers();
1929
+ }
1930
+ });
1931
+ it("records child failure and explains the per-agent refund behavior", async () => {
1932
+ mockRequiresSpendConfirmation.mockReturnValue(false);
1933
+ mockApiGet
1934
+ .mockResolvedValueOnce(playbook)
1935
+ .mockResolvedValueOnce({
1936
+ run_id: "22222222-2222-4222-8222-222222222222",
1937
+ status: "paused",
1938
+ playbook_id: playbook.id,
1939
+ playbook_slug: playbook.slug,
1940
+ playbook_version: 1,
1941
+ budget_usd: 5,
1942
+ quoted_cost_usd: 1.25,
1943
+ charged_usd: 1.25,
1944
+ refunded_usd: 1.25,
1945
+ remaining_budget_usd: 5,
1946
+ error_code: "CHILD_AGENT_FAILED",
1947
+ steps: [{
1948
+ playbook_step_id: "ads",
1949
+ step_index: 0,
1950
+ node_type: "aw_agent",
1951
+ agent_slug: "ad-strategy-intel",
1952
+ agent_id: "11111111-1111-4111-8111-111111111111",
1953
+ provider_id: null,
1954
+ job_id: "33333333-3333-4333-8333-333333333333",
1955
+ status: "failed",
1956
+ quoted_cost_usd: 1.25,
1957
+ charged_usd: 1.25,
1958
+ refunded_usd: 1.25,
1959
+ error_code: "UPSTREAM_FAILED",
1960
+ }],
1961
+ });
1962
+ mockApiPost.mockImplementation(async (path) => {
1963
+ if (path === "/playbook-runs") {
1964
+ return { run_id: "22222222-2222-4222-8222-222222222222", steps: playbook.current_quote.steps };
1965
+ }
1966
+ if (path === "/agents/11111111-1111-4111-8111-111111111111/run") {
1967
+ throw { status: 402, message: "payment required" };
1968
+ }
1969
+ return { ok: true };
1970
+ });
1971
+ mockApiPostWithPayment.mockResolvedValueOnce({
1972
+ status: "failed",
1973
+ job_id: "33333333-3333-4333-8333-333333333333",
1974
+ agent_id: "11111111-1111-4111-8111-111111111111",
1975
+ output: { error: "upstream failed" },
1976
+ error_code: "UPSTREAM_FAILED",
1977
+ });
1978
+ const { registerPlaybookTools } = await import("../playbooks.js");
1979
+ const harness = makeServerHarness();
1980
+ registerPlaybookTools(harness.server);
1981
+ const result = await harness.handlers.get("run_playbook")({
1982
+ slug: "competitor-ads",
1983
+ input: { domain: "notion.so" },
1984
+ budget: 5,
1985
+ pay_with: "card",
1986
+ confirmed: true,
1987
+ });
1988
+ const text = flattenToolText(result);
1989
+ expect(mockApiPost).toHaveBeenCalledWith("/playbook-runs/22222222-2222-4222-8222-222222222222/steps/ads", expect.objectContaining({
1990
+ status: "refunded",
1991
+ consumption_mode: "direct_charge",
1992
+ charged_usd: 1.25,
1993
+ refunded_usd: 1.25,
1994
+ error_code: "UPSTREAM_FAILED",
1995
+ }));
1996
+ expect(text).toContain("Existing per-agent refund behavior applies");
1997
+ });
1998
+ it("returns paid child output and marks the run for reconciliation when receipt writes keep failing", async () => {
1999
+ mockRequiresSpendConfirmation.mockReturnValue(false);
2000
+ mockApiGet.mockResolvedValueOnce(playbook);
2001
+ let successfulStepReceiptAttempts = 0;
2002
+ mockApiPost.mockImplementation(async (path, body) => {
2003
+ if (path === "/playbook-runs") {
2004
+ return { run_id: "22222222-2222-4222-8222-222222222222", steps: playbook.current_quote.steps };
2005
+ }
2006
+ if (path === "/agents/11111111-1111-4111-8111-111111111111/run") {
2007
+ throw { status: 402, message: "payment required" };
2008
+ }
2009
+ if (path === "/playbook-runs/22222222-2222-4222-8222-222222222222/steps/ads" && body?.status === "succeeded") {
2010
+ successfulStepReceiptAttempts += 1;
2011
+ throw new Error("receipt store unavailable");
2012
+ }
2013
+ return { ok: true };
2014
+ });
2015
+ mockApiPostWithPayment.mockResolvedValueOnce({
2016
+ status: "success",
2017
+ job_id: "33333333-3333-4333-8333-333333333333",
2018
+ agent_id: "11111111-1111-4111-8111-111111111111",
2019
+ output: { rows: 3 },
2020
+ cost: 1.25,
2021
+ });
2022
+ const { registerPlaybookTools } = await import("../playbooks.js");
2023
+ const harness = makeServerHarness();
2024
+ registerPlaybookTools(harness.server);
2025
+ const result = await harness.handlers.get("run_playbook")({
2026
+ slug: "competitor-ads",
2027
+ input: { domain: "notion.so" },
2028
+ budget: 5,
2029
+ pay_with: "card",
2030
+ confirmed: true,
2031
+ });
2032
+ const text = flattenToolText(result);
2033
+ expect(successfulStepReceiptAttempts).toBe(3);
2034
+ expect(mockApiPost).toHaveBeenCalledWith("/playbook-runs/22222222-2222-4222-8222-222222222222/status", expect.objectContaining({
2035
+ status: "needs_reconciliation",
2036
+ error_code: "RECEIPT_WRITE_FAILED",
2037
+ }));
2038
+ expect(text).toContain("needs reconciliation");
2039
+ expect(text).toContain("rows");
2040
+ });
2041
+ it("gets a paused playbook run receipt with completed, refunded, skipped, and resume details", async () => {
2042
+ mockApiGet.mockResolvedValueOnce({
2043
+ run_id: "22222222-2222-4222-8222-222222222222",
2044
+ status: "paused",
2045
+ playbook_id: playbook.id,
2046
+ playbook_slug: playbook.slug,
2047
+ playbook_version: 1,
2048
+ input: { domain: "notion.so" },
2049
+ limits: { prospects: 2 },
2050
+ budget_usd: 5,
2051
+ quoted_cost_usd: 3,
2052
+ charged_usd: 2,
2053
+ refunded_usd: 0.75,
2054
+ remaining_budget_usd: 3.75,
2055
+ error_code: "BUDGET_EXHAUSTED",
2056
+ steps: [
2057
+ {
2058
+ playbook_step_id: "ads",
2059
+ step_index: 0,
2060
+ node_type: "aw_agent",
2061
+ agent_slug: "ad-strategy-intel",
2062
+ agent_id: "11111111-1111-4111-8111-111111111111",
2063
+ provider_id: null,
2064
+ job_id: "33333333-3333-4333-8333-333333333333",
2065
+ consumption_mode: "direct_charge",
2066
+ status: "succeeded",
2067
+ quoted_cost_usd: 1.25,
2068
+ charged_usd: 1.25,
2069
+ refunded_usd: 0,
2070
+ },
2071
+ {
2072
+ playbook_step_id: "traffic",
2073
+ step_index: 1,
2074
+ node_type: "aw_agent",
2075
+ agent_slug: "traffic-estimator",
2076
+ agent_id: "44444444-4444-4444-8444-444444444444",
2077
+ provider_id: null,
2078
+ job_id: "55555555-5555-4555-8555-555555555555",
2079
+ consumption_mode: "direct_charge",
2080
+ status: "failed",
2081
+ quoted_cost_usd: 0.75,
2082
+ charged_usd: 0.75,
2083
+ refunded_usd: 0.75,
2084
+ error_code: "UPSTREAM_FAILED",
2085
+ },
2086
+ {
2087
+ playbook_step_id: "serp",
2088
+ step_index: 2,
2089
+ node_type: "external_x402",
2090
+ agent_slug: null,
2091
+ agent_id: null,
2092
+ provider_id: null,
2093
+ job_id: null,
2094
+ consumption_mode: "not_charged",
2095
+ status: "skipped",
2096
+ quoted_cost_usd: 1,
2097
+ charged_usd: 0,
2098
+ refunded_usd: 0,
2099
+ error_code: "BUDGET_EXHAUSTED",
2100
+ },
2101
+ ],
2102
+ });
2103
+ const { registerPlaybookTools } = await import("../playbooks.js");
2104
+ const harness = makeServerHarness();
2105
+ registerPlaybookTools(harness.server);
2106
+ const result = await harness.handlers.get("get_playbook_run")({
2107
+ run_id: "22222222-2222-4222-8222-222222222222",
2108
+ });
2109
+ const text = flattenToolText(result);
2110
+ expect(mockApiGet).toHaveBeenCalledWith("/playbook-runs/22222222-2222-4222-8222-222222222222");
2111
+ expect(text).toContain("competitor-ads v1 — paused");
2112
+ expect(text).toContain("Charged: $2.00");
2113
+ expect(text).toContain("Refunded: $0.7500");
2114
+ expect(text).toContain("Remaining unspent budget: $3.75");
2115
+ expect(text).toContain("ad-strategy-intel: succeeded");
2116
+ expect(text).toContain("traffic-estimator: failed");
2117
+ expect(text).toContain("refunded $0.7500");
2118
+ expect(text).toContain("serp: skipped · not charged");
2119
+ expect(text).not.toContain("null: skipped");
2120
+ expect(text).toContain("error: BUDGET_EXHAUSTED");
2121
+ expect(text).toContain('Resume: run_playbook({ resume_run_id: "22222222-2222-4222-8222-222222222222", confirmed: true, budget: 5, limits: {"prospects":2} })');
2122
+ });
2123
+ });