@tanstack/ai 0.47.3 → 0.48.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 (101) hide show
  1. package/README.md +2 -1
  2. package/dist/esm/activities/chat/adapter.d.ts +5 -4
  3. package/dist/esm/activities/chat/adapter.js.map +1 -1
  4. package/dist/esm/activities/chat/index.d.ts +4 -7
  5. package/dist/esm/activities/chat/index.js +201 -243
  6. package/dist/esm/activities/chat/index.js.map +1 -1
  7. package/dist/esm/activities/chat/messages.js +115 -25
  8. package/dist/esm/activities/chat/messages.js.map +1 -1
  9. package/dist/esm/activities/chat/stream/processor.d.ts +38 -17
  10. package/dist/esm/activities/chat/stream/processor.js +186 -101
  11. package/dist/esm/activities/chat/stream/processor.js.map +1 -1
  12. package/dist/esm/activities/chat/tools/tool-calls.d.ts +3 -2
  13. package/dist/esm/activities/chat/tools/tool-calls.js +15 -10
  14. package/dist/esm/activities/chat/tools/tool-calls.js.map +1 -1
  15. package/dist/esm/activities/generateVideo/index.js +6 -6
  16. package/dist/esm/activities/generateVideo/index.js.map +1 -1
  17. package/dist/esm/activities/stream-generation-result.js +7 -8
  18. package/dist/esm/activities/stream-generation-result.js.map +1 -1
  19. package/dist/esm/activities/summarize/chat-stream-summarize.d.ts +2 -1
  20. package/dist/esm/activities/summarize/chat-stream-summarize.js +56 -53
  21. package/dist/esm/activities/summarize/chat-stream-summarize.js.map +1 -1
  22. package/dist/esm/adapter-internals.d.ts +2 -0
  23. package/dist/esm/adapter-internals.js +3 -1
  24. package/dist/esm/client.d.ts +8 -1
  25. package/dist/esm/client.js +7 -2
  26. package/dist/esm/client.js.map +1 -1
  27. package/dist/esm/index.d.ts +6 -0
  28. package/dist/esm/index.js +6 -2
  29. package/dist/esm/middlewares/otel.js +11 -4
  30. package/dist/esm/middlewares/otel.js.map +1 -1
  31. package/dist/esm/stream-to-response.js +8 -5
  32. package/dist/esm/stream-to-response.js.map +1 -1
  33. package/dist/esm/stream-to-websocket.js +4 -2
  34. package/dist/esm/stream-to-websocket.js.map +1 -1
  35. package/dist/esm/strip-to-spec-middleware.d.ts +10 -13
  36. package/dist/esm/strip-to-spec-middleware.js +24 -22
  37. package/dist/esm/strip-to-spec-middleware.js.map +1 -1
  38. package/dist/esm/types.d.ts +82 -305
  39. package/dist/esm/utilities/adapter-yield-chunk.d.ts +31 -0
  40. package/dist/esm/utilities/ag-ui-usage.d.ts +24 -0
  41. package/dist/esm/utilities/ag-ui-usage.js +66 -0
  42. package/dist/esm/utilities/ag-ui-usage.js.map +1 -0
  43. package/dist/esm/utilities/ag-ui-wire.d.ts +14 -7
  44. package/dist/esm/utilities/ag-ui-wire.js +71 -30
  45. package/dist/esm/utilities/ag-ui-wire.js.map +1 -1
  46. package/dist/esm/utilities/chat-params.d.ts +3 -3
  47. package/dist/esm/utilities/chat-params.js +10 -30
  48. package/dist/esm/utilities/chat-params.js.map +1 -1
  49. package/dist/esm/utilities/chunk-ids.d.ts +5 -0
  50. package/dist/esm/utilities/chunk-ids.js +25 -0
  51. package/dist/esm/utilities/chunk-ids.js.map +1 -0
  52. package/dist/esm/utilities/merge-metadata.d.ts +14 -0
  53. package/dist/esm/utilities/merge-metadata.js +43 -0
  54. package/dist/esm/utilities/merge-metadata.js.map +1 -0
  55. package/dist/esm/utilities/normalize-stream-chunk.d.ts +3 -0
  56. package/dist/esm/utilities/normalize-stream-chunk.js +100 -0
  57. package/dist/esm/utilities/normalize-stream-chunk.js.map +1 -0
  58. package/dist/esm/utilities/reasoning-encrypted-value.d.ts +8 -0
  59. package/dist/esm/utilities/reasoning-encrypted-value.js +16 -0
  60. package/dist/esm/utilities/reasoning-encrypted-value.js.map +1 -0
  61. package/dist/esm/utilities/restore-inbound-chunk.d.ts +15 -0
  62. package/dist/esm/utilities/restore-inbound-chunk.js +43 -0
  63. package/dist/esm/utilities/restore-inbound-chunk.js.map +1 -0
  64. package/dist/esm/utilities/spec-event-keys.d.ts +2 -0
  65. package/dist/esm/utilities/spec-event-keys.js +56 -0
  66. package/dist/esm/utilities/spec-event-keys.js.map +1 -0
  67. package/dist/esm/utilities/structured-output-events.d.ts +3 -3
  68. package/dist/esm/utilities/structured-output-events.js.map +1 -1
  69. package/package.json +2 -2
  70. package/src/activities/chat/adapter.ts +4 -4
  71. package/src/activities/chat/index.ts +398 -400
  72. package/src/activities/chat/messages.ts +174 -34
  73. package/src/activities/chat/stream/processor.ts +289 -218
  74. package/src/activities/chat/tools/tool-calls.ts +23 -22
  75. package/src/activities/generateVideo/index.ts +7 -6
  76. package/src/activities/stream-generation-result.ts +8 -12
  77. package/src/activities/summarize/chat-stream-summarize.ts +94 -70
  78. package/src/adapter-internals.ts +2 -0
  79. package/src/client.ts +13 -0
  80. package/src/index.ts +6 -0
  81. package/src/middlewares/otel.ts +16 -8
  82. package/src/stream-to-response.ts +11 -4
  83. package/src/stream-to-websocket.ts +3 -1
  84. package/src/strip-to-spec-middleware.ts +48 -24
  85. package/src/types.ts +109 -393
  86. package/src/utilities/adapter-yield-chunk.ts +30 -0
  87. package/src/utilities/ag-ui-usage.test.ts +194 -0
  88. package/src/utilities/ag-ui-usage.ts +148 -0
  89. package/src/utilities/ag-ui-wire.ts +149 -31
  90. package/src/utilities/chat-params.ts +22 -39
  91. package/src/utilities/chunk-ids.ts +24 -0
  92. package/src/utilities/merge-metadata.test.ts +117 -0
  93. package/src/utilities/merge-metadata.ts +59 -0
  94. package/src/utilities/normalize-stream-chunk.test.ts +423 -0
  95. package/src/utilities/normalize-stream-chunk.ts +186 -0
  96. package/src/utilities/reasoning-encrypted-value.ts +18 -0
  97. package/src/utilities/restore-inbound-chunk.test.ts +133 -0
  98. package/src/utilities/restore-inbound-chunk.ts +72 -0
  99. package/src/utilities/spec-event-keys.test.ts +34 -0
  100. package/src/utilities/spec-event-keys.ts +74 -0
  101. package/src/utilities/structured-output-events.ts +3 -3
@@ -24,6 +24,12 @@ import {
24
24
  } from '../messages.js'
25
25
  import { runErrorEventToError } from '../../../utilities/errors'
26
26
  import { isProviderExecutedToolCall } from '../../../utilities/provider-executed'
27
+ import {
28
+ mergeMetadata,
29
+ tanstackMetadata,
30
+ } from '../../../utilities/merge-metadata'
31
+ import { getChunkRunId } from '../../../utilities/chunk-ids'
32
+ import type { AdapterYieldChunk } from '../../../utilities/adapter-yield-chunk'
27
33
  import { normalizeToolResult } from '../../../utilities/tool-result'
28
34
  import { defaultJSONParser } from './json-parser'
29
35
  import {
@@ -56,6 +62,7 @@ import type {
56
62
  MessagePart,
57
63
  ModelMessage,
58
64
  StreamChunk,
65
+ ThinkingPart,
59
66
  ToolCall,
60
67
  ToolCallPart,
61
68
  ToolResultPart,
@@ -239,6 +246,7 @@ export class StreamProcessor {
239
246
  *
240
247
  * @param content - The message content (string or array of content parts)
241
248
  * @param id - Optional custom message ID (generated if not provided)
249
+ * @param metadata - Optional AG-UI metadata bag
242
250
  * @returns The created UIMessage
243
251
  *
244
252
  * @example
@@ -256,7 +264,11 @@ export class StreamProcessor {
256
264
  * processor.addUserMessage('Hello!', 'custom-id-123')
257
265
  * ```
258
266
  */
259
- addUserMessage(content: string | Array<ContentPart>, id?: string): UIMessage {
267
+ addUserMessage(
268
+ content: string | Array<ContentPart>,
269
+ id?: string,
270
+ metadata?: UIMessage['metadata'],
271
+ ): UIMessage {
260
272
  // Convert content to message parts
261
273
  const parts: Array<MessagePart> =
262
274
  typeof content === 'string'
@@ -271,6 +283,7 @@ export class StreamProcessor {
271
283
  role: 'user',
272
284
  parts,
273
285
  createdAt: new Date(),
286
+ ...(metadata != null ? { metadata } : {}),
274
287
  }
275
288
 
276
289
  this.messages = [...this.messages, userMessage]
@@ -623,6 +636,12 @@ export class StreamProcessor {
623
636
  )
624
637
  break
625
638
 
639
+ case 'REASONING_ENCRYPTED_VALUE':
640
+ this.handleReasoningEncryptedValueEvent(
641
+ chunk as Extract<StreamChunk, { type: 'REASONING_ENCRYPTED_VALUE' }>,
642
+ )
643
+ break
644
+
626
645
  case 'TOOL_CALL_RESULT':
627
646
  this.handleToolCallResultEvent(
628
647
  chunk as Extract<StreamChunk, { type: 'TOOL_CALL_RESULT' }>,
@@ -784,6 +803,55 @@ export class StreamProcessor {
784
803
  // Event Handlers
785
804
  // ============================================
786
805
 
806
+ /**
807
+ * Merge event metadata onto a UIMessage. `tanstack` is deep-merged so a
808
+ * later delta does not wipe `tanstack.model`. High-frequency leftover
809
+ * keys (`content`, `args`) never stamp onto the message.
810
+ * Rebuilds `createdAt` when `tanstack.createdAt` is an ISO string.
811
+ */
812
+ private mergeMessageMetadata(messageId: string, incoming: unknown): void {
813
+ if (
814
+ incoming == null ||
815
+ typeof incoming !== 'object' ||
816
+ Array.isArray(incoming)
817
+ ) {
818
+ return
819
+ }
820
+ const message = this.messages.find((msg) => msg.id === messageId)
821
+ if (!message) return
822
+
823
+ const incomingRecord = incoming as NonNullable<UIMessage['metadata']>
824
+ const incomingTanstack = tanstackMetadata(incomingRecord)
825
+ const toMerge =
826
+ incomingTanstack != null &&
827
+ ('content' in incomingTanstack || 'args' in incomingTanstack)
828
+ ? {
829
+ ...incomingRecord,
830
+ tanstack: Object.fromEntries(
831
+ Object.entries(incomingTanstack).filter(
832
+ ([key]) => key !== 'content' && key !== 'args',
833
+ ),
834
+ ),
835
+ }
836
+ : incomingRecord
837
+ 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())
843
+ this.messages = this.messages.map((msg) =>
844
+ msg.id === messageId
845
+ ? {
846
+ ...msg,
847
+ ...(metadata !== undefined ? { metadata } : {}),
848
+ ...(createdAtValid ? { createdAt } : {}),
849
+ }
850
+ : msg,
851
+ )
852
+ this.emitMessagesChange()
853
+ }
854
+
787
855
  /**
788
856
  * Handle TEXT_MESSAGE_START event
789
857
  */
@@ -837,6 +905,7 @@ export class StreamProcessor {
837
905
  this.activeMessageIds.add(messageId)
838
906
  }
839
907
 
908
+ this.mergeMessageMetadata(messageId, chunk.metadata)
840
909
  this.emitMessagesChange()
841
910
  return
842
911
  }
@@ -862,6 +931,7 @@ export class StreamProcessor {
862
931
  existingState.hasToolCallsSinceTextStart = false
863
932
  }
864
933
  }
934
+ this.mergeMessageMetadata(messageId, chunk.metadata)
865
935
  return
866
936
  }
867
937
 
@@ -877,6 +947,7 @@ export class StreamProcessor {
877
947
  this.createMessageState(messageId, uiRole)
878
948
  this.activeMessageIds.add(messageId)
879
949
 
950
+ this.mergeMessageMetadata(messageId, chunk.metadata)
880
951
  this.events.onStreamStart?.()
881
952
  this.emitMessagesChange()
882
953
  }
@@ -888,6 +959,7 @@ export class StreamProcessor {
888
959
  chunk: Extract<StreamChunk, { type: 'TEXT_MESSAGE_END' }>,
889
960
  ): void {
890
961
  const { messageId } = chunk
962
+ this.mergeMessageMetadata(messageId, chunk.metadata)
891
963
  const state = this.getMessageState(messageId)
892
964
  if (!state) return
893
965
  if (state.isComplete) return
@@ -921,8 +993,19 @@ export class StreamProcessor {
921
993
  // unreconstructable metadata, reconcile the normalized snapshot against
922
994
  // the pre-snapshot state; see `reconcileSnapshotToolCalls`.
923
995
  const prevMessages = this.messages
924
- const normalized = chunk.messages.map(aguiSnapshotMessageToUIMessage)
925
- this.messages = this.reconcileSnapshotToolCalls(normalized, prevMessages)
996
+ const prevById = new Map(prevMessages.map((msg) => [msg.id, msg]))
997
+ const normalized = this.mergeReasoningFanOut(
998
+ chunk.messages.map(aguiSnapshotMessageToUIMessage),
999
+ )
1000
+ this.messages = this.reconcileSnapshotToolCalls(
1001
+ normalized,
1002
+ prevMessages,
1003
+ ).map((msg) => {
1004
+ if (msg.metadata != null) return msg
1005
+ const prev = prevById.get(msg.id)
1006
+ if (prev?.metadata == null) return msg
1007
+ return { ...msg, metadata: prev.metadata }
1008
+ })
926
1009
  this.emitMessagesChange()
927
1010
  }
928
1011
 
@@ -949,6 +1032,54 @@ export class StreamProcessor {
949
1032
  * matching `tool-call` (and prefer pre-snapshot complete/output when the
950
1033
  * snapshot is poorer) so server tools keep the same UI shape as client tools.
951
1034
  */
1035
+ /**
1036
+ * Wire order is reasoning fan-outs, then the assistant anchor.
1037
+ * Snapshot conversion turns each reasoning row into its own assistant
1038
+ * message. Fold leading thinking-only messages into the next real
1039
+ * assistant. Do not fold into a tool-result-only message (`role: 'tool'`
1040
+ * on the wire). `reconcileSnapshotToolCalls` anchors those results.
1041
+ */
1042
+ private mergeReasoningFanOut(messages: Array<UIMessage>): Array<UIMessage> {
1043
+ const out: Array<UIMessage> = []
1044
+ let pending: Array<UIMessage> = []
1045
+ const thinkingParts = (msg: UIMessage) =>
1046
+ msg.parts.filter((part): part is ThinkingPart => part.type === 'thinking')
1047
+ const isThinkingOnly = (msg: UIMessage) =>
1048
+ msg.role === 'assistant' &&
1049
+ msg.parts.length > 0 &&
1050
+ msg.parts.every((part) => part.type === 'thinking')
1051
+ const isToolResultOnly = (msg: UIMessage) =>
1052
+ msg.role === 'assistant' &&
1053
+ msg.parts.length === 1 &&
1054
+ msg.parts[0]?.type === 'tool-result'
1055
+ const flushPending = () => {
1056
+ out.push(...pending)
1057
+ pending = []
1058
+ }
1059
+ for (const msg of messages) {
1060
+ if (isThinkingOnly(msg)) {
1061
+ pending.push(msg)
1062
+ continue
1063
+ }
1064
+ if (
1065
+ msg.role === 'assistant' &&
1066
+ pending.length > 0 &&
1067
+ !isToolResultOnly(msg)
1068
+ ) {
1069
+ out.push({
1070
+ ...msg,
1071
+ parts: [...pending.flatMap(thinkingParts), ...msg.parts],
1072
+ })
1073
+ pending = []
1074
+ continue
1075
+ }
1076
+ flushPending()
1077
+ out.push(msg)
1078
+ }
1079
+ flushPending()
1080
+ return out
1081
+ }
1082
+
952
1083
  private reconcileSnapshotToolCalls(
953
1084
  snapshot: Array<UIMessage>,
954
1085
  prevMessages: Array<UIMessage>,
@@ -1145,30 +1276,10 @@ export class StreamProcessor {
1145
1276
  chunk: Extract<StreamChunk, { type: 'TEXT_MESSAGE_CONTENT' }>,
1146
1277
  ): void {
1147
1278
  const { messageId, state } = this.ensureAssistantMessage(chunk.messageId)
1279
+ this.mergeMessageMetadata(messageId, chunk.metadata)
1148
1280
 
1149
1281
  if (this.structuredMessageIds.has(messageId)) {
1150
- // `chunk.delta` is incremental; `chunk.content` is sometimes cumulative
1151
- // (mirrors what the plain-text branch handles below). Reconcile against
1152
- // the existing raw buffer so adapters that emit cumulative content
1153
- // don't duplicate the JSON.
1154
- let delta = chunk.delta || ''
1155
- if (delta === '' && chunk.content !== undefined && chunk.content !== '') {
1156
- const existingRaw = (
1157
- this.messages
1158
- .find((m) => m.id === messageId)
1159
- ?.parts.find(
1160
- (p): p is Extract<MessagePart, { type: 'structured-output' }> =>
1161
- p.type === 'structured-output',
1162
- ) ?? { raw: '' }
1163
- ).raw
1164
- if (chunk.content.startsWith(existingRaw)) {
1165
- delta = chunk.content.slice(existingRaw.length)
1166
- } else if (existingRaw.startsWith(chunk.content)) {
1167
- delta = ''
1168
- } else {
1169
- delta = chunk.content
1170
- }
1171
- }
1282
+ const delta = chunk.delta || ''
1172
1283
  if (delta !== '') {
1173
1284
  this.messages = appendStructuredOutputDelta(
1174
1285
  this.messages,
@@ -1202,31 +1313,15 @@ export class StreamProcessor {
1202
1313
  }
1203
1314
 
1204
1315
  const currentText = state.currentSegmentText
1205
- let nextText = currentText
1206
-
1207
- // Prefer delta over content - delta is the incremental change
1208
- // Normalize to empty string to avoid "undefined" string concatenation
1209
1316
  const delta = chunk.delta || ''
1210
- if (delta !== '') {
1211
- nextText = currentText + delta
1212
- } else if (chunk.content !== undefined && chunk.content !== '') {
1213
- // Fallback: use content if delta is not provided
1214
- if (chunk.content.startsWith(currentText)) {
1215
- nextText = chunk.content
1216
- } else if (currentText.startsWith(chunk.content)) {
1217
- nextText = currentText
1218
- } else {
1219
- nextText = currentText + chunk.content
1220
- }
1221
- }
1317
+ const nextText = delta !== '' ? currentText + delta : currentText
1222
1318
 
1223
1319
  // Calculate the delta for totalTextContent
1224
1320
  const textDelta = nextText.slice(currentText.length)
1225
1321
  state.currentSegmentText = nextText
1226
1322
  state.totalTextContent += textDelta
1227
1323
 
1228
- // Use delta for chunk strategy if available
1229
- const chunkPortion = chunk.delta || chunk.content || ''
1324
+ const chunkPortion = chunk.delta || ''
1230
1325
  const shouldEmit = this.chunkStrategy.shouldEmit(
1231
1326
  chunkPortion,
1232
1327
  state.currentSegmentText,
@@ -1269,13 +1364,7 @@ export class StreamProcessor {
1269
1364
  // New tool call starting
1270
1365
  const initialState: ToolCallState = 'awaiting-input'
1271
1366
 
1272
- // `toolName` is a deprecated alias for `toolCallName` (see ToolCallStartEvent
1273
- // in types.ts). Accept either so chunks from older code paths or any
1274
- // adapter that only sets the deprecated field still produce a named part.
1275
- // The type marks both as required strings, but in practice some emitters
1276
- // only set one — fall back via the runtime value rather than the type.
1277
- const toolName =
1278
- (chunk as { toolCallName?: string }).toolCallName ?? chunk.toolName
1367
+ const toolName = chunk.toolCallName
1279
1368
 
1280
1369
  // Capture provider metadata that arrived on TOOL_CALL_START so it
1281
1370
  // round-trips back through the assistant message on the next turn
@@ -1288,7 +1377,7 @@ export class StreamProcessor {
1288
1377
  arguments: '',
1289
1378
  state: initialState,
1290
1379
  parsedArguments: undefined,
1291
- index: chunk.index ?? state.toolCalls.size,
1380
+ index: state.toolCalls.size,
1292
1381
  ...(chunkMetadata !== undefined && { metadata: chunkMetadata }),
1293
1382
  }
1294
1383
 
@@ -1377,17 +1466,15 @@ export class StreamProcessor {
1377
1466
  }
1378
1467
 
1379
1468
  /**
1380
- * Handle TOOL_CALL_END event — authoritative signal that a tool call's input is finalized.
1381
- *
1382
- * This event has a DUAL ROLE:
1383
- * - Without `result`: Signals arguments are done (from adapter). Transitions to input-complete.
1384
- * - With `result`: Signals tool was executed and result is available (from TextEngine).
1385
- * Creates both output on the tool-call part AND a tool-result part.
1469
+ * Handle TOOL_CALL_END event — arguments are finalized (input-complete).
1470
+ * Tool output arrives on TOOL_CALL_RESULT, not on this event.
1386
1471
  *
1387
- * If `input` is provided, it overrides the accumulated string parse as the
1388
- * canonical parsed arguments.
1472
+ * If TOOL_CALL_END carries parsed `input`, use it as the canonical arguments:
1473
+ * back-fill the accumulated string when no TOOL_CALL_ARGS deltas were seen
1474
+ * (adapters that deliver the whole input on END — e.g. Anthropic
1475
+ * server_tool_use / web_search — issue #839) and override the rendered part's
1476
+ * `input` with the canonical value.
1389
1477
  *
1390
- * @see docs/chat-architecture.md#tool-results-and-the-tool_call_end-dual-role — Full explanation
1391
1478
  * @see docs/chat-architecture.md#single-shot-tool-call-response — End-to-end flow
1392
1479
  */
1393
1480
  private handleToolCallEndEvent(
@@ -1399,15 +1486,22 @@ export class StreamProcessor {
1399
1486
  const msgState = this.getMessageState(messageId)
1400
1487
  if (!msgState) return
1401
1488
 
1489
+ // The parsed input can ride on the spec `input` field or, for adapters
1490
+ // that only stamp it into TanStack metadata, on `metadata.tanstack.input`.
1491
+ const input =
1492
+ chunk.input !== undefined
1493
+ ? chunk.input
1494
+ : (tanstackMetadata(chunk)?.input as unknown)
1495
+
1402
1496
  // Transition the tool call to input-complete (the authoritative completion signal)
1403
1497
  const existingToolCall = msgState.toolCalls.get(chunk.toolCallId)
1404
1498
  if (existingToolCall && existingToolCall.state !== 'input-complete') {
1405
- // If TOOL_CALL_END provides parsed input and no TOOL_CALL_ARGS were
1406
- // received, back-fill the arguments string so the UIMessage ToolCallPart
1407
- // carries the correct value (defensive against adapters that skip ARGS).
1408
- if (chunk.input !== undefined && !existingToolCall.arguments) {
1499
+ // Back-fill the arguments string from the parsed input when no
1500
+ // TOOL_CALL_ARGS deltas were received, so completeToolCall's strict parse
1501
+ // surfaces the correct value on the ToolCallPart.
1502
+ if (input !== undefined && !existingToolCall.arguments) {
1409
1503
  try {
1410
- existingToolCall.arguments = JSON.stringify(chunk.input)
1504
+ existingToolCall.arguments = JSON.stringify(input)
1411
1505
  } catch {
1412
1506
  // circular refs, BigInt, etc. — leave arguments empty rather than
1413
1507
  // aborting stream processing
@@ -1416,20 +1510,18 @@ export class StreamProcessor {
1416
1510
 
1417
1511
  const index = msgState.toolCallOrder.indexOf(chunk.toolCallId)
1418
1512
  this.completeToolCall(messageId, index, existingToolCall)
1419
- // If TOOL_CALL_END provides parsed input, use it as the canonical parsed
1420
- // arguments (overrides the accumulated string parse from completeToolCall)
1421
- // and refresh the rendered part's `input` so it reflects the canonical
1422
- // value rather than the possibly-divergent accumulated-args parse that
1423
- // completeToolCall wrote (e.g. an adapter that coerces values differently
1424
- // between the streamed args and the final structured input).
1425
- if (chunk.input !== undefined) {
1426
- existingToolCall.parsedArguments = chunk.input
1513
+
1514
+ // Canonicalize on the parsed input: overrides the accumulated-args parse
1515
+ // that completeToolCall wrote (adapters may coerce values differently
1516
+ // between streamed args and the final structured input).
1517
+ if (input !== undefined) {
1518
+ existingToolCall.parsedArguments = input
1427
1519
  this.messages = updateToolCallPart(this.messages, messageId, {
1428
1520
  id: existingToolCall.id,
1429
1521
  name: existingToolCall.name,
1430
1522
  arguments: existingToolCall.arguments,
1431
1523
  state: 'input-complete',
1432
- input: chunk.input,
1524
+ input,
1433
1525
  ...(existingToolCall.metadata !== undefined && {
1434
1526
  metadata: existingToolCall.metadata,
1435
1527
  }),
@@ -1437,43 +1529,6 @@ export class StreamProcessor {
1437
1529
  this.emitMessagesChange()
1438
1530
  }
1439
1531
  }
1440
-
1441
- // Update UIMessage if there's a result
1442
- if (chunk.result) {
1443
- // Step 1: Update the tool-call part's output field (for UI consistency
1444
- // with client tools — see GitHub issue #176)
1445
- let output: unknown
1446
- if (Array.isArray(chunk.result)) {
1447
- output = chunk.result
1448
- } else {
1449
- try {
1450
- output = JSON.parse(chunk.result)
1451
- } catch {
1452
- output = chunk.result
1453
- }
1454
- }
1455
- this.messages = updateToolCallWithOutput(
1456
- this.messages,
1457
- chunk.toolCallId,
1458
- output,
1459
- chunk.state === 'output-error' ? 'error' : undefined,
1460
- )
1461
-
1462
- // Step 2: Create/update the tool-result part (for LLM conversation history)
1463
- const resultState: ToolResultState =
1464
- chunk.state === 'output-error' ? 'error' : 'complete'
1465
- this.messages = updateToolResultPart(
1466
- this.messages,
1467
- messageId,
1468
- chunk.toolCallId,
1469
- chunk.result,
1470
- resultState,
1471
- resultState === 'error'
1472
- ? this.extractToolResultError(output)
1473
- : undefined,
1474
- )
1475
- this.emitMessagesChange()
1476
- }
1477
1532
  }
1478
1533
 
1479
1534
  private extractToolResultError(output: unknown): string {
@@ -1498,9 +1553,27 @@ export class StreamProcessor {
1498
1553
  private handleToolCallResultEvent(
1499
1554
  chunk: Extract<StreamChunk, { type: 'TOOL_CALL_RESULT' }>,
1500
1555
  ): void {
1501
- const messageId = this.toolCallToMessage.get(chunk.toolCallId)
1556
+ // A resume stream delivers TOOL_CALL_RESULT for a tool call that was
1557
+ // started in a PRIOR run. A preceding MESSAGES_SNAPSHOT resets stream state
1558
+ // (clearing `toolCallToMessage`), so fall back to locating the message that
1559
+ // owns the tool-call part — matching `addToolResult`/`handleInterrupts`.
1560
+ // Without this the result is dropped, the follow-up request omits the tool
1561
+ // message, and the still-"pending" tool call re-interrupts (issue #532).
1562
+ const messageId =
1563
+ this.toolCallToMessage.get(chunk.toolCallId) ??
1564
+ this.messages.find((m) =>
1565
+ m.parts.some(
1566
+ (p): p is ToolCallPart =>
1567
+ p.type === 'tool-call' && p.id === chunk.toolCallId,
1568
+ ),
1569
+ )?.id
1502
1570
  if (!messageId) return
1503
1571
 
1572
+ const extra = chunk as AdapterYieldChunk
1573
+ const isOutputError =
1574
+ extra.state === 'output-error' ||
1575
+ tanstackMetadata(chunk)?.state === 'output-error'
1576
+
1504
1577
  // Step 1: Update the tool-call part's output field
1505
1578
  let output: unknown
1506
1579
  try {
@@ -1512,12 +1585,11 @@ export class StreamProcessor {
1512
1585
  this.messages,
1513
1586
  chunk.toolCallId,
1514
1587
  output,
1515
- chunk.state === 'output-error' ? 'error' : undefined,
1588
+ isOutputError ? 'error' : undefined,
1516
1589
  )
1517
1590
 
1518
1591
  // Step 2: Create/update the tool-result part
1519
- const resultState: ToolResultState =
1520
- chunk.state === 'output-error' ? 'error' : 'complete'
1592
+ const resultState: ToolResultState = isOutputError ? 'error' : 'complete'
1521
1593
  this.messages = updateToolResultPart(
1522
1594
  this.messages,
1523
1595
  messageId,
@@ -1554,7 +1626,11 @@ export class StreamProcessor {
1554
1626
  private handleRunFinishedEvent(
1555
1627
  chunk: Extract<StreamChunk, { type: 'RUN_FINISHED' }>,
1556
1628
  ): void {
1557
- this.finishReason = chunk.finishReason ?? null
1629
+ const extra = chunk as AdapterYieldChunk
1630
+ this.finishReason =
1631
+ extra.finishReason !== undefined
1632
+ ? extra.finishReason
1633
+ : (tanstackMetadata(chunk)?.finishReason ?? null)
1558
1634
  this.activeRuns.delete(chunk.runId)
1559
1635
 
1560
1636
  if (chunk.outcome?.type === 'interrupt') {
@@ -1647,10 +1723,7 @@ export class StreamProcessor {
1647
1723
  chunk: Extract<StreamChunk, { type: 'RUN_ERROR' }>,
1648
1724
  ): void {
1649
1725
  this.hasError = true
1650
- const runId =
1651
- 'runId' in chunk && typeof chunk.runId === 'string'
1652
- ? chunk.runId
1653
- : undefined
1726
+ const runId = getChunkRunId(chunk)
1654
1727
  if (runId) {
1655
1728
  this.activeRuns.delete(runId)
1656
1729
  } else {
@@ -1660,9 +1733,8 @@ export class StreamProcessor {
1660
1733
  // Prefer spec field `message`; fall back to deprecated `error.message`.
1661
1734
  // If neither is set, the chunk still carries debug context (provider
1662
1735
  // error codes, request ids, etc.) — log it so the failure isn't silent.
1663
- const errorMessage =
1664
- chunk.message || chunk.error?.message || 'An error occurred'
1665
- if (!chunk.message && !chunk.error?.message) {
1736
+ const errorMessage = chunk.message || 'An error occurred'
1737
+ if (!chunk.message) {
1666
1738
  console.error(
1667
1739
  '[StreamProcessor] RUN_ERROR with no message; original chunk:',
1668
1740
  chunk,
@@ -1691,14 +1763,14 @@ export class StreamProcessor {
1691
1763
  /**
1692
1764
  * Handle STEP_STARTED event (for thinking/reasoning content).
1693
1765
  *
1694
- * Records the stepId so that subsequent STEP_FINISHED deltas accumulate
1766
+ * Records the stepId so later REASONING_MESSAGE_CONTENT deltas accumulate
1695
1767
  * into their own ThinkingPart. Does not create a message — the message
1696
- * is lazily created when the first STEP_FINISHED content arrives.
1768
+ * is lazily created when the first REASONING_MESSAGE_CONTENT arrives.
1697
1769
  */
1698
1770
  private handleStepStartedEvent(
1699
1771
  chunk: Extract<StreamChunk, { type: 'STEP_STARTED' }>,
1700
1772
  ): void {
1701
- const stepId = chunk.stepId ?? generateMessageId()
1773
+ const stepId = chunk.stepName || generateMessageId()
1702
1774
  const activeId = this.getActiveAssistantMessageId()
1703
1775
  if (activeId) {
1704
1776
  const state = this.getMessageState(activeId)
@@ -1711,102 +1783,49 @@ export class StreamProcessor {
1711
1783
  // Clear any pending stepId from a prior STEP_STARTED that fired
1712
1784
  // before the assistant message existed. Now that we're tracking
1713
1785
  // the step directly on message state, the pending value is stale
1714
- // and must not leak into the next STEP_FINISHED (which would
1715
- // misattribute its delta to the stale step).
1786
+ // and must not leak into the next REASONING_MESSAGE_CONTENT.
1716
1787
  this.pendingThinkingStepId = null
1717
1788
  return
1718
1789
  }
1719
1790
  }
1720
1791
 
1721
- // No active message yet — defer until ensureAssistantMessage in STEP_FINISHED
1792
+ // No active message yet — defer until ensureAssistantMessage in
1793
+ // REASONING_MESSAGE_CONTENT
1722
1794
  this.pendingThinkingStepId = stepId
1723
1795
  }
1724
1796
 
1725
1797
  /**
1726
- * Handle STEP_FINISHED event (for thinking/reasoning content).
1798
+ * Handle STEP_FINISHED event.
1727
1799
  *
1728
- * Accumulates delta into the current thinking step's content and updates
1729
- * the corresponding ThinkingPart in the UIMessage.
1730
- *
1731
- * @see docs/chat-architecture.md#thinkingreasoning-content — Thinking flow
1800
+ * Thinking *content* comes from REASONING_MESSAGE_CONTENT, not STEP_FINISHED.
1801
+ * But some adapters (e.g. BytePlus thinking-summary) carry the provider
1802
+ * signature blob ONLY on the STEP_FINISHED event, so still extract that here
1803
+ * and attach it to the thinking step the reasoning events already built.
1732
1804
  */
1733
1805
  private handleStepFinishedEvent(
1734
1806
  chunk: Extract<StreamChunk, { type: 'STEP_FINISHED' }>,
1735
1807
  ): void {
1808
+ const extra = chunk as AdapterYieldChunk
1809
+ const signature = extra.signature
1810
+ if (!signature) return
1811
+
1736
1812
  const { messageId, state } = this.ensureAssistantMessage(
1737
1813
  this.getActiveAssistantMessageId() ?? undefined,
1738
1814
  )
1815
+ const stepId = state.currentThinkingStepId ?? extra.stepId
1816
+ if (!stepId) return
1817
+ const thinking = state.thinkingSteps.get(stepId)
1818
+ if (thinking === undefined) return
1739
1819
 
1740
- // During the transition period, adapters emit BOTH STEP_FINISHED and
1741
- // REASONING_MESSAGE_CONTENT with the same delta. If we've already processed
1742
- // REASONING_MESSAGE_CONTENT events for this message, skip the duplicate
1743
- // thinking content from STEP_FINISHED to avoid doubled content.
1744
- if (state.hasSeenReasoningEvents) {
1745
- if (chunk.signature) {
1746
- const stepId = state.currentThinkingStepId ?? chunk.stepId
1747
- if (!stepId) return
1748
- const thinking = state.thinkingSteps.get(stepId)
1749
- if (thinking !== undefined) {
1750
- state.thinkingStepSignatures.set(stepId, chunk.signature)
1751
- this.messages = updateThinkingPart(
1752
- this.messages,
1753
- messageId,
1754
- stepId,
1755
- thinking,
1756
- chunk.signature,
1757
- )
1758
- this.emitMessagesChange()
1759
- }
1760
- }
1761
- return
1762
- }
1763
-
1764
- this.consumePendingThinkingStep(state)
1765
-
1766
- const stepId =
1767
- state.currentThinkingStepId ?? chunk.stepId ?? generateMessageId()
1768
-
1769
- // Auto-initialize if no prior STEP_STARTED (backward compat)
1770
- if (!state.thinkingSteps.has(stepId)) {
1771
- state.thinkingSteps.set(stepId, '')
1772
- state.thinkingStepOrder.push(stepId)
1773
- state.currentThinkingStepId = stepId
1774
- }
1775
-
1776
- const previous = state.thinkingSteps.get(stepId) ?? ''
1777
- let nextThinking = previous
1778
-
1779
- // Prefer delta over content
1780
- if (chunk.delta && chunk.delta !== '') {
1781
- nextThinking = previous + chunk.delta
1782
- } else if (chunk.content && chunk.content !== '') {
1783
- if (chunk.content.startsWith(previous)) {
1784
- nextThinking = chunk.content
1785
- } else if (previous.startsWith(chunk.content)) {
1786
- nextThinking = previous
1787
- } else {
1788
- nextThinking = previous + chunk.content
1789
- }
1790
- }
1791
-
1792
- state.thinkingSteps.set(stepId, nextThinking)
1793
-
1794
- if (chunk.signature) {
1795
- state.thinkingStepSignatures.set(stepId, chunk.signature)
1796
- }
1797
-
1798
- // Update UIMessage
1820
+ state.thinkingStepSignatures.set(stepId, signature)
1799
1821
  this.messages = updateThinkingPart(
1800
1822
  this.messages,
1801
1823
  messageId,
1802
1824
  stepId,
1803
- nextThinking,
1804
- state.thinkingStepSignatures.get(stepId),
1825
+ thinking,
1826
+ signature,
1805
1827
  )
1806
1828
  this.emitMessagesChange()
1807
-
1808
- // Emit granular event
1809
- this.events.onThinkingUpdate?.(messageId, stepId, nextThinking)
1810
1829
  }
1811
1830
 
1812
1831
  /**
@@ -1849,6 +1868,74 @@ export class StreamProcessor {
1849
1868
  this.events.onThinkingUpdate?.(messageId, stepId, nextThinking)
1850
1869
  }
1851
1870
 
1871
+ /**
1872
+ * Attach a provider signature blob from REASONING_ENCRYPTED_VALUE.
1873
+ * `subtype: 'message'` updates ThinkingPart.signature.
1874
+ * `subtype: 'tool-call'` stores Gemini thoughtSignature on the tool-call part.
1875
+ */
1876
+ private handleReasoningEncryptedValueEvent(
1877
+ chunk: Extract<StreamChunk, { type: 'REASONING_ENCRYPTED_VALUE' }>,
1878
+ ): void {
1879
+ const encryptedValue = chunk.encryptedValue
1880
+ if (typeof encryptedValue !== 'string' || encryptedValue === '') return
1881
+
1882
+ if (chunk.subtype === 'tool-call') {
1883
+ this.attachToolCallSignature(chunk.entityId, encryptedValue)
1884
+ return
1885
+ }
1886
+
1887
+ const { messageId, state } = this.ensureAssistantMessage(
1888
+ this.getActiveAssistantMessageId() ?? undefined,
1889
+ )
1890
+ const stepId = state.currentThinkingStepId ?? chunk.entityId
1891
+ state.thinkingStepSignatures.set(stepId, encryptedValue)
1892
+ const content = state.thinkingSteps.get(stepId) ?? ''
1893
+ if (!state.thinkingSteps.has(stepId)) {
1894
+ state.thinkingSteps.set(stepId, content)
1895
+ state.thinkingStepOrder.push(stepId)
1896
+ }
1897
+ this.messages = updateThinkingPart(
1898
+ this.messages,
1899
+ messageId,
1900
+ stepId,
1901
+ content,
1902
+ encryptedValue,
1903
+ )
1904
+ this.emitMessagesChange()
1905
+ }
1906
+
1907
+ private attachToolCallSignature(
1908
+ toolCallId: string,
1909
+ thoughtSignature: string,
1910
+ ): void {
1911
+ this.messages = this.messages.map((msg) => {
1912
+ let changed = false
1913
+ const parts = msg.parts.map((part) => {
1914
+ if (part.type !== 'tool-call' || part.id !== toolCallId) return part
1915
+ changed = true
1916
+ return {
1917
+ ...part,
1918
+ metadata: {
1919
+ ...(part.metadata != null && typeof part.metadata === 'object'
1920
+ ? part.metadata
1921
+ : {}),
1922
+ thoughtSignature,
1923
+ },
1924
+ }
1925
+ })
1926
+ return changed ? { ...msg, parts } : msg
1927
+ })
1928
+ for (const state of this.messageStates.values()) {
1929
+ const call = state.toolCalls.get(toolCallId)
1930
+ if (!call) continue
1931
+ call.metadata = {
1932
+ ...(call.metadata ?? {}),
1933
+ thoughtSignature,
1934
+ }
1935
+ }
1936
+ this.emitMessagesChange()
1937
+ }
1938
+
1852
1939
  /**
1853
1940
  * Handle CUSTOM event.
1854
1941
  *
@@ -2022,22 +2109,10 @@ export class StreamProcessor {
2022
2109
  * Detect if an incoming content chunk represents a NEW text segment
2023
2110
  */
2024
2111
  private isNewTextSegment(
2025
- chunk: Extract<StreamChunk, { type: 'TEXT_MESSAGE_CONTENT' }>,
2026
- previous: string,
2112
+ _chunk: Extract<StreamChunk, { type: 'TEXT_MESSAGE_CONTENT' }>,
2113
+ _previous: string,
2027
2114
  ): boolean {
2028
- // Check if content is present (delta is always defined but may be empty string)
2029
- if (chunk.content !== undefined) {
2030
- if (chunk.content.length < previous.length) {
2031
- return true
2032
- }
2033
- if (
2034
- !chunk.content.startsWith(previous) &&
2035
- !previous.startsWith(chunk.content)
2036
- ) {
2037
- return true
2038
- }
2039
- }
2040
- return false
2115
+ return true
2041
2116
  }
2042
2117
 
2043
2118
  /**
@@ -2111,11 +2186,7 @@ export class StreamProcessor {
2111
2186
  }
2112
2187
 
2113
2188
  // Update UIMessage. The arguments are complete now, so surface the parsed
2114
- // input on the part. For adapters that skip TOOL_CALL_ARGS the arguments
2115
- // string was back-filled from TOOL_CALL_END.input, so this parse matches
2116
- // the canonical input. If a TOOL_CALL_END.input diverges from the
2117
- // accumulated args, handleToolCallEndEvent re-updates the part with the
2118
- // canonical value after this call.
2189
+ // input on the part from the accumulated TOOL_CALL_ARGS deltas.
2119
2190
  this.messages = updateToolCallPart(this.messages, messageId, {
2120
2191
  id: toolCall.id,
2121
2192
  name: toolCall.name,