@convex-dev/agent 0.6.2 → 0.6.4

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 (73) hide show
  1. package/dist/UIMessages.d.ts.map +1 -1
  2. package/dist/UIMessages.js.map +1 -1
  3. package/dist/client/definePlaygroundAPI.d.ts +69 -64
  4. package/dist/client/definePlaygroundAPI.d.ts.map +1 -1
  5. package/dist/client/definePlaygroundAPI.js +8 -5
  6. package/dist/client/definePlaygroundAPI.js.map +1 -1
  7. package/dist/client/index.d.ts +12 -1
  8. package/dist/client/index.d.ts.map +1 -1
  9. package/dist/client/index.js +11 -2
  10. package/dist/client/index.js.map +1 -1
  11. package/dist/client/saveInputMessages.d.ts.map +1 -1
  12. package/dist/client/saveInputMessages.js.map +1 -1
  13. package/dist/client/types.d.ts +3 -3
  14. package/dist/client/types.d.ts.map +1 -1
  15. package/dist/component/apiKeys.js +5 -5
  16. package/dist/component/apiKeys.js.map +1 -1
  17. package/dist/component/convex.config.d.ts +1 -1
  18. package/dist/component/files.d.ts.map +1 -1
  19. package/dist/component/files.js +13 -11
  20. package/dist/component/files.js.map +1 -1
  21. package/dist/component/messages.d.ts.map +1 -1
  22. package/dist/component/messages.js +37 -27
  23. package/dist/component/messages.js.map +1 -1
  24. package/dist/component/streams.d.ts.map +1 -1
  25. package/dist/component/streams.js +22 -17
  26. package/dist/component/streams.js.map +1 -1
  27. package/dist/component/threads.js +7 -7
  28. package/dist/component/threads.js.map +1 -1
  29. package/dist/component/users.js +2 -2
  30. package/dist/component/users.js.map +1 -1
  31. package/dist/component/vector/index.d.ts.map +1 -1
  32. package/dist/component/vector/index.js +14 -8
  33. package/dist/component/vector/index.js.map +1 -1
  34. package/dist/deltas.d.ts +16 -27
  35. package/dist/deltas.d.ts.map +1 -1
  36. package/dist/deltas.js +269 -286
  37. package/dist/deltas.js.map +1 -1
  38. package/dist/mapping.d.ts +9 -3
  39. package/dist/mapping.d.ts.map +1 -1
  40. package/dist/mapping.js +16 -14
  41. package/dist/mapping.js.map +1 -1
  42. package/dist/react/useStreamingUIMessages.d.ts.map +1 -1
  43. package/dist/react/useStreamingUIMessages.js +42 -26
  44. package/dist/react/useStreamingUIMessages.js.map +1 -1
  45. package/dist/react/useUIMessages.d.ts +1 -0
  46. package/dist/react/useUIMessages.d.ts.map +1 -1
  47. package/dist/react/useUIMessages.js +7 -3
  48. package/dist/react/useUIMessages.js.map +1 -1
  49. package/package.json +7 -6
  50. package/src/UIMessages.ts +1 -2
  51. package/src/client/approval.test.ts +25 -6
  52. package/src/client/createTool.ts +1 -1
  53. package/src/client/definePlaygroundAPI.ts +33 -17
  54. package/src/client/index.test.ts +91 -0
  55. package/src/client/index.ts +25 -1
  56. package/src/client/saveInputMessages.ts +4 -1
  57. package/src/client/streaming.integration.test.ts +39 -117
  58. package/src/client/types.ts +6 -21
  59. package/src/component/apiKeys.ts +5 -5
  60. package/src/component/files.test.ts +1 -1
  61. package/src/component/files.ts +14 -12
  62. package/src/component/messages.ts +40 -28
  63. package/src/component/streams.ts +33 -17
  64. package/src/component/threads.ts +7 -7
  65. package/src/component/users.ts +2 -2
  66. package/src/component/vector/index.ts +14 -7
  67. package/src/deltas.test.ts +373 -392
  68. package/src/deltas.ts +339 -378
  69. package/src/mapping.test.ts +296 -18
  70. package/src/mapping.ts +17 -11
  71. package/src/react/useStreamingUIMessages.ts +62 -34
  72. package/src/react/useUIMessages.test.ts +80 -1
  73. package/src/react/useUIMessages.ts +11 -3
package/src/deltas.ts CHANGED
@@ -3,13 +3,11 @@ import {
3
3
  type DynamicToolUIPart,
4
4
  type ProviderMetadata,
5
5
  type ReasoningUIPart,
6
- type TextStreamPart,
7
6
  type TextUIPart,
8
- type ToolSet,
9
7
  type ToolUIPart,
10
8
  type UIMessageChunk,
11
9
  } from "ai";
12
- import { assert, pick } from "convex-helpers";
10
+ import { assert } from "convex-helpers";
13
11
  import { type UIMessage } from "./UIMessages.js";
14
12
  import { joinText, sorted } from "./shared.js";
15
13
  import {
@@ -17,7 +15,6 @@ import {
17
15
  type StreamDelta,
18
16
  type StreamMessage,
19
17
  } from "./validators.js";
20
- import { getErrorMessage } from "@ai-sdk/provider-utils";
21
18
 
22
19
  export function blankUIMessage<METADATA = unknown>(
23
20
  streamMessage: StreamMessage & { metadata?: METADATA },
@@ -57,6 +54,9 @@ export async function updateFromUIMessageChunks(
57
54
  uiMessage: UIMessage,
58
55
  parts: UIMessageChunk[],
59
56
  ) {
57
+ if (parts.length === 0) {
58
+ return uiMessage;
59
+ }
60
60
  const partsStream = new ReadableStream<UIMessageChunk>({
61
61
  start(controller) {
62
62
  for (const part of parts) {
@@ -72,11 +72,7 @@ export async function updateFromUIMessageChunks(
72
72
  stream: partsStream,
73
73
  onError: (e) => {
74
74
  const errorMessage = e instanceof Error ? e.message : String(e);
75
- // Tool invocation errors can be safely ignored when streaming continuation
76
- // after tool approval - the stored messages have the complete tool context
77
75
  if (errorMessage.toLowerCase().includes("no tool invocation found")) {
78
- // Silently suppress - this is expected after tool approval when the
79
- // continuation stream has tool-result without the original tool-call
80
76
  suppressError = true;
81
77
  return;
82
78
  }
@@ -95,8 +91,6 @@ export async function updateFromUIMessageChunks(
95
91
  message = messagePart;
96
92
  }
97
93
  } catch (e) {
98
- // If we've already handled this error in onError and marked it as suppressed,
99
- // don't rethrow - the stored messages provide the fallback
100
94
  if (!suppressError) {
101
95
  throw e;
102
96
  }
@@ -108,188 +102,89 @@ export async function updateFromUIMessageChunks(
108
102
  return message;
109
103
  }
110
104
 
111
- export async function deriveUIMessagesFromDeltas(
112
- threadId: string,
113
- streamMessages: StreamMessage[],
114
- allDeltas: StreamDelta[],
115
- ): Promise<UIMessage[]> {
116
- const messages: UIMessage[] = [];
117
- for (const streamMessage of streamMessages) {
118
- if (streamMessage.format === "UIMessageChunk") {
119
- const { parts } = getParts<UIMessageChunk>(
120
- allDeltas.filter((d) => d.streamId === streamMessage.streamId),
121
- 0,
122
- );
123
- const uiMessage = await updateFromUIMessageChunks(
124
- blankUIMessage(streamMessage, threadId),
125
- parts,
126
- );
127
- messages.push(uiMessage);
128
- } else {
129
- const [uiMessages] = deriveUIMessagesFromTextStreamParts(
130
- threadId,
131
- [streamMessage],
132
- [],
133
- allDeltas,
134
- );
135
- messages.push(...uiMessages);
136
- }
137
- }
138
- return sorted(messages);
139
- }
105
+ type ToolPart = ToolUIPart | DynamicToolUIPart;
140
106
 
141
- /**
142
- *
143
- */
144
-
145
- export function deriveUIMessagesFromTextStreamParts(
146
- threadId: string,
147
- streamMessages: StreamMessage[],
148
- existingStreams: Array<{
149
- streamId: string;
150
- cursor: number;
151
- message: UIMessage;
152
- }>,
153
- allDeltas: StreamDelta[],
154
- ): [
155
- UIMessage[],
156
- Array<{ streamId: string; cursor: number; message: UIMessage }>,
157
- boolean,
158
- ] {
159
- const newStreams: Array<{
160
- streamId: string;
161
- cursor: number;
162
- message: UIMessage;
163
- }> = [];
164
- // Seed the existing chunks
165
- let changed = false;
166
- for (const streamMessage of streamMessages) {
167
- const deltas = allDeltas.filter(
168
- (d) => d.streamId === streamMessage.streamId,
169
- );
170
- const existing = existingStreams.find(
171
- (s) => s.streamId === streamMessage.streamId,
172
- );
173
- const [newStream, messageChanged] = updateFromTextStreamParts(
174
- threadId,
175
- streamMessage,
176
- existing,
177
- deltas,
178
- );
179
- newStreams.push(newStream);
180
- if (messageChanged) changed = true;
181
- }
182
- for (const { streamId } of existingStreams) {
183
- if (!newStreams.find((s) => s.streamId === streamId)) {
184
- // There's a stream that's no longer active.
185
- changed = true;
186
- }
187
- }
188
- const messages = sorted(newStreams.map((s) => s.message));
189
- return [messages, newStreams, changed];
107
+ function transitionToolPart<S extends ToolPart["state"]>(
108
+ part: ToolPart,
109
+ updates: { state: S } & Partial<Extract<ToolPart, { state: S }>>,
110
+ ): void {
111
+ Object.assign(part, updates);
190
112
  }
191
113
 
192
- export function getParts<T extends StreamDelta["parts"][number]>(
193
- deltas: StreamDelta[],
194
- fromCursor?: number,
195
- ): { parts: T[]; cursor: number } {
196
- const parts: T[] = [];
197
- let cursor = fromCursor ?? 0;
198
- for (const delta of deltas.sort((a, b) => a.start - b.start)) {
199
- if (delta.parts.length === 0) {
200
- console.debug(`Got delta with no parts: ${JSON.stringify(delta)}`);
201
- continue;
202
- }
203
- if (cursor !== delta.start) {
204
- if (cursor >= delta.end) {
205
- continue;
206
- } else if (cursor < delta.start) {
207
- console.warn(
208
- `Got delta for stream ${delta.streamId} that has a gap ${cursor} -> ${delta.start}`,
209
- );
210
- break;
211
- } else {
212
- throw new Error(
213
- `Got unexpected delta for stream ${delta.streamId}: delta: ${delta.start} -> ${delta.end} existing cursor: ${cursor}`,
214
- );
215
- }
216
- }
217
- parts.push(...delta.parts);
218
- cursor = delta.end;
219
- }
220
- return { parts, cursor };
114
+ export type IncrementalStreamState = {
115
+ // chunk id -> index of the streaming text part in message.parts
116
+ activeText: Record<string, number>;
117
+ // chunk id -> index of the streaming reasoning part in message.parts
118
+ activeReasoning: Record<string, number>;
119
+ // toolCallId -> raw accumulated input JSON text (kept separate from the
120
+ // parsed `input` so partial JSON can be repair-parsed each batch)
121
+ toolInputText: Record<string, string>;
122
+ };
123
+
124
+ export function emptyIncrementalStreamState(): IncrementalStreamState {
125
+ return { activeText: {}, activeReasoning: {}, toolInputText: {} };
221
126
  }
222
127
 
223
128
  /**
224
- * This is historically from when we would use the onChunk callback instead of
225
- * consuming the full UIMessageStream.
129
+ * Apply a batch of new UIMessageChunks to an existing UIMessage without
130
+ * replaying prior chunks. `prev` carries the ephemeral stream state that the
131
+ * UIMessage itself can't hold (which text/reasoning parts are still streaming,
132
+ * and the raw accumulated tool input text). Parts are append-only, so part
133
+ * indices stay stable across the structuredClone between batches. Behavior
134
+ * mirrors the AI SDK's processUIMessageStream.
226
135
  */
136
+ export function applyUIMessageChunksIncremental(
137
+ uiMessage: UIMessage,
138
+ newParts: UIMessageChunk[],
139
+ prev: IncrementalStreamState,
140
+ ): { message: UIMessage; streamState: IncrementalStreamState } {
141
+ const message: UIMessage = structuredClone(uiMessage);
142
+ const activeText: Record<string, number> = { ...prev.activeText };
143
+ const activeReasoning: Record<string, number> = { ...prev.activeReasoning };
144
+ const toolInputText: Record<string, string> = { ...prev.toolInputText };
145
+ const touchedTools = new Set<string>();
227
146
 
228
- // exported for testing
229
- export function updateFromTextStreamParts(
230
- threadId: string,
231
- streamMessage: StreamMessage,
232
- existing:
233
- | { streamId: string; cursor: number; message: UIMessage }
234
- | undefined,
235
- deltas: StreamDelta[],
236
- ): [{ streamId: string; cursor: number; message: UIMessage }, boolean] {
237
- const { cursor, parts } = getParts<TextStreamPart<ToolSet>>(
238
- deltas,
239
- existing?.cursor,
240
- );
241
- const changed =
242
- parts.length > 0 ||
243
- (existing &&
244
- statusFromStreamStatus(streamMessage.status) !== existing.message.status);
245
- const existingMessage =
246
- existing?.message ?? blankUIMessage(streamMessage, threadId);
247
- if (!changed) {
248
- return [
249
- existing ?? {
250
- streamId: streamMessage.streamId,
251
- cursor,
252
- message: existingMessage,
253
- },
254
- false,
255
- ];
256
- }
257
-
258
- const message: UIMessage = structuredClone(existingMessage);
259
- message.status = statusFromStreamStatus(streamMessage.status);
260
-
261
- const textPartsById = new Map<string, TextUIPart>();
262
- const toolPartsById = new Map<string, ToolUIPart | DynamicToolUIPart>(
263
- message.parts
264
- .filter(
265
- (p): p is ToolUIPart | DynamicToolUIPart =>
266
- p.type.startsWith("tool-") || p.type === "dynamic-tool",
267
- )
268
- .map((p) => [p.toolCallId, p]),
269
- );
270
- const reasoningPartsById = new Map<string, ReasoningUIPart>();
147
+ const toolIndexById = new Map<string, number>();
148
+ message.parts.forEach((p, i) => {
149
+ if (
150
+ "toolCallId" in p &&
151
+ (p.type.startsWith("tool-") || p.type === "dynamic-tool")
152
+ ) {
153
+ toolIndexById.set((p as ToolPart).toolCallId, i);
154
+ }
155
+ });
156
+ const toolPartAt = (toolCallId: string): ToolPart | undefined => {
157
+ const idx = toolIndexById.get(toolCallId);
158
+ return idx === undefined ? undefined : (message.parts[idx] as ToolPart);
159
+ };
160
+ const mergeMetadata = (metadata: unknown) => {
161
+ if (metadata == null) {
162
+ return;
163
+ }
164
+ message.metadata = {
165
+ ...(message.metadata as Record<string, unknown> | undefined),
166
+ ...(metadata as Record<string, unknown>),
167
+ } as typeof message.metadata;
168
+ };
271
169
 
272
- for (const part of parts) {
170
+ for (const part of newParts) {
273
171
  switch (part.type) {
274
- case "text-start":
172
+ case "text-start": {
173
+ const newPart: TextUIPart = {
174
+ type: "text",
175
+ text: "",
176
+ state: "streaming",
177
+ providerMetadata: part.providerMetadata,
178
+ };
179
+ message.parts.push(newPart);
180
+ activeText[part.id] = message.parts.length - 1;
181
+ break;
182
+ }
275
183
  case "text-delta": {
276
- if (!textPartsById.has(part.id)) {
277
- const lastPart = message.parts.at(-1);
278
- if (lastPart?.type === "text") {
279
- textPartsById.set(part.id, lastPart);
280
- } else {
281
- const newPart = {
282
- type: "text",
283
- text: "",
284
- providerMetadata: part.providerMetadata,
285
- } satisfies TextUIPart;
286
- textPartsById.set(part.id, newPart);
287
- message.parts.push(newPart);
288
- }
289
- }
290
- if (part.type === "text-delta") {
291
- const textPart = textPartsById.get(part.id)!;
292
- textPart.text += part.text;
184
+ const idx = activeText[part.id];
185
+ if (idx !== undefined) {
186
+ const textPart = message.parts[idx] as TextUIPart;
187
+ textPart.text += part.delta;
293
188
  textPart.providerMetadata = mergeProviderMetadata(
294
189
  textPart.providerMetadata,
295
190
  part.providerMetadata,
@@ -297,139 +192,35 @@ export function updateFromTextStreamParts(
297
192
  }
298
193
  break;
299
194
  }
300
- case "tool-input-start": {
301
- let newPart: ToolUIPart | DynamicToolUIPart;
302
- if (part.dynamic) {
303
- newPart = {
304
- type: "dynamic-tool",
305
- toolCallId: part.id,
306
- toolName: part.toolName,
307
- state: "input-streaming",
308
- input: "",
309
- } satisfies DynamicToolUIPart;
310
- } else {
311
- newPart = {
312
- type: `tool-${part.toolName}`,
313
- toolCallId: part.id,
314
- state: "input-streaming",
315
- input: "",
316
- providerExecuted: part.providerExecuted,
317
- } satisfies ToolUIPart;
318
- }
319
- toolPartsById.set(part.id, newPart);
320
- message.parts.push(newPart);
321
- break;
322
- }
323
- case "tool-input-delta":
324
- {
325
- const toUpdate = toolPartsById.get(part.id);
326
- assert(
327
- toUpdate,
328
- `Expected to find tool call part ${part.id} to update`,
329
- );
330
- toUpdate.input = (toUpdate.input ?? "") + part.delta;
331
- }
332
- break;
333
- case "tool-input-end":
334
- {
335
- const toUpdate = toolPartsById.get(part.id);
336
- assert(
337
- toUpdate,
338
- `Expected to find tool call part ${part.id} to update`,
195
+ case "text-end": {
196
+ const idx = activeText[part.id];
197
+ if (idx !== undefined) {
198
+ const textPart = message.parts[idx] as TextUIPart;
199
+ textPart.state = "done";
200
+ textPart.providerMetadata = mergeProviderMetadata(
201
+ textPart.providerMetadata,
202
+ part.providerMetadata,
339
203
  );
340
- toUpdate.state = "input-available";
341
- if (part.providerMetadata) {
342
- const updatable = toUpdate as Extract<
343
- ToolUIPart | DynamicToolUIPart,
344
- { state: "input-available" }
345
- >;
346
- updatable.callProviderMetadata = mergeProviderMetadata(
347
- updatable.callProviderMetadata,
348
- part.providerMetadata,
349
- );
350
- }
351
- }
352
- break;
353
- case "tool-call": {
354
- let newPart: ToolUIPart | DynamicToolUIPart;
355
- if (part.dynamic) {
356
- newPart = {
357
- type: "dynamic-tool",
358
- toolCallId: part.toolCallId,
359
- toolName: part.toolName,
360
- input: part.input,
361
- state: "input-available",
362
- };
363
- } else {
364
- newPart = {
365
- type: `tool-${part.toolName}`,
366
- toolCallId: part.toolCallId,
367
- input: part.input,
368
- state: "input-available",
369
- };
370
- if (part.providerExecuted) {
371
- newPart.providerExecuted = part.providerExecuted;
372
- }
373
- }
374
- if (part.providerMetadata) {
375
- newPart.callProviderMetadata = part.providerMetadata;
376
- }
377
- if (toolPartsById.has(part.toolCallId)) {
378
- const toUpdate = toolPartsById.get(part.toolCallId)!;
379
- Object.assign(toUpdate, newPart);
380
- } else {
381
- toolPartsById.set(part.toolCallId, newPart);
382
- message.parts.push(newPart);
204
+ delete activeText[part.id];
383
205
  }
384
206
  break;
385
207
  }
386
- case "tool-result": {
387
- const toolCall = toolPartsById.get(part.toolCallId);
388
- assert(
389
- toolCall,
390
- `Expected to find tool call part ${part.toolCallId} to update with result`,
391
- );
392
- let newPart: ToolUIPart | DynamicToolUIPart;
393
- if (toolCall.type === "dynamic-tool") {
394
- newPart = {
395
- ...toolCall,
396
- state: "output-available",
397
- input: part.input ?? toolCall.input,
398
- output: part.output ?? toolCall.output,
399
- ...pick(part, ["preliminary"]),
400
- } as DynamicToolUIPart;
401
- } else {
402
- newPart = {
403
- ...toolCall,
404
- state: "output-available",
405
- input: part.input ?? toolCall.input,
406
- output: part.output ?? toolCall.output,
407
- preliminary: part.preliminary,
408
- } as ToolUIPart;
409
- }
410
- Object.assign(toolCall, newPart);
208
+ case "reasoning-start": {
209
+ const newPart: ReasoningUIPart = {
210
+ type: "reasoning",
211
+ text: "",
212
+ state: "streaming",
213
+ providerMetadata: part.providerMetadata,
214
+ };
215
+ message.parts.push(newPart);
216
+ activeReasoning[part.id] = message.parts.length - 1;
411
217
  break;
412
218
  }
413
- case "reasoning-start":
414
219
  case "reasoning-delta": {
415
- if (!reasoningPartsById.has(part.id)) {
416
- const lastPart = message.parts.at(-1);
417
- if (lastPart?.type === "reasoning") {
418
- reasoningPartsById.set(part.id, lastPart);
419
- } else {
420
- const newPart = {
421
- type: "reasoning",
422
- state: "streaming",
423
- text: "",
424
- providerMetadata: part.providerMetadata,
425
- } satisfies ReasoningUIPart;
426
- reasoningPartsById.set(part.id, newPart);
427
- message.parts.push(newPart);
428
- }
429
- }
430
- const reasoningPart = reasoningPartsById.get(part.id)!;
431
- if (part.type === "reasoning-delta") {
432
- reasoningPart.text += part.text;
220
+ const idx = activeReasoning[part.id];
221
+ if (idx !== undefined) {
222
+ const reasoningPart = message.parts[idx] as ReasoningUIPart;
223
+ reasoningPart.text += part.delta;
433
224
  reasoningPart.providerMetadata = mergeProviderMetadata(
434
225
  reasoningPart.providerMetadata,
435
226
  part.providerMetadata,
@@ -438,111 +229,281 @@ export function updateFromTextStreamParts(
438
229
  break;
439
230
  }
440
231
  case "reasoning-end": {
441
- const reasoningPart =
442
- reasoningPartsById.get(part.id) ??
443
- message.parts.find(
444
- (p): p is ReasoningUIPart =>
445
- p.type === "reasoning" && p.state === "streaming",
446
- )!;
447
- if (reasoningPart) {
232
+ const idx = activeReasoning[part.id];
233
+ if (idx !== undefined) {
234
+ const reasoningPart = message.parts[idx] as ReasoningUIPart;
448
235
  reasoningPart.state = "done";
236
+ reasoningPart.providerMetadata = mergeProviderMetadata(
237
+ reasoningPart.providerMetadata,
238
+ part.providerMetadata,
239
+ );
240
+ delete activeReasoning[part.id];
241
+ }
242
+ break;
243
+ }
244
+ case "tool-input-start": {
245
+ const newToolPart: ToolUIPart | DynamicToolUIPart = part.dynamic
246
+ ? ({
247
+ type: "dynamic-tool",
248
+ toolCallId: part.toolCallId,
249
+ toolName: part.toolName,
250
+ state: "input-streaming",
251
+ input: undefined,
252
+ } satisfies DynamicToolUIPart)
253
+ : ({
254
+ type: `tool-${part.toolName}`,
255
+ toolCallId: part.toolCallId,
256
+ state: "input-streaming",
257
+ input: undefined,
258
+ providerExecuted: part.providerExecuted,
259
+ } satisfies ToolUIPart);
260
+ message.parts.push(newToolPart);
261
+ toolIndexById.set(part.toolCallId, message.parts.length - 1);
262
+ toolInputText[part.toolCallId] = "";
263
+ break;
264
+ }
265
+ case "tool-input-delta": {
266
+ if (toolIndexById.has(part.toolCallId)) {
267
+ toolInputText[part.toolCallId] =
268
+ (toolInputText[part.toolCallId] ?? "") + part.inputTextDelta;
269
+ touchedTools.add(part.toolCallId);
449
270
  } else {
450
271
  console.warn(
451
- `Expected to find reasoning part ${part.id} to finish, but found none`,
272
+ `tool-input-delta for unknown toolCallId ${part.toolCallId}`,
452
273
  );
453
274
  }
454
275
  break;
455
276
  }
456
- case "source":
457
- if (part.sourceType === "url") {
458
- message.parts.push({
459
- type: "source-url",
460
- url: part.url,
461
- sourceId: part.id,
462
- providerMetadata: part.providerMetadata,
463
- title: part.title,
277
+ case "tool-input-available": {
278
+ const toolPart = toolPartAt(part.toolCallId);
279
+ if (toolPart) {
280
+ transitionToolPart(toolPart, {
281
+ state: "input-available",
282
+ input: part.input,
283
+ callProviderMetadata: mergeProviderMetadata(
284
+ (toolPart as { callProviderMetadata?: ProviderMetadata })
285
+ .callProviderMetadata,
286
+ part.providerMetadata,
287
+ ),
464
288
  });
465
- } else if (part.sourceType === "document") {
466
- message.parts.push({
467
- type: "source-document",
468
- mediaType: part.mediaType,
469
- sourceId: part.id,
470
- title: part.title,
471
- filename: part.filename,
472
- providerMetadata: part.providerMetadata,
289
+ }
290
+ touchedTools.delete(part.toolCallId);
291
+ // The raw JSON buffer is no longer needed; drop it so it doesn't get
292
+ // carried through every later batch on the hot path.
293
+ delete toolInputText[part.toolCallId];
294
+ break;
295
+ }
296
+ case "tool-input-error": {
297
+ const toolPart = toolPartAt(part.toolCallId);
298
+ if (toolPart) {
299
+ transitionToolPart(toolPart, {
300
+ state: "output-error",
301
+ errorText: part.errorText,
302
+ providerExecuted: part.providerExecuted,
303
+ ...(toolPart.type === "dynamic-tool"
304
+ ? { input: part.input }
305
+ : { input: undefined, rawInput: part.input }),
306
+ callProviderMetadata: mergeProviderMetadata(
307
+ (toolPart as { callProviderMetadata?: ProviderMetadata })
308
+ .callProviderMetadata,
309
+ part.providerMetadata,
310
+ ),
473
311
  });
474
- } else {
475
- console.warn("Got source part with unknown source type", part);
476
312
  }
313
+ touchedTools.delete(part.toolCallId);
314
+ delete toolInputText[part.toolCallId];
477
315
  break;
478
- case "abort":
479
- message.status = "failed";
316
+ }
317
+ case "tool-output-available": {
318
+ const toolPart = toolPartAt(part.toolCallId);
319
+ if (toolPart) {
320
+ transitionToolPart(toolPart, {
321
+ state: "output-available",
322
+ output: part.output,
323
+ preliminary: part.preliminary,
324
+ providerExecuted: part.providerExecuted,
325
+ });
326
+ }
480
327
  break;
481
- case "error":
482
- message.status = "failed";
483
- console.warn("Generation failed with error", part.error);
328
+ }
329
+ case "tool-output-error": {
330
+ const toolPart = toolPartAt(part.toolCallId);
331
+ if (toolPart) {
332
+ transitionToolPart(toolPart, {
333
+ state: "output-error",
334
+ errorText: part.errorText,
335
+ providerExecuted: part.providerExecuted,
336
+ });
337
+ }
484
338
  break;
485
- case "tool-error": {
486
- const toolPart = toolPartsById.get(part.toolCallId);
339
+ }
340
+ case "tool-output-denied": {
341
+ const toolPart = toolPartAt(part.toolCallId);
487
342
  if (toolPart) {
488
- toolPart.errorText = getErrorMessage(part.error);
343
+ transitionToolPart(toolPart, { state: "output-denied" });
489
344
  }
490
345
  break;
491
346
  }
492
347
  case "tool-approval-request": {
493
- const typedPart = part as unknown as {
494
- type: "tool-approval-request";
495
- toolCallId: string;
496
- approvalId: string;
497
- };
498
- const toolPart = toolPartsById.get(typedPart.toolCallId);
348
+ const toolPart = toolPartAt(part.toolCallId);
499
349
  if (toolPart) {
500
- toolPart.state = "approval-requested";
501
- (toolPart as ToolUIPart & { approval?: object }).approval = {
502
- id: typedPart.approvalId,
503
- };
504
- } else {
505
- console.warn(
506
- `Expected tool call part ${typedPart.toolCallId} for approval request`,
507
- );
350
+ transitionToolPart(toolPart, {
351
+ state: "approval-requested",
352
+ approval: { id: part.approvalId },
353
+ });
508
354
  }
509
355
  break;
510
356
  }
357
+ case "source-url":
358
+ message.parts.push({
359
+ type: "source-url",
360
+ url: part.url,
361
+ sourceId: part.sourceId,
362
+ title: part.title,
363
+ providerMetadata: part.providerMetadata,
364
+ });
365
+ break;
366
+ case "source-document":
367
+ message.parts.push({
368
+ type: "source-document",
369
+ mediaType: part.mediaType,
370
+ sourceId: part.sourceId,
371
+ title: part.title,
372
+ filename: part.filename,
373
+ providerMetadata: part.providerMetadata,
374
+ });
375
+ break;
511
376
  case "file":
512
- case "text-end":
513
- case "finish-step":
514
- case "finish":
515
- case "raw":
377
+ message.parts.push({
378
+ type: "file",
379
+ mediaType: part.mediaType,
380
+ url: part.url,
381
+ });
382
+ break;
516
383
  case "start-step":
384
+ message.parts.push({ type: "step-start" });
385
+ break;
386
+ case "finish-step":
387
+ // Match the SDK: a new step starts fresh streaming parts; the prior
388
+ // parts keep their state rather than being forced to "done".
389
+ for (const id of Object.keys(activeText)) delete activeText[id];
390
+ for (const id of Object.keys(activeReasoning))
391
+ delete activeReasoning[id];
392
+ break;
517
393
  case "start":
518
- // ignore
394
+ case "finish":
395
+ case "message-metadata":
396
+ mergeMetadata(part.messageMetadata);
397
+ break;
398
+ case "abort":
399
+ case "error":
400
+ // The stream-level status (statusFromStreamStatus) is authoritative and
401
+ // is applied by the caller; nothing to mutate on the message here.
519
402
  break;
520
403
  default: {
521
- // Exhaustiveness check disabled intentionally for forwards compatibility.
522
- // New TextStreamPart types from future AI SDK versions will trigger a
523
- // runtime warning rather than a compile error, allowing graceful degradation.
524
- // const _: never = part;
525
- console.warn(`Received unexpected part: ${JSON.stringify(part)}`);
404
+ if (typeof part.type === "string" && part.type.startsWith("data-")) {
405
+ const dataPart = part as Extract<
406
+ UIMessageChunk,
407
+ { type: `data-${string}` }
408
+ >;
409
+ const existingIdx =
410
+ dataPart.id != null
411
+ ? message.parts.findIndex(
412
+ (p) =>
413
+ p.type === dataPart.type &&
414
+ (p as { id?: string }).id === dataPart.id,
415
+ )
416
+ : -1;
417
+ if (existingIdx >= 0) {
418
+ (message.parts[existingIdx] as { data?: unknown }).data =
419
+ dataPart.data;
420
+ } else {
421
+ message.parts.push(
422
+ dataPart as unknown as UIMessage["parts"][number],
423
+ );
424
+ }
425
+ } else {
426
+ console.warn(
427
+ `applyUIMessageChunksIncremental: unhandled chunk type ${String(part.type)}`,
428
+ );
429
+ }
526
430
  break;
527
431
  }
528
432
  }
529
433
  }
530
- // Consider reasoning done once something else happens
531
- for (let i = 0; i < message.parts.length - 1; i++) {
532
- const part = message.parts[i];
533
- if (part.type === "reasoning") {
534
- part.state = "done";
434
+
435
+ for (const toolCallId of touchedTools) {
436
+ const toolPart = toolPartAt(toolCallId);
437
+ if (toolPart && toolPart.state === "input-streaming") {
438
+ try {
439
+ toolPart.input = JSON.parse(toolInputText[toolCallId] ?? "");
440
+ } catch {
441
+ // partial JSON — leave input unset until complete
442
+ }
535
443
  }
536
444
  }
445
+
537
446
  message.text = joinText(message.parts);
538
- return [
539
- {
540
- streamId: streamMessage.streamId,
541
- cursor,
542
- message,
543
- },
544
- true,
545
- ];
447
+ return {
448
+ message,
449
+ streamState: { activeText, activeReasoning, toolInputText },
450
+ };
451
+ }
452
+
453
+ export async function deriveUIMessagesFromDeltas(
454
+ threadId: string,
455
+ streamMessages: StreamMessage[],
456
+ allDeltas: StreamDelta[],
457
+ ): Promise<UIMessage[]> {
458
+ const messages: UIMessage[] = [];
459
+ for (const streamMessage of streamMessages) {
460
+ if (streamMessage.format !== "UIMessageChunk") {
461
+ throw new Error(
462
+ `deriveUIMessagesFromDeltas: unsupported stream format "${streamMessage.format ?? "text"}" for stream ${streamMessage.streamId}`,
463
+ );
464
+ }
465
+ const { parts } = getParts<UIMessageChunk>(
466
+ allDeltas.filter((d) => d.streamId === streamMessage.streamId),
467
+ 0,
468
+ );
469
+ const uiMessage = await updateFromUIMessageChunks(
470
+ blankUIMessage(streamMessage, threadId),
471
+ parts,
472
+ );
473
+ messages.push(uiMessage);
474
+ }
475
+ return sorted(messages);
476
+ }
477
+
478
+ export function getParts<T extends StreamDelta["parts"][number]>(
479
+ deltas: StreamDelta[],
480
+ fromCursor?: number,
481
+ ): { parts: T[]; cursor: number } {
482
+ const parts: T[] = [];
483
+ let cursor = fromCursor ?? 0;
484
+ for (const delta of deltas.sort((a, b) => a.start - b.start)) {
485
+ if (delta.parts.length === 0) {
486
+ console.debug(`Got delta with no parts: ${JSON.stringify(delta)}`);
487
+ continue;
488
+ }
489
+ if (cursor !== delta.start) {
490
+ if (cursor >= delta.end) {
491
+ continue;
492
+ } else if (cursor < delta.start) {
493
+ console.warn(
494
+ `Got delta for stream ${delta.streamId} that has a gap ${cursor} -> ${delta.start}`,
495
+ );
496
+ break;
497
+ } else {
498
+ throw new Error(
499
+ `Got unexpected delta for stream ${delta.streamId}: delta: ${delta.start} -> ${delta.end} existing cursor: ${cursor}`,
500
+ );
501
+ }
502
+ }
503
+ parts.push(...delta.parts);
504
+ cursor = delta.end;
505
+ }
506
+ return { parts, cursor };
546
507
  }
547
508
 
548
509
  function mergeProviderMetadata(