okengine 0.22.0 → 0.23.1

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 (312) hide show
  1. package/AGENTS.md +7 -0
  2. package/manifest.v1.schema.json +44 -0
  3. package/package.json +32 -19
  4. package/site/content/docs/ai/mcp.mdx +14 -10
  5. package/site/content/docs/ai/skills.mdx +1 -1
  6. package/site/content/docs/elements/ai/agents.mdx +61 -13
  7. package/site/content/docs/elements/ai/decide.mdx +536 -0
  8. package/site/content/docs/elements/ai/events.mdx +373 -0
  9. package/site/content/docs/elements/ai/index.mdx +9 -3
  10. package/site/content/docs/elements/ai/meta.json +1 -1
  11. package/site/content/docs/elements/ai/prompts.mdx +4 -2
  12. package/site/content/docs/elements/channel/index.mdx +4 -4
  13. package/site/content/docs/elements/channel/receipts.mdx +7 -5
  14. package/site/content/docs/elements/channel/sms.mdx +2 -1
  15. package/site/content/docs/elements/flow/index.mdx +22 -18
  16. package/site/content/docs/elements/gate/auth.mdx +4 -1
  17. package/site/content/docs/elements/signal/index.mdx +15 -14
  18. package/site/content/docs/elements/store/index.mdx +1 -1
  19. package/site/content/docs/elements/store/kv.mdx +5 -5
  20. package/site/content/docs/plugins/otp.mdx +3 -3
  21. package/site/content/docs/recipes/dragonfly.mdx +5 -5
  22. package/site/content/docs/recipes/index.mdx +6 -6
  23. package/site/content/docs/recipes/postgres.mdx +12 -8
  24. package/site/content/docs/recipes/redis.mdx +4 -4
  25. package/site/content/docs/reference/cli.mdx +2 -1
  26. package/site/content/docs/reference/configuration.mdx +9 -7
  27. package/site/content/docs/reference/fx.mdx +19 -10
  28. package/src/auth/api-key-sql.test.ts +44 -0
  29. package/src/auth/api-key-sql.ts +1 -0
  30. package/src/auth/api-keys.ts +30 -12
  31. package/src/auth/config.ts +6 -2
  32. package/src/auth/gate-auth.test.ts +20 -0
  33. package/src/auth/tenants.ts +17 -0
  34. package/src/cli/agents-md.test.ts +25 -0
  35. package/src/cli/ask-vault-gaps.test.ts +38 -1
  36. package/src/cli/ask-vault-gaps.ts +35 -2
  37. package/src/cli/decide.ts +174 -0
  38. package/src/cli/decision-lock-watch.test.ts +45 -0
  39. package/src/cli/decision-lock-watch.ts +51 -0
  40. package/src/cli/dev-app-runner.ts +34 -8
  41. package/src/cli/dev-auth-secret.test.ts +49 -0
  42. package/src/cli/dev-auth-secret.ts +49 -0
  43. package/src/cli/dev-hot.test.ts +41 -0
  44. package/src/cli/dev-hot.ts +48 -0
  45. package/src/cli/dev.test.ts +1 -1
  46. package/src/cli/dev.ts +24 -6
  47. package/src/cli/doctor-diff.test.ts +29 -1
  48. package/src/cli/doctor-diff.ts +35 -0
  49. package/src/cli/eval.ts +142 -1
  50. package/src/cli/index.ts +5 -0
  51. package/src/cli/load-config.images.test.ts +2 -2
  52. package/src/cli/load-config.ts +1 -1
  53. package/src/cli/manifest-pr-diff.ts +16 -0
  54. package/src/cli/mcp-from-console.ts +48 -0
  55. package/src/cli/registry.ts +35 -1
  56. package/src/client/agent.ts +307 -0
  57. package/src/client/transport.test.ts +20 -0
  58. package/src/client/transport.ts +2 -1
  59. package/src/client/types.ts +1 -1
  60. package/src/client-react/index.ts +7 -1
  61. package/src/client-react/use-agent-run.test.ts +271 -0
  62. package/src/client-react/use-agent-run.ts +193 -0
  63. package/src/compiler/ai-approval.test.ts +36 -0
  64. package/src/compiler/ai-repair.test.ts +22 -0
  65. package/src/compiler/decisions.extract.test.ts +164 -0
  66. package/src/compiler/effects-fetch.test.ts +77 -0
  67. package/src/compiler/effects-infer.ts +114 -17
  68. package/src/compiler/effects-join.test.ts +48 -0
  69. package/src/compiler/extract.test.ts +28 -0
  70. package/src/compiler/extract.ts +279 -13
  71. package/src/compiler/fx-index.ts +58 -4
  72. package/src/compiler/response.ts +35 -2
  73. package/src/config/index.ts +21 -3
  74. package/src/console/index.ts +1 -1
  75. package/src/console/server/ai-runs-flows.ts +269 -0
  76. package/src/console/server/ai-runs.test.ts +323 -0
  77. package/src/console/server/ai-runs.ts +562 -0
  78. package/src/console/server/ai.ts +12 -1
  79. package/src/console/server/app.ts +10 -1
  80. package/src/console/server/decisions-flows.ts +157 -0
  81. package/src/console/server/decisions.test.ts +66 -0
  82. package/src/console/server/decisions.ts +229 -0
  83. package/src/console/server/flows.ts +50 -2
  84. package/src/console/server/invoke-user-flow.ts +9 -0
  85. package/src/console/server/runs-ingest.ts +16 -0
  86. package/src/console/server/serve.ts +5 -0
  87. package/src/console/server/state.ts +25 -1
  88. package/src/console/server/store-stats.test.ts +47 -0
  89. package/src/console/server/store-stats.ts +92 -18
  90. package/src/console/ui-next/dist/assets/access-page-C_N4qvai.js +4 -0
  91. package/src/console/ui-next/dist/assets/{agent-disclosure-Bkohc_8X.js → agent-disclosure-Cq1TxniW.js} +1 -1
  92. package/src/console/ui-next/dist/assets/{cache-glyph-rL0lHxat.js → cache-glyph-BaI4uxZz.js} +1 -1
  93. package/src/console/ui-next/dist/assets/{call-pii-button-Deo4PP18.js → call-pii-button-DNyK7IIY.js} +1 -1
  94. package/src/console/ui-next/dist/assets/{collapsible-CoJ6amHf.js → collapsible-CRhBJMZR.js} +1 -1
  95. package/src/console/ui-next/dist/assets/confirm-sheet-DZfuJXP4.js +1 -0
  96. package/src/console/ui-next/dist/assets/copy-inline-button-BTjPVjRT.js +1 -0
  97. package/src/console/ui-next/dist/assets/decisions-page-D9twj8Xy.js +1 -0
  98. package/src/console/ui-next/dist/assets/detail-header-Dk5LipF6.js +1 -0
  99. package/src/console/ui-next/dist/assets/dropdown-menu-CRGrj5JU.js +1 -0
  100. package/src/console/ui-next/dist/assets/duration-tone-CsHWlVPU.js +9 -0
  101. package/src/console/ui-next/dist/assets/element-icons-C6t8Vlml.js +1 -0
  102. package/src/console/ui-next/dist/assets/explorer-chrome-Dslh2yE8.js +1 -0
  103. package/src/console/ui-next/dist/assets/explorer-empty-CIVg7Jgb.js +1 -0
  104. package/src/console/ui-next/dist/assets/flows-page-ZaTJAhR9.js +1 -0
  105. package/src/console/ui-next/dist/assets/{highlighted-json-CPYBzDh0.js → highlighted-json-DQ-EMAfz.js} +1 -1
  106. package/src/console/ui-next/dist/assets/{http-method-DTPevKmM.js → http-method-Cn_Fl79s.js} +1 -1
  107. package/src/console/ui-next/dist/assets/index-BlrN49Hs.css +2 -0
  108. package/src/console/ui-next/dist/assets/index-CmRFeWdT.js +58 -0
  109. package/src/console/ui-next/dist/assets/observability-page-Cn6R_dXz.js +8 -0
  110. package/src/console/ui-next/dist/assets/{replica-lag-BM27lXld.js → replica-lag-Bx6EZhSe.js} +7 -7
  111. package/src/console/ui-next/dist/assets/request-meta-DmdiUrfh.js +1 -0
  112. package/src/console/ui-next/dist/assets/shortcut-keys-DTE1sjTC.js +1 -0
  113. package/src/console/ui-next/dist/assets/store-page-8P6vGXnn.js +41 -0
  114. package/src/console/ui-next/dist/assets/trace-detail-sheet-BfGvpFSv.js +2 -0
  115. package/src/console/ui-next/dist/assets/tree-expand-toggle-CWdUHbc0.js +55 -0
  116. package/src/console/ui-next/dist/assets/units-page-CZOFZzay.js +1 -0
  117. package/src/console/ui-next/dist/assets/use-vault-list-BAyQG8Re.js +1 -0
  118. package/src/console/ui-next/dist/assets/vault-page-ki_ishmX.js +2 -0
  119. package/src/console/ui-next/dist/index.html +4 -3
  120. package/src/console/ui-next/seed-parked-approval.ts +126 -0
  121. package/src/console/ui-next/src/client.ts +224 -1
  122. package/src/console/ui-next/src/components/explorer/detail-header.tsx +9 -0
  123. package/src/console/ui-next/src/components/explorer/explorer-start-toggle.tsx +5 -1
  124. package/src/console/ui-next/src/components/ui/sheet-form.tsx +21 -12
  125. package/src/console/ui-next/src/features/flows/decisions/decisions-page.tsx +216 -0
  126. package/src/console/ui-next/src/features/flows/decisions/resolve.test.ts +48 -0
  127. package/src/console/ui-next/src/features/flows/decisions/resolve.ts +111 -0
  128. package/src/console/ui-next/src/features/flows/flows-page.tsx +10 -0
  129. package/src/console/ui-next/src/features/flows/graph/element-map.ts +1 -0
  130. package/src/console/ui-next/src/features/flows/traces/effect-kind.ts +6 -0
  131. package/src/console/ui-next/src/features/flows/traces/effect-summary.test.ts +24 -0
  132. package/src/console/ui-next/src/features/flows/traces/effect-summary.ts +24 -0
  133. package/src/console/ui-next/src/features/flows/traces/http-status.ts +58 -0
  134. package/src/console/ui-next/src/features/flows/traces/request-client-card.tsx +355 -0
  135. package/src/console/ui-next/src/features/flows/traces/request-client.test.ts +114 -0
  136. package/src/console/ui-next/src/features/flows/traces/request-client.ts +423 -0
  137. package/src/console/ui-next/src/features/flows/traces/request-input-view.test.ts +52 -6
  138. package/src/console/ui-next/src/features/flows/traces/request-input-view.ts +85 -19
  139. package/src/console/ui-next/src/features/flows/traces/trace-detail-sheet.tsx +505 -80
  140. package/src/console/ui-next/src/features/flows/traces/trace-lanes.test.ts +61 -0
  141. package/src/console/ui-next/src/features/flows/traces/trace-lanes.ts +78 -0
  142. package/src/console/ui-next/src/features/flows/traces/trace-nav.test.ts +48 -0
  143. package/src/console/ui-next/src/features/flows/traces/trace-nav.ts +57 -0
  144. package/src/console/ui-next/src/features/flows/traces/trace-request-section.tsx +275 -96
  145. package/src/console/ui-next/src/features/flows/traces/traces-pane.tsx +16 -2
  146. package/src/console/ui-next/src/features/observability/detail/ai-runs.tsx +369 -0
  147. package/src/console/ui-next/src/features/observability/observability-page.tsx +31 -1
  148. package/src/console/ui-next/src/features/observability/state/observability-selection.ts +18 -3
  149. package/src/console/ui-next/src/features/store/performance/kv-performance-panel.tsx +12 -1
  150. package/src/console/ui-next/src/features/store/performance/performance-panel.tsx +14 -1
  151. package/src/console/ui-next/src/features/units/call/call-api-panel.tsx +1 -1
  152. package/src/console/ui-next/src/features/units/detail/flow-contract-panel.tsx +6 -2
  153. package/src/console/ui-next/src/features/units/explorer/units-tree.tsx +73 -18
  154. package/src/console/ui-next/src/features/units/lib/unit-tree.test.ts +21 -0
  155. package/src/console/ui-next/src/features/units/lib/unit-tree.ts +82 -0
  156. package/src/console/ui-next/src/features/units/units-page.tsx +25 -7
  157. package/src/console/ui-next/src/router.tsx +17 -0
  158. package/src/console/ui-next/ui-next-seed-runs.ts +23 -0
  159. package/src/console/ui-next/vite-console-kernel-plugin.ts +43 -3
  160. package/src/console/ui-next/vite.config.ts +9 -0
  161. package/src/docker/recipes/dragonfly.ts +3 -2
  162. package/src/docker/recipes/redis.ts +1 -1
  163. package/src/drivers/ai-anthropic.ts +212 -21
  164. package/src/drivers/ai-mock.ts +34 -1
  165. package/src/drivers/ai-openai-compatible.ts +67 -26
  166. package/src/drivers/ai-preconnect.ts +18 -0
  167. package/src/drivers/ai-stream.test.ts +62 -0
  168. package/src/drivers/ai-types.ts +20 -0
  169. package/src/drivers/journal-postgres.test.ts +22 -1
  170. package/src/drivers/journal-postgres.ts +511 -13
  171. package/src/drivers/memory-ddl.test.ts +18 -0
  172. package/src/drivers/memory.ts +21 -1
  173. package/src/drivers/pg-rls.test.ts +31 -0
  174. package/src/drivers/pg-rls.ts +15 -0
  175. package/src/drivers/postgres.test.ts +9 -0
  176. package/src/drivers/postgres.ts +49 -11
  177. package/src/drivers/signal-compete.test.ts +234 -0
  178. package/src/drivers/signal-postgres.ts +39 -19
  179. package/src/drivers/signal-redis.ts +167 -11
  180. package/src/drivers/signal-types.ts +12 -0
  181. package/src/elements/ai/agent-event-slot.ts +27 -0
  182. package/src/elements/ai/approval-http.ts +207 -0
  183. package/src/elements/ai/approval.test.ts +741 -0
  184. package/src/elements/ai/approval.ts +228 -0
  185. package/src/elements/ai/decisions/bind.ts +163 -0
  186. package/src/elements/ai/decisions/certificate.test.ts +291 -0
  187. package/src/elements/ai/decisions/certificate.ts +377 -0
  188. package/src/elements/ai/decisions/certify.ts +314 -0
  189. package/src/elements/ai/decisions/decide.test.ts +953 -0
  190. package/src/elements/ai/decisions/e2e.test.ts +399 -0
  191. package/src/elements/ai/decisions/export.test.ts +120 -0
  192. package/src/elements/ai/decisions/export.ts +146 -0
  193. package/src/elements/ai/decisions/fixtures/openrouter-request.json +24 -0
  194. package/src/elements/ai/decisions/fixtures/openrouter-response.json +22 -0
  195. package/src/elements/ai/decisions/fixtures/typesafe-request.json +19 -0
  196. package/src/elements/ai/decisions/fixtures/typesafe-response.json +13 -0
  197. package/src/elements/ai/decisions/http.test.ts +154 -0
  198. package/src/elements/ai/decisions/http.ts +240 -0
  199. package/src/elements/ai/decisions/labels.test.ts +282 -0
  200. package/src/elements/ai/decisions/labels.ts +257 -0
  201. package/src/elements/ai/decisions/openrouter.live.test.ts +136 -0
  202. package/src/elements/ai/decisions/openrouter.ts +36 -0
  203. package/src/elements/ai/decisions/provider.ts +102 -0
  204. package/src/elements/ai/decisions/typesafe.ts +36 -0
  205. package/src/elements/ai/declare.ts +252 -1
  206. package/src/elements/ai/events.test.ts +379 -0
  207. package/src/elements/ai/events.ts +138 -0
  208. package/src/elements/ai/mcp-protocol.ts +5 -6
  209. package/src/elements/ai/run-events.test.ts +461 -0
  210. package/src/elements/ai/run-events.ts +522 -0
  211. package/src/elements/ai/runtime.ts +1187 -105
  212. package/src/elements/ai/stream-turn.ts +214 -0
  213. package/src/elements/ai/stream.live.test.ts +76 -0
  214. package/src/elements/ai/subagent.test.ts +136 -0
  215. package/src/elements/ai.test.ts +497 -3
  216. package/src/elements/ai.ts +16 -1
  217. package/src/elements/channel/runtime.ts +11 -0
  218. package/src/elements/channel/sql-ledger.ts +251 -0
  219. package/src/elements/clock/durable.ts +27 -1
  220. package/src/elements/signal/runtime.ts +9 -0
  221. package/src/elements/signal.test.ts +1 -0
  222. package/src/elements/store/cache.test.ts +120 -15
  223. package/src/elements/store/cache.ts +200 -39
  224. package/src/elements/store/prepare-row.test.ts +6 -2
  225. package/src/elements/store/sql-session.test.ts +53 -0
  226. package/src/elements/store/sql-session.ts +206 -10
  227. package/src/elements/store.ts +1 -0
  228. package/src/i18n/catalogs/ar.ts +5 -0
  229. package/src/i18n/catalogs/en.ts +5 -0
  230. package/src/index.ts +2 -0
  231. package/src/kernel/agent-event-store.ts +448 -0
  232. package/src/kernel/app.ts +135 -17
  233. package/src/kernel/auto-cache.test.ts +228 -0
  234. package/src/kernel/boot-bind/ai.ts +3 -0
  235. package/src/kernel/boot-bind/channel.ts +63 -2
  236. package/src/kernel/boot-bind/honor-config.test.ts +40 -10
  237. package/src/kernel/boot-bind/signal.ts +61 -25
  238. package/src/kernel/boot-bind/store.test.ts +26 -0
  239. package/src/kernel/boot-bind/store.ts +82 -6
  240. package/src/kernel/boot.ts +31 -6
  241. package/src/kernel/builtin-errors.ts +2 -0
  242. package/src/kernel/capability.ts +3 -0
  243. package/src/kernel/decision-label-store.ts +441 -0
  244. package/src/kernel/effects.test.ts +2 -1
  245. package/src/kernel/effects.ts +13 -1
  246. package/src/kernel/element-registries.ts +3 -0
  247. package/src/kernel/errors-compiler.ts +11 -0
  248. package/src/kernel/errors-text.ts +20 -0
  249. package/src/kernel/errors.ts +10 -0
  250. package/src/kernel/flow.test.ts +2 -3
  251. package/src/kernel/flow.ts +33 -3
  252. package/src/kernel/fx-call-types.test.ts +32 -0
  253. package/src/kernel/fx-decide.ts +765 -0
  254. package/src/kernel/fx-sql-handle.ts +46 -7
  255. package/src/kernel/fx.test.ts +13 -8
  256. package/src/kernel/fx.ts +339 -45
  257. package/src/kernel/http-frame.test.ts +94 -0
  258. package/src/kernel/http-frame.ts +203 -0
  259. package/src/kernel/idempotency.test.ts +2 -1
  260. package/src/kernel/journal.test.ts +114 -0
  261. package/src/kernel/journal.ts +471 -51
  262. package/src/kernel/json-result.ts +10 -0
  263. package/src/kernel/pipeline-tenant.ts +5 -1
  264. package/src/kernel/pipeline.test.ts +45 -0
  265. package/src/kernel/pipeline.ts +5 -0
  266. package/src/kernel/signal-tx.ts +28 -0
  267. package/src/kernel/sse-id.ts +32 -0
  268. package/src/kernel/store-transaction.test.ts +95 -0
  269. package/src/manifest/diff.ts +1 -0
  270. package/src/manifest/types.ts +29 -2
  271. package/src/mcp/ai-tools.test.ts +202 -0
  272. package/src/mcp/authorization.ts +66 -0
  273. package/src/mcp/docs-server.ts +19 -2
  274. package/src/mcp/mcp.test.ts +83 -0
  275. package/src/mcp/protocol.ts +8 -2
  276. package/src/mcp/server.ts +23 -2
  277. package/src/mcp/session.ts +3 -0
  278. package/src/mcp/tools.ts +168 -0
  279. package/src/mcp/versions.ts +60 -0
  280. package/src/plugins/index.ts +1 -0
  281. package/src/plugins/mena.ts +56 -0
  282. package/src/release/http-graph.test.ts +2 -0
  283. package/src/release/http-graph.ts +5 -0
  284. package/src/release/measure.ts +39 -14
  285. package/src/runs/collect.ts +4 -1
  286. package/src/runs/parquet.test.ts +27 -1
  287. package/src/runs/parquet.ts +19 -0
  288. package/src/runs/types.ts +31 -0
  289. package/src/runtime/json-code-block.test.ts +106 -47
  290. package/src/runtime/json-code-block.ts +1209 -46
  291. package/src/term.test.ts +13 -0
  292. package/src/term.ts +81 -49
  293. package/src/console/ui-next/dist/assets/PlusSignIcon-CwG3nxfu.js +0 -1
  294. package/src/console/ui-next/dist/assets/access-page-Ze6ckjdd.js +0 -4
  295. package/src/console/ui-next/dist/assets/copy-inline-button-CWG5_ZOh.js +0 -1
  296. package/src/console/ui-next/dist/assets/detail-header-b_ZzIsmU.js +0 -1
  297. package/src/console/ui-next/dist/assets/dropdown-menu-nwXEO1Ac.js +0 -1
  298. package/src/console/ui-next/dist/assets/duration-tone-BbQ_8z50.js +0 -9
  299. package/src/console/ui-next/dist/assets/element-icons-CwyLpVXz.js +0 -1
  300. package/src/console/ui-next/dist/assets/explorer-empty-C8sSCqYT.js +0 -1
  301. package/src/console/ui-next/dist/assets/flows-page-D2E7BtKK.js +0 -1
  302. package/src/console/ui-next/dist/assets/index-VxoEz295.css +0 -2
  303. package/src/console/ui-next/dist/assets/index-vTuwmeQz.js +0 -58
  304. package/src/console/ui-next/dist/assets/observability-page-SJTSzHFJ.js +0 -4
  305. package/src/console/ui-next/dist/assets/request-meta-BjFT7DNl.js +0 -1
  306. package/src/console/ui-next/dist/assets/shortcut-keys-CUNegEV4.js +0 -1
  307. package/src/console/ui-next/dist/assets/store-page-Qj9ThRE-.js +0 -41
  308. package/src/console/ui-next/dist/assets/trace-detail-sheet-Cmitq3da.js +0 -2
  309. package/src/console/ui-next/dist/assets/tree-expand-toggle-CbDIB-7x.js +0 -55
  310. package/src/console/ui-next/dist/assets/units-page-_PuYqFty.js +0 -1
  311. package/src/console/ui-next/dist/assets/use-vault-list-Cs45Czkt.js +0 -1
  312. package/src/console/ui-next/dist/assets/vault-page-BOMr0go9.js +0 -2
@@ -0,0 +1,741 @@
1
+ /**
2
+ * Durable tool approval — park, first resolution wins, resume replays the tool.
3
+ */
4
+
5
+ import { afterEach, describe, expect, test } from "bun:test";
6
+ import type { AiMessage } from "../../drivers/ai-types.ts";
7
+ import { flow, resetFlowSeq, type AnyFlowDef } from "../../kernel/flow.ts";
8
+ import { createJournal, createMemoryJournalStore, isJournalSuspend } from "../../kernel/journal.ts";
9
+ import type { JournalSession } from "../../kernel/journal.ts";
10
+ import { oke, type OkeApp } from "../../kernel/app.ts";
11
+ import { resetBindings, type Binding } from "../../kernel/on.ts";
12
+ import { http } from "../../kernel/triggers.ts";
13
+ import { ai, createAiRuntime, type AiRuntime } from "../ai.ts";
14
+ import {
15
+ createPostgresJournalFake,
16
+ createPostgresJournalStore,
17
+ } from "../../drivers/journal-postgres.ts";
18
+ import { parseApprovalId, readAgentApproval, resolveAgentApproval } from "./approval.ts";
19
+ import { createGateRuntime, gate } from "../gate.ts";
20
+ import { getAgentEventLog } from "./run-events.ts";
21
+
22
+ const apps: OkeApp[] = [];
23
+
24
+ afterEach(async () => {
25
+ resetBindings();
26
+ resetFlowSeq();
27
+ for (const app of apps) await app.bootResult?.close();
28
+ apps.length = 0;
29
+ });
30
+
31
+ function model(seen: AiMessage[][], toolCallId = "tc1") {
32
+ return {
33
+ driverId: "mock" as const,
34
+ model: "smart",
35
+ async complete(opts: { messages: readonly AiMessage[] }) {
36
+ seen.push([...opts.messages]);
37
+ const answered = opts.messages.some((message) => message.role === "tool");
38
+ if (answered) {
39
+ return { text: "done", raw: {}, model: "smart", driverId: "mock" as const };
40
+ }
41
+ return {
42
+ text: "",
43
+ raw: {},
44
+ model: "smart",
45
+ driverId: "mock" as const,
46
+ toolCalls: [{ id: toolCallId, name: "refund", arguments: { amount: 10 } }],
47
+ };
48
+ },
49
+ };
50
+ }
51
+
52
+ function runtime(opts: {
53
+ readonly seen: AiMessage[][];
54
+ readonly calls: unknown[];
55
+ readonly store: ReturnType<typeof createMemoryJournalStore>;
56
+ readonly now: () => number;
57
+ readonly approval?: boolean | ((input: unknown) => boolean);
58
+ readonly allow?: boolean;
59
+ readonly toolCallId?: string;
60
+ }): AiRuntime {
61
+ const ops = gate.policy("ops", () => opts.allow !== false);
62
+ return createAiRuntime({
63
+ now: opts.now,
64
+ journalStore: opts.store,
65
+ models: [ai.model("smart")],
66
+ gates: createGateRuntime({ gates: [ops] }),
67
+ agents: [
68
+ ai.agent("support", {
69
+ model: "smart",
70
+ maxSteps: 4,
71
+ tools: [
72
+ {
73
+ name: "refund",
74
+ approval: opts.approval ?? true,
75
+ gate: "ops",
76
+ timeout: "1h",
77
+ },
78
+ ],
79
+ }),
80
+ ],
81
+ clients: { smart: model(opts.seen, opts.toolCallId) },
82
+ callFlow: async (_name, input) => {
83
+ opts.calls.push(input);
84
+ return { refunded: true };
85
+ },
86
+ });
87
+ }
88
+
89
+ function parkedId(session: JournalSession): string {
90
+ const entry = session.run.entries.find(
91
+ (item) => item.kind === "step" && item.name.startsWith("ai-approval:"),
92
+ );
93
+ if (!entry || entry.kind !== "step") throw new Error("expected a pending approval");
94
+ return entry.name.slice("ai-approval:".length);
95
+ }
96
+
97
+ async function park(aiRuntime: AiRuntime, session: JournalSession): Promise<void> {
98
+ try {
99
+ await aiRuntime.runAgent("support", {
100
+ message: "refund",
101
+ journal: session,
102
+ flow: "assist",
103
+ });
104
+ throw new Error("expected suspend");
105
+ } catch (err) {
106
+ if (!isJournalSuspend(err)) throw err;
107
+ }
108
+ }
109
+
110
+ describe("durable tool approval", () => {
111
+ test("approve, edited args, 409, and resume does not call the tool twice", async () => {
112
+ const seen: AiMessage[][] = [];
113
+ const calls: unknown[] = [];
114
+ const store = createMemoryJournalStore();
115
+ const now = () => 1_000_000;
116
+ const aiRuntime = runtime({ seen, calls, store, now });
117
+ const journal = createJournal({ store, now });
118
+ const session = await journal.start("assist", { message: "refund" });
119
+ await park(aiRuntime, session);
120
+
121
+ const id = parkedId(session);
122
+ const pending = await readAgentApproval(store, id);
123
+ expect(pending?.status).toBe("pending");
124
+ expect(pending?.tool).toBe("refund");
125
+
126
+ const approved = await resolveAgentApproval(
127
+ store,
128
+ id,
129
+ { decision: "approve", args: { amount: 4 }, approver: "sam", tenant: null },
130
+ now,
131
+ );
132
+ expect(approved).toEqual({ ok: true });
133
+ expect(await resolveAgentApproval(store, id, { decision: "deny", tenant: null }, now)).toEqual({
134
+ ok: false,
135
+ status: 409,
136
+ reason: "resolved",
137
+ });
138
+
139
+ const resumed = await journal.resume(session.runId);
140
+ const result = await aiRuntime.runAgent("support", {
141
+ message: "refund",
142
+ journal: resumed,
143
+ flow: "assist",
144
+ });
145
+ expect(result.stopReason).toBe("completed");
146
+ expect(calls).toEqual([{ amount: 4 }]);
147
+ expect(result.trail[0]?.approver).toBe("sam");
148
+
149
+ const again = await journal.resume(session.runId);
150
+ await aiRuntime.runAgent("support", {
151
+ message: "refund",
152
+ journal: again,
153
+ flow: "assist",
154
+ });
155
+ expect(calls).toEqual([{ amount: 4 }]);
156
+ });
157
+
158
+ test("deny feeds the reason to the model and does not set stopReason denied", async () => {
159
+ const seen: AiMessage[][] = [];
160
+ const calls: unknown[] = [];
161
+ const store = createMemoryJournalStore();
162
+ const now = () => 1_000_000;
163
+ const aiRuntime = runtime({ seen, calls, store, now });
164
+ const journal = createJournal({ store, now });
165
+ const session = await journal.start("assist", {});
166
+ await park(aiRuntime, session);
167
+ await resolveAgentApproval(
168
+ store,
169
+ parkedId(session),
170
+ { decision: "deny", reason: "over the limit", tenant: null },
171
+ now,
172
+ );
173
+ const resumed = await journal.resume(session.runId);
174
+ const result = await aiRuntime.runAgent("support", {
175
+ message: "refund",
176
+ journal: resumed,
177
+ flow: "assist",
178
+ });
179
+ expect(calls).toEqual([]);
180
+ expect(result.stopReason).toBe("completed");
181
+ const tool = seen.flat().find((message) => message.role === "tool");
182
+ expect(tool?.content).toContain("over the limit");
183
+ });
184
+
185
+ test("timeout denies and a new journal still sees a pending row before it", async () => {
186
+ const seen: AiMessage[][] = [];
187
+ const calls: unknown[] = [];
188
+ let clock = 5_000_000;
189
+ const store = createMemoryJournalStore();
190
+ const aiRuntime = runtime({ seen, calls, store, now: () => clock });
191
+ const journal = createJournal({ store, now: () => clock });
192
+ const session = await journal.start("assist", {});
193
+ await park(aiRuntime, session);
194
+
195
+ const restarted = createJournal({ store, now: () => clock });
196
+ expect((await readAgentApproval(restarted.store, parkedId(session)))?.status).toBe("pending");
197
+
198
+ clock += 60 * 60 * 1000 + 1;
199
+ const resumed = await restarted.resume(session.runId);
200
+ const result = await aiRuntime.runAgent("support", {
201
+ message: "refund",
202
+ journal: resumed,
203
+ flow: "assist",
204
+ });
205
+ expect(calls).toEqual([]);
206
+ expect(result.stopReason).toBe("completed");
207
+ const tool = seen.flat().find((message) => message.role === "tool");
208
+ expect(tool?.content).toContain("timeout");
209
+ });
210
+
211
+ test("a non-durable caller is refused and a false predicate does not park", async () => {
212
+ const seen: AiMessage[][] = [];
213
+ const calls: unknown[] = [];
214
+ const store = createMemoryJournalStore();
215
+ const aiRuntime = runtime({ seen, calls, store, now: () => 1 });
216
+ await expect(
217
+ aiRuntime.runAgent("support", { message: "refund", flow: "assist" }),
218
+ ).rejects.toThrow(/assist.*durable: true/);
219
+
220
+ const open = runtime({
221
+ seen,
222
+ calls,
223
+ store,
224
+ now: () => 1,
225
+ approval: () => false,
226
+ });
227
+ const result = await open.runAgent("support", { message: "refund", flow: "assist" });
228
+ expect(result.stopReason).toBe("completed");
229
+ expect(calls).toEqual([{ amount: 10 }]);
230
+ });
231
+
232
+ test("a denying gate is 403 and a mismatched tenant is 404", async () => {
233
+ const store = createMemoryJournalStore();
234
+ const now = () => 1;
235
+ const aiRuntime = runtime({ seen: [], calls: [], store, now, allow: false });
236
+ const journal = createJournal({ store, now });
237
+ const session = await journal.start("assist", {});
238
+ await park(aiRuntime, session);
239
+ const ctx = {
240
+ auth: { userId: null, scopes: new Set<string>() },
241
+ operator: { id: null },
242
+ };
243
+ expect(
244
+ await aiRuntime.resolveApproval(
245
+ parkedId(session),
246
+ { decision: "approve", tenant: null },
247
+ ctx,
248
+ ),
249
+ ).toEqual({
250
+ ok: false,
251
+ status: 403,
252
+ });
253
+ const pendingId = parkedId(session);
254
+ expect((await readAgentApproval(store, pendingId))?.status).toBe("pending");
255
+ const allowed = runtime({ seen: [], calls: [], store, now });
256
+ expect(
257
+ await allowed.resolveApproval(pendingId, { decision: "approve", tenant: "other" }, ctx),
258
+ ).toEqual({ ok: false, status: 404 });
259
+ });
260
+ });
261
+
262
+ describe("approval http", () => {
263
+ test("approve is gated, idempotent, and a later decision is 409", async () => {
264
+ const calls: unknown[] = [];
265
+ const store = createMemoryJournalStore();
266
+ const ops = gate.policy("ops", () => true);
267
+ const aiRuntime = createAiRuntime({
268
+ journalStore: store,
269
+ models: [ai.model("smart")],
270
+ gates: createGateRuntime({ gates: [ops] }),
271
+ agents: [
272
+ ai.agent("support", {
273
+ model: "smart",
274
+ maxSteps: 4,
275
+ tools: [{ name: "refund", approval: true, gate: "ops" }],
276
+ }),
277
+ ],
278
+ clients: {
279
+ smart: {
280
+ driverId: "mock",
281
+ model: "smart",
282
+ async complete(opts) {
283
+ if (opts.messages.some((message) => message.role === "tool")) {
284
+ return { text: "done", raw: {}, model: "smart", driverId: "mock" };
285
+ }
286
+ return {
287
+ text: "",
288
+ raw: {},
289
+ model: "smart",
290
+ driverId: "mock",
291
+ toolCalls: [{ id: "tc1", name: "refund", arguments: { amount: 10 } }],
292
+ };
293
+ },
294
+ },
295
+ },
296
+ });
297
+ const refund = flow("refund", {
298
+ do: async (input) => {
299
+ calls.push(input);
300
+ return { refunded: true };
301
+ },
302
+ });
303
+ const assist: Binding = {
304
+ trigger: http.post("/assist"),
305
+ flow: flow("assist", {
306
+ durable: true,
307
+ effects: { asks: ["support"], calls: ["refund"] },
308
+ do: async (_input, fx) => fx.run("support", { message: "refund" }),
309
+ }) as AnyFlowDef,
310
+ };
311
+ resetBindings();
312
+ const app = oke({
313
+ name: "approval-http",
314
+ env: "test",
315
+ startScheduler: false,
316
+ registry: "ignore",
317
+ gate: { unguardedHttp: "allow", policies: [ops] },
318
+ bindings: [assist, { trigger: http.post("/refund"), flow: refund as AnyFlowDef }],
319
+ elements: {
320
+ journal: { store, instanceId: "approval-test", leaseMs: 30_000, driverId: "memory" },
321
+ ai: aiRuntime,
322
+ },
323
+ });
324
+ await app.boot({ env: "test" });
325
+ apps.push(app);
326
+
327
+ const parked = await app.fetch(
328
+ new Request("http://localhost/assist", { method: "POST", body: "{}" }),
329
+ );
330
+ expect(parked.status).toBeLessThan(500);
331
+ const runs = await store.list();
332
+ const step = runs
333
+ .flatMap((run) => run.entries)
334
+ .find((entry) => entry.kind === "step" && entry.name.startsWith("ai-approval:"));
335
+ if (!step || step.kind !== "step") throw new Error("expected a pending approval");
336
+ const id = step.name.slice("ai-approval:".length);
337
+ expect((await readAgentApproval(store, id))?.status).toBe("pending");
338
+
339
+ const key = "approval-key-0001";
340
+ const first = await app.fetch(
341
+ new Request("http://localhost/agent/approvals/approve", {
342
+ method: "POST",
343
+ headers: {
344
+ "content-type": "application/json",
345
+ "idempotency-key": key,
346
+ },
347
+ body: JSON.stringify({ id, args: { amount: 4 } }),
348
+ }),
349
+ );
350
+ expect(first.status).toBe(200);
351
+
352
+ const replay = await app.fetch(
353
+ new Request("http://localhost/agent/approvals/approve", {
354
+ method: "POST",
355
+ headers: {
356
+ "content-type": "application/json",
357
+ "idempotency-key": key,
358
+ },
359
+ body: JSON.stringify({ id, args: { amount: 4 } }),
360
+ }),
361
+ );
362
+ expect(replay.status).toBe(200);
363
+ expect(replay.headers.get("idempotent-replayed")).toBe("true");
364
+
365
+ const conflict = await app.fetch(
366
+ new Request("http://localhost/agent/approvals/deny", {
367
+ method: "POST",
368
+ headers: { "content-type": "application/json" },
369
+ body: JSON.stringify({ id, reason: "late" }),
370
+ }),
371
+ );
372
+ expect(conflict.status).toBe(409);
373
+ expect(conflict.headers.get("retry-after")).toBeNull();
374
+ const conflictBody = (await conflict.json()) as { error: { code: string } };
375
+ expect(conflictBody.error.code).toBe("Conflict");
376
+
377
+ await app.resumeDurable(Date.now() + 1000);
378
+ expect(calls).toEqual([{ amount: 4 }]);
379
+ await app.resumeDurable(Date.now() + 1000);
380
+ expect(calls).toEqual([{ amount: 4 }]);
381
+ }, 20_000);
382
+
383
+ test("streamed approve, deny, and timeout park sleeping then resume the real output", async () => {
384
+ const calls: unknown[] = [];
385
+ const store = createMemoryJournalStore();
386
+ const ops = gate.policy("ops", () => true);
387
+ const aiRuntime = createAiRuntime({
388
+ journalStore: store,
389
+ models: [ai.model("smart")],
390
+ gates: createGateRuntime({ gates: [ops] }),
391
+ agents: [
392
+ ai.agent("support", {
393
+ model: "smart",
394
+ maxSteps: 4,
395
+ tools: [{ name: "refund", approval: true, gate: "ops", timeout: "1h" }],
396
+ }),
397
+ ],
398
+ clients: {
399
+ smart: {
400
+ driverId: "mock",
401
+ model: "smart",
402
+ async complete(opts) {
403
+ if (opts.messages.some((message) => message.role === "tool")) {
404
+ return { text: "done", raw: {}, model: "smart", driverId: "mock" };
405
+ }
406
+ return {
407
+ text: "",
408
+ raw: {},
409
+ model: "smart",
410
+ driverId: "mock",
411
+ toolCalls: [{ id: "tc1", name: "refund", arguments: { amount: 10 } }],
412
+ };
413
+ },
414
+ },
415
+ },
416
+ });
417
+ const refund = flow("refund", {
418
+ do: async (input) => {
419
+ calls.push(input);
420
+ return { refunded: true };
421
+ },
422
+ });
423
+ const assist: Binding = {
424
+ trigger: http.post("/assist"),
425
+ flow: flow("assist", {
426
+ durable: true,
427
+ effects: { asks: ["support"], calls: ["refund"] },
428
+ do: (_input, fx) =>
429
+ fx.json.stream(fx.run("support", { message: "refund" }, { stream: true })),
430
+ }) as AnyFlowDef,
431
+ };
432
+ const boot = async (name: string) => {
433
+ resetBindings();
434
+ const app = oke({
435
+ name,
436
+ env: "test",
437
+ startScheduler: false,
438
+ registry: "ignore",
439
+ gate: { unguardedHttp: "allow", policies: [ops] },
440
+ bindings: [assist, { trigger: http.post("/refund"), flow: refund as AnyFlowDef }],
441
+ elements: {
442
+ journal: { store, instanceId: name, leaseMs: 30_000, driverId: "memory" },
443
+ ai: aiRuntime,
444
+ },
445
+ });
446
+ await app.boot({ env: "test" });
447
+ apps.push(app);
448
+ return app;
449
+ };
450
+
451
+ const parkStream = async (app: OkeApp) => {
452
+ const parked = await app.fetch(
453
+ new Request("http://localhost/assist", { method: "POST", body: "{}" }),
454
+ );
455
+ expect(parked.status).toBe(200);
456
+ const body = await parked.text();
457
+ expect(body).toContain('"type":"interrupt"');
458
+ expect(body).toContain("data: [DONE]");
459
+ const run = (await store.list()).find(
460
+ (item) => item.flow === "assist" && item.status === "sleeping",
461
+ );
462
+ if (!run) throw new Error("expected a parked run");
463
+ expect(run.status).toBe("sleeping");
464
+ expect(typeof run.wakeAt).toBe("number");
465
+ const step = run.entries.find(
466
+ (entry) => entry.kind === "step" && entry.name.startsWith("ai-approval:"),
467
+ );
468
+ if (!step || step.kind !== "step") throw new Error("expected a pending approval");
469
+ return { run, id: step.name.slice("ai-approval:".length) };
470
+ };
471
+
472
+ const app = await boot("approval-stream");
473
+ const approved = await parkStream(app);
474
+ const ok = await app.fetch(
475
+ new Request("http://localhost/agent/approvals/approve", {
476
+ method: "POST",
477
+ headers: { "content-type": "application/json" },
478
+ body: JSON.stringify({ id: approved.id }),
479
+ }),
480
+ );
481
+ expect(ok.status).toBe(200);
482
+ await app.resumeDurable(Date.now() + 1000);
483
+ expect(calls).toEqual([{ amount: 10 }]);
484
+ const finished = await store.get(approved.run.id);
485
+ expect(finished?.status).toBe("completed");
486
+ expect(finished?.output).toMatchObject({
487
+ stopReason: "completed",
488
+ output: { refunded: true },
489
+ });
490
+ expect(finished?.output).not.toEqual({ streamed: true });
491
+
492
+ calls.length = 0;
493
+ const denyApp = await boot("approval-stream-deny");
494
+ const denied = await parkStream(denyApp);
495
+ const no = await denyApp.fetch(
496
+ new Request("http://localhost/agent/approvals/deny", {
497
+ method: "POST",
498
+ headers: { "content-type": "application/json" },
499
+ body: JSON.stringify({ id: denied.id, reason: "over the limit" }),
500
+ }),
501
+ );
502
+ expect(no.status).toBe(200);
503
+ await denyApp.resumeDurable(Date.now() + 1000);
504
+ expect(calls).toEqual([]);
505
+ const deniedRun = await store.get(denied.run.id);
506
+ expect(deniedRun?.status).toBe("completed");
507
+ expect(deniedRun?.output).toMatchObject({
508
+ stopReason: "completed",
509
+ output: { denied: true, reason: "over the limit" },
510
+ });
511
+
512
+ const timeoutApp = await boot("approval-stream-timeout");
513
+ const timed = await parkStream(timeoutApp);
514
+ await timeoutApp.resumeDurable(Date.now() + 60 * 60 * 1000 + 1000);
515
+ expect(calls).toEqual([]);
516
+ const timedRun = await store.get(timed.run.id);
517
+ expect(timedRun?.status).toBe("completed");
518
+ expect(timedRun?.output).toMatchObject({
519
+ stopReason: "completed",
520
+ output: { denied: true, reason: "timeout" },
521
+ });
522
+ }, 20_000);
523
+
524
+ test("follow the run after approve, resume Last-Event-ID, and reject another tenant or gate", async () => {
525
+ const calls: unknown[] = [];
526
+ const store = createMemoryJournalStore();
527
+ let allow = true;
528
+ const ops = gate.policy("ops", () => allow);
529
+ const aiRuntime = createAiRuntime({
530
+ journalStore: store,
531
+ models: [ai.model("smart")],
532
+ gates: createGateRuntime({ gates: [ops] }),
533
+ agents: [
534
+ ai.agent("support", {
535
+ model: "smart",
536
+ maxSteps: 4,
537
+ tools: [{ name: "refund", approval: true, gate: "public", timeout: "1h" }],
538
+ }),
539
+ ],
540
+ clients: {
541
+ smart: {
542
+ driverId: "mock",
543
+ model: "smart",
544
+ async complete(opts) {
545
+ if (opts.messages.some((message) => message.role === "tool")) {
546
+ return {
547
+ text: "Refunded.",
548
+ raw: {},
549
+ model: "smart",
550
+ driverId: "mock",
551
+ usage: { inputTokens: 2, outputTokens: 1, cost: 0 },
552
+ };
553
+ }
554
+ return {
555
+ text: "Checking.",
556
+ raw: {},
557
+ model: "smart",
558
+ driverId: "mock",
559
+ toolCalls: [{ id: "tc1", name: "refund", arguments: { amount: 10 } }],
560
+ };
561
+ },
562
+ },
563
+ },
564
+ });
565
+ const refund = flow("refund", {
566
+ do: async (input) => {
567
+ calls.push(input);
568
+ return { refunded: true };
569
+ },
570
+ });
571
+ const assist: Binding = {
572
+ trigger: http.post("/assist").gate(ops),
573
+ flow: flow("assist", {
574
+ durable: true,
575
+ effects: { asks: ["support"], calls: ["refund"] },
576
+ do: (_input, fx) =>
577
+ fx.json.stream(fx.run("support", { message: "refund" }, { stream: true })),
578
+ }) as AnyFlowDef,
579
+ };
580
+ resetBindings();
581
+ const app = oke({
582
+ name: "approval-follow",
583
+ env: "test",
584
+ startScheduler: false,
585
+ registry: "ignore",
586
+ gate: { unguardedHttp: "allow", policies: [ops] },
587
+ bindings: [assist, { trigger: http.post("/refund").public(), flow: refund as AnyFlowDef }],
588
+ elements: {
589
+ journal: { store, instanceId: "approval-follow", leaseMs: 30_000, driverId: "memory" },
590
+ ai: aiRuntime,
591
+ },
592
+ });
593
+ await app.boot({ env: "test" });
594
+ apps.push(app);
595
+
596
+ const parked = await app.fetch(
597
+ new Request("http://localhost/assist", { method: "POST", body: "{}" }),
598
+ );
599
+ expect(parked.status).toBe(200);
600
+ const first = await parked.text();
601
+ const started = first.match(/"runId":"([^"]+)"/);
602
+ const runId = started?.[1];
603
+ if (!runId) throw new Error("expected RUN_STARTED runId");
604
+ const interrupt = first.match(/"id":"([^"]+)"/);
605
+ const approval = interrupt?.[1];
606
+ if (!approval) throw new Error("expected an approval id");
607
+
608
+ const follow = await app.fetch(new Request(`http://localhost/agent/runs/${runId}/events`));
609
+ expect(follow.status).toBe(200);
610
+ const reader = follow.body?.getReader();
611
+ if (!reader) throw new Error("expected a follow body");
612
+ const decoder = new TextDecoder();
613
+ let buffered = "";
614
+ let lastId = "0";
615
+ const seen = new Set<string>();
616
+ while (!buffered.includes("TOOL_CALL_START") && buffered.length < 100_000) {
617
+ const chunk = await reader.read();
618
+ if (chunk.done) break;
619
+ buffered += decoder.decode(chunk.value, { stream: true });
620
+ }
621
+ for (const frame of buffered.split("\n\n")) {
622
+ const id = frame
623
+ .split("\n")
624
+ .find((line) => line.startsWith("id:"))
625
+ ?.slice(3)
626
+ .trim();
627
+ if (id) {
628
+ seen.add(id);
629
+ lastId = id;
630
+ }
631
+ }
632
+ await reader.cancel();
633
+
634
+ await getAgentEventLog()?.open({
635
+ runId: "other-tenant",
636
+ threadId: "t",
637
+ tenant: "acme",
638
+ gates: [],
639
+ userId: null,
640
+ operatorId: null,
641
+ });
642
+ const foreign = await app.fetch(new Request("http://localhost/agent/runs/other-tenant/events"));
643
+ expect(foreign.status).toBe(404);
644
+
645
+ allow = false;
646
+ const denied = await app.fetch(new Request(`http://localhost/agent/runs/${runId}/events`));
647
+ expect(denied.status).toBe(403);
648
+ allow = true;
649
+
650
+ const resumed = app.fetch(
651
+ new Request(`http://localhost/agent/runs/${runId}/events`, {
652
+ headers: { "last-event-id": lastId },
653
+ }),
654
+ );
655
+ const ok = await app.fetch(
656
+ new Request("http://localhost/agent/approvals/approve", {
657
+ method: "POST",
658
+ headers: { "content-type": "application/json" },
659
+ body: JSON.stringify({ id: approval }),
660
+ }),
661
+ );
662
+ expect(ok.status).toBe(200);
663
+ await app.resumeDurable(Date.now() + 1000);
664
+ const rest = await (await resumed).text();
665
+ expect(rest).toContain("RUN_FINISHED");
666
+ expect(rest).toContain("refunded");
667
+ for (const frame of rest.split("\n\n")) {
668
+ const id = frame
669
+ .split("\n")
670
+ .find((line) => line.startsWith("id:"))
671
+ ?.slice(3)
672
+ .trim();
673
+ if (!id) continue;
674
+ expect(seen.has(id)).toBe(false);
675
+ const seq = Number(id);
676
+ expect(seq).toBeGreaterThan(Number(lastId));
677
+ seen.add(id);
678
+ }
679
+ const seqs = [...seen].map(Number).sort((a, b) => a - b);
680
+ for (let i = 1; i < seqs.length; i++) {
681
+ expect(seqs[i]).toBe((seqs[i - 1] ?? 0) + 1);
682
+ }
683
+ expect(calls).toEqual([{ amount: 10 }]);
684
+ }, 20_000);
685
+ });
686
+
687
+ describe("approval ids", () => {
688
+ test("two runs with the same tool call id resolve independently", async () => {
689
+ const seen: AiMessage[][] = [];
690
+ const store = createMemoryJournalStore();
691
+ const now = () => 1_000_000;
692
+ const aiRuntime = runtime({ seen, calls: [], store, now, toolCallId: "call_0" });
693
+ const journal = createJournal({ store, now });
694
+ const first = await journal.start("assist", {});
695
+ const second = await journal.start("assist", {});
696
+ await park(aiRuntime, first);
697
+ await park(aiRuntime, second);
698
+ const a = parkedId(first);
699
+ const b = parkedId(second);
700
+ expect(a).not.toBe(b);
701
+ expect(parseApprovalId(a)?.toolCallId).toBe("call_0");
702
+ expect(parseApprovalId(b)?.toolCallId).toBe("call_0");
703
+ expect(
704
+ await resolveAgentApproval(store, a, { decision: "approve", tenant: null }, now),
705
+ ).toEqual({ ok: true });
706
+ expect((await readAgentApproval(store, b))?.status).toBe("pending");
707
+ expect(
708
+ await resolveAgentApproval(store, b, { decision: "deny", reason: "no", tenant: null }, now),
709
+ ).toEqual({ ok: true });
710
+ });
711
+
712
+ test("two instances on one postgres journal: one wins and entries stay", async () => {
713
+ const store = await createPostgresJournalStore({ sql: createPostgresJournalFake() });
714
+ const now = () => 1_000_000;
715
+ const journal = createJournal({ store, now });
716
+ const session = await journal.start("assist", { message: "refund" });
717
+ await session.step("keep-me", () => ({ n: 1 }));
718
+ const aiRuntime = runtime({ seen: [], calls: [], store, now });
719
+ await park(aiRuntime, session);
720
+ const id = parkedId(session);
721
+ const [left, right] = await Promise.all([
722
+ resolveAgentApproval(store, id, { decision: "approve", tenant: null }, now),
723
+ resolveAgentApproval(store, id, { decision: "deny", reason: "late", tenant: null }, now),
724
+ ]);
725
+ const wins = [left, right].filter((result) => result.ok);
726
+ const lost = [left, right].filter((result) => !result.ok);
727
+ expect(wins).toHaveLength(1);
728
+ expect(lost).toEqual([{ ok: false, status: 409, reason: "lease", retryAfterSeconds: 30 }]);
729
+ const row = await store.get(session.runId);
730
+ expect(row?.entries.some((entry) => entry.kind === "step" && entry.name === "keep-me")).toBe(
731
+ true,
732
+ );
733
+ const approval = row?.entries.find(
734
+ (entry) => entry.kind === "step" && entry.name === `ai-approval:${id}`,
735
+ );
736
+ expect(approval?.kind === "step" && (approval.value as { status: string }).status).not.toBe(
737
+ "pending",
738
+ );
739
+ await store.close();
740
+ });
741
+ });