@tanstack/ai 0.14.0 → 0.15.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.
@@ -90,7 +90,11 @@ export interface StreamProcessorEvents {
90
90
  state: ToolCallState,
91
91
  args: string,
92
92
  ) => void
93
- onThinkingUpdate?: (messageId: string, content: string) => void
93
+ onThinkingUpdate?: (
94
+ messageId: string,
95
+ stepId: string,
96
+ content: string,
97
+ ) => void
94
98
  }
95
99
 
96
100
  /**
@@ -139,6 +143,7 @@ export class StreamProcessor {
139
143
  private activeMessageIds: Set<string> = new Set()
140
144
  private toolCallToMessage: Map<string, string> = new Map()
141
145
  private pendingManualMessageId: string | null = null
146
+ private pendingThinkingStepId: string | null = null
142
147
 
143
148
  // Run tracking (for concurrent run safety)
144
149
  private activeRuns = new Set<string>()
@@ -541,8 +546,14 @@ export class StreamProcessor {
541
546
  )
542
547
  break
543
548
 
549
+ case 'STEP_STARTED':
550
+ this.handleStepStartedEvent(
551
+ chunk as Extract<StreamChunk, { type: 'STEP_STARTED' }>,
552
+ )
553
+ break
554
+
544
555
  default:
545
- // STEP_STARTED, STATE_SNAPSHOT, STATE_DELTA - no special handling needed
556
+ // STATE_SNAPSHOT, STATE_DELTA - no special handling needed
546
557
  break
547
558
  }
548
559
  }
@@ -564,8 +575,11 @@ export class StreamProcessor {
564
575
  totalTextContent: '',
565
576
  currentSegmentText: '',
566
577
  lastEmittedText: '',
567
- thinkingContent: '',
568
578
  hasSeenReasoningEvents: false,
579
+ thinkingSteps: new Map(),
580
+ thinkingStepSignatures: new Map(),
581
+ thinkingStepOrder: [],
582
+ currentThinkingStepId: null,
569
583
  toolCalls: new Map(),
570
584
  toolCallOrder: [],
571
585
  hasToolCallsSinceTextStart: false,
@@ -582,6 +596,23 @@ export class StreamProcessor {
582
596
  return this.messageStates.get(messageId)
583
597
  }
584
598
 
599
+ /**
600
+ * Promote a pending stepId from a STEP_STARTED that fired before the
601
+ * assistant message existed onto the given message state, so the next
602
+ * thinking event (STEP_FINISHED or REASONING_MESSAGE_CONTENT) attributes
603
+ * to the correct step.
604
+ */
605
+ private consumePendingThinkingStep(state: MessageStreamState): void {
606
+ if (!this.pendingThinkingStepId) return
607
+ const stepId = this.pendingThinkingStepId
608
+ state.currentThinkingStepId = stepId
609
+ if (!state.thinkingSteps.has(stepId)) {
610
+ state.thinkingSteps.set(stepId, '')
611
+ state.thinkingStepOrder.push(stepId)
612
+ }
613
+ this.pendingThinkingStepId = null
614
+ }
615
+
585
616
  /**
586
617
  * Get the most recent active assistant message ID.
587
618
  * Used as fallback for events that don't include a messageId.
@@ -897,7 +928,13 @@ export class StreamProcessor {
897
928
  // New tool call starting
898
929
  const initialState: ToolCallState = 'awaiting-input'
899
930
 
900
- const toolName = chunk.toolCallName
931
+ // `toolName` is a deprecated alias for `toolCallName` (see ToolCallStartEvent
932
+ // in types.ts). Accept either so chunks from older code paths or any
933
+ // adapter that only sets the deprecated field still produce a named part.
934
+ // The type marks both as required strings, but in practice some emitters
935
+ // only set one — fall back via the runtime value rather than the type.
936
+ const toolName =
937
+ (chunk as { toolCallName?: string }).toolCallName ?? chunk.toolName
901
938
 
902
939
  const newToolCall: InternalToolCallState = {
903
940
  id: chunk.toolCallId,
@@ -1155,11 +1192,45 @@ export class StreamProcessor {
1155
1192
  this.events.onError?.(new Error(errorMessage))
1156
1193
  }
1157
1194
 
1195
+ /**
1196
+ * Handle STEP_STARTED event (for thinking/reasoning content).
1197
+ *
1198
+ * Records the stepId so that subsequent STEP_FINISHED deltas accumulate
1199
+ * into their own ThinkingPart. Does not create a message — the message
1200
+ * is lazily created when the first STEP_FINISHED content arrives.
1201
+ */
1202
+ private handleStepStartedEvent(
1203
+ chunk: Extract<StreamChunk, { type: 'STEP_STARTED' }>,
1204
+ ): void {
1205
+ const stepId = chunk.stepId ?? generateMessageId()
1206
+ const activeId = this.getActiveAssistantMessageId()
1207
+ if (activeId) {
1208
+ const state = this.getMessageState(activeId)
1209
+ if (state) {
1210
+ state.currentThinkingStepId = stepId
1211
+ if (!state.thinkingSteps.has(stepId)) {
1212
+ state.thinkingSteps.set(stepId, '')
1213
+ state.thinkingStepOrder.push(stepId)
1214
+ }
1215
+ // Clear any pending stepId from a prior STEP_STARTED that fired
1216
+ // before the assistant message existed. Now that we're tracking
1217
+ // the step directly on message state, the pending value is stale
1218
+ // and must not leak into the next STEP_FINISHED (which would
1219
+ // misattribute its delta to the stale step).
1220
+ this.pendingThinkingStepId = null
1221
+ return
1222
+ }
1223
+ }
1224
+
1225
+ // No active message yet — defer until ensureAssistantMessage in STEP_FINISHED
1226
+ this.pendingThinkingStepId = stepId
1227
+ }
1228
+
1158
1229
  /**
1159
1230
  * Handle STEP_FINISHED event (for thinking/reasoning content).
1160
1231
  *
1161
- * Accumulates delta into thinkingContent and updates a single ThinkingPart
1162
- * in the UIMessage (replaced in-place, not appended).
1232
+ * Accumulates delta into the current thinking step's content and updates
1233
+ * the corresponding ThinkingPart in the UIMessage.
1163
1234
  *
1164
1235
  * @see docs/chat-architecture.md#thinkingreasoning-content — Thinking flow
1165
1236
  */
@@ -1175,10 +1246,38 @@ export class StreamProcessor {
1175
1246
  // REASONING_MESSAGE_CONTENT events for this message, skip the duplicate
1176
1247
  // thinking content from STEP_FINISHED to avoid doubled content.
1177
1248
  if (state.hasSeenReasoningEvents) {
1249
+ if (chunk.signature) {
1250
+ const stepId = state.currentThinkingStepId ?? chunk.stepId
1251
+ if (!stepId) return
1252
+ const thinking = state.thinkingSteps.get(stepId)
1253
+ if (thinking !== undefined) {
1254
+ state.thinkingStepSignatures.set(stepId, chunk.signature)
1255
+ this.messages = updateThinkingPart(
1256
+ this.messages,
1257
+ messageId,
1258
+ stepId,
1259
+ thinking,
1260
+ chunk.signature,
1261
+ )
1262
+ this.emitMessagesChange()
1263
+ }
1264
+ }
1178
1265
  return
1179
1266
  }
1180
1267
 
1181
- const previous = state.thinkingContent
1268
+ this.consumePendingThinkingStep(state)
1269
+
1270
+ const stepId =
1271
+ state.currentThinkingStepId ?? chunk.stepId ?? generateMessageId()
1272
+
1273
+ // Auto-initialize if no prior STEP_STARTED (backward compat)
1274
+ if (!state.thinkingSteps.has(stepId)) {
1275
+ state.thinkingSteps.set(stepId, '')
1276
+ state.thinkingStepOrder.push(stepId)
1277
+ state.currentThinkingStepId = stepId
1278
+ }
1279
+
1280
+ const previous = state.thinkingSteps.get(stepId)!
1182
1281
  let nextThinking = previous
1183
1282
 
1184
1283
  // Prefer delta over content
@@ -1194,25 +1293,31 @@ export class StreamProcessor {
1194
1293
  }
1195
1294
  }
1196
1295
 
1197
- state.thinkingContent = nextThinking
1296
+ state.thinkingSteps.set(stepId, nextThinking)
1297
+
1298
+ if (chunk.signature) {
1299
+ state.thinkingStepSignatures.set(stepId, chunk.signature)
1300
+ }
1198
1301
 
1199
1302
  // Update UIMessage
1200
1303
  this.messages = updateThinkingPart(
1201
1304
  this.messages,
1202
1305
  messageId,
1203
- state.thinkingContent,
1306
+ stepId,
1307
+ nextThinking,
1308
+ state.thinkingStepSignatures.get(stepId),
1204
1309
  )
1205
1310
  this.emitMessagesChange()
1206
1311
 
1207
1312
  // Emit granular event
1208
- this.events.onThinkingUpdate?.(messageId, state.thinkingContent)
1313
+ this.events.onThinkingUpdate?.(messageId, stepId, nextThinking)
1209
1314
  }
1210
1315
 
1211
1316
  /**
1212
1317
  * Handle REASONING_MESSAGE_CONTENT event (AG-UI reasoning protocol).
1213
1318
  *
1214
- * Accumulates reasoning delta into thinkingContent and updates the ThinkingPart
1215
- * in the UIMessage.
1319
+ * Accumulates reasoning delta into thinking content and updates the
1320
+ * corresponding ThinkingPart in the UIMessage.
1216
1321
  */
1217
1322
  private handleReasoningMessageContentEvent(
1218
1323
  chunk: Extract<StreamChunk, { type: 'REASONING_MESSAGE_CONTENT' }>,
@@ -1223,16 +1328,29 @@ export class StreamProcessor {
1223
1328
 
1224
1329
  state.hasSeenReasoningEvents = true
1225
1330
  const delta = chunk.delta || ''
1226
- state.thinkingContent = state.thinkingContent + delta
1331
+
1332
+ this.consumePendingThinkingStep(state)
1333
+
1334
+ const stepId = state.currentThinkingStepId ?? chunk.messageId
1335
+ if (!state.thinkingSteps.has(stepId)) {
1336
+ state.thinkingSteps.set(stepId, '')
1337
+ state.thinkingStepOrder.push(stepId)
1338
+ state.currentThinkingStepId = stepId
1339
+ }
1340
+
1341
+ const nextThinking = (state.thinkingSteps.get(stepId) ?? '') + delta
1342
+ state.thinkingSteps.set(stepId, nextThinking)
1227
1343
 
1228
1344
  this.messages = updateThinkingPart(
1229
1345
  this.messages,
1230
1346
  messageId,
1231
- state.thinkingContent,
1347
+ stepId,
1348
+ nextThinking,
1349
+ state.thinkingStepSignatures.get(stepId),
1232
1350
  )
1233
1351
  this.emitMessagesChange()
1234
1352
 
1235
- this.events.onThinkingUpdate?.(messageId, state.thinkingContent)
1353
+ this.events.onThinkingUpdate?.(messageId, stepId, nextThinking)
1236
1354
  }
1237
1355
 
1238
1356
  /**
@@ -1518,7 +1636,9 @@ export class StreamProcessor {
1518
1636
 
1519
1637
  for (const state of this.messageStates.values()) {
1520
1638
  content += state.totalTextContent
1521
- thinking += state.thinkingContent
1639
+ for (const stepId of state.thinkingStepOrder) {
1640
+ thinking += state.thinkingSteps.get(stepId) ?? ''
1641
+ }
1522
1642
  }
1523
1643
 
1524
1644
  return {
@@ -1540,7 +1660,9 @@ export class StreamProcessor {
1540
1660
 
1541
1661
  for (const state of this.messageStates.values()) {
1542
1662
  content += state.totalTextContent
1543
- thinking += state.thinkingContent
1663
+ for (const stepId of state.thinkingStepOrder) {
1664
+ thinking += state.thinkingSteps.get(stepId) ?? ''
1665
+ }
1544
1666
  for (const [id, tc] of state.toolCalls) {
1545
1667
  toolCalls.set(id, tc)
1546
1668
  }
@@ -1586,6 +1708,7 @@ export class StreamProcessor {
1586
1708
  this.activeRuns.clear()
1587
1709
  this.toolCallToMessage.clear()
1588
1710
  this.pendingManualMessageId = null
1711
+ this.pendingThinkingStepId = null
1589
1712
  this.finishReason = null
1590
1713
  this.hasError = false
1591
1714
  this.isDone = false
@@ -56,8 +56,11 @@ export interface MessageStreamState {
56
56
  totalTextContent: string
57
57
  currentSegmentText: string
58
58
  lastEmittedText: string
59
- thinkingContent: string
60
59
  hasSeenReasoningEvents: boolean
60
+ thinkingSteps: Map<string, string>
61
+ thinkingStepSignatures: Map<string, string>
62
+ thinkingStepOrder: Array<string>
63
+ currentThinkingStepId: string | null
61
64
  toolCalls: Map<string, InternalToolCallState>
62
65
  toolCallOrder: Array<string>
63
66
  hasToolCallsSinceTextStart: boolean
@@ -93,7 +93,9 @@ export class ToolCallManager {
93
93
  */
94
94
  addToolCallStartEvent(event: ToolCallStartEvent): void {
95
95
  const index = event.index ?? this.toolCallsMap.size
96
- const name = event.toolCallName
96
+ const runtimeEvent = event as Partial<ToolCallStartEvent> &
97
+ Pick<ToolCallStartEvent, 'toolName'>
98
+ const name = runtimeEvent.toolCallName ?? runtimeEvent.toolName
97
99
  this.toolCallsMap.set(index, {
98
100
  id: event.toolCallId,
99
101
  type: 'function',
@@ -11,3 +11,8 @@ export {
11
11
  type ContentGuardRule,
12
12
  type ContentFilteredInfo,
13
13
  } from './content-guard'
14
+
15
+ // otelMiddleware is exported from the dedicated subpath
16
+ // `@tanstack/ai/middlewares/otel` so that importing the main middlewares barrel
17
+ // does not eagerly require `@opentelemetry/api` (which is an optional peer
18
+ // dependency).