@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
@@ -0,0 +1,977 @@
1
+ /**
2
+ * Vercel AI SDK reducer.
3
+ *
4
+ * Pure `(init, fold)` over the `VercelInput | VercelOutput` union. Folds
5
+ * input variants (user-message, tool-result, tool-result-error,
6
+ * tool-approval-response) and `UIMessageChunk` outputs into a
7
+ * VercelProjection holding `UIMessage[]` plus internal stream-tracker
8
+ * state.
9
+ *
10
+ * The reducer is stateless: every fold is `(state, event, meta) → state'`,
11
+ * with no instance state. Mutation in place is allowed — the projection
12
+ * is single-owner.
13
+ *
14
+ * Idempotency is **per conflict key**, not stream-wide: when two events
15
+ * compete for the same logical state (e.g. two `tool-output-available`
16
+ * for the same `toolCallId`), the higher-serial one wins and the other
17
+ * is dropped. Unrelated events arrive freely in any order. See
18
+ * `_conflictKeyOf` for the per-variant key derivation.
19
+ *
20
+ * Client-published tool resolutions (`ToolResult`, `ToolResultError`,
21
+ * `ToolApprovalResponse`) carry `codecMessageId` targeting the assistant
22
+ * they amend; the reducer applies the resolution onto that assistant's
23
+ * `dynamic-tool` part directly. If the assistant has not yet arrived in
24
+ * the projection (out-of-order delivery), the resolution is buffered in
25
+ * `pendingToolResolutions` and re-evaluated on each subsequent fold.
26
+ */
27
+
28
+ import type * as AI from 'ai';
29
+
30
+ import type {
31
+ CodecMessage,
32
+ ReducerMeta,
33
+ ToolApprovalResponse,
34
+ ToolResult,
35
+ ToolResultError,
36
+ } from '../../core/codec/types.js';
37
+ import { stripUndefined } from '../../utils.js';
38
+ import type {
39
+ VercelInput,
40
+ VercelOutput,
41
+ VercelToolApprovalResponsePayload,
42
+ VercelToolResultErrorPayload,
43
+ VercelToolResultPayload,
44
+ } from './events.js';
45
+ import { toolBase, transitionToolPart } from './tool-transitions.js';
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Internal tracker state
49
+ // ---------------------------------------------------------------------------
50
+
51
+ /**
52
+ * Tracks an in-progress tool part within a UIMessage. Text and reasoning
53
+ * parts don't need this — we write to them directly via partIndex. Tool
54
+ * parts need an extra `inputText` buffer because deltas arrive as raw
55
+ * JSON fragments that must be accumulated before parsing.
56
+ */
57
+ interface ToolPartTracker {
58
+ /** Index in the message's parts array. */
59
+ partIndex: number;
60
+ /** Accumulated streaming input text (for JSON parsing on completion). */
61
+ inputText: string;
62
+ }
63
+
64
+ /** Per-codecMessageId tracking state for in-progress streams within a UIMessage. */
65
+ interface MessageTrackers {
66
+ /** Text stream id → partIndex. */
67
+ text: Map<string, number>;
68
+ /** Reasoning stream id → partIndex. */
69
+ reasoning: Map<string, number>;
70
+ /** Tool call id → tracker. */
71
+ tools: Map<string, ToolPartTracker>;
72
+ }
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // Projection
76
+ // ---------------------------------------------------------------------------
77
+
78
+ /**
79
+ * The per-Run state produced by the Vercel codec's reducer.
80
+ *
81
+ * The SDK reads only `messages` (via `Codec.getMessages`). The remaining
82
+ * fields are internal to the reducer; they happen to live on the
83
+ * projection because the projection is the only thing the reducer can
84
+ * carry from fold to fold (it has no instance state).
85
+ */
86
+ export interface VercelProjection {
87
+ /**
88
+ * UIMessages produced or modified in this Run, in publication order,
89
+ * each paired with its codec-message-id. The reducer correlates strictly
90
+ * on `codecMessageId`; `message.id` is preserved verbatim from the source
91
+ * (the AI SDK stream's `start.messageId` for assistants, the caller's id
92
+ * for user messages) and is never used as an identity key.
93
+ */
94
+ messages: CodecMessage<AI.UIMessage>[];
95
+ /**
96
+ * Per-conflict-key high-water-marks. Maps a codec-derived conflict key
97
+ * (see `_conflictKeyOf`) to the highest `meta.serial` already folded for
98
+ * that key. Events whose serial is `<=` the stored value are dropped as
99
+ * duplicates of an already-incorporated operation. Events that have no
100
+ * conflict key (additive content, lifecycle markers) are folded
101
+ * unconditionally.
102
+ */
103
+ conflictSerials: Map<string, string>;
104
+ /** Per-codecMessageId tracker state for streamed parts. Internal — do not access. */
105
+ trackers: Map<string, MessageTrackers>;
106
+ /**
107
+ * Tool-resolution events that arrived before any assistant in this
108
+ * projection had a matching `toolCallId`. Re-evaluated on every
109
+ * subsequent fold so that an out-of-order tool output is folded as
110
+ * soon as the corresponding assistant lands.
111
+ */
112
+ pendingToolResolutions: PendingToolResolution[];
113
+ }
114
+
115
+ /**
116
+ * A buffered tool resolution waiting for its assistant message to arrive.
117
+ * The reducer scans pending entries after every successful fold so an
118
+ * out-of-order tool output is promoted as soon as the matching assistant
119
+ * is added to the projection.
120
+ */
121
+ interface PendingToolResolution {
122
+ /** The codec-message-id of the assistant the resolution targets. */
123
+ targetCodecMessageId: string;
124
+ /** Tool call this resolution targets. */
125
+ toolCallId: string;
126
+ /** Serial of the wire message — used by the conflict-key check on promotion. */
127
+ serial: string;
128
+ /** Variant of the tool-resolution used to transition the assistant's tool part. */
129
+ resolution:
130
+ | { kind: 'tool-result'; output: unknown }
131
+ | { kind: 'tool-result-error'; message: string }
132
+ | { kind: 'tool-approval-response'; approved: boolean; reason?: string };
133
+ }
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // init
137
+ // ---------------------------------------------------------------------------
138
+
139
+ /**
140
+ * Build an empty initial projection.
141
+ * @returns A fresh VercelProjection with no messages and no tracker state.
142
+ */
143
+ export const init = (): VercelProjection => ({
144
+ messages: [],
145
+ conflictSerials: new Map(),
146
+ trackers: new Map(),
147
+ pendingToolResolutions: [],
148
+ });
149
+
150
+ // ---------------------------------------------------------------------------
151
+ // fold
152
+ // ---------------------------------------------------------------------------
153
+
154
+ /**
155
+ * Fold one input or output event into the projection. Mutates and returns
156
+ * `state`.
157
+ *
158
+ * Idempotency is per conflict key (see `_conflictKeyOf`): if the event has
159
+ * a conflict key and the projection has already folded an event for that
160
+ * key at a higher-or-equal serial, this call is a no-op. Events without a
161
+ * conflict key (additive content, lifecycle markers) are folded
162
+ * unconditionally. Orphan events (e.g. tool-output for an unknown
163
+ * toolCallId) are dropped silently inside the per-variant fold helpers.
164
+ * @param state - Projection to fold into (may be mutated in place).
165
+ * @param event - Input or output event to fold.
166
+ * @param meta - Transport-derived metadata (serial, optional messageId).
167
+ * @returns The same projection reference, possibly mutated.
168
+ */
169
+ export const fold = (
170
+ state: VercelProjection,
171
+ event: VercelInput | VercelOutput,
172
+ meta: ReducerMeta,
173
+ ): VercelProjection => {
174
+ if (meta.serial) {
175
+ const key = _conflictKeyOf(event, meta);
176
+ if (key !== undefined) {
177
+ const seen = state.conflictSerials.get(key);
178
+ if (seen !== undefined && meta.serial <= seen) {
179
+ return state;
180
+ }
181
+ state.conflictSerials.set(key, meta.serial);
182
+ }
183
+ }
184
+
185
+ if (_isInput(event)) {
186
+ switch (event.kind) {
187
+ case 'user-message': {
188
+ _foldUserMessage(state, event.message, meta);
189
+ break;
190
+ }
191
+ case 'regenerate': {
192
+ // Regenerate input — wire-only signal. Carries no projection state;
193
+ // the agent reads `target` / `parent` from the wire headers via
194
+ // the input-event lookup path. No fold work to do here.
195
+ break;
196
+ }
197
+ case 'tool-result': {
198
+ _foldClientToolResult(state, event, meta);
199
+ break;
200
+ }
201
+ case 'tool-result-error': {
202
+ _foldClientToolResultError(state, event, meta);
203
+ break;
204
+ }
205
+ case 'tool-approval-response': {
206
+ _foldToolApprovalResponse(state, event, meta);
207
+ break;
208
+ }
209
+ }
210
+ } else {
211
+ _foldChunk(state, event, meta);
212
+ }
213
+
214
+ // Re-evaluate pending tool resolutions in case the just-folded event
215
+ // produced the assistant they were waiting on. Cheap when the list is
216
+ // empty (the common case).
217
+ if (state.pendingToolResolutions.length > 0) {
218
+ _retryPendingResolutions(state);
219
+ }
220
+
221
+ return state;
222
+ };
223
+
224
+ /**
225
+ * Narrow the union to TInput vs TOutput by the discriminator field name.
226
+ * VercelInput variants carry `kind`; VercelOutput variants carry `type`.
227
+ * @param event - The event to narrow.
228
+ * @returns True when the event is a VercelInput, false for VercelOutput.
229
+ */
230
+ const _isInput = (event: VercelInput | VercelOutput): event is VercelInput => 'kind' in event;
231
+
232
+ // ---------------------------------------------------------------------------
233
+ // Conflict-key derivation
234
+ // ---------------------------------------------------------------------------
235
+
236
+ /**
237
+ * Derive a per-event conflict key, or `undefined` if the event doesn't
238
+ * compete with any other event for shared state. Used by `fold` to scope
239
+ * the high-water-mark check to genuine conflicts (e.g. two
240
+ * `tool-output-available` for the same `toolCallId`) rather than to every
241
+ * event in the stream.
242
+ * @param event - The event being folded.
243
+ * @param meta - Transport-derived metadata (used for events keyed by codec-message-id).
244
+ * @returns The conflict key, or `undefined` if the event is additive / independent.
245
+ */
246
+ const _conflictKeyOf = (event: VercelInput | VercelOutput, meta: ReducerMeta): string | undefined => {
247
+ if (_isInput(event)) {
248
+ switch (event.kind) {
249
+ case 'user-message': {
250
+ // Dedup re-publishes of the same user message by its wire
251
+ // codec-message-id, never by the domain `message.id`. Without a
252
+ // codec-message-id there is nothing to correlate on, so the fold
253
+ // is left unconditional.
254
+ return meta.messageId === undefined ? undefined : `user-msg:${meta.messageId}`;
255
+ }
256
+ case 'tool-approval-response': {
257
+ return `tool-approval:${event.payload.toolCallId}`;
258
+ }
259
+ // Client tool results compete for the same final state of the tool
260
+ // call (against agent-side `tool-output-available`/`tool-output-error`
261
+ // chunks and against `tool-output-denied`/`tool-approval-request`).
262
+ // Highest serial wins. Shares the `tool-output:` namespace with the
263
+ // agent-side chunks below.
264
+ case 'tool-result':
265
+ case 'tool-result-error': {
266
+ return `tool-output:${event.payload.toolCallId}`;
267
+ }
268
+ case 'regenerate': {
269
+ return undefined;
270
+ }
271
+ }
272
+ }
273
+
274
+ switch (event.type) {
275
+ // Tool-input state machine, keyed by toolCallId.
276
+ case 'tool-input-start':
277
+ case 'tool-input-available':
278
+ case 'tool-input-error': {
279
+ return `${event.type}:${event.toolCallId}`;
280
+ }
281
+
282
+ // All "tool-output-ish" output variants compete for the same final
283
+ // state of the tool call. Shares the `tool-output:` namespace with
284
+ // the client-published input variants above.
285
+ case 'tool-output-available':
286
+ case 'tool-output-error':
287
+ case 'tool-output-denied':
288
+ case 'tool-approval-request': {
289
+ return `tool-output:${event.toolCallId}`;
290
+ }
291
+
292
+ // Per-stream start/end markers: duplicates would create phantom parts
293
+ // or wipe accumulated text. Keyed by (codec-message-id, stream-id).
294
+ case 'text-start':
295
+ case 'text-end':
296
+ case 'reasoning-start':
297
+ case 'reasoning-end': {
298
+ return `${event.type}:${meta.messageId ?? ''}:${event.id}`;
299
+ }
300
+
301
+ // Message-level markers, keyed by codec-message-id.
302
+ case 'finish':
303
+ case 'message-metadata': {
304
+ return `${event.type}:${meta.messageId ?? ''}`;
305
+ }
306
+
307
+ // Purely additive or independent — never dedup:
308
+ // text-delta / reasoning-delta / tool-input-delta (additive content)
309
+ // start / start-step / finish-step / abort / error (lifecycle)
310
+ // file / source-url / source-document (independent attachments)
311
+ // data-* (opaque to the reducer)
312
+ default: {
313
+ return undefined;
314
+ }
315
+ }
316
+ };
317
+
318
+ // ---------------------------------------------------------------------------
319
+ // Input folds
320
+ // ---------------------------------------------------------------------------
321
+
322
+ const _foldUserMessage = (state: VercelProjection, message: AI.UIMessage, meta: ReducerMeta): VercelProjection => {
323
+ // Correlate the projection entry on the wire codec-message-id; the
324
+ // caller-supplied `message.id` is preserved verbatim and surfaced to the
325
+ // application unchanged. Without a codec-message-id the message has no
326
+ // identity to key on, so it is appended as a fresh entry.
327
+ const codecMessageId = meta.messageId;
328
+ if (codecMessageId === undefined) {
329
+ state.messages.push({ codecMessageId: message.id, message });
330
+ return state;
331
+ }
332
+ const existingIdx = state.messages.findIndex((e) => e.codecMessageId === codecMessageId);
333
+ if (existingIdx === -1) {
334
+ state.messages.push({ codecMessageId, message });
335
+ } else {
336
+ state.messages[existingIdx] = { codecMessageId, message };
337
+ }
338
+ return state;
339
+ };
340
+
341
+ /**
342
+ * Fold a client-published `ToolResult`. The input carries
343
+ * `codecMessageId` pointing at the assistant whose `dynamic-tool` part
344
+ * holds the matching `toolCallId`. If the assistant and its matching
345
+ * `dynamic-tool` part are both present, fold directly; otherwise pend
346
+ * until that tool part arrives.
347
+ * @param state - Projection to fold into.
348
+ * @param event - The tool-result input (codecMessageId + domain payload).
349
+ * @param meta - Transport-derived metadata.
350
+ * @returns The same projection reference.
351
+ */
352
+ const _foldClientToolResult = (
353
+ state: VercelProjection,
354
+ event: ToolResult<VercelToolResultPayload>,
355
+ meta: ReducerMeta,
356
+ ): VercelProjection => {
357
+ const { toolCallId, output } = event.payload;
358
+ const owner = _findOwner(state, event.codecMessageId, toolCallId);
359
+ if (owner) {
360
+ owner.message.parts[owner.tracker.partIndex] = transitionToolPart(owner.part, {
361
+ type: 'tool-output-available',
362
+ toolCallId,
363
+ output,
364
+ });
365
+ return state;
366
+ }
367
+
368
+ state.pendingToolResolutions.push({
369
+ targetCodecMessageId: event.codecMessageId,
370
+ toolCallId,
371
+ serial: meta.serial,
372
+ resolution: { kind: 'tool-result', output },
373
+ });
374
+ return state;
375
+ };
376
+
377
+ /**
378
+ * Fold a client-published `ToolResultError`. Mirrors
379
+ * {@link _foldClientToolResult} but with the error transition.
380
+ * @param state - Projection to fold into.
381
+ * @param event - The tool-result-error input (codecMessageId + domain payload).
382
+ * @param meta - Transport-derived metadata.
383
+ * @returns The same projection reference.
384
+ */
385
+ const _foldClientToolResultError = (
386
+ state: VercelProjection,
387
+ event: ToolResultError<VercelToolResultErrorPayload>,
388
+ meta: ReducerMeta,
389
+ ): VercelProjection => {
390
+ const { toolCallId, message } = event.payload;
391
+ const owner = _findOwner(state, event.codecMessageId, toolCallId);
392
+ if (owner) {
393
+ owner.message.parts[owner.tracker.partIndex] = transitionToolPart(owner.part, {
394
+ type: 'tool-output-error',
395
+ toolCallId,
396
+ errorText: message,
397
+ });
398
+ return state;
399
+ }
400
+
401
+ state.pendingToolResolutions.push({
402
+ targetCodecMessageId: event.codecMessageId,
403
+ toolCallId,
404
+ serial: meta.serial,
405
+ resolution: { kind: 'tool-result-error', message },
406
+ });
407
+ return state;
408
+ };
409
+
410
+ /**
411
+ * Fold a client-published `ToolApprovalResponse`. The input carries
412
+ * `codecMessageId` pointing at the assistant whose `dynamic-tool` part
413
+ * holds the matching `toolCallId`. Approval → `approval-responded`;
414
+ * denial → `output-denied` via {@link transitionToolPart}.
415
+ * @param state - Projection to fold into.
416
+ * @param event - The approval-response input.
417
+ * @param meta - Transport-derived metadata.
418
+ * @returns The same projection reference.
419
+ */
420
+ const _foldToolApprovalResponse = (
421
+ state: VercelProjection,
422
+ event: ToolApprovalResponse<VercelToolApprovalResponsePayload>,
423
+ meta: ReducerMeta,
424
+ ): VercelProjection => {
425
+ const { toolCallId, approved, reason } = event.payload;
426
+ const owner = _findOwner(state, event.codecMessageId, toolCallId);
427
+ if (owner) {
428
+ owner.message.parts[owner.tracker.partIndex] = _approvalTransition(owner.part, approved, reason);
429
+ return state;
430
+ }
431
+
432
+ state.pendingToolResolutions.push({
433
+ targetCodecMessageId: event.codecMessageId,
434
+ toolCallId,
435
+ serial: meta.serial,
436
+ resolution: {
437
+ kind: 'tool-approval-response',
438
+ approved,
439
+ ...(reason === undefined ? {} : { reason }),
440
+ },
441
+ });
442
+ return state;
443
+ };
444
+
445
+ interface OwnerLookup {
446
+ message: AI.UIMessage;
447
+ tracker: ToolPartTracker;
448
+ part: AI.DynamicToolUIPart;
449
+ }
450
+
451
+ const _findOwner = (state: VercelProjection, codecMessageId: string, toolCallId: string): OwnerLookup | undefined => {
452
+ const entry = state.messages.find((e) => e.codecMessageId === codecMessageId);
453
+ if (!entry) return undefined;
454
+ const trackers = _ensureTrackers(state, codecMessageId);
455
+ const found = _getToolPart(entry.message, trackers, toolCallId);
456
+ if (!found) return undefined;
457
+ return { message: entry.message, tracker: found.tracker, part: found.part };
458
+ };
459
+
460
+ /**
461
+ * Locate the `dynamic-tool` part for a `toolCallId` anywhere in the projection.
462
+ * Agent-emitted second-pass tool outputs (after an approved tool runs) are
463
+ * stamped with a fresh codec-message-id that differs from the assistant holding
464
+ * the tool call, so they can't be found via `meta.messageId` — they fold onto
465
+ * whichever message holds the matching tool call (created in the first pass or
466
+ * by an approval response).
467
+ * @param state - Projection to scan.
468
+ * @param toolCallId - The tool call to locate.
469
+ * @returns The owning message, tracker, and part, or `undefined` if absent.
470
+ */
471
+ const _findToolPartOwner = (state: VercelProjection, toolCallId: string): OwnerLookup | undefined => {
472
+ for (const entry of state.messages) {
473
+ const trackers = state.trackers.get(entry.codecMessageId);
474
+ if (!trackers) continue;
475
+ const found = _getToolPart(entry.message, trackers, toolCallId);
476
+ if (found) return { message: entry.message, tracker: found.tracker, part: found.part };
477
+ }
478
+ return undefined;
479
+ };
480
+
481
+ /**
482
+ * Build the next `dynamic-tool` part shape for an approval response.
483
+ *
484
+ * For `approved=true`, transition to `approval-responded` so the AI SDK's
485
+ * multi-step loop will auto-run the tool on the next step.
486
+ * `transitionToolPart` has no shape for this transition, so we synthesize
487
+ * the part directly.
488
+ *
489
+ * For `approved=false`, delegate to `transitionToolPart` with a synthetic
490
+ * `tool-output-denied` chunk so denial mirrors the chunk-driven path.
491
+ * @param part - The existing `dynamic-tool` part being transitioned.
492
+ * @param approved - Whether the user approved the tool execution.
493
+ * @param reason - Optional human-readable reason.
494
+ * @returns The replacement `dynamic-tool` part.
495
+ */
496
+ const _approvalTransition = (
497
+ part: AI.DynamicToolUIPart,
498
+ approved: boolean,
499
+ reason: string | undefined,
500
+ ): AI.DynamicToolUIPart => {
501
+ if (approved) {
502
+ return {
503
+ ...toolBase(part),
504
+ state: 'approval-responded',
505
+ input: 'input' in part ? part.input : undefined,
506
+ approval: {
507
+ id: 'approval' in part && part.approval ? part.approval.id : '',
508
+ approved: true,
509
+ ...(reason === undefined ? {} : { reason }),
510
+ },
511
+ };
512
+ }
513
+ return transitionToolPart(part, {
514
+ type: 'tool-output-denied',
515
+ toolCallId: part.toolCallId,
516
+ ...(reason === undefined ? {} : { reason }),
517
+ });
518
+ };
519
+
520
+ /**
521
+ * Re-attempt every pending tool resolution against the current projection.
522
+ * Successfully promoted entries are removed from the pending list. Cheap:
523
+ * bounded by the number of pending entries.
524
+ * @param state - Projection to walk and mutate.
525
+ */
526
+ const _retryPendingResolutions = (state: VercelProjection): void => {
527
+ const next: PendingToolResolution[] = [];
528
+ for (const pending of state.pendingToolResolutions) {
529
+ const owner = _findOwner(state, pending.targetCodecMessageId, pending.toolCallId);
530
+ if (!owner) {
531
+ next.push(pending);
532
+ continue;
533
+ }
534
+ switch (pending.resolution.kind) {
535
+ case 'tool-result': {
536
+ owner.message.parts[owner.tracker.partIndex] = transitionToolPart(owner.part, {
537
+ type: 'tool-output-available',
538
+ toolCallId: pending.toolCallId,
539
+ output: pending.resolution.output,
540
+ });
541
+ break;
542
+ }
543
+ case 'tool-result-error': {
544
+ owner.message.parts[owner.tracker.partIndex] = transitionToolPart(owner.part, {
545
+ type: 'tool-output-error',
546
+ toolCallId: pending.toolCallId,
547
+ errorText: pending.resolution.message,
548
+ });
549
+ break;
550
+ }
551
+ case 'tool-approval-response': {
552
+ owner.message.parts[owner.tracker.partIndex] = _approvalTransition(
553
+ owner.part,
554
+ pending.resolution.approved,
555
+ pending.resolution.reason,
556
+ );
557
+ break;
558
+ }
559
+ }
560
+ }
561
+ state.pendingToolResolutions = next;
562
+ };
563
+
564
+ // ---------------------------------------------------------------------------
565
+ // UIMessageChunk fold
566
+ // ---------------------------------------------------------------------------
567
+
568
+ const _foldChunk = (state: VercelProjection, chunk: VercelOutput, meta: ReducerMeta): VercelProjection => {
569
+ const messageId = meta.messageId;
570
+ if (messageId === undefined) {
571
+ // Without a target codec-message-id, a chunk has nowhere to land. Drop.
572
+ return state;
573
+ }
574
+
575
+ switch (chunk.type) {
576
+ case 'start':
577
+ case 'start-step':
578
+ case 'finish-step':
579
+ case 'finish':
580
+ case 'abort':
581
+ case 'error':
582
+ case 'message-metadata': {
583
+ return _foldLifecycle(state, chunk, messageId);
584
+ }
585
+
586
+ case 'text-start':
587
+ case 'text-delta':
588
+ case 'text-end':
589
+ case 'reasoning-start':
590
+ case 'reasoning-delta':
591
+ case 'reasoning-end': {
592
+ return _foldTextOrReasoning(state, chunk, messageId);
593
+ }
594
+
595
+ case 'tool-input-start':
596
+ case 'tool-input-delta':
597
+ case 'tool-input-available':
598
+ case 'tool-input-error': {
599
+ return _foldToolInput(state, chunk, messageId);
600
+ }
601
+
602
+ case 'tool-output-available':
603
+ case 'tool-output-error':
604
+ case 'tool-output-denied':
605
+ case 'tool-approval-request': {
606
+ return _foldToolOutput(state, chunk, messageId);
607
+ }
608
+
609
+ case 'file':
610
+ case 'source-url':
611
+ case 'source-document': {
612
+ return _foldContentPart(state, chunk, messageId);
613
+ }
614
+
615
+ default: {
616
+ if (chunk.type.startsWith('data-')) {
617
+ return _foldDataPart(state, chunk, messageId);
618
+ }
619
+ return state;
620
+ }
621
+ }
622
+ };
623
+
624
+ // ---------------------------------------------------------------------------
625
+ // Message + tracker helpers
626
+ // ---------------------------------------------------------------------------
627
+
628
+ const _ensureMessage = (state: VercelProjection, codecMessageId: string): AI.UIMessage => {
629
+ let entry = state.messages.find((e) => e.codecMessageId === codecMessageId);
630
+ if (!entry) {
631
+ // No source id seen yet — seed the domain `message.id` with the
632
+ // codec-message-id as a fallback. The `start` chunk overwrites it with
633
+ // the stream's `messageId` when the stream provides one.
634
+ entry = { codecMessageId, message: { id: codecMessageId, role: 'assistant', parts: [] } };
635
+ state.messages.push(entry);
636
+ }
637
+ return entry.message;
638
+ };
639
+
640
+ const _ensureTrackers = (state: VercelProjection, messageId: string): MessageTrackers => {
641
+ let trackers = state.trackers.get(messageId);
642
+ if (!trackers) {
643
+ trackers = { text: new Map(), reasoning: new Map(), tools: new Map() };
644
+ state.trackers.set(messageId, trackers);
645
+ }
646
+ return trackers;
647
+ };
648
+
649
+ const _getToolPart = (
650
+ message: AI.UIMessage,
651
+ trackers: MessageTrackers,
652
+ toolCallId: string,
653
+ ): { tracker: ToolPartTracker; part: AI.DynamicToolUIPart } | undefined => {
654
+ const tracker = trackers.tools.get(toolCallId);
655
+ if (!tracker) return undefined;
656
+ const part = message.parts[tracker.partIndex];
657
+ if (part?.type !== 'dynamic-tool') return undefined;
658
+ return { tracker, part };
659
+ };
660
+
661
+ // ---------------------------------------------------------------------------
662
+ // Lifecycle events
663
+ // ---------------------------------------------------------------------------
664
+
665
+ const _foldLifecycle = (
666
+ state: VercelProjection,
667
+ chunk: Extract<
668
+ AI.UIMessageChunk,
669
+ { type: 'start' | 'start-step' | 'finish-step' | 'finish' | 'abort' | 'error' | 'message-metadata' }
670
+ >,
671
+ messageId: string,
672
+ ): VercelProjection => {
673
+ switch (chunk.type) {
674
+ case 'start': {
675
+ // The projection entry is keyed on the wire codec-message-id
676
+ // (`messageId`); every subsequent chunk for this message correlates on
677
+ // that, independent of `message.id`. So we faithfully reproduce the
678
+ // stream's own `messageId` on the reconstructed `UIMessage.id` (the
679
+ // value surfaced to the application) without risk of orphaning later
680
+ // chunks. When the stream omits it, the codec-message-id seeded by
681
+ // `_ensureMessage` stands as the fallback id.
682
+ const message = _ensureMessage(state, messageId);
683
+ if (chunk.messageId !== undefined) message.id = chunk.messageId;
684
+ if (chunk.messageMetadata !== undefined) message.metadata = chunk.messageMetadata;
685
+ return state;
686
+ }
687
+ case 'start-step': {
688
+ const message = _ensureMessage(state, messageId);
689
+ message.parts.push({ type: 'step-start' });
690
+ return state;
691
+ }
692
+ case 'finish-step': {
693
+ // Reset text/reasoning stream trackers so a follow-up step can start
694
+ // new parts with potentially-reused stream ids.
695
+ const trackers = state.trackers.get(messageId);
696
+ if (trackers) {
697
+ trackers.text.clear();
698
+ trackers.reasoning.clear();
699
+ }
700
+ return state;
701
+ }
702
+ case 'finish': {
703
+ const message = state.messages.find((e) => e.codecMessageId === messageId)?.message;
704
+ if (message && chunk.messageMetadata !== undefined) {
705
+ message.metadata = chunk.messageMetadata;
706
+ }
707
+ // Tracker state retained — late events still resolvable; cleanup happens at Run end.
708
+ return state;
709
+ }
710
+ case 'abort':
711
+ case 'error': {
712
+ // No state mutation — run termination is observed via the wire run-end
713
+ // event, not the projection.
714
+ return state;
715
+ }
716
+ case 'message-metadata': {
717
+ const message = state.messages.find((e) => e.codecMessageId === messageId)?.message;
718
+ if (message && chunk.messageMetadata !== undefined) {
719
+ message.metadata = chunk.messageMetadata;
720
+ }
721
+ return state;
722
+ }
723
+ }
724
+ };
725
+
726
+ // ---------------------------------------------------------------------------
727
+ // Text and reasoning streaming
728
+ // ---------------------------------------------------------------------------
729
+
730
+ const _foldTextOrReasoning = (
731
+ state: VercelProjection,
732
+ chunk: Extract<
733
+ AI.UIMessageChunk,
734
+ { type: 'text-start' | 'text-delta' | 'text-end' | 'reasoning-start' | 'reasoning-delta' | 'reasoning-end' }
735
+ >,
736
+ messageId: string,
737
+ ): VercelProjection => {
738
+ const message = _ensureMessage(state, messageId);
739
+ const trackers = _ensureTrackers(state, messageId);
740
+
741
+ const isText = chunk.type.startsWith('text-');
742
+ const partType = isText ? 'text' : 'reasoning';
743
+ const activeMap = isText ? trackers.text : trackers.reasoning;
744
+
745
+ switch (chunk.type) {
746
+ case 'text-start':
747
+ case 'reasoning-start': {
748
+ activeMap.set(chunk.id, message.parts.length);
749
+ message.parts.push({ type: partType, text: '' });
750
+ return state;
751
+ }
752
+ case 'text-delta':
753
+ case 'reasoning-delta': {
754
+ const idx = activeMap.get(chunk.id);
755
+ if (idx === undefined) return state;
756
+ const part = message.parts[idx];
757
+ if (part?.type === partType) {
758
+ part.text += chunk.delta;
759
+ }
760
+ return state;
761
+ }
762
+ case 'text-end':
763
+ case 'reasoning-end': {
764
+ activeMap.delete(chunk.id);
765
+ return state;
766
+ }
767
+ }
768
+ };
769
+
770
+ // ---------------------------------------------------------------------------
771
+ // Tool input streaming
772
+ // ---------------------------------------------------------------------------
773
+
774
+ const _foldToolInput = (
775
+ state: VercelProjection,
776
+ chunk: Extract<
777
+ AI.UIMessageChunk,
778
+ { type: 'tool-input-start' | 'tool-input-delta' | 'tool-input-available' | 'tool-input-error' }
779
+ >,
780
+ messageId: string,
781
+ ): VercelProjection => {
782
+ const message = _ensureMessage(state, messageId);
783
+ const trackers = _ensureTrackers(state, messageId);
784
+
785
+ switch (chunk.type) {
786
+ case 'tool-input-start': {
787
+ const partIndex = message.parts.length;
788
+ message.parts.push({ ...toolBase(chunk), state: 'input-streaming', input: undefined });
789
+ trackers.tools.set(chunk.toolCallId, { partIndex, inputText: '' });
790
+ return state;
791
+ }
792
+ case 'tool-input-delta': {
793
+ const tracker = trackers.tools.get(chunk.toolCallId);
794
+ if (!tracker) return state;
795
+ tracker.inputText += chunk.inputTextDelta;
796
+
797
+ let parsedInput: unknown;
798
+ try {
799
+ // CAST: JSON.parse returns any; unknown is the safe trust-boundary type.
800
+ parsedInput = JSON.parse(tracker.inputText) as unknown;
801
+ } catch {
802
+ parsedInput = undefined;
803
+ }
804
+
805
+ const found = _getToolPart(message, trackers, chunk.toolCallId);
806
+ if (!found) return state;
807
+ message.parts[found.tracker.partIndex] = {
808
+ ...toolBase(found.part),
809
+ state: 'input-streaming',
810
+ input: parsedInput,
811
+ };
812
+ return state;
813
+ }
814
+ case 'tool-input-available': {
815
+ const found = _getToolPart(message, trackers, chunk.toolCallId);
816
+ if (!found) return state;
817
+ message.parts[found.tracker.partIndex] = {
818
+ ...toolBase(found.part),
819
+ state: 'input-available',
820
+ input: chunk.input,
821
+ };
822
+ return state;
823
+ }
824
+ case 'tool-input-error': {
825
+ const found = _getToolPart(message, trackers, chunk.toolCallId);
826
+ if (found) {
827
+ message.parts[found.tracker.partIndex] = {
828
+ ...toolBase(found.part),
829
+ state: 'output-error',
830
+ input: chunk.input,
831
+ errorText: chunk.errorText,
832
+ };
833
+ } else {
834
+ const partIndex = message.parts.length;
835
+ message.parts.push({
836
+ ...toolBase(chunk),
837
+ state: 'output-error',
838
+ input: chunk.input,
839
+ errorText: chunk.errorText,
840
+ });
841
+ trackers.tools.set(chunk.toolCallId, { partIndex, inputText: '' });
842
+ }
843
+ return state;
844
+ }
845
+ }
846
+ };
847
+
848
+ // ---------------------------------------------------------------------------
849
+ // Tool output transitions (agent-published chunks)
850
+ // ---------------------------------------------------------------------------
851
+
852
+ const _foldToolOutput = (
853
+ state: VercelProjection,
854
+ chunk: Extract<
855
+ AI.UIMessageChunk,
856
+ { type: 'tool-output-available' | 'tool-output-error' | 'tool-output-denied' | 'tool-approval-request' }
857
+ >,
858
+ messageId: string,
859
+ ): VercelProjection => {
860
+ // `tool-output-available` / `tool-output-error` after an approved tool runs
861
+ // are emitted by streamText's continuation pass under a fresh
862
+ // codec-message-id that differs from the assistant holding the tool call.
863
+ // Resolve the owning part by toolCallId across the whole projection so the
864
+ // output folds onto the original message. Deliberately do NOT materialise
865
+ // `messageId` first — that would leave a phantom empty message behind the
866
+ // fresh id. Drop on miss: a tool output with no matching tool call has no
867
+ // anchor to attach to.
868
+ if (chunk.type === 'tool-output-available' || chunk.type === 'tool-output-error') {
869
+ const owner = _findToolPartOwner(state, chunk.toolCallId);
870
+ if (!owner) return state;
871
+ owner.message.parts[owner.tracker.partIndex] = transitionToolPart(owner.part, chunk);
872
+ return state;
873
+ }
874
+
875
+ // `tool-approval-request` (first pass) creates the part on the run's own
876
+ // message; `tool-output-denied` transitions that same part. Both key on the
877
+ // stamped messageId.
878
+ const message = _ensureMessage(state, messageId);
879
+ const trackers = _ensureTrackers(state, messageId);
880
+
881
+ const found = _getToolPart(message, trackers, chunk.toolCallId);
882
+ if (!found) return state;
883
+
884
+ message.parts[found.tracker.partIndex] = transitionToolPart(found.part, chunk);
885
+ return state;
886
+ };
887
+
888
+ // ---------------------------------------------------------------------------
889
+ // File / source content parts
890
+ // ---------------------------------------------------------------------------
891
+
892
+ const _foldContentPart = (
893
+ state: VercelProjection,
894
+ chunk: Extract<AI.UIMessageChunk, { type: 'file' | 'source-url' | 'source-document' }>,
895
+ messageId: string,
896
+ ): VercelProjection => {
897
+ const message = _ensureMessage(state, messageId);
898
+
899
+ switch (chunk.type) {
900
+ case 'file': {
901
+ message.parts.push({ type: 'file', mediaType: chunk.mediaType, url: chunk.url });
902
+ return state;
903
+ }
904
+ case 'source-url': {
905
+ message.parts.push(
906
+ stripUndefined({
907
+ type: 'source-url' as const,
908
+ sourceId: chunk.sourceId,
909
+ url: chunk.url,
910
+ title: chunk.title,
911
+ }),
912
+ );
913
+ return state;
914
+ }
915
+ case 'source-document': {
916
+ message.parts.push(
917
+ stripUndefined({
918
+ type: 'source-document' as const,
919
+ sourceId: chunk.sourceId,
920
+ mediaType: chunk.mediaType,
921
+ title: chunk.title,
922
+ filename: chunk.filename,
923
+ }),
924
+ );
925
+ return state;
926
+ }
927
+ }
928
+ };
929
+
930
+ // ---------------------------------------------------------------------------
931
+ // data-* parts
932
+ // ---------------------------------------------------------------------------
933
+
934
+ const _foldDataPart = (
935
+ state: VercelProjection,
936
+ chunk: Extract<AI.UIMessageChunk, { type: `data-${string}` }>,
937
+ messageId: string,
938
+ ): VercelProjection => {
939
+ if (chunk.transient) return state;
940
+
941
+ const message = _ensureMessage(state, messageId);
942
+
943
+ // CAST: chunk.type is `data-${string}` which satisfies DataUIPart, but
944
+ // TypeScript cannot verify the template literal matches a specific
945
+ // UIMessagePart variant at the type level.
946
+ const dataPart = stripUndefined({
947
+ type: chunk.type,
948
+ id: chunk.id,
949
+ data: chunk.data,
950
+ }) as AI.UIMessage['parts'][number];
951
+
952
+ if (chunk.id !== undefined) {
953
+ const idx = message.parts.findIndex((p) => p.type === chunk.type && 'id' in p && p.id === chunk.id);
954
+ if (idx !== -1) {
955
+ message.parts[idx] = dataPart;
956
+ return state;
957
+ }
958
+ }
959
+
960
+ message.parts.push(dataPart);
961
+ return state;
962
+ };
963
+
964
+ // ---------------------------------------------------------------------------
965
+ // getMessages
966
+ // ---------------------------------------------------------------------------
967
+
968
+ /**
969
+ * Extract the UIMessage list from a projection, each paired with its
970
+ * codec-message-id. Client-published tool resolutions amend existing
971
+ * assistants in place via `kind: 'tool-result'` etc. — they never
972
+ * materialise as their own UIMessage in the projection, so no filtering is
973
+ * needed here.
974
+ * @param projection - Projection produced by `init` + repeated `fold` calls.
975
+ * @returns The visible messages with their codec-message-ids, in publication order.
976
+ */
977
+ export const getMessages = (projection: VercelProjection): CodecMessage<AI.UIMessage>[] => projection.messages;