@ably/ai-transport 0.0.1 → 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 (167) hide show
  1. package/README.md +114 -116
  2. package/dist/ably-ai-transport.js +1743 -961
  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 +117 -39
  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 +410 -101
  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 +97 -17
  18. package/dist/core/transport/index.d.ts +5 -3
  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 -8
  24. package/dist/core/transport/run-manager.d.ts +78 -0
  25. package/dist/core/transport/tree.d.ts +435 -0
  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 -402
  32. package/dist/core/transport/view.d.ts +354 -0
  33. package/dist/errors.d.ts +37 -9
  34. package/dist/index.d.ts +6 -6
  35. package/dist/logger.d.ts +12 -0
  36. package/dist/react/ably-ai-transport-react.js +1164 -645
  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 +16 -10
  44. package/dist/react/internal/use-resolved-session.d.ts +36 -0
  45. package/dist/react/use-ably-messages.d.ts +20 -11
  46. package/dist/react/use-client-session.d.ts +81 -0
  47. package/dist/react/use-create-view.d.ts +23 -0
  48. package/dist/react/use-tree.d.ts +35 -0
  49. package/dist/react/use-view.d.ts +110 -0
  50. package/dist/utils.d.ts +32 -23
  51. package/dist/vercel/ably-ai-transport-vercel.js +2748 -1625
  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 +50 -0
  61. package/dist/vercel/index.d.ts +4 -2
  62. package/dist/vercel/react/ably-ai-transport-vercel-react.js +10298 -1410
  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 +70 -1
  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 +33 -0
  67. package/dist/vercel/react/contexts/chat-transport-provider.d.ts +96 -0
  68. package/dist/vercel/react/index.d.ts +4 -0
  69. package/dist/vercel/react/use-chat-transport.d.ts +66 -21
  70. package/dist/vercel/react/use-message-sync.d.ts +31 -12
  71. package/dist/vercel/run-end-reason.d.ts +29 -0
  72. package/dist/vercel/transport/chat-transport.d.ts +71 -30
  73. package/dist/vercel/transport/index.d.ts +25 -18
  74. package/dist/vercel/transport/run-output-stream.d.ts +56 -0
  75. package/dist/version.d.ts +2 -0
  76. package/package.json +47 -34
  77. package/src/constants.ts +126 -47
  78. package/src/core/agent.ts +68 -0
  79. package/src/core/codec/decoder.ts +71 -98
  80. package/src/core/codec/encoder.ts +115 -58
  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 +438 -106
  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 +182 -19
  89. package/src/core/transport/index.ts +29 -22
  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 +58 -40
  95. package/src/core/transport/run-manager.ts +249 -0
  96. package/src/core/transport/tree.ts +1167 -0
  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 -527
  103. package/src/core/transport/view.ts +1271 -0
  104. package/src/errors.ts +42 -9
  105. package/src/event-emitter.ts +3 -2
  106. package/src/index.ts +55 -39
  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 +27 -10
  112. package/src/react/internal/use-resolved-session.ts +63 -0
  113. package/src/react/use-ably-messages.ts +47 -19
  114. package/src/react/use-client-session.ts +201 -0
  115. package/src/react/use-create-view.ts +72 -0
  116. package/src/react/use-tree.ts +84 -0
  117. package/src/react/use-view.ts +275 -0
  118. package/src/react/vite.config.ts +4 -1
  119. package/src/utils.ts +63 -45
  120. package/src/vercel/codec/decoder.ts +336 -255
  121. package/src/vercel/codec/encoder.ts +348 -196
  122. package/src/vercel/codec/events.ts +87 -0
  123. package/src/vercel/codec/index.ts +59 -14
  124. package/src/vercel/codec/reducer.ts +977 -0
  125. package/src/vercel/codec/tool-transitions.ts +122 -0
  126. package/src/vercel/index.ts +7 -3
  127. package/src/vercel/react/contexts/chat-transport-context.ts +41 -0
  128. package/src/vercel/react/contexts/chat-transport-provider.tsx +150 -0
  129. package/src/vercel/react/index.ts +13 -1
  130. package/src/vercel/react/use-chat-transport.ts +162 -42
  131. package/src/vercel/react/use-message-sync.ts +121 -22
  132. package/src/vercel/react/vite.config.ts +4 -2
  133. package/src/vercel/run-end-reason.ts +78 -0
  134. package/src/vercel/transport/chat-transport.ts +553 -113
  135. package/src/vercel/transport/index.ts +40 -28
  136. package/src/vercel/transport/run-output-stream.ts +170 -0
  137. package/src/version.ts +2 -0
  138. package/dist/core/transport/client-transport.d.ts +0 -10
  139. package/dist/core/transport/conversation-tree.d.ts +0 -9
  140. package/dist/core/transport/decode-history.d.ts +0 -41
  141. package/dist/core/transport/server-transport.d.ts +0 -7
  142. package/dist/core/transport/stream-router.d.ts +0 -19
  143. package/dist/core/transport/turn-manager.d.ts +0 -34
  144. package/dist/react/use-active-turns.d.ts +0 -8
  145. package/dist/react/use-client-transport.d.ts +0 -7
  146. package/dist/react/use-conversation-tree.d.ts +0 -20
  147. package/dist/react/use-edit.d.ts +0 -7
  148. package/dist/react/use-history.d.ts +0 -19
  149. package/dist/react/use-messages.d.ts +0 -7
  150. package/dist/react/use-regenerate.d.ts +0 -7
  151. package/dist/react/use-send.d.ts +0 -7
  152. package/dist/vercel/codec/accumulator.d.ts +0 -21
  153. package/src/core/transport/client-transport.ts +0 -959
  154. package/src/core/transport/conversation-tree.ts +0 -434
  155. package/src/core/transport/decode-history.ts +0 -337
  156. package/src/core/transport/server-transport.ts +0 -458
  157. package/src/core/transport/stream-router.ts +0 -118
  158. package/src/core/transport/turn-manager.ts +0 -147
  159. package/src/react/use-active-turns.ts +0 -61
  160. package/src/react/use-client-transport.ts +0 -37
  161. package/src/react/use-conversation-tree.ts +0 -71
  162. package/src/react/use-edit.ts +0 -24
  163. package/src/react/use-history.ts +0 -111
  164. package/src/react/use-messages.ts +0 -32
  165. package/src/react/use-regenerate.ts +0 -24
  166. package/src/react/use-send.ts +0 -25
  167. package/src/vercel/codec/accumulator.ts +0 -603
@@ -1,25 +1,44 @@
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
- * - submit-message: appends the new user message, passes the full array
10
+ * - submit-message (new): appends the new user message, passes the full array
11
+ * - submit-message (edit): truncates after the edited message, replaces it,
12
+ * passes the truncated array with messageId set
11
13
  * - regenerate-message: truncates after the target, passes the truncated array
12
14
  *
13
- * The adapter uses `trigger` to determine the history/messages split:
14
- * - 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
15
22
  * - regenerate-message: no new messages, entire array is history
23
+ *
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.
16
29
  */
17
30
 
18
31
  import * as Ably from 'ably';
19
32
  import type * as AI from 'ai';
20
33
 
21
- 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';
22
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';
23
42
 
24
43
  // ---------------------------------------------------------------------------
25
44
  // ChatTransport options
@@ -31,34 +50,48 @@ import { ErrorCode } from '../../errors.js';
31
50
  */
32
51
  export interface SendMessagesRequestContext {
33
52
  /** Chat session ID (from useChat's id). */
34
- id?: string;
53
+ chatId?: string;
35
54
  /** What triggered the request: user sent a message, or requested regeneration. */
36
55
  trigger: 'submit-message' | 'regenerate-message';
37
56
  /**
38
- * The message ID for regeneration requests. Identifies which assistant
39
- * message to regenerate. Undefined for submit-message.
57
+ * The message ID for edit or regeneration requests. For regeneration,
58
+ * identifies the assistant message to regenerate. For edits (submit-message
59
+ * with messageId), identifies the user message being replaced. Undefined
60
+ * when submitting a new message.
40
61
  */
41
62
  messageId?: string;
42
63
  /** Previous messages in the conversation (context for the LLM). */
43
64
  history: AI.UIMessage[];
44
- /** 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). */
45
66
  messages: AI.UIMessage[];
46
- /** 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. */
47
68
  forkOf?: string;
48
- /** The msg-id of the predecessor in the conversation thread. */
49
- parent?: string | null;
69
+ /** The codec-message-id of the predecessor in the conversation thread. */
70
+ parent?: string;
50
71
  }
51
72
 
73
+ /** Default agent endpoint the transport POSTs invocations to — mirrors Vercel's DefaultChatTransport. */
74
+ export const DEFAULT_VERCEL_API = '/api/chat';
75
+
52
76
  /** Options for customizing the ChatTransport behavior. */
53
77
  export interface ChatTransportOptions {
54
78
  /**
55
- * Customize the POST body before sending. Called by sendMessages()
56
- * with the conversation context. Return the body and headers for
57
- * the HTTP POST.
58
- *
59
- * 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.
60
93
  * @param context - The conversation context for the current request.
61
- * @returns The body and headers to use for the HTTP POST.
94
+ * @returns The body and headers to merge into the invocation POST.
62
95
  */
63
96
  prepareSendMessagesRequest?: (context: SendMessagesRequestContext) => {
64
97
  body?: Record<string, unknown>;
@@ -91,7 +124,8 @@ interface ChatRequestOptions {
91
124
  *
92
125
  * Structurally compatible with the AI SDK's internal `ChatTransport<UIMessage>`
93
126
  * interface. Extended with `close()` for releasing the underlying Ably transport
94
- * resources.
127
+ * resources and `streaming` / `onStreamingChange` for coordinating with
128
+ * useMessageSync.
95
129
  */
96
130
  export interface ChatTransport {
97
131
  /** Send messages and return a streaming response of UIMessageChunk events. */
@@ -122,39 +156,342 @@ export interface ChatTransport {
122
156
  ) => Promise<ReadableStream<AI.UIMessageChunk> | null>;
123
157
 
124
158
  /** Close the underlying transport, releasing all resources. */
125
- close(options?: CloseOptions): Promise<void>;
159
+ close(): Promise<void>;
160
+
161
+ /** Whether an own-run stream is currently being consumed by useChat. */
162
+ readonly streaming: boolean;
163
+
164
+ /**
165
+ * Subscribe to streaming state changes. The callback fires when the
166
+ * ChatTransport transitions between streaming and idle. Used by
167
+ * useMessageSync to gate setMessages calls during active streams.
168
+ * @param callback - Called with `true` when a stream starts, `false` when it ends.
169
+ * @returns Unsubscribe function.
170
+ */
171
+ onStreamingChange(callback: (streaming: boolean) => void): () => void;
126
172
  }
127
173
 
174
+ // ---------------------------------------------------------------------------
175
+ // Stream wrapper — passthrough that signals completion via a promise
176
+ // ---------------------------------------------------------------------------
177
+
178
+ /**
179
+ * Wrap a ReadableStream in a passthrough TransformStream that resolves a
180
+ * promise when the stream completes or errors. The returned stream passes
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).
184
+ * @param source - The original stream to wrap.
185
+ * @returns The wrapped stream, a `done` promise that resolves when the stream
186
+ * closes, and a `fail` callback that errors the wrapped stream.
187
+ */
188
+
189
+ const wrapStreamWithDone = <T>(
190
+ source: ReadableStream<T>,
191
+ ): { stream: ReadableStream<T>; done: Promise<void>; fail: (reason: Ably.ErrorInfo) => void } => {
192
+ let resolveDone: () => void;
193
+ const done = new Promise<void>((resolve) => {
194
+ resolveDone = resolve;
195
+ });
196
+
197
+ const passthrough = new TransformStream<T, T>({
198
+ flush: () => {
199
+ resolveDone();
200
+ },
201
+ });
202
+
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.
210
+ // Fire-and-forget: the pipe runs independently; errors surface through
211
+ // the readable side that useChat consumes.
212
+ source.pipeTo(passthrough.writable, { signal: failController.signal, preventCancel: true }).catch(() => {
213
+ resolveDone();
214
+ });
215
+
216
+ return {
217
+ stream: passthrough.readable,
218
+ done,
219
+ fail: (reason: Ably.ErrorInfo) => {
220
+ failController.abort(reason);
221
+ },
222
+ };
223
+ };
224
+
225
+ // ---------------------------------------------------------------------------
226
+ // Unresolved tool call detection
227
+ // ---------------------------------------------------------------------------
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
+
241
+ /**
242
+ * Whether an assistant message has a `dynamic-tool` part that can't resolve
243
+ * without further user action. Matches:
244
+ * - `input-streaming` / `input-available` — tool call emitted, not yet run.
245
+ * - `approval-requested` — waiting for the user.
246
+ *
247
+ * Excludes `approval-responded` (streamText will run the tool this run)
248
+ * and all terminal `output-*` states.
249
+ * @param msg - The UIMessage to inspect.
250
+ * @returns True when a fork-on-send is warranted to avoid shipping a
251
+ * dangling tool call to the LLM.
252
+ */
253
+ const hasUnresolvedToolCall = (msg: AI.UIMessage): boolean =>
254
+ msg.role === 'assistant' &&
255
+ msg.parts.some(
256
+ (p) =>
257
+ _isToolPart(p) &&
258
+ (p.state === 'input-streaming' || p.state === 'input-available' || p.state === 'approval-requested'),
259
+ );
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
+
128
388
  // ---------------------------------------------------------------------------
129
389
  // Factory
130
390
  // ---------------------------------------------------------------------------
131
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
+
132
398
  /**
133
- * Create a Vercel ChatTransport from a core ClientTransport.
399
+ * Create a Vercel ChatTransport from a core ClientSession.
134
400
  *
135
- * Maps Vercel's useChat contract to the core transport's methods:
136
- * - trigger 'submit-message' transport.send(lastMessage) with history in body
137
- * - trigger 'regenerate-message' transport.send([]) with all messages as history
138
- * - abortSignal → transport.cancel({ all: true })
139
- * - reconnectToStream null (observer mode handles in-progress streams)
140
- * @param transport - The core client transport to wrap.
401
+ * Exposes a `streaming` flag and `onStreamingChange` callback so that
402
+ * `useMessageSync` can gate `setMessages` calls during active own-run
403
+ * streams, preventing the push/replace ID mismatch in useChat's `write()`.
404
+ *
405
+ * Note: concurrent `sendMessage` calls from the same user are a useChat
406
+ * limitation that cannot be fixed from the transport layer. The
407
+ * developer must respect useChat's `status` and only call `sendMessage`
408
+ * when status is `'ready'`.
409
+ * @param session - The core client session to wrap.
141
410
  * @param chatOptions - Optional hooks for customizing request construction.
142
411
  * @returns A {@link ChatTransport} compatible with Vercel's useChat hook.
143
412
  */
144
413
  export const createChatTransport = (
145
- transport: ClientTransport<AI.UIMessageChunk, AI.UIMessage>,
414
+ session: ClientSession<VercelInput, VercelOutput, VercelProjection, AI.UIMessage>,
146
415
  chatOptions?: ChatTransportOptions,
147
- ): ChatTransport => ({
148
- sendMessages: async (opts) => {
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
+
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.
428
+ let _streaming = false;
429
+ const emitter = new EventEmitter<ChatTransportEventsMap>(makeLogger({ logLevel: LogLevel.Silent }));
430
+
431
+ const setStreaming = (value: boolean): void => {
432
+ _streaming = value;
433
+ emitter.emit('streaming', value);
434
+ };
435
+
436
+ // -- sendMessages implementation -------------------------------------------
437
+
438
+ const sendMessages: ChatTransport['sendMessages'] = async (opts) => {
149
439
  const { messages, abortSignal, trigger, messageId } = opts;
150
440
 
151
- // Determine the history/messages split based on trigger.
152
- // - submit-message: useChat appended the new user message last is new
153
- // - regenerate-message: useChat truncated the array no new messages
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);
449
+
450
+ // useChat calls sendMessages in three distinct modes. We disambiguate
451
+ // by (trigger, last-message role) so each mode dispatches correctly:
452
+ //
453
+ // - 'regenerate-message' → fork an assistant
454
+ // - 'submit-message' + last message is assistant → continuation
455
+ // (auto-submit after
456
+ // addToolResult, or
457
+ // multi-step tool use)
458
+ // - 'submit-message' + last message is user → new user message
459
+ // (or edit if
460
+ // messageId is set)
461
+ //
462
+ // Continuation mode must NOT publish the assistant as a new message or
463
+ // treat messageId as a fork target — useChat v6's sendAutomaticallyWhen
464
+ // path always sets messageId to the last message id regardless.
465
+ const lastMessage = messages.at(-1);
466
+ const lastMessageInTree = !!lastMessage && codecIdByDomainId.has(lastMessage.id);
467
+ const isContinuation = trigger === 'submit-message' && lastMessage?.role === 'assistant' && lastMessageInTree;
468
+
469
+ // Fork-on-unresolved-tool: user sent a new message while the preceding
470
+ // assistant has an unresolved tool call (approval-requested, input-*).
471
+ // Fork the new message off the preceding assistant so the unresolved
472
+ // tool call stays dormant on a sibling branch. Inference for this run runs
473
+ // on the clean fork — the LLM never sees the dangling tool_use.
474
+ //
475
+ // Only applies to fresh user-message submits (not continuations, not
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.
481
+ const precedingMessage =
482
+ trigger === 'submit-message' && !messageId && lastMessage?.role === 'user' ? messages.at(-2) : undefined;
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
488
+ : undefined;
489
+
490
+ // Determine the history/messages split based on mode.
154
491
  let newMessages: AI.UIMessage[];
155
492
  let history: AI.UIMessage[];
156
493
 
157
- if (trigger === 'regenerate-message') {
494
+ if (trigger === 'regenerate-message' || isContinuation) {
158
495
  newMessages = [];
159
496
  history = messages;
160
497
  } else {
@@ -168,27 +505,30 @@ export const createChatTransport = (
168
505
  // CAST: length check above guarantees at least one element; .at(-1) cannot be undefined.
169
506
  // eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style -- prefer `as` over `!` per TYPES.md
170
507
  newMessages = [messages.at(-1) as AI.UIMessage];
171
- history = messages.slice(0, -1);
508
+ // When forking off an unresolved tool call, drop the unresolved
509
+ // assistant from history too — it belongs on the sibling branch, not
510
+ // the ancestor chain of the new message.
511
+ history = forkSourceDomainId ? messages.slice(0, -2) : messages.slice(0, -1);
172
512
  }
173
513
 
174
- // Compute fork metadata from the conversation tree.
175
- // For regeneration: forkOf = messageId (the assistant message being regenerated),
176
- // parent = the parent of that message in the tree.
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.
177
518
  let forkOf: string | undefined;
178
- let parent: string | null | undefined;
179
-
180
- if (trigger === 'regenerate-message' && messageId) {
181
- forkOf = messageId;
182
- // Look up the parent of the message being regenerated.
183
- // messageId comes from useChat (UIMessage.id), so use getNodeByKey
184
- // which resolves via the codec key secondary index.
185
- const node = transport.getTree().getNodeByKey(messageId);
186
- if (node) {
187
- // Use the tree node's msgId (x-ably-msg-id) as forkOf this is
188
- // what the server stamps on the wire, not the UIMessage.id.
189
- forkOf = node.msgId;
190
- parent = node.parentId;
191
- }
519
+ let parent: string | undefined;
520
+
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) {
528
+ // Fork off the preceding assistant the new user message becomes a
529
+ // sibling of the unresolved tool call assistant, rooted at its parent.
530
+ forkOf = codecIdOf(forkSourceDomainId);
531
+ parent = findPredecessorCodecId(codecMessages, forkSourceDomainId);
192
532
  }
193
533
 
194
534
  let sendBody: Record<string, unknown>;
@@ -196,7 +536,7 @@ export const createChatTransport = (
196
536
 
197
537
  if (chatOptions?.prepareSendMessagesRequest) {
198
538
  const prepared = chatOptions.prepareSendMessagesRequest({
199
- id: opts.chatId,
539
+ chatId: opts.chatId,
200
540
  trigger,
201
541
  messageId,
202
542
  history,
@@ -207,72 +547,172 @@ export const createChatTransport = (
207
547
  sendBody = prepared.body ?? {};
208
548
  sendHeaders = prepared.headers;
209
549
  } else {
210
- const historyWithHeaders = history.map((m) => ({
211
- message: m,
212
- headers: transport.getMessageHeaders(m),
213
- }));
214
- sendBody = {
215
- history: historyWithHeaders,
216
- id: opts.chatId,
217
- trigger,
218
- ...(messageId !== undefined && { messageId }),
219
- ...(forkOf !== undefined && { forkOf }),
220
- ...(parent !== undefined && { parent }),
221
- };
550
+ sendBody = {};
222
551
  sendHeaders = undefined;
223
552
  }
224
553
 
225
- const sendOpts: SendOptions = { body: sendBody, headers: sendHeaders };
554
+ const sendOpts: SendOptions = {};
226
555
  if (forkOf !== undefined) sendOpts.forkOf = forkOf;
227
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
+ }
567
+
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
+ }
228
608
 
229
- const turn = await transport.send(newMessages, sendOpts);
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);
230
617
 
231
- // Wire abort signal to cancel all turns on the channel.
232
- // In multi-user scenarios, any client can stop any stream — cancelling
233
- // by specific turnId would only work for the sender.
234
618
  if (abortSignal) {
235
- abortSignal.addEventListener('abort', () => void transport.cancel({ all: true }), {
236
- once: true,
237
- });
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
+ }
238
640
  }
239
641
 
240
- // Return an empty stream that closes when the turn ends.
241
- // useChat consumes the returned stream to accumulate the assistant message,
242
- // but useMessageSync already pushes the transport's authoritative message
243
- // state into useChat via setMessages. Returning the real event stream would
244
- // cause useChat to accumulate a duplicate assistant message. Instead, we
245
- // return a stream that produces no chunks and closes when the turn's stream
246
- // finishes, so useChat knows when streaming is done without duplicating state.
247
- const { readable, writable } = new TransformStream<AI.UIMessageChunk>();
248
- const writer = writable.getWriter();
249
- // Fire-and-forget: we only care about the close/abort signal, not the piped data.
250
- // Errors on the turn stream are surfaced via transport.on('error'), not here.
251
- /* eslint-disable @typescript-eslint/no-empty-function -- swallow: writer.close() rejection after stream teardown is unrecoverable */
252
- turn.stream
253
- .pipeTo(
254
- new WritableStream({
255
- close: () => {
256
- writer.close().catch(() => {});
257
- },
258
- abort: () => {
259
- writer.close().catch(() => {});
260
- },
261
- }),
262
- )
263
- .catch(() => {
264
- writer.close().catch(() => {});
642
+ // Wrap the stream to detect completion. The streaming flag gates
643
+ // useMessageSync so that setMessages doesn't interfere with
644
+ // useChat's internal write() during active streams.
645
+ const { stream, done, fail } = wrapStreamWithDone(runStream.stream);
646
+ setStreaming(true);
647
+
648
+ // Fire-and-forget: clear the streaming flag when the stream ends.
649
+ void done.then(() => {
650
+ setStreaming(false);
651
+ });
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
+ );
265
690
  });
266
- /* eslint-enable @typescript-eslint/no-empty-function */
267
- return readable;
268
- },
269
-
270
- // Observer mode handles in-progress streams automatically.
271
- // The transport subscribes before attach — on the next server append,
272
- // observer accumulation emits lifecycle events that useMessageSync
273
- // upserts into React state.
274
- // eslint-disable-next-line unicorn/no-null, @typescript-eslint/promise-function-async -- null is required by the AI SDK ChatTransport contract; no await needed
275
- reconnectToStream: () => Promise.resolve(null),
276
-
277
- close: async (options?: CloseOptions) => transport.close(options),
278
- });
691
+
692
+ return stream;
693
+ };
694
+
695
+ return {
696
+ sendMessages,
697
+
698
+ // Observer mode handles in-progress streams automatically.
699
+ // The transport subscribes before attach on the next server append,
700
+ // observer accumulation emits lifecycle events that useMessageSync
701
+ // upserts into React state.
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
703
+ reconnectToStream: () => Promise.resolve(null),
704
+
705
+ close: async () => session.close(),
706
+
707
+ get streaming(): boolean {
708
+ return _streaming;
709
+ },
710
+
711
+ onStreamingChange: (callback: (streaming: boolean) => void): (() => void) => {
712
+ emitter.on('streaming', callback);
713
+ return () => {
714
+ emitter.off('streaming', callback);
715
+ };
716
+ },
717
+ };
718
+ };