@tanstack/ai 0.49.1 → 0.50.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.
@@ -19,6 +19,7 @@
19
19
  */
20
20
  import {
21
21
  aguiSnapshotMessageToUIMessage,
22
+ coerceCreatedAt,
22
23
  generateMessageId,
23
24
  uiMessageToModelMessages,
24
25
  } from '../messages.js'
@@ -835,11 +836,10 @@ export class StreamProcessor {
835
836
  }
836
837
  : incomingRecord
837
838
  const metadata = mergeMetadata(message.metadata, toMerge)
838
- const createdAtRaw = tanstackMetadata(incomingRecord)?.createdAt
839
- const createdAt =
840
- typeof createdAtRaw === 'string' ? new Date(createdAtRaw) : undefined
841
- const createdAtValid =
842
- createdAt !== undefined && !Number.isNaN(createdAt.getTime())
839
+ const createdAt = coerceCreatedAt(
840
+ tanstackMetadata(incomingRecord)?.createdAt,
841
+ )
842
+ const createdAtValid = createdAt !== undefined
843
843
  this.messages = this.messages.map((msg) =>
844
844
  msg.id === messageId
845
845
  ? {
@@ -1050,8 +1050,10 @@ export class StreamProcessor {
1050
1050
  msg.parts.every((part) => part.type === 'thinking')
1051
1051
  const isToolResultOnly = (msg: UIMessage) =>
1052
1052
  msg.role === 'assistant' &&
1053
- msg.parts.length === 1 &&
1054
- msg.parts[0]?.type === 'tool-result'
1053
+ msg.parts.some((part) => part.type === 'tool-result') &&
1054
+ msg.parts.every(
1055
+ (part) => part.type === 'tool-result' || part.type === 'ui-resource',
1056
+ )
1055
1057
  const flushPending = () => {
1056
1058
  out.push(...pending)
1057
1059
  pending = []
@@ -1109,9 +1111,16 @@ export class StreamProcessor {
1109
1111
 
1110
1112
  const reconciled: Array<UIMessage> = []
1111
1113
  for (const msg of snapshot) {
1114
+ const toolResultParts = msg.parts.filter(
1115
+ (part): part is ToolResultPart => part.type === 'tool-result',
1116
+ )
1112
1117
  const toolResultPart =
1113
- msg.role === 'assistant' && msg.parts.length === 1
1114
- ? msg.parts.find((p): p is ToolResultPart => p.type === 'tool-result')
1118
+ msg.role === 'assistant' &&
1119
+ toolResultParts.length === 1 &&
1120
+ msg.parts.every(
1121
+ (part) => part.type === 'tool-result' || part.type === 'ui-resource',
1122
+ )
1123
+ ? toolResultParts[0]
1115
1124
  : undefined
1116
1125
 
1117
1126
  if (!toolResultPart) {
@@ -1170,7 +1179,7 @@ export class StreamProcessor {
1170
1179
  )
1171
1180
  }
1172
1181
  }
1173
- parts.push(toolResultPart)
1182
+ parts.push(...msg.parts)
1174
1183
  // Replace rather than push into `target.parts`: a snapshot message that
1175
1184
  // arrived already carrying `parts` (TanStack server echoing UIMessages)
1176
1185
  // shares its array with the incoming chunk, and mutating it in place
package/src/types.ts CHANGED
@@ -372,6 +372,10 @@ export interface ModelMessage<
372
372
  toolCalls?: Array<ToolCall>
373
373
  toolCallId?: string
374
374
  thinking?: Array<{ content: string; signature?: string }>
375
+ /** Error reported by an AG-UI tool message. */
376
+ error?: string
377
+ /** Optional AG-UI message metadata. TanStack-owned fields live under `tanstack`. */
378
+ metadata?: Record<string, any>
375
379
  /**
376
380
  * Completed structured output represented by this assistant message.
377
381
  * `content` remains the provider-facing JSON text; this field preserves the
@@ -435,10 +439,14 @@ export interface ToolCallPart<TMetadata = unknown> {
435
439
 
436
440
  export interface ToolResultPart {
437
441
  type: 'tool-result'
442
+ id?: string
443
+ name?: string
438
444
  toolCallId: string
439
445
  content: string | Array<ContentPart>
440
446
  state: ToolResultState
441
447
  error?: string // Error message if state is "error"
448
+ metadata?: Record<string, unknown>
449
+ createdAt?: Date
442
450
  }
443
451
 
444
452
  export interface ThinkingPart {
@@ -524,9 +532,17 @@ export interface TanStackMessageMetadata {
524
532
  signature?: string
525
533
  /** Per-tool-call provider metadata keyed by tool call id (e.g. Gemini thoughtSignature). */
526
534
  toolCallMetadata?: Record<string, unknown>
535
+ toolResult?: {
536
+ id?: string
537
+ createdAt?: string
538
+ content?: Array<ContentPart>
539
+ }
527
540
  structuredOutput?: {
528
541
  status?: 'streaming' | 'complete' | 'error'
542
+ partial?: unknown
543
+ data?: unknown
529
544
  raw?: string
545
+ reasoning?: string
530
546
  errorMessage?: string
531
547
  }
532
548
  uiResources?: Array<UIResourcePart>
@@ -562,6 +578,8 @@ export interface UIMessage<TData = unknown> {
562
578
  role: 'system' | 'user' | 'assistant'
563
579
  parts: Array<MessagePart<TData>>
564
580
  createdAt?: Date
581
+ /** Optional AG-UI sender name. Converters preserve it across wire and persist. */
582
+ name?: string
565
583
  /**
566
584
  * Optional AG-UI metadata bag. TanStack writes the `tanstack` key.
567
585
  * User keys stay at the top.
@@ -1,6 +1,16 @@
1
+ import type {
2
+ AssistantMessage,
3
+ InputContent,
4
+ ReasoningMessage,
5
+ SystemMessage,
6
+ ToolCall,
7
+ ToolMessage,
8
+ UserMessage,
9
+ } from '@ag-ui/core'
1
10
  import type {
2
11
  ContentPart,
3
12
  MessagePart,
13
+ ModelMessage,
4
14
  StructuredOutputPart,
5
15
  TanStackMessageMetadata,
6
16
  UIMessage,
@@ -8,72 +18,125 @@ import type {
8
18
  } from '../types'
9
19
  import type { MetadataRecord } from './merge-metadata'
10
20
  import { tanstackMetadata } from './merge-metadata'
21
+ import { normalizeToolResult } from './tool-result'
22
+ import {
23
+ coerceCreatedAt,
24
+ modelMessageToUIMessage,
25
+ } from '../activities/chat/messages'
11
26
 
12
- type AGUITextInputContent = { type: 'text'; text: string }
13
- type AGUIInputContent =
14
- | AGUITextInputContent
15
- | (ContentPart & { type: 'image' | 'audio' | 'video' | 'document' })
16
-
17
- type AGUIToolCallMirror = {
18
- id: string
19
- type: 'function'
20
- function: { name: string; arguments: string }
21
- encryptedValue?: string
22
- }
23
-
24
- type AGUIToolMessage = {
25
- role: 'tool'
26
- id: string
27
- toolCallId: string
28
- content: string
29
- error?: string
30
- }
27
+ type WithMetadata<T> = T & { metadata?: MetadataRecord }
28
+ type WireSystemMessage = WithMetadata<SystemMessage>
29
+ type WireUserMessage = WithMetadata<UserMessage>
30
+ type WireAssistantMessage = WithMetadata<AssistantMessage>
31
+ type WireToolMessage = WithMetadata<
32
+ ToolMessage & {
33
+ name?: string
34
+ }
35
+ >
36
+ type WireReasoningMessage = WithMetadata<ReasoningMessage>
31
37
 
32
- type AGUIReasoningMessage = {
33
- role: 'reasoning'
34
- id: string
35
- content: string
36
- encryptedValue?: string
37
- metadata?: MetadataRecord
38
+ function isRecord(value: unknown): value is Record<string, unknown> {
39
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
38
40
  }
39
41
 
40
- /** Spec AG-UI message. No `parts`, no `createdAt` Date. */
41
- type WireAnchorMessage = {
42
- id: string
43
- role: UIMessage['role']
44
- name?: string
45
- content?: string | Array<AGUIInputContent>
46
- toolCalls?: Array<AGUIToolCallMirror>
47
- metadata?: MetadataRecord
42
+ function rebuiltToolMetadata(
43
+ metadata: unknown,
44
+ createdAt: unknown,
45
+ id: string | undefined,
46
+ content: string | null | Array<ContentPart>,
47
+ anchorOwnsUiResources = false,
48
+ ): MetadataRecord | undefined {
49
+ const source: MetadataRecord = isRecord(metadata) ? metadata : {}
50
+ const tanstack = isRecord(source.tanstack) ? { ...source.tanstack } : {}
51
+ if (anchorOwnsUiResources) delete tanstack.uiResources
52
+ const date = coerceCreatedAt(createdAt)
53
+ const toolResult: NonNullable<TanStackMessageMetadata['toolResult']> = {
54
+ ...(id !== undefined ? { id } : {}),
55
+ ...(date && { createdAt: date.toISOString() }),
56
+ ...(Array.isArray(content) && { content }),
57
+ }
58
+ const result = {
59
+ ...source,
60
+ tanstack: { ...tanstack, toolResult },
61
+ }
62
+ return Object.keys(result).length ? result : undefined
48
63
  }
49
64
 
50
65
  export type WireMessage =
51
- | WireAnchorMessage
52
- | AGUIToolMessage
53
- | AGUIReasoningMessage
66
+ | WireSystemMessage
67
+ | WireUserMessage
68
+ | WireAssistantMessage
69
+ | WireToolMessage
70
+ | WireReasoningMessage
54
71
 
55
72
  /**
56
- * Serialize TanStack `UIMessage`s into the AG-UI `RunAgentInput.messages`
57
- * wire shape. Anchors are spec-only (`id`, `role`, `name`, `content`,
58
- * `toolCalls`, `metadata`). Tool results and thinking parts on assistant
59
- * messages are additionally emitted as fan-out `{role:'tool',...}` and
60
- * `{role:'reasoning',...}` entries for strict AG-UI server consumers.
73
+ * Serialize TanStack `UIMessage`s and `ModelMessage`s into the AG-UI
74
+ * `RunAgentInput.messages` wire shape. Anchors are spec-only (`id`, `role`,
75
+ * `name`, `content`, `toolCalls`, `metadata`). Tool results and thinking parts
76
+ * on assistant messages are additionally emitted as fan-out
77
+ * `{role:'tool',...}` and `{role:'reasoning',...}` entries for strict AG-UI
78
+ * server consumers. Set `includeSnapshotStructuredOutput` to retain complete
79
+ * structured-output metadata for UI snapshots.
61
80
  */
62
81
  export function uiMessagesToWire(
63
- messages: Array<UIMessage>,
82
+ messages: Array<UIMessage | ModelMessage>,
83
+ options?: { includeSnapshotStructuredOutput: boolean },
64
84
  ): Array<WireMessage> {
65
85
  const wire: Array<WireMessage> = []
86
+ const usedWireIds = new Set<string>(
87
+ messages.flatMap((message) =>
88
+ 'id' in message && message.id && message.role !== 'tool'
89
+ ? [message.id]
90
+ : [],
91
+ ),
92
+ )
93
+ const includeSnapshotStructuredOutput =
94
+ options?.includeSnapshotStructuredOutput ?? false
95
+
96
+ const assistantIds = new Set<string>()
97
+ for (const msg of messages) {
98
+ if (msg.role === 'assistant' && msg.id !== undefined) {
99
+ assistantIds.add(msg.id)
100
+ }
101
+ }
66
102
 
67
103
  for (const msg of messages) {
68
- // Defensive: ModelMessage-shaped input has no `parts`; fall back to `content`.
69
- const parts: ReadonlyArray<MessagePart> =
70
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion -- runtime input may be ModelMessage-shaped (no `parts`); cast forces the optional-chain fallback below to remain in scope
71
- (msg.parts as ReadonlyArray<MessagePart> | undefined) ?? []
104
+ if (!('parts' in msg) && msg.role === 'tool' && msg.toolCallId) {
105
+ const id = uniqueToolWireId(
106
+ toolWireId(msg.id, msg.toolCallId, assistantIds),
107
+ usedWireIds,
108
+ )
109
+ const metadata = rebuiltToolMetadata(
110
+ msg.metadata,
111
+ msg.createdAt,
112
+ msg.id,
113
+ msg.content,
114
+ )
115
+ wire.push({
116
+ role: 'tool',
117
+ id,
118
+ ...(msg.name !== undefined && { name: msg.name }),
119
+ toolCallId: msg.toolCallId,
120
+ content:
121
+ typeof msg.content === 'string'
122
+ ? msg.content
123
+ : JSON.stringify(msg.content),
124
+ ...(msg.error !== undefined && { error: msg.error }),
125
+ ...(metadata !== undefined && { metadata }),
126
+ })
127
+ continue
128
+ }
129
+
130
+ const uiMessage: UIMessage =
131
+ 'parts' in msg ? msg : modelMessageToUIMessage(msg, msg.id)
132
+ const parts: ReadonlyArray<MessagePart> = uiMessage.parts
133
+ usedWireIds.add(uiMessage.id)
72
134
 
73
135
  if (msg.role === 'system') {
74
136
  wire.push(
75
137
  toAnchor(
76
- msg,
138
+ uiMessage,
139
+ 'system',
77
140
  {
78
141
  content:
79
142
  parts.length > 0
@@ -81,6 +144,7 @@ export function uiMessagesToWire(
81
144
  : ((msg as { content?: string }).content ?? ''),
82
145
  },
83
146
  parts,
147
+ includeSnapshotStructuredOutput,
84
148
  ),
85
149
  )
86
150
  continue
@@ -89,7 +153,8 @@ export function uiMessagesToWire(
89
153
  if (msg.role === 'user') {
90
154
  wire.push(
91
155
  toAnchor(
92
- msg,
156
+ uiMessage,
157
+ 'user',
93
158
  {
94
159
  content:
95
160
  parts.length > 0
@@ -97,6 +162,7 @@ export function uiMessagesToWire(
97
162
  : ((msg as { content?: string }).content ?? ''),
98
163
  },
99
164
  parts,
165
+ includeSnapshotStructuredOutput,
100
166
  ),
101
167
  )
102
168
  continue
@@ -105,9 +171,9 @@ export function uiMessagesToWire(
105
171
  // assistant: emit reasoning fan-outs first, then anchor, then tool fan-outs
106
172
  for (const part of parts) {
107
173
  if (part.type === 'thinking') {
108
- const reasoning: AGUIReasoningMessage = {
174
+ const reasoning: WireReasoningMessage = {
109
175
  role: 'reasoning',
110
- id: deriveReasoningId(msg.id, part),
176
+ id: uniqueWireId(deriveReasoningId(uiMessage.id, part), usedWireIds),
111
177
  content: part.content,
112
178
  }
113
179
  if (part.signature) {
@@ -121,26 +187,79 @@ export function uiMessagesToWire(
121
187
  const toolCalls = collectToolCalls(parts)
122
188
  wire.push(
123
189
  toAnchor(
124
- msg,
190
+ uiMessage,
191
+ 'assistant',
125
192
  {
126
193
  ...(text !== '' && { content: text }),
127
194
  ...(toolCalls && { toolCalls }),
128
195
  },
129
196
  parts,
197
+ includeSnapshotStructuredOutput,
130
198
  ),
131
199
  )
132
200
 
201
+ const explicitToolResults = new Set(
202
+ parts.flatMap((part) =>
203
+ part.type === 'tool-result' ? [part.toolCallId] : [],
204
+ ),
205
+ )
133
206
  for (const part of parts) {
134
207
  if (part.type === 'tool-result') {
208
+ const id = uniqueToolWireId(
209
+ part.id ?? deriveToolMessageId(part.toolCallId),
210
+ usedWireIds,
211
+ )
212
+ const metadata = rebuiltToolMetadata(
213
+ part.metadata,
214
+ part.createdAt,
215
+ part.id,
216
+ part.content,
217
+ true,
218
+ )
135
219
  wire.push({
136
220
  role: 'tool',
137
- id: deriveToolMessageId(part.toolCallId),
221
+ id,
138
222
  toolCallId: part.toolCallId,
223
+ ...(part.name !== undefined && { name: part.name }),
139
224
  content:
140
225
  typeof part.content === 'string'
141
226
  ? part.content
142
227
  : JSON.stringify(part.content),
143
228
  ...(part.error !== undefined && { error: part.error }),
229
+ ...(metadata !== undefined && { metadata }),
230
+ })
231
+ } else if (part.type === 'tool-call') {
232
+ const approved = part.approval?.approved
233
+ if (
234
+ explicitToolResults.has(part.id) ||
235
+ (part.output === undefined &&
236
+ (part.state !== 'approval-responded' || approved === undefined))
237
+ ) {
238
+ continue
239
+ }
240
+ const result =
241
+ part.output !== undefined
242
+ ? normalizeToolResult(part.output)
243
+ : JSON.stringify({
244
+ approved,
245
+ ...(approved && { pendingExecution: true }),
246
+ message: approved
247
+ ? 'User approved this action'
248
+ : 'User denied this action',
249
+ })
250
+ const content =
251
+ typeof result === 'string' ? result : JSON.stringify(result)
252
+ wire.push({
253
+ role: 'tool',
254
+ id: uniqueToolWireId(deriveToolMessageId(part.id), usedWireIds),
255
+ toolCallId: part.id,
256
+ content,
257
+ metadata: rebuiltToolMetadata(
258
+ undefined,
259
+ undefined,
260
+ undefined,
261
+ result,
262
+ ),
144
263
  })
145
264
  }
146
265
  }
@@ -151,33 +270,78 @@ export function uiMessagesToWire(
151
270
 
152
271
  function toAnchor(
153
272
  msg: UIMessage,
273
+ role: 'system',
274
+ extras: { content: string },
275
+ parts: ReadonlyArray<MessagePart>,
276
+ includeSnapshotStructuredOutput: boolean,
277
+ ): WireSystemMessage
278
+ function toAnchor(
279
+ msg: UIMessage,
280
+ role: 'user',
281
+ extras: { content: string | Array<InputContent> },
282
+ parts: ReadonlyArray<MessagePart>,
283
+ includeSnapshotStructuredOutput: boolean,
284
+ ): WireUserMessage
285
+ function toAnchor(
286
+ msg: UIMessage,
287
+ role: 'assistant',
154
288
  extras: {
155
- content?: string | Array<AGUIInputContent>
156
- toolCalls?: Array<AGUIToolCallMirror>
289
+ content?: string
290
+ toolCalls?: Array<ToolCall>
157
291
  },
158
292
  parts: ReadonlyArray<MessagePart>,
159
- ): WireAnchorMessage {
160
- const metadata = messageMetadata(msg, parts)
161
- const name = (msg as { name?: string }).name
162
- return {
293
+ includeSnapshotStructuredOutput: boolean,
294
+ ): WireAssistantMessage
295
+ function toAnchor(
296
+ msg: UIMessage,
297
+ role: UIMessage['role'],
298
+ extras: {
299
+ content?: string | Array<InputContent>
300
+ toolCalls?: Array<ToolCall>
301
+ },
302
+ parts: ReadonlyArray<MessagePart>,
303
+ includeSnapshotStructuredOutput: boolean,
304
+ ): WireSystemMessage | WireUserMessage | WireAssistantMessage {
305
+ const metadata = messageMetadata(msg, parts, includeSnapshotStructuredOutput)
306
+ const base = {
163
307
  id: msg.id,
164
- role: msg.role,
165
- ...(name !== undefined && { name }),
166
- ...extras,
308
+ ...(msg.name !== undefined && { name: msg.name }),
167
309
  ...(metadata !== undefined && { metadata }),
168
310
  }
311
+ if (role === 'system') {
312
+ return { ...base, role, content: String(extras.content ?? '') }
313
+ }
314
+ if (role === 'user') {
315
+ return { ...base, role, content: extras.content ?? '' }
316
+ }
317
+ return {
318
+ ...base,
319
+ role,
320
+ ...(typeof extras.content === 'string' && { content: extras.content }),
321
+ ...(extras.toolCalls !== undefined && { toolCalls: extras.toolCalls }),
322
+ }
169
323
  }
170
324
 
171
325
  function messageMetadata(
172
326
  msg: UIMessage,
173
327
  parts: ReadonlyArray<MessagePart>,
328
+ includeSnapshotStructuredOutput: boolean,
174
329
  ): MetadataRecord | undefined {
175
330
  const base: MetadataRecord = { ...(msg.metadata ?? {}) }
176
- const tanstack: MetadataRecord = { ...(tanstackMetadata(msg) ?? {}) }
177
- if (msg.createdAt) tanstack.createdAt = msg.createdAt.toISOString()
331
+ const previousTanstack = tanstackMetadata(msg)
332
+ const tanstack: TanStackMessageMetadata = {}
333
+ if (previousTanstack?.model !== undefined)
334
+ tanstack.model = previousTanstack.model
335
+ if (previousTanstack?.signature !== undefined)
336
+ tanstack.signature = previousTanstack.signature
337
+ const createdAt = coerceCreatedAt(msg.createdAt)
338
+ if (createdAt !== undefined) tanstack.createdAt = createdAt.toISOString()
178
339
 
179
- const leftover = unfinishedStructuredOutput(parts)
180
- if (leftover) tanstack.structuredOutput = leftover
340
+ const structuredOutput = serializedStructuredOutput(
341
+ parts,
342
+ includeSnapshotStructuredOutput,
343
+ )
344
+ if (structuredOutput) tanstack.structuredOutput = structuredOutput
181
345
 
182
346
  const toolCallMetadata: Record<string, unknown> = {}
183
347
  for (const part of parts) {
@@ -195,26 +359,41 @@ function messageMetadata(
195
359
  if (uiResources.length > 0) tanstack.uiResources = uiResources
196
360
 
197
361
  if (Object.keys(tanstack).length > 0) base.tanstack = tanstack
362
+ else delete base.tanstack
198
363
  return Object.keys(base).length > 0 ? base : undefined
199
364
  }
200
365
 
201
- function unfinishedStructuredOutput(
366
+ function serializedStructuredOutput(
202
367
  parts: ReadonlyArray<MessagePart>,
368
+ includeSnapshotStructuredOutput: boolean,
203
369
  ): TanStackMessageMetadata['structuredOutput'] | undefined {
204
370
  for (const p of parts) {
205
- if (p.type === 'structured-output' && p.status !== 'complete') {
206
- return structuredOutputLeftover(p)
371
+ if (
372
+ p.type === 'structured-output' &&
373
+ (includeSnapshotStructuredOutput || p.status !== 'complete')
374
+ ) {
375
+ return structuredOutputMetadata(p, includeSnapshotStructuredOutput)
207
376
  }
208
377
  }
209
378
  return undefined
210
379
  }
211
380
 
212
- function structuredOutputLeftover(
381
+ function structuredOutputMetadata(
213
382
  part: StructuredOutputPart,
383
+ includeSnapshotStructuredOutput: boolean,
214
384
  ): NonNullable<TanStackMessageMetadata['structuredOutput']> {
215
385
  return {
216
386
  status: part.status,
217
387
  raw: part.raw,
388
+ ...(includeSnapshotStructuredOutput && part.partial !== undefined
389
+ ? { partial: part.partial }
390
+ : {}),
391
+ ...(includeSnapshotStructuredOutput && part.data !== undefined
392
+ ? { data: part.data }
393
+ : {}),
394
+ ...(includeSnapshotStructuredOutput && part.reasoning
395
+ ? { reasoning: part.reasoning }
396
+ : {}),
218
397
  ...(part.errorMessage !== undefined && { errorMessage: part.errorMessage }),
219
398
  }
220
399
  }
@@ -247,7 +426,7 @@ function collectText(parts: ReadonlyArray<MessagePart>): string {
247
426
 
248
427
  function collectUserContent(
249
428
  parts: ReadonlyArray<MessagePart>,
250
- ): string | Array<AGUIInputContent> {
429
+ ): string | Array<InputContent> {
251
430
  const hasMultimodal = parts.some(
252
431
  (p) =>
253
432
  p.type === 'image' ||
@@ -258,7 +437,7 @@ function collectUserContent(
258
437
  if (!hasMultimodal) {
259
438
  return collectText(parts)
260
439
  }
261
- const out: Array<AGUIInputContent> = []
440
+ const out: Array<InputContent> = []
262
441
  for (const p of parts) {
263
442
  if (p.type === 'text') {
264
443
  out.push({ type: 'text', text: p.content })
@@ -289,8 +468,8 @@ function thoughtSignatureFromMetadata(metadata: unknown): string | undefined {
289
468
 
290
469
  function collectToolCalls(
291
470
  parts: ReadonlyArray<MessagePart>,
292
- ): Array<AGUIToolCallMirror> | undefined {
293
- const calls: Array<AGUIToolCallMirror> = []
471
+ ): Array<ToolCall> | undefined {
472
+ const calls: Array<ToolCall> = []
294
473
  for (const p of parts) {
295
474
  if (p.type === 'tool-call') {
296
475
  const encryptedValue = thoughtSignatureFromMetadata(p.metadata)
@@ -313,6 +492,32 @@ function deriveToolMessageId(toolCallId: string): string {
313
492
  return `tool-${toolCallId}`
314
493
  }
315
494
 
495
+ function uniqueToolWireId(id: string, used: Set<string>): string {
496
+ return uniqueWireId(id, used)
497
+ }
498
+
499
+ function uniqueWireId(id: string, used: Set<string>): string {
500
+ if (!used.has(id)) {
501
+ used.add(id)
502
+ return id
503
+ }
504
+ let suffix = 2
505
+ while (used.has(`${id}-${suffix}`)) suffix++
506
+ const unique = `${id}-${suffix}`
507
+ used.add(unique)
508
+ return unique
509
+ }
510
+
511
+ function toolWireId(
512
+ id: string | undefined,
513
+ toolCallId: string,
514
+ assistantIds: ReadonlySet<string>,
515
+ ): string {
516
+ const derived = deriveToolMessageId(toolCallId)
517
+ if (id === undefined || assistantIds.has(id)) return derived
518
+ return id
519
+ }
520
+
316
521
  function hashContent(s: string): string {
317
522
  // Cheap deterministic id suffix; collisions are tolerable since
318
523
  // reasoning ids only matter for AG-UI server consumers, not for our