okengine 0.22.0 → 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 (262) 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 +13 -9
  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/gate/auth.mdx +4 -1
  13. package/site/content/docs/elements/store/index.mdx +1 -1
  14. package/site/content/docs/elements/store/kv.mdx +5 -5
  15. package/site/content/docs/recipes/dragonfly.mdx +5 -5
  16. package/site/content/docs/recipes/index.mdx +6 -6
  17. package/site/content/docs/recipes/postgres.mdx +12 -8
  18. package/site/content/docs/recipes/redis.mdx +4 -4
  19. package/site/content/docs/reference/cli.mdx +2 -1
  20. package/site/content/docs/reference/configuration.mdx +9 -7
  21. package/site/content/docs/reference/fx.mdx +7 -6
  22. package/src/auth/api-key-sql.test.ts +44 -0
  23. package/src/auth/api-key-sql.ts +1 -0
  24. package/src/auth/api-keys.ts +30 -12
  25. package/src/auth/config.ts +6 -2
  26. package/src/auth/gate-auth.test.ts +20 -0
  27. package/src/cli/agents-md.test.ts +25 -0
  28. package/src/cli/ask-vault-gaps.test.ts +38 -1
  29. package/src/cli/ask-vault-gaps.ts +35 -2
  30. package/src/cli/decide.ts +174 -0
  31. package/src/cli/decision-lock-watch.test.ts +45 -0
  32. package/src/cli/decision-lock-watch.ts +51 -0
  33. package/src/cli/dev-app-runner.ts +34 -8
  34. package/src/cli/dev-auth-secret.test.ts +49 -0
  35. package/src/cli/dev-auth-secret.ts +49 -0
  36. package/src/cli/dev-hot.test.ts +41 -0
  37. package/src/cli/dev-hot.ts +48 -0
  38. package/src/cli/dev.test.ts +1 -1
  39. package/src/cli/dev.ts +24 -6
  40. package/src/cli/eval.ts +142 -1
  41. package/src/cli/index.ts +5 -0
  42. package/src/cli/load-config.images.test.ts +2 -2
  43. package/src/cli/load-config.ts +1 -1
  44. package/src/cli/mcp-from-console.ts +48 -0
  45. package/src/cli/registry.ts +35 -1
  46. package/src/client/agent.ts +307 -0
  47. package/src/client/transport.test.ts +20 -0
  48. package/src/client/transport.ts +2 -1
  49. package/src/client/types.ts +1 -1
  50. package/src/client-react/index.ts +7 -1
  51. package/src/client-react/use-agent-run.test.ts +271 -0
  52. package/src/client-react/use-agent-run.ts +193 -0
  53. package/src/compiler/ai-approval.test.ts +36 -0
  54. package/src/compiler/ai-repair.test.ts +22 -0
  55. package/src/compiler/decisions.extract.test.ts +164 -0
  56. package/src/compiler/effects-infer.ts +27 -1
  57. package/src/compiler/extract.ts +268 -12
  58. package/src/compiler/response.ts +35 -2
  59. package/src/config/index.ts +21 -3
  60. package/src/console/server/ai-runs-flows.ts +269 -0
  61. package/src/console/server/ai-runs.test.ts +323 -0
  62. package/src/console/server/ai-runs.ts +562 -0
  63. package/src/console/server/ai.ts +12 -1
  64. package/src/console/server/app.ts +10 -1
  65. package/src/console/server/decisions-flows.ts +157 -0
  66. package/src/console/server/decisions.test.ts +66 -0
  67. package/src/console/server/decisions.ts +229 -0
  68. package/src/console/server/flows.ts +50 -2
  69. package/src/console/server/invoke-user-flow.ts +9 -0
  70. package/src/console/server/runs-ingest.ts +16 -0
  71. package/src/console/server/serve.ts +5 -0
  72. package/src/console/server/state.ts +25 -1
  73. package/src/console/server/store-stats.test.ts +47 -0
  74. package/src/console/server/store-stats.ts +92 -18
  75. package/src/console/ui-next/dist/assets/access-page-DDKkhT9v.js +4 -0
  76. package/src/console/ui-next/dist/assets/{agent-disclosure-Bkohc_8X.js → agent-disclosure-62Xts8Xi.js} +1 -1
  77. package/src/console/ui-next/dist/assets/{cache-glyph-rL0lHxat.js → cache-glyph-DyeoKHKA.js} +1 -1
  78. package/src/console/ui-next/dist/assets/{call-pii-button-Deo4PP18.js → call-pii-button-DvfpLXsU.js} +1 -1
  79. package/src/console/ui-next/dist/assets/{collapsible-CoJ6amHf.js → collapsible-BY03SeCg.js} +1 -1
  80. package/src/console/ui-next/dist/assets/confirm-sheet-DZfuJXP4.js +1 -0
  81. package/src/console/ui-next/dist/assets/copy-inline-button-BTjPVjRT.js +1 -0
  82. package/src/console/ui-next/dist/assets/decisions-page-CgkKlA56.js +1 -0
  83. package/src/console/ui-next/dist/assets/detail-header-Dk5LipF6.js +1 -0
  84. package/src/console/ui-next/dist/assets/dropdown-menu-CRGrj5JU.js +1 -0
  85. package/src/console/ui-next/dist/assets/duration-tone-Y6HLjyJ5.js +9 -0
  86. package/src/console/ui-next/dist/assets/element-icons-C6t8Vlml.js +1 -0
  87. package/src/console/ui-next/dist/assets/explorer-chrome-Dslh2yE8.js +1 -0
  88. package/src/console/ui-next/dist/assets/explorer-empty-CIVg7Jgb.js +1 -0
  89. package/src/console/ui-next/dist/assets/flows-page-DLPfNy-_.js +1 -0
  90. package/src/console/ui-next/dist/assets/{highlighted-json-CPYBzDh0.js → highlighted-json-D7nNzpgB.js} +1 -1
  91. package/src/console/ui-next/dist/assets/{http-method-DTPevKmM.js → http-method-DdL19zzh.js} +1 -1
  92. package/src/console/ui-next/dist/assets/index-BlrN49Hs.css +2 -0
  93. package/src/console/ui-next/dist/assets/index-C0lc_s9d.js +58 -0
  94. package/src/console/ui-next/dist/assets/observability-page-CRlcyLCC.js +8 -0
  95. package/src/console/ui-next/dist/assets/{replica-lag-BM27lXld.js → replica-lag-Bb3FWUu9.js} +7 -7
  96. package/src/console/ui-next/dist/assets/request-meta-DYBMWhX8.js +1 -0
  97. package/src/console/ui-next/dist/assets/shortcut-keys-DTE1sjTC.js +1 -0
  98. package/src/console/ui-next/dist/assets/store-page-CcE-SXC8.js +41 -0
  99. package/src/console/ui-next/dist/assets/trace-detail-sheet-CKMSEQ4s.js +2 -0
  100. package/src/console/ui-next/dist/assets/tree-expand-toggle-QOfk0M0H.js +55 -0
  101. package/src/console/ui-next/dist/assets/units-page-DKC1CDDd.js +1 -0
  102. package/src/console/ui-next/dist/assets/use-vault-list-BAyQG8Re.js +1 -0
  103. package/src/console/ui-next/dist/assets/vault-page-B54v4Yit.js +2 -0
  104. package/src/console/ui-next/dist/index.html +4 -3
  105. package/src/console/ui-next/seed-parked-approval.ts +126 -0
  106. package/src/console/ui-next/src/client.ts +224 -1
  107. package/src/console/ui-next/src/components/explorer/detail-header.tsx +9 -0
  108. package/src/console/ui-next/src/components/explorer/explorer-start-toggle.tsx +5 -1
  109. package/src/console/ui-next/src/components/ui/sheet-form.tsx +21 -12
  110. package/src/console/ui-next/src/features/flows/decisions/decisions-page.tsx +216 -0
  111. package/src/console/ui-next/src/features/flows/decisions/resolve.test.ts +48 -0
  112. package/src/console/ui-next/src/features/flows/decisions/resolve.ts +111 -0
  113. package/src/console/ui-next/src/features/flows/flows-page.tsx +10 -0
  114. package/src/console/ui-next/src/features/flows/graph/element-map.ts +1 -0
  115. package/src/console/ui-next/src/features/flows/traces/effect-kind.ts +6 -0
  116. package/src/console/ui-next/src/features/flows/traces/effect-summary.test.ts +24 -0
  117. package/src/console/ui-next/src/features/flows/traces/effect-summary.ts +24 -0
  118. package/src/console/ui-next/src/features/flows/traces/http-status.ts +58 -0
  119. package/src/console/ui-next/src/features/flows/traces/request-client-card.tsx +355 -0
  120. package/src/console/ui-next/src/features/flows/traces/request-client.test.ts +114 -0
  121. package/src/console/ui-next/src/features/flows/traces/request-client.ts +423 -0
  122. package/src/console/ui-next/src/features/flows/traces/request-input-view.test.ts +52 -6
  123. package/src/console/ui-next/src/features/flows/traces/request-input-view.ts +85 -19
  124. package/src/console/ui-next/src/features/flows/traces/trace-detail-sheet.tsx +505 -80
  125. package/src/console/ui-next/src/features/flows/traces/trace-lanes.test.ts +61 -0
  126. package/src/console/ui-next/src/features/flows/traces/trace-lanes.ts +78 -0
  127. package/src/console/ui-next/src/features/flows/traces/trace-nav.test.ts +48 -0
  128. package/src/console/ui-next/src/features/flows/traces/trace-nav.ts +57 -0
  129. package/src/console/ui-next/src/features/flows/traces/trace-request-section.tsx +275 -96
  130. package/src/console/ui-next/src/features/flows/traces/traces-pane.tsx +16 -2
  131. package/src/console/ui-next/src/features/observability/detail/ai-runs.tsx +369 -0
  132. package/src/console/ui-next/src/features/observability/observability-page.tsx +31 -1
  133. package/src/console/ui-next/src/features/observability/state/observability-selection.ts +18 -3
  134. package/src/console/ui-next/src/features/store/performance/kv-performance-panel.tsx +12 -1
  135. package/src/console/ui-next/src/features/store/performance/performance-panel.tsx +14 -1
  136. package/src/console/ui-next/src/features/units/call/call-api-panel.tsx +1 -1
  137. package/src/console/ui-next/src/features/units/detail/flow-contract-panel.tsx +6 -2
  138. package/src/console/ui-next/src/features/units/explorer/units-tree.tsx +73 -18
  139. package/src/console/ui-next/src/features/units/lib/unit-tree.test.ts +21 -0
  140. package/src/console/ui-next/src/features/units/lib/unit-tree.ts +82 -0
  141. package/src/console/ui-next/src/features/units/units-page.tsx +25 -7
  142. package/src/console/ui-next/src/router.tsx +17 -0
  143. package/src/console/ui-next/ui-next-seed-runs.ts +23 -0
  144. package/src/console/ui-next/vite-console-kernel-plugin.ts +43 -3
  145. package/src/console/ui-next/vite.config.ts +9 -0
  146. package/src/docker/recipes/dragonfly.ts +3 -2
  147. package/src/docker/recipes/redis.ts +1 -1
  148. package/src/drivers/ai-anthropic.ts +212 -21
  149. package/src/drivers/ai-mock.ts +34 -1
  150. package/src/drivers/ai-openai-compatible.ts +67 -26
  151. package/src/drivers/ai-preconnect.ts +18 -0
  152. package/src/drivers/ai-stream.test.ts +62 -0
  153. package/src/drivers/ai-types.ts +20 -0
  154. package/src/drivers/journal-postgres.ts +289 -2
  155. package/src/drivers/memory-ddl.test.ts +18 -0
  156. package/src/drivers/memory.ts +21 -1
  157. package/src/drivers/pg-rls.test.ts +31 -0
  158. package/src/drivers/pg-rls.ts +15 -0
  159. package/src/drivers/postgres.test.ts +9 -0
  160. package/src/drivers/postgres.ts +49 -11
  161. package/src/elements/ai/agent-event-slot.ts +27 -0
  162. package/src/elements/ai/approval-http.ts +207 -0
  163. package/src/elements/ai/approval.test.ts +741 -0
  164. package/src/elements/ai/approval.ts +228 -0
  165. package/src/elements/ai/decisions/bind.ts +163 -0
  166. package/src/elements/ai/decisions/certificate.test.ts +291 -0
  167. package/src/elements/ai/decisions/certificate.ts +377 -0
  168. package/src/elements/ai/decisions/certify.ts +314 -0
  169. package/src/elements/ai/decisions/decide.test.ts +953 -0
  170. package/src/elements/ai/decisions/e2e.test.ts +399 -0
  171. package/src/elements/ai/decisions/export.test.ts +120 -0
  172. package/src/elements/ai/decisions/export.ts +146 -0
  173. package/src/elements/ai/decisions/fixtures/openrouter-request.json +24 -0
  174. package/src/elements/ai/decisions/fixtures/openrouter-response.json +22 -0
  175. package/src/elements/ai/decisions/fixtures/typesafe-request.json +19 -0
  176. package/src/elements/ai/decisions/fixtures/typesafe-response.json +13 -0
  177. package/src/elements/ai/decisions/http.test.ts +154 -0
  178. package/src/elements/ai/decisions/http.ts +240 -0
  179. package/src/elements/ai/decisions/labels.test.ts +282 -0
  180. package/src/elements/ai/decisions/labels.ts +257 -0
  181. package/src/elements/ai/decisions/openrouter.live.test.ts +136 -0
  182. package/src/elements/ai/decisions/openrouter.ts +36 -0
  183. package/src/elements/ai/decisions/provider.ts +102 -0
  184. package/src/elements/ai/decisions/typesafe.ts +36 -0
  185. package/src/elements/ai/declare.ts +252 -1
  186. package/src/elements/ai/events.test.ts +379 -0
  187. package/src/elements/ai/events.ts +138 -0
  188. package/src/elements/ai/run-events.test.ts +461 -0
  189. package/src/elements/ai/run-events.ts +522 -0
  190. package/src/elements/ai/runtime.ts +1186 -105
  191. package/src/elements/ai/stream-turn.ts +214 -0
  192. package/src/elements/ai/stream.live.test.ts +76 -0
  193. package/src/elements/ai/subagent.test.ts +136 -0
  194. package/src/elements/ai.test.ts +497 -3
  195. package/src/elements/ai.ts +16 -1
  196. package/src/elements/clock/durable.ts +27 -1
  197. package/src/elements/store/sql-session.test.ts +51 -0
  198. package/src/elements/store/sql-session.ts +111 -1
  199. package/src/i18n/catalogs/ar.ts +5 -0
  200. package/src/i18n/catalogs/en.ts +5 -0
  201. package/src/index.ts +2 -0
  202. package/src/kernel/agent-event-store.ts +448 -0
  203. package/src/kernel/app.ts +103 -1
  204. package/src/kernel/boot-bind/ai.ts +3 -0
  205. package/src/kernel/boot-bind/store.test.ts +26 -0
  206. package/src/kernel/boot-bind/store.ts +82 -6
  207. package/src/kernel/boot.ts +15 -4
  208. package/src/kernel/builtin-errors.ts +2 -0
  209. package/src/kernel/capability.ts +3 -0
  210. package/src/kernel/decision-label-store.ts +441 -0
  211. package/src/kernel/effects.test.ts +2 -1
  212. package/src/kernel/effects.ts +13 -1
  213. package/src/kernel/element-registries.ts +3 -0
  214. package/src/kernel/errors-text.ts +4 -0
  215. package/src/kernel/errors.ts +2 -0
  216. package/src/kernel/flow.test.ts +2 -3
  217. package/src/kernel/fx-decide.ts +761 -0
  218. package/src/kernel/fx.test.ts +12 -5
  219. package/src/kernel/fx.ts +312 -29
  220. package/src/kernel/http-frame.test.ts +94 -0
  221. package/src/kernel/http-frame.ts +203 -0
  222. package/src/kernel/journal.ts +104 -0
  223. package/src/kernel/json-result.ts +10 -0
  224. package/src/kernel/pipeline.test.ts +45 -0
  225. package/src/kernel/sse-id.ts +32 -0
  226. package/src/manifest/diff.ts +1 -0
  227. package/src/manifest/types.ts +29 -2
  228. package/src/mcp/ai-tools.test.ts +202 -0
  229. package/src/mcp/authorization.ts +66 -0
  230. package/src/mcp/session.ts +3 -0
  231. package/src/mcp/tools.ts +168 -0
  232. package/src/release/http-graph.test.ts +2 -0
  233. package/src/release/http-graph.ts +5 -0
  234. package/src/release/measure.ts +39 -14
  235. package/src/runs/collect.ts +4 -1
  236. package/src/runs/parquet.test.ts +27 -1
  237. package/src/runs/parquet.ts +19 -0
  238. package/src/runs/types.ts +31 -0
  239. package/src/runtime/json-code-block.test.ts +106 -47
  240. package/src/runtime/json-code-block.ts +1209 -46
  241. package/src/term.test.ts +13 -0
  242. package/src/term.ts +81 -49
  243. package/src/console/ui-next/dist/assets/PlusSignIcon-CwG3nxfu.js +0 -1
  244. package/src/console/ui-next/dist/assets/access-page-Ze6ckjdd.js +0 -4
  245. package/src/console/ui-next/dist/assets/copy-inline-button-CWG5_ZOh.js +0 -1
  246. package/src/console/ui-next/dist/assets/detail-header-b_ZzIsmU.js +0 -1
  247. package/src/console/ui-next/dist/assets/dropdown-menu-nwXEO1Ac.js +0 -1
  248. package/src/console/ui-next/dist/assets/duration-tone-BbQ_8z50.js +0 -9
  249. package/src/console/ui-next/dist/assets/element-icons-CwyLpVXz.js +0 -1
  250. package/src/console/ui-next/dist/assets/explorer-empty-C8sSCqYT.js +0 -1
  251. package/src/console/ui-next/dist/assets/flows-page-D2E7BtKK.js +0 -1
  252. package/src/console/ui-next/dist/assets/index-VxoEz295.css +0 -2
  253. package/src/console/ui-next/dist/assets/index-vTuwmeQz.js +0 -58
  254. package/src/console/ui-next/dist/assets/observability-page-SJTSzHFJ.js +0 -4
  255. package/src/console/ui-next/dist/assets/request-meta-BjFT7DNl.js +0 -1
  256. package/src/console/ui-next/dist/assets/shortcut-keys-CUNegEV4.js +0 -1
  257. package/src/console/ui-next/dist/assets/store-page-Qj9ThRE-.js +0 -41
  258. package/src/console/ui-next/dist/assets/trace-detail-sheet-Cmitq3da.js +0 -2
  259. package/src/console/ui-next/dist/assets/tree-expand-toggle-CbDIB-7x.js +0 -55
  260. package/src/console/ui-next/dist/assets/units-page-_PuYqFty.js +0 -1
  261. package/src/console/ui-next/dist/assets/use-vault-list-Cs45Czkt.js +0 -1
  262. package/src/console/ui-next/dist/assets/vault-page-BOMr0go9.js +0 -2
@@ -0,0 +1,307 @@
1
+ /**
2
+ * Parse AG-UI agent events from JSON or SSE.
3
+ *
4
+ * Import from `okengine/client/agent`. This stays off the core `okengine/client`
5
+ * graph. Wire `CUSTOM` events stay `CUSTOM`. The three OKE subagent names are
6
+ * also returned as typed variants.
7
+ */
8
+
9
+ import type { AgUiEvent } from "../elements/ai/events.ts";
10
+
11
+ /** Typed subagent notice parsed from `CUSTOM`. */
12
+ export type ParsedSubagentEvent =
13
+ | {
14
+ readonly type: "subagent.started";
15
+ readonly runId: string;
16
+ readonly parentToolCallId?: string;
17
+ }
18
+ | {
19
+ readonly type: "subagent.finished";
20
+ readonly runId: string;
21
+ readonly parentToolCallId?: string;
22
+ }
23
+ | {
24
+ readonly type: "subagent.error";
25
+ readonly runId: string;
26
+ readonly parentToolCallId?: string;
27
+ readonly message?: string;
28
+ };
29
+
30
+ /** One event from {@link parseAgentEvent}. */
31
+ export type ParsedAgentEvent = AgUiEvent | ParsedSubagentEvent;
32
+
33
+ const SUBAGENT_NAMES = {
34
+ "oke.subagent.started": "subagent.started",
35
+ "oke.subagent.finished": "subagent.finished",
36
+ "oke.subagent.error": "subagent.error",
37
+ } as const;
38
+
39
+ /**
40
+ * Parse one JSON event. Unknown objects return undefined.
41
+ *
42
+ * @param data - Parsed JSON value
43
+ */
44
+ export function parseAgentEvent(data: unknown): ParsedAgentEvent | undefined {
45
+ if (typeof data !== "object" || data === null || !("type" in data)) return undefined;
46
+ const event = data as AgUiEvent;
47
+ if (event.type !== "CUSTOM") return event;
48
+ const kind = SUBAGENT_NAMES[event.name as keyof typeof SUBAGENT_NAMES];
49
+ if (!kind) return event;
50
+ const value =
51
+ typeof event.value === "object" && event.value !== null
52
+ ? (event.value as { runId?: unknown; parentToolCallId?: unknown; message?: unknown })
53
+ : {};
54
+ const runId = typeof value.runId === "string" ? value.runId : "";
55
+ const parentToolCallId =
56
+ typeof value.parentToolCallId === "string" ? value.parentToolCallId : undefined;
57
+ if (kind === "subagent.error") {
58
+ return {
59
+ type: kind,
60
+ runId,
61
+ ...(parentToolCallId !== undefined ? { parentToolCallId } : {}),
62
+ ...(typeof value.message === "string" ? { message: value.message } : {}),
63
+ };
64
+ }
65
+ return {
66
+ type: kind,
67
+ runId,
68
+ ...(parentToolCallId !== undefined ? { parentToolCallId } : {}),
69
+ };
70
+ }
71
+
72
+ /**
73
+ * Read `data:` frames from an SSE response body.
74
+ *
75
+ * Pass a URL to follow a run: the generator reconnects and sends
76
+ * `Last-Event-ID` from the last frame it yielded.
77
+ *
78
+ * @param source - One response, or the follow URL
79
+ * @param init - Fetch, abort, and a starting event id
80
+ */
81
+ export async function* readAgentEvents(
82
+ source: Response | string | URL,
83
+ init?: AgentFollowInit,
84
+ ): AsyncIterable<ParsedAgentEvent> {
85
+ if (source instanceof Response) {
86
+ yield* readAgentEventResponse(source, init);
87
+ return;
88
+ }
89
+ const fetcher = init?.fetch ?? fetch;
90
+ let lastEventId = init?.lastEventId;
91
+ let delayMs = 250;
92
+ for (;;) {
93
+ if (init?.signal?.aborted) return;
94
+ const headers = new Headers(init?.headers);
95
+ if (lastEventId) headers.set("last-event-id", lastEventId);
96
+ let response: Response;
97
+ try {
98
+ response = await fetcher(source, { headers, signal: init?.signal });
99
+ } catch (err) {
100
+ if (init?.signal?.aborted) return;
101
+ init?.trace?.(err instanceof Error ? err.message : "agent events: network error");
102
+ await wait(delayMs, init?.signal);
103
+ delayMs = Math.min(delayMs * 2, 10_000);
104
+ continue;
105
+ }
106
+ delayMs = 250;
107
+ if (!response.ok) throw new Error(`agent events: HTTP ${response.status}`);
108
+ let sawFrame = false;
109
+ try {
110
+ for await (const frame of readAgentEventFrames(response, init?.trace)) {
111
+ sawFrame = true;
112
+ if (frame.id) {
113
+ lastEventId = frame.id;
114
+ init?.onId?.(frame.id);
115
+ }
116
+ if (frame.event) yield frame.event;
117
+ if (frame.event?.type === "RUN_ERROR") return;
118
+ if (frame.event?.type === "RUN_FINISHED" && frame.event.outcome?.type !== "interrupt")
119
+ return;
120
+ }
121
+ } catch (err) {
122
+ if (init?.signal?.aborted) return;
123
+ init?.trace?.(err instanceof Error ? err.message : "agent events: network error");
124
+ await wait(delayMs, init?.signal);
125
+ delayMs = Math.min(delayMs * 2, 10_000);
126
+ continue;
127
+ }
128
+ if (!sawFrame || init?.signal?.aborted) return;
129
+ }
130
+ }
131
+
132
+ /** Options for {@link approve} and {@link deny}. */
133
+ export interface AgentDecisionInit {
134
+ readonly fetch?: typeof fetch;
135
+ /** Cap on lease retries. Default 5. */
136
+ readonly attempts?: number;
137
+ readonly headers?: Record<string, string>;
138
+ }
139
+
140
+ /**
141
+ * Approve a parked tool. Retries `JournalLeaseBusy`. Throws on `Conflict`.
142
+ *
143
+ * @param url - Approve route (`/agent/approvals/approve`)
144
+ * @param id - Approval id
145
+ * @param args - Replacement tool input
146
+ * @param init - Fetch override
147
+ */
148
+ export async function approve(
149
+ url: string,
150
+ id: string,
151
+ args?: unknown,
152
+ init?: AgentDecisionInit,
153
+ ): Promise<void> {
154
+ await postDecision(url, { id, ...(args !== undefined ? { args } : {}) }, init);
155
+ }
156
+
157
+ /**
158
+ * Deny a parked tool. Retries `JournalLeaseBusy`. Throws on `Conflict`.
159
+ *
160
+ * @param url - Deny route (`/agent/approvals/deny`)
161
+ * @param id - Approval id
162
+ * @param reason - Text the model sees
163
+ * @param init - Fetch override
164
+ */
165
+ export async function deny(
166
+ url: string,
167
+ id: string,
168
+ reason?: string,
169
+ init?: AgentDecisionInit,
170
+ ): Promise<void> {
171
+ await postDecision(url, { id, ...(reason !== undefined ? { reason } : {}) }, init);
172
+ }
173
+
174
+ /** Follow options for {@link readAgentEvents}. */
175
+ export interface AgentFollowInit {
176
+ readonly fetch?: typeof fetch;
177
+ readonly headers?: Record<string, string>;
178
+ readonly signal?: AbortSignal;
179
+ readonly lastEventId?: string;
180
+ /** Called with each SSE id as it is read. */
181
+ onId?(id: string): void;
182
+ /** Called when a frame cannot be parsed. The frame is skipped. */
183
+ trace?(message: string): void;
184
+ }
185
+
186
+ async function* readAgentEventResponse(
187
+ response: Response,
188
+ init?: AgentFollowInit,
189
+ ): AsyncIterable<ParsedAgentEvent> {
190
+ for await (const frame of readAgentEventFrames(response, init?.trace)) {
191
+ if (frame.id) init?.onId?.(frame.id);
192
+ if (frame.event) yield frame.event;
193
+ }
194
+ }
195
+
196
+ async function* readAgentEventFrames(
197
+ response: Response,
198
+ trace?: (message: string) => void,
199
+ ): AsyncIterable<{ id?: string; event?: ParsedAgentEvent }> {
200
+ const body = response.body;
201
+ if (!body) return;
202
+ const reader = body.getReader();
203
+ const decoder = new TextDecoder();
204
+ let buffer = "";
205
+ for (;;) {
206
+ const chunk = await reader.read();
207
+ if (chunk.done) break;
208
+ buffer += decoder.decode(chunk.value, { stream: true });
209
+ const frames = buffer.split("\n\n");
210
+ buffer = frames.pop() ?? "";
211
+ for (const frame of frames) {
212
+ const lines = frame.split("\n");
213
+ const idLine = lines.find((line) => line.startsWith("id:"));
214
+ const data = lines
215
+ .filter((line) => line.startsWith("data:"))
216
+ .map((line) => line.slice(5).trim())
217
+ .join("\n");
218
+ if (!data || data === "[DONE]") continue;
219
+ let parsed: ParsedAgentEvent | undefined;
220
+ try {
221
+ parsed = parseAgentEvent(JSON.parse(data) as unknown);
222
+ } catch (err) {
223
+ trace?.(err instanceof Error ? err.message : "agent events: malformed frame");
224
+ continue;
225
+ }
226
+ yield {
227
+ ...(idLine ? { id: idLine.slice(3).trim() } : {}),
228
+ ...(parsed ? { event: parsed } : {}),
229
+ };
230
+ }
231
+ }
232
+ }
233
+
234
+ async function postDecision(
235
+ url: string,
236
+ body: { id: string; args?: unknown; reason?: string },
237
+ init?: AgentDecisionInit,
238
+ ): Promise<void> {
239
+ const fetcher = init?.fetch ?? fetch;
240
+ const attempts = init?.attempts ?? 5;
241
+ for (let attempt = 0; attempt < attempts; attempt++) {
242
+ const response = await fetcher(url, {
243
+ method: "POST",
244
+ headers: { "content-type": "application/json", ...headersOf(init?.headers) },
245
+ body: JSON.stringify(body),
246
+ });
247
+ if (response.ok) return;
248
+ const payload = (await response.json().catch(() => ({}))) as {
249
+ error?: { code?: string };
250
+ };
251
+ const code = payload.error?.code;
252
+ if (response.status === 409 && code === "JournalLeaseBusy") {
253
+ const retryAfter = retryAfterMs(response.headers.get("retry-after"));
254
+ await new Promise((resolve) => setTimeout(resolve, retryAfter));
255
+ continue;
256
+ }
257
+ if (response.status === 409 && code === "Conflict") {
258
+ const error = new Error("That value is already in use.");
259
+ error.name = "Conflict";
260
+ throw error;
261
+ }
262
+ const error = new Error(code ?? `agent decision: HTTP ${response.status}`);
263
+ error.name = code ?? "Error";
264
+ throw error;
265
+ }
266
+ const error = new Error("This run is locked by another worker. Retry after the given delay.");
267
+ error.name = "JournalLeaseBusy";
268
+ throw error;
269
+ }
270
+
271
+ /**
272
+ * `Retry-After` as delta-seconds or an HTTP-date.
273
+ *
274
+ * @param header - Header value
275
+ */
276
+ function retryAfterMs(header: string | null): number {
277
+ if (!header) return 0;
278
+ const seconds = Number(header);
279
+ if (Number.isFinite(seconds)) return Math.max(0, seconds) * 1000;
280
+ const at = Date.parse(header);
281
+ if (Number.isNaN(at)) return 0;
282
+ return Math.max(0, at - Date.now());
283
+ }
284
+
285
+ function headersOf(headers: Record<string, string> | undefined): Record<string, string> {
286
+ return headers ?? {};
287
+ }
288
+
289
+ /**
290
+ * Wait, or return early when `signal` aborts.
291
+ *
292
+ * @param ms - Delay
293
+ * @param signal - Abort
294
+ */
295
+ function wait(ms: number, signal?: AbortSignal): Promise<void> {
296
+ return new Promise((resolve) => {
297
+ const timer = setTimeout(resolve, ms);
298
+ signal?.addEventListener(
299
+ "abort",
300
+ () => {
301
+ clearTimeout(timer);
302
+ resolve();
303
+ },
304
+ { once: true },
305
+ );
306
+ });
307
+ }
@@ -246,6 +246,26 @@ describe("transport — retry", () => {
246
246
  expect(n).toBe(3);
247
247
  });
248
248
 
249
+ test("JournalLeaseBusy waits for Retry-After", async () => {
250
+ let n = 0;
251
+ const api = createClient<PingApp>("http://app.test", {
252
+ retry: { retries: 1, delay: 1, backoff: 1 },
253
+ fetch: async () => {
254
+ n += 1;
255
+ if (n === 1) {
256
+ return Response.json(
257
+ { data: null, error: { code: "JournalLeaseBusy", data: { retryAfter: 1 } } },
258
+ { status: 409, headers: { "retry-after": "0.01" } },
259
+ );
260
+ }
261
+ return Response.json({ data: { ok: true }, error: null });
262
+ },
263
+ });
264
+ const result = await api.sys.ping();
265
+ expect(result.error).toBeNull();
266
+ expect(n).toBe(2);
267
+ });
268
+
249
269
  test("other 409 envelopes are not retried", async () => {
250
270
  let n = 0;
251
271
  const api = createClient<PingApp>("http://app.test", {
@@ -104,7 +104,8 @@ export function createTransport(base: string, opts: ClientOptions = {}): Transpo
104
104
  const seconds = Number(res.headers.get("retry-after"));
105
105
  const wait = seconds > 0 ? seconds * 1000 : delay;
106
106
  const structured = await decodeIfEnvelope(res);
107
- if (structured?.error?.code === "IdempotencyInProgress") {
107
+ const code = structured?.error?.code;
108
+ if (code === "IdempotencyInProgress" || code === "JournalLeaseBusy") {
108
109
  await sleep(wait);
109
110
  delay *= backoff;
110
111
  attempt += 1;
@@ -264,7 +264,7 @@ export interface ClientOptions {
264
264
  * A call retries when it is `GET` or `QUERY`, or when it carries an
265
265
  * idempotency key. Keys are sent automatically on every non-GET call.
266
266
  * `idempotencyKey: false` runs once unless that call passes `{ retry: true }`.
267
- * `409 IdempotencyInProgress` waits for `Retry-After` and counts as an attempt.
267
+ * `409 IdempotencyInProgress` and `409 JournalLeaseBusy` wait for `Retry-After` and count as an attempt.
268
268
  */
269
269
  readonly retry?: {
270
270
  /** Extra attempts after the first (default 0). */
@@ -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
+ });