experimental-a2 0.20.0 → 0.22.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 (309) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +1 -1
  3. package/dist/{actor-C7b-1arn.d.ts → actor-CqibLgjQ.d.ts} +15 -7
  4. package/dist/actor-CqibLgjQ.d.ts.map +1 -0
  5. package/dist/actor-client.d.ts +1 -1
  6. package/dist/actor-client.js +2 -2
  7. package/dist/actor-react.d.ts +3 -3
  8. package/dist/actor-react.js +5 -5
  9. package/dist/{actor-shared-t7w8xIKP.js → actor-shared-DFET5q16.js} +3 -3
  10. package/dist/actor-shared-DFET5q16.js.map +1 -0
  11. package/dist/actor.d.ts +1 -1
  12. package/dist/actor.js +54 -43
  13. package/dist/actor.js.map +1 -1
  14. package/dist/{ai-Cz3hqVhk.d.ts → ai-DAu77CDr.d.ts} +8 -8
  15. package/dist/{ai-Cz3hqVhk.d.ts.map → ai-DAu77CDr.d.ts.map} +1 -1
  16. package/dist/{ai-mMiWul1T.js → ai-EEfKyc-z.js} +9 -9
  17. package/dist/ai-EEfKyc-z.js.map +1 -0
  18. package/dist/ai-client.d.ts +2 -2
  19. package/dist/ai-client.js +2 -2
  20. package/dist/ai-fx.d.ts +4 -2
  21. package/dist/ai-fx.d.ts.map +1 -1
  22. package/dist/ai-fx.js +214 -67
  23. package/dist/ai-fx.js.map +1 -1
  24. package/dist/{ai-harness-CV6rXbB3.d.ts → ai-harness-DTjkAr2T.d.ts} +2 -2
  25. package/dist/{ai-harness-CV6rXbB3.d.ts.map → ai-harness-DTjkAr2T.d.ts.map} +1 -1
  26. package/dist/{ai-harness-Clbg5ei7.js → ai-harness-dQeJaR1t.js} +7 -3
  27. package/dist/ai-harness-dQeJaR1t.js.map +1 -0
  28. package/dist/ai-pi.d.ts +1 -1
  29. package/dist/ai-pi.js +3 -3
  30. package/dist/ai-pi.js.map +1 -1
  31. package/dist/ai-react.d.ts +5 -5
  32. package/dist/ai-react.js +2 -2
  33. package/dist/ai-server.d.ts +14 -17
  34. package/dist/ai-server.d.ts.map +1 -1
  35. package/dist/ai-server.js +86 -73
  36. package/dist/ai-server.js.map +1 -1
  37. package/dist/ai.d.ts +2 -2
  38. package/dist/ai.js +2 -2
  39. package/dist/{client-BhHLoNKF.js → client-BQS4i_V9.js} +29 -6
  40. package/dist/client-BQS4i_V9.js.map +1 -0
  41. package/dist/{client-h_DKFAjj.d.ts → client-CUMbS_38.d.ts} +3 -3
  42. package/dist/{client-h_DKFAjj.d.ts.map → client-CUMbS_38.d.ts.map} +1 -1
  43. package/dist/client.d.ts +3 -3
  44. package/dist/client.js +1 -1
  45. package/dist/{contract-BYKASlCV.js → contract-CWukt01d.js} +2 -2
  46. package/dist/contract-CWukt01d.js.map +1 -0
  47. package/dist/index.d.ts +5 -5
  48. package/dist/index.js +3 -3
  49. package/dist/{initial-snapshot-Bx-c0pDP.d.ts → initial-snapshot-D3Lve43o.d.ts} +4 -4
  50. package/dist/initial-snapshot-D3Lve43o.d.ts.map +1 -0
  51. package/dist/{internal-CscSY7K_.js → internal-CvsrprnU.js} +40 -16
  52. package/dist/internal-CvsrprnU.js.map +1 -0
  53. package/dist/otel.d.ts +1 -1
  54. package/dist/otel.js +1 -1
  55. package/dist/otel.js.map +1 -1
  56. package/dist/{react-BBXgHWFc.d.ts → react-BoHvC3v9.d.ts} +4 -4
  57. package/dist/{react-BBXgHWFc.d.ts.map → react-BoHvC3v9.d.ts.map} +1 -1
  58. package/dist/react.d.ts +1 -1
  59. package/dist/react.js +1 -1
  60. package/dist/{reducer-CqA59Q6U.d.ts → reducer-CYPHsSmd.d.ts} +2 -2
  61. package/dist/{reducer-CqA59Q6U.d.ts.map → reducer-CYPHsSmd.d.ts.map} +1 -1
  62. package/dist/{reducer-BAXc8JyF.js → reducer-CjmvZSAb.js} +2 -2
  63. package/dist/{reducer-BAXc8JyF.js.map → reducer-CjmvZSAb.js.map} +1 -1
  64. package/dist/runtime-consumer-CIeL33wj.js +12 -0
  65. package/dist/runtime-consumer-CIeL33wj.js.map +1 -0
  66. package/dist/scheduler-qstash.d.ts +2 -2
  67. package/dist/scheduler-qstash.js +3 -3
  68. package/dist/scheduler-task-X1SToDjP.js.map +1 -1
  69. package/dist/scheduler-vercel.d.ts +2 -2
  70. package/dist/scheduler-vercel.js +3 -3
  71. package/dist/scheduler-vercel.js.map +1 -1
  72. package/dist/{server-qyb5aJJG.js → server-DtFrWxi3.js} +412 -116
  73. package/dist/server-DtFrWxi3.js.map +1 -0
  74. package/dist/{server-DYAATFuT.d.ts → server-qgez0Xny.d.ts} +108 -41
  75. package/dist/server-qgez0Xny.d.ts.map +1 -0
  76. package/dist/server.browser.js +1 -1
  77. package/dist/server.browser.js.map +1 -1
  78. package/dist/server.d.ts +4 -4
  79. package/dist/server.js +2 -2
  80. package/dist/{session-clear-C-QwTXV1.d.ts → session-clear-D1PFe7xl.d.ts} +2 -2
  81. package/dist/session-clear-D1PFe7xl.d.ts.map +1 -0
  82. package/dist/{session-clear-DGI4jTQ5.js → session-clear-wHd5GCpZ.js} +5 -4
  83. package/dist/session-clear-wHd5GCpZ.js.map +1 -0
  84. package/dist/store-D0MhBvg6.d.ts +532 -0
  85. package/dist/store-D0MhBvg6.d.ts.map +1 -0
  86. package/dist/store-N8PXxDAS.js.map +1 -1
  87. package/dist/store-codec-DwrWlgQx.js +65 -0
  88. package/dist/store-codec-DwrWlgQx.js.map +1 -0
  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 +406 -225
  92. package/dist/store-memory.js.map +1 -1
  93. package/dist/{store-polling-7WJV0YWP.js → store-polling-BZt2Rf3D.js} +2 -2
  94. package/dist/{store-polling-7WJV0YWP.js.map → store-polling-BZt2Rf3D.js.map} +1 -1
  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 +1598 -587
  98. package/dist/store-postgres.js.map +1 -1
  99. package/dist/store-presence-polling-JuAy2Ck2.js +111 -0
  100. package/dist/store-presence-polling-JuAy2Ck2.js.map +1 -0
  101. package/dist/store-redis-http.d.ts +1 -1
  102. package/dist/store-redis-http.js +4 -4
  103. package/dist/store-redis-notify-CsJw_hfD.js +3060 -0
  104. package/dist/store-redis-notify-CsJw_hfD.js.map +1 -0
  105. package/dist/store-redis.d.ts +1 -1
  106. package/dist/store-redis.d.ts.map +1 -1
  107. package/dist/store-redis.js +4 -5
  108. package/dist/store-redis.js.map +1 -1
  109. package/dist/store-sqlite.d.ts +1 -1
  110. package/dist/store-sqlite.d.ts.map +1 -1
  111. package/dist/store-sqlite.js +527 -299
  112. package/dist/store-sqlite.js.map +1 -1
  113. package/dist/stream-buffer-DrTyiIuo.js +57 -0
  114. package/dist/stream-buffer-DrTyiIuo.js.map +1 -0
  115. package/dist/{telemetry-Cyfqo6Qt.d.ts → telemetry-CWwsh-Gx.d.ts} +3 -3
  116. package/dist/telemetry-CWwsh-Gx.d.ts.map +1 -0
  117. package/dist/{wire-BKxH1vbP.js → wire-CV-Z7wz-.js} +6 -5
  118. package/dist/wire-CV-Z7wz-.js.map +1 -0
  119. package/docs/01-quickstart.mdx +21 -15
  120. package/docs/02-examples.mdx +1 -1
  121. package/docs/actors/01-introduction.mdx +13 -2
  122. package/docs/actors/02-concurrency.mdx +1 -1
  123. package/docs/agents/01-introduction.mdx +1 -1
  124. package/docs/agents/02-tools.mdx +12 -11
  125. package/docs/agents/03-conversations.mdx +36 -25
  126. package/docs/agents/04-harnesses.mdx +14 -7
  127. package/docs/client/03-presence.mdx +18 -14
  128. package/docs/contract/01-events.mdx +2 -2
  129. package/docs/contract/02-state.mdx +18 -14
  130. package/docs/contract/03-sessions.mdx +32 -25
  131. package/docs/index.mdx +29 -24
  132. package/docs/reference/01-core.mdx +7 -6
  133. package/docs/reference/02-server.mdx +250 -191
  134. package/docs/reference/04-ai.mdx +33 -23
  135. package/docs/reference/05-telemetry.mdx +27 -13
  136. package/docs/reference/06-errors.mdx +25 -19
  137. package/docs/reference/meta.ts +1 -1
  138. package/docs/schedulers/01-overview.mdx +8 -8
  139. package/docs/schedulers/02-vercel-queues.mdx +2 -2
  140. package/docs/schedulers/03-qstash.mdx +4 -4
  141. package/docs/server/01-consumers.mdx +418 -0
  142. package/docs/server/02-timers.mdx +31 -21
  143. package/docs/server/03-cancellation.mdx +52 -44
  144. package/docs/server/04-routes.mdx +4 -1
  145. package/docs/server/05-sql-projections.mdx +391 -0
  146. package/docs/server/06-guarantees.mdx +61 -51
  147. package/docs/stores/01-overview.mdx +3 -3
  148. package/docs/stores/02-redis.mdx +24 -10
  149. package/docs/stores/03-redis-http.mdx +8 -6
  150. package/docs/stores/04-postgres.mdx +65 -14
  151. package/docs/stores/05-sqlite.mdx +5 -3
  152. package/docs/upgrades/01-overview.mdx +31 -0
  153. package/docs/upgrades/78-0-22.mdx +115 -0
  154. package/docs/upgrades/79-0-21.mdx +165 -0
  155. package/docs/upgrades/80-0-20.mdx +43 -0
  156. package/docs/upgrades/81-0-19.mdx +33 -0
  157. package/docs/upgrades/82-0-18.mdx +33 -0
  158. package/docs/upgrades/83-0-17.mdx +69 -0
  159. package/docs/upgrades/meta.ts +3 -0
  160. package/examples/playground/DEPLOY.md +39 -7
  161. package/examples/playground/app/a0/chat.test.tsx +2 -2
  162. package/examples/playground/app/a0/sandbox-log.ts +17 -15
  163. package/examples/playground/app/a0/sandbox.ts +58 -13
  164. package/examples/playground/app/a0/server.ts +4 -4
  165. package/examples/playground/app/a2slack/a2slack.css +1067 -0
  166. package/examples/playground/app/a2slack/c/[channel]/page.tsx +20 -0
  167. package/examples/playground/app/a2slack/consumers/agent.ts +63 -0
  168. package/examples/playground/app/a2slack/consumers/feed.ts +164 -0
  169. package/examples/playground/app/a2slack/consumers/notify.ts +116 -0
  170. package/examples/playground/app/a2slack/consumers/search.ts +37 -0
  171. package/examples/playground/app/a2slack/consumers/triage.ts +31 -0
  172. package/examples/playground/app/a2slack/consumers/welcome.ts +25 -0
  173. package/examples/playground/app/a2slack/db/a2.ts +209 -0
  174. package/examples/playground/app/a2slack/db/index.ts +73 -0
  175. package/examples/playground/app/a2slack/db/migrations/0000_extensions.sql +4 -0
  176. package/examples/playground/app/a2slack/db/migrations/0001_workspace.sql +74 -0
  177. package/examples/playground/app/a2slack/db/migrations/0002_a2.sql +215 -0
  178. package/examples/playground/app/a2slack/db/migrations/0003_a2_batch.sql +67 -0
  179. package/examples/playground/app/a2slack/db/migrations/0004_a2_session_event_ids.sql +2 -0
  180. package/examples/playground/app/a2slack/db/migrations/meta/0000_snapshot.json +18 -0
  181. package/examples/playground/app/a2slack/db/migrations/meta/0001_snapshot.json +500 -0
  182. package/examples/playground/app/a2slack/db/migrations/meta/0002_snapshot.json +1159 -0
  183. package/examples/playground/app/a2slack/db/migrations/meta/0003_snapshot.json +1159 -0
  184. package/examples/playground/app/a2slack/db/migrations/meta/0004_snapshot.json +1165 -0
  185. package/examples/playground/app/a2slack/db/migrations/meta/_journal.json +41 -0
  186. package/examples/playground/app/a2slack/db/queries.ts +263 -0
  187. package/examples/playground/app/a2slack/db/schema.ts +145 -0
  188. package/examples/playground/app/a2slack/drills/a2slack.drill.ts +708 -0
  189. package/examples/playground/app/a2slack/drills/harness.ts +244 -0
  190. package/examples/playground/app/a2slack/events/route.ts +36 -0
  191. package/examples/playground/app/a2slack/identity.ts +44 -0
  192. package/examples/playground/app/a2slack/inbox/route.ts +8 -0
  193. package/examples/playground/app/a2slack/lag/data/route.ts +8 -0
  194. package/examples/playground/app/a2slack/lag/page.tsx +101 -0
  195. package/examples/playground/app/a2slack/lag.ts +73 -0
  196. package/examples/playground/app/a2slack/layout.tsx +43 -0
  197. package/examples/playground/app/a2slack/messages/route.ts +17 -0
  198. package/examples/playground/app/a2slack/model.test.ts +208 -0
  199. package/examples/playground/app/a2slack/model.ts +369 -0
  200. package/examples/playground/app/a2slack/models.test.ts +72 -0
  201. package/examples/playground/app/a2slack/page.tsx +10 -0
  202. package/examples/playground/app/a2slack/scheduler/route.ts +5 -0
  203. package/examples/playground/app/a2slack/search/route.ts +40 -0
  204. package/examples/playground/app/a2slack/search-index.ts +108 -0
  205. package/examples/playground/app/a2slack/server.test.ts +315 -0
  206. package/examples/playground/app/a2slack/server.ts +55 -0
  207. package/examples/playground/app/a2slack/sessions.ts +50 -0
  208. package/examples/playground/app/a2slack/thread/route.ts +8 -0
  209. package/examples/playground/app/a2slack/ui/ChannelView.tsx +87 -0
  210. package/examples/playground/app/a2slack/ui/Composer.tsx +117 -0
  211. package/examples/playground/app/a2slack/ui/DotsShadow.tsx +42 -0
  212. package/examples/playground/app/a2slack/ui/Feed.tsx +157 -0
  213. package/examples/playground/app/a2slack/ui/InboxContext.tsx +63 -0
  214. package/examples/playground/app/a2slack/ui/InboxView.tsx +82 -0
  215. package/examples/playground/app/a2slack/ui/Menu.tsx +128 -0
  216. package/examples/playground/app/a2slack/ui/MessageRow.tsx +314 -0
  217. package/examples/playground/app/a2slack/ui/NameGate.tsx +35 -0
  218. package/examples/playground/app/a2slack/ui/SearchContext.tsx +146 -0
  219. package/examples/playground/app/a2slack/ui/SearchResults.tsx +97 -0
  220. package/examples/playground/app/a2slack/ui/Shell.tsx +34 -0
  221. package/examples/playground/app/a2slack/ui/Sidebar.tsx +199 -0
  222. package/examples/playground/app/a2slack/ui/ThreadPanel.tsx +134 -0
  223. package/examples/playground/app/a2slack/ui/useInbox.ts +71 -0
  224. package/examples/playground/app/agent/server.ts +3 -3
  225. package/examples/playground/app/chat/[chatId]/chat-client.tsx +1 -1
  226. package/examples/playground/app/chat/server.ts +45 -35
  227. package/examples/playground/app/components/demo-icons.tsx +7 -0
  228. package/examples/playground/app/components/demos.tsx +7 -0
  229. package/examples/playground/app/orders/[orderId]/order-client.tsx +2 -2
  230. package/examples/playground/app/orders/page.tsx +1 -1
  231. package/examples/playground/app/orders/server.ts +49 -30
  232. package/examples/playground/app/recovery/[recoveryId]/recovery-client.tsx +14 -15
  233. package/examples/playground/app/recovery/page.tsx +4 -4
  234. package/examples/playground/app/recovery/server.ts +23 -32
  235. package/examples/playground/drizzle.config.ts +14 -0
  236. package/examples/playground/lib/store.ts +1 -1
  237. package/examples/playground/next.config.ts +6 -1
  238. package/examples/playground/package.json +12 -1
  239. package/examples/playground/scripts/migrate.mjs +31 -0
  240. package/examples/playground/vercel.json +9 -0
  241. package/examples/playground/vitest.drills.config.ts +32 -0
  242. package/package.json +5 -2
  243. package/sql/postgres/drizzle-schema.ts +209 -0
  244. package/sql/postgres/schema.sql +227 -67
  245. package/src/actor-shared.ts +10 -2
  246. package/src/actor.ts +101 -71
  247. package/src/ai-agent.ts +162 -111
  248. package/src/ai-control-server.ts +8 -8
  249. package/src/ai-fx.ts +324 -82
  250. package/src/ai-harness.ts +17 -2
  251. package/src/ai-pi.ts +2 -2
  252. package/src/ai-server.ts +2 -2
  253. package/src/ai.ts +7 -7
  254. package/src/client.ts +19 -2
  255. package/src/contract.ts +1 -1
  256. package/src/deterministic-id.ts +27 -4
  257. package/src/errors.ts +8 -5
  258. package/src/idempotent-replay.ts +0 -1
  259. package/src/index.ts +1 -1
  260. package/src/internal.ts +57 -16
  261. package/src/otel.ts +1 -1
  262. package/src/postgres-schema.ts +98 -57
  263. package/src/presence-recovery.ts +18 -11
  264. package/src/presence-tombstones.ts +52 -0
  265. package/src/push-envelope.ts +20 -0
  266. package/src/runtime-consumer.ts +10 -0
  267. package/src/scheduler-task.ts +2 -2
  268. package/src/scheduler-vercel.ts +1 -1
  269. package/src/server.browser.ts +1 -1
  270. package/src/server.ts +819 -218
  271. package/src/session-socket.ts +1 -1
  272. package/src/store-codec.ts +66 -0
  273. package/src/store-memory.ts +604 -286
  274. package/src/store-postgres.ts +2006 -857
  275. package/src/store-presence-polling.ts +59 -34
  276. package/src/store-redis-core.ts +1961 -1333
  277. package/src/store-redis-notify.ts +76 -26
  278. package/src/store-redis.ts +2 -3
  279. package/src/store-sqlite.ts +915 -516
  280. package/src/store.ts +241 -102
  281. package/src/telemetry.ts +2 -1
  282. package/src/wire.ts +1 -0
  283. package/dist/actor-C7b-1arn.d.ts.map +0 -1
  284. package/dist/actor-shared-t7w8xIKP.js.map +0 -1
  285. package/dist/ai-harness-Clbg5ei7.js.map +0 -1
  286. package/dist/ai-mMiWul1T.js.map +0 -1
  287. package/dist/client-BhHLoNKF.js.map +0 -1
  288. package/dist/contract-BYKASlCV.js.map +0 -1
  289. package/dist/idempotent-replay-JdgMQRRm.js +0 -21
  290. package/dist/idempotent-replay-JdgMQRRm.js.map +0 -1
  291. package/dist/initial-snapshot-Bx-c0pDP.d.ts.map +0 -1
  292. package/dist/internal-CscSY7K_.js.map +0 -1
  293. package/dist/server-DYAATFuT.d.ts.map +0 -1
  294. package/dist/server-qyb5aJJG.js.map +0 -1
  295. package/dist/session-clear-C-QwTXV1.d.ts.map +0 -1
  296. package/dist/session-clear-DGI4jTQ5.js.map +0 -1
  297. package/dist/store-CyLUy86v.d.ts +0 -404
  298. package/dist/store-CyLUy86v.d.ts.map +0 -1
  299. package/dist/store-codec-DTG0Ftek.js +0 -10
  300. package/dist/store-codec-DTG0Ftek.js.map +0 -1
  301. package/dist/store-presence-polling-B1ixeqIS.js +0 -94
  302. package/dist/store-presence-polling-B1ixeqIS.js.map +0 -1
  303. package/dist/store-redis-notify-CM0AfLY0.js +0 -2544
  304. package/dist/store-redis-notify-CM0AfLY0.js.map +0 -1
  305. package/dist/stream-buffer-Cx7AAPGq.js +0 -23
  306. package/dist/stream-buffer-Cx7AAPGq.js.map +0 -1
  307. package/dist/telemetry-Cyfqo6Qt.d.ts.map +0 -1
  308. package/dist/wire-BKxH1vbP.js.map +0 -1
  309. package/docs/server/01-handlers.mdx +0 -227
@@ -0,0 +1,208 @@
1
+ import type { ContractEvent } from 'experimental-a2'
2
+ import { describe, expect, it } from 'vitest'
3
+ import {
4
+ channelsReducer,
5
+ liveInbox,
6
+ liveMessages,
7
+ liveThread,
8
+ toMessage,
9
+ workspace,
10
+ type Author,
11
+ type InboxPage,
12
+ type MessagePage,
13
+ type WorkspaceEvent,
14
+ } from './model'
15
+
16
+ type Events = typeof workspace.events
17
+
18
+ const ada: Author = { id: 'user-1', name: 'ada' }
19
+ const bob: Author = { id: 'user-2', name: 'bob' }
20
+
21
+ const event = <K extends keyof Events & string>(
22
+ index: number,
23
+ type: K,
24
+ payload: ContractEvent<Events, K>['payload'],
25
+ ): ContractEvent<Events, K> =>
26
+ ({
27
+ id: `event-${index}`,
28
+ type,
29
+ payload,
30
+ index,
31
+ sessionId: 'workspace',
32
+ createdAt: new Date(index * 1_000),
33
+ }) as ContractEvent<Events, K>
34
+
35
+ const posted = (
36
+ index: number,
37
+ text: string,
38
+ extra: { channel?: string; threadId?: string; author?: Author } = {},
39
+ ) =>
40
+ event(index, 'message.posted', {
41
+ channel: extra.channel ?? 'general',
42
+ author: extra.author ?? ada,
43
+ text,
44
+ ...(extra.threadId === undefined ? {} : { threadId: extra.threadId }),
45
+ })
46
+
47
+ /** The page a read at `through` would return: the log up to it, folded. */
48
+ const pageOf = (events: WorkspaceEvent[], through: number): MessagePage => ({
49
+ messages: liveMessages(
50
+ { messages: [], through: 0, more: false },
51
+ events.filter((e) => e.index <= through),
52
+ { channel: 'general', append: true },
53
+ ),
54
+ through,
55
+ more: false,
56
+ })
57
+
58
+ describe('channelsReducer', () => {
59
+ it('lists channels once, in creation order', () => {
60
+ const state = [
61
+ event(1, 'channel.created', { id: 'general', by: ada }),
62
+ event(2, 'channel.created', { id: 'random', by: bob }),
63
+ event(3, 'channel.created', { id: 'general', by: bob }),
64
+ ].reduce(
65
+ (folded, e) => channelsReducer.fold(folded, e),
66
+ channelsReducer.initialState,
67
+ )
68
+ expect(state.channels).toEqual(['general', 'random'])
69
+ })
70
+ })
71
+
72
+ describe('liveMessages', () => {
73
+ const log = [
74
+ posted(1, 'hello'),
75
+ posted(2, 'elsewhere', { channel: 'random' }),
76
+ posted(3, 'a reply', { threadId: 'event-1', author: bob }),
77
+ event(4, 'reaction.added', {
78
+ channel: 'general',
79
+ messageId: 'event-1',
80
+ emoji: '👍',
81
+ by: bob,
82
+ }),
83
+ posted(5, 'second'),
84
+ event(6, 'message.edited', {
85
+ channel: 'general',
86
+ messageId: 'event-5',
87
+ text: 'second, edited',
88
+ by: ada,
89
+ }),
90
+ event(7, 'message.labeled', {
91
+ channel: 'general',
92
+ messageId: 'event-1',
93
+ label: 'question',
94
+ }),
95
+ ]
96
+
97
+ it('applies only the events after the page, to its own channel', () => {
98
+ for (const through of [0, 1, 3, 5, 7]) {
99
+ const live = liveMessages(pageOf(log, through), log, {
100
+ channel: 'general',
101
+ append: true,
102
+ })
103
+ // Wherever the page was read, the result is the same.
104
+ expect(live).toEqual(pageOf(log, 7).messages)
105
+ }
106
+ expect(pageOf(log, 7).messages).toMatchObject([
107
+ {
108
+ text: 'hello',
109
+ replyCount: 1,
110
+ label: 'question',
111
+ reactions: [{ emoji: '👍', by: [bob] }],
112
+ },
113
+ { text: 'second, edited', editedAt: new Date(6_000).toISOString() },
114
+ ])
115
+ })
116
+
117
+ it('leaves new messages off an older page, but keeps its messages live', () => {
118
+ const older: MessagePage = {
119
+ messages: [toMessage(posted(1, 'hello'))],
120
+ through: 1,
121
+ more: true,
122
+ }
123
+ expect(
124
+ liveMessages(older, log, { channel: 'general', append: false }),
125
+ ).toMatchObject([{ text: 'hello', replyCount: 1, label: 'question' }])
126
+ })
127
+ })
128
+
129
+ describe('liveThread', () => {
130
+ it('adds replies after the page once, and decorates them', () => {
131
+ const page = {
132
+ root: toMessage(posted(1, 'root')),
133
+ replies: [toMessage(posted(2, 'first', { threadId: 'event-1' }))],
134
+ through: 2,
135
+ }
136
+ const live = liveThread(
137
+ page,
138
+ [
139
+ posted(2, 'first', { threadId: 'event-1' }),
140
+ posted(3, 'second', { threadId: 'event-1', author: bob }),
141
+ posted(4, 'not this thread', { threadId: 'event-9' }),
142
+ event(5, 'message.deleted', {
143
+ channel: 'general',
144
+ messageId: 'event-2',
145
+ by: ada,
146
+ }),
147
+ ],
148
+ 'event-1',
149
+ )
150
+ expect(live.root).toMatchObject({ replyCount: 1 })
151
+ expect(live.replies.map((m) => [m.text, m.deleted ?? false])).toEqual([
152
+ ['', true],
153
+ ['second', false],
154
+ ])
155
+ })
156
+ })
157
+
158
+ describe('liveThread, opened before the feed has its root', () => {
159
+ it('adopts the root and its replies as they arrive', () => {
160
+ const page = { root: null, replies: [], through: 0 }
161
+ const live = liveThread(
162
+ page,
163
+ [
164
+ posted(1, 'root'),
165
+ posted(2, 'first', { threadId: 'event-1' }),
166
+ posted(3, 'elsewhere', { threadId: 'event-9' }),
167
+ ],
168
+ 'event-1',
169
+ )
170
+ expect(live.root).toMatchObject({ text: 'root', replyCount: 1 })
171
+ expect(live.replies.map((m) => m.text)).toEqual(['first'])
172
+ })
173
+ })
174
+
175
+ describe('liveInbox', () => {
176
+ const notification = (index: number, to: string, threadId: string) =>
177
+ event(index, 'notification.sent', {
178
+ channel: 'general',
179
+ messageId: `m-${index}`,
180
+ to,
181
+ reason: 'reply',
182
+ threadId,
183
+ messageIndex: index - 1,
184
+ from: bob,
185
+ text: `news ${index}`,
186
+ })
187
+
188
+ it('raises a thread for each notification to me, and clears it on read', () => {
189
+ const page: InboxPage = { items: [], through: 0 }
190
+ const events = [
191
+ notification(1, ada.id, 't1'),
192
+ notification(2, bob.id, 't1'),
193
+ notification(3, ada.id, 't2'),
194
+ notification(4, ada.id, 't1'),
195
+ event(5, 'inbox.read', { by: ada.id, threadId: 't2', through: 3 }),
196
+ ]
197
+ expect(
198
+ liveInbox(page, events, ada.id).map(({ threadId, text, unread }) => [
199
+ threadId,
200
+ text,
201
+ unread,
202
+ ]),
203
+ ).toEqual([
204
+ ['t1', 'news 4', 2],
205
+ ['t2', 'news 3', 0],
206
+ ])
207
+ })
208
+ })
@@ -0,0 +1,369 @@
1
+ /**
2
+ * a2slack's contract, and how the client keeps a page live.
3
+ *
4
+ * The whole workspace is one A2 session, as vboard's board is: every
5
+ * channel's messages, reactions, labels, and notifications are events in one
6
+ * log. Nothing folds that log into one big state. Consumers build tables from
7
+ * it (server/), every read comes from those tables, and a page read at log
8
+ * index `through` stays live by applying the events after `through`, with
9
+ * the functions below.
10
+ */
11
+ import { z } from 'zod'
12
+ import { contract, reducer, type ContractEvent } from 'experimental-a2'
13
+
14
+ /** The feed writes these to Postgres, whose text can't hold NUL: refused at the door. */
15
+ const text = () =>
16
+ z.string().refine((value) => !value.includes('\u0000'), 'contains NUL')
17
+
18
+ export const authorSchema = z.object({
19
+ id: text().min(1).max(64),
20
+ name: text().min(1).max(32),
21
+ })
22
+
23
+ export type Author = z.output<typeof authorSchema>
24
+
25
+ export const channelIdSchema = z.string().regex(/^[a-z0-9][a-z0-9-]{0,31}$/)
26
+
27
+ /** The one session the workspace is. */
28
+ export const WORKSPACE = 'workspace'
29
+
30
+ /** Posts each channel's opening message. */
31
+ export const SYSTEM: Author = { id: 'system', name: 'system' }
32
+
33
+ /** The bot: `@bot` notifies it, and so does a reply in a thread it's in. */
34
+ export const BOT: Author = { id: 'a2slack-bot', name: 'bot' }
35
+
36
+ const messageText = text().min(1).max(2000)
37
+
38
+ /** What triage calls a channel's top-level message. */
39
+ export const LABELS = ['question', 'idea', 'update', 'chatter'] as const
40
+
41
+ export const labelSchema = z.enum(LABELS)
42
+
43
+ export type Label = z.output<typeof labelSchema>
44
+
45
+ /** Why someone was notified: named in the message, or in its thread. */
46
+ export const REASONS = ['mention', 'reply'] as const
47
+
48
+ export type Reason = (typeof REASONS)[number]
49
+
50
+ /** A message's id, which is its post's event id: a UUID. Postgres indexes it, so it's capped. */
51
+ const messageId = () => text().max(64)
52
+
53
+ const ref = { channel: channelIdSchema, messageId: messageId() }
54
+
55
+ const a2slack = contract({
56
+ name: 'a2slack',
57
+ events: {
58
+ 'channel.created': z.object({ id: channelIdSchema, by: authorSchema }),
59
+ 'message.posted': z.object({
60
+ channel: channelIdSchema,
61
+ author: authorSchema,
62
+ text: messageText,
63
+ threadId: messageId().optional(),
64
+ }),
65
+ 'message.edited': z.object({ ...ref, text: messageText, by: authorSchema }),
66
+ 'message.deleted': z.object({ ...ref, by: authorSchema }),
67
+ 'reaction.added': z.object({
68
+ ...ref,
69
+ emoji: text().min(1).max(8),
70
+ by: authorSchema,
71
+ }),
72
+ 'reaction.removed': z.object({
73
+ ...ref,
74
+ emoji: text().min(1).max(8),
75
+ by: authorSchema,
76
+ }),
77
+ 'message.labeled': z.object({ ...ref, label: labelSchema }),
78
+ /** A message someone should hear about, addressed to them. */
79
+ 'notification.sent': z.object({
80
+ ...ref,
81
+ to: authorSchema.shape.id,
82
+ reason: z.enum(REASONS),
83
+ threadId: messageId(),
84
+ messageIndex: z.number().int().positive(),
85
+ from: authorSchema,
86
+ text: text(),
87
+ }),
88
+ /** `by` has seen a thread's notifications through log index `through`. */
89
+ 'inbox.read': z.object({
90
+ by: authorSchema.shape.id,
91
+ threadId: messageId(),
92
+ through: z.number().int().nonnegative(),
93
+ }),
94
+ },
95
+ presence: {
96
+ typing: z.object({ name: z.string().min(1).max(32), channel: z.string() }),
97
+ },
98
+ })
99
+
100
+ export type WorkspaceEvent = ContractEvent<typeof a2slack.events>
101
+
102
+ const reactionSchema = z.object({
103
+ emoji: z.string(),
104
+ by: z.array(authorSchema),
105
+ })
106
+
107
+ export type Reaction = z.output<typeof reactionSchema>
108
+
109
+ export type ChatMessage = {
110
+ id: string
111
+ index: number
112
+ channel: string
113
+ author: Author
114
+ text: string
115
+ at: string
116
+ editedAt?: string
117
+ deleted?: boolean
118
+ reactions: Reaction[]
119
+ threadId?: string
120
+ replyCount: number
121
+ lastReplyAt?: string
122
+ label?: Label
123
+ }
124
+
125
+ /** A channel's top-level messages, oldest first, read at log index `through`. */
126
+ export type MessagePage = {
127
+ messages: ChatMessage[]
128
+ through: number
129
+ /** Older messages exist before this page. */
130
+ more: boolean
131
+ }
132
+
133
+ /** A thread's root and replies, oldest first, read at log index `through`. */
134
+ export type ThreadPage = {
135
+ root: ChatMessage | null
136
+ replies: ChatMessage[]
137
+ through: number
138
+ }
139
+
140
+ /** One thread with news for someone: its latest notification, and how many are unread. */
141
+ export type InboxItem = {
142
+ threadId: string
143
+ channel: string
144
+ reason: Reason
145
+ from: Author
146
+ text: string
147
+ messageId: string
148
+ at: string
149
+ /** The latest notification's log index. */
150
+ index: number
151
+ unread: number
152
+ }
153
+
154
+ export type InboxPage = { items: InboxItem[]; through: number }
155
+
156
+ /** One hit from the search route. */
157
+ export type SearchResult = {
158
+ channel: string
159
+ id: string
160
+ threadId?: string
161
+ author: Author
162
+ text: string
163
+ at: string
164
+ }
165
+
166
+ const channelsState = z.object({
167
+ channels: z.array(z.string()),
168
+ /** The log index each channel was first created at. */
169
+ opened: z.record(z.string(), z.number()),
170
+ })
171
+
172
+ /**
173
+ * The channel list: the one state anything folds whole, because it stays
174
+ * small. The client folds it live; welcome reads `opened` to tell a new
175
+ * channel from one created twice. Re-applying a `channel.created` changes
176
+ * nothing, so a page may hand it events it already reflects.
177
+ */
178
+ export const channelsReducer = reducer({
179
+ contract: a2slack,
180
+ name: 'channels',
181
+ initialState: { channels: [], opened: {} },
182
+ stateSchema: channelsState,
183
+ }).fold((state, event) => {
184
+ if (event.type !== 'channel.created') return state
185
+ if (state.channels.includes(event.payload.id)) return state
186
+ return {
187
+ channels: [...state.channels, event.payload.id],
188
+ opened: { ...state.opened, [event.payload.id]: event.index },
189
+ }
190
+ })
191
+
192
+ export const workspace = { ...a2slack, reducer: channelsReducer }
193
+
194
+ export function toMessage(
195
+ event: ContractEvent<typeof a2slack.events, 'message.posted'>,
196
+ ): ChatMessage {
197
+ return {
198
+ id: event.id,
199
+ index: event.index,
200
+ channel: event.payload.channel,
201
+ author: event.payload.author,
202
+ text: event.payload.text,
203
+ at: event.createdAt.toISOString(),
204
+ reactions: [],
205
+ replyCount: 0,
206
+ ...(event.payload.threadId !== undefined
207
+ ? { threadId: event.payload.threadId }
208
+ : {}),
209
+ }
210
+ }
211
+
212
+ /** Applies an edit, deletion, label, or reaction; the same message otherwise. */
213
+ export function decorate(
214
+ message: ChatMessage,
215
+ event: WorkspaceEvent,
216
+ ): ChatMessage {
217
+ switch (event.type) {
218
+ case 'message.edited':
219
+ if (event.payload.messageId !== message.id || message.deleted)
220
+ return message
221
+ return {
222
+ ...message,
223
+ text: event.payload.text,
224
+ editedAt: event.createdAt.toISOString(),
225
+ }
226
+ case 'message.deleted':
227
+ if (event.payload.messageId !== message.id) return message
228
+ return { ...message, deleted: true, text: '', reactions: [] }
229
+ case 'message.labeled':
230
+ if (event.payload.messageId !== message.id || message.deleted)
231
+ return message
232
+ return { ...message, label: event.payload.label }
233
+ case 'reaction.added': {
234
+ if (event.payload.messageId !== message.id || message.deleted)
235
+ return message
236
+ const { emoji, by } = event.payload
237
+ const existing = message.reactions.find((r) => r.emoji === emoji)
238
+ if (existing?.by.some((a) => a.id === by.id)) return message
239
+ const reactions = existing
240
+ ? message.reactions.map((r) =>
241
+ r.emoji === emoji ? { ...r, by: [...r.by, by] } : r,
242
+ )
243
+ : [...message.reactions, { emoji, by: [by] }]
244
+ return { ...message, reactions }
245
+ }
246
+ case 'reaction.removed': {
247
+ if (event.payload.messageId !== message.id) return message
248
+ const { emoji, by } = event.payload
249
+ const reactions = message.reactions
250
+ .map((r) =>
251
+ r.emoji === emoji
252
+ ? { ...r, by: r.by.filter((a) => a.id !== by.id) }
253
+ : r,
254
+ )
255
+ .filter((r) => r.by.length > 0)
256
+ return { ...message, reactions }
257
+ }
258
+ default:
259
+ return message
260
+ }
261
+ }
262
+
263
+ /** The events after `through`, in log order. */
264
+ const after = (
265
+ events: readonly WorkspaceEvent[],
266
+ through: number,
267
+ ): WorkspaceEvent[] =>
268
+ events
269
+ .filter((event) => event.index > through)
270
+ .toSorted((a, b) => a.index - b.index)
271
+
272
+ /** A reply's effect on its root: one more reply, and when the last one was. */
273
+ const bump = (root: ChatMessage, reply: ChatMessage): ChatMessage => ({
274
+ ...root,
275
+ replyCount: root.replyCount + 1,
276
+ lastReplyAt: reply.at,
277
+ })
278
+
279
+ /**
280
+ * A page of `channel`'s top-level messages, live: the events after the page
281
+ * edit, react to, label, and reply to its messages. With `append`, the
282
+ * newest page, top-level messages posted after it join the end.
283
+ */
284
+ export function liveMessages(
285
+ page: MessagePage,
286
+ events: readonly WorkspaceEvent[],
287
+ { channel, append }: { channel: string; append: boolean },
288
+ ): ChatMessage[] {
289
+ let messages = page.messages
290
+ for (const event of after(events, page.through)) {
291
+ if (!('channel' in event.payload) || event.payload.channel !== channel)
292
+ continue
293
+ if (event.type === 'message.posted') {
294
+ const posted = toMessage(event)
295
+ const rootId = posted.threadId
296
+ if (rootId !== undefined) {
297
+ messages = messages.map((m) => (m.id === rootId ? bump(m, posted) : m))
298
+ } else if (append && !messages.some((m) => m.id === posted.id)) {
299
+ messages = [...messages, posted]
300
+ }
301
+ continue
302
+ }
303
+ messages = messages.map((m) => decorate(m, event))
304
+ }
305
+ return messages
306
+ }
307
+
308
+ /** A thread, live: replies posted after the page join it, and edits apply. */
309
+ export function liveThread(
310
+ page: ThreadPage,
311
+ events: readonly WorkspaceEvent[],
312
+ rootId: string,
313
+ ): { root: ChatMessage | null; replies: ChatMessage[] } {
314
+ let { root, replies } = page
315
+ for (const event of after(events, page.through)) {
316
+ if (event.type === 'message.posted') {
317
+ // A root the feed hadn't applied when the page was read.
318
+ if (event.id === rootId && root === null) {
319
+ root = toMessage(event)
320
+ for (const reply of replies) root = bump(root, reply)
321
+ continue
322
+ }
323
+ if (event.payload.threadId !== rootId) continue
324
+ const reply = toMessage(event)
325
+ if (replies.some((m) => m.id === reply.id)) continue
326
+ replies = [...replies, reply]
327
+ if (root) root = bump(root, reply)
328
+ continue
329
+ }
330
+ if (root) root = decorate(root, event)
331
+ replies = replies.map((m) => decorate(m, event))
332
+ }
333
+ return { root, replies }
334
+ }
335
+
336
+ /** `me`'s inbox, live: new notifications raise a thread, and reads clear it. */
337
+ export function liveInbox(
338
+ page: InboxPage,
339
+ events: readonly WorkspaceEvent[],
340
+ me: string,
341
+ ): InboxItem[] {
342
+ let items = page.items
343
+ for (const event of after(events, page.through)) {
344
+ if (event.type === 'notification.sent' && event.payload.to === me) {
345
+ const { threadId } = event.payload
346
+ const previous = items.find((item) => item.threadId === threadId)
347
+ const item: InboxItem = {
348
+ threadId,
349
+ channel: event.payload.channel,
350
+ reason: event.payload.reason,
351
+ from: event.payload.from,
352
+ text: event.payload.text,
353
+ messageId: event.payload.messageId,
354
+ at: event.createdAt.toISOString(),
355
+ index: event.index,
356
+ unread: (previous?.unread ?? 0) + 1,
357
+ }
358
+ items = [item, ...items.filter((other) => other.threadId !== threadId)]
359
+ } else if (event.type === 'inbox.read' && event.payload.by === me) {
360
+ const { threadId, through } = event.payload
361
+ items = items.map((item) =>
362
+ item.threadId === threadId && item.index <= through
363
+ ? { ...item, unread: 0 }
364
+ : item,
365
+ )
366
+ }
367
+ }
368
+ return items
369
+ }
@@ -0,0 +1,72 @@
1
+ import { MockLanguageModelV4 } from 'ai/test'
2
+ import { beforeEach, expect, it, vi } from 'vitest'
3
+
4
+ // Triage and the bot run only where AI Gateway is reachable; these models
5
+ // stand in for the two they call.
6
+ vi.hoisted(() => {
7
+ process.env.AI_GATEWAY_API_KEY = 'test'
8
+ })
9
+
10
+ const { threadPage } = await import('./db/queries')
11
+ const { BOT, WORKSPACE } = await import('./model')
12
+ const { a2slackServer } = await import('./server')
13
+
14
+ const usage = {
15
+ inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 },
16
+ outputTokens: { total: 1, text: 1, reasoning: 0 },
17
+ }
18
+
19
+ const prompts: Record<string, string[]> = {}
20
+
21
+ /** A model that answers every call with `text`, recording its prompts. */
22
+ const answering = (id: string, text: string) =>
23
+ new MockLanguageModelV4({
24
+ modelId: id,
25
+ doGenerate: async (options) => {
26
+ ;(prompts[id] ??= []).push(JSON.stringify(options.prompt))
27
+ return {
28
+ content: [{ type: 'text', text }],
29
+ finishReason: { unified: 'stop', raw: 'stop' },
30
+ usage,
31
+ warnings: [],
32
+ }
33
+ },
34
+ })
35
+
36
+ beforeEach(() => {
37
+ const models = {
38
+ 'openai/gpt-6-luna-decisions': answering(
39
+ 'openai/gpt-6-luna-decisions',
40
+ '{"result":"question"}',
41
+ ),
42
+ 'openai/gpt-5.4': answering('openai/gpt-5.4', 'ask ada, she owns it.'),
43
+ }
44
+ vi.stubGlobal('AI_SDK_DEFAULT_PROVIDER', {
45
+ specificationVersion: 'v4',
46
+ languageModel: (id: keyof typeof models) => models[id],
47
+ })
48
+ })
49
+
50
+ const room = a2slackServer.session(WORKSPACE)
51
+ const ada = { id: 'user-1', name: 'ada' }
52
+
53
+ it('wakes the bot when someone mentions it, and it answers in the thread', async () => {
54
+ const [asked] = await room.append({
55
+ type: 'message.posted',
56
+ payload: { channel: 'bot', author: ada, text: '@bot who owns the deploy?' },
57
+ })
58
+
59
+ await vi.waitFor(
60
+ async () => {
61
+ const { replies } = await threadPage(asked!.id)
62
+ expect(replies.map(({ author, text }) => [author.name, text])).toEqual([
63
+ [BOT.name, 'ask ada, she owns it.'],
64
+ ])
65
+ },
66
+ { timeout: 10_000 },
67
+ )
68
+ expect(prompts['openai/gpt-5.4']).toHaveLength(1)
69
+ expect(prompts['openai/gpt-5.4']![0]).toContain(
70
+ 'ada: @bot who owns the deploy?',
71
+ )
72
+ })
@@ -0,0 +1,10 @@
1
+ import type { ReactNode } from 'react'
2
+
3
+ export default function HomePage(): ReactNode {
4
+ return (
5
+ <div className="a2slack-empty">
6
+ <p className="a2slack-empty-title">a2slack</p>
7
+ <p className="a2slack-empty-hint">pick a channel — or start one</p>
8
+ </div>
9
+ )
10
+ }
@@ -0,0 +1,5 @@
1
+ import { a2slackScheduler, a2slackServer } from '../server'
2
+
3
+ export const POST =
4
+ a2slackScheduler?.handler(a2slackServer) ??
5
+ (() => new Response('a2slack scheduler is not configured', { status: 503 }))
@@ -0,0 +1,40 @@
1
+ import { A2Error } from 'experimental-a2'
2
+ import { feed } from '../consumers/feed'
3
+ import { searchIndex } from '../consumers/search'
4
+ import { WORKSPACE } from '../model'
5
+ import { hasSearchIndex, indexed, search } from '../search-index'
6
+ import { a2slackServer } from '../server'
7
+
8
+ const ms = (from: number, to: number): string => (to - from).toFixed(0)
9
+
10
+ /** Searches every channel's messages. */
11
+ export async function GET(request: Request): Promise<Response> {
12
+ const q = new URL(request.url).searchParams.get('q')?.trim() ?? ''
13
+ if (q.length === 0) return Response.json({ results: [] })
14
+ // The index fills in the background after each message. Catch it up first,
15
+ // and let Upstash finish indexing, so a message posted a moment ago is found.
16
+ const started = performance.now()
17
+ await a2slackServer
18
+ .session(WORKSPACE)
19
+ .consume(hasSearchIndex ? searchIndex : feed, { signal: request.signal })
20
+ .catch((error: unknown) => {
21
+ // A message the index gave up on leaves the rest searchable.
22
+ if (!(
23
+ error instanceof A2Error && error.code === 'CONSUMER_DEAD_LETTERED'
24
+ ))
25
+ throw error
26
+ })
27
+ const consumed = performance.now()
28
+ await indexed()
29
+ const waited = performance.now()
30
+ const results = await search(q.slice(0, 200))
31
+ const done = performance.now()
32
+ return Response.json(
33
+ { results },
34
+ {
35
+ headers: {
36
+ 'server-timing': `consume;dur=${ms(started, consumed)}, indexing;dur=${ms(consumed, waited)}, query;dur=${ms(waited, done)}`,
37
+ },
38
+ },
39
+ )
40
+ }