experimental-a2 0.4.0 → 0.5.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 (182) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/dist/{ai-B4YhEnfw.d.ts → ai-CFNeCrRl.d.ts} +3 -2
  3. package/dist/ai-CFNeCrRl.d.ts.map +1 -0
  4. package/dist/ai-server.browser.js +2 -0
  5. package/dist/ai-server.browser.js.map +1 -0
  6. package/dist/ai-server.d.ts +4 -3
  7. package/dist/ai-server.d.ts.map +1 -0
  8. package/dist/ai-server.js +4 -2
  9. package/dist/ai-server.js.map +1 -0
  10. package/dist/ai.d.ts +1 -1
  11. package/dist/ai.js +3 -1
  12. package/dist/ai.js.map +1 -0
  13. package/dist/cli-B3VuxoDe.js +2 -0
  14. package/dist/cli-B3VuxoDe.js.map +1 -0
  15. package/dist/cli-bin.js +2 -0
  16. package/dist/cli-bin.js.map +1 -0
  17. package/dist/cli.d.ts +2 -1
  18. package/dist/cli.d.ts.map +1 -0
  19. package/dist/{client-Bt4tAKi9.js → client-BKlyLiOU.js} +295 -85
  20. package/dist/client-BKlyLiOU.js.map +1 -0
  21. package/dist/{client-BrfDXQ8A.d.ts → client-D7mvIXrF.d.ts} +40 -4
  22. package/dist/client-D7mvIXrF.d.ts.map +1 -0
  23. package/dist/client.d.ts +2 -2
  24. package/dist/client.js +1 -1
  25. package/dist/contract-48bUMgcL.js +2 -0
  26. package/dist/contract-48bUMgcL.js.map +1 -0
  27. package/dist/contract-jIfaR085.d.ts +2 -1
  28. package/dist/contract-jIfaR085.d.ts.map +1 -0
  29. package/dist/devtools-J_jZ2vQf.d.ts +2 -1
  30. package/dist/devtools-J_jZ2vQf.d.ts.map +1 -0
  31. package/dist/devtools-kJJaORn-.js +2 -0
  32. package/dist/devtools-kJJaORn-.js.map +1 -0
  33. package/dist/devtools-server.browser.js +2 -0
  34. package/dist/devtools-server.browser.js.map +1 -0
  35. package/dist/devtools-server.d.ts +2 -1
  36. package/dist/devtools-server.d.ts.map +1 -0
  37. package/dist/devtools-server.js +2 -0
  38. package/dist/devtools-server.js.map +1 -0
  39. package/dist/errors-BQuJpe82.js +2 -0
  40. package/dist/errors-BQuJpe82.js.map +1 -0
  41. package/dist/errors-W6nwJ-fm.d.ts +2 -1
  42. package/dist/errors-W6nwJ-fm.d.ts.map +1 -0
  43. package/dist/http.d.ts +121 -72
  44. package/dist/http.d.ts.map +1 -0
  45. package/dist/http.js +503 -178
  46. package/dist/http.js.map +1 -0
  47. package/dist/idempotent-replay-DuqEkYA7.js +2 -0
  48. package/dist/idempotent-replay-DuqEkYA7.js.map +1 -0
  49. package/dist/index.d.ts +1 -1
  50. package/dist/inspection-DaxB5jM2.js +2 -0
  51. package/dist/inspection-DaxB5jM2.js.map +1 -0
  52. package/dist/{internal-aEotMzu_.js → internal-DstsI6Re.js} +3 -1
  53. package/dist/internal-DstsI6Re.js.map +1 -0
  54. package/dist/otel.d.ts +2 -1
  55. package/dist/otel.d.ts.map +1 -0
  56. package/dist/otel.js +2 -0
  57. package/dist/otel.js.map +1 -0
  58. package/dist/platform-B4TnJtWu.js +2 -0
  59. package/dist/platform-B4TnJtWu.js.map +1 -0
  60. package/dist/react.d.ts +12 -3
  61. package/dist/react.d.ts.map +1 -0
  62. package/dist/react.js +5 -1
  63. package/dist/react.js.map +1 -0
  64. package/dist/retryable-lazy-DZWmHpii.js +2 -0
  65. package/dist/retryable-lazy-DZWmHpii.js.map +1 -0
  66. package/dist/scheduler-qstash.d.ts +4 -3
  67. package/dist/scheduler-qstash.d.ts.map +1 -0
  68. package/dist/scheduler-qstash.js +4 -2
  69. package/dist/scheduler-qstash.js.map +1 -0
  70. package/dist/scheduler-task-BpzhPnRS.js +2 -0
  71. package/dist/scheduler-task-BpzhPnRS.js.map +1 -0
  72. package/dist/scheduler-vercel.d.ts +4 -3
  73. package/dist/scheduler-vercel.d.ts.map +1 -0
  74. package/dist/scheduler-vercel.js +4 -2
  75. package/dist/scheduler-vercel.js.map +1 -0
  76. package/dist/{server-CcNnFnoW.js → server-C72KOw51.js} +100 -48
  77. package/dist/server-C72KOw51.js.map +1 -0
  78. package/dist/{server-YtPq7hjw.d.ts → server-DUF9pjsx.d.ts} +5 -4
  79. package/dist/server-DUF9pjsx.d.ts.map +1 -0
  80. package/dist/server.browser.js +2 -0
  81. package/dist/server.browser.js.map +1 -0
  82. package/dist/server.d.ts +2 -2
  83. package/dist/server.js +1 -1
  84. package/dist/{store-C3sNAaBT.d.ts → store-DysUkTH3.d.ts} +10 -1
  85. package/dist/store-DysUkTH3.d.ts.map +1 -0
  86. package/dist/store-N8PXxDAS.js +2 -0
  87. package/dist/store-N8PXxDAS.js.map +1 -0
  88. package/dist/store-codec-DTG0Ftek.js +2 -0
  89. package/dist/store-codec-DTG0Ftek.js.map +1 -0
  90. package/dist/store-memory.d.ts +3 -2
  91. package/dist/store-memory.d.ts.map +1 -0
  92. package/dist/store-memory.js +19 -11
  93. package/dist/store-memory.js.map +1 -0
  94. package/dist/{store-polling-DgrrAE3d.js → store-polling-dSeLxzfb.js} +3 -1
  95. package/dist/store-polling-dSeLxzfb.js.map +1 -0
  96. package/dist/store-postgres.d.ts +3 -2
  97. package/dist/store-postgres.d.ts.map +1 -0
  98. package/dist/store-postgres.js +57 -1
  99. package/dist/store-postgres.js.map +1 -0
  100. package/dist/{store-redis-core-DWqx3F47.js → store-redis-core-BFLwz0Wj.js} +3 -1
  101. package/dist/store-redis-core-BFLwz0Wj.js.map +1 -0
  102. package/dist/store-redis-http.d.ts +3 -2
  103. package/dist/store-redis-http.d.ts.map +1 -0
  104. package/dist/store-redis-http.js +4 -2
  105. package/dist/store-redis-http.js.map +1 -0
  106. package/dist/store-redis.d.ts +3 -2
  107. package/dist/store-redis.d.ts.map +1 -0
  108. package/dist/store-redis.js +5 -3
  109. package/dist/store-redis.js.map +1 -0
  110. package/dist/store-sqlite.d.ts +3 -2
  111. package/dist/store-sqlite.d.ts.map +1 -0
  112. package/dist/store-sqlite.js +3 -1
  113. package/dist/store-sqlite.js.map +1 -0
  114. package/dist/telemetry-BjYHTfh2.d.ts +2 -1
  115. package/dist/telemetry-BjYHTfh2.d.ts.map +1 -0
  116. package/dist/testing.browser.js +2 -0
  117. package/dist/testing.browser.js.map +1 -0
  118. package/dist/testing.d.ts +2 -1
  119. package/dist/testing.d.ts.map +1 -0
  120. package/dist/testing.js +2 -0
  121. package/dist/testing.js.map +1 -0
  122. package/dist/validate-XKT4FSNn.js +2 -0
  123. package/dist/validate-XKT4FSNn.js.map +1 -0
  124. package/dist/{wire-DCUZBUlT.js → wire-BFQmSJ-9.js} +77 -15
  125. package/dist/wire-BFQmSJ-9.js.map +1 -0
  126. package/docs/guides/03-react.mdx +59 -39
  127. package/docs/guides/06-ai-agents.mdx +5 -27
  128. package/docs/guides/09-presence.mdx +19 -40
  129. package/docs/guides/10-transports.mdx +49 -40
  130. package/docs/reference/01-api.mdx +107 -26
  131. package/docs/reference/02-errors.mdx +4 -2
  132. package/package.json +2 -1
  133. package/src/ai-coordinator.ts +358 -0
  134. package/src/ai-projector.ts +524 -0
  135. package/src/ai-sdk-step.ts +261 -0
  136. package/src/ai-server.browser.ts +5 -0
  137. package/src/ai-server.ts +1719 -0
  138. package/src/ai.ts +2155 -0
  139. package/src/cache-indexeddb.ts +10 -0
  140. package/src/cli-bin.ts +5 -0
  141. package/src/cli.ts +1046 -0
  142. package/src/client.ts +1826 -0
  143. package/src/contract.ts +206 -0
  144. package/src/deterministic-id.ts +72 -0
  145. package/src/devtools-app.ts +989 -0
  146. package/src/devtools-server.browser.ts +5 -0
  147. package/src/devtools-server.ts +604 -0
  148. package/src/devtools.ts +716 -0
  149. package/src/errors.ts +50 -0
  150. package/src/http.ts +394 -0
  151. package/src/idempotent-replay.ts +53 -0
  152. package/src/index.ts +37 -0
  153. package/src/inspection.ts +39 -0
  154. package/src/internal.ts +426 -0
  155. package/src/otel.ts +59 -0
  156. package/src/platform.ts +60 -0
  157. package/src/push-envelope.ts +137 -0
  158. package/src/react.ts +284 -0
  159. package/src/reducer.ts +108 -0
  160. package/src/retryable-lazy.ts +27 -0
  161. package/src/scheduler-qstash.ts +915 -0
  162. package/src/scheduler-task.ts +106 -0
  163. package/src/scheduler-vercel.ts +437 -0
  164. package/src/server.browser.ts +12 -0
  165. package/src/server.ts +2700 -0
  166. package/src/session-socket.ts +548 -0
  167. package/src/sse.ts +141 -0
  168. package/src/standard-schema.ts +77 -0
  169. package/src/store-codec.ts +10 -0
  170. package/src/store-memory.ts +788 -0
  171. package/src/store-polling.ts +102 -0
  172. package/src/store-postgres.ts +1212 -0
  173. package/src/store-redis-core.ts +1494 -0
  174. package/src/store-redis-http.ts +116 -0
  175. package/src/store-redis.ts +458 -0
  176. package/src/store-sqlite.ts +1108 -0
  177. package/src/store.ts +385 -0
  178. package/src/telemetry.ts +47 -0
  179. package/src/testing.browser.ts +5 -0
  180. package/src/testing.ts +185 -0
  181. package/src/validate.ts +39 -0
  182. package/src/wire.ts +454 -0
@@ -123,8 +123,8 @@ Server-only by construction: `experimental-a2/server` is the only entry point th
123
123
  can reach a store backend, and its exports map resolves to a loud error
124
124
  under the browser condition.
125
125
 
126
- `validatePush(context)` runs only for input that came from `parsePushBody()`,
127
- once per plane. The events plane invokes it with `{ sessionId, events }` before
126
+ `validatePush(context)` runs only for input that arrived over the wire
127
+ through `handle`'s push lane, once per plane. The events plane invokes it with `{ sessionId, events }` before
128
128
  contract schema validation and before the store append, so throwing rejects the
129
129
  complete push without writing anything. The presence plane invokes it with
130
130
  `{ sessionId, events: [], presence }`, the whole pushed patch with its
@@ -132,7 +132,7 @@ participant, before field validation and the broadcast, so authorizing the
132
132
  participant id (and applying any size or cardinality policy) happens at the
133
133
  same seam. The patch arrives frozen: authorize, don't rewrite (a mutation
134
134
  attempt throws and fails the push). Direct trusted server appends, handler appends, and server-side
135
- `setPresence` bypass it. `parsePushBody()` creates
135
+ `setPresence` bypass it. The envelope parser inside `handle` creates
136
136
  the runtime provenance brand after reading the envelope; a caller-supplied
137
137
  field with the same name is ignored, and the brand is not stored in the log.
138
138
 
@@ -428,8 +428,8 @@ The only way to move a session forward. Payloads are validated against
428
428
  the contract's schemas before anything is written. A multi-event append
429
429
  is atomic: all-or-nothing, consecutive positions, one transaction. Pass
430
430
  `id` to make an append idempotent across retries; re-sending an
431
- identical batch returns the original rows. (Events parsed by
432
- `parsePushBody` are accepted directly, the push-route path.) See
431
+ identical batch returns the original rows. (Events that arrived through
432
+ `handle`'s push lane are accepted directly.) See
433
433
  [Durability](/concepts/durability).
434
434
 
435
435
  After the write, A2 dispatches only event types with registered handlers.
@@ -537,14 +537,14 @@ session.stream(options: {
537
537
 
538
538
  A live feed of the session's events. `startAfter` is a non-negative safe integer;
539
539
  `startAfter: 20` begins with event 21.
540
- Server-side only; expose it over SSE with `sseResponse`. Subscribing never
541
- dispatches handlers.
540
+ Server-side only; `handle` exposes it over SSE, and over the multiplexed
541
+ socket when `upgrade` is set. Subscribing never dispatches handlers.
542
542
 
543
543
  With `presence: true`, the feed yields one `PresenceSnapshot` first:
544
544
  `{ snapshot }`, the current pruned map with each field's own `value`,
545
545
  `seen`, and `at` stamp. Live presence patches then interleave with
546
- events. `sseResponse` sends both as named SSE frames, so clients that
547
- don't know them skip them. The return type widens only under the
546
+ events. The stream response sends both as named SSE frames, so clients
547
+ that don't know them skip them. The return type widens only under the
548
548
  literal `presence: true`; without it, existing consumers keep
549
549
  `AsyncIterable<Event>`. The option itself exists only on sessions of
550
550
  contracts that declare `presence`; elsewhere it is a type error, not a
@@ -580,9 +580,9 @@ by backend TTL when a participant goes silent (default 60 seconds; set
580
580
  the storage clock, not the sender stamp, so a hostile stamp can only
581
581
  vandalize its own field and still expires on schedule.
582
582
 
583
- The patch is structurally the `presence` object `parsePushBody` returns,
584
- so a push route forwards it whole: `session.setPresence(presence)`. The
585
- API mirrors the wire.
583
+ The patch is structurally the `presence` sibling of the push envelope;
584
+ `handle`'s push lane forwards it whole to `session.setPresence(presence)`.
585
+ The API mirrors the wire.
586
586
 
587
587
  The handler-scoped form `ctx.session.setPresence(...)` is the same
588
588
  operation; `seen` defaults to the triggering event's `index`.
@@ -666,16 +666,32 @@ not affect state hydration or stream resumption.
666
666
  ### `useSession()`
667
667
 
668
668
  ```ts
669
- const { state, push, events, index, connection, presence, setPresence } =
670
- useSession()
669
+ const { state, push, events, index, loadHistory, history, connection,
670
+ presence, setPresence } = useSession()
671
671
  ```
672
672
 
673
673
  `state` starts from the initial snapshot and folds live events through the
674
- shared reducer. `events` is the raw observed feed. Unless `initialEvents`
675
- explicitly seeds earlier entries, it begins after `initialIndex`. `index` is
674
+ shared reducer. `events` is the raw event feed: observed, explicitly
675
+ seeded, or backscrolled. Unless `initialEvents` seeds earlier entries or
676
+ `loadHistory` fetches them, it begins after `initialIndex`. `index` is
676
677
  the stream frontier, the `lastSeenIndex` for
677
678
  [cancellation](/guides/cancellation).
678
679
 
680
+ `loadHistory({ before?, limit? })` backscrolls: it fetches a bounded
681
+ slice of the log from below the frontier (the same route, `gte`/`lte`
682
+ query parameters) and merges it into `events`, deduped, ordered, and
683
+ shared across every handle of the session. It resolves with the events
684
+ in the requested range. Defaults walk backward 50 at a time from the
685
+ oldest loaded event; `before` is an exclusive upper bound. After a
686
+ hydrate jump (returning to a session whose frontier advanced while
687
+ away), default paging still continues from the oldest loaded event;
688
+ pass an explicit `before` to fill the gap between the old feed and the
689
+ new frontier. It never touches `state` or the optimistic overlay. Calls
690
+ serialize per session and already-loaded ranges are not refetched.
691
+ `history` is the progress: `{ loading, complete, oldestLoaded }`, where
692
+ `complete` means the feed reaches index 1 (or the log is empty). The `ws` api has no history lane; there `loadHistory`
693
+ throws a `TypeError`.
694
+
679
695
  `presence` is the replicated ephemeral map,
680
696
  `Record<participantId, { [field]: { value, seen, at } }>`, including
681
697
  this client. `setPresence(values)` is fire-and-forget: validated
@@ -1032,10 +1048,10 @@ subscription with frontier resume and reconnection, the optimistic push
1032
1048
  queue with ack/rollback, and the local fold. `client.session(id, {
1033
1049
  initialState?, initialIndex?, initialEvents?, participant? })` returns a
1034
1050
  handle with `getSnapshot()`/`subscribe()` (the `useSyncExternalStore`
1035
- contract), `push()`, `connect()`, and `close()`. Snapshots carry
1036
- `state`, `events`, `index`, and `connection` (the same fields
1037
- `useSession` exposes), and `push` returns the same ack-then-`confirmed`
1038
- result. On contracts that declare `presence` the handle also carries
1051
+ contract), `push()`, `loadHistory()`, `connect()`, and `close()`.
1052
+ Snapshots carry `state`, `events`, `index`, `history`, and `connection`
1053
+ (the same fields `useSession` exposes), and `push` returns the same
1054
+ ack-then-`confirmed` result. On contracts that declare `presence` the handle also carries
1039
1055
  `setPresence()` and snapshots carry the `presence` map, exactly like
1040
1056
  the hook; `participant` is the identity `setPresence` sends under. Use
1041
1057
  it directly from any other framework, or none.
@@ -1051,13 +1067,77 @@ across route transitions, while IndexedDB preserves the replica across reloads.
1051
1067
 
1052
1068
  ## `experimental-a2/http`
1053
1069
 
1070
+ ### `handle(server, options?)`
1071
+
1072
+ ```ts
1073
+ handle(server: A2Server, options?: {
1074
+ before?(args: { request: Request; intent: A2Intent }):
1075
+ Response | undefined | void | Promise<Response | undefined | void>
1076
+ after?(args: { request: Request; intent: A2Intent; outcome: A2Outcome; response: Response }):
1077
+ Response | undefined | void | Promise<Response | undefined | void>
1078
+ upgrade?: UpgradeFn // e.g. (attach) => experimental_upgradeWebSocket(attach)
1079
+ presence?: boolean // interleave presence on every stream lane
1080
+ deadline?: number // epoch ms: close sockets cleanly before it
1081
+ }): { GET(req: Request): Promise<Response>; POST(req: Request): Promise<Response> }
1082
+
1083
+ type A2Intent =
1084
+ | { type: 'ws-upgrade' }
1085
+ | { type: 'stream'; sessionId: string; startAfter: number; transport: 'sse' | 'ws' }
1086
+ | { type: 'history'; sessionId: string; gte: number; lte: number }
1087
+ | { type: 'push'; sessionId: string; events: PushedEvent[]; presence?: PushedPresence; transport: 'http' | 'ws' }
1088
+
1089
+ type A2Outcome =
1090
+ | { type: 'stream' }
1091
+ | { type: 'history'; covered: boolean; events: Event[] }
1092
+ | { type: 'push'; appended: Event[] }
1093
+ ```
1094
+
1095
+ The session route pair as one call: `export const { GET, POST } =
1096
+ handle(server)` in a route module (any framework speaking
1097
+ `(req: Request) => Promise<Response>`). A plain `GET` is the live SSE
1098
+ stream, resumed after the `index` query parameter, with a `: connected`
1099
+ prelude, a `: ping` heartbeat every 15s, and a clean close one second
1100
+ before an ambient Vercel invocation deadline when available; presence
1101
+ patches ride as named frames when `presence: true`. A `GET` with
1102
+ `gte`/`lte` query parameters is a history slice: the closed log range
1103
+ as JSON wire events, the read `loadHistory` rides. `POST` is the push
1104
+ envelope `{ sessionId, events, presence? }`, answered with the appended
1105
+ events. A `GET` carrying an upgrade header becomes the multiplexed
1106
+ WebSocket when `options.upgrade` is present, and answers `426` when it
1107
+ is not.
1108
+
1109
+ Parsing is protocol, hooks are policy. A request that fails to parse
1110
+ (missing `sessionId`, malformed bounds, a bad push envelope) answers
1111
+ `INVALID_PAYLOAD` on the wire before any hook runs. `before` sees every
1112
+ parsed intent, HTTP requests and socket frames alike; over the socket,
1113
+ each subscribe arrives as a `stream` intent and each push as a `push`
1114
+ intent, with `request` always the original upgrade Request. Returning a
1115
+ Response short-circuits: over HTTP it is the response, verbatim; over
1116
+ the socket it is translated into the wire's own vocabulary (a denied
1117
+ subscribe answers `unsubscribed`, a denied push a non-retryable error
1118
+ ack), because a Response cannot cross a socket.
1119
+
1120
+ `after` runs only where the library produced an HTTP response: never
1121
+ after a short-circuit, never for `ws-upgrade` or socket frames. It may
1122
+ mutate `response.headers` in place or return a replacement Response.
1123
+ `outcome.covered` on a history read means the closed range came back
1124
+ fully covered (`events.length === lte - gte + 1`): an immutable slice
1125
+ of the append-only log, safe to cache under whatever policy your
1126
+ `after` applies. The history response carries no cache headers of its
1127
+ own.
1128
+
1129
+ The socket is one connection for all of a client's sessions:
1130
+ `subscribe`/`unsubscribe` frames open and close per-session lanes at
1131
+ their own resume frontiers, `sessionId` tags route pushes, presence,
1132
+ and acks, and a lane ending or failing answers `unsubscribed` without
1133
+ taking the socket down. See [Transports](/guides/transports).
1134
+
1135
+ ### The rest of the entry
1136
+
1054
1137
  | Helper | What it does |
1055
1138
  | ----------------------- | ------------------------------------------------------------------------------ |
1056
1139
  | `schedulerHandler(...servers)` | returns the delivery route after synchronously verifying one shared scheduler |
1057
- | `parsePushBody(req)` | validates the push envelope `{ sessionId, events, presence? }`, throws `INVALID_PAYLOAD` |
1058
- | `sseResponse(iterable)` | pipes a `session.stream()` iterable into an SSE `Response`, with a `: connected` prelude, a `: ping` heartbeat every 15s, and a clean close one second before an ambient Vercel invocation deadline when available; presence patches ride as named `presence` frames |
1059
- | `sessionSocket(session, socket, options?)` | speaks the A2 wire over any `ws`-shaped socket: the stream pumps down as JSON frames, pushes and presence come up with the same validation and `validatePush` seam as the POST route; `options` carries `startAfter`, `presence`, and an optional `deadline` for clean pre-deadline closes. See [Transports](/guides/transports) |
1060
- | `errorResponse(err)` | serializes an `A2Error` to `{ error: { code, message, details } }` + status |
1140
+ | `errorResponse(err)` | serializes an `A2Error` to `{ error: { code, message, details } }` + status; the natural return value of a refusing `before` hook |
1061
1141
  | `deserializeError(body)` | rebuilds an `A2Error` from a wire body, or `null` if the body isn't one |
1062
1142
 
1063
1143
  `schedulerHandler(...servers)` is the application-facing scheduler route. It
@@ -1071,8 +1151,9 @@ Different scheduler instances use different routes. Match each QStash route to
1071
1151
  that instance's resolved `url`; additional QStash routes pass an explicit
1072
1152
  `url`. Match each Vercel Queues route and trigger to that instance's `topic`.
1073
1153
 
1074
- Together the last two are the `A2Error` wire format that `push` and the
1075
- push route share. See [Errors](/reference/errors#over-the-wire).
1154
+ `errorResponse` and `deserializeError` are the `A2Error` wire format
1155
+ that `push` and the push lane share. See
1156
+ [Errors](/reference/errors#over-the-wire).
1076
1157
 
1077
1158
  ## `experimental-a2/cache-indexeddb`
1078
1159
 
@@ -78,5 +78,7 @@ with a mapped status: `INVALID_PAYLOAD`, `UNKNOWN_EVENT_TYPE`, and
78
78
 
79
79
  The client's `push` deserializes the body back into an `A2Error`, so client
80
80
  and server code branch on identical codes. `push` auto-retries only
81
- `STORE_UNAVAILABLE`. The serializer pair ships in `experimental-a2/http`, alongside
82
- `parsePushBody` and `sseResponse`.
81
+ `STORE_UNAVAILABLE`. The serializer pair, `errorResponse` and
82
+ `deserializeError`, ships in `experimental-a2/http` alongside `handle`;
83
+ a `before` hook that wants the wire's own error shapes returns
84
+ `errorResponse(new A2Error(...))`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "experimental-a2",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Durable sync and reactions for things with a lifecycle: one event log, derived state, and live client per session.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -23,6 +23,7 @@
23
23
  "files": [
24
24
  "dist",
25
25
  "docs",
26
+ "src",
26
27
  "CHANGELOG.md"
27
28
  ],
28
29
  "exports": {
@@ -0,0 +1,358 @@
1
+ import type { UIMessage } from 'ai'
2
+ import type {
3
+ AIEventDefs,
4
+ ApprovalRequestedPayload,
5
+ ApprovalRespondedPayload,
6
+ GenerationCompletedPayload,
7
+ GenerationFailedPayload,
8
+ GenerationRequestedPayload,
9
+ GenerationStartedPayload,
10
+ InputRequestedPayload,
11
+ MessageCreatedPayload,
12
+ MessageInterruptedPayload,
13
+ ToolCalledPayload,
14
+ ToolResultPayload,
15
+ } from './ai.ts'
16
+ import type { Contract, ContractEvent, EventDefs } from './contract.ts'
17
+ import type { Reducer } from './reducer.ts'
18
+
19
+ export type CoordinatorCall = {
20
+ index: number
21
+ call: ToolCalledPayload
22
+ approval?: ApprovalRequestedPayload
23
+ response?: ApprovalRespondedPayload
24
+ responseIndex?: number
25
+ terminal: boolean
26
+ }
27
+
28
+ export type CoordinatorResponse = {
29
+ rootMessageId: string
30
+ responseMessageId: string
31
+ status: 'requested' | 'generating' | 'waiting' | 'failed'
32
+ activeRequestId?: string
33
+ generation?: GenerationStartedPayload
34
+ completion?: GenerationCompletedPayload
35
+ failure?: GenerationFailedPayload
36
+ calls: CoordinatorCall[]
37
+ inputs: InputRequestedPayload[]
38
+ inputResponse?: {
39
+ index: number
40
+ generationId: string
41
+ inputId: string
42
+ name: string
43
+ }
44
+ }
45
+
46
+ export type AICoordinatorState = {
47
+ closed: boolean
48
+ queued: Array<{ index: number; messageId: string; generate: boolean }>
49
+ response?: CoordinatorResponse
50
+ }
51
+
52
+ const updateResponse = (
53
+ state: AICoordinatorState,
54
+ update: (response: CoordinatorResponse) => CoordinatorResponse,
55
+ ): AICoordinatorState =>
56
+ state.response === undefined
57
+ ? state
58
+ : { ...state, response: update(state.response) }
59
+
60
+ const foldCoordinator = <M extends UIMessage, D extends EventDefs>(
61
+ state: AICoordinatorState,
62
+ event: ContractEvent<D>,
63
+ ): AICoordinatorState => {
64
+ switch (event.type) {
65
+ case 'ai.session.closed':
66
+ return { closed: true, queued: [] }
67
+ case 'ai.message.created': {
68
+ const payload = event.payload as MessageCreatedPayload<M>
69
+ const message = payload.message
70
+ if (message.role !== 'user') return state
71
+ return {
72
+ ...state,
73
+ queued: [
74
+ ...state.queued.filter((item) => item.messageId !== message.id),
75
+ {
76
+ index: event.index,
77
+ messageId: message.id,
78
+ generate: payload.generate !== false,
79
+ },
80
+ ].toSorted((left, right) => left.index - right.index),
81
+ }
82
+ }
83
+ case 'ai.generation.requested': {
84
+ const request = event.payload as GenerationRequestedPayload
85
+ if (request.reason === 'message') {
86
+ const responseMessageId =
87
+ request.responseMessageId ?? `${request.messageId}:assistant`
88
+ const requested = state.queued.find(
89
+ (item) => item.messageId === request.messageId,
90
+ )
91
+ return {
92
+ ...state,
93
+ queued:
94
+ requested === undefined
95
+ ? state.queued.filter(
96
+ (item) => item.messageId !== request.messageId,
97
+ )
98
+ : state.queued.filter((item) => item.index > requested.index),
99
+ response: {
100
+ rootMessageId: request.messageId,
101
+ responseMessageId,
102
+ status: 'requested',
103
+ activeRequestId: event.id,
104
+ calls: [],
105
+ inputs: [],
106
+ },
107
+ }
108
+ }
109
+ if (
110
+ state.response === undefined ||
111
+ request.responseMessageId !== state.response.responseMessageId
112
+ ) {
113
+ return state
114
+ }
115
+ if (
116
+ (request.reason === 'tool' &&
117
+ (!continuationReady(state) ||
118
+ event.id !==
119
+ `ai.generate:tools:${state.response.generation?.generationId}`)) ||
120
+ (request.reason === 'retry' && state.response.status !== 'failed') ||
121
+ (request.reason === 'input' &&
122
+ state.response.inputResponse === undefined)
123
+ ) {
124
+ return state
125
+ }
126
+ return updateResponse(state, (response) => ({
127
+ rootMessageId: response.rootMessageId,
128
+ responseMessageId: response.responseMessageId,
129
+ status: 'requested',
130
+ activeRequestId: event.id,
131
+ calls: [],
132
+ inputs: [],
133
+ }))
134
+ }
135
+ case 'ai.generation.started': {
136
+ const generation = event.payload as GenerationStartedPayload
137
+ if (
138
+ state.response?.responseMessageId !== generation.responseMessageId ||
139
+ state.response.activeRequestId !== generation.requestId ||
140
+ (state.response.generation?.requestId === generation.requestId &&
141
+ state.response.generation.attempt >= generation.attempt)
142
+ ) {
143
+ return state
144
+ }
145
+ return updateResponse(state, (response) => ({
146
+ rootMessageId: response.rootMessageId,
147
+ responseMessageId: response.responseMessageId,
148
+ status: 'generating',
149
+ generation,
150
+ activeRequestId: generation.requestId,
151
+ calls: [],
152
+ inputs: [],
153
+ }))
154
+ }
155
+ case 'ai.generation.completed': {
156
+ const completion = event.payload as GenerationCompletedPayload
157
+ if (
158
+ state.response?.generation?.generationId !== completion.generationId
159
+ ) {
160
+ return state
161
+ }
162
+ return updateResponse(state, (response) => ({
163
+ ...response,
164
+ status: 'waiting',
165
+ completion,
166
+ }))
167
+ }
168
+ case 'ai.generation.failed': {
169
+ const failure = event.payload as GenerationFailedPayload
170
+ const response = state.response
171
+ const ownsActiveGeneration =
172
+ response?.generation?.generationId === failure.generationId
173
+ const ownsActiveStepLimit =
174
+ failure.stepLimit === true &&
175
+ response?.activeRequestId ===
176
+ `ai.generate:tools:${failure.generationId}` &&
177
+ response.responseMessageId === failure.responseMessageId
178
+ if (!ownsActiveGeneration && !ownsActiveStepLimit) {
179
+ return state
180
+ }
181
+ return updateResponse(state, (current) => ({
182
+ ...current,
183
+ status: 'failed',
184
+ failure,
185
+ }))
186
+ }
187
+ case 'ai.message.completed': {
188
+ const messageId = (event.payload as { messageId: string }).messageId
189
+ return state.response?.responseMessageId === messageId
190
+ ? { closed: state.closed, queued: state.queued }
191
+ : state
192
+ }
193
+ case 'ai.message.interrupted': {
194
+ const interruption = event.payload as MessageInterruptedPayload
195
+ const response = state.response
196
+ return response?.responseMessageId === interruption.messageId &&
197
+ (interruption.generationId === response.generation?.generationId ||
198
+ interruption.requestId === response.activeRequestId)
199
+ ? { closed: state.closed, queued: state.queued }
200
+ : state
201
+ }
202
+ case 'ai.tool.called': {
203
+ const call = event.payload as ToolCalledPayload
204
+ if (state.response?.generation?.generationId !== call.generationId) {
205
+ return state
206
+ }
207
+ return updateResponse(state, (response) => ({
208
+ ...response,
209
+ calls: [
210
+ ...response.calls.filter(
211
+ (candidate) => candidate.call.toolCallId !== call.toolCallId,
212
+ ),
213
+ { index: event.index, call, terminal: false },
214
+ ],
215
+ }))
216
+ }
217
+ case 'ai.approval.requested': {
218
+ const approval = event.payload as ApprovalRequestedPayload
219
+ if (state.response?.generation?.generationId !== approval.generationId) {
220
+ return state
221
+ }
222
+ return updateResponse(state, (response) => ({
223
+ ...response,
224
+ calls: response.calls.map((candidate) =>
225
+ candidate.call.toolCallId === approval.toolCallId
226
+ ? { ...candidate, approval }
227
+ : candidate,
228
+ ),
229
+ }))
230
+ }
231
+ case 'ai.approval.responded': {
232
+ const approval = event.payload as ApprovalRespondedPayload
233
+ if (
234
+ state.response?.responseMessageId !== approval.messageId ||
235
+ state.response.generation?.generationId !== approval.generationId
236
+ ) {
237
+ return state
238
+ }
239
+ return updateResponse(state, (response) => ({
240
+ ...response,
241
+ calls: response.calls.map((candidate) =>
242
+ candidate.approval?.approvalId === approval.approvalId
243
+ ? { ...candidate, response: approval, responseIndex: event.index }
244
+ : candidate,
245
+ ),
246
+ }))
247
+ }
248
+ case 'ai.tool.result': {
249
+ const result = event.payload as ToolResultPayload
250
+ if (
251
+ result.preliminary === true ||
252
+ state.response?.generation?.generationId !== result.generationId
253
+ ) {
254
+ return state
255
+ }
256
+ return updateResponse(state, (response) => ({
257
+ ...response,
258
+ calls: response.calls.map((candidate) =>
259
+ candidate.call.toolCallId === result.toolCallId
260
+ ? { ...candidate, terminal: true }
261
+ : candidate,
262
+ ),
263
+ }))
264
+ }
265
+ case 'ai.input.requested': {
266
+ const input = event.payload as InputRequestedPayload
267
+ if (
268
+ state.response?.responseMessageId !== input.messageId ||
269
+ state.response.generation?.generationId !== input.generationId
270
+ ) {
271
+ return state
272
+ }
273
+ return updateResponse(state, (response) => ({
274
+ ...response,
275
+ inputs: [
276
+ ...response.inputs.filter(
277
+ (candidate) =>
278
+ candidate.messageId !== input.messageId ||
279
+ candidate.generationId !== input.generationId ||
280
+ candidate.inputId !== input.inputId,
281
+ ),
282
+ input,
283
+ ],
284
+ }))
285
+ }
286
+ case 'ai.input.responded': {
287
+ const input = event.payload as {
288
+ messageId: string
289
+ generationId: string
290
+ inputId: string
291
+ name: string
292
+ }
293
+ const requested = state.response?.inputs.find(
294
+ (candidate) =>
295
+ candidate.messageId === input.messageId &&
296
+ candidate.generationId === input.generationId &&
297
+ candidate.inputId === input.inputId &&
298
+ candidate.name === input.name,
299
+ )
300
+ if (
301
+ state.response?.responseMessageId !== input.messageId ||
302
+ state.response.generation?.generationId !== input.generationId ||
303
+ requested === undefined
304
+ ) {
305
+ return state
306
+ }
307
+ return updateResponse(state, (response) => ({
308
+ ...response,
309
+ inputs: response.inputs.filter(
310
+ (candidate) => candidate.inputId !== input.inputId,
311
+ ),
312
+ inputResponse: {
313
+ index: event.index,
314
+ generationId: input.generationId,
315
+ inputId: input.inputId,
316
+ name: requested.name,
317
+ },
318
+ }))
319
+ }
320
+ default:
321
+ return state
322
+ }
323
+ }
324
+
325
+ export const aiCoordinatorReducer = <
326
+ M extends UIMessage,
327
+ D extends AIEventDefs<M> & EventDefs,
328
+ >(
329
+ contract: Contract<D>,
330
+ ): Reducer<D, AICoordinatorState> =>
331
+ contract
332
+ .reducer({
333
+ name: 'a2.ai.coordinator.v3',
334
+ initialState: {
335
+ closed: false,
336
+ queued: [],
337
+ } as AICoordinatorState,
338
+ })
339
+ .fold((state, event) => foldCoordinator<M, D>(state, event))
340
+
341
+ export const continuationReady = (state: AICoordinatorState): boolean => {
342
+ const response = state.response
343
+ return (
344
+ !state.closed &&
345
+ response?.completion?.finishReason === 'tool-calls' &&
346
+ response.generation?.generationId === response.completion.generationId &&
347
+ response.failure === undefined &&
348
+ response.calls.length > 0 &&
349
+ response.calls.every(
350
+ (call) =>
351
+ call.terminal ||
352
+ (call.call.providerExecuted === true &&
353
+ call.call.supportsDeferredResults !== true &&
354
+ call.approval !== undefined &&
355
+ call.response !== undefined),
356
+ )
357
+ )
358
+ }