@tanstack/ai 0.49.1 → 0.51.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.
@@ -103,7 +103,6 @@ import type {
103
103
  Interrupt,
104
104
  JSONSchema,
105
105
  LazyToolsConfig,
106
- MessagesSnapshotEvent,
107
106
  ModelMessage,
108
107
  ProviderTool,
109
108
  RunFinishedEvent,
@@ -2756,9 +2755,9 @@ class TextEngine<
2756
2755
  return {
2757
2756
  type: EventType.MESSAGES_SNAPSHOT,
2758
2757
  timestamp: Date.now(),
2759
- messages: uiMessagesToWire(
2760
- modelMessagesToUIMessages(withIds),
2761
- ) as MessagesSnapshotEvent['messages'],
2758
+ messages: uiMessagesToWire(modelMessagesToUIMessages(withIds), {
2759
+ includeSnapshotStructuredOutput: true,
2760
+ }),
2762
2761
  }
2763
2762
  }
2764
2763
 
@@ -1,5 +1,8 @@
1
1
  import { isProviderExecutedToolCall } from '../../utilities/provider-executed'
2
- import { normalizeToolResult } from '../../utilities/tool-result'
2
+ import {
3
+ isContentPartArray,
4
+ normalizeToolResult,
5
+ } from '../../utilities/tool-result'
3
6
  import { tanstackMetadata } from '../../utilities/merge-metadata'
4
7
  import type { Message as AGUIMessage } from '@ag-ui/core'
5
8
  import type {
@@ -7,11 +10,17 @@ import type {
7
10
  MessagePart,
8
11
  ModelMessage,
9
12
  StructuredOutputPart,
13
+ TanStackMessageMetadata,
10
14
  TextPart,
11
15
  ToolCall,
12
16
  ToolCallPart,
13
17
  UIMessage,
18
+ UIResourcePart,
14
19
  } from '../../types'
20
+
21
+ function isRecord(value: unknown): value is Record<string, unknown> {
22
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
23
+ }
15
24
  // ===========================
16
25
  // Message Converters
17
26
  // ===========================
@@ -56,6 +65,9 @@ function toolCallFromWire(toolCall: ToolCall, bag: unknown): ToolCall {
56
65
  ? bag
57
66
  : undefined
58
67
  const encrypted = encryptedValueFrom(toolCall)
68
+ if (bag === null && encrypted === undefined) {
69
+ return { ...toolCall, metadata: null }
70
+ }
59
71
  if (fromBag === undefined && encrypted === undefined) return toolCall
60
72
  return {
61
73
  ...toolCall,
@@ -112,6 +124,12 @@ function getTextContent(
112
124
  .join('')
113
125
  }
114
126
 
127
+ function toolResultContent(
128
+ content: string | null | undefined | Array<ContentPart>,
129
+ ): string | Array<ContentPart> {
130
+ return Array.isArray(content) ? content : getTextContent(content)
131
+ }
132
+
115
133
  /**
116
134
  * Convert UIMessages or ModelMessages to ModelMessages
117
135
  */
@@ -140,12 +158,15 @@ export function convertMessagesToModelMessages(
140
158
  continue
141
159
  }
142
160
 
143
- const role = (msg as { role: string }).role
161
+ const modelMessage = restoreModelMessageCreatedAt(
162
+ restoreToolResultOwnership(msg),
163
+ )
164
+ const role = (modelMessage as { role: string }).role
144
165
 
145
166
  if (
146
167
  role === 'tool' &&
147
- msg.toolCallId &&
148
- anchoredToolCallIds.has(msg.toolCallId)
168
+ modelMessage.toolCallId &&
169
+ anchoredToolCallIds.has(modelMessage.toolCallId)
149
170
  ) {
150
171
  continue
151
172
  }
@@ -170,6 +191,11 @@ export function convertMessagesToModelMessages(
170
191
  modelMessages.push({
171
192
  role: 'system' as ModelMessage['role'],
172
193
  content: (msg as { content: string }).content,
194
+ ...optionalName(modelMessage),
195
+ ...optionalCreatedAt(modelMessage),
196
+ ...(modelMessage.metadata !== undefined && {
197
+ metadata: modelMessage.metadata,
198
+ }),
173
199
  })
174
200
  continue
175
201
  }
@@ -187,7 +213,7 @@ export function convertMessagesToModelMessages(
187
213
  part.type === 'text' && 'text' in part && !('content' in part),
188
214
  )
189
215
  ) {
190
- modelMessages.push(msg as ModelMessage)
216
+ modelMessages.push(modelMessage)
191
217
  continue
192
218
  }
193
219
  const parts = aguiUserContentToParts(
@@ -200,12 +226,17 @@ export function convertMessagesToModelMessages(
200
226
  ...((msg as { id?: string }).id !== undefined && {
201
227
  id: (msg as { id: string }).id,
202
228
  }),
229
+ ...optionalName(modelMessage),
230
+ ...optionalCreatedAt(modelMessage),
231
+ ...(modelMessage.metadata !== undefined && {
232
+ metadata: modelMessage.metadata,
233
+ }),
203
234
  })
204
235
  continue
205
236
  }
206
237
 
207
238
  if (role === 'assistant') {
208
- const source = msg as ModelMessage
239
+ const source = modelMessage
209
240
  const toolCallMetadata = tanstackMetadata(msg)?.toolCallMetadata
210
241
  const toolCalls = source.toolCalls?.map((toolCall) =>
211
242
  toolCallFromWire(toolCall, toolCallMetadata?.[toolCall.id]),
@@ -223,11 +254,155 @@ export function convertMessagesToModelMessages(
223
254
  continue
224
255
  }
225
256
 
226
- modelMessages.push(msg as ModelMessage)
257
+ modelMessages.push(modelMessage)
227
258
  }
228
259
  return modelMessages
229
260
  }
230
261
 
262
+ function restoreModelMessageCreatedAt(message: ModelMessage): ModelMessage {
263
+ const createdAt =
264
+ coerceCreatedAt(message.createdAt) ?? createdAtFromMetadata(message)
265
+ if (createdAt === undefined) {
266
+ if (message.createdAt === undefined) return message
267
+ const { createdAt: _invalid, ...rest } = message
268
+ return rest
269
+ }
270
+ return Object.is(message.createdAt, createdAt)
271
+ ? message
272
+ : { ...message, createdAt }
273
+ }
274
+
275
+ export function restoreToolResultOwnership<T extends object>(message: T): T {
276
+ if (!('role' in message) || message.role !== 'tool') return message
277
+ const source = message
278
+ const metadata = tanstackMetadata(source)
279
+ const owned = metadata?.toolResult
280
+ if (!isRecord(owned)) return message
281
+ if ('content' in owned && !isContentPartArray(owned.content)) return message
282
+ const next = { ...message }
283
+ if (!('id' in owned)) Reflect.deleteProperty(next, 'id')
284
+ if (!('createdAt' in owned)) Reflect.deleteProperty(next, 'createdAt')
285
+ if (typeof owned.id === 'string') Reflect.set(next, 'id', owned.id)
286
+ if (typeof owned.createdAt === 'string') {
287
+ const createdAt = coerceCreatedAt(owned.createdAt)
288
+ if (createdAt) Reflect.set(next, 'createdAt', createdAt)
289
+ }
290
+ if (isContentPartArray(owned.content)) {
291
+ Reflect.set(next, 'content', owned.content)
292
+ }
293
+ const sourceMetadata: unknown = Reflect.get(source, 'metadata')
294
+ if (isRecord(sourceMetadata)) {
295
+ const tanstack = sourceMetadata.tanstack
296
+ if (isRecord(tanstack)) {
297
+ const { toolResult: _toolResult, ...restTanstack } = tanstack
298
+ const restMetadata = { ...sourceMetadata }
299
+ if (Object.keys(restTanstack).length) restMetadata.tanstack = restTanstack
300
+ else delete restMetadata.tanstack
301
+ if (Object.keys(restMetadata).length)
302
+ Reflect.set(next, 'metadata', restMetadata)
303
+ else Reflect.deleteProperty(next, 'metadata')
304
+ }
305
+ }
306
+ return next
307
+ }
308
+
309
+ /**
310
+ * Rebuild a `Date` from a live `Date` or from an ISO string.
311
+ * `JSON.stringify` turns `Date` into a string, so persistence reload
312
+ * and AG-UI wire both land here as strings.
313
+ */
314
+ export function coerceCreatedAt(value: unknown): Date | undefined {
315
+ if (value instanceof Date) {
316
+ return Number.isNaN(value.getTime()) ? undefined : value
317
+ }
318
+ if (typeof value !== 'string') return undefined
319
+ const createdAt = new Date(value)
320
+ return Number.isNaN(createdAt.getTime()) ? undefined : createdAt
321
+ }
322
+
323
+ function normalizeMessagePart(part: MessagePart): MessagePart {
324
+ if (part.type !== 'tool-result') return part
325
+ const createdAt = coerceCreatedAt(part.createdAt)
326
+ const { createdAt: _createdAt, ...rest } = part
327
+ return createdAt === undefined ? rest : { ...rest, createdAt }
328
+ }
329
+
330
+ function createdAtFromMetadata(source: object): Date | undefined {
331
+ return coerceCreatedAt(tanstackMetadata(source)?.createdAt)
332
+ }
333
+
334
+ function optionalCreatedAt(source: { createdAt?: unknown }): {
335
+ createdAt?: Date
336
+ } {
337
+ const createdAt = coerceCreatedAt(source.createdAt)
338
+ return createdAt !== undefined ? { createdAt } : {}
339
+ }
340
+
341
+ function optionalName(source: { name?: string }): { name?: string } {
342
+ return source.name !== undefined ? { name: source.name } : {}
343
+ }
344
+
345
+ function isUiResourcePart(value: unknown): value is UIResourcePart {
346
+ if (value == null || typeof value !== 'object' || Array.isArray(value)) {
347
+ return false
348
+ }
349
+ if (!('type' in value) || value.type !== 'ui-resource') return false
350
+ if (!('toolCallId' in value) || typeof value.toolCallId !== 'string') {
351
+ return false
352
+ }
353
+ if (!('toolName' in value) || typeof value.toolName !== 'string') {
354
+ return false
355
+ }
356
+ if (
357
+ !('resource' in value) ||
358
+ value.resource == null ||
359
+ typeof value.resource !== 'object' ||
360
+ Array.isArray(value.resource)
361
+ ) {
362
+ return false
363
+ }
364
+ const resource = value.resource
365
+ return (
366
+ 'uri' in resource &&
367
+ typeof resource.uri === 'string' &&
368
+ 'mimeType' in resource &&
369
+ typeof resource.mimeType === 'string'
370
+ )
371
+ }
372
+
373
+ function uiResourceKey(part: UIResourcePart): string {
374
+ return `${part.toolCallId}\0${part.toolName}\0${part.resource.uri}`
375
+ }
376
+
377
+ function appendUiResources(
378
+ ui: UIMessage,
379
+ resources: ReadonlyArray<UIResourcePart>,
380
+ ): UIMessage {
381
+ if (resources.length === 0) return ui
382
+ const seen = new Set(
383
+ ui.parts.filter(isUiResourcePart).map((part) => uiResourceKey(part)),
384
+ )
385
+ const extra = resources.filter((part) => !seen.has(uiResourceKey(part)))
386
+ if (extra.length === 0) return ui
387
+ return { ...ui, parts: [...ui.parts, ...extra] }
388
+ }
389
+
390
+ function assistantMetadata(
391
+ uiMessage: UIMessage,
392
+ ): UIMessage['metadata'] | undefined {
393
+ const fromParts = uiMessage.parts.filter(isUiResourcePart)
394
+ const current = uiMessage.metadata ?? {}
395
+ const previous = tanstackMetadata(uiMessage)
396
+ const tanstack: TanStackMessageMetadata = {}
397
+ if (previous?.model !== undefined) tanstack.model = previous.model
398
+ if (previous?.signature !== undefined) tanstack.signature = previous.signature
399
+ if (fromParts.length > 0) tanstack.uiResources = fromParts
400
+ const result = { ...current }
401
+ if (Object.keys(tanstack).length > 0) result.tanstack = tanstack
402
+ else delete result.tanstack
403
+ return Object.keys(result).length > 0 ? result : undefined
404
+ }
405
+
231
406
  /**
232
407
  * Convert a UIMessage to ModelMessage(s)
233
408
  *
@@ -281,9 +456,9 @@ function buildUserOrToolMessage(uiMessage: UIMessage): ModelMessage {
281
456
  id: uiMessage.id,
282
457
  role: uiMessage.role as 'user' | 'assistant' | 'tool',
283
458
  content: collapseContentParts(contentParts),
284
- ...(uiMessage.createdAt !== undefined && {
285
- createdAt: uiMessage.createdAt,
286
- }),
459
+ ...optionalName(uiMessage),
460
+ ...optionalCreatedAt(uiMessage),
461
+ ...(uiMessage.metadata !== undefined && { metadata: uiMessage.metadata }),
287
462
  }
288
463
  }
289
464
 
@@ -337,16 +512,26 @@ function buildAssistantMessages(uiMessage: UIMessage): Array<ModelMessage> {
337
512
  // A tool call can have BOTH an explicit tool-result part AND an output
338
513
  // field on the tool-call part. We only want one per tool call ID.
339
514
  const emittedToolResultIds = new Set<string>()
515
+ const identityFields = {
516
+ id: uiMessage.id,
517
+ ...optionalName(uiMessage),
518
+ ...optionalCreatedAt(uiMessage),
519
+ }
520
+ const metadata = assistantMetadata(uiMessage)
521
+ const assistantFields = {
522
+ ...identityFields,
523
+ ...(metadata !== undefined && { metadata }),
524
+ }
340
525
 
341
- function flushSegment(): void {
526
+ function flushSegment(force = false): void {
342
527
  const content = collapseContentParts(current.contentParts)
343
528
  const hasContent = content !== null
344
529
  const hasToolCalls = current.toolCalls.length > 0
345
530
  const hasThinking = pendingThinking.length > 0
346
531
 
347
- if (hasContent || hasToolCalls || hasThinking) {
532
+ if (force || hasContent || hasToolCalls || hasThinking) {
348
533
  messageList.push({
349
- id: uiMessage.id,
534
+ ...assistantFields,
350
535
  role: 'assistant',
351
536
  content,
352
537
  ...(hasToolCalls && { toolCalls: current.toolCalls }),
@@ -354,9 +539,6 @@ function buildAssistantMessages(uiMessage: UIMessage): Array<ModelMessage> {
354
539
  ...(current.structuredOutput && {
355
540
  structuredOutput: current.structuredOutput,
356
541
  }),
357
- ...(uiMessage.createdAt !== undefined && {
358
- createdAt: uiMessage.createdAt,
359
- }),
360
542
  })
361
543
  pendingThinking = []
362
544
  }
@@ -389,7 +571,7 @@ function buildAssistantMessages(uiMessage: UIMessage): Array<ModelMessage> {
389
571
 
390
572
  case 'tool-result':
391
573
  // Flush the current assistant segment before emitting the tool result
392
- flushSegment()
574
+ flushSegment(messageList.length === 0)
393
575
 
394
576
  // Emit the tool result
395
577
  if (
@@ -397,13 +579,14 @@ function buildAssistantMessages(uiMessage: UIMessage): Array<ModelMessage> {
397
579
  !emittedToolResultIds.has(part.toolCallId)
398
580
  ) {
399
581
  messageList.push({
400
- id: uiMessage.id,
582
+ ...(part.id !== undefined && { id: part.id }),
583
+ ...optionalCreatedAt(part),
401
584
  role: 'tool',
402
585
  content: part.content,
403
586
  toolCallId: part.toolCallId,
404
- ...(uiMessage.createdAt !== undefined && {
405
- createdAt: uiMessage.createdAt,
406
- }),
587
+ ...(part.name !== undefined && { name: part.name }),
588
+ ...(part.metadata !== undefined && { metadata: part.metadata }),
589
+ ...(part.error !== undefined && { error: part.error }),
407
590
  })
408
591
  emittedToolResultIds.add(part.toolCallId)
409
592
  }
@@ -466,13 +649,9 @@ function buildAssistantMessages(uiMessage: UIMessage): Array<ModelMessage> {
466
649
  // emit the concrete output regardless of approval metadata.
467
650
  if (part.output !== undefined && !emittedToolResultIds.has(part.id)) {
468
651
  messageList.push({
469
- id: uiMessage.id,
470
652
  role: 'tool',
471
653
  content: normalizeToolResult(part.output),
472
654
  toolCallId: part.id,
473
- ...(uiMessage.createdAt !== undefined && {
474
- createdAt: uiMessage.createdAt,
475
- }),
476
655
  })
477
656
  emittedToolResultIds.add(part.id)
478
657
  }
@@ -486,7 +665,6 @@ function buildAssistantMessages(uiMessage: UIMessage): Array<ModelMessage> {
486
665
  ) {
487
666
  const approved = part.approval.approved
488
667
  messageList.push({
489
- id: uiMessage.id,
490
668
  role: 'tool',
491
669
  content: JSON.stringify({
492
670
  approved,
@@ -496,9 +674,6 @@ function buildAssistantMessages(uiMessage: UIMessage): Array<ModelMessage> {
496
674
  : 'User denied this action',
497
675
  }),
498
676
  toolCallId: part.id,
499
- ...(uiMessage.createdAt !== undefined && {
500
- createdAt: uiMessage.createdAt,
501
- }),
502
677
  })
503
678
  emittedToolResultIds.add(part.id)
504
679
  }
@@ -507,12 +682,9 @@ function buildAssistantMessages(uiMessage: UIMessage): Array<ModelMessage> {
507
682
  // If no messages were produced (e.g., empty parts), emit a minimal assistant message
508
683
  if (messageList.length === 0) {
509
684
  messageList.push({
510
- id: uiMessage.id,
685
+ ...assistantFields,
511
686
  role: 'assistant',
512
687
  content: null,
513
- ...(uiMessage.createdAt !== undefined && {
514
- createdAt: uiMessage.createdAt,
515
- }),
516
688
  })
517
689
  }
518
690
 
@@ -536,6 +708,7 @@ export function modelMessageToUIMessage(
536
708
  id?: string,
537
709
  ): UIMessage {
538
710
  const parts: Array<MessagePart> = []
711
+ const createdAt = coerceCreatedAt(modelMessage.createdAt)
539
712
 
540
713
  if (modelMessage.role === 'assistant' && modelMessage.thinking?.length) {
541
714
  for (const thinking of modelMessage.thinking) {
@@ -550,14 +723,36 @@ export function modelMessageToUIMessage(
550
723
 
551
724
  // Handle tool results (when role is "tool") - only produce tool-result part,
552
725
  // not a text part (the content IS the tool result, not display text)
553
- if (modelMessage.role === 'assistant' && modelMessage.structuredOutput) {
554
- parts.push(modelMessage.structuredOutput)
726
+ const structuredOutput =
727
+ modelMessage.structuredOutput ??
728
+ snapshotStructuredOutput(tanstackMetadata(modelMessage)?.structuredOutput)
729
+ if (modelMessage.role === 'assistant' && structuredOutput) {
730
+ if (
731
+ typeof modelMessage.content === 'string' &&
732
+ modelMessage.content &&
733
+ modelMessage.content !== structuredOutput.raw
734
+ ) {
735
+ const suffix = structuredOutput.raw
736
+ const text =
737
+ suffix !== '' && modelMessage.content.endsWith(suffix)
738
+ ? modelMessage.content.slice(0, -suffix.length)
739
+ : modelMessage.content
740
+ if (text) parts.push({ type: 'text', content: text })
741
+ }
742
+ parts.push(structuredOutput)
555
743
  } else if (modelMessage.role === 'tool' && modelMessage.toolCallId) {
556
744
  parts.push({
557
745
  type: 'tool-result',
558
746
  toolCallId: modelMessage.toolCallId,
559
- content: getTextContent(modelMessage.content),
560
- state: 'complete',
747
+ content: toolResultContent(modelMessage.content),
748
+ state: modelMessage.error === undefined ? 'complete' : 'error',
749
+ ...(modelMessage.id !== undefined && { id: modelMessage.id }),
750
+ ...(modelMessage.name !== undefined && { name: modelMessage.name }),
751
+ ...(modelMessage.metadata !== undefined && {
752
+ metadata: modelMessage.metadata,
753
+ }),
754
+ ...(createdAt !== undefined && { createdAt }),
755
+ ...(modelMessage.error !== undefined && { error: modelMessage.error }),
561
756
  })
562
757
  } else if (Array.isArray(modelMessage.content)) {
563
758
  // Multimodal content - preserve all content parts as MessageParts
@@ -598,14 +793,23 @@ export function modelMessageToUIMessage(
598
793
  }
599
794
  }
600
795
 
601
- return {
796
+ const ui: UIMessage = {
602
797
  id: id || generateMessageId(),
603
798
  role: modelMessage.role === 'tool' ? 'assistant' : modelMessage.role,
604
799
  parts,
605
- ...(modelMessage.createdAt !== undefined && {
606
- createdAt: modelMessage.createdAt,
800
+ ...optionalName(modelMessage),
801
+ ...(createdAt !== undefined && { createdAt }),
802
+ ...(modelMessage.metadata !== undefined && {
803
+ metadata: modelMessage.metadata,
607
804
  }),
608
805
  }
806
+ const storedResources = tanstackMetadata(modelMessage)?.uiResources
807
+ return appendUiResources(
808
+ ui,
809
+ Array.isArray(storedResources)
810
+ ? storedResources.filter(isUiResourcePart)
811
+ : [],
812
+ )
609
813
  }
610
814
 
611
815
  /**
@@ -644,13 +848,19 @@ export function aguiSnapshotMessageToUIMessage(
644
848
  parts: aguiUserContentToParts(message.content),
645
849
  })
646
850
  case 'assistant': {
647
- const toolCallMetadata = tanstackMetadata(message)?.toolCallMetadata
851
+ const metadata = tanstackMetadata(message)
852
+ const toolCallMetadata = metadata?.toolCallMetadata
853
+ const structuredOutput = snapshotStructuredOutput(
854
+ metadata?.structuredOutput,
855
+ )
648
856
  const toolCalls = message.toolCalls?.map((toolCall) => {
649
- const metadata =
857
+ const callMetadata =
650
858
  toolCallMetadata != null && typeof toolCallMetadata === 'object'
651
859
  ? (toolCallMetadata as Record<string, unknown>)[toolCall.id]
652
860
  : undefined
653
- return metadata !== undefined ? { ...toolCall, metadata } : toolCall
861
+ return callMetadata !== undefined
862
+ ? { ...toolCall, metadata: callMetadata }
863
+ : toolCall
654
864
  })
655
865
  return applySnapshotMetadata(
656
866
  message,
@@ -658,13 +868,20 @@ export function aguiSnapshotMessageToUIMessage(
658
868
  {
659
869
  role: 'assistant',
660
870
  content: message.content ?? null,
871
+ ...optionalName(message),
661
872
  ...(toolCalls && { toolCalls }),
873
+ ...(structuredOutput && { structuredOutput }),
662
874
  },
663
875
  id,
664
876
  ),
665
877
  )
666
878
  }
667
- case 'tool':
879
+ case 'tool': {
880
+ message = restoreToolResultOwnership(message)
881
+ const createdAt =
882
+ coerceCreatedAt(
883
+ 'createdAt' in message ? message.createdAt : undefined,
884
+ ) ?? createdAtFromMetadata(message)
668
885
  return applySnapshotMetadata(
669
886
  message,
670
887
  modelMessageToUIMessage(
@@ -672,10 +889,22 @@ export function aguiSnapshotMessageToUIMessage(
672
889
  role: 'tool',
673
890
  content: message.content,
674
891
  toolCallId: message.toolCallId,
892
+ ...('name' in message && typeof message.name === 'string'
893
+ ? { name: message.name }
894
+ : {}),
895
+ ...('id' in message && typeof message.id === 'string'
896
+ ? { id: message.id }
897
+ : {}),
898
+ ...('metadata' in message && message.metadata != null
899
+ ? { metadata: message.metadata }
900
+ : {}),
901
+ ...(createdAt !== undefined ? { createdAt } : {}),
902
+ ...(message.error !== undefined && { error: message.error }),
675
903
  },
676
904
  id,
677
905
  ),
678
906
  )
907
+ }
679
908
  case 'system':
680
909
  case 'developer':
681
910
  // `ModelMessage` has no system/developer role; build the part directly.
@@ -715,19 +944,62 @@ export function aguiSnapshotMessageToUIMessage(
715
944
 
716
945
  /** Copy snapshot metadata when it is a record. Rebuild createdAt from tanstack.createdAt. */
717
946
  function applySnapshotMetadata(source: object, ui: UIMessage): UIMessage {
718
- if (!('metadata' in source)) return ui
719
- const raw = source.metadata
720
- if (raw == null || typeof raw !== 'object' || Array.isArray(raw)) return ui
721
- const metadata = raw as NonNullable<UIMessage['metadata']>
722
- const createdAtRaw = tanstackMetadata(metadata)?.createdAt
947
+ const normalizedParts = ui.parts.map((part) => normalizeMessagePart(part))
948
+ if (normalizedParts !== ui.parts) ui = { ...ui, parts: normalizedParts }
949
+ const name =
950
+ 'name' in source && typeof source.name === 'string'
951
+ ? source.name
952
+ : undefined
953
+ let next = name !== undefined ? { ...ui, name } : ui
954
+
955
+ let metadata: NonNullable<UIMessage['metadata']> | undefined
956
+ if ('metadata' in source) {
957
+ const raw = source.metadata
958
+ if (raw != null && typeof raw === 'object' && !Array.isArray(raw)) {
959
+ metadata = raw as NonNullable<UIMessage['metadata']>
960
+ next = { ...next, metadata }
961
+ }
962
+ }
963
+
723
964
  const createdAt =
724
- typeof createdAtRaw === 'string' ? new Date(createdAtRaw) : undefined
725
- const createdAtValid =
726
- createdAt !== undefined && !Number.isNaN(createdAt.getTime())
965
+ coerceCreatedAt(next.createdAt) ??
966
+ (metadata !== undefined ? createdAtFromMetadata(metadata) : undefined)
967
+ if (createdAt !== undefined && !Object.is(createdAt, next.createdAt)) {
968
+ next = { ...next, createdAt }
969
+ } else if (createdAt === undefined && next.createdAt !== undefined) {
970
+ const { createdAt: _invalid, ...rest } = next
971
+ next = rest
972
+ }
973
+
974
+ const uiResources = tanstackMetadata(metadata)?.uiResources
975
+ const resources = Array.isArray(uiResources)
976
+ ? uiResources.filter(isUiResourcePart)
977
+ : []
978
+ return appendUiResources(next, resources)
979
+ }
980
+
981
+ function snapshotStructuredOutput(
982
+ value: TanStackMessageMetadata['structuredOutput'],
983
+ ): StructuredOutputPart | undefined {
984
+ if (
985
+ value == null ||
986
+ (value.status !== 'streaming' &&
987
+ value.status !== 'complete' &&
988
+ value.status !== 'error') ||
989
+ typeof value.raw !== 'string'
990
+ ) {
991
+ return undefined
992
+ }
727
993
  return {
728
- ...ui,
729
- metadata,
730
- ...(createdAtValid ? { createdAt } : {}),
994
+ type: 'structured-output',
995
+ status: value.status,
996
+ raw: value.raw,
997
+ ...(value.partial !== undefined ? { partial: value.partial } : {}),
998
+ ...(value.data !== undefined ? { data: value.data } : {}),
999
+ ...(value.reasoning ? { reasoning: value.reasoning } : {}),
1000
+ ...(value.errorMessage !== undefined
1001
+ ? { errorMessage: value.errorMessage }
1002
+ : {}),
731
1003
  }
732
1004
  }
733
1005
 
@@ -780,22 +1052,31 @@ export function modelMessagesToUIMessages(
780
1052
  currentAssistantMessage &&
781
1053
  currentAssistantMessage.role === 'assistant'
782
1054
  ) {
783
- const content = getTextContent(msg.content)
1055
+ const content = toolResultContent(msg.content)
784
1056
  const toolCallPart = currentAssistantMessage.parts.find(
785
1057
  (part): part is ToolCallPart =>
786
1058
  part.type === 'tool-call' && part.id === msg.toolCallId,
787
1059
  )
788
1060
 
789
1061
  if (toolCallPart) {
790
- toolCallPart.output = parseToolResultContent(content)
791
- toolCallPart.state = 'complete'
1062
+ toolCallPart.output =
1063
+ typeof content === 'string'
1064
+ ? parseToolResultContent(content)
1065
+ : content
1066
+ toolCallPart.state = msg.error === undefined ? 'complete' : 'error'
792
1067
  }
793
1068
 
794
1069
  currentAssistantMessage.parts.push({
795
1070
  type: 'tool-result',
796
1071
  toolCallId: msg.toolCallId,
797
1072
  content,
798
- state: 'complete',
1073
+ state: msg.error === undefined ? 'complete' : 'error',
1074
+ ...(msg.id !== undefined &&
1075
+ msg.id !== currentAssistantMessage.id && { id: msg.id }),
1076
+ ...(msg.name !== undefined && { name: msg.name }),
1077
+ ...(msg.metadata !== undefined && { metadata: msg.metadata }),
1078
+ ...optionalCreatedAt(msg),
1079
+ ...(msg.error !== undefined && { error: msg.error }),
799
1080
  })
800
1081
  } else {
801
1082
  // No assistant message to merge into, create a standalone one
@@ -834,16 +1115,18 @@ export function normalizeToUIMessage(
834
1115
  ): UIMessage {
835
1116
  if ('parts' in message) {
836
1117
  // Already a UIMessage
1118
+ const parts = message.parts.map((part) => normalizeMessagePart(part))
837
1119
  return {
838
1120
  ...message,
1121
+ parts,
839
1122
  id: message.id || generateId(),
840
- createdAt: message.createdAt || new Date(),
1123
+ createdAt: coerceCreatedAt(message.createdAt) ?? new Date(),
841
1124
  }
842
1125
  } else {
843
1126
  // ModelMessage - convert to UIMessage
844
1127
  return {
845
1128
  ...modelMessageToUIMessage(message, generateId()),
846
- createdAt: message.createdAt ?? new Date(),
1129
+ createdAt: coerceCreatedAt(message.createdAt) ?? new Date(),
847
1130
  }
848
1131
  }
849
1132
  }