@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
@@ -17,6 +17,7 @@ import type * as AI from 'ai';
17
17
 
18
18
  import type { DecoderOutput, MessageAccumulator } from '../../core/codec/types.js';
19
19
  import { stripUndefined } from '../../utils.js';
20
+ import { toolBase, transitionToolPart } from './tool-transitions.js';
20
21
 
21
22
  // ---------------------------------------------------------------------------
22
23
  // Internal types
@@ -38,15 +39,6 @@ interface ToolPartTracker {
38
39
  inputText: string;
39
40
  }
40
41
 
41
- /** Fields shared by all DynamicToolUIPart state variants. */
42
- interface ToolBaseFields {
43
- type: 'dynamic-tool';
44
- toolName: string;
45
- toolCallId: string;
46
- title?: string;
47
- providerExecuted?: boolean;
48
- }
49
-
50
42
  /** Bundled per-message state for an in-progress message. */
51
43
  interface ActiveMessageState {
52
44
  message: AI.UIMessage;
@@ -56,34 +48,6 @@ interface ActiveMessageState {
56
48
  streamStatus: Map<string, StreamStatus>;
57
49
  }
58
50
 
59
- // ---------------------------------------------------------------------------
60
- // Tool base helper
61
- // ---------------------------------------------------------------------------
62
-
63
- /**
64
- * Extract the state-independent base fields for a DynamicToolUIPart.
65
- * Works with both chunks (tool-input-start, etc.) and existing parts.
66
- * @param source - Any object containing the required tool identity fields.
67
- * @param source.toolCallId - The tool call identifier.
68
- * @param source.toolName - The tool name.
69
- * @param source.title - Optional display title.
70
- * @param source.providerExecuted - Whether the provider executed the tool.
71
- * @returns Base fields shared across all DynamicToolUIPart state variants.
72
- */
73
- const toolBase = (source: {
74
- toolCallId: string;
75
- toolName: string;
76
- title?: string;
77
- providerExecuted?: boolean;
78
- }): ToolBaseFields =>
79
- stripUndefined({
80
- type: 'dynamic-tool' as const,
81
- toolCallId: source.toolCallId,
82
- toolName: source.toolName,
83
- title: source.title,
84
- providerExecuted: source.providerExecuted,
85
- });
86
-
87
51
  // ---------------------------------------------------------------------------
88
52
  // DeltaStreamTracker — manages text or reasoning stream accumulation
89
53
  // ---------------------------------------------------------------------------
@@ -172,6 +136,68 @@ class DefaultUIMessageAccumulator implements MessageAccumulator<AI.UIMessageChun
172
136
  }
173
137
  }
174
138
 
139
+ initMessage(messageId: string, message: AI.UIMessage): void {
140
+ const existing = this._activeMessages.get(messageId);
141
+
142
+ if (existing) {
143
+ // Already active — sync with the externally updated message.
144
+ // Replace the message and rebuild tool trackers so the accumulator
145
+ // reflects updates (e.g. cross-turn amendments applied to the tree)
146
+ // that happened outside the streaming flow.
147
+ const cloned = structuredClone(message);
148
+ const listIdx = this._messageList.indexOf(existing.message);
149
+ existing.message = cloned;
150
+ if (listIdx !== -1) {
151
+ this._messageList[listIdx] = cloned;
152
+ }
153
+ existing.toolTrackers = {};
154
+ for (let i = 0; i < cloned.parts.length; i++) {
155
+ const part = cloned.parts[i];
156
+ if (part?.type === 'dynamic-tool') {
157
+ existing.toolTrackers[part.toolCallId] = { partIndex: i, inputText: '' };
158
+ existing.streamStatus.set(part.toolCallId, 'finished');
159
+ }
160
+ }
161
+ return;
162
+ }
163
+
164
+ // Not active — create tracking state from the existing message.
165
+ const cloned = structuredClone(message);
166
+ const toolTrackers: Record<string, ToolPartTracker> = {};
167
+ const streamStatus = new Map<string, StreamStatus>();
168
+
169
+ for (let i = 0; i < cloned.parts.length; i++) {
170
+ const part = cloned.parts[i];
171
+ if (part?.type === 'dynamic-tool') {
172
+ toolTrackers[part.toolCallId] = { partIndex: i, inputText: '' };
173
+ streamStatus.set(part.toolCallId, 'finished');
174
+ }
175
+ }
176
+
177
+ const state: ActiveMessageState = {
178
+ message: cloned,
179
+ textStreams: new DeltaStreamTracker('text'),
180
+ reasoningStreams: new DeltaStreamTracker('reasoning'),
181
+ toolTrackers,
182
+ streamStatus,
183
+ };
184
+
185
+ this._activeMessages.set(messageId, state);
186
+
187
+ // If this message is already in the list (completed previously),
188
+ // replace in-place. Otherwise push as a new entry.
189
+ const existingIdx = this._messageList.findIndex((m) => m.id === message.id);
190
+ if (existingIdx === -1) {
191
+ this._messageList.push(state.message);
192
+ } else {
193
+ this._messageList[existingIdx] = state.message;
194
+ }
195
+ }
196
+
197
+ completeMessage(messageId: string): void {
198
+ this._activeMessages.delete(messageId);
199
+ }
200
+
175
201
  // -------------------------------------------------------------------------
176
202
  // Shared helpers
177
203
  // -------------------------------------------------------------------------
@@ -503,48 +529,7 @@ class DefaultUIMessageAccumulator implements MessageAccumulator<AI.UIMessageChun
503
529
  const found = this._getToolPart(chunk.toolCallId, state);
504
530
  if (!found) return;
505
531
 
506
- switch (chunk.type) {
507
- case 'tool-output-available': {
508
- state.message.parts[found.tracker.partIndex] = stripUndefined({
509
- ...toolBase(found.part),
510
- state: 'output-available' as const,
511
- input: found.part.input,
512
- output: chunk.output,
513
- preliminary: chunk.preliminary,
514
- });
515
- break;
516
- }
517
-
518
- case 'tool-output-error': {
519
- state.message.parts[found.tracker.partIndex] = {
520
- ...toolBase(found.part),
521
- state: 'output-error',
522
- input: found.part.input,
523
- errorText: chunk.errorText,
524
- };
525
- break;
526
- }
527
-
528
- case 'tool-output-denied': {
529
- state.message.parts[found.tracker.partIndex] = {
530
- ...toolBase(found.part),
531
- state: 'output-denied',
532
- input: found.part.input,
533
- approval: { id: '', approved: false },
534
- };
535
- break;
536
- }
537
-
538
- case 'tool-approval-request': {
539
- state.message.parts[found.tracker.partIndex] = {
540
- ...toolBase(found.part),
541
- state: 'approval-requested',
542
- input: found.part.input,
543
- approval: { id: chunk.approvalId },
544
- };
545
- break;
546
- }
547
- }
532
+ state.message.parts[found.tracker.partIndex] = transitionToolPart(found.part, chunk);
548
533
  }
549
534
 
550
535
  // -------------------------------------------------------------------------
@@ -14,7 +14,7 @@
14
14
  import type * as Ably from 'ably';
15
15
  import type * as AI from 'ai';
16
16
 
17
- import { HEADER_ROLE, HEADER_TURN_ID } from '../../constants.js';
17
+ import { HEADER_DISCRETE, HEADER_ROLE, HEADER_TURN_ID } from '../../constants.js';
18
18
  import type { DecoderCore, DecoderCoreHooks, DecoderCoreOptions } from '../../core/codec/decoder.js';
19
19
  import { createDecoderCore, eventOutput } from '../../core/codec/decoder.js';
20
20
  import type { LifecycleTracker } from '../../core/codec/lifecycle-tracker.js';
@@ -277,7 +277,8 @@ const decodeFinish = (r: VercelHeaderReader, turnId: string, lifecycle: Lifecycl
277
277
  );
278
278
  };
279
279
 
280
- const decodeError = (data: unknown): Out[] => {
280
+ const decodeError = (data: unknown, turnId: string, lifecycle: LifecycleTracker<AI.UIMessageChunk>): Out[] => {
281
+ lifecycle.clearScope(turnId);
281
282
  const errorText = typeof data === 'string' ? data : '';
282
283
  return event({ type: 'error', errorText });
283
284
  };
@@ -483,14 +484,16 @@ const decodeDiscreteMessage = (input: MessagePayload): Out[] => {
483
484
 
484
485
  /**
485
486
  * Whether a message name represents a discrete message part (written by writeMessages)
486
- * rather than a streaming lifecycle event. Discrete message parts carry x-ably-role
487
- * and encode a single UIMessage part each.
487
+ * rather than a streaming lifecycle event. Distinguished by the `x-ably-discrete` header
488
+ * which {@link publishDiscreteBatch} sets on batch-published message payloads. Lifecycle
489
+ * events published via {@link publishDiscrete} (including streaming `data-*` chunks)
490
+ * do not carry this header.
488
491
  * @param name - The Ably message name to check.
489
- * @param headers - The Ably message headers to inspect for role presence.
492
+ * @param headers - The Ably message headers to inspect for discrete marker presence.
490
493
  * @returns True if this is a discrete message part, false if it's a lifecycle event.
491
494
  */
492
495
  const isDiscreteMessagePart = (name: string, headers: Record<string, string>): boolean =>
493
- (name === 'text' || name === 'file' || isDataEventName(name)) && HEADER_ROLE in headers;
496
+ (name === 'text' || name === 'file' || isDataEventName(name)) && HEADER_DISCRETE in headers;
494
497
 
495
498
  const decodeDiscretePayload = (input: MessagePayload, lifecycle: LifecycleTracker<AI.UIMessageChunk>): Out[] => {
496
499
  const h = input.headers ?? {};
@@ -498,7 +501,7 @@ const decodeDiscretePayload = (input: MessagePayload, lifecycle: LifecycleTracke
498
501
  const turnId = h[HEADER_TURN_ID] ?? '';
499
502
 
500
503
  // Discrete message parts from writeMessages (user messages, history entries).
501
- // Distinguished from lifecycle events by the presence of x-ably-role.
504
+ // Distinguished from lifecycle events by the presence of x-ably-discrete.
502
505
  if (isDiscreteMessagePart(input.name, h)) {
503
506
  return decodeDiscreteMessage(input);
504
507
  }
@@ -521,7 +524,7 @@ const decodeDiscretePayload = (input: MessagePayload, lifecycle: LifecycleTracke
521
524
  return decodeFinish(r, turnId, lifecycle);
522
525
  }
523
526
  case 'error': {
524
- return decodeError(input.data);
527
+ return decodeError(input.data, turnId, lifecycle);
525
528
  }
526
529
  case 'abort': {
527
530
  return decodeAbort(input.data, turnId, lifecycle);
@@ -40,16 +40,78 @@ import type { ChannelWriter, MessagePayload, StreamEncoder, WriteOptions } from
40
40
  import { ErrorCode, errorInfoIs } from '../../errors.js';
41
41
  import { headerWriter } from '../../utils.js';
42
42
 
43
+ // ---------------------------------------------------------------------------
44
+ // Discrete event payload builder
45
+ // ---------------------------------------------------------------------------
46
+
47
+ /**
48
+ * Build a MessagePayload for discrete (non-streaming) event types.
49
+ * Used by both `writeEvent` and `appendEvent` for tool output events,
50
+ * content parts, and data-* custom chunks.
51
+ * @param chunk - The UI message chunk to encode.
52
+ * @returns The message payload for publishing to the channel.
53
+ */
54
+ const buildDiscretePayload = (chunk: AI.UIMessageChunk): MessagePayload => {
55
+ switch (chunk.type) {
56
+ case 'tool-output-available': {
57
+ const h = headerWriter()
58
+ .str('toolCallId', chunk.toolCallId)
59
+ .bool('dynamic', chunk.dynamic)
60
+ .bool('providerExecuted', chunk.providerExecuted)
61
+ .bool('preliminary', chunk.preliminary)
62
+ .build();
63
+ return { name: 'tool-output-available', data: { output: chunk.output }, headers: h };
64
+ }
65
+
66
+ case 'tool-output-error': {
67
+ const h = headerWriter()
68
+ .str('toolCallId', chunk.toolCallId)
69
+ .bool('dynamic', chunk.dynamic)
70
+ .bool('providerExecuted', chunk.providerExecuted)
71
+ .build();
72
+ return { name: 'tool-output-error', data: { errorText: chunk.errorText }, headers: h };
73
+ }
74
+
75
+ case 'tool-approval-request': {
76
+ const h = headerWriter().str('toolCallId', chunk.toolCallId).str('approvalId', chunk.approvalId).build();
77
+ return { name: 'tool-approval-request', data: '', headers: h };
78
+ }
79
+
80
+ case 'tool-output-denied': {
81
+ const h = headerWriter().str('toolCallId', chunk.toolCallId).build();
82
+ return { name: 'tool-output-denied', data: '', headers: h };
83
+ }
84
+
85
+ default: {
86
+ if (chunk.type.startsWith('data-')) {
87
+ // CAST: data-* chunks always have id, transient, and data fields per AI SDK types.
88
+ // TypeScript can't narrow the template literal union in a default case.
89
+ const dataChunk = chunk as Extract<AI.UIMessageChunk, { type: `data-${string}` }>;
90
+ const h = headerWriter().str('id', dataChunk.id).bool('transient', dataChunk.transient).build();
91
+ const ephemeral = dataChunk.transient === true;
92
+ return { name: chunk.type, data: dataChunk.data, headers: h, ephemeral };
93
+ }
94
+ throw new Ably.ErrorInfo(
95
+ `unable to write event; unsupported chunk type '${chunk.type}'`,
96
+ ErrorCode.InvalidArgument,
97
+ 400,
98
+ );
99
+ }
100
+ }
101
+ };
102
+
43
103
  // ---------------------------------------------------------------------------
44
104
  // Default implementation
45
105
  // ---------------------------------------------------------------------------
46
106
 
47
107
  class DefaultUIMessageEncoder implements StreamEncoder<AI.UIMessageChunk, AI.UIMessage> {
48
108
  private readonly _core: EncoderCore;
109
+ private readonly _messageId: string | undefined;
49
110
  private _aborted = false;
50
111
 
51
112
  constructor(writer: ChannelWriter, options: EncoderCoreOptions = {}) {
52
113
  this._core = createEncoderCore(writer, options);
114
+ this._messageId = options.messageId;
53
115
  }
54
116
 
55
117
  async appendEvent(chunk: AI.UIMessageChunk, perWrite?: WriteOptions): Promise<void> {
@@ -144,7 +206,7 @@ class DefaultUIMessageEncoder implements StreamEncoder<AI.UIMessageChunk, AI.UIM
144
206
 
145
207
  case 'start': {
146
208
  const h = headerWriter()
147
- .str('messageId', chunk.messageId)
209
+ .str('messageId', chunk.messageId ?? this._messageId)
148
210
  .json('messageMetadata', chunk.messageMetadata)
149
211
  .build();
150
212
  await this._core.publishDiscrete({ name: 'start', data: '', headers: h }, perWrite);
@@ -204,44 +266,11 @@ class DefaultUIMessageEncoder implements StreamEncoder<AI.UIMessageChunk, AI.UIM
204
266
  break;
205
267
  }
206
268
 
207
- case 'tool-output-available': {
208
- const h = headerWriter()
209
- .str('toolCallId', chunk.toolCallId)
210
- .bool('dynamic', chunk.dynamic)
211
- .bool('providerExecuted', chunk.providerExecuted)
212
- .bool('preliminary', chunk.preliminary)
213
- .build();
214
- await this._core.publishDiscrete({
215
- name: 'tool-output-available',
216
- data: { output: chunk.output },
217
- headers: h,
218
- });
219
- break;
220
- }
221
-
222
- case 'tool-output-error': {
223
- const h = headerWriter()
224
- .str('toolCallId', chunk.toolCallId)
225
- .bool('dynamic', chunk.dynamic)
226
- .bool('providerExecuted', chunk.providerExecuted)
227
- .build();
228
- await this._core.publishDiscrete({
229
- name: 'tool-output-error',
230
- data: { errorText: chunk.errorText },
231
- headers: h,
232
- });
233
- break;
234
- }
235
-
236
- case 'tool-approval-request': {
237
- const h = headerWriter().str('toolCallId', chunk.toolCallId).str('approvalId', chunk.approvalId).build();
238
- await this._core.publishDiscrete({ name: 'tool-approval-request', data: '', headers: h }, perWrite);
239
- break;
240
- }
241
-
269
+ case 'tool-output-available':
270
+ case 'tool-output-error':
271
+ case 'tool-approval-request':
242
272
  case 'tool-output-denied': {
243
- const h = headerWriter().str('toolCallId', chunk.toolCallId).build();
244
- await this._core.publishDiscrete({ name: 'tool-output-denied', data: '', headers: h }, perWrite);
273
+ await this._core.publishDiscrete(buildDiscretePayload(chunk), perWrite);
245
274
  break;
246
275
  }
247
276
 
@@ -298,22 +327,7 @@ class DefaultUIMessageEncoder implements StreamEncoder<AI.UIMessageChunk, AI.UIM
298
327
  }
299
328
 
300
329
  async writeEvent(chunk: AI.UIMessageChunk, perWrite?: WriteOptions): Promise<Ably.PublishResult> {
301
- if (!chunk.type.startsWith('data-')) {
302
- throw new Ably.ErrorInfo(
303
- `unable to write event; only data-* chunk types are supported, got '${chunk.type}'`,
304
- ErrorCode.InvalidArgument,
305
- 400,
306
- );
307
- }
308
- const h = headerWriter()
309
- .str('id', 'id' in chunk ? chunk.id : undefined)
310
- .bool('transient', 'transient' in chunk ? chunk.transient : undefined)
311
- .build();
312
- const ephemeral = 'transient' in chunk && chunk.transient === true;
313
- return this._core.publishDiscrete(
314
- { name: chunk.type, data: 'data' in chunk ? chunk.data : undefined, headers: h, ephemeral },
315
- perWrite,
316
- );
330
+ return this._core.publishDiscrete(buildDiscretePayload(chunk), perWrite);
317
331
  }
318
332
 
319
333
  async writeMessages(messages: AI.UIMessage[], perWrite?: WriteOptions): Promise<Ably.PublishResult> {
@@ -30,8 +30,6 @@ export const UIMessageCodec: Codec<AI.UIMessageChunk, AI.UIMessage> = {
30
30
  createDecoder,
31
31
  createAccumulator,
32
32
 
33
- getMessageKey: (message: AI.UIMessage): string => message.id,
34
-
35
33
  isTerminal: (event: AI.UIMessageChunk): boolean =>
36
34
  event.type === 'finish' || event.type === 'error' || event.type === 'abort',
37
35
  };
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Shared tool part transition logic for the Vercel AI SDK codec.
3
+ *
4
+ * Extracted from the accumulator so the tool output state transition logic
5
+ * lives in one place, reusable by the accumulator and any other callers.
6
+ */
7
+
8
+ import type * as AI from 'ai';
9
+
10
+ import { stripUndefined } from '../../utils.js';
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Tool output chunk type guard
14
+ // ---------------------------------------------------------------------------
15
+
16
+ /** The set of UIMessageChunk types that represent tool output transitions. */
17
+ export type ToolOutputChunk = Extract<
18
+ AI.UIMessageChunk,
19
+ { type: 'tool-output-available' | 'tool-output-error' | 'tool-output-denied' | 'tool-approval-request' }
20
+ >;
21
+
22
+ /**
23
+ * Whether a UIMessageChunk is a tool output transition event.
24
+ * @param chunk - The chunk to test.
25
+ * @returns True if the chunk is a tool output transition type.
26
+ */
27
+ export const isToolOutputChunk = (chunk: AI.UIMessageChunk): chunk is ToolOutputChunk =>
28
+ chunk.type === 'tool-output-available' ||
29
+ chunk.type === 'tool-output-error' ||
30
+ chunk.type === 'tool-output-denied' ||
31
+ chunk.type === 'tool-approval-request';
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Tool base helper
35
+ // ---------------------------------------------------------------------------
36
+
37
+ /** Fields shared by all DynamicToolUIPart state variants. */
38
+ interface ToolBaseFields {
39
+ type: 'dynamic-tool';
40
+ toolName: string;
41
+ toolCallId: string;
42
+ title?: string;
43
+ providerExecuted?: boolean;
44
+ }
45
+
46
+ /**
47
+ * Extract the state-independent base fields for a DynamicToolUIPart.
48
+ * Works with both chunks (tool-input-start, etc.) and existing parts.
49
+ * @param source - Any object containing the required tool identity fields.
50
+ * @param source.toolCallId - The tool call identifier.
51
+ * @param source.toolName - The tool name.
52
+ * @param source.title - Optional display title.
53
+ * @param source.providerExecuted - Whether the provider executed the tool.
54
+ * @returns Base fields shared across all DynamicToolUIPart state variants.
55
+ */
56
+ export const toolBase = (source: {
57
+ toolCallId: string;
58
+ toolName: string;
59
+ title?: string;
60
+ providerExecuted?: boolean;
61
+ }): ToolBaseFields =>
62
+ stripUndefined({
63
+ type: 'dynamic-tool' as const,
64
+ toolCallId: source.toolCallId,
65
+ toolName: source.toolName,
66
+ title: source.title,
67
+ providerExecuted: source.providerExecuted,
68
+ });
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // Tool part transition
72
+ // ---------------------------------------------------------------------------
73
+
74
+ /**
75
+ * Transition a DynamicToolUIPart to a new state based on a tool output chunk.
76
+ * Pure function — does not mutate the input part.
77
+ * @param part - The existing tool part to transition.
78
+ * @param chunk - The tool output chunk describing the transition.
79
+ * @returns A new DynamicToolUIPart in the target state.
80
+ */
81
+ export const transitionToolPart = (part: AI.DynamicToolUIPart, chunk: ToolOutputChunk): AI.DynamicToolUIPart => {
82
+ const base = toolBase(part);
83
+
84
+ switch (chunk.type) {
85
+ case 'tool-output-available': {
86
+ return stripUndefined({
87
+ ...base,
88
+ state: 'output-available' as const,
89
+ input: part.input,
90
+ output: chunk.output,
91
+ preliminary: chunk.preliminary,
92
+ });
93
+ }
94
+
95
+ case 'tool-output-error': {
96
+ return {
97
+ ...base,
98
+ state: 'output-error',
99
+ input: part.input,
100
+ errorText: chunk.errorText,
101
+ };
102
+ }
103
+
104
+ case 'tool-output-denied': {
105
+ return {
106
+ ...base,
107
+ state: 'output-denied',
108
+ input: part.input,
109
+ approval: { id: '', approved: false },
110
+ };
111
+ }
112
+
113
+ case 'tool-approval-request': {
114
+ return {
115
+ ...base,
116
+ state: 'approval-requested',
117
+ input: part.input,
118
+ approval: { id: chunk.approvalId },
119
+ };
120
+ }
121
+ }
122
+ };
@@ -10,3 +10,20 @@ export type {
10
10
  VercelServerTransportOptions,
11
11
  } from './transport/index.js';
12
12
  export { createChatTransport, createClientTransport, createServerTransport } from './transport/index.js';
13
+
14
+ // Server-side tool result merge helper
15
+ export { applyToolEventsToHistory } from './tool-events.js';
16
+
17
+ // Server-side tool approval helpers
18
+ export type {
19
+ PrepareApprovalTurnOptions,
20
+ PrepareApprovalTurnResult,
21
+ StreamResponseWithApprovalRedirectOptions,
22
+ ToolApprovalDecision,
23
+ } from './tool-approvals.js';
24
+ export {
25
+ applyToolApprovalsToHistory,
26
+ extractApprovalDecisionsFromHistory,
27
+ prepareApprovalTurn,
28
+ streamResponseWithApprovalRedirect,
29
+ } from './tool-approvals.js';
@@ -0,0 +1,40 @@
1
+ import type * as Ably from 'ably';
2
+ import type * as AI from 'ai';
3
+ import { createContext } from 'react';
4
+
5
+ import type { ClientTransport } from '../../../core/transport/types.js';
6
+ import type { ChatTransport } from '../../transport/chat-transport.js';
7
+
8
+ /**
9
+ * A single entry in the chat transport registry, holding both the
10
+ * underlying {@link ClientTransport} and the {@link ChatTransport} wrapping it.
11
+ */
12
+ export interface ChatTransportSlot {
13
+ /** The underlying client transport used to create the chat transport. */
14
+ readonly transport: ClientTransport<AI.UIMessageChunk, AI.UIMessage>;
15
+ /** Construction error from the underlying {@link ClientTransport}, or `undefined` on success. */
16
+ readonly transportError: Ably.ErrorInfo | undefined;
17
+ /** The chat transport adapter for use with Vercel's useChat hook. */
18
+ readonly chatTransport: ChatTransport;
19
+ }
20
+
21
+ /**
22
+ * The shape of the single {@link ChatTransportContext} value.
23
+ * Combines the nearest slot with the full registry in one context object.
24
+ */
25
+ export interface ChatTransportContextValue {
26
+ /** The slot from the nearest {@link ChatTransportProvider} in the tree. */
27
+ readonly nearest: ChatTransportSlot | undefined;
28
+ /** All registered slots, keyed by channelName. */
29
+ readonly providers: Readonly<Record<string, ChatTransportSlot>>;
30
+ }
31
+
32
+ /**
33
+ * Context that carries both the nearest {@link ChatTransportSlot} and the full registry of
34
+ * registered slots keyed by channelName. Populated by {@link ChatTransportProvider};
35
+ * read by {@link useChatTransport}.
36
+ */
37
+ export const ChatTransportContext = createContext<ChatTransportContextValue>({
38
+ nearest: undefined,
39
+ providers: {},
40
+ });