okengine 0.21.1 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (342) hide show
  1. package/AGENTS.md +11 -4
  2. package/LICENSE +202 -0
  3. package/NOTICE +2 -0
  4. package/README.md +15 -9
  5. package/manifest.v1.schema.json +54 -0
  6. package/package.json +53 -38
  7. package/site/content/docs/ai/mcp.mdx +13 -9
  8. package/site/content/docs/ai/skills.mdx +2 -2
  9. package/site/content/docs/client/calling.mdx +27 -19
  10. package/site/content/docs/client/index.mdx +1 -1
  11. package/site/content/docs/elements/ai/agents.mdx +61 -13
  12. package/site/content/docs/elements/ai/decide.mdx +536 -0
  13. package/site/content/docs/elements/ai/events.mdx +373 -0
  14. package/site/content/docs/elements/ai/index.mdx +9 -3
  15. package/site/content/docs/elements/ai/meta.json +1 -1
  16. package/site/content/docs/elements/ai/prompts.mdx +4 -2
  17. package/site/content/docs/elements/channel/email.mdx +1 -1
  18. package/site/content/docs/elements/channel/index.mdx +1 -1
  19. package/site/content/docs/elements/flow/http.mdx +4 -4
  20. package/site/content/docs/elements/flow/index.mdx +3 -3
  21. package/site/content/docs/elements/flow/meta.json +1 -1
  22. package/site/content/docs/elements/gate/auth.mdx +4 -1
  23. package/site/content/docs/elements/gate/tenancy.mdx +1 -1
  24. package/site/content/docs/elements/store/index.mdx +1 -1
  25. package/site/content/docs/elements/store/kv.mdx +5 -5
  26. package/site/content/docs/elements/vault/rotation.mdx +15 -0
  27. package/site/content/docs/plugins/otp.mdx +31 -31
  28. package/site/content/docs/recipes/dragonfly.mdx +5 -5
  29. package/site/content/docs/recipes/index.mdx +6 -6
  30. package/site/content/docs/recipes/mailpit.mdx +1 -1
  31. package/site/content/docs/recipes/meilisearch.mdx +1 -1
  32. package/site/content/docs/recipes/pgdog.mdx +1 -1
  33. package/site/content/docs/recipes/postgres.mdx +12 -8
  34. package/site/content/docs/recipes/redis.mdx +4 -4
  35. package/site/content/docs/reference/cli.mdx +2 -1
  36. package/site/content/docs/reference/configuration.mdx +11 -9
  37. package/site/content/docs/reference/errors.mdx +12 -2
  38. package/site/content/docs/reference/fx.mdx +35 -21
  39. package/site/content/docs/reference/idempotency.mdx +192 -0
  40. package/site/content/docs/reference/index.mdx +6 -1
  41. package/site/content/docs/reference/meta.json +2 -1
  42. package/site/content/docs/understand/meta.json +1 -1
  43. package/site/content/docs/{elements/flow → understand}/routing.mdx +11 -10
  44. package/site/content/docs/understand/the-architecture.mdx +14 -12
  45. package/site/content/docs/understand/try-it.mdx +3 -3
  46. package/src/auth/api-key-sql.test.ts +44 -0
  47. package/src/auth/api-key-sql.ts +1 -0
  48. package/src/auth/api-keys.ts +30 -12
  49. package/src/auth/config.ts +6 -2
  50. package/src/auth/gate-auth.test.ts +20 -0
  51. package/src/cli/agents-md.test.ts +25 -0
  52. package/src/cli/ask-vault-gaps.test.ts +38 -1
  53. package/src/cli/ask-vault-gaps.ts +35 -2
  54. package/src/cli/competitor-mention-removal.test.ts +3 -3
  55. package/src/cli/decide.ts +174 -0
  56. package/src/cli/decision-lock-watch.test.ts +45 -0
  57. package/src/cli/decision-lock-watch.ts +51 -0
  58. package/src/cli/dev-app-runner.ts +34 -8
  59. package/src/cli/dev-auth-secret.test.ts +49 -0
  60. package/src/cli/dev-auth-secret.ts +49 -0
  61. package/src/cli/dev-hot.test.ts +41 -0
  62. package/src/cli/dev-hot.ts +48 -0
  63. package/src/cli/dev.test.ts +1 -1
  64. package/src/cli/dev.ts +24 -6
  65. package/src/cli/docker-cli.test.ts +1 -1
  66. package/src/cli/eval.ts +142 -1
  67. package/src/cli/index.ts +14 -1
  68. package/src/cli/load-config.images.test.ts +12 -12
  69. package/src/cli/load-config.ts +2 -2
  70. package/src/cli/mcp-from-console.ts +48 -0
  71. package/src/cli/registry.ts +35 -1
  72. package/src/cli/vault-cmd.test.ts +23 -0
  73. package/src/cli/vault-cmd.ts +7 -0
  74. package/src/client/agent.ts +307 -0
  75. package/src/client/create.ts +18 -1
  76. package/src/client/transport.test.ts +245 -0
  77. package/src/client/transport.ts +133 -19
  78. package/src/client/types.ts +29 -3
  79. package/src/client-react/index.ts +7 -1
  80. package/src/client-react/use-agent-run.test.ts +271 -0
  81. package/src/client-react/use-agent-run.ts +193 -0
  82. package/src/compiler/ai-approval.test.ts +36 -0
  83. package/src/compiler/ai-repair.test.ts +22 -0
  84. package/src/compiler/decisions.extract.test.ts +164 -0
  85. package/src/compiler/effects-infer.ts +753 -17
  86. package/src/compiler/extract.test.ts +37 -0
  87. package/src/compiler/extract.ts +381 -24
  88. package/src/compiler/fixtures/skyport.expected.json +24 -0
  89. package/src/compiler/fx-follow.test.ts +164 -0
  90. package/src/compiler/fx-index.ts +399 -0
  91. package/src/compiler/response.ts +35 -2
  92. package/src/config/index.ts +21 -3
  93. package/src/console/server/ai-runs-flows.ts +269 -0
  94. package/src/console/server/ai-runs.test.ts +323 -0
  95. package/src/console/server/ai-runs.ts +562 -0
  96. package/src/console/server/ai.ts +12 -1
  97. package/src/console/server/app.ts +10 -1
  98. package/src/console/server/decisions-flows.ts +157 -0
  99. package/src/console/server/decisions.test.ts +66 -0
  100. package/src/console/server/decisions.ts +229 -0
  101. package/src/console/server/flows-invoke.test.ts +2 -2
  102. package/src/console/server/flows.ts +50 -2
  103. package/src/console/server/invoke-user-flow.ts +9 -0
  104. package/src/console/server/runs-ingest.ts +16 -0
  105. package/src/console/server/serve.ts +5 -0
  106. package/src/console/server/state.ts +25 -1
  107. package/src/console/server/store-stats.test.ts +47 -0
  108. package/src/console/server/store-stats.ts +92 -18
  109. package/src/console/ui-next/dist/assets/access-page-DDKkhT9v.js +4 -0
  110. package/src/console/ui-next/dist/assets/agent-disclosure-62Xts8Xi.js +1 -0
  111. package/src/console/ui-next/dist/assets/{cache-glyph-92uM5MO7.js → cache-glyph-DyeoKHKA.js} +1 -1
  112. package/src/console/ui-next/dist/assets/{call-pii-button-DtGVPtcs.js → call-pii-button-DvfpLXsU.js} +1 -1
  113. package/src/console/ui-next/dist/assets/{collapsible-DN7l6zmC.js → collapsible-BY03SeCg.js} +1 -1
  114. package/src/console/ui-next/dist/assets/confirm-sheet-DZfuJXP4.js +1 -0
  115. package/src/console/ui-next/dist/assets/copy-inline-button-BTjPVjRT.js +1 -0
  116. package/src/console/ui-next/dist/assets/decisions-page-CgkKlA56.js +1 -0
  117. package/src/console/ui-next/dist/assets/detail-header-Dk5LipF6.js +1 -0
  118. package/src/console/ui-next/dist/assets/dropdown-menu-CRGrj5JU.js +1 -0
  119. package/src/console/ui-next/dist/assets/duration-tone-Y6HLjyJ5.js +9 -0
  120. package/src/console/ui-next/dist/assets/element-icons-C6t8Vlml.js +1 -0
  121. package/src/console/ui-next/dist/assets/explorer-chrome-Dslh2yE8.js +1 -0
  122. package/src/console/ui-next/dist/assets/explorer-empty-CIVg7Jgb.js +1 -0
  123. package/src/console/ui-next/dist/assets/flows-page-DLPfNy-_.js +1 -0
  124. package/src/console/ui-next/dist/assets/{highlighted-json-BlAEVgNW.js → highlighted-json-D7nNzpgB.js} +1 -1
  125. package/src/console/ui-next/dist/assets/{http-method-BDf7OAHv.js → http-method-DdL19zzh.js} +1 -1
  126. package/src/console/ui-next/dist/assets/index-BlrN49Hs.css +2 -0
  127. package/src/console/ui-next/dist/assets/index-C0lc_s9d.js +58 -0
  128. package/src/console/ui-next/dist/assets/observability-page-CRlcyLCC.js +8 -0
  129. package/src/console/ui-next/dist/assets/{replica-lag-B3GLNVfF.js → replica-lag-Bb3FWUu9.js} +7 -7
  130. package/src/console/ui-next/dist/assets/request-meta-DYBMWhX8.js +1 -0
  131. package/src/console/ui-next/dist/assets/shortcut-keys-DTE1sjTC.js +1 -0
  132. package/src/console/ui-next/dist/assets/store-page-CcE-SXC8.js +41 -0
  133. package/src/console/ui-next/dist/assets/trace-detail-sheet-CKMSEQ4s.js +2 -0
  134. package/src/console/ui-next/dist/assets/tree-expand-toggle-QOfk0M0H.js +55 -0
  135. package/src/console/ui-next/dist/assets/units-page-DKC1CDDd.js +1 -0
  136. package/src/console/ui-next/dist/assets/use-vault-list-BAyQG8Re.js +1 -0
  137. package/src/console/ui-next/dist/assets/vault-page-B54v4Yit.js +2 -0
  138. package/src/console/ui-next/dist/index.html +4 -3
  139. package/src/console/ui-next/seed-parked-approval.ts +126 -0
  140. package/src/console/ui-next/src/client.ts +224 -1
  141. package/src/console/ui-next/src/components/explorer/detail-header.tsx +9 -0
  142. package/src/console/ui-next/src/components/explorer/explorer-start-toggle.tsx +5 -1
  143. package/src/console/ui-next/src/components/ui/sheet-form.tsx +21 -12
  144. package/src/console/ui-next/src/features/flows/decisions/decisions-page.tsx +216 -0
  145. package/src/console/ui-next/src/features/flows/decisions/resolve.test.ts +48 -0
  146. package/src/console/ui-next/src/features/flows/decisions/resolve.ts +111 -0
  147. package/src/console/ui-next/src/features/flows/flows-page.tsx +10 -0
  148. package/src/console/ui-next/src/features/flows/graph/element-map.ts +1 -0
  149. package/src/console/ui-next/src/features/flows/traces/effect-kind.ts +6 -0
  150. package/src/console/ui-next/src/features/flows/traces/effect-summary.test.ts +24 -0
  151. package/src/console/ui-next/src/features/flows/traces/effect-summary.ts +24 -0
  152. package/src/console/ui-next/src/features/flows/traces/http-status.ts +58 -0
  153. package/src/console/ui-next/src/features/flows/traces/request-client-card.tsx +355 -0
  154. package/src/console/ui-next/src/features/flows/traces/request-client.test.ts +114 -0
  155. package/src/console/ui-next/src/features/flows/traces/request-client.ts +423 -0
  156. package/src/console/ui-next/src/features/flows/traces/request-input-view.test.ts +52 -6
  157. package/src/console/ui-next/src/features/flows/traces/request-input-view.ts +85 -19
  158. package/src/console/ui-next/src/features/flows/traces/trace-detail-sheet.tsx +505 -80
  159. package/src/console/ui-next/src/features/flows/traces/trace-lanes.test.ts +61 -0
  160. package/src/console/ui-next/src/features/flows/traces/trace-lanes.ts +78 -0
  161. package/src/console/ui-next/src/features/flows/traces/trace-nav.test.ts +48 -0
  162. package/src/console/ui-next/src/features/flows/traces/trace-nav.ts +57 -0
  163. package/src/console/ui-next/src/features/flows/traces/trace-request-section.tsx +275 -96
  164. package/src/console/ui-next/src/features/flows/traces/traces-pane.tsx +16 -2
  165. package/src/console/ui-next/src/features/observability/detail/ai-runs.tsx +369 -0
  166. package/src/console/ui-next/src/features/observability/observability-page.tsx +31 -1
  167. package/src/console/ui-next/src/features/observability/state/observability-selection.ts +18 -3
  168. package/src/console/ui-next/src/features/store/performance/kv-performance-panel.tsx +12 -1
  169. package/src/console/ui-next/src/features/store/performance/performance-panel.tsx +14 -1
  170. package/src/console/ui-next/src/features/units/call/call-api-panel.tsx +1 -1
  171. package/src/console/ui-next/src/features/units/detail/flow-contract-panel.tsx +15 -2
  172. package/src/console/ui-next/src/features/units/explorer/units-tree.tsx +73 -18
  173. package/src/console/ui-next/src/features/units/lib/unit-tree.test.ts +21 -0
  174. package/src/console/ui-next/src/features/units/lib/unit-tree.ts +82 -0
  175. package/src/console/ui-next/src/features/units/units-page.tsx +25 -7
  176. package/src/console/ui-next/src/router.tsx +17 -0
  177. package/src/console/ui-next/ui-next-seed-runs.ts +23 -0
  178. package/src/console/ui-next/vite-console-kernel-plugin.ts +43 -3
  179. package/src/console/ui-next/vite.config.ts +9 -0
  180. package/src/docker/docker.test.ts +13 -13
  181. package/src/docker/images-config.test.ts +12 -12
  182. package/src/docker/recipes/dragonfly.ts +3 -2
  183. package/src/docker/recipes/redis.ts +1 -1
  184. package/src/docker/stack-id.test.ts +1 -1
  185. package/src/drivers/ai-anthropic.ts +212 -21
  186. package/src/drivers/ai-mock.ts +34 -1
  187. package/src/drivers/ai-openai-compatible.ts +67 -26
  188. package/src/drivers/ai-preconnect.ts +18 -0
  189. package/src/drivers/ai-stream.test.ts +62 -0
  190. package/src/drivers/ai-types.ts +20 -0
  191. package/src/drivers/journal-postgres.ts +455 -3
  192. package/src/drivers/meilisearch.ts +2 -0
  193. package/src/drivers/memory-ddl.test.ts +18 -0
  194. package/src/drivers/memory.ts +21 -1
  195. package/src/drivers/pg-rls.test.ts +31 -0
  196. package/src/drivers/pg-rls.ts +15 -0
  197. package/src/drivers/postgres.test.ts +9 -0
  198. package/src/drivers/postgres.ts +49 -11
  199. package/src/drivers/vault-builtin.test.ts +10 -0
  200. package/src/drivers/vault-builtin.ts +13 -1
  201. package/src/drivers/vault-remote-bag.ts +5 -1
  202. package/src/drivers/vault-types.ts +6 -1
  203. package/src/elements/ai/agent-event-slot.ts +27 -0
  204. package/src/elements/ai/approval-http.ts +207 -0
  205. package/src/elements/ai/approval.test.ts +741 -0
  206. package/src/elements/ai/approval.ts +228 -0
  207. package/src/elements/ai/decisions/bind.ts +163 -0
  208. package/src/elements/ai/decisions/certificate.test.ts +291 -0
  209. package/src/elements/ai/decisions/certificate.ts +377 -0
  210. package/src/elements/ai/decisions/certify.ts +314 -0
  211. package/src/elements/ai/decisions/decide.test.ts +953 -0
  212. package/src/elements/ai/decisions/e2e.test.ts +399 -0
  213. package/src/elements/ai/decisions/export.test.ts +120 -0
  214. package/src/elements/ai/decisions/export.ts +146 -0
  215. package/src/elements/ai/decisions/fixtures/openrouter-request.json +24 -0
  216. package/src/elements/ai/decisions/fixtures/openrouter-response.json +22 -0
  217. package/src/elements/ai/decisions/fixtures/typesafe-request.json +19 -0
  218. package/src/elements/ai/decisions/fixtures/typesafe-response.json +13 -0
  219. package/src/elements/ai/decisions/http.test.ts +154 -0
  220. package/src/elements/ai/decisions/http.ts +240 -0
  221. package/src/elements/ai/decisions/labels.test.ts +282 -0
  222. package/src/elements/ai/decisions/labels.ts +257 -0
  223. package/src/elements/ai/decisions/openrouter.live.test.ts +136 -0
  224. package/src/elements/ai/decisions/openrouter.ts +36 -0
  225. package/src/elements/ai/decisions/provider.ts +102 -0
  226. package/src/elements/ai/decisions/typesafe.ts +36 -0
  227. package/src/elements/ai/declare.ts +252 -1
  228. package/src/elements/ai/events.test.ts +379 -0
  229. package/src/elements/ai/events.ts +138 -0
  230. package/src/elements/ai/run-events.test.ts +461 -0
  231. package/src/elements/ai/run-events.ts +522 -0
  232. package/src/elements/ai/runtime.ts +1186 -105
  233. package/src/elements/ai/stream-turn.ts +214 -0
  234. package/src/elements/ai/stream.live.test.ts +76 -0
  235. package/src/elements/ai/subagent.test.ts +136 -0
  236. package/src/elements/ai.test.ts +497 -3
  237. package/src/elements/ai.ts +16 -1
  238. package/src/elements/clock/durable.ts +27 -1
  239. package/src/elements/store/emit-drizzle.ts +2 -2
  240. package/src/elements/store/live-default.test.ts +2 -0
  241. package/src/elements/store/live-http.test.ts +1 -0
  242. package/src/elements/store/resource-list-docs.test.ts +2 -2
  243. package/src/elements/store/resource.test.ts +3 -3
  244. package/src/elements/store/schema-decl.test.ts +1 -0
  245. package/src/elements/store/sql-condition.ts +24 -7
  246. package/src/elements/store/sql-session.test.ts +51 -0
  247. package/src/elements/store/sql-session.ts +111 -1
  248. package/src/elements/store/upsert-app.test.ts +1 -1
  249. package/src/elements/vault/audit.test.ts +137 -0
  250. package/src/elements/vault/audit.ts +106 -13
  251. package/src/elements/vault/boot-chain.ts +9 -1
  252. package/src/elements/vault/builtin-adapter.ts +33 -3
  253. package/src/i18n/catalogs/ar.ts +9 -0
  254. package/src/i18n/catalogs/en.ts +9 -0
  255. package/src/index.ts +2 -0
  256. package/src/kernel/abort-scope.ts +33 -2
  257. package/src/kernel/agent-event-store.ts +448 -0
  258. package/src/kernel/app.ts +294 -19
  259. package/src/kernel/auto-cache.test.ts +6 -6
  260. package/src/kernel/boot-bind/ai.ts +3 -0
  261. package/src/kernel/boot-bind/store.test.ts +26 -0
  262. package/src/kernel/boot-bind/store.ts +82 -6
  263. package/src/kernel/boot-bind/vault.ts +1 -0
  264. package/src/kernel/boot.ts +34 -5
  265. package/src/kernel/builtin-errors.ts +10 -0
  266. package/src/kernel/capability.ts +3 -0
  267. package/src/kernel/client-descriptor.ts +1 -0
  268. package/src/kernel/concurrency.test.ts +33 -0
  269. package/src/kernel/decision-label-store.ts +441 -0
  270. package/src/kernel/effects-stamping.test.ts +2 -2
  271. package/src/kernel/effects.test.ts +2 -1
  272. package/src/kernel/effects.ts +13 -1
  273. package/src/kernel/element-registries.ts +3 -0
  274. package/src/kernel/errors-compiler.ts +20 -0
  275. package/src/kernel/errors-text.ts +4 -0
  276. package/src/kernel/errors.ts +2 -0
  277. package/src/kernel/external-effects.test.ts +36 -0
  278. package/src/kernel/flow.test.ts +2 -3
  279. package/src/kernel/flow.ts +24 -0
  280. package/src/kernel/fx-decide.ts +761 -0
  281. package/src/kernel/fx-fetch.ts +5 -1
  282. package/src/kernel/fx.test.ts +12 -5
  283. package/src/kernel/fx.ts +316 -29
  284. package/src/kernel/http-frame.test.ts +94 -0
  285. package/src/kernel/http-frame.ts +203 -0
  286. package/src/kernel/idempotency-store.ts +578 -0
  287. package/src/kernel/idempotency.test.ts +556 -0
  288. package/src/kernel/idempotency.ts +304 -0
  289. package/src/kernel/index.ts +1 -0
  290. package/src/kernel/journal.ts +160 -0
  291. package/src/kernel/json-result.ts +10 -0
  292. package/src/kernel/pipeline.test.ts +45 -0
  293. package/src/kernel/sse-id.ts +32 -0
  294. package/src/manifest/diff.ts +1 -0
  295. package/src/manifest/types.ts +37 -2
  296. package/src/mcp/ai-tools.test.ts +202 -0
  297. package/src/mcp/authorization.ts +66 -0
  298. package/src/mcp/session.ts +3 -0
  299. package/src/mcp/tools.ts +168 -0
  300. package/src/plugins/auth-delivery.mailpit.integration.test.ts +1 -1
  301. package/src/plugins/otp.test.ts +65 -1
  302. package/src/plugins/otp.ts +59 -7
  303. package/src/release/absolute-regression.test.ts +63 -0
  304. package/src/release/http-graph.test.ts +27 -0
  305. package/src/release/http-graph.ts +95 -0
  306. package/src/release/index.ts +3 -0
  307. package/src/release/limits.ts +17 -2
  308. package/src/release/measure.ts +144 -28
  309. package/src/runs/collect.ts +4 -1
  310. package/src/runs/parquet.test.ts +27 -1
  311. package/src/runs/parquet.ts +19 -0
  312. package/src/runs/types.ts +31 -0
  313. package/src/runtime/json-code-block.test.ts +106 -47
  314. package/src/runtime/json-code-block.ts +1209 -46
  315. package/src/term.test.ts +13 -0
  316. package/src/term.ts +81 -49
  317. package/src/test/create-test-app.test.ts +3 -1
  318. package/src/test/create-test-app.ts +40 -0
  319. package/src/test/live-signals.test.ts +1 -0
  320. package/src/test/provisions.integration.test.ts +1 -0
  321. package/src/test/tenant-isolation.test.ts +1 -0
  322. package/src/console/ui-next/dist/assets/PlusSignIcon-CwG3nxfu.js +0 -1
  323. package/src/console/ui-next/dist/assets/access-page-Bgt9bq2r.js +0 -4
  324. package/src/console/ui-next/dist/assets/agent-disclosure-B43CXZZR.js +0 -1
  325. package/src/console/ui-next/dist/assets/copy-inline-button-CAYD18cr.js +0 -1
  326. package/src/console/ui-next/dist/assets/detail-header-DVWNjWjg.js +0 -1
  327. package/src/console/ui-next/dist/assets/dropdown-menu-4h2LOVXM.js +0 -1
  328. package/src/console/ui-next/dist/assets/duration-tone-BmIR9FV8.js +0 -9
  329. package/src/console/ui-next/dist/assets/element-icons-BI8cJgdh.js +0 -1
  330. package/src/console/ui-next/dist/assets/explorer-empty-CJs5A-wm.js +0 -1
  331. package/src/console/ui-next/dist/assets/flows-page-BMHs-IzK.js +0 -1
  332. package/src/console/ui-next/dist/assets/index-BKpaes3n.js +0 -63
  333. package/src/console/ui-next/dist/assets/index-VxoEz295.css +0 -2
  334. package/src/console/ui-next/dist/assets/observability-page-WnVLI-0j.js +0 -4
  335. package/src/console/ui-next/dist/assets/request-meta-DMbnAe3f.js +0 -1
  336. package/src/console/ui-next/dist/assets/shortcut-keys-3ILd8oGn.js +0 -1
  337. package/src/console/ui-next/dist/assets/store-page-KvFDingJ.js +0 -41
  338. package/src/console/ui-next/dist/assets/trace-detail-sheet-Htk8Cm9t.js +0 -2
  339. package/src/console/ui-next/dist/assets/tree-expand-toggle-CFMPWX4f.js +0 -55
  340. package/src/console/ui-next/dist/assets/units-page-C4NdNuxP.js +0 -1
  341. package/src/console/ui-next/dist/assets/use-vault-list-CT4-gajj.js +0 -1
  342. package/src/console/ui-next/dist/assets/vault-page-CBYl0LW_.js +0 -2
@@ -20,11 +20,18 @@ import {
20
20
  toQuery,
21
21
  } from "./wire.ts";
22
22
 
23
- /** Per-call transport options (binary decode, abort). */
23
+ /** Per-call transport options (binary decode, abort, retry opt-in). */
24
24
  export interface TransportCallOptions {
25
25
  readonly headers?: ClientHeaders;
26
26
  readonly response?: "json" | "blob" | "arrayBuffer";
27
27
  readonly signal?: AbortSignal;
28
+ /**
29
+ * Repeat this call under the client `retry` policy even when it sends
30
+ * no idempotency key.
31
+ */
32
+ readonly retry?: boolean;
33
+ /** Stable key, or `false` to send none. */
34
+ readonly idempotencyKey?: string | false;
28
35
  }
29
36
 
30
37
  /** Internal transport handle. */
@@ -63,10 +70,25 @@ export function createTransport(base: string, opts: ClientOptions = {}): Transpo
63
70
  let delay = delay0;
64
71
  const callClientOpts: ClientOptions =
65
72
  callOpts.signal !== undefined ? { ...opts, signal: callOpts.signal } : opts;
73
+ const method = (opts.routes?.[key.replace("/", ".")]?.method ?? "POST").toUpperCase();
74
+ const idempotencyKey = resolveIdempotencyKey(method, callOpts.idempotencyKey);
75
+ const allowRetry =
76
+ callOpts.retry === true ||
77
+ method === "GET" ||
78
+ method === "QUERY" ||
79
+ idempotencyKey !== undefined;
66
80
 
67
81
  for (;;) {
68
82
  try {
69
- const res = await once(base, key, input, callClientOpts, fetchFn, callOpts.headers);
83
+ const res = await once(
84
+ base,
85
+ key,
86
+ input,
87
+ callClientOpts,
88
+ fetchFn,
89
+ callOpts.headers,
90
+ idempotencyKey,
91
+ );
70
92
  if (
71
93
  res.status === 401 &&
72
94
  opts.auth &&
@@ -78,9 +100,23 @@ export function createTransport(base: string, opts: ClientOptions = {}): Transpo
78
100
  await opts.auth.refresh();
79
101
  continue;
80
102
  }
103
+ if (res.status === 409 && allowRetry && attempt < retries) {
104
+ const seconds = Number(res.headers.get("retry-after"));
105
+ const wait = seconds > 0 ? seconds * 1000 : delay;
106
+ const structured = await decodeIfEnvelope(res);
107
+ const code = structured?.error?.code;
108
+ if (code === "IdempotencyInProgress" || code === "JournalLeaseBusy") {
109
+ await sleep(wait);
110
+ delay *= backoff;
111
+ attempt += 1;
112
+ continue;
113
+ }
114
+ if (structured) return stampReplay(structured, res);
115
+ return transportEnvelope("HTTP 409", 409);
116
+ }
81
117
  if (res.status >= 500) {
82
118
  const structured = await decodeIfEnvelope(res);
83
- if (structured) return structured;
119
+ if (structured) return stampReplay(structured, res);
84
120
  throw new Error(`HTTP ${res.status}`);
85
121
  }
86
122
  if (callOpts.response === "blob" || callOpts.response === "arrayBuffer") {
@@ -89,7 +125,7 @@ export function createTransport(base: string, opts: ClientOptions = {}): Transpo
89
125
  return decode(res);
90
126
  } catch (err) {
91
127
  const transient = isTransient(err);
92
- if (!transient || attempt >= retries) {
128
+ if (!transient || !allowRetry || attempt >= retries) {
93
129
  return transportEnvelope(err instanceof Error ? err.message : String(err));
94
130
  }
95
131
  await sleep(delay);
@@ -127,10 +163,20 @@ function normalizeCallOpts(
127
163
  if (Array.isArray(headersOrOpts)) return { headers: headersOrOpts };
128
164
  if (
129
165
  typeof headersOrOpts === "object" &&
130
- ("response" in headersOrOpts || "signal" in headersOrOpts || "headers" in headersOrOpts)
166
+ ("response" in headersOrOpts ||
167
+ "signal" in headersOrOpts ||
168
+ "headers" in headersOrOpts ||
169
+ "retry" in headersOrOpts ||
170
+ "idempotencyKey" in headersOrOpts)
131
171
  ) {
132
172
  const o = headersOrOpts as TransportCallOptions;
133
- if (o.response !== undefined || o.signal !== undefined || o.headers !== undefined) {
173
+ if (
174
+ o.response !== undefined ||
175
+ o.signal !== undefined ||
176
+ o.headers !== undefined ||
177
+ o.retry !== undefined ||
178
+ o.idempotencyKey !== undefined
179
+ ) {
134
180
  return o;
135
181
  }
136
182
  }
@@ -138,7 +184,9 @@ function normalizeCallOpts(
138
184
  }
139
185
 
140
186
  /**
141
- * Single HTTP attempt. Throws on network / abort / 5xx (retryable).
187
+ * Single HTTP attempt. Throws on network / abort / non-envelope 5xx.
188
+ * A client timeout is rethrown as a plain `Error` so the retry loop can
189
+ * repeat it. A caller `AbortSignal` stays an `AbortError` and is not retried.
142
190
  *
143
191
  * @param base - Origin
144
192
  * @param key - `unit/flow`
@@ -154,6 +202,7 @@ async function once(
154
202
  opts: ClientOptions,
155
203
  fetchFn: ClientFetch,
156
204
  callHeaders?: ClientHeaders,
205
+ idempotencyKey?: string,
157
206
  ): Promise<Response> {
158
207
  const route = opts.routes?.[key.replace("/", ".")];
159
208
  const { url, method, body } = route
@@ -163,6 +212,7 @@ async function once(
163
212
  const headers = new Headers();
164
213
  applyHeaderBag(headers, await resolveHeaders(opts));
165
214
  applyHeaderBag(headers, callHeaders);
215
+ if (idempotencyKey !== undefined) headers.set("Idempotency-Key", idempotencyKey);
166
216
  if (body !== undefined && !headers.has("content-type") && typeof body === "string") {
167
217
  headers.set("content-type", "application/json");
168
218
  }
@@ -172,13 +222,20 @@ async function once(
172
222
  const signal =
173
223
  opts.signal && timeout ? AbortSignal.any([opts.signal, timeout]) : (opts.signal ?? timeout);
174
224
 
175
- return await fetchFn(url, {
176
- method,
177
- headers,
178
- body: body as RequestInit["body"],
179
- signal,
180
- ...(opts.credentials !== undefined ? { credentials: opts.credentials } : {}),
181
- });
225
+ try {
226
+ return await fetchFn(url, {
227
+ method,
228
+ headers,
229
+ body: body as RequestInit["body"],
230
+ signal,
231
+ ...(opts.credentials !== undefined ? { credentials: opts.credentials } : {}),
232
+ });
233
+ } catch (err) {
234
+ if (isAbortError(err) && timeout?.aborted === true && opts.signal?.aborted !== true) {
235
+ throw new Error(err instanceof Error ? err.message : "timeout");
236
+ }
237
+ throw err;
238
+ }
182
239
  }
183
240
 
184
241
  /**
@@ -253,12 +310,12 @@ function isRawBody(input: unknown): input is ClientBodyInit {
253
310
 
254
311
  async function decode(res: Response): Promise<ClientEnvelope> {
255
312
  if (res.status === 204) {
256
- return { data: undefined, error: null };
313
+ return stampReplay({ data: undefined, error: null }, res);
257
314
  }
258
315
 
259
316
  const text = await res.text();
260
317
  if (!text) {
261
- if (res.ok) return { data: undefined, error: null };
318
+ if (res.ok) return stampReplay({ data: undefined, error: null }, res);
262
319
  return transportEnvelope(`HTTP ${res.status}`, res.status);
263
320
  }
264
321
 
@@ -270,11 +327,11 @@ async function decode(res: Response): Promise<ClientEnvelope> {
270
327
  }
271
328
 
272
329
  if (json !== null && typeof json === "object" && "data" in json && "error" in json) {
273
- return json as ClientEnvelope;
330
+ return stampReplay(json as ClientEnvelope, res);
274
331
  }
275
332
 
276
333
  if (res.ok) {
277
- return { data: json, error: null };
334
+ return stampReplay({ data: json, error: null }, res);
278
335
  }
279
336
 
280
337
  return transportEnvelope(`HTTP ${res.status}`, res.status);
@@ -287,7 +344,64 @@ async function decodeBinary(res: Response, mode: "blob" | "arrayBuffer"): Promis
287
344
  return transportEnvelope(`HTTP ${res.status}`, res.status);
288
345
  }
289
346
  const data = mode === "blob" ? await res.blob() : await res.arrayBuffer();
290
- return { data, error: null };
347
+ return stampReplay({ data, error: null }, res);
348
+ }
349
+
350
+ /**
351
+ * Default `okid()` alphabet, inlined.
352
+ *
353
+ * Same 64-character Base64URL order as {@link OKID_ALPHABET}. Importing
354
+ * `okid` would pull `lazyRequire` and every alphabet constant into the client.
355
+ */
356
+ export const IDEMPOTENCY_KEY_ALPHABET =
357
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
358
+
359
+ /**
360
+ * 21-char key from `crypto.getRandomValues`.
361
+ *
362
+ * `getRandomValues` works on plain HTTP. When `crypto` is missing entirely,
363
+ * returns `undefined` so the call sends no key and runs once.
364
+ */
365
+ export function mintIdempotencyKey(): string | undefined {
366
+ const webCrypto = globalThis.crypto;
367
+ if (webCrypto?.getRandomValues === undefined) return undefined;
368
+ const bytes = new Uint8Array(21);
369
+ webCrypto.getRandomValues(bytes);
370
+ let out = "";
371
+ for (let i = 0; i < 21; i++) out += IDEMPOTENCY_KEY_ALPHABET[bytes[i]! & 63]!;
372
+ return out;
373
+ }
374
+
375
+ /**
376
+ * Key for this logical call, or `undefined` when none is sent.
377
+ *
378
+ * @param method - HTTP method, already uppercased
379
+ * @param opt - Per-call override
380
+ */
381
+ function resolveIdempotencyKey(
382
+ method: string,
383
+ opt: string | false | undefined,
384
+ ): string | undefined {
385
+ if (opt === false) return undefined;
386
+ if (typeof opt === "string") return opt;
387
+ if (method === "GET") return undefined;
388
+ return mintIdempotencyKey();
389
+ }
390
+
391
+ /**
392
+ * Copy `Idempotent-Replayed: true` onto result meta.
393
+ *
394
+ * @param envelope - Decoded envelope
395
+ * @param res - HTTP response
396
+ */
397
+ function stampReplay(envelope: ClientEnvelope, res: Response): ClientEnvelope {
398
+ if (res.headers.get("idempotent-replayed") !== "true") return envelope;
399
+ const prev = envelope.meta;
400
+ return { ...envelope, meta: { ...prev, idempotentReplayed: true } };
401
+ }
402
+
403
+ function isAbortError(err: unknown): boolean {
404
+ return err instanceof Error && (err.name === "AbortError" || err.name === "TimeoutError");
291
405
  }
292
406
 
293
407
  function isTransient(err: unknown): boolean {
@@ -146,6 +146,8 @@ export type ClientListMeta = {
146
146
  readonly prev?: ClientPagerLink | null;
147
147
  readonly total?: number;
148
148
  readonly offset?: number;
149
+ /** Set when the server replayed a completed idempotent call. */
150
+ readonly idempotentReplayed?: true;
149
151
  };
150
152
 
151
153
  /**
@@ -163,7 +165,11 @@ export type ClientEnvelope<
163
165
  readonly error: null;
164
166
  readonly meta?: ClientListMeta;
165
167
  }
166
- | { readonly data: null; readonly error: ClientError<E> | TransportError };
168
+ | {
169
+ readonly data: null;
170
+ readonly error: ClientError<E> | TransportError;
171
+ readonly meta?: ClientListMeta;
172
+ };
167
173
 
168
174
  /**
169
175
  * Client call result — envelope plus always-callable `next` / `prev`.
@@ -251,7 +257,15 @@ export interface ClientOptions {
251
257
  readonly headers?: ClientHeaders | (() => ClientHeaders | Promise<ClientHeaders>);
252
258
  /** Abort the request after this many milliseconds. */
253
259
  readonly timeout?: number;
254
- /** Retry transient failures (network / 5xx). */
260
+ /**
261
+ * Retry transient failures (network errors, client timeouts, and
262
+ * non-envelope 5xx).
263
+ *
264
+ * A call retries when it is `GET` or `QUERY`, or when it carries an
265
+ * idempotency key. Keys are sent automatically on every non-GET call.
266
+ * `idempotencyKey: false` runs once unless that call passes `{ retry: true }`.
267
+ * `409 IdempotencyInProgress` and `409 JournalLeaseBusy` wait for `Retry-After` and count as an attempt.
268
+ */
255
269
  readonly retry?: {
256
270
  /** Extra attempts after the first (default 0). */
257
271
  readonly retries?: number;
@@ -296,11 +310,22 @@ export interface ClientOptions {
296
310
  export type ClientThenableIterable<T> = PromiseLike<T> & AsyncIterable<T>;
297
311
 
298
312
  /**
299
- * Per-call options on a Flow invoke (binary decode / abort).
313
+ * Per-call options on a Flow invoke (binary decode, abort, retry opt-in).
300
314
  */
301
315
  export interface ClientCallOpts {
302
316
  readonly response?: "json" | "blob" | "arrayBuffer";
303
317
  readonly signal?: AbortSignal;
318
+ /**
319
+ * Repeat this call under the client `retry` policy even when it sends
320
+ * no idempotency key.
321
+ */
322
+ readonly retry?: boolean;
323
+ /**
324
+ * Stable `Idempotency-Key` for this logical call. A string is sent as-is
325
+ * (one key per form render dedupes double submits). `false` sends no key.
326
+ * Omitted on a non-GET call generates one key and reuses it across retries.
327
+ */
328
+ readonly idempotencyKey?: string | false;
304
329
  }
305
330
 
306
331
  /**
@@ -503,6 +528,7 @@ export interface ClientDescriptor {
503
528
  readonly stream?: true;
504
529
  readonly matchKey?: readonly string[];
505
530
  readonly gates?: readonly string[];
531
+ readonly idempotency?: { readonly mode: "off" | "auto" | "required"; readonly ttl: string };
506
532
  };
507
533
  };
508
534
  };
@@ -11,9 +11,15 @@ export {
11
11
  type ResourceStreamOptions,
12
12
  type ResourceStreamStop,
13
13
  } from "./live-resource.ts";
14
- export type { LiveRouteContract } from "./use-live-query.ts";
14
+ export {
15
+ useAgentRun,
16
+ type AgentPendingApproval,
17
+ type AgentRunState,
18
+ type UseAgentRunOptions,
19
+ } from "./use-agent-run.ts";
15
20
  export {
16
21
  useLiveQuery,
22
+ type LiveRouteContract,
17
23
  type UseLiveQueryOptions,
18
24
  type UseLiveQueryState,
19
25
  } from "./use-live-query.ts";
@@ -0,0 +1,271 @@
1
+ /**
2
+ * useAgentRun keeps one follow and does not replay text.
3
+ */
4
+
5
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
6
+ import { Window } from "happy-dom";
7
+ import { act, createElement, useEffect } from "react";
8
+ import { createRoot, type Root } from "react-dom/client";
9
+ import type { AiMessage } from "../drivers/ai-types.ts";
10
+ import { ai, createAiRuntime } from "../elements/ai.ts";
11
+ import { createGateRuntime, gate } from "../elements/gate.ts";
12
+ import { oke } from "../kernel/app.ts";
13
+ import { flow, resetFlowSeq, type AnyFlowDef } from "../kernel/flow.ts";
14
+ import { createMemoryJournalStore } from "../kernel/journal.ts";
15
+ import { resetBindings, type Binding } from "../kernel/on.ts";
16
+ import { http } from "../kernel/triggers.ts";
17
+ import { useAgentRun, type AgentRunState } from "./use-agent-run.ts";
18
+
19
+ function frames(rows: readonly { id: string; event: unknown }[]): string {
20
+ return rows.map((row) => `id: ${row.id}\ndata: ${JSON.stringify(row.event)}\n\n`).join("");
21
+ }
22
+
23
+ function sse(body: string): Response {
24
+ return new Response(body, { status: 200, headers: { "content-type": "text/event-stream" } });
25
+ }
26
+
27
+ describe("useAgentRun", () => {
28
+ let happy: Window;
29
+ let root: Root | undefined;
30
+
31
+ beforeEach(() => {
32
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
33
+ happy = new Window({ url: "http://app.test/" });
34
+ Object.defineProperty(globalThis, "window", {
35
+ value: happy,
36
+ configurable: true,
37
+ writable: true,
38
+ });
39
+ Object.defineProperty(globalThis, "document", {
40
+ value: happy.document,
41
+ configurable: true,
42
+ writable: true,
43
+ });
44
+ });
45
+
46
+ afterEach(() => {
47
+ root?.unmount();
48
+ happy.close();
49
+ });
50
+
51
+ test("text is not duplicated and approve does not start a second follow", async () => {
52
+ const encoder = new TextEncoder();
53
+ let follow: ReadableStreamDefaultController<Uint8Array> | undefined;
54
+ let lastEventId: string | null = null;
55
+ let follows = 0;
56
+ const fetchFn = Object.assign(
57
+ async (input: string | URL | Request, init?: RequestInit) => {
58
+ const url = String(input);
59
+ if (url.endsWith("/send")) {
60
+ return sse(
61
+ frames([
62
+ { id: "1", event: { type: "RUN_STARTED", threadId: "t", runId: "run-1" } },
63
+ { id: "2", event: { type: "TEXT_MESSAGE_CONTENT", messageId: "m", delta: "Hel" } },
64
+ {
65
+ id: "3",
66
+ event: {
67
+ type: "RUN_FINISHED",
68
+ threadId: "t",
69
+ runId: "run-1",
70
+ outcome: {
71
+ type: "interrupt",
72
+ interrupts: [{ id: "ap-1", reason: "approval", payload: { tool: "pay" } }],
73
+ },
74
+ },
75
+ },
76
+ ]),
77
+ );
78
+ }
79
+ if (url.endsWith("/events")) {
80
+ follows += 1;
81
+ lastEventId = new Headers(init?.headers).get("last-event-id");
82
+ return new Response(
83
+ new ReadableStream({
84
+ start(controller) {
85
+ follow = controller;
86
+ },
87
+ }),
88
+ { status: 200, headers: { "content-type": "text/event-stream" } },
89
+ );
90
+ }
91
+ follow?.enqueue(
92
+ encoder.encode(
93
+ frames([
94
+ { id: "4", event: { type: "TEXT_MESSAGE_CONTENT", messageId: "m", delta: "lo" } },
95
+ {
96
+ id: "5",
97
+ event: {
98
+ type: "RUN_FINISHED",
99
+ threadId: "t",
100
+ runId: "run-1",
101
+ result: { cost: 0, stopReason: "completed", output: "Hello" },
102
+ },
103
+ },
104
+ ]),
105
+ ),
106
+ );
107
+ follow?.close();
108
+ return new Response(JSON.stringify({ data: { ok: true }, error: null }), { status: 200 });
109
+ },
110
+ { preconnect: () => undefined },
111
+ ) as typeof fetch;
112
+
113
+ let view: AgentRunState | undefined;
114
+ function Probe(): null {
115
+ const state = useAgentRun({
116
+ sendUrl: "http://app.test/send",
117
+ approveUrl: "http://app.test/approve",
118
+ denyUrl: "http://app.test/deny",
119
+ followUrl: (runId) => `http://app.test/runs/${runId}/events`,
120
+ fetch: fetchFn,
121
+ });
122
+ useEffect(() => {
123
+ view = state;
124
+ });
125
+ view = state;
126
+ return null;
127
+ }
128
+
129
+ root = createRoot(happy.document.createElement("div"));
130
+ await act(async () => {
131
+ root?.render(createElement(Probe));
132
+ });
133
+ await act(async () => {
134
+ await view?.send("hi");
135
+ });
136
+ expect(view?.status).toBe("approval");
137
+ expect(view?.text).toBe("Hel");
138
+ await act(async () => {
139
+ await view?.approve();
140
+ });
141
+ const start = Date.now();
142
+ while (view?.text !== "Hello") {
143
+ if (Date.now() - start > 1_000) throw new Error(`text stayed ${view?.text}`);
144
+ await act(async () => {
145
+ await new Promise((resolve) => setTimeout(resolve, 5));
146
+ });
147
+ }
148
+ expect(view?.text).toBe("Hello");
149
+ expect(follows).toBe(1);
150
+ if (lastEventId !== "3") throw new Error(`last event id ${String(lastEventId)}`);
151
+ });
152
+
153
+ test("a booted app shows the model text once across a real approval", async () => {
154
+ const calls: unknown[] = [];
155
+ const store = createMemoryJournalStore();
156
+ const ops = gate.policy("ops", () => true);
157
+ const runtime = createAiRuntime({
158
+ journalStore: store,
159
+ models: [ai.model("smart")],
160
+ gates: createGateRuntime({ gates: [ops] }),
161
+ agents: [
162
+ ai.agent("support", {
163
+ model: "smart",
164
+ maxSteps: 4,
165
+ tools: [{ name: "refund", approval: true, gate: "ops", timeout: "1h" }],
166
+ }),
167
+ ],
168
+ clients: {
169
+ smart: {
170
+ driverId: "mock",
171
+ model: "smart",
172
+ async complete() {
173
+ return { text: "", raw: {}, model: "smart", driverId: "mock" as const };
174
+ },
175
+ async *stream(opts: { messages: readonly AiMessage[] }) {
176
+ const answered = opts.messages.some((message) => message.role === "tool");
177
+ if (!answered) {
178
+ yield { text: "Hello" };
179
+ yield {
180
+ text: "",
181
+ toolCall: { index: 0, id: "tc1", name: "refund", argumentsDelta: "{}" },
182
+ };
183
+ yield { text: "", done: true as const };
184
+ return;
185
+ }
186
+ yield { text: "!" };
187
+ yield { text: "", done: true as const };
188
+ },
189
+ },
190
+ },
191
+ });
192
+ const refund = flow("refund", {
193
+ do: async (input) => {
194
+ calls.push(input);
195
+ return { refunded: true };
196
+ },
197
+ });
198
+ const assist: Binding = {
199
+ trigger: http.post("/assist"),
200
+ flow: flow("assist", {
201
+ durable: true,
202
+ effects: { asks: ["support"], calls: ["refund"] },
203
+ do: (_input, fx) =>
204
+ fx.json.stream(fx.run("support", { message: "refund" }, { stream: true })),
205
+ }) as AnyFlowDef,
206
+ };
207
+ resetBindings();
208
+ const app = oke({
209
+ name: "use-agent-run",
210
+ env: "test",
211
+ startScheduler: false,
212
+ registry: "ignore",
213
+ gate: { unguardedHttp: "allow", policies: [ops] },
214
+ bindings: [assist, { trigger: http.post("/refund"), flow: refund as AnyFlowDef }],
215
+ elements: {
216
+ journal: { store, instanceId: "use-agent-run", leaseMs: 30_000, driverId: "memory" },
217
+ ai: runtime,
218
+ },
219
+ });
220
+ await app.boot({ env: "test" });
221
+ const fetchFn = Object.assign(
222
+ async (input: string | URL | Request, init?: RequestInit) => {
223
+ const request = input instanceof Request ? input : new Request(String(input), init);
224
+ return app.fetch(request);
225
+ },
226
+ { preconnect: () => undefined },
227
+ ) as typeof fetch;
228
+
229
+ let view: AgentRunState | undefined;
230
+ function Probe(): null {
231
+ const state = useAgentRun({
232
+ sendUrl: "http://localhost/assist",
233
+ approveUrl: "http://localhost/agent/approvals/approve",
234
+ denyUrl: "http://localhost/agent/approvals/deny",
235
+ followUrl: (runId) => `http://localhost/agent/runs/${runId}/events`,
236
+ fetch: fetchFn,
237
+ });
238
+ useEffect(() => {
239
+ view = state;
240
+ });
241
+ view = state;
242
+ return null;
243
+ }
244
+
245
+ root = createRoot(happy.document.createElement("div"));
246
+ await act(async () => {
247
+ root?.render(createElement(Probe));
248
+ });
249
+ await act(async () => {
250
+ await view?.send("refund");
251
+ });
252
+ expect(view?.status).toBe("approval");
253
+ expect(view?.text).toBe("Hello");
254
+ await act(async () => {
255
+ await view?.approve();
256
+ });
257
+ await app.resumeDurable(Date.now() + 1000);
258
+ const start = Date.now();
259
+ while (view?.text !== "Hello!") {
260
+ if (Date.now() - start > 3_000) throw new Error(`text stayed ${view?.text}`);
261
+ await act(async () => {
262
+ await new Promise((resolve) => setTimeout(resolve, 20));
263
+ });
264
+ }
265
+ expect(view?.text).toBe("Hello!");
266
+ expect(calls).toEqual([{}]);
267
+ await app.bootResult?.close();
268
+ resetBindings();
269
+ resetFlowSeq();
270
+ });
271
+ });