@ably/ai-transport 0.0.1 → 0.1.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 (110) hide show
  1. package/README.md +54 -47
  2. package/dist/ably-ai-transport.js +1006 -539
  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 +4 -0
  7. package/dist/core/codec/types.d.ts +19 -2
  8. package/dist/core/transport/decode-history.d.ts +8 -6
  9. package/dist/core/transport/headers.d.ts +4 -2
  10. package/dist/core/transport/index.d.ts +4 -1
  11. package/dist/core/transport/pipe-stream.d.ts +3 -2
  12. package/dist/core/transport/stream-router.d.ts +11 -1
  13. package/dist/core/transport/tree.d.ts +171 -0
  14. package/dist/core/transport/turn-manager.d.ts +4 -1
  15. package/dist/core/transport/types.d.ts +270 -119
  16. package/dist/core/transport/view.d.ts +166 -0
  17. package/dist/errors.d.ts +19 -2
  18. package/dist/index.d.ts +3 -1
  19. package/dist/react/ably-ai-transport-react.js +1019 -486
  20. package/dist/react/ably-ai-transport-react.js.map +1 -1
  21. package/dist/react/ably-ai-transport-react.umd.cjs +1 -1
  22. package/dist/react/ably-ai-transport-react.umd.cjs.map +1 -1
  23. package/dist/react/contexts/transport-context.d.ts +31 -0
  24. package/dist/react/contexts/transport-provider.d.ts +49 -0
  25. package/dist/react/create-transport-hooks.d.ts +124 -0
  26. package/dist/react/index.d.ts +14 -8
  27. package/dist/react/use-ably-messages.d.ts +14 -8
  28. package/dist/react/use-active-turns.d.ts +7 -3
  29. package/dist/react/use-client-transport.d.ts +78 -5
  30. package/dist/react/use-create-view.d.ts +22 -0
  31. package/dist/react/use-tree.d.ts +20 -0
  32. package/dist/react/use-view.d.ts +79 -0
  33. package/dist/vercel/ably-ai-transport-vercel.js +1478 -842
  34. package/dist/vercel/ably-ai-transport-vercel.js.map +1 -1
  35. package/dist/vercel/ably-ai-transport-vercel.umd.cjs +1 -1
  36. package/dist/vercel/ably-ai-transport-vercel.umd.cjs.map +1 -1
  37. package/dist/vercel/codec/tool-transitions.d.ts +50 -0
  38. package/dist/vercel/index.d.ts +3 -0
  39. package/dist/vercel/react/ably-ai-transport-vercel-react.js +9099 -852
  40. package/dist/vercel/react/ably-ai-transport-vercel-react.js.map +1 -1
  41. package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs +45 -1
  42. package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs.map +1 -1
  43. package/dist/vercel/react/contexts/chat-transport-context.d.ts +32 -0
  44. package/dist/vercel/react/contexts/chat-transport-provider.d.ts +84 -0
  45. package/dist/vercel/react/index.d.ts +5 -0
  46. package/dist/vercel/react/use-chat-transport.d.ts +61 -20
  47. package/dist/vercel/react/use-message-sync.d.ts +41 -9
  48. package/dist/vercel/react/use-staged-add-tool-approval-response.d.ts +30 -0
  49. package/dist/vercel/tool-approvals.d.ts +124 -0
  50. package/dist/vercel/tool-events.d.ts +26 -0
  51. package/dist/vercel/transport/chat-transport.d.ts +33 -11
  52. package/dist/vercel/transport/index.d.ts +5 -2
  53. package/package.json +23 -17
  54. package/src/constants.ts +6 -0
  55. package/src/core/codec/encoder.ts +10 -1
  56. package/src/core/codec/types.ts +19 -3
  57. package/src/core/transport/client-transport.ts +382 -364
  58. package/src/core/transport/decode-history.ts +229 -81
  59. package/src/core/transport/headers.ts +6 -2
  60. package/src/core/transport/index.ts +13 -5
  61. package/src/core/transport/pipe-stream.ts +8 -5
  62. package/src/core/transport/server-transport.ts +212 -58
  63. package/src/core/transport/stream-router.ts +21 -3
  64. package/src/core/transport/{conversation-tree.ts → tree.ts} +192 -77
  65. package/src/core/transport/turn-manager.ts +28 -10
  66. package/src/core/transport/types.ts +318 -139
  67. package/src/core/transport/view.ts +840 -0
  68. package/src/errors.ts +21 -1
  69. package/src/index.ts +10 -5
  70. package/src/react/contexts/transport-context.ts +37 -0
  71. package/src/react/contexts/transport-provider.tsx +164 -0
  72. package/src/react/create-transport-hooks.ts +144 -0
  73. package/src/react/index.ts +15 -8
  74. package/src/react/use-ably-messages.ts +34 -16
  75. package/src/react/use-active-turns.ts +28 -17
  76. package/src/react/use-client-transport.ts +184 -24
  77. package/src/react/use-create-view.ts +68 -0
  78. package/src/react/use-tree.ts +53 -0
  79. package/src/react/use-view.ts +233 -0
  80. package/src/react/vite.config.ts +4 -1
  81. package/src/vercel/codec/accumulator.ts +64 -79
  82. package/src/vercel/codec/decoder.ts +11 -8
  83. package/src/vercel/codec/encoder.ts +68 -54
  84. package/src/vercel/codec/index.ts +0 -2
  85. package/src/vercel/codec/tool-transitions.ts +122 -0
  86. package/src/vercel/index.ts +17 -0
  87. package/src/vercel/react/contexts/chat-transport-context.ts +40 -0
  88. package/src/vercel/react/contexts/chat-transport-provider.tsx +122 -0
  89. package/src/vercel/react/index.ts +14 -0
  90. package/src/vercel/react/use-chat-transport.ts +164 -42
  91. package/src/vercel/react/use-message-sync.ts +77 -19
  92. package/src/vercel/react/use-staged-add-tool-approval-response.ts +87 -0
  93. package/src/vercel/react/vite.config.ts +4 -2
  94. package/src/vercel/tool-approvals.ts +380 -0
  95. package/src/vercel/tool-events.ts +53 -0
  96. package/src/vercel/transport/chat-transport.ts +225 -79
  97. package/src/vercel/transport/index.ts +14 -3
  98. package/dist/core/transport/conversation-tree.d.ts +0 -9
  99. package/dist/react/use-conversation-tree.d.ts +0 -20
  100. package/dist/react/use-edit.d.ts +0 -7
  101. package/dist/react/use-history.d.ts +0 -19
  102. package/dist/react/use-messages.d.ts +0 -7
  103. package/dist/react/use-regenerate.d.ts +0 -7
  104. package/dist/react/use-send.d.ts +0 -7
  105. package/src/react/use-conversation-tree.ts +0 -71
  106. package/src/react/use-edit.ts +0 -24
  107. package/src/react/use-history.ts +0 -111
  108. package/src/react/use-messages.ts +0 -32
  109. package/src/react/use-regenerate.ts +0 -24
  110. package/src/react/use-send.ts +0 -25
@@ -7,12 +7,18 @@
7
7
  * to the core transport'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
15
  * The adapter uses `trigger` to determine the history/messages split:
14
16
  * - submit-message: last message is new (publish to channel), rest is history
15
17
  * - regenerate-message: no new messages, entire array is history
18
+ *
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.
16
22
  */
17
23
 
18
24
  import * as Ably from 'ably';
@@ -31,12 +37,14 @@ import { ErrorCode } from '../../errors.js';
31
37
  */
32
38
  export interface SendMessagesRequestContext {
33
39
  /** Chat session ID (from useChat's id). */
34
- id?: string;
40
+ chatId?: string;
35
41
  /** What triggered the request: user sent a message, or requested regeneration. */
36
42
  trigger: 'submit-message' | 'regenerate-message';
37
43
  /**
38
- * The message ID for regeneration requests. Identifies which assistant
39
- * message to regenerate. Undefined for submit-message.
44
+ * The message ID for edit or regeneration requests. For regeneration,
45
+ * identifies the assistant message to regenerate. For edits (submit-message
46
+ * with messageId), identifies the user message being replaced. Undefined
47
+ * when submitting a new message.
40
48
  */
41
49
  messageId?: string;
42
50
  /** Previous messages in the conversation (context for the LLM). */
@@ -46,7 +54,7 @@ export interface SendMessagesRequestContext {
46
54
  /** The msg-id of the message being forked (regenerated or edited). */
47
55
  forkOf?: string;
48
56
  /** The msg-id of the predecessor in the conversation thread. */
49
- parent?: string | null;
57
+ parent?: string;
50
58
  }
51
59
 
52
60
  /** Options for customizing the ChatTransport behavior. */
@@ -91,7 +99,8 @@ interface ChatRequestOptions {
91
99
  *
92
100
  * Structurally compatible with the AI SDK's internal `ChatTransport<UIMessage>`
93
101
  * interface. Extended with `close()` for releasing the underlying Ably transport
94
- * resources.
102
+ * resources and `streaming` / `onStreamingChange` for coordinating with
103
+ * useMessageSync.
95
104
  */
96
105
  export interface ChatTransport {
97
106
  /** Send messages and return a streaming response of UIMessageChunk events. */
@@ -123,8 +132,78 @@ export interface ChatTransport {
123
132
 
124
133
  /** Close the underlying transport, releasing all resources. */
125
134
  close(options?: CloseOptions): Promise<void>;
135
+
136
+ /** Whether an own-turn stream is currently being consumed by useChat. */
137
+ readonly streaming: boolean;
138
+
139
+ /**
140
+ * Subscribe to streaming state changes. The callback fires when the
141
+ * ChatTransport transitions between streaming and idle. Used by
142
+ * useMessageSync to gate setMessages calls during active streams.
143
+ * @param callback - Called with `true` when a stream starts, `false` when it ends.
144
+ * @returns Unsubscribe function.
145
+ */
146
+ onStreamingChange(callback: (streaming: boolean) => void): () => void;
126
147
  }
127
148
 
149
+ // ---------------------------------------------------------------------------
150
+ // Stream wrapper — passthrough that signals completion via a promise
151
+ // ---------------------------------------------------------------------------
152
+
153
+ /**
154
+ * Wrap a ReadableStream in a passthrough TransformStream that resolves a
155
+ * promise when the stream completes or errors. The returned stream passes
156
+ * all chunks through unchanged.
157
+ * @param source - The original stream to wrap.
158
+ * @returns The wrapped stream and a `done` promise that resolves when the stream closes.
159
+ */
160
+ const wrapStreamWithDone = <T>(source: ReadableStream<T>): { stream: ReadableStream<T>; done: Promise<void> } => {
161
+ let resolveDone: () => void;
162
+ const done = new Promise<void>((resolve) => {
163
+ resolveDone = resolve;
164
+ });
165
+
166
+ const passthrough = new TransformStream<T, T>({
167
+ flush: () => {
168
+ resolveDone();
169
+ },
170
+ });
171
+
172
+ // Pipe in the background. If the source errors or is cancelled, resolve
173
+ // done so the serialization queue advances.
174
+ // Fire-and-forget: the pipe runs independently; errors surface through
175
+ // the readable side that useChat consumes.
176
+ source.pipeTo(passthrough.writable).catch(() => {
177
+ resolveDone();
178
+ });
179
+
180
+ return { stream: passthrough.readable, done };
181
+ };
182
+
183
+ // ---------------------------------------------------------------------------
184
+ // Unresolved tool call detection
185
+ // ---------------------------------------------------------------------------
186
+
187
+ /**
188
+ * Whether an assistant message has a `dynamic-tool` part that can't resolve
189
+ * without further user action. Matches:
190
+ * - `input-streaming` / `input-available` — tool call emitted, not yet run.
191
+ * - `approval-requested` — waiting for the user.
192
+ *
193
+ * Excludes `approval-responded` (streamText will run the tool this turn)
194
+ * and all terminal `output-*` states.
195
+ * @param msg - The UIMessage to inspect.
196
+ * @returns True when a fork-on-send is warranted to avoid shipping a
197
+ * dangling tool call to the LLM.
198
+ */
199
+ const hasUnresolvedToolCall = (msg: AI.UIMessage): boolean =>
200
+ msg.role === 'assistant' &&
201
+ msg.parts.some(
202
+ (p) =>
203
+ p.type === 'dynamic-tool' &&
204
+ (p.state === 'input-streaming' || p.state === 'input-available' || p.state === 'approval-requested'),
205
+ );
206
+
128
207
  // ---------------------------------------------------------------------------
129
208
  // Factory
130
209
  // ---------------------------------------------------------------------------
@@ -132,11 +211,14 @@ export interface ChatTransport {
132
211
  /**
133
212
  * Create a Vercel ChatTransport from a core ClientTransport.
134
213
  *
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)
214
+ * Exposes a `streaming` flag and `onStreamingChange` callback so that
215
+ * `useMessageSync` can gate `setMessages` calls during active own-turn
216
+ * streams, preventing the push/replace ID mismatch in useChat's `write()`.
217
+ *
218
+ * Note: concurrent `sendMessage` calls from the same user are a useChat
219
+ * limitation that cannot be fixed from the transport layer. The
220
+ * developer must respect useChat's `status` and only call `sendMessage`
221
+ * when status is `'ready'`.
140
222
  * @param transport - The core client transport to wrap.
141
223
  * @param chatOptions - Optional hooks for customizing request construction.
142
224
  * @returns A {@link ChatTransport} compatible with Vercel's useChat hook.
@@ -144,17 +226,73 @@ export interface ChatTransport {
144
226
  export const createChatTransport = (
145
227
  transport: ClientTransport<AI.UIMessageChunk, AI.UIMessage>,
146
228
  chatOptions?: ChatTransportOptions,
147
- ): ChatTransport => ({
148
- sendMessages: async (opts) => {
229
+ ): ChatTransport => {
230
+ // -- Streaming state -------------------------------------------------------
231
+ let _streaming = false;
232
+ const streamingCallbacks = new Set<(streaming: boolean) => void>();
233
+
234
+ const setStreaming = (value: boolean): void => {
235
+ _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
+ }
245
+ };
246
+
247
+ // -- sendMessages implementation -------------------------------------------
248
+
249
+ const sendMessages: ChatTransport['sendMessages'] = async (opts) => {
149
250
  const { messages, abortSignal, trigger, messageId } = opts;
251
+ const allNodes = transport.view.flattenNodes();
252
+
253
+ // useChat calls sendMessages in three distinct modes. We disambiguate
254
+ // by (trigger, last-message role) so each mode dispatches correctly:
255
+ //
256
+ // - 'regenerate-message' → fork an assistant
257
+ // - 'submit-message' + last message is assistant → continuation
258
+ // (auto-submit after
259
+ // addToolResult, or
260
+ // multi-step tool use)
261
+ // - 'submit-message' + last message is user → new user message
262
+ // (or edit if
263
+ // messageId is set)
264
+ //
265
+ // Continuation mode must NOT publish the assistant as a new message or
266
+ // treat messageId as a fork target — useChat v6's sendAutomaticallyWhen
267
+ // 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
+ 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;
150
275
 
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
276
+ // Fork-on-unresolved-tool: user sent a new message while the preceding
277
+ // assistant has an unresolved tool call (approval-requested, input-*).
278
+ // Fork the new message off the preceding assistant so the unresolved
279
+ // tool call stays dormant on a sibling branch. Inference this turn runs
280
+ // on the clean fork — the LLM never sees the dangling tool_use.
281
+ //
282
+ // Only applies to fresh user-message submits (not continuations, not
283
+ // regenerates, not edits-with-messageId).
284
+ const precedingMessage =
285
+ 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)
289
+ : undefined;
290
+
291
+ // Determine the history/messages split based on mode.
154
292
  let newMessages: AI.UIMessage[];
155
293
  let history: AI.UIMessage[];
156
294
 
157
- if (trigger === 'regenerate-message') {
295
+ if (trigger === 'regenerate-message' || isContinuation) {
158
296
  newMessages = [];
159
297
  history = messages;
160
298
  } else {
@@ -168,27 +306,38 @@ export const createChatTransport = (
168
306
  // CAST: length check above guarantees at least one element; .at(-1) cannot be undefined.
169
307
  // eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style -- prefer `as` over `!` per TYPES.md
170
308
  newMessages = [messages.at(-1) as AI.UIMessage];
171
- history = messages.slice(0, -1);
309
+ // When forking off an unresolved tool call, drop the unresolved
310
+ // assistant from history too — it belongs on the sibling branch, not
311
+ // the ancestor chain of the new message.
312
+ history = forkSource ? messages.slice(0, -2) : messages.slice(0, -1);
172
313
  }
173
314
 
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.
315
+ // Compute fork metadata. Only set in regenerate or edit modes — in
316
+ // continuation mode we do NOT fork, we continue the branch.
177
317
  let forkOf: string | undefined;
178
- let parent: string | null | undefined;
318
+ let parent: string | undefined;
179
319
 
180
- if (trigger === 'regenerate-message' && messageId) {
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.
181
324
  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);
325
+ const node = allNodes.find((n) => n.message.id === messageId);
186
326
  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
327
  forkOf = node.msgId;
190
328
  parent = node.parentId;
191
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) {
337
+ // Fork off the preceding assistant — the new user message becomes a
338
+ // sibling of the unresolved tool call assistant, rooted at its parent.
339
+ forkOf = forkSource.msgId;
340
+ parent = forkSource.parentId;
192
341
  }
193
342
 
194
343
  let sendBody: Record<string, unknown>;
@@ -196,7 +345,7 @@ export const createChatTransport = (
196
345
 
197
346
  if (chatOptions?.prepareSendMessagesRequest) {
198
347
  const prepared = chatOptions.prepareSendMessagesRequest({
199
- id: opts.chatId,
348
+ chatId: opts.chatId,
200
349
  trigger,
201
350
  messageId,
202
351
  history,
@@ -207,13 +356,11 @@ export const createChatTransport = (
207
356
  sendBody = prepared.body ?? {};
208
357
  sendHeaders = prepared.headers;
209
358
  } else {
210
- const historyWithHeaders = history.map((m) => ({
211
- message: m,
212
- headers: transport.getMessageHeaders(m),
213
- }));
359
+ const historyIds = new Set(history.map((m) => m.id));
360
+ const historyNodes = allNodes.filter((n) => historyIds.has(n.message.id));
214
361
  sendBody = {
215
- history: historyWithHeaders,
216
- id: opts.chatId,
362
+ history: historyNodes,
363
+ chatId: opts.chatId,
217
364
  trigger,
218
365
  ...(messageId !== undefined && { messageId }),
219
366
  ...(forkOf !== undefined && { forkOf }),
@@ -226,53 +373,52 @@ export const createChatTransport = (
226
373
  if (forkOf !== undefined) sendOpts.forkOf = forkOf;
227
374
  if (parent !== undefined) sendOpts.parent = parent;
228
375
 
229
- const turn = await transport.send(newMessages, sendOpts);
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);
230
380
 
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
381
  if (abortSignal) {
235
382
  abortSignal.addEventListener('abort', () => void transport.cancel({ all: true }), {
236
383
  once: true,
237
384
  });
238
385
  }
239
386
 
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(() => {});
265
- });
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
- });
387
+ // Wrap the stream to detect completion. The streaming flag gates
388
+ // useMessageSync so that setMessages doesn't interfere with
389
+ // useChat's internal write() during active streams.
390
+ const { stream, done } = wrapStreamWithDone(turn.stream);
391
+ setStreaming(true);
392
+
393
+ // Fire-and-forget: clear the streaming flag when the stream ends.
394
+ void done.then(() => {
395
+ setStreaming(false);
396
+ });
397
+
398
+ return stream;
399
+ };
400
+
401
+ return {
402
+ sendMessages,
403
+
404
+ // Observer mode handles in-progress streams automatically.
405
+ // The transport subscribes before attach — on the next server append,
406
+ // observer accumulation emits lifecycle events that useMessageSync
407
+ // upserts into React state.
408
+ // 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
+ reconnectToStream: () => Promise.resolve(null),
410
+
411
+ close: async (options?: CloseOptions) => transport.close(options),
412
+
413
+ get streaming(): boolean {
414
+ return _streaming;
415
+ },
416
+
417
+ onStreamingChange: (callback: (streaming: boolean) => void): (() => void) => {
418
+ streamingCallbacks.add(callback);
419
+ return () => {
420
+ streamingCallbacks.delete(callback);
421
+ };
422
+ },
423
+ };
424
+ };
@@ -27,12 +27,17 @@ import type {
27
27
  } from '../../core/transport/types.js';
28
28
  import { UIMessageCodec } from '../codec/index.js';
29
29
 
30
- /** Options for creating a Vercel client transport. Same as core options but without the codec field. */
31
- export type VercelClientTransportOptions = Omit<ClientTransportOptions<AI.UIMessageChunk, AI.UIMessage>, 'codec'>;
30
+ /** Core client transport options with Vercel AI SDK types pre-applied. */
31
+ type CoreClientOpts = ClientTransportOptions<AI.UIMessageChunk, AI.UIMessage>;
32
+
33
+ /** Options for creating a Vercel client transport. Same as core options but without the codec field, and with `api` optional (defaults to `"/api/chat"`). */
34
+ export type VercelClientTransportOptions = Omit<CoreClientOpts, 'codec' | 'api'> & Partial<Pick<CoreClientOpts, 'api'>>;
32
35
 
33
36
  /** Options for creating a Vercel server transport. Same as core options but without the codec field. */
34
37
  export type VercelServerTransportOptions = Omit<ServerTransportOptions<AI.UIMessageChunk, AI.UIMessage>, 'codec'>;
35
38
 
39
+ export const DEFAULT_VERCEL_API = '/api/chat';
40
+
36
41
  /**
37
42
  * Create a client-side transport pre-configured with the Vercel AI SDK codec.
38
43
  *
@@ -42,7 +47,13 @@ export type VercelServerTransportOptions = Omit<ServerTransportOptions<AI.UIMess
42
47
  */
43
48
  export const createClientTransport = (
44
49
  options: VercelClientTransportOptions,
45
- ): ClientTransport<AI.UIMessageChunk, AI.UIMessage> => createCoreClientTransport({ ...options, codec: UIMessageCodec });
50
+ ): ClientTransport<AI.UIMessageChunk, AI.UIMessage> =>
51
+ createCoreClientTransport({
52
+ ...options,
53
+ codec: UIMessageCodec,
54
+ // Mirrors the Vercel AI SDK's DefaultChatTransport default.
55
+ api: options.api ?? DEFAULT_VERCEL_API,
56
+ });
46
57
 
47
58
  /**
48
59
  * Create a server-side transport pre-configured with the Vercel AI SDK codec.
@@ -1,9 +0,0 @@
1
- import { Logger } from '../../logger.js';
2
- import { ConversationTree } from './types.js';
3
- /**
4
- * Create a ConversationTree that materializes branching history from a flat oplog.
5
- * @param getKey - Codec function that returns a stable key for a domain message.
6
- * @param logger - Logger for diagnostic output.
7
- * @returns A new {@link ConversationTree} instance.
8
- */
9
- export declare const createConversationTree: <TMessage>(getKey: (message: TMessage) => string, logger: Logger) => ConversationTree<TMessage>;
@@ -1,20 +0,0 @@
1
- import { ClientTransport } from '../core/transport/types.js';
2
- /** Handle for navigating the branching conversation tree. */
3
- export interface ConversationTreeHandle<TMessage> {
4
- /** Linear message list for the currently selected branch. */
5
- messages: TMessage[];
6
- /** Get all sibling messages at a fork point. */
7
- getSiblings: (msgId: string) => TMessage[];
8
- /** Whether a message has siblings (should show navigation arrows). */
9
- hasSiblings: (msgId: string) => boolean;
10
- /** Index of the currently selected sibling. */
11
- getSelectedIndex: (msgId: string) => number;
12
- /** Navigate to a sibling. Triggers re-render with updated messages. */
13
- selectSibling: (msgId: string, index: number) => void;
14
- }
15
- /**
16
- * Subscribe to transport message updates and provide branch navigation primitives.
17
- * @param transport - The client transport whose conversation tree to navigate.
18
- * @returns A {@link ConversationTreeHandle} with the current messages and navigation methods.
19
- */
20
- export declare const useConversationTree: <TEvent, TMessage>(transport: ClientTransport<TEvent, TMessage>) => ConversationTreeHandle<TMessage>;
@@ -1,7 +0,0 @@
1
- import { ActiveTurn, ClientTransport, SendOptions } from '../core/transport/types.js';
2
- /**
3
- * Return a stable `edit` callback bound to the given transport.
4
- * @param transport - The client transport to edit through.
5
- * @returns A function that edits a user message and returns an {@link ActiveTurn} handle.
6
- */
7
- export declare const useEdit: <TEvent, TMessage>(transport: ClientTransport<TEvent, TMessage>) => ((messageId: string, newMessages: TMessage | TMessage[], options?: SendOptions) => Promise<ActiveTurn<TEvent>>);
@@ -1,19 +0,0 @@
1
- import { ClientTransport, LoadHistoryOptions } from '../core/transport/types.js';
2
- /** Handle for paginated history loading. */
3
- export interface HistoryHandle {
4
- /** Are there older pages available? False until `load()` has been called. */
5
- hasNext: boolean;
6
- /** Is a page being fetched? */
7
- loading: boolean;
8
- /** Load the first page (or re-load with different options). Inserts into the conversation tree. */
9
- load: (options?: LoadHistoryOptions) => Promise<void>;
10
- /** Fetch the next (older) page. No-op if loading or no more pages. Inserts into the conversation tree. */
11
- next: () => Promise<void>;
12
- }
13
- /**
14
- * Paginated history handle for a client transport.
15
- * @param transport - The client transport to load history from, or null/undefined if not yet available.
16
- * @param options - When provided, auto-loads the first page on mount. Omit or pass null for manual loading.
17
- * @returns A {@link HistoryHandle} for loading and paginating through history.
18
- */
19
- export declare const useHistory: <TEvent, TMessage>(transport: ClientTransport<TEvent, TMessage> | null | undefined, options?: LoadHistoryOptions | null) => HistoryHandle;
@@ -1,7 +0,0 @@
1
- import { ClientTransport } from '../core/transport/types.js';
2
- /**
3
- * Subscribe to transport message updates and return the current message list.
4
- * @param transport - The client transport to observe.
5
- * @returns The current list of decoded messages.
6
- */
7
- export declare const useMessages: <TEvent, TMessage>(transport: ClientTransport<TEvent, TMessage>) => TMessage[];
@@ -1,7 +0,0 @@
1
- import { ActiveTurn, ClientTransport, SendOptions } from '../core/transport/types.js';
2
- /**
3
- * Return a stable `regenerate` callback bound to the given transport.
4
- * @param transport - The client transport to regenerate through.
5
- * @returns A function that regenerates an assistant message and returns an {@link ActiveTurn} handle.
6
- */
7
- export declare const useRegenerate: <TEvent, TMessage>(transport: ClientTransport<TEvent, TMessage>) => ((messageId: string, options?: SendOptions) => Promise<ActiveTurn<TEvent>>);
@@ -1,7 +0,0 @@
1
- import { ActiveTurn, ClientTransport, SendOptions } from '../core/transport/types.js';
2
- /**
3
- * Return a stable `send` callback bound to the given transport.
4
- * @param transport - The client transport to send through.
5
- * @returns A function that sends messages and returns an {@link ActiveTurn} handle.
6
- */
7
- export declare const useSend: <TEvent, TMessage>(transport: ClientTransport<TEvent, TMessage>) => ((messages: TMessage[], options?: SendOptions) => Promise<ActiveTurn<TEvent>>);
@@ -1,71 +0,0 @@
1
- /**
2
- * useConversationTree — reactive branch navigation for a ClientTransport.
3
- *
4
- * Subscribes to the transport's "message" notification and provides
5
- * branch navigation primitives (getSiblings, selectSibling, hasSiblings)
6
- * backed by the transport's ConversationTree.
7
- *
8
- * Branch selection state is local to the hook instance — each component
9
- * (or tab) can navigate branches independently.
10
- */
11
-
12
- import { useCallback, useEffect, useState } from 'react';
13
-
14
- import type { ClientTransport } from '../core/transport/types.js';
15
-
16
- /** Handle for navigating the branching conversation tree. */
17
- export interface ConversationTreeHandle<TMessage> {
18
- /** Linear message list for the currently selected branch. */
19
- messages: TMessage[];
20
- /** Get all sibling messages at a fork point. */
21
- getSiblings: (msgId: string) => TMessage[];
22
- /** Whether a message has siblings (should show navigation arrows). */
23
- hasSiblings: (msgId: string) => boolean;
24
- /** Index of the currently selected sibling. */
25
- getSelectedIndex: (msgId: string) => number;
26
- /** Navigate to a sibling. Triggers re-render with updated messages. */
27
- selectSibling: (msgId: string, index: number) => void;
28
- }
29
-
30
- /**
31
- * Subscribe to transport message updates and provide branch navigation primitives.
32
- * @param transport - The client transport whose conversation tree to navigate.
33
- * @returns A {@link ConversationTreeHandle} with the current messages and navigation methods.
34
- */
35
- export const useConversationTree = <TEvent, TMessage>(
36
- transport: ClientTransport<TEvent, TMessage>,
37
- ): ConversationTreeHandle<TMessage> => {
38
- const [messages, setMessages] = useState<TMessage[]>(() => transport.getMessages());
39
-
40
- useEffect(() => {
41
- setMessages(transport.getMessages());
42
-
43
- const unsub = transport.on('message', () => {
44
- setMessages(transport.getMessages());
45
- });
46
- return unsub;
47
- }, [transport]);
48
-
49
- const getSiblings = useCallback((msgId: string) => transport.getTree().getSiblings(msgId), [transport]);
50
-
51
- const hasSiblings = useCallback((msgId: string) => transport.getTree().hasSiblings(msgId), [transport]);
52
-
53
- const getSelectedIndex = useCallback((msgId: string) => transport.getTree().getSelectedIndex(msgId), [transport]);
54
-
55
- const selectSibling = useCallback(
56
- (msgId: string, index: number) => {
57
- transport.getTree().select(msgId, index);
58
- // flatten() returns a new array after select(), triggering re-render.
59
- setMessages(transport.getMessages());
60
- },
61
- [transport],
62
- );
63
-
64
- return {
65
- messages,
66
- getSiblings,
67
- hasSiblings,
68
- getSelectedIndex,
69
- selectSibling,
70
- };
71
- };
@@ -1,24 +0,0 @@
1
- /**
2
- * useEdit — stable callback for editing a user message.
3
- *
4
- * Delegates to `transport.edit()`, which automatically computes
5
- * `forkOf`, `parent`, and history from the conversation tree.
6
- */
7
-
8
- import { useCallback } from 'react';
9
-
10
- import type { ActiveTurn, ClientTransport, SendOptions } from '../core/transport/types.js';
11
-
12
- /**
13
- * Return a stable `edit` callback bound to the given transport.
14
- * @param transport - The client transport to edit through.
15
- * @returns A function that edits a user message and returns an {@link ActiveTurn} handle.
16
- */
17
- export const useEdit = <TEvent, TMessage>(
18
- transport: ClientTransport<TEvent, TMessage>,
19
- ): ((messageId: string, newMessages: TMessage | TMessage[], options?: SendOptions) => Promise<ActiveTurn<TEvent>>) =>
20
- useCallback(
21
- async (messageId: string, newMessages: TMessage | TMessage[], options?: SendOptions): Promise<ActiveTurn<TEvent>> =>
22
- transport.edit(messageId, newMessages, options),
23
- [transport],
24
- );