@tanstack/ai 0.47.2 → 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 (105) 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 +126 -31
  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 +197 -110
  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 +59 -54
  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 +12 -5
  30. package/dist/esm/middlewares/otel.js.map +1 -1
  31. package/dist/esm/stream-to-response.js +13 -6
  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/errors.d.ts +9 -0
  53. package/dist/esm/utilities/errors.js +13 -1
  54. package/dist/esm/utilities/errors.js.map +1 -1
  55. package/dist/esm/utilities/merge-metadata.d.ts +14 -0
  56. package/dist/esm/utilities/merge-metadata.js +43 -0
  57. package/dist/esm/utilities/merge-metadata.js.map +1 -0
  58. package/dist/esm/utilities/normalize-stream-chunk.d.ts +3 -0
  59. package/dist/esm/utilities/normalize-stream-chunk.js +100 -0
  60. package/dist/esm/utilities/normalize-stream-chunk.js.map +1 -0
  61. package/dist/esm/utilities/reasoning-encrypted-value.d.ts +8 -0
  62. package/dist/esm/utilities/reasoning-encrypted-value.js +16 -0
  63. package/dist/esm/utilities/reasoning-encrypted-value.js.map +1 -0
  64. package/dist/esm/utilities/restore-inbound-chunk.d.ts +15 -0
  65. package/dist/esm/utilities/restore-inbound-chunk.js +43 -0
  66. package/dist/esm/utilities/restore-inbound-chunk.js.map +1 -0
  67. package/dist/esm/utilities/spec-event-keys.d.ts +2 -0
  68. package/dist/esm/utilities/spec-event-keys.js +56 -0
  69. package/dist/esm/utilities/spec-event-keys.js.map +1 -0
  70. package/dist/esm/utilities/structured-output-events.d.ts +3 -3
  71. package/dist/esm/utilities/structured-output-events.js.map +1 -1
  72. package/package.json +3 -3
  73. package/src/activities/chat/adapter.ts +4 -4
  74. package/src/activities/chat/index.ts +398 -400
  75. package/src/activities/chat/messages.ts +183 -36
  76. package/src/activities/chat/stream/processor.ts +313 -239
  77. package/src/activities/chat/tools/tool-calls.ts +23 -22
  78. package/src/activities/generateVideo/index.ts +7 -6
  79. package/src/activities/stream-generation-result.ts +8 -12
  80. package/src/activities/summarize/chat-stream-summarize.ts +98 -70
  81. package/src/adapter-internals.ts +2 -0
  82. package/src/client.ts +13 -0
  83. package/src/index.ts +6 -0
  84. package/src/middlewares/otel.ts +16 -8
  85. package/src/stream-to-response.ts +16 -4
  86. package/src/stream-to-websocket.ts +3 -1
  87. package/src/strip-to-spec-middleware.ts +48 -24
  88. package/src/types.ts +109 -393
  89. package/src/utilities/adapter-yield-chunk.ts +30 -0
  90. package/src/utilities/ag-ui-usage.test.ts +194 -0
  91. package/src/utilities/ag-ui-usage.ts +148 -0
  92. package/src/utilities/ag-ui-wire.ts +149 -31
  93. package/src/utilities/chat-params.ts +22 -39
  94. package/src/utilities/chunk-ids.ts +24 -0
  95. package/src/utilities/errors.ts +23 -0
  96. package/src/utilities/merge-metadata.test.ts +117 -0
  97. package/src/utilities/merge-metadata.ts +59 -0
  98. package/src/utilities/normalize-stream-chunk.test.ts +423 -0
  99. package/src/utilities/normalize-stream-chunk.ts +186 -0
  100. package/src/utilities/reasoning-encrypted-value.ts +18 -0
  101. package/src/utilities/restore-inbound-chunk.test.ts +133 -0
  102. package/src/utilities/restore-inbound-chunk.ts +72 -0
  103. package/src/utilities/spec-event-keys.test.ts +34 -0
  104. package/src/utilities/spec-event-keys.ts +74 -0
  105. package/src/utilities/structured-output-events.ts +3 -3
@@ -22,8 +22,15 @@ import {
22
22
  generateMessageId,
23
23
  uiMessageToModelMessages,
24
24
  } from '../messages.js'
25
- import { normalizeToolResult } from '../../../utilities/tool-result'
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'
33
+ import { normalizeToolResult } from '../../../utilities/tool-result'
27
34
  import { defaultJSONParser } from './json-parser'
28
35
  import {
29
36
  appendStructuredOutputDelta,
@@ -55,6 +62,7 @@ import type {
55
62
  MessagePart,
56
63
  ModelMessage,
57
64
  StreamChunk,
65
+ ThinkingPart,
58
66
  ToolCall,
59
67
  ToolCallPart,
60
68
  ToolResultPart,
@@ -238,6 +246,7 @@ export class StreamProcessor {
238
246
  *
239
247
  * @param content - The message content (string or array of content parts)
240
248
  * @param id - Optional custom message ID (generated if not provided)
249
+ * @param metadata - Optional AG-UI metadata bag
241
250
  * @returns The created UIMessage
242
251
  *
243
252
  * @example
@@ -255,7 +264,11 @@ export class StreamProcessor {
255
264
  * processor.addUserMessage('Hello!', 'custom-id-123')
256
265
  * ```
257
266
  */
258
- addUserMessage(content: string | Array<ContentPart>, id?: string): UIMessage {
267
+ addUserMessage(
268
+ content: string | Array<ContentPart>,
269
+ id?: string,
270
+ metadata?: UIMessage['metadata'],
271
+ ): UIMessage {
259
272
  // Convert content to message parts
260
273
  const parts: Array<MessagePart> =
261
274
  typeof content === 'string'
@@ -270,6 +283,7 @@ export class StreamProcessor {
270
283
  role: 'user',
271
284
  parts,
272
285
  createdAt: new Date(),
286
+ ...(metadata != null ? { metadata } : {}),
273
287
  }
274
288
 
275
289
  this.messages = [...this.messages, userMessage]
@@ -622,6 +636,12 @@ export class StreamProcessor {
622
636
  )
623
637
  break
624
638
 
639
+ case 'REASONING_ENCRYPTED_VALUE':
640
+ this.handleReasoningEncryptedValueEvent(
641
+ chunk as Extract<StreamChunk, { type: 'REASONING_ENCRYPTED_VALUE' }>,
642
+ )
643
+ break
644
+
625
645
  case 'TOOL_CALL_RESULT':
626
646
  this.handleToolCallResultEvent(
627
647
  chunk as Extract<StreamChunk, { type: 'TOOL_CALL_RESULT' }>,
@@ -783,6 +803,55 @@ export class StreamProcessor {
783
803
  // Event Handlers
784
804
  // ============================================
785
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
+
786
855
  /**
787
856
  * Handle TEXT_MESSAGE_START event
788
857
  */
@@ -819,6 +888,15 @@ export class StreamProcessor {
819
888
  // Update activeMessageIds
820
889
  this.activeMessageIds.delete(pendingId)
821
890
  this.activeMessageIds.add(messageId)
891
+
892
+ // TOOL_CALL_ARGS/END route through toolCallToMessage. Keep those
893
+ // entries on the remapped id so later args still accumulate
894
+ // (interleaved text can arrive as a full START/CONTENT/END block).
895
+ for (const [toolCallId, mappedMessageId] of this.toolCallToMessage) {
896
+ if (mappedMessageId === pendingId) {
897
+ this.toolCallToMessage.set(toolCallId, messageId)
898
+ }
899
+ }
822
900
  }
823
901
 
824
902
  // Ensure state exists
@@ -827,6 +905,7 @@ export class StreamProcessor {
827
905
  this.activeMessageIds.add(messageId)
828
906
  }
829
907
 
908
+ this.mergeMessageMetadata(messageId, chunk.metadata)
830
909
  this.emitMessagesChange()
831
910
  return
832
911
  }
@@ -852,6 +931,7 @@ export class StreamProcessor {
852
931
  existingState.hasToolCallsSinceTextStart = false
853
932
  }
854
933
  }
934
+ this.mergeMessageMetadata(messageId, chunk.metadata)
855
935
  return
856
936
  }
857
937
 
@@ -867,6 +947,7 @@ export class StreamProcessor {
867
947
  this.createMessageState(messageId, uiRole)
868
948
  this.activeMessageIds.add(messageId)
869
949
 
950
+ this.mergeMessageMetadata(messageId, chunk.metadata)
870
951
  this.events.onStreamStart?.()
871
952
  this.emitMessagesChange()
872
953
  }
@@ -878,6 +959,7 @@ export class StreamProcessor {
878
959
  chunk: Extract<StreamChunk, { type: 'TEXT_MESSAGE_END' }>,
879
960
  ): void {
880
961
  const { messageId } = chunk
962
+ this.mergeMessageMetadata(messageId, chunk.metadata)
881
963
  const state = this.getMessageState(messageId)
882
964
  if (!state) return
883
965
  if (state.isComplete) return
@@ -886,9 +968,6 @@ export class StreamProcessor {
886
968
  if (state.currentSegmentText !== state.lastEmittedText) {
887
969
  this.emitTextUpdateForMessage(messageId)
888
970
  }
889
-
890
- // Complete all tool calls for this message
891
- this.completeAllToolCallsForMessage(messageId)
892
971
  }
893
972
 
894
973
  /**
@@ -914,8 +993,19 @@ export class StreamProcessor {
914
993
  // unreconstructable metadata, reconcile the normalized snapshot against
915
994
  // the pre-snapshot state; see `reconcileSnapshotToolCalls`.
916
995
  const prevMessages = this.messages
917
- const normalized = chunk.messages.map(aguiSnapshotMessageToUIMessage)
918
- 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
+ })
919
1009
  this.emitMessagesChange()
920
1010
  }
921
1011
 
@@ -942,6 +1032,54 @@ export class StreamProcessor {
942
1032
  * matching `tool-call` (and prefer pre-snapshot complete/output when the
943
1033
  * snapshot is poorer) so server tools keep the same UI shape as client tools.
944
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
+
945
1083
  private reconcileSnapshotToolCalls(
946
1084
  snapshot: Array<UIMessage>,
947
1085
  prevMessages: Array<UIMessage>,
@@ -1138,33 +1276,10 @@ export class StreamProcessor {
1138
1276
  chunk: Extract<StreamChunk, { type: 'TEXT_MESSAGE_CONTENT' }>,
1139
1277
  ): void {
1140
1278
  const { messageId, state } = this.ensureAssistantMessage(chunk.messageId)
1141
-
1142
- // Content arriving means all current tool calls for this message are complete
1143
- this.completeAllToolCallsForMessage(messageId)
1279
+ this.mergeMessageMetadata(messageId, chunk.metadata)
1144
1280
 
1145
1281
  if (this.structuredMessageIds.has(messageId)) {
1146
- // `chunk.delta` is incremental; `chunk.content` is sometimes cumulative
1147
- // (mirrors what the plain-text branch handles below). Reconcile against
1148
- // the existing raw buffer so adapters that emit cumulative content
1149
- // don't duplicate the JSON.
1150
- let delta = chunk.delta || ''
1151
- if (delta === '' && chunk.content !== undefined && chunk.content !== '') {
1152
- const existingRaw = (
1153
- this.messages
1154
- .find((m) => m.id === messageId)
1155
- ?.parts.find(
1156
- (p): p is Extract<MessagePart, { type: 'structured-output' }> =>
1157
- p.type === 'structured-output',
1158
- ) ?? { raw: '' }
1159
- ).raw
1160
- if (chunk.content.startsWith(existingRaw)) {
1161
- delta = chunk.content.slice(existingRaw.length)
1162
- } else if (existingRaw.startsWith(chunk.content)) {
1163
- delta = ''
1164
- } else {
1165
- delta = chunk.content
1166
- }
1167
- }
1282
+ const delta = chunk.delta || ''
1168
1283
  if (delta !== '') {
1169
1284
  this.messages = appendStructuredOutputDelta(
1170
1285
  this.messages,
@@ -1198,31 +1313,15 @@ export class StreamProcessor {
1198
1313
  }
1199
1314
 
1200
1315
  const currentText = state.currentSegmentText
1201
- let nextText = currentText
1202
-
1203
- // Prefer delta over content - delta is the incremental change
1204
- // Normalize to empty string to avoid "undefined" string concatenation
1205
1316
  const delta = chunk.delta || ''
1206
- if (delta !== '') {
1207
- nextText = currentText + delta
1208
- } else if (chunk.content !== undefined && chunk.content !== '') {
1209
- // Fallback: use content if delta is not provided
1210
- if (chunk.content.startsWith(currentText)) {
1211
- nextText = chunk.content
1212
- } else if (currentText.startsWith(chunk.content)) {
1213
- nextText = currentText
1214
- } else {
1215
- nextText = currentText + chunk.content
1216
- }
1217
- }
1317
+ const nextText = delta !== '' ? currentText + delta : currentText
1218
1318
 
1219
1319
  // Calculate the delta for totalTextContent
1220
1320
  const textDelta = nextText.slice(currentText.length)
1221
1321
  state.currentSegmentText = nextText
1222
1322
  state.totalTextContent += textDelta
1223
1323
 
1224
- // Use delta for chunk strategy if available
1225
- const chunkPortion = chunk.delta || chunk.content || ''
1324
+ const chunkPortion = chunk.delta || ''
1226
1325
  const shouldEmit = this.chunkStrategy.shouldEmit(
1227
1326
  chunkPortion,
1228
1327
  state.currentSegmentText,
@@ -1265,13 +1364,7 @@ export class StreamProcessor {
1265
1364
  // New tool call starting
1266
1365
  const initialState: ToolCallState = 'awaiting-input'
1267
1366
 
1268
- // `toolName` is a deprecated alias for `toolCallName` (see ToolCallStartEvent
1269
- // in types.ts). Accept either so chunks from older code paths or any
1270
- // adapter that only sets the deprecated field still produce a named part.
1271
- // The type marks both as required strings, but in practice some emitters
1272
- // only set one — fall back via the runtime value rather than the type.
1273
- const toolName =
1274
- (chunk as { toolCallName?: string }).toolCallName ?? chunk.toolName
1367
+ const toolName = chunk.toolCallName
1275
1368
 
1276
1369
  // Capture provider metadata that arrived on TOOL_CALL_START so it
1277
1370
  // round-trips back through the assistant message on the next turn
@@ -1284,7 +1377,7 @@ export class StreamProcessor {
1284
1377
  arguments: '',
1285
1378
  state: initialState,
1286
1379
  parsedArguments: undefined,
1287
- index: chunk.index ?? state.toolCalls.size,
1380
+ index: state.toolCalls.size,
1288
1381
  ...(chunkMetadata !== undefined && { metadata: chunkMetadata }),
1289
1382
  }
1290
1383
 
@@ -1373,17 +1466,15 @@ export class StreamProcessor {
1373
1466
  }
1374
1467
 
1375
1468
  /**
1376
- * Handle TOOL_CALL_END event — authoritative signal that a tool call's input is finalized.
1469
+ * Handle TOOL_CALL_END event — arguments are finalized (input-complete).
1470
+ * Tool output arrives on TOOL_CALL_RESULT, not on this event.
1377
1471
  *
1378
- * This event has a DUAL ROLE:
1379
- * - Without `result`: Signals arguments are done (from adapter). Transitions to input-complete.
1380
- * - With `result`: Signals tool was executed and result is available (from TextEngine).
1381
- * Creates both output on the tool-call part AND a tool-result part.
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.
1382
1477
  *
1383
- * If `input` is provided, it overrides the accumulated string parse as the
1384
- * canonical parsed arguments.
1385
- *
1386
- * @see docs/chat-architecture.md#tool-results-and-the-tool_call_end-dual-role — Full explanation
1387
1478
  * @see docs/chat-architecture.md#single-shot-tool-call-response — End-to-end flow
1388
1479
  */
1389
1480
  private handleToolCallEndEvent(
@@ -1395,15 +1486,22 @@ export class StreamProcessor {
1395
1486
  const msgState = this.getMessageState(messageId)
1396
1487
  if (!msgState) return
1397
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
+
1398
1496
  // Transition the tool call to input-complete (the authoritative completion signal)
1399
1497
  const existingToolCall = msgState.toolCalls.get(chunk.toolCallId)
1400
1498
  if (existingToolCall && existingToolCall.state !== 'input-complete') {
1401
- // If TOOL_CALL_END provides parsed input and no TOOL_CALL_ARGS were
1402
- // received, back-fill the arguments string so the UIMessage ToolCallPart
1403
- // carries the correct value (defensive against adapters that skip ARGS).
1404
- 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) {
1405
1503
  try {
1406
- existingToolCall.arguments = JSON.stringify(chunk.input)
1504
+ existingToolCall.arguments = JSON.stringify(input)
1407
1505
  } catch {
1408
1506
  // circular refs, BigInt, etc. — leave arguments empty rather than
1409
1507
  // aborting stream processing
@@ -1412,20 +1510,18 @@ export class StreamProcessor {
1412
1510
 
1413
1511
  const index = msgState.toolCallOrder.indexOf(chunk.toolCallId)
1414
1512
  this.completeToolCall(messageId, index, existingToolCall)
1415
- // If TOOL_CALL_END provides parsed input, use it as the canonical parsed
1416
- // arguments (overrides the accumulated string parse from completeToolCall)
1417
- // and refresh the rendered part's `input` so it reflects the canonical
1418
- // value rather than the possibly-divergent accumulated-args parse that
1419
- // completeToolCall wrote (e.g. an adapter that coerces values differently
1420
- // between the streamed args and the final structured input).
1421
- if (chunk.input !== undefined) {
1422
- 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
1423
1519
  this.messages = updateToolCallPart(this.messages, messageId, {
1424
1520
  id: existingToolCall.id,
1425
1521
  name: existingToolCall.name,
1426
1522
  arguments: existingToolCall.arguments,
1427
1523
  state: 'input-complete',
1428
- input: chunk.input,
1524
+ input,
1429
1525
  ...(existingToolCall.metadata !== undefined && {
1430
1526
  metadata: existingToolCall.metadata,
1431
1527
  }),
@@ -1433,43 +1529,6 @@ export class StreamProcessor {
1433
1529
  this.emitMessagesChange()
1434
1530
  }
1435
1531
  }
1436
-
1437
- // Update UIMessage if there's a result
1438
- if (chunk.result) {
1439
- // Step 1: Update the tool-call part's output field (for UI consistency
1440
- // with client tools — see GitHub issue #176)
1441
- let output: unknown
1442
- if (Array.isArray(chunk.result)) {
1443
- output = chunk.result
1444
- } else {
1445
- try {
1446
- output = JSON.parse(chunk.result)
1447
- } catch {
1448
- output = chunk.result
1449
- }
1450
- }
1451
- this.messages = updateToolCallWithOutput(
1452
- this.messages,
1453
- chunk.toolCallId,
1454
- output,
1455
- chunk.state === 'output-error' ? 'error' : undefined,
1456
- )
1457
-
1458
- // Step 2: Create/update the tool-result part (for LLM conversation history)
1459
- const resultState: ToolResultState =
1460
- chunk.state === 'output-error' ? 'error' : 'complete'
1461
- this.messages = updateToolResultPart(
1462
- this.messages,
1463
- messageId,
1464
- chunk.toolCallId,
1465
- chunk.result,
1466
- resultState,
1467
- resultState === 'error'
1468
- ? this.extractToolResultError(output)
1469
- : undefined,
1470
- )
1471
- this.emitMessagesChange()
1472
- }
1473
1532
  }
1474
1533
 
1475
1534
  private extractToolResultError(output: unknown): string {
@@ -1494,9 +1553,27 @@ export class StreamProcessor {
1494
1553
  private handleToolCallResultEvent(
1495
1554
  chunk: Extract<StreamChunk, { type: 'TOOL_CALL_RESULT' }>,
1496
1555
  ): void {
1497
- 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
1498
1570
  if (!messageId) return
1499
1571
 
1572
+ const extra = chunk as AdapterYieldChunk
1573
+ const isOutputError =
1574
+ extra.state === 'output-error' ||
1575
+ tanstackMetadata(chunk)?.state === 'output-error'
1576
+
1500
1577
  // Step 1: Update the tool-call part's output field
1501
1578
  let output: unknown
1502
1579
  try {
@@ -1508,12 +1585,11 @@ export class StreamProcessor {
1508
1585
  this.messages,
1509
1586
  chunk.toolCallId,
1510
1587
  output,
1511
- chunk.state === 'output-error' ? 'error' : undefined,
1588
+ isOutputError ? 'error' : undefined,
1512
1589
  )
1513
1590
 
1514
1591
  // Step 2: Create/update the tool-result part
1515
- const resultState: ToolResultState =
1516
- chunk.state === 'output-error' ? 'error' : 'complete'
1592
+ const resultState: ToolResultState = isOutputError ? 'error' : 'complete'
1517
1593
  this.messages = updateToolResultPart(
1518
1594
  this.messages,
1519
1595
  messageId,
@@ -1550,7 +1626,11 @@ export class StreamProcessor {
1550
1626
  private handleRunFinishedEvent(
1551
1627
  chunk: Extract<StreamChunk, { type: 'RUN_FINISHED' }>,
1552
1628
  ): void {
1553
- 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)
1554
1634
  this.activeRuns.delete(chunk.runId)
1555
1635
 
1556
1636
  if (chunk.outcome?.type === 'interrupt') {
@@ -1643,10 +1723,7 @@ export class StreamProcessor {
1643
1723
  chunk: Extract<StreamChunk, { type: 'RUN_ERROR' }>,
1644
1724
  ): void {
1645
1725
  this.hasError = true
1646
- const runId =
1647
- 'runId' in chunk && typeof chunk.runId === 'string'
1648
- ? chunk.runId
1649
- : undefined
1726
+ const runId = getChunkRunId(chunk)
1650
1727
  if (runId) {
1651
1728
  this.activeRuns.delete(runId)
1652
1729
  } else {
@@ -1656,9 +1733,8 @@ export class StreamProcessor {
1656
1733
  // Prefer spec field `message`; fall back to deprecated `error.message`.
1657
1734
  // If neither is set, the chunk still carries debug context (provider
1658
1735
  // error codes, request ids, etc.) — log it so the failure isn't silent.
1659
- const errorMessage =
1660
- chunk.message || chunk.error?.message || 'An error occurred'
1661
- if (!chunk.message && !chunk.error?.message) {
1736
+ const errorMessage = chunk.message || 'An error occurred'
1737
+ if (!chunk.message) {
1662
1738
  console.error(
1663
1739
  '[StreamProcessor] RUN_ERROR with no message; original chunk:',
1664
1740
  chunk,
@@ -1681,28 +1757,20 @@ export class StreamProcessor {
1681
1757
  // the surfaced Error so consumers can recover the upstream detail that the
1682
1758
  // RUN_ERROR's `message` alone discards. Both are optional and added only
1683
1759
  // when present, keeping the Error backward compatible.
1684
- const error = new Error(errorMessage)
1685
- const code = chunk.code ?? chunk.error?.code
1686
- if (code !== undefined) {
1687
- Object.assign(error, { code })
1688
- }
1689
- if (chunk.rawEvent !== undefined) {
1690
- Object.assign(error, { rawEvent: chunk.rawEvent })
1691
- }
1692
- this.events.onError?.(error)
1760
+ this.events.onError?.(runErrorEventToError(chunk))
1693
1761
  }
1694
1762
 
1695
1763
  /**
1696
1764
  * Handle STEP_STARTED event (for thinking/reasoning content).
1697
1765
  *
1698
- * Records the stepId so that subsequent STEP_FINISHED deltas accumulate
1766
+ * Records the stepId so later REASONING_MESSAGE_CONTENT deltas accumulate
1699
1767
  * into their own ThinkingPart. Does not create a message — the message
1700
- * is lazily created when the first STEP_FINISHED content arrives.
1768
+ * is lazily created when the first REASONING_MESSAGE_CONTENT arrives.
1701
1769
  */
1702
1770
  private handleStepStartedEvent(
1703
1771
  chunk: Extract<StreamChunk, { type: 'STEP_STARTED' }>,
1704
1772
  ): void {
1705
- const stepId = chunk.stepId ?? generateMessageId()
1773
+ const stepId = chunk.stepName || generateMessageId()
1706
1774
  const activeId = this.getActiveAssistantMessageId()
1707
1775
  if (activeId) {
1708
1776
  const state = this.getMessageState(activeId)
@@ -1715,102 +1783,49 @@ export class StreamProcessor {
1715
1783
  // Clear any pending stepId from a prior STEP_STARTED that fired
1716
1784
  // before the assistant message existed. Now that we're tracking
1717
1785
  // the step directly on message state, the pending value is stale
1718
- // and must not leak into the next STEP_FINISHED (which would
1719
- // misattribute its delta to the stale step).
1786
+ // and must not leak into the next REASONING_MESSAGE_CONTENT.
1720
1787
  this.pendingThinkingStepId = null
1721
1788
  return
1722
1789
  }
1723
1790
  }
1724
1791
 
1725
- // No active message yet — defer until ensureAssistantMessage in STEP_FINISHED
1792
+ // No active message yet — defer until ensureAssistantMessage in
1793
+ // REASONING_MESSAGE_CONTENT
1726
1794
  this.pendingThinkingStepId = stepId
1727
1795
  }
1728
1796
 
1729
1797
  /**
1730
- * Handle STEP_FINISHED event (for thinking/reasoning content).
1731
- *
1732
- * Accumulates delta into the current thinking step's content and updates
1733
- * the corresponding ThinkingPart in the UIMessage.
1798
+ * Handle STEP_FINISHED event.
1734
1799
  *
1735
- * @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.
1736
1804
  */
1737
1805
  private handleStepFinishedEvent(
1738
1806
  chunk: Extract<StreamChunk, { type: 'STEP_FINISHED' }>,
1739
1807
  ): void {
1808
+ const extra = chunk as AdapterYieldChunk
1809
+ const signature = extra.signature
1810
+ if (!signature) return
1811
+
1740
1812
  const { messageId, state } = this.ensureAssistantMessage(
1741
1813
  this.getActiveAssistantMessageId() ?? undefined,
1742
1814
  )
1815
+ const stepId = state.currentThinkingStepId ?? extra.stepId
1816
+ if (!stepId) return
1817
+ const thinking = state.thinkingSteps.get(stepId)
1818
+ if (thinking === undefined) return
1743
1819
 
1744
- // During the transition period, adapters emit BOTH STEP_FINISHED and
1745
- // REASONING_MESSAGE_CONTENT with the same delta. If we've already processed
1746
- // REASONING_MESSAGE_CONTENT events for this message, skip the duplicate
1747
- // thinking content from STEP_FINISHED to avoid doubled content.
1748
- if (state.hasSeenReasoningEvents) {
1749
- if (chunk.signature) {
1750
- const stepId = state.currentThinkingStepId ?? chunk.stepId
1751
- if (!stepId) return
1752
- const thinking = state.thinkingSteps.get(stepId)
1753
- if (thinking !== undefined) {
1754
- state.thinkingStepSignatures.set(stepId, chunk.signature)
1755
- this.messages = updateThinkingPart(
1756
- this.messages,
1757
- messageId,
1758
- stepId,
1759
- thinking,
1760
- chunk.signature,
1761
- )
1762
- this.emitMessagesChange()
1763
- }
1764
- }
1765
- return
1766
- }
1767
-
1768
- this.consumePendingThinkingStep(state)
1769
-
1770
- const stepId =
1771
- state.currentThinkingStepId ?? chunk.stepId ?? generateMessageId()
1772
-
1773
- // Auto-initialize if no prior STEP_STARTED (backward compat)
1774
- if (!state.thinkingSteps.has(stepId)) {
1775
- state.thinkingSteps.set(stepId, '')
1776
- state.thinkingStepOrder.push(stepId)
1777
- state.currentThinkingStepId = stepId
1778
- }
1779
-
1780
- const previous = state.thinkingSteps.get(stepId) ?? ''
1781
- let nextThinking = previous
1782
-
1783
- // Prefer delta over content
1784
- if (chunk.delta && chunk.delta !== '') {
1785
- nextThinking = previous + chunk.delta
1786
- } else if (chunk.content && chunk.content !== '') {
1787
- if (chunk.content.startsWith(previous)) {
1788
- nextThinking = chunk.content
1789
- } else if (previous.startsWith(chunk.content)) {
1790
- nextThinking = previous
1791
- } else {
1792
- nextThinking = previous + chunk.content
1793
- }
1794
- }
1795
-
1796
- state.thinkingSteps.set(stepId, nextThinking)
1797
-
1798
- if (chunk.signature) {
1799
- state.thinkingStepSignatures.set(stepId, chunk.signature)
1800
- }
1801
-
1802
- // Update UIMessage
1820
+ state.thinkingStepSignatures.set(stepId, signature)
1803
1821
  this.messages = updateThinkingPart(
1804
1822
  this.messages,
1805
1823
  messageId,
1806
1824
  stepId,
1807
- nextThinking,
1808
- state.thinkingStepSignatures.get(stepId),
1825
+ thinking,
1826
+ signature,
1809
1827
  )
1810
1828
  this.emitMessagesChange()
1811
-
1812
- // Emit granular event
1813
- this.events.onThinkingUpdate?.(messageId, stepId, nextThinking)
1814
1829
  }
1815
1830
 
1816
1831
  /**
@@ -1853,6 +1868,74 @@ export class StreamProcessor {
1853
1868
  this.events.onThinkingUpdate?.(messageId, stepId, nextThinking)
1854
1869
  }
1855
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
+
1856
1939
  /**
1857
1940
  * Handle CUSTOM event.
1858
1941
  *
@@ -2026,22 +2109,10 @@ export class StreamProcessor {
2026
2109
  * Detect if an incoming content chunk represents a NEW text segment
2027
2110
  */
2028
2111
  private isNewTextSegment(
2029
- chunk: Extract<StreamChunk, { type: 'TEXT_MESSAGE_CONTENT' }>,
2030
- previous: string,
2112
+ _chunk: Extract<StreamChunk, { type: 'TEXT_MESSAGE_CONTENT' }>,
2113
+ _previous: string,
2031
2114
  ): boolean {
2032
- // Check if content is present (delta is always defined but may be empty string)
2033
- if (chunk.content !== undefined) {
2034
- if (chunk.content.length < previous.length) {
2035
- return true
2036
- }
2037
- if (
2038
- !chunk.content.startsWith(previous) &&
2039
- !previous.startsWith(chunk.content)
2040
- ) {
2041
- return true
2042
- }
2043
- }
2044
- return false
2115
+ return true
2045
2116
  }
2046
2117
 
2047
2118
  /**
@@ -2087,8 +2158,17 @@ export class StreamProcessor {
2087
2158
  // counts as a completed tool call in getCompletedToolCalls()/getState().
2088
2159
  toolCall.state = 'input-complete'
2089
2160
 
2090
- // Try final parse
2091
- toolCall.parsedArguments = this.jsonParser.parse(toolCall.arguments)
2161
+ // Only surface `input` from a strict parse. The streaming partial-JSON
2162
+ // parser closes unterminated strings, so truncated arguments would become
2163
+ // a plausible but wrong object (GitHub issue #1017). If parse fails,
2164
+ // `input` stays unset and consumers use the raw `arguments` string.
2165
+ let strictParseSucceeded = false
2166
+ try {
2167
+ toolCall.parsedArguments = JSON.parse(toolCall.arguments)
2168
+ strictParseSucceeded = true
2169
+ } catch {
2170
+ toolCall.parsedArguments = undefined
2171
+ }
2092
2172
 
2093
2173
  // Don't downgrade the rendered part of a call that already reached the
2094
2174
  // terminal 'error' state (e.g. an output-error TOOL_CALL_RESULT arrived
@@ -2106,19 +2186,13 @@ export class StreamProcessor {
2106
2186
  }
2107
2187
 
2108
2188
  // Update UIMessage. The arguments are complete now, so surface the parsed
2109
- // input on the part. For adapters that skip TOOL_CALL_ARGS the arguments
2110
- // string was back-filled from TOOL_CALL_END.input, so this parse matches
2111
- // the canonical input. If a TOOL_CALL_END.input diverges from the
2112
- // accumulated args, handleToolCallEndEvent re-updates the part with the
2113
- // canonical value after this call.
2189
+ // input on the part from the accumulated TOOL_CALL_ARGS deltas.
2114
2190
  this.messages = updateToolCallPart(this.messages, messageId, {
2115
2191
  id: toolCall.id,
2116
2192
  name: toolCall.name,
2117
2193
  arguments: toolCall.arguments,
2118
2194
  state: 'input-complete',
2119
- ...(toolCall.parsedArguments !== undefined && {
2120
- input: toolCall.parsedArguments,
2121
- }),
2195
+ ...(strictParseSucceeded && { input: toolCall.parsedArguments }),
2122
2196
  ...(toolCall.metadata !== undefined && { metadata: toolCall.metadata }),
2123
2197
  })
2124
2198
  this.emitMessagesChange()