@desplega.ai/agent-swarm 2.4.3 → 2.4.4

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 (150) hide show
  1. package/dist/{acp-adapter-1885dpr7.js → acp-adapter-thq04adh.js} +1 -2
  2. package/dist/{actions-pn76n32q.js → actions-j6ywh6x5.js} +17 -17
  3. package/dist/{amp-adapter-af84rx1d.js → amp-adapter-k8b5xmj8.js} +2 -2
  4. package/dist/{app-6sv8mhzm.js → app-rb7vc02s.js} +3 -3
  5. package/dist/{assistant-sc2tf825.js → assistant-ftta1zjf.js} +18 -18
  6. package/dist/{boot-reembed-y3m0ydzj.js → boot-reembed-srp6v6af.js} +1 -1
  7. package/dist/{boot-reembed-gvxjs2py.js → boot-reembed-xns7h5h9.js} +1 -1
  8. package/dist/{boot-scrub-sweep-1j9mk05f.js → boot-scrub-sweep-3qqrh2mz.js} +2 -2
  9. package/dist/{boot-seeds-37sp626b.js → boot-seeds-9ks8hce7.js} +4 -4
  10. package/dist/{claude-adapter-rp0jtnwr.js → claude-adapter-k5ksyres.js} +2 -2
  11. package/dist/{claude-managed-setup-rdt248k7.js → claude-managed-setup-n5a5eqks.js} +1 -1
  12. package/dist/{claude-sdk-session-53rcw5we.js → claude-sdk-session-2j3j4kwn.js} +2 -2
  13. package/dist/{cli-v87dr6e3.js → cli-1d70crsy.js} +1 -1
  14. package/dist/{cli-1ma335fz.js → cli-1ephgwnq.js} +2 -2
  15. package/dist/{cli-4yef8ghv.js → cli-2v045xyf.js} +3 -3
  16. package/dist/{cli-73ezfhha.js → cli-2y91wnbh.js} +10 -5
  17. package/dist/{cli-1eszr343.js → cli-2zqpkvtt.js} +1 -1
  18. package/dist/{cli-e4d78xsv.js → cli-37yb7307.js} +1 -1
  19. package/dist/{cli-8y99kfey.js → cli-3cgngyd8.js} +9 -9
  20. package/dist/{cli-wk28hzqn.js → cli-44159k28.js} +1 -1
  21. package/dist/{cli-qp2m4btv.js → cli-4dg55wsk.js} +2 -2
  22. package/dist/{cli-0dxxwfkt.js → cli-51y09c6p.js} +17 -14
  23. package/dist/{cli-aa3tb29r.js → cli-55qmbp6z.js} +1 -1
  24. package/dist/{cli-mzkwkbdy.js → cli-82d3nkc3.js} +1 -1
  25. package/dist/{cli-sds5zzs8.js → cli-9fhkkpj5.js} +78 -12
  26. package/dist/{cli-vq9gqarq.js → cli-amyhm3gj.js} +1 -1
  27. package/dist/{cli-bxrqvpg5.js → cli-b8k7epj1.js} +5 -5
  28. package/dist/{cli-8vverzf7.js → cli-ba8ne1es.js} +5 -0
  29. package/dist/{cli-58vwn100.js → cli-bnz0r3kc.js} +1 -1
  30. package/dist/{cli-6eyncg0y.js → cli-cynvc9fj.js} +34 -6
  31. package/dist/{cli-f0p0f6ja.js → cli-dqb4n629.js} +7 -7
  32. package/dist/{cli-pygdsgh8.js → cli-esxerzg7.js} +1 -1
  33. package/dist/{cli-dm8p2dpf.js → cli-gqvh8d8a.js} +6 -6
  34. package/dist/{cli-p0qerd2r.js → cli-hj3debgw.js} +1 -1
  35. package/dist/{cli-836ehxmt.js → cli-nc35pxb7.js} +2 -2
  36. package/dist/{cli-vcgscaan.js → cli-p20r8w42.js} +8 -6
  37. package/dist/{cli-1xsraagq.js → cli-pccfmtb5.js} +4 -4
  38. package/dist/{cli-s9rr4gr2.js → cli-q6r7t299.js} +1036 -298
  39. package/dist/{cli-tkf4x6ha.js → cli-qkhc5aqy.js} +11 -11
  40. package/dist/{cli-1n9gdg56.js → cli-t7cyef5g.js} +4 -4
  41. package/dist/{cli-038fga5k.js → cli-te6b5wvg.js} +148 -183
  42. package/dist/{cli-kgnkyabk.js → cli-v3yr2gbg.js} +109 -35
  43. package/dist/{cli-h0mgxann.js → cli-vdm20wej.js} +2 -2
  44. package/dist/{cli-zvbb4n0e.js → cli-vq97g1jq.js} +2 -2
  45. package/dist/{cli-gx0j2khs.js → cli-w6nadkh4.js} +3 -3
  46. package/dist/{cli-99jez72r.js → cli-w9etnnxk.js} +6 -1
  47. package/dist/{cli-npfe9t1s.js → cli-xc27fwzg.js} +2 -2
  48. package/dist/{cli-6aen3ezb.js → cli-xj0re5tm.js} +1 -1
  49. package/dist/{cli-aypbg9r2.js → cli-zrk6jxv8.js} +1 -1
  50. package/dist/{cli-1012xb7j.js → cli-zrwpctp4.js} +20 -20
  51. package/dist/cli.js +41 -16
  52. package/dist/{codex-adapter-2zrmab9b.js → codex-adapter-968xwfy7.js} +1 -1
  53. package/dist/{codex-session-runner-kbkz62vc.js → codex-session-runner-ht4z99fa.js} +1 -1
  54. package/dist/{commands-hd574th6.js → commands-pcymaeg9.js} +2 -2
  55. package/dist/{controller-pz5mcfg4.js → controller-fpjyk46r.js} +2 -2
  56. package/dist/{controllers-connect-5rgq2hfk.js → controllers-connect-mwzx1fh6.js} +18 -18
  57. package/dist/{cursor-adapter-mgaprq0j.js → cursor-adapter-cp4s2e2f.js} +1 -1
  58. package/dist/{db-h06dxp05.js → db-sgd12bt9.js} +3 -3
  59. package/dist/{db-6nf1207q.js → db-xsjg9jqq.js} +1 -1
  60. package/dist/{dispatcher-zthpy4m1.js → dispatcher-x0jfw2c9.js} +16 -16
  61. package/dist/{dsh-adapter-64dhw0j7.js → dsh-adapter-jrq7ma7h.js} +1 -1
  62. package/dist/{external-channel-x01t7ke6.js → external-channel-8njj4h3b.js} +2 -2
  63. package/dist/{grok-adapter-h21jv594.js → grok-adapter-kkr97v2f.js} +2 -3
  64. package/dist/handlers-0pvee9nw.js +39 -0
  65. package/dist/{heartbeat-zzfksckz.js → heartbeat-wd03zhn0.js} +19 -19
  66. package/dist/{hook-z4zhzj1f.js → hook-f0vghj42.js} +1 -1
  67. package/dist/{hook-3xfqhh2s.js → hook-xzj2ts2p.js} +1 -1
  68. package/dist/{http-q813tmya.js → http-4hwxv62p.js} +255 -117
  69. package/dist/{index-va3y63es.js → index-6srwvd5b.js} +1 -2
  70. package/dist/{index-220tk9bw.js → index-97q4xn03.js} +19 -10
  71. package/dist/{index-s98037v0.js → index-cbxkmfc5.js} +1 -2
  72. package/dist/{index-m0a4ydqj.js → index-gdh8jrdm.js} +16 -16
  73. package/dist/{index-wbbs14c5.js → index-hg0x2cdv.js} +16 -16
  74. package/dist/{index-d0g18bax.js → index-thm0y6a8.js} +19 -19
  75. package/dist/{index-x8xbyn1t.js → index-ww2vznkp.js} +1 -1
  76. package/dist/{keepalive-y5t1my7m.js → keepalive-hrh7xnr9.js} +3 -3
  77. package/dist/{lead-2xwtq66b.js → lead-ee8vazz2.js} +26 -27
  78. package/dist/{maintenance-ybfsaedv.js → maintenance-e8wnwnqx.js} +1 -1
  79. package/dist/{oauth-refresh-sweep-909jmn12.js → oauth-refresh-sweep-zm0yj4sj.js} +2 -2
  80. package/dist/{onboard-xn6fkkys.js → onboard-dtwcq603.js} +2 -2
  81. package/dist/{otel-impl-e6mn0z13.js → otel-impl-w3fbhpn0.js} +1 -1
  82. package/dist/{pricing-refresh-ygd362ep.js → pricing-refresh-j38dehfs.js} +3 -3
  83. package/dist/{rbac-roles-c9axvfnz.js → rbac-roles-ae8yttq4.js} +2 -2
  84. package/dist/{rbac-roles-mb470p4e.js → rbac-roles-ejxx9b35.js} +2 -2
  85. package/dist/{render-v2-g0306nh1.js → render-v2-n16n0phe.js} +3 -3
  86. package/dist/{resume-v315hnq6.js → resume-z0fgjc38.js} +5 -5
  87. package/dist/{session-access-2zg285a1.js → session-access-cv3mq6mc.js} +6 -4
  88. package/dist/{setup-a4mtf02k.js → setup-mafhj3yt.js} +1 -1
  89. package/dist/{work-objects-9fr0fgtn.js → work-objects-8b86mgjp.js} +19 -19
  90. package/dist/{worker-qae22jeb.js → worker-16s75hm5.js} +26 -27
  91. package/dist/worker-token-xf11yyds.js +136 -0
  92. package/openapi.json +295 -12
  93. package/package.json +4 -4
  94. package/src/apps/action-runner.ts +4 -0
  95. package/src/apps/catalog.generated.json +3 -0
  96. package/src/apps/page-validator.ts +8 -2
  97. package/src/be/migrations/232_agent_session_tokens.sql +41 -0
  98. package/src/be/seed-scripts/catalog/heartbeat-jev-gate.ts +10 -1
  99. package/src/be/swarm-config-guard.ts +16 -0
  100. package/src/be/users.ts +169 -12
  101. package/src/cli.tsx +24 -0
  102. package/src/commands/worker-token.ts +164 -0
  103. package/src/http/apps.ts +43 -5
  104. package/src/http/codex-oauth-keep-warm.ts +27 -9
  105. package/src/http/config.ts +5 -3
  106. package/src/http/core.ts +2 -0
  107. package/src/http/session-access.ts +51 -2
  108. package/src/http/sessions.ts +249 -10
  109. package/src/http/tasks.ts +4 -32
  110. package/src/providers/acp-adapter.ts +33 -13
  111. package/src/providers/acp-swarm-events.ts +1 -1
  112. package/src/providers/amp-adapter.ts +1 -1
  113. package/src/providers/codex-oauth/storage.ts +1 -1
  114. package/src/rbac/legacy-policy.ts +3 -1
  115. package/src/rbac/permissions.ts +4 -0
  116. package/src/tests/acp-adapter.test.ts +10 -23
  117. package/src/tests/acp-fake-agent.ts +36 -0
  118. package/src/tests/acp-swarm-events.test.ts +1 -1
  119. package/src/tests/agent-session-tokens.test.ts +452 -0
  120. package/src/tests/apps-action-parent-task.test.ts +295 -0
  121. package/src/tests/codex-oauth-keep-warm.test.ts +85 -1
  122. package/src/tests/config-session-auth.test.ts +14 -0
  123. package/src/tests/docs-index-footprint.test.js +2 -2
  124. package/src/tests/grok-adapter.test.ts +3 -3
  125. package/src/tests/heartbeat-jev-gate.test.ts +23 -0
  126. package/src/tests/migration-232-agent-session-tokens.test.ts +67 -0
  127. package/src/tests/rbac-engine.test.ts +1 -0
  128. package/src/tests/runtime-child-token-cleanup.test.ts +3 -3
  129. package/src/tests/script-connections.test.ts +2 -0
  130. package/src/tests/session-route-posture.test.ts +2 -0
  131. package/src/tests/worker-token-cli.test.ts +76 -0
  132. package/src/tests/workflow-executors.test.ts +4 -2
  133. package/src/tests/workflow-system-one-extraction.test.ts +805 -0
  134. package/src/types.ts +2 -1
  135. package/src/utils/request-auth-context.ts +2 -1
  136. package/src/workflows/definition.ts +7 -1
  137. package/src/workflows/engine.ts +18 -12
  138. package/src/workflows/executors/index.ts +1 -0
  139. package/src/workflows/executors/registry.ts +2 -0
  140. package/src/workflows/executors/system-one-decision.ts +56 -255
  141. package/src/workflows/executors/system-one-extract-wire.ts +57 -0
  142. package/src/workflows/executors/system-one-extraction.ts +830 -0
  143. package/src/workflows/executors/system-one-providers.ts +277 -110
  144. package/src/workflows/executors/system-one-transport.ts +264 -0
  145. package/templates/official/lead/HEARTBEAT.md +1 -1
  146. package/templates/skills/apps/SKILL.md +3 -1
  147. package/templates/skills/apps/content.md +3 -1
  148. package/templates/skills/heartbeat-runbook/content.md +3 -3
  149. package/dist/cli-v1sjhn52.js +0 -7
  150. package/dist/handlers-2qjw434w.js +0 -39
@@ -0,0 +1,805 @@
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { unlink } from "node:fs/promises";
3
+ import { closeDb, getDbClient, initDb, upsertSwarmConfig } from "../be/db";
4
+ import type { WorkflowNode } from "../types";
5
+ import { validateDefinition } from "../workflows/definition";
6
+ import { interpolateNodeConfig } from "../workflows/engine";
7
+ import { InProcessEventBus } from "../workflows/event-bus";
8
+ import type { ExecutorDependencies } from "../workflows/executors/base";
9
+ import { createExecutorRegistry } from "../workflows/executors/registry";
10
+ import { SystemOneDecisionExecutor } from "../workflows/executors/system-one-decision";
11
+ import {
12
+ SystemOneExtractionConfigSchema,
13
+ SystemOneExtractionExecutor,
14
+ type SystemOneExtractionExecutorOptions,
15
+ SystemOneExtractionOutputSchema,
16
+ } from "../workflows/executors/system-one-extraction";
17
+ import {
18
+ SYSTEM_ONE_EXTRACT_PROVIDER_IDS,
19
+ SYSTEM_ONE_PROVIDER_IDS,
20
+ } from "../workflows/executors/system-one-providers";
21
+ import { interpolate } from "../workflows/template";
22
+
23
+ const TEST_DB_PATH = "./test-workflow-system-one-extraction.sqlite";
24
+ // Shaped like tokens but obviously fake; they must never appear in any output or error.
25
+ const CF_TOKEN = "cf-example-test-token-0123456789abcdef";
26
+ const CF_KEY = "cf-example-test-key-fedcba9876543210";
27
+ const CF_ACCOUNT = "0123456789abcdef0123456789abcdef";
28
+ const CF_BASE = `https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT}/ai/run/@cf/cloudflare`;
29
+
30
+ let deps: ExecutorDependencies;
31
+
32
+ async function removeDbFiles(): Promise<void> {
33
+ for (const suffix of ["", "-wal", "-shm"]) {
34
+ try {
35
+ await unlink(TEST_DB_PATH + suffix);
36
+ } catch (error) {
37
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
38
+ }
39
+ }
40
+ }
41
+
42
+ beforeAll(async () => {
43
+ await removeDbFiles();
44
+ initDb(TEST_DB_PATH);
45
+ const db = await import("../be/db");
46
+ deps = {
47
+ db,
48
+ eventBus: new InProcessEventBus(),
49
+ interpolate: (template, ctx) => interpolate(template, ctx).result,
50
+ };
51
+ });
52
+
53
+ afterAll(async () => {
54
+ closeDb();
55
+ await removeDbFiles();
56
+ });
57
+
58
+ beforeEach(async () => {
59
+ await getDbClient().run(
60
+ "DELETE FROM swarm_config WHERE key IN ('CLOUDFLARE_ACCOUNT_ID', 'CLOUDFLARE_API_TOKEN', 'CLOUDFLARE_API_KEY')",
61
+ );
62
+ });
63
+
64
+ // ─── Harness ────────────────────────────────────────────────
65
+
66
+ type TransportStep = Response | Error;
67
+
68
+ interface RecordedCall {
69
+ url: string;
70
+ init: RequestInit;
71
+ body: Record<string, unknown>;
72
+ }
73
+
74
+ function makeTransport(steps: TransportStep[]) {
75
+ const calls: RecordedCall[] = [];
76
+ const transport = (async (input: string | URL | Request, init?: RequestInit) => {
77
+ const step = steps[Math.min(calls.length, steps.length - 1)];
78
+ calls.push({
79
+ url: String(input),
80
+ init: init ?? {},
81
+ body: JSON.parse(String(init?.body ?? "{}")) as Record<string, unknown>,
82
+ });
83
+ if (step instanceof Error) throw step;
84
+ return (step as Response).clone();
85
+ }) as typeof fetch;
86
+ return { calls, transport };
87
+ }
88
+
89
+ function jsonResponse(body: unknown, status = 200): Response {
90
+ return new Response(JSON.stringify(body), {
91
+ status,
92
+ headers: { "content-type": "application/json", "cf-ray": "8f00aa11bb22cc33-MAD" },
93
+ });
94
+ }
95
+
96
+ function makeExecutor(steps: TransportStep[], options: SystemOneExtractionExecutorOptions = {}) {
97
+ const { calls, transport } = makeTransport(steps);
98
+ const executor = new SystemOneExtractionExecutor(deps, {
99
+ fetch: transport,
100
+ env: {},
101
+ getApiKey: async () => CF_TOKEN,
102
+ getServerUrl: async () => CF_ACCOUNT,
103
+ sleep: async () => {},
104
+ random: () => 1,
105
+ ...options,
106
+ });
107
+ return { executor, calls };
108
+ }
109
+
110
+ function run(executor: SystemOneExtractionExecutor, config: Record<string, unknown>) {
111
+ return executor.run({
112
+ config,
113
+ context: {},
114
+ meta: {
115
+ runId: crypto.randomUUID(),
116
+ stepId: crypto.randomUUID(),
117
+ nodeId: "claim",
118
+ workflowId: crypto.randomUUID(),
119
+ dryRun: false,
120
+ },
121
+ });
122
+ }
123
+
124
+ // ─── Fixtures: the damaged-order email from Cloudflare's Clairic post ─────
125
+
126
+ const EMAIL =
127
+ "Hi, order A-88412 arrived on Sept 28, 2026 with a cracked screen and a dented corner. I paid $649.00 plus $52.10 tax. It's now Oct 9, 2026 and I'd like this fixed or refunded. Thanks, Dana";
128
+
129
+ const fields = {
130
+ order_id: { type: "text", instructions: "Order number" },
131
+ delivered_on: { type: "date", instructions: "Delivery date" },
132
+ amount_paid: {
133
+ type: "number",
134
+ decimals: 2,
135
+ unit: "USD",
136
+ instructions: "Total paid including tax",
137
+ },
138
+ days_since_delivery: {
139
+ type: "duration",
140
+ unit: "days",
141
+ integer: true,
142
+ instructions: "Days from delivery to the date the customer wrote",
143
+ },
144
+ damage: { type: "list", items: "text", instructions: "Each kind of damage described" },
145
+ tracking_number: { type: "text" },
146
+ repair_cost: {
147
+ type: "estimate",
148
+ unit: "USD",
149
+ min: 10,
150
+ max: 5000,
151
+ scale: "log",
152
+ step: 1,
153
+ instructions: "Cost to repair the damage described",
154
+ },
155
+ };
156
+
157
+ function config(overrides: Record<string, unknown> = {}): Record<string, unknown> {
158
+ return { state: EMAIL, fields, ...overrides };
159
+ }
160
+
161
+ function clairicResult(answers: Record<string, unknown> = {}) {
162
+ return {
163
+ model: "clairic",
164
+ answers: {
165
+ order_id: {
166
+ type: "text",
167
+ p_null: 3.4e-6,
168
+ span: [10, 17],
169
+ guess: "A-88412",
170
+ confidence: 0.999957,
171
+ value: "A-88412",
172
+ },
173
+ delivered_on: {
174
+ type: "date",
175
+ p_null: 6.5e-8,
176
+ guess: "2026-09-28",
177
+ confidence: 0.999999,
178
+ value: "2026-09-28",
179
+ },
180
+ amount_paid: {
181
+ type: "number",
182
+ p_null: 1.6e-5,
183
+ guess: 701.1,
184
+ confidence: 0.999944,
185
+ value: 701.1,
186
+ },
187
+ days_since_delivery: {
188
+ type: "duration",
189
+ p_null: 6.1e-9,
190
+ unit: "days",
191
+ guess: 11,
192
+ confidence: 0.999999,
193
+ value: 11,
194
+ },
195
+ damage: {
196
+ type: "list",
197
+ value: ["cracked screen", "dented corner"],
198
+ spans: [
199
+ [50, 64],
200
+ [71, 84],
201
+ ],
202
+ },
203
+ tracking_number: {
204
+ type: "text",
205
+ p_null: 1,
206
+ span: [106, 112],
207
+ guess: "$52.10",
208
+ confidence: 0.416016,
209
+ value: null,
210
+ },
211
+ repair_cost: {
212
+ type: "estimate",
213
+ value: 236,
214
+ mean: 236.26,
215
+ median: 213,
216
+ p10: 122,
217
+ p90: 370,
218
+ p_below: 4.4e-6,
219
+ p_above: 2.5e-6,
220
+ },
221
+ ...answers,
222
+ },
223
+ truncated: false,
224
+ usage: { state_tokens: 61, input_tokens: 655, output_tokens: 0, passes: 3 },
225
+ };
226
+ }
227
+
228
+ /** `@cf/cloudflare/clairic` on 2026-10-20 for EMAIL and `fields`, verbatim. */
229
+ const LIVE_CLOUDFLARE_RESPONSE = {
230
+ result: {
231
+ model: "clairic",
232
+ answers: {
233
+ order_id: {
234
+ type: "text",
235
+ p_null: 0.0019279655534774065,
236
+ span: [10, 17],
237
+ guess: "A-88412",
238
+ confidence: 0.9999996423721313,
239
+ value: "A-88412",
240
+ },
241
+ delivered_on: {
242
+ type: "date",
243
+ p_null: 0.0000010288927114743274,
244
+ guess: "2026-09-28",
245
+ confidence: 0.9999949932298761,
246
+ value: "2026-09-28",
247
+ },
248
+ amount_paid: {
249
+ type: "number",
250
+ p_null: 3.2816508621635876e-8,
251
+ guess: 701.1,
252
+ confidence: 0.9999686488145774,
253
+ value: 701.1,
254
+ },
255
+ days_since_delivery: {
256
+ type: "duration",
257
+ p_null: 3.2392144078130514e-8,
258
+ unit: "days",
259
+ guess: 11,
260
+ confidence: 0.9955674593910555,
261
+ value: 11,
262
+ },
263
+ damage: {
264
+ type: "list",
265
+ value: ["cracked screen", "dented corner"],
266
+ spans: [
267
+ [50, 64],
268
+ [71, 84],
269
+ ],
270
+ confidence: 0.6304950857959576,
271
+ },
272
+ tracking_number: {
273
+ type: "text",
274
+ p_null: 0.9999991655349731,
275
+ span: [10, 17],
276
+ guess: "A-88412",
277
+ confidence: 0.9594870209693909,
278
+ value: null,
279
+ },
280
+ repair_cost: {
281
+ type: "estimate",
282
+ value: 236,
283
+ mean: 236.25989418083398,
284
+ median: 213,
285
+ p10: 122,
286
+ p90: 370,
287
+ p_below: 0.0000044131897993793245,
288
+ p_above: 0.0000025400161121069686,
289
+ },
290
+ },
291
+ truncated: false,
292
+ usage: {
293
+ state_tokens: 74,
294
+ image_tokens: 0,
295
+ schema_tokens: 517,
296
+ input_tokens: 739,
297
+ output_tokens: 0,
298
+ passes: 3,
299
+ },
300
+ },
301
+ success: true,
302
+ errors: [],
303
+ messages: [],
304
+ };
305
+
306
+ function envelope(result: unknown = clairicResult()) {
307
+ return { result, success: true, errors: [], messages: [] };
308
+ }
309
+
310
+ // ─── Request and answers ────────────────────────────────────
311
+
312
+ describe("system-one-extraction on Cloudflare Clairic", () => {
313
+ test("sends the Clairic request to the account-scoped route and returns typed answers", async () => {
314
+ const { executor, calls } = makeExecutor([jsonResponse(envelope())]);
315
+ const result = await run(executor, config({ referenceDate: "2026-10-09" }));
316
+
317
+ expect(result.status).toBe("success");
318
+ expect(calls).toHaveLength(1);
319
+ expect(calls[0]?.url).toBe(`${CF_BASE}/clairic`);
320
+ expect((calls[0]?.init.headers as Record<string, string>).authorization).toBe(
321
+ `Bearer ${CF_TOKEN}`,
322
+ );
323
+ expect(calls[0]?.body).toEqual({
324
+ model: "clairic",
325
+ state: EMAIL,
326
+ fields,
327
+ reference_date: "2026-10-09",
328
+ });
329
+
330
+ if (result.status !== "success") throw new Error("unreachable");
331
+ const output = SystemOneExtractionOutputSchema.parse(result.output);
332
+ expect(output.model).toBe("clairic");
333
+ expect(output.requestId).toBe("8f00aa11bb22cc33-MAD");
334
+ expect(output.truncated).toBe(false);
335
+ expect(output.usage).toEqual({ input_tokens: 655, output_tokens: 0 });
336
+ expect(output.answers.order_id).toMatchObject({ value: "A-88412", span: [10, 17] });
337
+ expect(output.answers.amount_paid?.value).toBe(701.1);
338
+ expect(output.answers.days_since_delivery).toMatchObject({ value: 11, unit: "days" });
339
+ expect(output.answers.damage?.value).toEqual(["cracked screen", "dented corner"]);
340
+ // The model saw "$52.10" and still said there is no tracking number.
341
+ expect(output.answers.tracking_number).toMatchObject({
342
+ value: null,
343
+ p_null: 1,
344
+ guess: "$52.10",
345
+ });
346
+ expect(output.answers.repair_cost).toMatchObject({
347
+ value: 236,
348
+ median: 213,
349
+ p10: 122,
350
+ p90: 370,
351
+ });
352
+ });
353
+
354
+ test("accepts a recorded live Workers AI response unchanged", async () => {
355
+ const { executor } = makeExecutor([jsonResponse(LIVE_CLOUDFLARE_RESPONSE)]);
356
+ const result = await run(executor, config());
357
+
358
+ expect(result.status).toBe("success");
359
+ if (result.status !== "success") throw new Error("unreachable");
360
+ const output = SystemOneExtractionOutputSchema.parse(result.output);
361
+ expect(output.usage).toEqual({ input_tokens: 739, output_tokens: 0 });
362
+ expect(output.answers.tracking_number).toMatchObject({ value: null, guess: "A-88412" });
363
+ expect(output.answers.damage).toMatchObject({ confidence: 0.6304950857959576 });
364
+ expect(output.answers.repair_cost).toMatchObject({ value: 236, median: 213 });
365
+ });
366
+
367
+ test("structured state is sent as JSON text and images ride along", async () => {
368
+ const { executor, calls } = makeExecutor([jsonResponse(envelope())]);
369
+ const image = { content_type: "image/png", base64: "iVBORw0KGgo=" };
370
+ const result = await run(
371
+ executor,
372
+ config({ state: { from: "dana@example.com", body: EMAIL }, images: [image] }),
373
+ );
374
+ expect(result.status).toBe("success");
375
+ expect(calls[0]?.body.state).toBe(JSON.stringify({ from: "dana@example.com", body: EMAIL }));
376
+ expect(calls[0]?.body.images).toEqual([image]);
377
+ });
378
+
379
+ test("a forecast answer carries one entry per step", async () => {
380
+ const forecast = {
381
+ travelers: {
382
+ type: "forecast",
383
+ horizon: 2,
384
+ interval: "day",
385
+ min: 500000,
386
+ max: 4000000,
387
+ scale: "log",
388
+ },
389
+ };
390
+ const { executor } = makeExecutor([
391
+ jsonResponse(
392
+ envelope({
393
+ model: "clairic",
394
+ answers: {
395
+ travelers: {
396
+ type: "forecast",
397
+ value: [2416117, 2300000],
398
+ median: [2419283, 2310000],
399
+ p10: [2270774, 2100000],
400
+ p90: [2556769, 2500000],
401
+ },
402
+ },
403
+ usage: { input_tokens: 10, output_tokens: 0 },
404
+ }),
405
+ ),
406
+ ]);
407
+ const result = await run(executor, { state: "TSA counts", fields: forecast });
408
+ expect(result.status).toBe("success");
409
+ if (result.status !== "success") throw new Error("unreachable");
410
+ expect(result.output.answers.travelers?.p90).toEqual([2556769, 2500000]);
411
+ });
412
+
413
+ test("an answer that does not fit its field fails validation and names it", async () => {
414
+ const cases: Array<[Record<string, unknown>, string]> = [
415
+ [{ amount_paid: { type: "text", value: "701.10" } }, 'answer "amount_paid" has type "text"'],
416
+ [
417
+ { amount_paid: { type: "number", value: "701.10" } },
418
+ '"amount_paid".value must be a finite number',
419
+ ],
420
+ [{ days_since_delivery: { type: "duration", value: 11.5 } }, "must be an integer"],
421
+ [{ damage: { type: "list", value: [1, 2] } }, '"damage".value items must all be strings'],
422
+ [
423
+ { repair_cost: { type: "estimate", value: 180, median: 180, p10: 90 } },
424
+ '"repair_cost".p90',
425
+ ],
426
+ [{ order_id: { type: "text", value: "A-88412", confidence: 1.5 } }, '"order_id".confidence'],
427
+ [{ order_id: { type: "text" } }, 'answer "order_id" is missing its value'],
428
+ [{ extra: { type: "text", value: "x" } }, "answer(s) for undeclared fields"],
429
+ ];
430
+ for (const [answers, message] of cases) {
431
+ const { executor } = makeExecutor([jsonResponse(envelope(clairicResult(answers)))]);
432
+ const result = await run(executor, config());
433
+ expect(result.status).toBe("failed");
434
+ if (result.status !== "failed") throw new Error("unreachable");
435
+ expect(result.error).toContain("SystemOne response failed validation: ");
436
+ expect(result.error).toContain(message);
437
+ expect(result.error).toContain("[request 8f00aa11bb22cc33-MAD]");
438
+ }
439
+ });
440
+
441
+ test("a failed Cloudflare run is reported with its code and nothing extracted", async () => {
442
+ const { executor } = makeExecutor([
443
+ jsonResponse({
444
+ success: false,
445
+ errors: [{ code: 5007, message: "No such model" }],
446
+ result: null,
447
+ }),
448
+ ]);
449
+ const result = await run(executor, config());
450
+ expect(result.status).toBe("failed");
451
+ if (result.status !== "failed") throw new Error("unreachable");
452
+ expect(result.error).toContain(
453
+ "Cloudflare Workers AI reported a failed run (5007). Nothing was extracted.",
454
+ );
455
+ });
456
+
457
+ test("transient errors retry through the shared transport; a 401 does not", async () => {
458
+ const retried = makeExecutor([jsonResponse({}, 503), jsonResponse(envelope())]);
459
+ expect((await run(retried.executor, config())).status).toBe("success");
460
+ expect(retried.calls).toHaveLength(2);
461
+
462
+ const rejected = makeExecutor([
463
+ jsonResponse(
464
+ { success: false, errors: [{ code: 10000, message: "Authentication error" }] },
465
+ 401,
466
+ ),
467
+ ]);
468
+ const result = await run(rejected.executor, config({ maxRetries: 3 }));
469
+ expect(rejected.calls).toHaveLength(1);
470
+ if (result.status !== "failed") throw new Error("expected failure");
471
+ expect(result.error).toContain(
472
+ "CLOUDFLARE_API_TOKEN was rejected by Cloudflare Workers AI (HTTP 401 (10000))",
473
+ );
474
+ expect(result.error).toContain("Nothing was extracted.");
475
+ expect(result.error).not.toContain(CF_TOKEN);
476
+ });
477
+ });
478
+
479
+ // ─── Config validation ──────────────────────────────────────
480
+
481
+ describe("system-one-extraction config", () => {
482
+ const parse = (overrides: Record<string, unknown>) =>
483
+ SystemOneExtractionConfigSchema.safeParse(config(overrides));
484
+ const field = (spec: Record<string, unknown>) => parse({ fields: { f: spec } });
485
+
486
+ test("the blog's fields are valid as written", () => {
487
+ expect(parse({}).success).toBe(true);
488
+ });
489
+
490
+ test("each type takes only its own options", () => {
491
+ expect(field({ type: "text", decimals: 2 }).success).toBe(false);
492
+ expect(field({ type: "date", unit: "days" }).success).toBe(false);
493
+ expect(field({ type: "number", decimals: 7 }).success).toBe(false);
494
+ expect(field({ type: "number", integer: true, decimals: 2 }).success).toBe(false);
495
+ expect(field({ type: "duration", unit: "fortnights" }).success).toBe(false);
496
+ expect(field({ type: "duration", unit: "weeks", integer: true }).success).toBe(true);
497
+ expect(field({ type: "list" }).success).toBe(false);
498
+ expect(field({ type: "list", items: "text", decimals: 2 }).success).toBe(false);
499
+ expect(field({ type: "list", items: "number", decimals: 2 }).success).toBe(true);
500
+ expect(field({ type: "text", null_threshold: 0 }).success).toBe(false);
501
+ expect(field({ type: "text", null_threshold: 0.8 }).success).toBe(true);
502
+ expect(field({ type: "sentiment" }).success).toBe(false);
503
+ });
504
+
505
+ test("estimates and forecasts need a valid range", () => {
506
+ expect(field({ type: "estimate" }).success).toBe(false);
507
+ expect(field({ type: "estimate", min: 10, max: 10 }).success).toBe(false);
508
+ expect(field({ type: "estimate", min: 0, max: 10, scale: "log" }).success).toBe(false);
509
+ expect(field({ type: "estimate", min: 10, max: "2026-01-01" }).success).toBe(false);
510
+ expect(field({ type: "estimate", min: 1, max: 10, step: "month" }).success).toBe(false);
511
+ expect(
512
+ field({ type: "estimate", min: "2026-01-01", max: "2027-01-01", step: "month" }).success,
513
+ ).toBe(true);
514
+ expect(field({ type: "forecast", min: 1, max: 10, horizon: 65 }).success).toBe(false);
515
+ expect(field({ type: "forecast", min: 1, max: 10, step: 1 }).success).toBe(false);
516
+ expect(field({ type: "forecast", min: 1, max: 10, horizon: 7, interval: "day" }).success).toBe(
517
+ true,
518
+ );
519
+ });
520
+
521
+ test("field ids are usable as output paths", () => {
522
+ expect(parse({ fields: { "order.id": { type: "text" } } }).success).toBe(false);
523
+ expect(parse({ fields: { constructor: { type: "text" } } }).success).toBe(false);
524
+ expect(parse({ fields: {} }).success).toBe(false);
525
+ });
526
+
527
+ test("state may be empty only with images; images are capped", () => {
528
+ const png = { content_type: "image/png", base64: "iVBORw0KGgo=" };
529
+ expect(parse({ state: "" }).success).toBe(false);
530
+ expect(parse({ state: "", images: [png] }).success).toBe(true);
531
+ expect(parse({ images: [png, png, png, png, png] }).success).toBe(false);
532
+ expect(parse({ images: ["https://example.com/receipt.png"] }).success).toBe(false);
533
+ expect(parse({ images: ["data:image/jpeg;base64,/9j/4AAQ"] }).success).toBe(true);
534
+ const big = { content_type: "image/png", base64: "A".repeat(6 * 1024 * 1024) };
535
+ expect(parse({ images: [big] }).success).toBe(false);
536
+ });
537
+
538
+ test("only Clairic models and extraction hosts are accepted", () => {
539
+ expect(parse({ model: "clef" }).success).toBe(false);
540
+ expect(parse({ provider: "typesafe" }).success).toBe(false);
541
+ expect(SYSTEM_ONE_EXTRACT_PROVIDER_IDS).toEqual(["cloudflare"]);
542
+ // The decision hosts are unchanged.
543
+ expect(SYSTEM_ONE_PROVIDER_IDS).toEqual([
544
+ "typesafe",
545
+ "openrouter",
546
+ "laya",
547
+ "openai",
548
+ "cloudflare",
549
+ ]);
550
+ });
551
+ });
552
+
553
+ // ─── Credentials and readiness ──────────────────────────────
554
+
555
+ describe("Cloudflare credential handling", () => {
556
+ const defaults = { getApiKey: undefined, getServerUrl: undefined };
557
+
558
+ test("CLOUDFLARE_API_KEY is used when CLOUDFLARE_API_TOKEN is unset, for Clairic and Clef", async () => {
559
+ await upsertSwarmConfig({ scope: "global", key: "CLOUDFLARE_ACCOUNT_ID", value: CF_ACCOUNT });
560
+ await upsertSwarmConfig({
561
+ scope: "global",
562
+ key: "CLOUDFLARE_API_KEY",
563
+ value: CF_KEY,
564
+ isSecret: true,
565
+ });
566
+
567
+ const { executor, calls } = makeExecutor([jsonResponse(envelope())], defaults);
568
+ expect((await run(executor, config())).status).toBe("success");
569
+ expect((calls[0]?.init.headers as Record<string, string>).authorization).toBe(
570
+ `Bearer ${CF_KEY}`,
571
+ );
572
+ expect(await executor.checkReadiness([{ id: "a", config: config() }])).toEqual([]);
573
+
574
+ const { transport, calls: clefCalls } = makeTransport([
575
+ jsonResponse({
576
+ success: true,
577
+ errors: [],
578
+ result: {
579
+ model: "clef",
580
+ answers: { urgent: { type: "noul", noul: 0.9 } },
581
+ usage: { input_tokens: 1, output_tokens: 1 },
582
+ },
583
+ }),
584
+ ]);
585
+ const clef = new SystemOneDecisionExecutor(deps, { fetch: transport, env: {} });
586
+ const decided = await clef.decide({
587
+ provider: "cloudflare",
588
+ state: "x",
589
+ questions: { urgent: { type: "noul", instructions: "Urgent?" } },
590
+ returns: { urgent: { type: "noul" } },
591
+ timeoutMs: 30_000,
592
+ maxRetries: 0,
593
+ });
594
+ expect(decided.ok).toBe(true);
595
+ expect(clefCalls[0]?.url).toBe(`${CF_BASE}/clef`);
596
+ expect((clefCalls[0]?.init.headers as Record<string, string>).authorization).toBe(
597
+ `Bearer ${CF_KEY}`,
598
+ );
599
+ });
600
+
601
+ test("CLOUDFLARE_API_TOKEN wins over CLOUDFLARE_API_KEY", async () => {
602
+ await upsertSwarmConfig({ scope: "global", key: "CLOUDFLARE_ACCOUNT_ID", value: CF_ACCOUNT });
603
+ await upsertSwarmConfig({
604
+ scope: "global",
605
+ key: "CLOUDFLARE_API_KEY",
606
+ value: CF_KEY,
607
+ isSecret: true,
608
+ });
609
+ await upsertSwarmConfig({
610
+ scope: "global",
611
+ key: "CLOUDFLARE_API_TOKEN",
612
+ value: CF_TOKEN,
613
+ isSecret: true,
614
+ });
615
+ const { executor, calls } = makeExecutor([jsonResponse(envelope())], defaults);
616
+ expect((await run(executor, config())).status).toBe("success");
617
+ expect((calls[0]?.init.headers as Record<string, string>).authorization).toBe(
618
+ `Bearer ${CF_TOKEN}`,
619
+ );
620
+ });
621
+
622
+ test("a rejected CLOUDFLARE_API_KEY is named as the key that was sent", async () => {
623
+ await upsertSwarmConfig({ scope: "global", key: "CLOUDFLARE_ACCOUNT_ID", value: CF_ACCOUNT });
624
+ await upsertSwarmConfig({
625
+ scope: "global",
626
+ key: "CLOUDFLARE_API_KEY",
627
+ value: CF_KEY,
628
+ isSecret: true,
629
+ });
630
+ const { executor } = makeExecutor(
631
+ [jsonResponse({ success: false, errors: [{ code: 10000 }] }, 403)],
632
+ defaults,
633
+ );
634
+ const result = await run(executor, config());
635
+ if (result.status !== "failed") throw new Error("expected failure");
636
+ expect(result.error).toContain(
637
+ "CLOUDFLARE_API_KEY was rejected by Cloudflare Workers AI (HTTP 403 (10000))",
638
+ );
639
+ expect(result.error).not.toContain(CF_KEY);
640
+ });
641
+
642
+ test("readiness names the account id and both key names when nothing is set", async () => {
643
+ const { executor, calls } = makeExecutor([jsonResponse(envelope())], defaults);
644
+ const problems = await executor.checkReadiness([
645
+ { id: "a", config: config() },
646
+ { id: "b", config: config() },
647
+ ]);
648
+ expect(problems).toHaveLength(1);
649
+ expect(problems[0]?.nodeIds).toEqual(["a", "b"]);
650
+ expect(problems[0]?.message).toContain("CLOUDFLARE_ACCOUNT_ID is not configured");
651
+ expect(problems[0]?.message).toContain("CLOUDFLARE_API_TOKEN is not configured");
652
+ expect(problems[0]?.message).toContain("CLOUDFLARE_API_KEY");
653
+ const result = await run(executor, config());
654
+ expect(result.status).toBe("failed");
655
+ expect(calls).toHaveLength(0);
656
+ });
657
+ });
658
+
659
+ // ─── Engine rules ───────────────────────────────────────────
660
+
661
+ describe("system-one-extraction in a workflow definition", () => {
662
+ const registry = () => createExecutorRegistry(deps);
663
+ const node = (overrides: Partial<WorkflowNode> = {}): WorkflowNode => ({
664
+ id: "claim",
665
+ type: "system-one-extraction",
666
+ inputs: { email: "trigger.email" },
667
+ config: config({ state: "{{email}}" }),
668
+ ...overrides,
669
+ });
670
+
671
+ test("a valid node saves; engine retries, a token provider, and token fields are refused", () => {
672
+ expect(validateDefinition({ nodes: [node()] }, registry()).valid).toBe(true);
673
+
674
+ const retry = validateDefinition({ nodes: [node({ retry: { maxRetries: 2 } })] }, registry());
675
+ expect(retry.errors.join(";")).toContain(
676
+ 'Node "claim" (system-one-extraction) must not set retry.maxRetries > 0',
677
+ );
678
+ const provider = validateDefinition(
679
+ { nodes: [node({ config: config({ provider: "{{p}}" }) })] },
680
+ registry(),
681
+ );
682
+ expect(provider.errors.join(";")).toContain("config.provider must be a literal");
683
+ const tokenFields = validateDefinition(
684
+ { nodes: [node({ config: config({ fields: "{{f}}" }) })] },
685
+ registry(),
686
+ );
687
+ expect(tokenFields.errors.join(";")).toContain("config.fields must be an object");
688
+ });
689
+
690
+ test("a templated instructions does not hide an invalid field spec or a token type", () => {
691
+ const save = (spec: Record<string, unknown>) =>
692
+ validateDefinition(
693
+ { nodes: [node({ config: config({ fields: { note: spec } }) })] },
694
+ registry(),
695
+ );
696
+
697
+ expect(save({ type: "text", instructions: "{{hint}}" }).valid).toBe(true);
698
+
699
+ // Regression: before the fix, the generic check skipped the whole spec because it held a token.
700
+ const badOption = save({ type: "text", instructions: "{{hint}}", decimals: 2 });
701
+ expect(badOption.valid).toBe(false);
702
+ expect(badOption.errors.join(";")).toContain("config.fields.note");
703
+
704
+ const tokenType = save({ type: "{{t}}", instructions: "{{hint}}" });
705
+ expect(tokenType.errors.join(";")).toContain(
706
+ "config.fields.note.type must be a literal: only instructions may use {{tokens}}",
707
+ );
708
+ const tokenTypeAlone = save({ type: "{{t}}" });
709
+ expect(tokenTypeAlone.errors.join(";")).toContain("config.fields.note.type must be a literal");
710
+
711
+ const tokenBound = save({ type: "estimate", min: "{{lo}}", max: 10 });
712
+ expect(tokenBound.errors.join(";")).toContain("config.fields.note.min must be a literal");
713
+ });
714
+
715
+ test("a saved templated field spec cannot resolve to a different shape or an invalid id", () => {
716
+ const saveFields = (fields: Record<string, unknown>) =>
717
+ validateDefinition({ nodes: [node({ config: config({ fields }) })] }, registry());
718
+ const hinted = { type: "text", instructions: "{{hint}}" };
719
+
720
+ // Regression: each of these saved valid and only failed the executor's input check at run time.
721
+ const refused: Array<[string, Record<string, unknown>, string]> = [
722
+ ["whole-token spec", { note: "{{spec}}" }, "config.fields.note must be an object"],
723
+ ["dotted id", { "bad.id": hinted }, "config.fields.bad.id: field ids must start"],
724
+ ["token id", { "{{field}}": hinted }, "config.fields.{{field}}: field ids must start"],
725
+ ["reserved id", { constructor: hinted }, '"constructor" is reserved'],
726
+ [
727
+ "list instructions",
728
+ { note: { type: "text", instructions: ["{{hint}}"] } },
729
+ "config.fields.note.instructions must be a string",
730
+ ],
731
+ [
732
+ "object instructions",
733
+ { note: { type: "text", instructions: { text: "{{hint}}" } } },
734
+ "config.fields.note.instructions must be a string",
735
+ ],
736
+ ];
737
+ for (const [label, fields, message] of refused) {
738
+ const result = saveFields(fields);
739
+ expect({ label, valid: result.valid }).toEqual({ label, valid: false });
740
+ expect(result.errors.join(";")).toContain(message);
741
+ }
742
+
743
+ // Literal specs with the same bad ids are still refused by the generic check.
744
+ expect(saveFields({ "{{field}}": { type: "text" } }).valid).toBe(false);
745
+ expect(saveFields({ constructor: { type: "text" } }).valid).toBe(false);
746
+
747
+ // What does save resolves to a config the executor accepts, with the static type intact.
748
+ const saved = node({ config: config({ fields: { note: hinted } }) });
749
+ expect(validateDefinition({ nodes: [saved] }, registry()).valid).toBe(true);
750
+ const resolved = interpolateNodeConfig(saved, { email: EMAIL, hint: "the order id" });
751
+ expect(resolved.strictUnresolved ?? []).toEqual([]);
752
+ const parsed = SystemOneExtractionConfigSchema.parse(resolved.value);
753
+ expect(parsed.fields.note).toEqual({ type: "text", instructions: "the order id" });
754
+ });
755
+
756
+ test("a {{token}} does not hide a whole-config limit at save", () => {
757
+ const save = (overrides: Record<string, unknown>) =>
758
+ validateDefinition({ nodes: [node({ config: config(overrides) })] }, registry());
759
+ const manyFields = (count: number) =>
760
+ Object.fromEntries(
761
+ Array.from({ length: count }, (_, i) => [
762
+ `f${i}`,
763
+ i === 0 ? { type: "text", instructions: "{{hint}}" } : { type: "text" },
764
+ ]),
765
+ );
766
+ const image = "data:image/png;base64,AAAA";
767
+
768
+ // Regression: each of these saved valid and only failed the executor's input check at run time.
769
+ const refused: Array<[string, Record<string, unknown>, string]> = [
770
+ ["65 fields, one templated", { fields: manyFields(65) }, "at most 64 fields"],
771
+ ["5 images, one templated", { images: [image, image, image, image, "{{img}}"] }, "images"],
772
+ ["unknown templated key", { extra: "{{hint}}" }, "extra"],
773
+ ];
774
+ for (const [label, overrides, message] of refused) {
775
+ const result = save(overrides);
776
+ expect({ label, valid: result.valid }).toEqual({ label, valid: false });
777
+ expect(result.errors.join(";")).toContain(message);
778
+ }
779
+
780
+ // Templated values that can still resolve to a valid config keep saving.
781
+ const accepted: Array<[string, Record<string, unknown>]> = [
782
+ ["64 fields, one templated", { fields: manyFields(64) }],
783
+ ["templated images", { state: "", images: [image, "{{img}}"] }],
784
+ ["whole-token images", { images: "{{imgs}}" }],
785
+ ["templated options", { model: "{{m}}", timeoutMs: "{{t}}", referenceDate: "{{d}}" }],
786
+ ];
787
+ for (const [label, overrides] of accepted) {
788
+ const result = save(overrides);
789
+ expect({ label, errors: result.errors }).toEqual({ label, errors: [] });
790
+ }
791
+ });
792
+
793
+ test("a whole-token state keeps its JSON type and an unresolved token is strict", () => {
794
+ const resolved = interpolateNodeConfig(node({ config: config({ state: "{{email}}" }) }), {
795
+ email: { subject: "Broken", body: EMAIL },
796
+ });
797
+ expect((resolved.value as Record<string, unknown>).state).toEqual({
798
+ subject: "Broken",
799
+ body: EMAIL,
800
+ });
801
+
802
+ const missing = interpolateNodeConfig(node({ config: config({ state: "{{nope}}" }) }), {});
803
+ expect(missing.strictUnresolved).toEqual(["nope"]);
804
+ });
805
+ });