@tanstack/ai 0.49.1 → 0.51.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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'
@@ -211,6 +212,7 @@ export class StreamProcessor {
211
212
  private finishReason: string | null = null
212
213
  private hasError = false
213
214
  private isDone = false
215
+ private streamEndEmitted = false
214
216
 
215
217
  // Recording
216
218
  private recording: ChunkRecording | null = null
@@ -728,9 +730,26 @@ export class StreamProcessor {
728
730
  return id
729
731
  }
730
732
  }
733
+ // finalizeStream() clears activeMessageIds but keeps messageStates.
734
+ // Leftover reasoning after an early RUN_FINISHED must resume that
735
+ // assistant. A new user turn calls prepareAssistantMessage(), which
736
+ // clears messageStates first.
737
+ for (const [id, state] of [...this.messageStates].reverse()) {
738
+ if (state.role === 'assistant') {
739
+ return id
740
+ }
741
+ }
731
742
  return null
732
743
  }
733
744
 
745
+ private resumeAssistantState(id: string, state: MessageStreamState): void {
746
+ this.activeMessageIds.add(id)
747
+ if (state.isComplete || this.isDone) {
748
+ state.isComplete = false
749
+ this.isDone = false
750
+ }
751
+ }
752
+
734
753
  /**
735
754
  * Ensure an active assistant message exists, creating one if needed.
736
755
  * Used for backward compat when events arrive without prior TEXT_MESSAGE_START.
@@ -747,14 +766,20 @@ export class StreamProcessor {
747
766
  // Try to find state by preferred ID
748
767
  if (preferredId) {
749
768
  const state = this.getMessageState(preferredId)
750
- if (state) return { messageId: preferredId, state }
769
+ if (state) {
770
+ this.resumeAssistantState(preferredId, state)
771
+ return { messageId: preferredId, state }
772
+ }
751
773
  }
752
774
 
753
775
  // Try active assistant message
754
776
  const activeId = this.getActiveAssistantMessageId()
755
777
  if (activeId) {
756
778
  const state = this.getMessageState(activeId)
757
- if (state) return { messageId: activeId, state }
779
+ if (state) {
780
+ this.resumeAssistantState(activeId, state)
781
+ return { messageId: activeId, state }
782
+ }
758
783
  }
759
784
 
760
785
  // Check if a message with preferredId already exists (reconnect/resume case).
@@ -835,11 +860,10 @@ export class StreamProcessor {
835
860
  }
836
861
  : incomingRecord
837
862
  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())
863
+ const createdAt = coerceCreatedAt(
864
+ tanstackMetadata(incomingRecord)?.createdAt,
865
+ )
866
+ const createdAtValid = createdAt !== undefined
843
867
  this.messages = this.messages.map((msg) =>
844
868
  msg.id === messageId
845
869
  ? {
@@ -900,9 +924,21 @@ export class StreamProcessor {
900
924
  }
901
925
 
902
926
  // Ensure state exists
903
- if (!this.messageStates.has(messageId)) {
904
- this.createMessageState(messageId, uiRole)
927
+ let pendingState = this.messageStates.get(messageId)
928
+ if (!pendingState) {
929
+ pendingState = this.createMessageState(messageId, uiRole)
905
930
  this.activeMessageIds.add(messageId)
931
+ } else if (pendingState.hasToolCallsSinceTextStart) {
932
+ // A tool call (e.g. TOOL_CALL_START with parentMessageId) marked
933
+ // this message before its "real" TEXT_MESSAGE_START arrived — same
934
+ // reset Case 2 performs, so the segment accumulator doesn't carry
935
+ // stale tool-call state into the text that follows.
936
+ if (pendingState.currentSegmentText !== pendingState.lastEmittedText) {
937
+ this.emitTextUpdateForMessage(messageId)
938
+ }
939
+ pendingState.currentSegmentText = ''
940
+ pendingState.lastEmittedText = ''
941
+ pendingState.hasToolCallsSinceTextStart = false
906
942
  }
907
943
 
908
944
  this.mergeMessageMetadata(messageId, chunk.metadata)
@@ -1050,8 +1086,10 @@ export class StreamProcessor {
1050
1086
  msg.parts.every((part) => part.type === 'thinking')
1051
1087
  const isToolResultOnly = (msg: UIMessage) =>
1052
1088
  msg.role === 'assistant' &&
1053
- msg.parts.length === 1 &&
1054
- msg.parts[0]?.type === 'tool-result'
1089
+ msg.parts.some((part) => part.type === 'tool-result') &&
1090
+ msg.parts.every(
1091
+ (part) => part.type === 'tool-result' || part.type === 'ui-resource',
1092
+ )
1055
1093
  const flushPending = () => {
1056
1094
  out.push(...pending)
1057
1095
  pending = []
@@ -1109,9 +1147,16 @@ export class StreamProcessor {
1109
1147
 
1110
1148
  const reconciled: Array<UIMessage> = []
1111
1149
  for (const msg of snapshot) {
1150
+ const toolResultParts = msg.parts.filter(
1151
+ (part): part is ToolResultPart => part.type === 'tool-result',
1152
+ )
1112
1153
  const toolResultPart =
1113
- msg.role === 'assistant' && msg.parts.length === 1
1114
- ? msg.parts.find((p): p is ToolResultPart => p.type === 'tool-result')
1154
+ msg.role === 'assistant' &&
1155
+ toolResultParts.length === 1 &&
1156
+ msg.parts.every(
1157
+ (part) => part.type === 'tool-result' || part.type === 'ui-resource',
1158
+ )
1159
+ ? toolResultParts[0]
1115
1160
  : undefined
1116
1161
 
1117
1162
  if (!toolResultPart) {
@@ -1170,7 +1215,7 @@ export class StreamProcessor {
1170
1215
  )
1171
1216
  }
1172
1217
  }
1173
- parts.push(toolResultPart)
1218
+ parts.push(...msg.parts)
1174
1219
  // Replace rather than push into `target.parts`: a snapshot message that
1175
1220
  // arrived already carrying `parts` (TanStack server echoing UIMessages)
1176
1221
  // shares its array with the incoming chunk, and mutating it in place
@@ -1638,8 +1683,14 @@ export class StreamProcessor {
1638
1683
  }
1639
1684
 
1640
1685
  if (this.activeRuns.size === 0) {
1641
- this.isDone = true
1642
1686
  this.completeAllToolCalls()
1687
+ const isIntermediateToolTurn =
1688
+ this.finishReason === 'tool_calls' &&
1689
+ chunk.outcome?.type !== 'interrupt'
1690
+ if (isIntermediateToolTurn) {
1691
+ return
1692
+ }
1693
+ this.isDone = true
1643
1694
  this.finalizeStream()
1644
1695
  }
1645
1696
  }
@@ -2335,6 +2386,7 @@ export class StreamProcessor {
2335
2386
  * @see docs/chat-architecture.md#single-shot-text-response — Finalization step
2336
2387
  */
2337
2388
  finalizeStream(): void {
2389
+ this.isDone = true
2338
2390
  let lastAssistantMessage: UIMessage | undefined
2339
2391
 
2340
2392
  // Finalize ALL active messages
@@ -2398,7 +2450,8 @@ export class StreamProcessor {
2398
2450
  }
2399
2451
 
2400
2452
  // Emit stream end for the last assistant message
2401
- if (lastAssistantMessage) {
2453
+ if (lastAssistantMessage && !this.streamEndEmitted) {
2454
+ this.streamEndEmitted = true
2402
2455
  this.events.onStreamEnd?.(lastAssistantMessage)
2403
2456
  }
2404
2457
  }
@@ -2517,6 +2570,7 @@ export class StreamProcessor {
2517
2570
  this.finishReason = null
2518
2571
  this.hasError = false
2519
2572
  this.isDone = false
2573
+ this.streamEndEmitted = false
2520
2574
  this.chunkStrategy.reset?.()
2521
2575
  }
2522
2576
 
package/src/index.ts CHANGED
@@ -104,6 +104,8 @@ export type {
104
104
  // MCP error classes (value exports — usable with instanceof)
105
105
  export { MCPDuplicateToolNameError } from './activities/chat/mcp/manager'
106
106
  export { DuplicateToolNameError } from './activities/chat/tools/unique-tool-names'
107
+ export { SkillLimitError } from './utilities/errors'
108
+ export type { SkillLimitErrorInit } from './utilities/errors'
107
109
 
108
110
  // Schema conversion (Standard JSON Schema compliant)
109
111
  export {
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.