@ably/ai-transport 0.1.0 → 0.3.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 (221) hide show
  1. package/README.md +93 -111
  2. package/dist/ably-ai-transport.js +2401 -1387
  3. package/dist/ably-ai-transport.js.map +1 -1
  4. package/dist/ably-ai-transport.umd.cjs +1 -1
  5. package/dist/ably-ai-transport.umd.cjs.map +1 -1
  6. package/dist/constants.d.ts +116 -42
  7. package/dist/core/agent.d.ts +44 -0
  8. package/dist/core/channel-options.d.ts +57 -0
  9. package/dist/core/codec/codec-event.d.ts +9 -0
  10. package/dist/core/codec/decoder.d.ts +24 -24
  11. package/dist/core/codec/define-codec.d.ts +100 -0
  12. package/dist/core/codec/encoder.d.ts +10 -12
  13. package/dist/core/codec/field-bag.d.ts +85 -0
  14. package/dist/core/codec/fields.d.ts +141 -0
  15. package/dist/core/codec/index.d.ts +8 -2
  16. package/dist/core/codec/input-descriptor-decoder.d.ts +19 -0
  17. package/dist/core/codec/input-descriptor-encoder.d.ts +22 -0
  18. package/dist/core/codec/input-descriptors.d.ts +281 -0
  19. package/dist/core/codec/lifecycle-tracker.d.ts +10 -9
  20. package/dist/core/codec/output-descriptor-decoder.d.ts +29 -0
  21. package/dist/core/codec/output-descriptor-encoder.d.ts +31 -0
  22. package/dist/core/codec/output-descriptors.d.ts +237 -0
  23. package/dist/core/codec/types.d.ts +470 -119
  24. package/dist/core/codec/well-known-inputs.d.ts +52 -0
  25. package/dist/core/transport/agent-session.d.ts +10 -0
  26. package/dist/core/transport/agent-view.d.ts +296 -0
  27. package/dist/core/transport/client-session.d.ts +13 -0
  28. package/dist/core/transport/decode-fold.d.ts +55 -0
  29. package/dist/core/transport/headers.d.ts +121 -14
  30. package/dist/core/transport/index.d.ts +5 -6
  31. package/dist/core/transport/internal/bounded-map.d.ts +20 -0
  32. package/dist/core/transport/invocation.d.ts +74 -0
  33. package/dist/core/transport/load-history-pages.d.ts +71 -0
  34. package/dist/core/transport/load-history.d.ts +44 -0
  35. package/dist/core/transport/pipe-stream.d.ts +9 -9
  36. package/dist/core/transport/run-manager.d.ts +76 -0
  37. package/dist/core/transport/session-support.d.ts +55 -0
  38. package/dist/core/transport/tree.d.ts +523 -109
  39. package/dist/core/transport/types/agent.d.ts +375 -0
  40. package/dist/core/transport/types/client.d.ts +201 -0
  41. package/dist/core/transport/types/shared.d.ts +24 -0
  42. package/dist/core/transport/types/tree.d.ts +357 -0
  43. package/dist/core/transport/types/view.d.ts +249 -0
  44. package/dist/core/transport/types.d.ts +13 -553
  45. package/dist/core/transport/view.d.ts +390 -84
  46. package/dist/core/transport/wire-log.d.ts +102 -0
  47. package/dist/errors.d.ts +27 -10
  48. package/dist/index.d.ts +8 -9
  49. package/dist/logger.d.ts +12 -0
  50. package/dist/react/ably-ai-transport-react.js +1365 -1010
  51. package/dist/react/ably-ai-transport-react.js.map +1 -1
  52. package/dist/react/ably-ai-transport-react.umd.cjs +1 -1
  53. package/dist/react/ably-ai-transport-react.umd.cjs.map +1 -1
  54. package/dist/react/contexts/client-session-context.d.ts +37 -0
  55. package/dist/react/contexts/client-session-provider.d.ts +56 -0
  56. package/dist/react/create-session-hooks.d.ts +116 -0
  57. package/dist/react/index.d.ts +13 -12
  58. package/dist/react/internal/skipped-session.d.ts +8 -0
  59. package/dist/react/internal/use-resolved-session.d.ts +36 -0
  60. package/dist/react/use-ably-messages.d.ts +17 -14
  61. package/dist/react/use-client-session.d.ts +81 -0
  62. package/dist/react/use-create-view.d.ts +14 -13
  63. package/dist/react/use-tree.d.ts +30 -15
  64. package/dist/react/use-view.d.ts +81 -50
  65. package/dist/utils.d.ts +48 -71
  66. package/dist/vercel/ably-ai-transport-vercel.js +3257 -2499
  67. package/dist/vercel/ably-ai-transport-vercel.js.map +1 -1
  68. package/dist/vercel/ably-ai-transport-vercel.umd.cjs +1 -1
  69. package/dist/vercel/ably-ai-transport-vercel.umd.cjs.map +1 -1
  70. package/dist/vercel/codec/decode-lifecycle.d.ts +9 -0
  71. package/dist/vercel/codec/events.d.ts +50 -0
  72. package/dist/vercel/codec/fields.d.ts +44 -0
  73. package/dist/vercel/codec/fold-content.d.ts +16 -0
  74. package/dist/vercel/codec/fold-data.d.ts +16 -0
  75. package/dist/vercel/codec/fold-input.d.ts +67 -0
  76. package/dist/vercel/codec/fold-lifecycle.d.ts +16 -0
  77. package/dist/vercel/codec/fold-text.d.ts +16 -0
  78. package/dist/vercel/codec/fold-tool-input.d.ts +17 -0
  79. package/dist/vercel/codec/fold-tool-output.d.ts +16 -0
  80. package/dist/vercel/codec/index.d.ts +7 -20
  81. package/dist/vercel/codec/inputs.d.ts +11 -0
  82. package/dist/vercel/codec/outputs.d.ts +11 -0
  83. package/dist/vercel/codec/reducer-state.d.ts +121 -0
  84. package/dist/vercel/codec/reducer.d.ts +62 -0
  85. package/dist/vercel/codec/tool-transitions.d.ts +2 -8
  86. package/dist/vercel/codec/wire-data.d.ts +34 -0
  87. package/dist/vercel/index.d.ts +5 -5
  88. package/dist/vercel/react/ably-ai-transport-vercel-react.js +2859 -9705
  89. package/dist/vercel/react/ably-ai-transport-vercel-react.js.map +1 -1
  90. package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs +1 -45
  91. package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs.map +1 -1
  92. package/dist/vercel/react/contexts/chat-transport-context.d.ts +9 -7
  93. package/dist/vercel/react/contexts/chat-transport-provider.d.ts +53 -41
  94. package/dist/vercel/react/index.d.ts +1 -2
  95. package/dist/vercel/react/use-chat-transport.d.ts +30 -26
  96. package/dist/vercel/react/use-message-sync.d.ts +17 -30
  97. package/dist/vercel/run-end-reason.d.ts +84 -0
  98. package/dist/vercel/tool-part.d.ts +21 -0
  99. package/dist/vercel/transport/chat-transport.d.ts +41 -24
  100. package/dist/vercel/transport/index.d.ts +24 -20
  101. package/dist/vercel/transport/run-output-stream.d.ts +54 -0
  102. package/dist/version.d.ts +2 -0
  103. package/package.json +31 -24
  104. package/src/constants.ts +124 -51
  105. package/src/core/agent.ts +92 -0
  106. package/src/core/channel-options.ts +89 -0
  107. package/src/core/codec/codec-event.ts +27 -0
  108. package/src/core/codec/decoder.ts +202 -105
  109. package/src/core/codec/define-codec.ts +432 -0
  110. package/src/core/codec/encoder.ts +114 -107
  111. package/src/core/codec/field-bag.ts +142 -0
  112. package/src/core/codec/fields.ts +193 -0
  113. package/src/core/codec/index.ts +56 -6
  114. package/src/core/codec/input-descriptor-decoder.ts +97 -0
  115. package/src/core/codec/input-descriptor-encoder.ts +150 -0
  116. package/src/core/codec/input-descriptors.ts +373 -0
  117. package/src/core/codec/lifecycle-tracker.ts +10 -9
  118. package/src/core/codec/output-descriptor-decoder.ts +139 -0
  119. package/src/core/codec/output-descriptor-encoder.ts +101 -0
  120. package/src/core/codec/output-descriptors.ts +307 -0
  121. package/src/core/codec/types.ts +505 -126
  122. package/src/core/codec/well-known-inputs.ts +96 -0
  123. package/src/core/transport/agent-session.ts +1085 -0
  124. package/src/core/transport/agent-view.ts +738 -0
  125. package/src/core/transport/client-session.ts +780 -0
  126. package/src/core/transport/decode-fold.ts +101 -0
  127. package/src/core/transport/headers.ts +234 -22
  128. package/src/core/transport/index.ts +27 -27
  129. package/src/core/transport/internal/bounded-map.ts +27 -0
  130. package/src/core/transport/invocation.ts +98 -0
  131. package/src/core/transport/load-history-pages.ts +220 -0
  132. package/src/core/transport/load-history.ts +271 -0
  133. package/src/core/transport/pipe-stream.ts +63 -39
  134. package/src/core/transport/run-manager.ts +243 -0
  135. package/src/core/transport/session-support.ts +96 -0
  136. package/src/core/transport/tree.ts +1293 -308
  137. package/src/core/transport/types/agent.ts +434 -0
  138. package/src/core/transport/types/client.ts +247 -0
  139. package/src/core/transport/types/shared.ts +27 -0
  140. package/src/core/transport/types/tree.ts +393 -0
  141. package/src/core/transport/types/view.ts +288 -0
  142. package/src/core/transport/types.ts +13 -706
  143. package/src/core/transport/view.ts +1229 -450
  144. package/src/core/transport/wire-log.ts +189 -0
  145. package/src/errors.ts +29 -9
  146. package/src/event-emitter.ts +3 -2
  147. package/src/index.ts +86 -42
  148. package/src/logger.ts +14 -1
  149. package/src/react/contexts/client-session-context.ts +41 -0
  150. package/src/react/contexts/client-session-provider.tsx +222 -0
  151. package/src/react/create-session-hooks.ts +141 -0
  152. package/src/react/index.ts +24 -13
  153. package/src/react/internal/skipped-session.ts +62 -0
  154. package/src/react/internal/use-resolved-session.ts +63 -0
  155. package/src/react/use-ably-messages.ts +32 -22
  156. package/src/react/use-client-session.ts +178 -0
  157. package/src/react/use-create-view.ts +33 -29
  158. package/src/react/use-tree.ts +61 -30
  159. package/src/react/use-view.ts +138 -96
  160. package/src/utils.ts +83 -131
  161. package/src/vercel/codec/decode-lifecycle.ts +70 -0
  162. package/src/vercel/codec/events.ts +85 -0
  163. package/src/vercel/codec/fields.ts +58 -0
  164. package/src/vercel/codec/fold-content.ts +54 -0
  165. package/src/vercel/codec/fold-data.ts +46 -0
  166. package/src/vercel/codec/fold-input.ts +255 -0
  167. package/src/vercel/codec/fold-lifecycle.ts +85 -0
  168. package/src/vercel/codec/fold-text.ts +55 -0
  169. package/src/vercel/codec/fold-tool-input.ts +86 -0
  170. package/src/vercel/codec/fold-tool-output.ts +79 -0
  171. package/src/vercel/codec/index.ts +28 -21
  172. package/src/vercel/codec/inputs.ts +116 -0
  173. package/src/vercel/codec/outputs.ts +207 -0
  174. package/src/vercel/codec/reducer-state.ts +169 -0
  175. package/src/vercel/codec/reducer.ts +191 -0
  176. package/src/vercel/codec/tool-transitions.ts +3 -14
  177. package/src/vercel/codec/wire-data.ts +64 -0
  178. package/src/vercel/index.ts +7 -19
  179. package/src/vercel/react/contexts/chat-transport-context.ts +8 -7
  180. package/src/vercel/react/contexts/chat-transport-provider.tsx +87 -59
  181. package/src/vercel/react/index.ts +3 -5
  182. package/src/vercel/react/use-chat-transport.ts +44 -66
  183. package/src/vercel/react/use-message-sync.ts +75 -39
  184. package/src/vercel/run-end-reason.ts +157 -0
  185. package/src/vercel/tool-part.ts +25 -0
  186. package/src/vercel/transport/chat-transport.ts +380 -98
  187. package/src/vercel/transport/index.ts +38 -37
  188. package/src/vercel/transport/run-output-stream.ts +169 -0
  189. package/src/version.ts +2 -0
  190. package/dist/core/transport/client-transport.d.ts +0 -10
  191. package/dist/core/transport/decode-history.d.ts +0 -43
  192. package/dist/core/transport/server-transport.d.ts +0 -7
  193. package/dist/core/transport/stream-router.d.ts +0 -29
  194. package/dist/core/transport/turn-manager.d.ts +0 -37
  195. package/dist/react/contexts/transport-context.d.ts +0 -31
  196. package/dist/react/contexts/transport-provider.d.ts +0 -49
  197. package/dist/react/create-transport-hooks.d.ts +0 -124
  198. package/dist/react/use-active-turns.d.ts +0 -12
  199. package/dist/react/use-client-transport.d.ts +0 -80
  200. package/dist/vercel/codec/accumulator.d.ts +0 -21
  201. package/dist/vercel/codec/decoder.d.ts +0 -22
  202. package/dist/vercel/codec/encoder.d.ts +0 -41
  203. package/dist/vercel/react/use-staged-add-tool-approval-response.d.ts +0 -30
  204. package/dist/vercel/tool-approvals.d.ts +0 -124
  205. package/dist/vercel/tool-events.d.ts +0 -26
  206. package/src/core/transport/client-transport.ts +0 -977
  207. package/src/core/transport/decode-history.ts +0 -485
  208. package/src/core/transport/server-transport.ts +0 -612
  209. package/src/core/transport/stream-router.ts +0 -136
  210. package/src/core/transport/turn-manager.ts +0 -165
  211. package/src/react/contexts/transport-context.ts +0 -37
  212. package/src/react/contexts/transport-provider.tsx +0 -164
  213. package/src/react/create-transport-hooks.ts +0 -144
  214. package/src/react/use-active-turns.ts +0 -72
  215. package/src/react/use-client-transport.ts +0 -197
  216. package/src/vercel/codec/accumulator.ts +0 -588
  217. package/src/vercel/codec/decoder.ts +0 -618
  218. package/src/vercel/codec/encoder.ts +0 -410
  219. package/src/vercel/react/use-staged-add-tool-approval-response.ts +0 -87
  220. package/src/vercel/tool-approvals.ts +0 -380
  221. package/src/vercel/tool-events.ts +0 -53
@@ -1,10 +1,10 @@
1
1
  /**
2
- * Vercel chat transport: wraps a core ClientTransport to satisfy the
2
+ * Vercel chat transport: wraps a core ClientSession to satisfy the
3
3
  * ChatTransport interface that useChat expects.
4
4
  *
5
- * This is a thin adapter — the real logic lives in the core transport.
5
+ * This is a thin adapter — the real logic lives in the core client session.
6
6
  * The chat transport maps Vercel's sendMessages/reconnectToStream contract
7
- * to the core transport's send/cancel methods.
7
+ * to the core session's send/cancel methods.
8
8
  *
9
9
  * useChat manages message state before calling sendMessages:
10
10
  * - submit-message (new): appends the new user message, passes the full array
@@ -12,20 +12,35 @@
12
12
  * passes the truncated array with messageId set
13
13
  * - regenerate-message: truncates after the target, passes the truncated array
14
14
  *
15
- * The adapter uses `trigger` to determine the history/messages split:
16
- * - submit-message: last message is new (publish to channel), rest is history
15
+ * The adapter uses `(trigger, last-message role)` to determine the
16
+ * history/messages split:
17
+ * - submit-message + last message is a user message: that last message is new
18
+ * (publish to channel), rest is history. A new submit and an edit both take
19
+ * this path — an edit just carries a messageId.
20
+ * - submit-message + last message is an assistant already in the tree
21
+ * (continuation): no new messages, entire array is history
17
22
  * - regenerate-message: no new messages, entire array is history
18
23
  *
19
- * When messageId is set (edit or regeneration), the adapter computes fork
20
- * metadata (forkOf/parent) from the conversation tree so the server can
21
- * place the response on the correct branch.
24
+ * For an edit (submit-message with messageId) and for forking off an
25
+ * unresolved tool call, the adapter computes fork metadata (forkOf/parent)
26
+ * from the conversation tree so the server can place the response on the
27
+ * correct branch. Regeneration fork metadata is NOT computed here —
28
+ * `View.regenerate` derives forkOf/parent from the tree itself.
22
29
  */
23
30
 
24
31
  import * as Ably from 'ably';
25
32
  import type * as AI from 'ai';
26
33
 
27
- import type { ClientTransport, CloseOptions, SendOptions } from '../../core/transport/types.js';
34
+ import type { CodecMessage } from '../../core/codec/index.js';
35
+ import type { ActiveRun, ClientSession, SendOptions } from '../../core/transport/types.js';
28
36
  import { ErrorCode } from '../../errors.js';
37
+ import { EventEmitter } from '../../event-emitter.js';
38
+ import { LogLevel, makeLogger } from '../../logger.js';
39
+ import { errorCause, errorMessage } from '../../utils.js';
40
+ import type { VercelInput, VercelOutput, VercelProjection } from '../codec/index.js';
41
+ import { UIMessageCodec } from '../codec/index.js';
42
+ import { isToolPart, type ToolPart } from '../tool-part.js';
43
+ import { createRunOutputStream } from './run-output-stream.js';
29
44
 
30
45
  // ---------------------------------------------------------------------------
31
46
  // ChatTransport options
@@ -49,24 +64,36 @@ export interface SendMessagesRequestContext {
49
64
  messageId?: string;
50
65
  /** Previous messages in the conversation (context for the LLM). */
51
66
  history: AI.UIMessage[];
52
- /** The new message(s) being sent (to publish to the channel). Empty for regeneration. */
67
+ /** The new message(s) being sent (to publish to the channel). Empty for regeneration and for continuations (an auto-submit where the last message is an already-tracked assistant). */
53
68
  messages: AI.UIMessage[];
54
- /** The msg-id of the message being forked (regenerated or edited). */
69
+ /** The codec-message-id of the message being forked the edited user message, or the preceding assistant when forking off an unresolved tool call. Undefined for regeneration (View.regenerate derives it) and fresh sends. */
55
70
  forkOf?: string;
56
- /** The msg-id of the predecessor in the conversation thread. */
71
+ /** The codec-message-id of the predecessor in the conversation thread. */
57
72
  parent?: string;
58
73
  }
59
74
 
75
+ /** Default agent endpoint the transport POSTs invocations to — mirrors Vercel's DefaultChatTransport. */
76
+ const DEFAULT_VERCEL_API = '/api/chat';
77
+
60
78
  /** Options for customizing the ChatTransport behavior. */
61
79
  export interface ChatTransportOptions {
62
80
  /**
63
- * Customize the POST body before sending. Called by sendMessages()
64
- * with the conversation context. Return the body and headers for
65
- * the HTTP POST.
66
- *
67
- * Default: sends all previous messages as `history` in the body.
81
+ * Endpoint the transport POSTs the invocation pointer to, to wake the
82
+ * agent. Mirrors useChat's request-driven contract. Default `/api/chat`.
83
+ */
84
+ api?: string;
85
+ /** Fetch credentials mode for the invocation POST (e.g. `'include'`). */
86
+ credentials?: RequestCredentials;
87
+ /** Custom fetch implementation for the invocation POST. Defaults to `globalThis.fetch`. */
88
+ fetch?: typeof globalThis.fetch;
89
+ /**
90
+ * Customize the invocation POST before sending. Called by sendMessages()
91
+ * with the conversation context; the returned `body` is merged into the
92
+ * POST body (the run's invocation identifiers always take precedence) and
93
+ * `headers` are added to the request. Use it for auth headers or extra
94
+ * agent metadata.
68
95
  * @param context - The conversation context for the current request.
69
- * @returns The body and headers to use for the HTTP POST.
96
+ * @returns The body and headers to merge into the invocation POST.
70
97
  */
71
98
  prepareSendMessagesRequest?: (context: SendMessagesRequestContext) => {
72
99
  body?: Record<string, unknown>;
@@ -131,9 +158,9 @@ export interface ChatTransport {
131
158
  ) => Promise<ReadableStream<AI.UIMessageChunk> | null>;
132
159
 
133
160
  /** Close the underlying transport, releasing all resources. */
134
- close(options?: CloseOptions): Promise<void>;
161
+ close(): Promise<void>;
135
162
 
136
- /** Whether an own-turn stream is currently being consumed by useChat. */
163
+ /** Whether an own-run stream is currently being consumed by useChat. */
137
164
  readonly streaming: boolean;
138
165
 
139
166
  /**
@@ -153,11 +180,17 @@ export interface ChatTransport {
153
180
  /**
154
181
  * Wrap a ReadableStream in a passthrough TransformStream that resolves a
155
182
  * promise when the stream completes or errors. The returned stream passes
156
- * all chunks through unchanged.
183
+ * all chunks through unchanged, and `fail(reason)` errors the readable side
184
+ * useChat consumes without cancelling or otherwise disturbing the source run
185
+ * stream (used when the agent-invocation POST fails).
157
186
  * @param source - The original stream to wrap.
158
- * @returns The wrapped stream and a `done` promise that resolves when the stream closes.
187
+ * @returns The wrapped stream, a `done` promise that resolves when the stream
188
+ * closes, and a `fail` callback that errors the wrapped stream.
159
189
  */
160
- const wrapStreamWithDone = <T>(source: ReadableStream<T>): { stream: ReadableStream<T>; done: Promise<void> } => {
190
+
191
+ const wrapStreamWithDone = <T>(
192
+ source: ReadableStream<T>,
193
+ ): { stream: ReadableStream<T>; done: Promise<void>; fail: (reason: Ably.ErrorInfo) => void } => {
161
194
  let resolveDone: () => void;
162
195
  const done = new Promise<void>((resolve) => {
163
196
  resolveDone = resolve;
@@ -169,15 +202,26 @@ const wrapStreamWithDone = <T>(source: ReadableStream<T>): { stream: ReadableStr
169
202
  },
170
203
  });
171
204
 
172
- // Pipe in the background. If the source errors or is cancelled, resolve
173
- // done so the serialization queue advances.
205
+ // Aborting this signal errors the destination (the readable useChat reads)
206
+ // with the abort reason. `preventCancel` keeps the source run stream intact
207
+ // so the tree/observers are unaffected — only the useChat-facing view fails.
208
+ const failController = new AbortController();
209
+
210
+ // Pipe in the background. If the source errors/cancels, or `fail()` aborts,
211
+ // resolve done so the serialization queue advances.
174
212
  // Fire-and-forget: the pipe runs independently; errors surface through
175
213
  // the readable side that useChat consumes.
176
- source.pipeTo(passthrough.writable).catch(() => {
214
+ source.pipeTo(passthrough.writable, { signal: failController.signal, preventCancel: true }).catch(() => {
177
215
  resolveDone();
178
216
  });
179
217
 
180
- return { stream: passthrough.readable, done };
218
+ return {
219
+ stream: passthrough.readable,
220
+ done,
221
+ fail: (reason: Ably.ErrorInfo) => {
222
+ failController.abort(reason);
223
+ },
224
+ };
181
225
  };
182
226
 
183
227
  // ---------------------------------------------------------------------------
@@ -190,7 +234,7 @@ const wrapStreamWithDone = <T>(source: ReadableStream<T>): { stream: ReadableStr
190
234
  * - `input-streaming` / `input-available` — tool call emitted, not yet run.
191
235
  * - `approval-requested` — waiting for the user.
192
236
  *
193
- * Excludes `approval-responded` (streamText will run the tool this turn)
237
+ * Excludes `approval-responded` (streamText will run the tool this run)
194
238
  * and all terminal `output-*` states.
195
239
  * @param msg - The UIMessage to inspect.
196
240
  * @returns True when a fork-on-send is warranted to avoid shipping a
@@ -200,55 +244,197 @@ const hasUnresolvedToolCall = (msg: AI.UIMessage): boolean =>
200
244
  msg.role === 'assistant' &&
201
245
  msg.parts.some(
202
246
  (p) =>
203
- p.type === 'dynamic-tool' &&
247
+ isToolPart(p) &&
204
248
  (p.state === 'input-streaming' || p.state === 'input-available' || p.state === 'approval-requested'),
205
249
  );
206
250
 
251
+ /**
252
+ * `dynamic-tool` part states that mean "the LLM produced a tool call and
253
+ * is waiting on it". Used to detect new client-side resolutions in the
254
+ * useChat overlay relative to the tree.
255
+ */
256
+ const UNRESOLVED_TOOL_STATES = new Set(['input-streaming', 'input-available', 'approval-requested']);
257
+
258
+ /**
259
+ * Walk the useChat message overlay against the session tree and synthesize
260
+ * the {@link VercelInput}s needed to resolve every `dynamic-tool` part the
261
+ * user acted on (executed a tool, approved, denied) but the tree's reduced
262
+ * state hasn't reflected yet.
263
+ *
264
+ * Each input carries the prior assistant's tree codec-message-id (the one
265
+ * holding the original `dynamic-tool` part the resolution targets) in its
266
+ * `codecMessageId` field, so the encoder stamps `codec-message-id`
267
+ * and the reducer's direct-fold path lands the resolution on that assistant
268
+ * in one step — no cross-message redirect-by-toolCallId fallback. Every
269
+ * variant rides the `ai-input` wire, matching its publisher (client → input).
270
+ *
271
+ * The resulting inputs are passed alongside the continuation `view.send`
272
+ * so the channel publish and the continuation POST land as ONE atomic
273
+ * operation — the agent's `loadConversation()` history walk is guaranteed
274
+ * to see them because the channel publish happens before the POST inside
275
+ * `_internalSend`.
276
+ *
277
+ * Three resolutions are produced:
278
+ *
279
+ * - `approval-responded` overlay vs `approval-requested` tree →
280
+ * `tool-approval-response` carrying the user's decision
281
+ * (`approved` = `overlayPart.approval.approved`, i.e. approve or deny)
282
+ * - `output-available` overlay vs unresolved tree → `tool-result`
283
+ * - `output-error` overlay vs unresolved tree → `tool-result-error`
284
+ * @param codecMessages - The visible tree messages paired with their codec-message-ids.
285
+ * @param messages - useChat's local overlay messages.
286
+ * @returns The continuation inputs to publish, in tree order. Each input
287
+ * carries its own `codecMessageId` targeting the prior assistant it folds
288
+ * onto.
289
+ */
290
+ const deriveContinuationInputs = (
291
+ codecMessages: CodecMessage<AI.UIMessage>[],
292
+ messages: AI.UIMessage[],
293
+ ): VercelInput[] => {
294
+ const inputs: VercelInput[] = [];
295
+ for (const overlay of messages) {
296
+ if (overlay.role !== 'assistant') continue;
297
+ // Match the overlay to its tree message by domain id (both sides
298
+ // reconstruct the same stream id), but address the emitted inputs by
299
+ // the tree message's codec-message-id — the agent folds tool
300
+ // resolutions onto the assistant by codec-message-id, never by the
301
+ // domain `message.id`.
302
+ const treeEntry = codecMessages.find((p) => p.message.id === overlay.id);
303
+ if (!treeEntry) continue;
304
+ const { codecMessageId, message: treeMessage } = treeEntry;
305
+
306
+ for (const overlayPart of overlay.parts) {
307
+ if (!isToolPart(overlayPart)) continue;
308
+ // The codec normalises every tool part to `dynamic-tool`, but the
309
+ // AI SDK's useChat overlay emits `tool-${name}` parts for statically
310
+ // declared tools. Match by toolCallId rather than the type prefix
311
+ // so the cross-representation comparison works regardless of which
312
+ // side the tool was declared on.
313
+ const treePart = treeMessage.parts.find(
314
+ (p: AI.UIMessage['parts'][number]): p is ToolPart => isToolPart(p) && p.toolCallId === overlayPart.toolCallId,
315
+ );
316
+
317
+ // Approval response: useChat's `addToolApprovalResponse` flipped the
318
+ // overlay part to `approval-responded` while the tree still sits on
319
+ // `approval-requested`. Publish a `tool-approval-response` TInput so the
320
+ // agent's projection sees the decision.
321
+ if (overlayPart.state === 'approval-responded' && (!treePart || treePart.state === 'approval-requested')) {
322
+ inputs.push(
323
+ UIMessageCodec.createToolApprovalResponse(codecMessageId, {
324
+ toolCallId: overlayPart.toolCallId,
325
+ approved: overlayPart.approval.approved,
326
+ ...(overlayPart.approval.reason === undefined ? {} : { reason: overlayPart.approval.reason }),
327
+ }),
328
+ );
329
+ continue;
330
+ }
331
+
332
+ // Client-tool resolution: overlay has `output-available` / `output-error`
333
+ // while the tree's part is still unresolved. Construct a TInput
334
+ // variant (not a UIMessageChunk) so the encoder publishes on the
335
+ // `ai-input` wire — client tool results belong on `ai-input`, matching
336
+ // their client publisher, not on `ai-output`.
337
+ if (overlayPart.state !== 'output-available' && overlayPart.state !== 'output-error') continue;
338
+ // Tree already resolved (echo arrived back) — nothing to do.
339
+ if (treePart && !UNRESOLVED_TOOL_STATES.has(treePart.state)) continue;
340
+
341
+ if (overlayPart.state === 'output-available') {
342
+ inputs.push(
343
+ UIMessageCodec.createToolResult(codecMessageId, {
344
+ toolCallId: overlayPart.toolCallId,
345
+ output: overlayPart.output,
346
+ }),
347
+ );
348
+ } else {
349
+ inputs.push(
350
+ UIMessageCodec.createToolResultError(codecMessageId, {
351
+ toolCallId: overlayPart.toolCallId,
352
+ message: overlayPart.errorText,
353
+ }),
354
+ );
355
+ }
356
+ }
357
+ }
358
+ return inputs;
359
+ };
360
+
361
+ /**
362
+ * Find the codec-message-id immediately preceding the message identified by
363
+ * domain id `domainId` in the flat visible conversation. The target is
364
+ * located by its domain `message.id` (the id useChat references), but the
365
+ * returned value is the predecessor's codec-message-id — never a domain id.
366
+ * Returns undefined if the target is the first message or not found.
367
+ * @param codecMessages - Visible messages paired with their codec-message-ids.
368
+ * @param domainId - The domain id of the target message.
369
+ * @returns The predecessor's codec-message-id, or undefined.
370
+ */
371
+ const findPredecessorCodecId = (codecMessages: CodecMessage<AI.UIMessage>[], domainId: string): string | undefined => {
372
+ const idx = codecMessages.findIndex((p) => p.message.id === domainId);
373
+ if (idx <= 0) return undefined;
374
+ return codecMessages[idx - 1]?.codecMessageId;
375
+ };
376
+
207
377
  // ---------------------------------------------------------------------------
208
378
  // Factory
209
379
  // ---------------------------------------------------------------------------
210
380
 
381
+ /** Internal EventEmitter events map backing the transport's streaming state. */
382
+ interface ChatTransportEventsMap {
383
+ /** Fired on every streaming-state transition with the new value. */
384
+ streaming: boolean;
385
+ }
386
+
211
387
  /**
212
- * Create a Vercel ChatTransport from a core ClientTransport.
388
+ * Create a Vercel ChatTransport from a core ClientSession.
213
389
  *
214
390
  * Exposes a `streaming` flag and `onStreamingChange` callback so that
215
- * `useMessageSync` can gate `setMessages` calls during active own-turn
391
+ * `useMessageSync` can gate `setMessages` calls during active own-run
216
392
  * streams, preventing the push/replace ID mismatch in useChat's `write()`.
217
393
  *
218
394
  * Note: concurrent `sendMessage` calls from the same user are a useChat
219
395
  * limitation that cannot be fixed from the transport layer. The
220
396
  * developer must respect useChat's `status` and only call `sendMessage`
221
397
  * when status is `'ready'`.
222
- * @param transport - The core client transport to wrap.
398
+ * @param session - The core client session to wrap.
223
399
  * @param chatOptions - Optional hooks for customizing request construction.
224
400
  * @returns A {@link ChatTransport} compatible with Vercel's useChat hook.
225
401
  */
226
402
  export const createChatTransport = (
227
- transport: ClientTransport<AI.UIMessageChunk, AI.UIMessage>,
403
+ session: ClientSession<VercelInput, VercelOutput, VercelProjection, AI.UIMessage>,
228
404
  chatOptions?: ChatTransportOptions,
229
405
  ): ChatTransport => {
406
+ // -- Invocation POST config (the transport owns waking the agent) ----------
407
+ const api = chatOptions?.api ?? DEFAULT_VERCEL_API;
408
+ const fetchFn = chatOptions?.fetch ?? globalThis.fetch.bind(globalThis);
409
+ const credentials = chatOptions?.credentials;
410
+
230
411
  // -- Streaming state -------------------------------------------------------
412
+ // Backed by the shared EventEmitter for listener error isolation (one bad
413
+ // onStreamingChange handler can't prevent others from firing or block the
414
+ // state transition) and uniform emitter behaviour across the SDK. The
415
+ // factory takes no logger, so a silent one is used — listener exceptions are
416
+ // swallowed by the emitter rather than surfaced.
231
417
  let _streaming = false;
232
- const streamingCallbacks = new Set<(streaming: boolean) => void>();
418
+ const emitter = new EventEmitter<ChatTransportEventsMap>(makeLogger({ logLevel: LogLevel.Silent }));
233
419
 
234
420
  const setStreaming = (value: boolean): void => {
235
421
  _streaming = value;
236
- for (const cb of streamingCallbacks) {
237
- try {
238
- cb(value);
239
- } catch {
240
- // Isolate subscriber errors so one bad handler doesn't prevent
241
- // other subscribers from being notified or block the streaming
242
- // state transition.
243
- }
244
- }
422
+ emitter.emit('streaming', value);
245
423
  };
246
424
 
247
425
  // -- sendMessages implementation -------------------------------------------
248
426
 
249
427
  const sendMessages: ChatTransport['sendMessages'] = async (opts) => {
250
428
  const { messages, abortSignal, trigger, messageId } = opts;
251
- const allNodes = transport.view.flattenNodes();
429
+
430
+ // The visible messages paired with their codec-message-ids. useChat
431
+ // references messages by their domain `message.id`; we match on that to
432
+ // locate a message in the tree, then route every transport operation by
433
+ // the message's codec-message-id (the SDK never correlates on the domain
434
+ // id, which may differ from the codec-message-id).
435
+ const codecMessages = session.view.getMessages();
436
+ const codecIdByDomainId = new Map(codecMessages.map((m) => [m.message.id, m.codecMessageId]));
437
+ const codecIdOf = (domainId: string): string | undefined => codecIdByDomainId.get(domainId);
252
438
 
253
439
  // useChat calls sendMessages in three distinct modes. We disambiguate
254
440
  // by (trigger, last-message role) so each mode dispatches correctly:
@@ -265,27 +451,29 @@ export const createChatTransport = (
265
451
  // Continuation mode must NOT publish the assistant as a new message or
266
452
  // treat messageId as a fork target — useChat v6's sendAutomaticallyWhen
267
453
  // path always sets messageId to the last message id regardless.
268
- //
269
- // Client-side tool outputs are expected to be staged on the transport
270
- // via transport.stageEvents() before this runs; the core transport
271
- // flushes staged events into the POST body automatically.
272
454
  const lastMessage = messages.at(-1);
273
- const lastMessageNode = lastMessage ? allNodes.find((n) => n.message.id === lastMessage.id) : undefined;
274
- const isContinuation = trigger === 'submit-message' && lastMessage?.role === 'assistant' && !!lastMessageNode;
455
+ const lastMessageInTree = !!lastMessage && codecIdByDomainId.has(lastMessage.id);
456
+ const isContinuation = trigger === 'submit-message' && lastMessage?.role === 'assistant' && lastMessageInTree;
275
457
 
276
458
  // Fork-on-unresolved-tool: user sent a new message while the preceding
277
459
  // assistant has an unresolved tool call (approval-requested, input-*).
278
460
  // Fork the new message off the preceding assistant so the unresolved
279
- // tool call stays dormant on a sibling branch. Inference this turn runs
461
+ // tool call stays dormant on a sibling branch. Inference for this run runs
280
462
  // on the clean fork — the LLM never sees the dangling tool_use.
281
463
  //
282
464
  // Only applies to fresh user-message submits (not continuations, not
283
465
  // regenerates, not edits-with-messageId).
466
+ //
467
+ // `messages.at(-1)` is the fresh user-prompt being submitted right now;
468
+ // `messages.at(-2)` is therefore the prior assistant whose tool state
469
+ // we need to inspect for the unresolved-tool gate below.
284
470
  const precedingMessage =
285
471
  trigger === 'submit-message' && !messageId && lastMessage?.role === 'user' ? messages.at(-2) : undefined;
286
- const forkSource =
287
- precedingMessage && hasUnresolvedToolCall(precedingMessage)
288
- ? allNodes.find((n) => n.message.id === precedingMessage.id)
472
+ // The domain id of the preceding assistant when it carries an unresolved
473
+ // tool call and is present in the tree — the new user message forks off it.
474
+ const forkSourceDomainId =
475
+ precedingMessage && hasUnresolvedToolCall(precedingMessage) && codecIdByDomainId.has(precedingMessage.id)
476
+ ? precedingMessage.id
289
477
  : undefined;
290
478
 
291
479
  // Determine the history/messages split based on mode.
@@ -309,35 +497,27 @@ export const createChatTransport = (
309
497
  // When forking off an unresolved tool call, drop the unresolved
310
498
  // assistant from history too — it belongs on the sibling branch, not
311
499
  // the ancestor chain of the new message.
312
- history = forkSource ? messages.slice(0, -2) : messages.slice(0, -1);
500
+ history = forkSourceDomainId ? messages.slice(0, -2) : messages.slice(0, -1);
313
501
  }
314
502
 
315
- // Compute fork metadata. Only set in regenerate or edit modes — in
316
- // continuation mode we do NOT fork, we continue the branch.
503
+ // Compute fork metadata for edit (submit-message with messageId) and
504
+ // fork-on-unresolved-tool. Regenerate is NOT precomputed here
505
+ // `View.regenerate` derives forkOf/parent from the tree itself and
506
+ // overrides anything we'd set.
317
507
  let forkOf: string | undefined;
318
508
  let parent: string | undefined;
319
509
 
320
- if (messageId && !isContinuation) {
321
- // Regeneration: messageId = assistant to regenerate.
322
- // Edit (submit-message with user message and messageId): messageId = user being replaced.
323
- // In both cases forkOf = the x-ably-msg-id, parent = that message's parent.
324
- forkOf = messageId;
325
- const node = allNodes.find((n) => n.message.id === messageId);
326
- if (node) {
327
- forkOf = node.msgId;
328
- parent = node.parentId;
329
- }
330
- } else if (isContinuation) {
331
- // Continuation: the server's next assistant message is a child of the
332
- // last assistant (no fork). Pass parent so the server places the new
333
- // message correctly in the tree. isContinuation narrows lastMessageNode
334
- // to defined.
335
- parent = lastMessageNode.msgId;
336
- } else if (forkSource) {
510
+ if (trigger === 'submit-message' && messageId && !isContinuation) {
511
+ // Edit: messageId is the domain id of the user message being replaced.
512
+ // forkOf = its codec-message-id, parent = the immediately-preceding
513
+ // codec-message-id in the flat conversation.
514
+ forkOf = codecIdOf(messageId);
515
+ parent = findPredecessorCodecId(codecMessages, messageId);
516
+ } else if (forkSourceDomainId) {
337
517
  // Fork off the preceding assistant — the new user message becomes a
338
518
  // sibling of the unresolved tool call assistant, rooted at its parent.
339
- forkOf = forkSource.msgId;
340
- parent = forkSource.parentId;
519
+ forkOf = codecIdOf(forkSourceDomainId);
520
+ parent = findPredecessorCodecId(codecMessages, forkSourceDomainId);
341
521
  }
342
522
 
343
523
  let sendBody: Record<string, unknown>;
@@ -356,38 +536,102 @@ export const createChatTransport = (
356
536
  sendBody = prepared.body ?? {};
357
537
  sendHeaders = prepared.headers;
358
538
  } else {
359
- const historyIds = new Set(history.map((m) => m.id));
360
- const historyNodes = allNodes.filter((n) => historyIds.has(n.message.id));
361
- sendBody = {
362
- history: historyNodes,
363
- chatId: opts.chatId,
364
- trigger,
365
- ...(messageId !== undefined && { messageId }),
366
- ...(forkOf !== undefined && { forkOf }),
367
- ...(parent !== undefined && { parent }),
368
- };
539
+ sendBody = {};
369
540
  sendHeaders = undefined;
370
541
  }
371
542
 
372
- const sendOpts: SendOptions = { body: sendBody, headers: sendHeaders };
543
+ const sendOpts: SendOptions = {};
373
544
  if (forkOf !== undefined) sendOpts.forkOf = forkOf;
374
545
  if (parent !== undefined) sendOpts.parent = parent;
546
+ // Continuations reuse the suspended assistant's runId so the agent's
547
+ // existing run resumes under a fresh invocation rather than spinning
548
+ // up a brand-new run. `isContinuation` implies `lastMessage` is defined.
549
+ if (isContinuation) {
550
+ // `isContinuation` implies `lastMessage` is defined (it gates on
551
+ // `lastMessage?.role`). Route the runId lookup by codec-message-id.
552
+ const codecId = codecIdOf(lastMessage.id);
553
+ const run = codecId === undefined ? undefined : session.view.runOf(codecId);
554
+ if (run) sendOpts.runId = run.runId;
555
+ }
375
556
 
376
- // A single dispatch path: view.send with the (possibly empty)
377
- // newMessages array. Any events staged via transport.stageEvents()
378
- // flow automatically through _internalSend into the POST body.
379
- const turn = await transport.view.send(newMessages, sendOpts);
557
+ // Dispatch by mode:
558
+ //
559
+ // - Continuation: derive tool-resolution events from useChat's overlay
560
+ // vs the tree and pair each with the prior assistant's tree codec-message-id —
561
+ // the SDK stamps the wire's `codec-message-id` to that id so the
562
+ // reducer's direct fold path runs (no redirect, no consume).
563
+ // - Regenerate: route through `view.regenerate`. The View mints a
564
+ // wire-only regenerate event (`ait-regenerate`) carrying
565
+ // `forkOf=A1` / `parent=U1` on transport headers. U1 is NOT
566
+ // republished — A1 and A2 group as tree siblings under U1 via the
567
+ // existing forkOf machinery. The LLM receives the truncated history
568
+ // through U1 inclusive via the body.
569
+ // - Fresh send / edit: publish the new user-message input(s) via
570
+ // `view.send`.
571
+ let run: ActiveRun;
572
+ if (isContinuation) {
573
+ const inputs = deriveContinuationInputs(codecMessages, messages);
574
+ run = await session.view.send(inputs, sendOpts);
575
+ } else if (trigger === 'regenerate-message') {
576
+ if (messageId === undefined) {
577
+ throw new Ably.ErrorInfo(
578
+ 'unable to regenerate; regenerate-message trigger fired without messageId',
579
+ ErrorCode.InvalidArgument,
580
+ 400,
581
+ );
582
+ }
583
+ // useChat passes the assistant's domain id; route by its codec-message-id.
584
+ const regenCodecId = codecIdOf(messageId);
585
+ if (regenCodecId === undefined) {
586
+ throw new Ably.ErrorInfo(
587
+ `unable to regenerate; message not visible: ${messageId}`,
588
+ ErrorCode.InvalidArgument,
589
+ 400,
590
+ );
591
+ }
592
+ run = await session.view.regenerate(regenCodecId, sendOpts);
593
+ } else {
594
+ const inputs = newMessages.map((m) => UIMessageCodec.createUserMessage(m));
595
+ run = await session.view.send(inputs, sendOpts);
596
+ }
597
+
598
+ // Build the consumer-facing stream from the Tree's events for this run.
599
+ // Streaming is a useChat concern owned by the Vercel layer; the core
600
+ // session exposes no per-run stream. Key it on
601
+ // `run.inputCodecMessageId` — the triggering input's codec-message-id, which
602
+ // the client owns from send time and the agent echoes as
603
+ // `input-codec-message-id`. The agent mints the runId, supplied as
604
+ // `run.runId` (a promise) for the run-end safety-net.
605
+ const runStream = createRunOutputStream(session, run.runId, run.inputCodecMessageId);
380
606
 
381
607
  if (abortSignal) {
382
- abortSignal.addEventListener('abort', () => void transport.cancel({ all: true }), {
383
- once: true,
384
- });
608
+ const onAbort = (): void => {
609
+ // Best-effort cancel via the run handle (knows its own key / runId);
610
+ // the core resolves the runId once the agent mints it.
611
+ void run.cancel();
612
+ // Close the consumer stream immediately so useChat's reader ends
613
+ // without waiting for the agent's run-end round-trip.
614
+ runStream.close();
615
+ };
616
+ // useChat sets `status: 'submitted'` synchronously inside `makeRequest`
617
+ // BEFORE awaiting `transport.sendMessages`. That immediately enables
618
+ // the Stop button in the UI. If the user clicks Stop while
619
+ // `session.view.send` is still awaiting the run-start ack (which
620
+ // can take seconds for a real LLM), useChat aborts the signal before
621
+ // we ever get here. `addEventListener('abort', ...)` does not fire
622
+ // for an already-aborted signal, so we'd silently lose the cancel
623
+ // and the agent would keep streaming.
624
+ if (abortSignal.aborted) {
625
+ onAbort();
626
+ } else {
627
+ abortSignal.addEventListener('abort', onAbort, { once: true });
628
+ }
385
629
  }
386
630
 
387
631
  // Wrap the stream to detect completion. The streaming flag gates
388
632
  // useMessageSync so that setMessages doesn't interfere with
389
633
  // useChat's internal write() during active streams.
390
- const { stream, done } = wrapStreamWithDone(turn.stream);
634
+ const { stream, done, fail } = wrapStreamWithDone(runStream.stream);
391
635
  setStreaming(true);
392
636
 
393
637
  // Fire-and-forget: clear the streaming flag when the stream ends.
@@ -395,6 +639,44 @@ export const createChatTransport = (
395
639
  setStreaming(false);
396
640
  });
397
641
 
642
+ // Wake the agent: POST the invocation pointer to the configured endpoint.
643
+ // useChat's transport contract is request-driven, so the transport owns
644
+ // this POST (the core session is HTTP-free). Fire-and-forget — `await`
645
+ // would delay the stream return, and the agent's response arrives over
646
+ // the Ably channel, not the HTTP response. The run's invocation
647
+ // identifiers always win over any custom body so the agent can parse it
648
+ // via Invocation.fromJSON. A failed POST means the agent never woke, so
649
+ // error the useChat-facing stream; the core run and observers are
650
+ // untouched.
651
+ const postBody = { ...sendBody, ...run.toInvocation().toJSON() };
652
+ fetchFn(api, {
653
+ method: 'POST',
654
+ headers: { 'Content-Type': 'application/json', ...sendHeaders },
655
+ body: JSON.stringify(postBody),
656
+ ...(credentials ? { credentials } : {}),
657
+ })
658
+ .then((response) => {
659
+ if (!response.ok) {
660
+ fail(
661
+ new Ably.ErrorInfo(
662
+ `unable to send; HTTP POST to ${api} returned ${String(response.status)} ${response.statusText}`,
663
+ ErrorCode.SessionSendFailed,
664
+ response.status,
665
+ ),
666
+ );
667
+ }
668
+ })
669
+ .catch((error: unknown) => {
670
+ fail(
671
+ new Ably.ErrorInfo(
672
+ `unable to send; HTTP POST to ${api} failed: ${errorMessage(error)}`,
673
+ ErrorCode.SessionSendFailed,
674
+ 500,
675
+ errorCause(error),
676
+ ),
677
+ );
678
+ });
679
+
398
680
  return stream;
399
681
  };
400
682
 
@@ -408,16 +690,16 @@ export const createChatTransport = (
408
690
  // eslint-disable-next-line unicorn/no-null, @typescript-eslint/promise-function-async -- null is required by the AI SDK ChatTransport contract; no await needed
409
691
  reconnectToStream: () => Promise.resolve(null),
410
692
 
411
- close: async (options?: CloseOptions) => transport.close(options),
693
+ close: async () => session.close(),
412
694
 
413
695
  get streaming(): boolean {
414
696
  return _streaming;
415
697
  },
416
698
 
417
699
  onStreamingChange: (callback: (streaming: boolean) => void): (() => void) => {
418
- streamingCallbacks.add(callback);
700
+ emitter.on('streaming', callback);
419
701
  return () => {
420
- streamingCallbacks.delete(callback);
702
+ emitter.off('streaming', callback);
421
703
  };
422
704
  },
423
705
  };