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