experimental-a2 0.16.0 → 0.17.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 (334) hide show
  1. package/CHANGELOG.md +360 -0
  2. package/dist/{actor-ohPC-81x.d.ts → actor-DBbe5xeE.d.ts} +21 -11
  3. package/dist/actor-DBbe5xeE.d.ts.map +1 -0
  4. package/dist/actor-client.d.ts +2 -5
  5. package/dist/actor-client.d.ts.map +1 -1
  6. package/dist/actor-client.js +13 -4
  7. package/dist/actor-client.js.map +1 -1
  8. package/dist/actor-react.d.ts +5 -3
  9. package/dist/actor-react.d.ts.map +1 -1
  10. package/dist/actor-react.js +20 -10
  11. package/dist/actor-react.js.map +1 -1
  12. package/dist/{actor-shared-B5tJfzt-.js → actor-shared-t7w8xIKP.js} +12 -7
  13. package/dist/actor-shared-t7w8xIKP.js.map +1 -0
  14. package/dist/actor.d.ts +1 -1
  15. package/dist/actor.js +42 -23
  16. package/dist/actor.js.map +1 -1
  17. package/dist/{ai-CrLYwNEx.js → ai-UrWfKeuv.js} +966 -541
  18. package/dist/ai-UrWfKeuv.js.map +1 -0
  19. package/dist/ai-client.d.ts +27 -0
  20. package/dist/ai-client.d.ts.map +1 -0
  21. package/dist/ai-client.js +29 -0
  22. package/dist/ai-client.js.map +1 -0
  23. package/dist/ai-react.d.ts +96 -0
  24. package/dist/ai-react.d.ts.map +1 -0
  25. package/dist/ai-react.js +142 -0
  26. package/dist/ai-react.js.map +1 -0
  27. package/dist/ai-server.browser.js +2 -2
  28. package/dist/ai-server.browser.js.map +1 -1
  29. package/dist/ai-server.d.ts +80 -36
  30. package/dist/ai-server.d.ts.map +1 -1
  31. package/dist/ai-server.js +466 -265
  32. package/dist/ai-server.js.map +1 -1
  33. package/dist/{ai-HJ9fHfYI.d.ts → ai-vpGtcaCB.d.ts} +115 -101
  34. package/dist/ai-vpGtcaCB.d.ts.map +1 -0
  35. package/dist/ai.d.ts +2 -2
  36. package/dist/ai.js +2 -2
  37. package/dist/{client-BYzHjkwU.js → client-COaiaDub.js} +447 -80
  38. package/dist/client-COaiaDub.js.map +1 -0
  39. package/dist/{client-CzyacQpJ.d.ts → client-qRnRUzBS.d.ts} +14 -13
  40. package/dist/client-qRnRUzBS.d.ts.map +1 -0
  41. package/dist/client.d.ts +4 -2
  42. package/dist/client.js +1 -1
  43. package/dist/{contract-CKRg_E4q.js → contract-BYKASlCV.js} +12 -21
  44. package/dist/contract-BYKASlCV.js.map +1 -0
  45. package/dist/{idempotent-replay-DVOlyYbx.js → idempotent-replay-JdgMQRRm.js} +2 -2
  46. package/dist/{idempotent-replay-DVOlyYbx.js.map → idempotent-replay-JdgMQRRm.js.map} +1 -1
  47. package/dist/index.d.ts +6 -5
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +4 -3
  50. package/dist/{errors-DvhSXnxk.d.ts → initial-snapshot-BrLso5x_.d.ts} +7 -3
  51. package/dist/initial-snapshot-BrLso5x_.d.ts.map +1 -0
  52. package/dist/{internal-Dq2qYxou.js → internal-CbVvEwad.js} +23 -2
  53. package/dist/internal-CbVvEwad.js.map +1 -0
  54. package/dist/otel.d.ts +1 -1
  55. package/dist/react-BbJ-g6Uu.d.ts +86 -0
  56. package/dist/react-BbJ-g6Uu.d.ts.map +1 -0
  57. package/dist/react.d.ts +2 -122
  58. package/dist/react.js +107 -100
  59. package/dist/react.js.map +1 -1
  60. package/dist/reducer-BAXc8JyF.js +87 -0
  61. package/dist/reducer-BAXc8JyF.js.map +1 -0
  62. package/dist/{reducer-otzHjuJj.d.ts → reducer-BYu6DT7h.d.ts} +45 -29
  63. package/dist/reducer-BYu6DT7h.d.ts.map +1 -0
  64. package/dist/reducer-runtime-DQwz_J59.js +28 -0
  65. package/dist/reducer-runtime-DQwz_J59.js.map +1 -0
  66. package/dist/scheduler-qstash.d.ts +3 -3
  67. package/dist/scheduler-qstash.d.ts.map +1 -1
  68. package/dist/scheduler-qstash.js +33 -10
  69. package/dist/scheduler-qstash.js.map +1 -1
  70. package/dist/{scheduler-task-BpzhPnRS.js → scheduler-task-X1SToDjP.js} +18 -3
  71. package/dist/scheduler-task-X1SToDjP.js.map +1 -0
  72. package/dist/scheduler-vercel.d.ts +4 -9
  73. package/dist/scheduler-vercel.d.ts.map +1 -1
  74. package/dist/scheduler-vercel.js +25 -44
  75. package/dist/scheduler-vercel.js.map +1 -1
  76. package/dist/{server-DbD7IJVK.d.ts → server-CGM8WzRG.d.ts} +87 -26
  77. package/dist/server-CGM8WzRG.d.ts.map +1 -0
  78. package/dist/{server-BD5ckxZb.js → server-Rj64fNvX.js} +854 -202
  79. package/dist/server-Rj64fNvX.js.map +1 -0
  80. package/dist/server.d.ts +4 -4
  81. package/dist/server.js +2 -2
  82. package/dist/{errors-DCk6ch5n.js → session-clear-DGI4jTQ5.js} +17 -2
  83. package/dist/session-clear-DGI4jTQ5.js.map +1 -0
  84. package/dist/session-clear-Dshpuhff.d.ts +12 -0
  85. package/dist/session-clear-Dshpuhff.d.ts.map +1 -0
  86. package/dist/{store-DyZM6fS5.d.ts → store-Gpkq7WBo.d.ts} +44 -3
  87. package/dist/store-Gpkq7WBo.d.ts.map +1 -0
  88. package/dist/store-N8PXxDAS.js.map +1 -1
  89. package/dist/store-memory.d.ts +1 -1
  90. package/dist/store-memory.d.ts.map +1 -1
  91. package/dist/store-memory.js +182 -69
  92. package/dist/store-memory.js.map +1 -1
  93. package/dist/{store-polling-CmxUbV93.js → store-polling-4_y8lETm.js} +21 -4
  94. package/dist/store-polling-4_y8lETm.js.map +1 -0
  95. package/dist/store-postgres.d.ts +1 -1
  96. package/dist/store-postgres.d.ts.map +1 -1
  97. package/dist/store-postgres.js +399 -108
  98. package/dist/store-postgres.js.map +1 -1
  99. package/dist/{store-presence-polling-C7-XZyW9.js → store-presence-polling-Q1C-k2dd.js} +3 -3
  100. package/dist/{store-presence-polling-C7-XZyW9.js.map → store-presence-polling-Q1C-k2dd.js.map} +1 -1
  101. package/dist/store-redis-http.d.ts +1 -1
  102. package/dist/store-redis-http.d.ts.map +1 -1
  103. package/dist/store-redis-http.js +293 -11
  104. package/dist/store-redis-http.js.map +1 -1
  105. package/dist/store-redis-notify-tqWNnMRu.js +2374 -0
  106. package/dist/store-redis-notify-tqWNnMRu.js.map +1 -0
  107. package/dist/store-redis.d.ts +1 -1
  108. package/dist/store-redis.d.ts.map +1 -1
  109. package/dist/store-redis.js +11 -11
  110. package/dist/store-redis.js.map +1 -1
  111. package/dist/store-sqlite.d.ts +1 -1
  112. package/dist/store-sqlite.d.ts.map +1 -1
  113. package/dist/store-sqlite.js +166 -32
  114. package/dist/store-sqlite.js.map +1 -1
  115. package/dist/stream-buffer-Cx7AAPGq.js +23 -0
  116. package/dist/stream-buffer-Cx7AAPGq.js.map +1 -0
  117. package/dist/{telemetry-B5jzpy6y.d.ts → telemetry-cZH-cibj.d.ts} +2 -2
  118. package/dist/telemetry-cZH-cibj.d.ts.map +1 -0
  119. package/dist/{wire-BO5wWCb1.js → wire-DjfHOvdW.js} +132 -10
  120. package/dist/wire-DjfHOvdW.js.map +1 -0
  121. package/docs/01-quickstart.mdx +2 -2
  122. package/docs/actors/01-introduction.mdx +4 -0
  123. package/docs/actors/04-routes.mdx +14 -1
  124. package/docs/concepts/01-contracts.mdx +95 -0
  125. package/docs/concepts/04-state.mdx +3 -3
  126. package/docs/guides/01-timers.mdx +22 -14
  127. package/docs/guides/02-cancellation.mdx +1 -1
  128. package/docs/guides/03-react.mdx +268 -328
  129. package/docs/guides/04-local-first.mdx +32 -73
  130. package/docs/guides/05-production.mdx +40 -27
  131. package/docs/guides/06-ai-agents.mdx +680 -580
  132. package/docs/guides/09-presence.mdx +3 -7
  133. package/docs/guides/10-transports.mdx +5 -5
  134. package/docs/index.mdx +10 -18
  135. package/docs/reference/01-api.mdx +662 -195
  136. package/docs/reference/02-errors.mdx +2 -1
  137. package/examples/playground/DEPLOY.md +52 -6
  138. package/examples/playground/app/a0/[appId]/loading.tsx +16 -0
  139. package/examples/playground/app/a0/[appId]/page.tsx +48 -0
  140. package/examples/playground/app/a0/a0.css +818 -0
  141. package/examples/playground/app/a0/app-list.tsx +211 -0
  142. package/examples/playground/app/a0/app-view.tsx +55 -0
  143. package/examples/playground/app/a0/apps/[appId]/preview/route.ts +27 -0
  144. package/examples/playground/app/a0/apps/[appId]/route.ts +23 -0
  145. package/examples/playground/app/a0/apps.test.ts +116 -0
  146. package/examples/playground/app/a0/apps.ts +87 -0
  147. package/examples/playground/app/a0/chat.tsx +364 -0
  148. package/examples/playground/app/a0/columns.tsx +109 -0
  149. package/examples/playground/app/a0/context-menu.tsx +116 -0
  150. package/examples/playground/app/a0/cookies.ts +46 -0
  151. package/examples/playground/app/a0/crash-on-request.ts +72 -0
  152. package/examples/playground/app/a0/crashes/route.ts +3 -0
  153. package/examples/playground/app/a0/crashes.test.ts +33 -0
  154. package/examples/playground/app/a0/crashes.ts +28 -0
  155. package/examples/playground/app/a0/devtools.tsx +164 -0
  156. package/examples/playground/app/a0/events/route.ts +6 -0
  157. package/examples/playground/app/a0/flags.ts +30 -0
  158. package/examples/playground/app/a0/layout.tsx +38 -0
  159. package/examples/playground/app/a0/list/route.ts +10 -0
  160. package/examples/playground/app/a0/model.test.ts +56 -0
  161. package/examples/playground/app/a0/model.ts +58 -0
  162. package/examples/playground/app/a0/page.tsx +14 -0
  163. package/examples/playground/app/a0/preview-pane.tsx +145 -0
  164. package/examples/playground/app/a0/preview.test.ts +53 -0
  165. package/examples/playground/app/a0/preview.ts +26 -0
  166. package/examples/playground/app/a0/sandbox-log.ts +69 -0
  167. package/examples/playground/app/a0/sandbox.ts +191 -0
  168. package/examples/playground/app/a0/sandboxes/scheduler/route.ts +6 -0
  169. package/examples/playground/app/a0/scheduler/route.ts +7 -0
  170. package/examples/playground/app/a0/server.ts +158 -0
  171. package/examples/playground/app/a0/session.ts +47 -0
  172. package/examples/playground/app/a0/shell.tsx +150 -0
  173. package/examples/playground/app/a0/short/events/route.ts +9 -0
  174. package/examples/playground/app/a0/splitter.tsx +120 -0
  175. package/examples/playground/app/agent/[agentId]/agent-client.tsx +144 -174
  176. package/examples/playground/app/agent/[agentId]/command-receipt.test.tsx +65 -0
  177. package/examples/playground/app/agent/[agentId]/command-receipt.tsx +27 -0
  178. package/examples/playground/app/agent/[agentId]/compaction-event.tsx +25 -24
  179. package/examples/playground/app/agent/[agentId]/compaction-panel.test.tsx +119 -0
  180. package/examples/playground/app/agent/[agentId]/compaction-panel.tsx +208 -165
  181. package/examples/playground/app/agent/[agentId]/page.tsx +2 -16
  182. package/examples/playground/app/agent/events/route.ts +5 -2
  183. package/examples/playground/app/agent/ingress.test.ts +98 -0
  184. package/examples/playground/app/agent/model-input.test.tsx +58 -0
  185. package/examples/playground/app/agent/model-input.tsx +115 -0
  186. package/examples/playground/app/agent/model.test.ts +73 -0
  187. package/examples/playground/app/agent/model.ts +58 -26
  188. package/examples/playground/app/agent/new-agent-session.tsx +37 -19
  189. package/examples/playground/app/agent/page.tsx +4 -4
  190. package/examples/playground/app/agent/server.ts +15 -14
  191. package/examples/playground/app/agent/session.ts +5 -6
  192. package/examples/playground/app/agent/telemetry.test.ts +168 -0
  193. package/examples/playground/app/agent/telemetry.ts +30 -0
  194. package/examples/playground/app/agent/trace.test.ts +23 -0
  195. package/examples/playground/app/agent/trace.ts +18 -0
  196. package/examples/playground/app/canvas/[canvasId]/canvas-client.tsx +11 -11
  197. package/examples/playground/app/canvas/[canvasId]/page.tsx +1 -2
  198. package/examples/playground/app/canvas/[canvasId]/session.ts +2 -2
  199. package/examples/playground/app/canvas/model.ts +53 -53
  200. package/examples/playground/app/chat/[chatId]/agent-state.test.ts +176 -0
  201. package/examples/playground/app/chat/[chatId]/agent-stream-drawer.tsx +16 -1
  202. package/examples/playground/app/chat/[chatId]/chat-client.tsx +55 -77
  203. package/examples/playground/app/chat/[chatId]/chat-view.ts +37 -3
  204. package/examples/playground/app/chat/[chatId]/page.tsx +7 -15
  205. package/examples/playground/app/chat/[chatId]/session.ts +2 -19
  206. package/examples/playground/app/chat/agent/route.ts +9 -0
  207. package/examples/playground/app/chat/model.test.ts +46 -119
  208. package/examples/playground/app/chat/model.ts +82 -128
  209. package/examples/playground/app/chat/scheduler/route.ts +2 -2
  210. package/examples/playground/app/chat/server.test.ts +108 -0
  211. package/examples/playground/app/chat/server.ts +69 -73
  212. package/examples/playground/app/components/activity-feed.tsx +30 -1
  213. package/examples/playground/app/components/activity-history.test.tsx +35 -0
  214. package/examples/playground/app/components/demo-icons.tsx +89 -0
  215. package/examples/playground/app/components/demos.tsx +33 -0
  216. package/examples/playground/app/components/fast-link.test.tsx +58 -0
  217. package/examples/playground/app/components/fast-link.tsx +55 -0
  218. package/examples/playground/app/components/site-header.tsx +96 -0
  219. package/examples/playground/app/counter/counter-client.tsx +3 -3
  220. package/examples/playground/app/counter/model.test.ts +3 -3
  221. package/examples/playground/app/counter/model.ts +11 -21
  222. package/examples/playground/app/counter/page.tsx +2 -15
  223. package/examples/playground/app/counter/session.ts +2 -2
  224. package/examples/playground/app/documents/[documentId]/code-editor.tsx +12 -1
  225. package/examples/playground/app/documents/[documentId]/document-client.tsx +10 -11
  226. package/examples/playground/app/documents/[documentId]/page.tsx +1 -2
  227. package/examples/playground/app/documents/[documentId]/session.ts +2 -2
  228. package/examples/playground/app/documents/model.ts +27 -27
  229. package/examples/playground/app/globals.css +420 -207
  230. package/examples/playground/app/initial-state.test.tsx +54 -0
  231. package/examples/playground/app/layout.tsx +7 -23
  232. package/examples/playground/app/orders/[orderId]/page.tsx +1 -2
  233. package/examples/playground/app/orders/[orderId]/session.ts +2 -2
  234. package/examples/playground/app/orders/model.ts +49 -49
  235. package/examples/playground/app/page.tsx +13 -102
  236. package/examples/playground/app/recovery/[recoveryId]/page.tsx +1 -2
  237. package/examples/playground/app/recovery/[recoveryId]/session.ts +2 -2
  238. package/examples/playground/app/recovery/model.ts +30 -30
  239. package/examples/playground/instrumentation.ts +5 -0
  240. package/examples/playground/package.json +10 -2
  241. package/examples/playground/scripts/a0-template.ts +77 -0
  242. package/examples/playground/vercel.json +18 -0
  243. package/package.json +3 -1
  244. package/src/actor-client.ts +16 -4
  245. package/src/actor-react.ts +25 -11
  246. package/src/actor-shared.ts +21 -6
  247. package/src/actor.ts +76 -31
  248. package/src/ai-agent-actions.ts +219 -0
  249. package/src/ai-agent.ts +2343 -0
  250. package/src/ai-app-state.ts +91 -0
  251. package/src/ai-client-state.ts +39 -2
  252. package/src/ai-client.ts +62 -0
  253. package/src/ai-context-schema.ts +25 -5
  254. package/src/ai-context.ts +90 -40
  255. package/src/ai-control-server.ts +94 -55
  256. package/src/ai-control.ts +2 -0
  257. package/src/ai-coordinator.ts +23 -22
  258. package/src/ai-message-base.ts +209 -0
  259. package/src/ai-message-projection.ts +382 -58
  260. package/src/ai-progress-batches.ts +26 -0
  261. package/src/ai-projector.ts +95 -14
  262. package/src/ai-react.ts +146 -0
  263. package/src/ai-server.browser.ts +2 -2
  264. package/src/ai-server.ts +20 -1951
  265. package/src/ai-stored-state.ts +48 -163
  266. package/src/ai.ts +343 -276
  267. package/src/bootstrap-stream.ts +72 -0
  268. package/src/bounded-cache.ts +55 -0
  269. package/src/client-render-session.ts +49 -0
  270. package/src/client.ts +688 -151
  271. package/src/contract.ts +92 -42
  272. package/src/deterministic-id.ts +37 -0
  273. package/src/equal-json.ts +32 -0
  274. package/src/errors.ts +13 -1
  275. package/src/index.ts +5 -1
  276. package/src/initial-snapshot.ts +39 -0
  277. package/src/internal.ts +45 -0
  278. package/src/postgres-notification-scope.ts +14 -3
  279. package/src/react.ts +171 -206
  280. package/src/redis-http-reply.ts +256 -0
  281. package/src/redis-http-stream.ts +73 -0
  282. package/src/redis-http-subscriptions.ts +13 -5
  283. package/src/reducer-runtime.ts +52 -0
  284. package/src/reducer.ts +92 -10
  285. package/src/scheduler-qstash.ts +49 -20
  286. package/src/scheduler-task.ts +34 -2
  287. package/src/scheduler-vercel.ts +32 -84
  288. package/src/server-fetch.ts +139 -35
  289. package/src/server.ts +957 -237
  290. package/src/session-clear.ts +23 -0
  291. package/src/session-socket.ts +154 -49
  292. package/src/sse.ts +71 -4
  293. package/src/store-memory.ts +220 -78
  294. package/src/store-polling.ts +18 -4
  295. package/src/store-postgres.ts +461 -114
  296. package/src/store-redis-core.ts +1260 -434
  297. package/src/store-redis-http.ts +18 -2
  298. package/src/store-redis-notify.ts +260 -85
  299. package/src/store-redis.ts +19 -9
  300. package/src/store-sqlite.ts +259 -45
  301. package/src/store.ts +46 -1
  302. package/src/stream-buffer.ts +43 -0
  303. package/src/telemetry.ts +13 -1
  304. package/src/wire.ts +241 -13
  305. package/dist/actor-ohPC-81x.d.ts.map +0 -1
  306. package/dist/actor-shared-B5tJfzt-.js.map +0 -1
  307. package/dist/ai-CrLYwNEx.js.map +0 -1
  308. package/dist/ai-HJ9fHfYI.d.ts.map +0 -1
  309. package/dist/client-BYzHjkwU.js.map +0 -1
  310. package/dist/client-CzyacQpJ.d.ts.map +0 -1
  311. package/dist/contract-CKRg_E4q.js.map +0 -1
  312. package/dist/errors-DCk6ch5n.js.map +0 -1
  313. package/dist/errors-DvhSXnxk.d.ts.map +0 -1
  314. package/dist/internal-Dq2qYxou.js.map +0 -1
  315. package/dist/react.d.ts.map +0 -1
  316. package/dist/reducer-DEMjEY_O.js +0 -29
  317. package/dist/reducer-DEMjEY_O.js.map +0 -1
  318. package/dist/reducer-otzHjuJj.d.ts.map +0 -1
  319. package/dist/scheduler-task-BpzhPnRS.js.map +0 -1
  320. package/dist/server-BD5ckxZb.js.map +0 -1
  321. package/dist/server-DbD7IJVK.d.ts.map +0 -1
  322. package/dist/store-DyZM6fS5.d.ts.map +0 -1
  323. package/dist/store-polling-CmxUbV93.js.map +0 -1
  324. package/dist/store-redis-notify-BVLUfI3j.js +0 -1583
  325. package/dist/store-redis-notify-BVLUfI3j.js.map +0 -1
  326. package/dist/telemetry-B5jzpy6y.d.ts.map +0 -1
  327. package/dist/validate-XKT4FSNn.js +0 -30
  328. package/dist/validate-XKT4FSNn.js.map +0 -1
  329. package/dist/wire-BO5wWCb1.js.map +0 -1
  330. package/examples/playground/app/agent/[agentId]/compaction/route.ts +0 -14
  331. package/examples/playground/app/agent/compaction-settings.test.ts +0 -144
  332. package/examples/playground/app/agent/compaction-settings.ts +0 -49
  333. package/examples/playground/app/agent/compaction-timeline.test.ts +0 -377
  334. package/examples/playground/app/agent/compaction-timeline.ts +0 -208
@@ -0,0 +1,27 @@
1
+ import type { ControlReceipt } from 'experimental-a2/ai'
2
+
3
+ export function CommandReceipt({
4
+ receipt,
5
+ stopCommandIds,
6
+ }: {
7
+ receipt: ControlReceipt | null
8
+ stopCommandIds: readonly string[]
9
+ }) {
10
+ if (receipt?.outcome !== 'rejected') return null
11
+ if (
12
+ receipt.reason === 'stale-turn' &&
13
+ stopCommandIds.includes(receipt.commandId)
14
+ ) {
15
+ return (
16
+ <p className="hint" role="status">
17
+ That turn had already finished.
18
+ </p>
19
+ )
20
+ }
21
+ return (
22
+ <p className="error" role="alert">
23
+ Command rejected: {receipt.reason}. Refresh the input or turn you want to
24
+ change.
25
+ </p>
26
+ )
27
+ }
@@ -1,38 +1,39 @@
1
1
  import type { ReactNode } from 'react'
2
- import { formatActivityTime } from '@/app/components/activity-feed'
3
- import type { CompactionEntry } from '../compaction-timeline'
2
+ import type { CompactionPartData } from 'experimental-a2/ai'
3
+
4
+ const number = new Intl.NumberFormat('en-US')
4
5
 
5
6
  export function CompactionEvent({
6
- entry,
7
+ data,
7
8
  }: {
8
- entry: CompactionEntry
9
+ data: CompactionPartData
9
10
  }): ReactNode {
10
- const { event, running, outcome } = entry
11
- const completed = event.type === 'ai.compaction.completed'
12
- const label = completed
13
- ? 'Context compacted'
14
- : running
15
- ? 'Compacting…'
16
- : 'Compaction did not complete'
11
+ const { status, summary, inputTokens, thresholdTokens } = data
12
+ const running = status === 'running'
13
+ const label =
14
+ status === 'completed'
15
+ ? 'Context compacted'
16
+ : running
17
+ ? 'Compacting…'
18
+ : 'Compaction did not complete'
19
+ const hint = [
20
+ inputTokens === undefined || thresholdTokens === undefined
21
+ ? undefined
22
+ : `${number.format(inputTokens)} tokens against a ${number.format(thresholdTokens)} token threshold`,
23
+ status === 'failed' || status === 'interrupted'
24
+ ? `Generation ${status}`
25
+ : undefined,
26
+ ]
27
+ .filter((line) => line !== undefined)
28
+ .join(' · ')
17
29
 
18
30
  return (
19
31
  <details className="agent-compaction-event">
20
32
  <summary>
21
33
  <span role={running ? 'status' : undefined}>{label}</span>
22
- <time dateTime={event.createdAt.toISOString()}>
23
- {formatActivityTime(event.createdAt)}
24
- </time>
25
34
  </summary>
26
- <p className="hint">
27
- Event #{event.index} ·{' '}
28
- {event.payload.throughIndex === undefined
29
- ? `Through message ${event.payload.throughMessageId}`
30
- : `Summarized through event #${event.payload.throughIndex}`}
31
- {outcome === undefined ? null : ` · Generation ${outcome}`}
32
- </p>
33
- {completed && event.payload.summary ? (
34
- <pre>{event.payload.summary}</pre>
35
- ) : null}
35
+ {hint ? <p className="hint">{hint}</p> : null}
36
+ {summary ? <pre>{summary}</pre> : null}
36
37
  </details>
37
38
  )
38
39
  }
@@ -0,0 +1,119 @@
1
+ // @vitest-environment jsdom
2
+ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
3
+ import { afterEach, beforeEach, expect, it, vi } from 'vitest'
4
+ import { demoAgent } from '../model'
5
+ import type { AgentSession } from '../session'
6
+ import { CompactionPanel } from './compaction-panel'
7
+
8
+ const loadHistory = vi.fn<AgentSession['loadHistory']>()
9
+ const push = vi.fn<AgentSession['push']>()
10
+ let session: AgentSession
11
+
12
+ beforeEach(() => {
13
+ loadHistory.mockReset().mockResolvedValue([])
14
+ push.mockReset()
15
+ session = {
16
+ state: demoAgent.reducer.initialState,
17
+ hydrated: true,
18
+ events: [],
19
+ index: 10,
20
+ connection: { status: 'live', reconnects: 0 },
21
+ history: { complete: false, loading: false, oldestLoaded: null },
22
+ loadHistory,
23
+ push,
24
+ reconnect: vi.fn<() => void>(),
25
+ }
26
+ })
27
+ afterEach(cleanup)
28
+
29
+ async function toggle(panel: HTMLDetailsElement, open: boolean) {
30
+ await act(async () => {
31
+ panel.open = open
32
+ fireEvent(panel, new Event('toggle'))
33
+ })
34
+ }
35
+
36
+ it('uses seeded settings immediately and loads diagnostic history only on open', async () => {
37
+ session.state = {
38
+ ...session.state,
39
+ compactionSettings: { mode: 'manual', thresholdTokens: 1234 },
40
+ }
41
+ const view = render(<CompactionPanel session={session} />)
42
+ expect((screen.getByLabelText('Mode') as HTMLSelectElement).value).toBe(
43
+ 'manual',
44
+ )
45
+ expect(
46
+ (screen.getByLabelText('Threshold in tokens') as HTMLInputElement).value,
47
+ ).toBe('1234')
48
+ expect(push).not.toHaveBeenCalled()
49
+ expect(loadHistory).not.toHaveBeenCalled()
50
+ const panel =
51
+ view.container.querySelector<HTMLDetailsElement>('.agent-compaction')!
52
+ await toggle(panel, true)
53
+ expect(loadHistory).toHaveBeenCalledTimes(1)
54
+ await toggle(panel, false)
55
+ await toggle(panel, true)
56
+ expect(loadHistory).toHaveBeenCalledTimes(1)
57
+ session.state = {
58
+ ...session.state,
59
+ compactionSettings: { mode: 'disabled' },
60
+ compactionSettingsId: 'new',
61
+ }
62
+ session.events = [
63
+ {
64
+ id: 'old',
65
+ sessionId: 'one',
66
+ index: 2,
67
+ createdAt: new Date(),
68
+ type: 'compaction.settings.updated',
69
+ payload: { mode: 'automatic' },
70
+ },
71
+ ]
72
+ view.rerender(<CompactionPanel session={session} />)
73
+ expect((screen.getByLabelText('Mode') as HTMLSelectElement).value).toBe(
74
+ 'disabled',
75
+ )
76
+ expect(push).not.toHaveBeenCalled()
77
+ })
78
+
79
+ it('keeps diagnostic read failures local and retries only when requested', async () => {
80
+ loadHistory.mockRejectedValueOnce(new Error('History failed'))
81
+ const view = render(
82
+ <>
83
+ <input aria-label="composer" />
84
+ <CompactionPanel session={session} />
85
+ </>,
86
+ )
87
+ await toggle(view.container.querySelector('details')!, true)
88
+ expect(screen.getByRole('alert').textContent).toBe('History failed')
89
+ expect(screen.getByLabelText('composer')).toBeDefined()
90
+ expect((screen.getByLabelText('Mode') as HTMLSelectElement).value).toBe(
91
+ 'automatic',
92
+ )
93
+ await act(async () =>
94
+ fireEvent.click(
95
+ screen.getByRole('button', { name: 'Load earlier events' }),
96
+ ),
97
+ )
98
+ expect(loadHistory).toHaveBeenCalledTimes(2)
99
+ expect(screen.queryByRole('alert')).toBeNull()
100
+ expect(push).not.toHaveBeenCalled()
101
+ })
102
+
103
+ it('pushes the settings event and reports a failed save outside the form', async () => {
104
+ push.mockImplementationOnce(() =>
105
+ Object.assign(Promise.reject(new Error('Save failed')), {
106
+ confirmed: Promise.resolve([]),
107
+ }),
108
+ )
109
+ const view = render(<CompactionPanel session={session} />)
110
+ await toggle(view.container.querySelector('details')!, true)
111
+ await act(async () =>
112
+ fireEvent.click(screen.getByRole('button', { name: 'Set to 1 token' })),
113
+ )
114
+ expect(push).toHaveBeenCalledWith({
115
+ type: 'compaction.settings.updated',
116
+ payload: { mode: 'manual', thresholdTokens: 1 },
117
+ })
118
+ expect(screen.getByRole('alert').textContent).toBe('Save failed')
119
+ })
@@ -1,15 +1,15 @@
1
1
  'use client'
2
2
 
3
3
  import type { FormEvent, ReactNode } from 'react'
4
- import { useEffect, useId, useRef, useState } from 'react'
5
- import type { AIState } from 'experimental-a2/ai'
6
- import { formatActivityTime } from '@/app/components/activity-feed'
4
+ import { useId, useRef, useState } from 'react'
5
+ import { formatActivityTime } from '../../components/activity-feed'
6
+ import type { AgentSession } from '../session'
7
7
  import {
8
8
  agentModel,
9
9
  compactionSettingsSchema,
10
- latestCompactionSettings,
11
- type AgentEvent,
10
+ type AgentState,
12
11
  type CompactionSettings,
12
+ type AgentEvent,
13
13
  } from '../model'
14
14
 
15
15
  const number = new Intl.NumberFormat('en-US')
@@ -19,13 +19,15 @@ const tokens = (value: number | undefined): string =>
19
19
  : `${number.format(value)} ${value === 1 ? 'token' : 'tokens'}`
20
20
 
21
21
  function SettingsForm({
22
- agentId,
23
22
  settings,
24
23
  closed,
24
+ saving,
25
+ save,
25
26
  }: {
26
- agentId: string
27
27
  settings: CompactionSettings
28
28
  closed: boolean
29
+ saving: boolean
30
+ save: (settings: CompactionSettings) => void
29
31
  }): ReactNode {
30
32
  const modeId = useId()
31
33
  const thresholdId = useId()
@@ -33,46 +35,11 @@ function SettingsForm({
33
35
  const [threshold, setThreshold] = useState(
34
36
  settings.mode === 'manual' ? String(settings.thresholdTokens) : '',
35
37
  )
36
- const [saving, setSaving] = useState(false)
37
38
  const [error, setError] = useState<string | null>(null)
38
- const [notice, setNotice] = useState<string | null>(null)
39
- const pending = useRef<AbortController | null>(null)
40
-
41
- useEffect(() => () => pending.current?.abort(), [])
42
-
43
- const save = async (next: CompactionSettings): Promise<void> => {
44
- pending.current?.abort()
45
- const controller = new AbortController()
46
- pending.current = controller
47
- setSaving(true)
48
- setError(null)
49
- setNotice(null)
50
- try {
51
- const response = await fetch(
52
- `/agent/${encodeURIComponent(agentId)}/compaction`,
53
- {
54
- method: 'POST',
55
- headers: { 'content-type': 'application/json' },
56
- body: JSON.stringify({ id: crypto.randomUUID(), settings: next }),
57
- signal: controller.signal,
58
- },
59
- )
60
- if (!response.ok)
61
- throw new Error('Could not save compaction settings. Try again.')
62
- setNotice('Saved. The live feed will confirm the latest settings.')
63
- } catch (cause) {
64
- if (!controller.signal.aborted)
65
- setError(cause instanceof Error ? cause.message : String(cause))
66
- } finally {
67
- if (pending.current === controller) {
68
- pending.current = null
69
- setSaving(false)
70
- }
71
- }
72
- }
73
39
 
74
40
  const submit = (event: FormEvent): void => {
75
41
  event.preventDefault()
42
+ setError(null)
76
43
  const parsed = compactionSettingsSchema.safeParse(
77
44
  mode === 'manual'
78
45
  ? { mode, thresholdTokens: Number(threshold) }
@@ -82,7 +49,7 @@ function SettingsForm({
82
49
  setError('Enter a positive whole-number threshold.')
83
50
  return
84
51
  }
85
- void save(parsed.data)
52
+ save(parsed.data)
86
53
  }
87
54
 
88
55
  return (
@@ -124,14 +91,14 @@ function SettingsForm({
124
91
  <button
125
92
  type="button"
126
93
  className="secondary"
127
- onClick={() => void save({ mode: 'manual', thresholdTokens: 1 })}
94
+ onClick={() => save({ mode: 'manual', thresholdTokens: 1 })}
128
95
  >
129
96
  Set to 1 token
130
97
  </button>
131
98
  <button
132
99
  type="button"
133
100
  className="secondary"
134
- onClick={() => void save({ mode: 'automatic' })}
101
+ onClick={() => save({ mode: 'automatic' })}
135
102
  >
136
103
  Reset to automatic
137
104
  </button>
@@ -147,11 +114,6 @@ function SettingsForm({
147
114
  Existing summaries remain in use. This stops future compaction.
148
115
  </p>
149
116
  ) : null}
150
- {notice ? (
151
- <p role="status" className="hint">
152
- {notice}
153
- </p>
154
- ) : null}
155
117
  {error ? (
156
118
  <p role="alert" className="error">
157
119
  {error}
@@ -161,17 +123,36 @@ function SettingsForm({
161
123
  )
162
124
  }
163
125
 
164
- export function CompactionPanel({
165
- agentId,
126
+ function CompactionDetails({
166
127
  state,
167
128
  events,
129
+ complete,
130
+ push,
168
131
  }: {
169
- agentId: string
170
- state: AIState
132
+ state: AgentState
171
133
  events: readonly AgentEvent[]
134
+ complete: boolean
135
+ push: AgentSession['push']
172
136
  }): ReactNode {
173
- const { settings, id } = latestCompactionSettings(events)
174
- const metadata = state.modelMetadata[agentModel]
137
+ const settings = state.compactionSettings
138
+ const id = state.compactionSettingsId
139
+ // The save outlives the form, which remounts when the optimistic push lands.
140
+ const [saving, setSaving] = useState(false)
141
+ const [saveError, setSaveError] = useState<string | null>(null)
142
+ const save = async (next: CompactionSettings): Promise<void> => {
143
+ setSaving(true)
144
+ setSaveError(null)
145
+ try {
146
+ await push({ type: 'compaction.settings.updated', payload: next })
147
+ } catch (cause) {
148
+ setSaveError(cause instanceof Error ? cause.message : String(cause))
149
+ } finally {
150
+ setSaving(false)
151
+ }
152
+ }
153
+ const model =
154
+ state.activeGeneration?.model ?? state.modelChoice.model ?? agentModel
155
+ const metadata = state.modelMetadata[model]
175
156
  const limits = metadata?.status === 'resolved' ? metadata.limits : undefined
176
157
  const automaticThreshold =
177
158
  limits === undefined ? undefined : Math.floor(limits.contextWindow * 0.75)
@@ -188,127 +169,189 @@ export function CompactionPanel({
188
169
  .filter((event) => event.type === 'ai.compaction.requested')
189
170
  .map((event) => [event.payload.generationId, event.payload]),
190
171
  )
191
- const compacting = state.compaction?.status === 'running'
172
+ return (
173
+ <div className="compaction-controls">
174
+ <p className="hint">
175
+ Model: <code>{model}</code>. Limits are saved for this session.
176
+ </p>
177
+ <dl className="compaction-metrics">
178
+ <div>
179
+ <dt>Context window</dt>
180
+ <dd>{tokens(limits?.contextWindow)}</dd>
181
+ </div>
182
+ <div>
183
+ <dt>Model output ceiling</dt>
184
+ <dd>{tokens(limits?.maxOutputTokens)}</dd>
185
+ </div>
186
+ <div>
187
+ <dt>Automatic trigger · 75%</dt>
188
+ <dd>{tokens(automaticThreshold)}</dd>
189
+ </div>
190
+ <div>
191
+ <dt>Threshold for next step</dt>
192
+ <dd>
193
+ {settings.mode === 'disabled'
194
+ ? 'Disabled'
195
+ : tokens(effectiveThreshold)}
196
+ </dd>
197
+ </div>
198
+ <div>
199
+ <dt>Last loaded step input · reported</dt>
200
+ <dd>{tokens(lastStep?.payload.usage?.inputTokens)}</dd>
201
+ </div>
202
+ <div>
203
+ <dt>Last loaded step input · raw estimate</dt>
204
+ <dd>{tokens(lastStep?.payload.inputTokenEstimate)}</dd>
205
+ </div>
206
+ </dl>
207
+ <p className="hint">
208
+ The raw estimate excludes provider metadata. Compaction adjusts it using
209
+ the previous step’s reported usage when the model matches and the prompt
210
+ has not shrunk.
211
+ </p>
212
+ {limits === undefined ? (
213
+ <p className="hint">
214
+ {metadata?.status === 'unavailable'
215
+ ? 'Gateway did not report limits for this model. You can set a custom threshold.'
216
+ : metadata?.status === 'pending'
217
+ ? 'Model limits have been requested but are not resolved yet. The agent can continue.'
218
+ : 'Send a message to discover the model limits, or set a custom threshold first.'}
219
+ </p>
220
+ ) : null}
221
+ <SettingsForm
222
+ key={id}
223
+ settings={settings}
224
+ closed={state.status === 'closed'}
225
+ saving={saving}
226
+ save={(next) => void save(next)}
227
+ />
228
+ {saveError ? (
229
+ <p role="alert" className="error">
230
+ {saveError}
231
+ </p>
232
+ ) : null}
233
+ <details className="compaction-history">
234
+ <summary>
235
+ Loaded compactions <span>{completed.length}</span>
236
+ </summary>
237
+ {completed.length === 0 ? (
238
+ <p className="hint">
239
+ {complete
240
+ ? 'No completed compactions yet.'
241
+ : 'No completed compactions in the loaded history.'}
242
+ </p>
243
+ ) : (
244
+ <ol>
245
+ {completed.map((event) => {
246
+ const trigger = requested.get(event.payload.generationId)
247
+ return (
248
+ <li key={event.id}>
249
+ <div className="compaction-marker">
250
+ <strong>Context compacted · #{event.index}</strong>
251
+ <time dateTime={event.createdAt.toISOString()}>
252
+ {formatActivityTime(event.createdAt)}
253
+ </time>
254
+ </div>
255
+ <p className="hint">
256
+ {event.payload.throughIndex === undefined
257
+ ? `Through message ${event.payload.throughMessageId}`
258
+ : `Summarized through event #${event.payload.throughIndex}`}{' '}
259
+ · {tokens(event.payload.usage?.inputTokens)} in ·{' '}
260
+ {tokens(event.payload.usage?.outputTokens)} out
261
+ </p>
262
+ {trigger?.inputTokens === undefined ? null : (
263
+ <p className="hint">
264
+ Trigger: {tokens(trigger.inputTokens)} against a{' '}
265
+ {tokens(trigger.thresholdTokens)} threshold. Raw estimate:{' '}
266
+ {tokens(trigger.inputTokenEstimate)}.
267
+ </p>
268
+ )}
269
+ {event.payload.summary ? (
270
+ <details>
271
+ <summary>View summary</summary>
272
+ <pre>{event.payload.summary}</pre>
273
+ </details>
274
+ ) : null}
275
+ </li>
276
+ )
277
+ })}
278
+ </ol>
279
+ )}
280
+ </details>
281
+ </div>
282
+ )
283
+ }
192
284
 
285
+ export function CompactionPanel({
286
+ session,
287
+ }: {
288
+ session: AgentSession
289
+ }): ReactNode {
290
+ const { state, events, history, loadHistory, push } = session
291
+ const opened = useRef(false)
292
+ const [historyError, setHistoryError] = useState<string | null>(null)
293
+ const loadEarlier = async (): Promise<void> => {
294
+ setHistoryError(null)
295
+ try {
296
+ await loadHistory()
297
+ } catch (error) {
298
+ setHistoryError(
299
+ error instanceof Error ? error.message : 'Could not load history.',
300
+ )
301
+ }
302
+ }
193
303
  return (
194
- <details className="agent-compaction">
304
+ <details
305
+ className="agent-compaction"
306
+ onToggle={(event) => {
307
+ if (
308
+ event.target !== event.currentTarget ||
309
+ !event.currentTarget.open ||
310
+ opened.current
311
+ )
312
+ return
313
+ opened.current = true
314
+ if (!history.complete) void loadEarlier()
315
+ }}
316
+ >
195
317
  <summary>
196
318
  <span className="compaction-chevron" aria-hidden="true">
197
319
  ›
198
320
  </span>
199
321
  <strong>Compaction</strong>
200
- <span className={`badge${compacting ? ' live' : ''}`}>
201
- {compacting
322
+ <span
323
+ className={`badge${state.compaction?.status === 'running' ? ' live' : ''}`}
324
+ >
325
+ {state.compaction?.status === 'running'
202
326
  ? 'Compacting…'
203
- : settings.mode === 'disabled'
327
+ : state.compactionSettings.mode === 'disabled'
204
328
  ? 'Disabled'
205
- : settings.mode === 'manual'
206
- ? `Custom · ${tokens(settings.thresholdTokens)}`
329
+ : state.compactionSettings.mode === 'manual'
330
+ ? `Custom · ${tokens(state.compactionSettings.thresholdTokens)}`
207
331
  : 'Automatic'}
208
332
  </span>
209
333
  </summary>
210
- <div className="compaction-controls">
211
- <p className="hint">
212
- Model: <code>{agentModel}</code>. Limits are saved for this session.
213
- </p>
214
- <dl className="compaction-metrics">
215
- <div>
216
- <dt>Context window</dt>
217
- <dd>{tokens(limits?.contextWindow)}</dd>
218
- </div>
219
- <div>
220
- <dt>Model output ceiling</dt>
221
- <dd>{tokens(limits?.maxOutputTokens)}</dd>
222
- </div>
223
- <div>
224
- <dt>Automatic trigger · 75%</dt>
225
- <dd>{tokens(automaticThreshold)}</dd>
226
- </div>
227
- <div>
228
- <dt>Threshold for next step</dt>
229
- <dd>
230
- {settings.mode === 'disabled'
231
- ? 'Disabled'
232
- : tokens(effectiveThreshold)}
233
- </dd>
234
- </div>
235
- <div>
236
- <dt>Last step input · reported</dt>
237
- <dd>{tokens(lastStep?.payload.usage?.inputTokens)}</dd>
238
- </div>
239
- <div>
240
- <dt>Last step input · raw estimate</dt>
241
- <dd>{tokens(lastStep?.payload.inputTokenEstimate)}</dd>
242
- </div>
243
- </dl>
244
- <p className="hint">
245
- The raw estimate excludes provider metadata. Compaction adjusts it
246
- using the previous step’s reported usage when the model matches and
247
- the prompt has not shrunk.
334
+ <CompactionDetails
335
+ state={state}
336
+ events={events}
337
+ complete={history.complete}
338
+ push={push}
339
+ />
340
+ {historyError ? (
341
+ <p role="alert" className="error">
342
+ {historyError}
248
343
  </p>
249
- {limits === undefined ? (
250
- <p className="hint">
251
- {metadata?.status === 'unavailable'
252
- ? 'Gateway did not report limits for this model. You can set a custom threshold.'
253
- : metadata?.status === 'pending'
254
- ? 'Model limits have been requested but are not resolved yet. The agent can continue.'
255
- : 'Send a message to discover the model limits, or set a custom threshold first.'}
256
- </p>
257
- ) : null}
258
- <SettingsForm
259
- key={id}
260
- agentId={agentId}
261
- settings={settings}
262
- closed={state.status === 'closed'}
263
- />
264
- <details className="compaction-history">
265
- <summary>
266
- Completed compactions <span>{completed.length}</span>
267
- </summary>
268
- {completed.length === 0 ? (
269
- <p className="hint">
270
- No completed compactions yet. Each completion will stay here after
271
- reload.
272
- </p>
273
- ) : (
274
- <ol>
275
- {completed.map((event) => {
276
- const trigger = requested.get(event.payload.generationId)
277
- return (
278
- <li key={event.id}>
279
- <div className="compaction-marker">
280
- <strong>Context compacted · #{event.index}</strong>
281
- <time dateTime={event.createdAt.toISOString()}>
282
- {formatActivityTime(event.createdAt)}
283
- </time>
284
- </div>
285
- <p className="hint">
286
- {event.payload.throughIndex === undefined
287
- ? `Through message ${event.payload.throughMessageId}`
288
- : `Summarized through event #${event.payload.throughIndex}`}{' '}
289
- · {tokens(event.payload.usage?.inputTokens)} in ·{' '}
290
- {tokens(event.payload.usage?.outputTokens)} out
291
- </p>
292
- {trigger?.inputTokens === undefined ? null : (
293
- <p className="hint">
294
- Trigger: {tokens(trigger.inputTokens)} against a{' '}
295
- {tokens(trigger.thresholdTokens)} threshold. Raw
296
- estimate: {tokens(trigger.inputTokenEstimate)}.
297
- </p>
298
- )}
299
- {event.payload.summary ? (
300
- <details>
301
- <summary>View summary</summary>
302
- <pre>{event.payload.summary}</pre>
303
- </details>
304
- ) : null}
305
- </li>
306
- )
307
- })}
308
- </ol>
309
- )}
310
- </details>
311
- </div>
344
+ ) : null}
345
+ {!history.complete ? (
346
+ <button
347
+ type="button"
348
+ className="secondary"
349
+ disabled={history.loading}
350
+ onClick={() => void loadEarlier()}
351
+ >
352
+ {history.loading ? 'Loading history…' : 'Load earlier events'}
353
+ </button>
354
+ ) : null}
312
355
  </details>
313
356
  )
314
357
  }