@tanstack/ai 0.8.0 → 0.8.1

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.
@@ -12,6 +12,7 @@
12
12
  * - Thinking/reasoning content
13
13
  * - Recording/replay for testing
14
14
  * - Event-driven architecture for UI updates
15
+ * - Per-message stream state tracking for multi-message sessions
15
16
  *
16
17
  * @see docs/chat-architecture.md — Canonical reference for AG-UI chunk ordering,
17
18
  * adapter contract, single-shot flows, and expected UIMessage output.
@@ -32,6 +33,7 @@ import type {
32
33
  ChunkRecording,
33
34
  ChunkStrategy,
34
35
  InternalToolCallState,
36
+ MessageStreamState,
35
37
  ProcessorResult,
36
38
  ProcessorState,
37
39
  ToolCallState,
@@ -116,9 +118,8 @@ export interface StreamProcessorOptions {
116
118
  *
117
119
  * State tracking:
118
120
  * - Full message array
119
- * - Current assistant message being streamed
120
- * - Text content accumulation (reset on TEXT_MESSAGE_START)
121
- * - Multiple parallel tool calls
121
+ * - Per-message stream state (text, tool calls, thinking)
122
+ * - Multiple concurrent message streams
122
123
  * - Tool call completion via TOOL_CALL_END events
123
124
  *
124
125
  * @see docs/chat-architecture.md#streamprocessor-internal-state — State field reference
@@ -132,17 +133,17 @@ export class StreamProcessor {
132
133
 
133
134
  // Message state
134
135
  private messages: Array<UIMessage> = []
135
- private currentAssistantMessageId: string | null = null
136
-
137
- // Stream state for current assistant message
138
- // Total accumulated text across all segments (for the final result)
139
- private totalTextContent = ''
140
- // Current segment's text content (for onTextUpdate callbacks)
141
- private currentSegmentText = ''
142
- private lastEmittedText = ''
143
- private thinkingContent = ''
144
- private toolCalls: Map<string, InternalToolCallState> = new Map()
145
- private toolCallOrder: Array<string> = []
136
+
137
+ // Per-message stream state
138
+ private messageStates: Map<string, MessageStreamState> = new Map()
139
+ private activeMessageIds: Set<string> = new Set()
140
+ private toolCallToMessage: Map<string, string> = new Map()
141
+ private pendingManualMessageId: string | null = null
142
+
143
+ // Run tracking (for concurrent run safety)
144
+ private activeRuns = new Set<string>()
145
+
146
+ // Shared stream state
146
147
  private finishReason: string | null = null
147
148
  private hasError = false
148
149
  private isDone = false
@@ -231,18 +232,17 @@ export class StreamProcessor {
231
232
  prepareAssistantMessage(): void {
232
233
  // Reset stream state for new message
233
234
  this.resetStreamState()
234
- // Clear the current assistant message ID so ensureAssistantMessage()
235
- // will create a fresh message on the first content chunk
236
- this.currentAssistantMessageId = null
237
235
  }
238
236
 
239
237
  /**
240
238
  * @deprecated Use prepareAssistantMessage() instead. This eagerly creates
241
239
  * an assistant message which can cause empty message flicker.
242
240
  */
243
- startAssistantMessage(): string {
241
+ startAssistantMessage(messageId?: string): string {
244
242
  this.prepareAssistantMessage()
245
- return this.ensureAssistantMessage()
243
+ const { messageId: id } = this.ensureAssistantMessage(messageId)
244
+ this.pendingManualMessageId = id
245
+ return id
246
246
  }
247
247
 
248
248
  /**
@@ -251,39 +251,16 @@ export class StreamProcessor {
251
251
  * has arrived yet.
252
252
  */
253
253
  getCurrentAssistantMessageId(): string | null {
254
- return this.currentAssistantMessageId
255
- }
256
-
257
- /**
258
- * Lazily create the assistant message if it hasn't been created yet.
259
- * Called by content handlers on the first content-bearing chunk.
260
- * Returns the message ID.
261
- *
262
- * Content-bearing chunks that trigger this:
263
- * TEXT_MESSAGE_CONTENT, TOOL_CALL_START, STEP_FINISHED, RUN_ERROR.
264
- *
265
- * @see docs/chat-architecture.md#streamprocessor-internal-state — Lazy creation pattern
266
- */
267
- private ensureAssistantMessage(): string {
268
- if (this.currentAssistantMessageId) {
269
- return this.currentAssistantMessageId
270
- }
271
-
272
- const assistantMessage: UIMessage = {
273
- id: generateMessageId(),
274
- role: 'assistant',
275
- parts: [],
276
- createdAt: new Date(),
254
+ // Scan all message states (not just active) for the last assistant.
255
+ // After finalizeStream() clears activeMessageIds, messageStates retains entries.
256
+ // After reset() / resetStreamState(), messageStates is cleared → returns null.
257
+ let lastId: string | null = null
258
+ for (const [id, state] of this.messageStates) {
259
+ if (state.role === 'assistant') {
260
+ lastId = id
261
+ }
277
262
  }
278
-
279
- this.currentAssistantMessageId = assistantMessage.id
280
- this.messages = [...this.messages, assistantMessage]
281
-
282
- // Emit events
283
- this.events.onStreamStart?.()
284
- this.emitMessagesChange()
285
-
286
- return assistantMessage.id
263
+ return lastId
287
264
  }
288
265
 
289
266
  /**
@@ -410,7 +387,10 @@ export class StreamProcessor {
410
387
  */
411
388
  clearMessages(): void {
412
389
  this.messages = []
413
- this.currentAssistantMessageId = null
390
+ this.messageStates.clear()
391
+ this.activeMessageIds.clear()
392
+ this.toolCallToMessage.clear()
393
+ this.pendingManualMessageId = null
414
394
  this.emitMessagesChange()
415
395
  }
416
396
 
@@ -451,7 +431,7 @@ export class StreamProcessor {
451
431
  *
452
432
  * Central dispatch for all AG-UI events. Each event type maps to a specific
453
433
  * handler. Events not listed in the switch are intentionally ignored
454
- * (RUN_STARTED, TEXT_MESSAGE_END, STEP_STARTED, STATE_SNAPSHOT, STATE_DELTA).
434
+ * (RUN_STARTED, STEP_STARTED, STATE_DELTA).
455
435
  *
456
436
  * @see docs/chat-architecture.md#adapter-contract — Expected event types and ordering
457
437
  */
@@ -468,13 +448,17 @@ export class StreamProcessor {
468
448
  switch (chunk.type) {
469
449
  // AG-UI Events
470
450
  case 'TEXT_MESSAGE_START':
471
- this.handleTextMessageStartEvent()
451
+ this.handleTextMessageStartEvent(chunk)
472
452
  break
473
453
 
474
454
  case 'TEXT_MESSAGE_CONTENT':
475
455
  this.handleTextMessageContentEvent(chunk)
476
456
  break
477
457
 
458
+ case 'TEXT_MESSAGE_END':
459
+ this.handleTextMessageEndEvent(chunk)
460
+ break
461
+
478
462
  case 'TOOL_CALL_START':
479
463
  this.handleToolCallStartEvent(chunk)
480
464
  break
@@ -499,35 +483,264 @@ export class StreamProcessor {
499
483
  this.handleStepFinishedEvent(chunk)
500
484
  break
501
485
 
486
+ case 'MESSAGES_SNAPSHOT':
487
+ this.handleMessagesSnapshotEvent(chunk)
488
+ break
489
+
502
490
  case 'CUSTOM':
503
491
  this.handleCustomEvent(chunk)
504
492
  break
505
493
 
494
+ case 'RUN_STARTED':
495
+ this.handleRunStartedEvent(chunk)
496
+ break
497
+
506
498
  default:
507
- // RUN_STARTED, TEXT_MESSAGE_END, STEP_STARTED,
508
- // STATE_SNAPSHOT, STATE_DELTA - no special handling needed
499
+ // STEP_STARTED, STATE_SNAPSHOT, STATE_DELTA - no special handling needed
509
500
  break
510
501
  }
511
502
  }
512
503
 
504
+ // ============================================
505
+ // Per-Message State Helpers
506
+ // ============================================
507
+
508
+ /**
509
+ * Create a new MessageStreamState for a message
510
+ */
511
+ private createMessageState(
512
+ messageId: string,
513
+ role: 'user' | 'assistant' | 'system',
514
+ ): MessageStreamState {
515
+ const state: MessageStreamState = {
516
+ id: messageId,
517
+ role,
518
+ totalTextContent: '',
519
+ currentSegmentText: '',
520
+ lastEmittedText: '',
521
+ thinkingContent: '',
522
+ toolCalls: new Map(),
523
+ toolCallOrder: [],
524
+ hasToolCallsSinceTextStart: false,
525
+ isComplete: false,
526
+ }
527
+ this.messageStates.set(messageId, state)
528
+ return state
529
+ }
530
+
513
531
  /**
514
- * Handle TEXT_MESSAGE_START event — marks the beginning of a new text segment.
515
- * Resets segment accumulation so text after tool calls starts fresh.
516
- *
517
- * This is the key mechanism for multi-segment text (text before and after tool
518
- * calls becoming separate TextParts). Without this reset, all text would merge
519
- * into a single TextPart and tool-call interleaving would be lost.
532
+ * Get the MessageStreamState for a message
533
+ */
534
+ private getMessageState(messageId: string): MessageStreamState | undefined {
535
+ return this.messageStates.get(messageId)
536
+ }
537
+
538
+ /**
539
+ * Get the most recent active assistant message ID.
540
+ * Used as fallback for events that don't include a messageId.
541
+ */
542
+ private getActiveAssistantMessageId(): string | null {
543
+ // Set iteration is insertion-order; convert to array and search from the end
544
+ const ids = Array.from(this.activeMessageIds)
545
+ for (let i = ids.length - 1; i >= 0; i--) {
546
+ const id = ids[i]!
547
+ const state = this.messageStates.get(id)
548
+ if (state && state.role === 'assistant') {
549
+ return id
550
+ }
551
+ }
552
+ return null
553
+ }
554
+
555
+ /**
556
+ * Ensure an active assistant message exists, creating one if needed.
557
+ * Used for backward compat when events arrive without prior TEXT_MESSAGE_START.
520
558
  *
521
- * @see docs/chat-architecture.md#single-shot-text-response — Step-by-step text processing
522
- * @see docs/chat-architecture.md#text-then-tool-interleaving-single-shot — Multi-segment text
559
+ * On reconnect/resume, a TEXT_MESSAGE_CONTENT may arrive for a message that
560
+ * already exists in this.messages (e.g. from initialMessages or a prior
561
+ * MESSAGES_SNAPSHOT) but whose transient state was cleared. In that case we
562
+ * hydrate state from the existing message rather than creating a duplicate.
523
563
  */
524
- private handleTextMessageStartEvent(): void {
525
- // Emit any pending text from a previous segment before resetting
526
- if (this.currentSegmentText !== this.lastEmittedText) {
527
- this.emitTextUpdate()
564
+ private ensureAssistantMessage(preferredId?: string): {
565
+ messageId: string
566
+ state: MessageStreamState
567
+ } {
568
+ // Try to find state by preferred ID
569
+ if (preferredId) {
570
+ const state = this.getMessageState(preferredId)
571
+ if (state) return { messageId: preferredId, state }
572
+ }
573
+
574
+ // Try active assistant message
575
+ const activeId = this.getActiveAssistantMessageId()
576
+ if (activeId) {
577
+ const state = this.getMessageState(activeId)!
578
+ return { messageId: activeId, state }
579
+ }
580
+
581
+ // Check if a message with preferredId already exists (reconnect/resume case).
582
+ // Hydrate transient state from the existing message instead of duplicating it.
583
+ if (preferredId) {
584
+ const existingMsg = this.messages.find((m) => m.id === preferredId)
585
+ if (existingMsg) {
586
+ const state = this.createMessageState(preferredId, existingMsg.role)
587
+ this.activeMessageIds.add(preferredId)
588
+
589
+ // Seed segment text from the existing last text part so that
590
+ // incoming deltas append correctly and updateTextPart produces
591
+ // the right content (existing text + new delta).
592
+ const lastPart =
593
+ existingMsg.parts.length > 0
594
+ ? existingMsg.parts[existingMsg.parts.length - 1]
595
+ : null
596
+ if (lastPart && lastPart.type === 'text') {
597
+ state.currentSegmentText = lastPart.content
598
+ state.lastEmittedText = lastPart.content
599
+ state.totalTextContent = lastPart.content
600
+ }
601
+
602
+ return { messageId: preferredId, state }
603
+ }
528
604
  }
529
- this.currentSegmentText = ''
530
- this.lastEmittedText = ''
605
+
606
+ // Auto-create an assistant message (backward compat for process() without TEXT_MESSAGE_START)
607
+ const id = preferredId || generateMessageId()
608
+ const assistantMessage: UIMessage = {
609
+ id,
610
+ role: 'assistant',
611
+ parts: [],
612
+ createdAt: new Date(),
613
+ }
614
+ this.messages = [...this.messages, assistantMessage]
615
+ const state = this.createMessageState(id, 'assistant')
616
+ this.activeMessageIds.add(id)
617
+ this.pendingManualMessageId = id
618
+ this.events.onStreamStart?.()
619
+ this.emitMessagesChange()
620
+ return { messageId: id, state }
621
+ }
622
+
623
+ // ============================================
624
+ // Event Handlers
625
+ // ============================================
626
+
627
+ /**
628
+ * Handle TEXT_MESSAGE_START event
629
+ */
630
+ private handleTextMessageStartEvent(
631
+ chunk: Extract<StreamChunk, { type: 'TEXT_MESSAGE_START' }>,
632
+ ): void {
633
+ const { messageId, role } = chunk
634
+
635
+ // Map 'tool' role to 'assistant' for both UIMessage and MessageStreamState
636
+ // (UIMessage doesn't support 'tool' role, and lookups like
637
+ // getActiveAssistantMessageId() check state.role === 'assistant')
638
+ const uiRole: 'system' | 'user' | 'assistant' =
639
+ role === 'tool' ? 'assistant' : role
640
+
641
+ // Case 1: A manual message was created via startAssistantMessage()
642
+ if (this.pendingManualMessageId) {
643
+ const pendingId = this.pendingManualMessageId
644
+ this.pendingManualMessageId = null
645
+
646
+ if (pendingId !== messageId) {
647
+ // Update the message's ID in the messages array
648
+ this.messages = this.messages.map((msg) =>
649
+ msg.id === pendingId ? { ...msg, id: messageId } : msg,
650
+ )
651
+
652
+ // Move state to the new key
653
+ const existingState = this.messageStates.get(pendingId)
654
+ if (existingState) {
655
+ existingState.id = messageId
656
+ this.messageStates.delete(pendingId)
657
+ this.messageStates.set(messageId, existingState)
658
+ }
659
+
660
+ // Update activeMessageIds
661
+ this.activeMessageIds.delete(pendingId)
662
+ this.activeMessageIds.add(messageId)
663
+ }
664
+
665
+ // Ensure state exists
666
+ if (!this.messageStates.has(messageId)) {
667
+ this.createMessageState(messageId, uiRole)
668
+ this.activeMessageIds.add(messageId)
669
+ }
670
+
671
+ this.emitMessagesChange()
672
+ return
673
+ }
674
+
675
+ // Case 2: Message already exists (dedup)
676
+ const existingMsg = this.messages.find((m) => m.id === messageId)
677
+ if (existingMsg) {
678
+ this.activeMessageIds.add(messageId)
679
+ if (!this.messageStates.has(messageId)) {
680
+ this.createMessageState(messageId, uiRole)
681
+ } else {
682
+ const existingState = this.messageStates.get(messageId)!
683
+ // If tool calls happened since last text, this TEXT_MESSAGE_START
684
+ // signals a new text segment — reset segment accumulation
685
+ if (existingState.hasToolCallsSinceTextStart) {
686
+ if (
687
+ existingState.currentSegmentText !== existingState.lastEmittedText
688
+ ) {
689
+ this.emitTextUpdateForMessage(messageId)
690
+ }
691
+ existingState.currentSegmentText = ''
692
+ existingState.lastEmittedText = ''
693
+ existingState.hasToolCallsSinceTextStart = false
694
+ }
695
+ }
696
+ return
697
+ }
698
+
699
+ // Case 3: New message from the stream
700
+ const newMessage: UIMessage = {
701
+ id: messageId,
702
+ role: uiRole,
703
+ parts: [],
704
+ createdAt: new Date(),
705
+ }
706
+
707
+ this.messages = [...this.messages, newMessage]
708
+ this.createMessageState(messageId, uiRole)
709
+ this.activeMessageIds.add(messageId)
710
+
711
+ this.events.onStreamStart?.()
712
+ this.emitMessagesChange()
713
+ }
714
+
715
+ /**
716
+ * Handle TEXT_MESSAGE_END event
717
+ */
718
+ private handleTextMessageEndEvent(
719
+ chunk: Extract<StreamChunk, { type: 'TEXT_MESSAGE_END' }>,
720
+ ): void {
721
+ const { messageId } = chunk
722
+ const state = this.getMessageState(messageId)
723
+ if (!state) return
724
+ if (state.isComplete) return
725
+
726
+ // Emit any pending text for this message
727
+ if (state.currentSegmentText !== state.lastEmittedText) {
728
+ this.emitTextUpdateForMessage(messageId)
729
+ }
730
+
731
+ // Complete all tool calls for this message
732
+ this.completeAllToolCallsForMessage(messageId)
733
+ }
734
+
735
+ /**
736
+ * Handle MESSAGES_SNAPSHOT event
737
+ */
738
+ private handleMessagesSnapshotEvent(
739
+ chunk: Extract<StreamChunk, { type: 'MESSAGES_SNAPSHOT' }>,
740
+ ): void {
741
+ this.resetStreamState()
742
+ this.messages = [...chunk.messages]
743
+ this.emitMessagesChange()
531
744
  }
532
745
 
533
746
  /**
@@ -544,17 +757,62 @@ export class StreamProcessor {
544
757
  private handleTextMessageContentEvent(
545
758
  chunk: Extract<StreamChunk, { type: 'TEXT_MESSAGE_CONTENT' }>,
546
759
  ): void {
547
- this.ensureAssistantMessage()
760
+ const { messageId, state } = this.ensureAssistantMessage(chunk.messageId)
761
+
762
+ // Content arriving means all current tool calls for this message are complete
763
+ this.completeAllToolCallsForMessage(messageId)
764
+
765
+ const previousSegment = state.currentSegmentText
766
+
767
+ // Detect if this is a NEW text segment (after tool calls) vs continuation
768
+ const isNewSegment =
769
+ state.hasToolCallsSinceTextStart &&
770
+ previousSegment.length > 0 &&
771
+ this.isNewTextSegment(chunk, previousSegment)
772
+
773
+ if (isNewSegment) {
774
+ // Emit any accumulated text before starting new segment
775
+ if (previousSegment !== state.lastEmittedText) {
776
+ this.emitTextUpdateForMessage(messageId)
777
+ }
778
+ // Reset SEGMENT text accumulation for the new text segment after tool calls
779
+ state.currentSegmentText = ''
780
+ state.lastEmittedText = ''
781
+ state.hasToolCallsSinceTextStart = false
782
+ }
548
783
 
549
- this.currentSegmentText += chunk.delta
550
- this.totalTextContent += chunk.delta
784
+ const currentText = state.currentSegmentText
785
+ let nextText = currentText
786
+
787
+ // Prefer delta over content - delta is the incremental change
788
+ // Normalize to empty string to avoid "undefined" string concatenation
789
+ const delta = chunk.delta || ''
790
+ if (delta !== '') {
791
+ nextText = currentText + delta
792
+ } else if (chunk.content !== undefined && chunk.content !== '') {
793
+ // Fallback: use content if delta is not provided
794
+ if (chunk.content.startsWith(currentText)) {
795
+ nextText = chunk.content
796
+ } else if (currentText.startsWith(chunk.content)) {
797
+ nextText = currentText
798
+ } else {
799
+ nextText = currentText + chunk.content
800
+ }
801
+ }
551
802
 
803
+ // Calculate the delta for totalTextContent
804
+ const textDelta = nextText.slice(currentText.length)
805
+ state.currentSegmentText = nextText
806
+ state.totalTextContent += textDelta
807
+
808
+ // Use delta for chunk strategy if available
809
+ const chunkPortion = chunk.delta || chunk.content || ''
552
810
  const shouldEmit = this.chunkStrategy.shouldEmit(
553
- chunk.delta,
554
- this.currentSegmentText,
811
+ chunkPortion,
812
+ state.currentSegmentText,
555
813
  )
556
- if (shouldEmit && this.currentSegmentText !== this.lastEmittedText) {
557
- this.emitTextUpdate()
814
+ if (shouldEmit && state.currentSegmentText !== state.lastEmittedText) {
815
+ this.emitTextUpdateForMessage(messageId)
558
816
  }
559
817
  }
560
818
 
@@ -574,10 +832,18 @@ export class StreamProcessor {
574
832
  private handleToolCallStartEvent(
575
833
  chunk: Extract<StreamChunk, { type: 'TOOL_CALL_START' }>,
576
834
  ): void {
577
- this.ensureAssistantMessage()
835
+ // Determine the message this tool call belongs to
836
+ const targetMessageId =
837
+ chunk.parentMessageId ?? this.getActiveAssistantMessageId()
838
+ const { messageId, state } = this.ensureAssistantMessage(
839
+ targetMessageId ?? undefined,
840
+ )
841
+
842
+ // Mark that we've seen tool calls since the last text segment
843
+ state.hasToolCallsSinceTextStart = true
578
844
 
579
845
  const toolCallId = chunk.toolCallId
580
- const existingToolCall = this.toolCalls.get(toolCallId)
846
+ const existingToolCall = state.toolCalls.get(toolCallId)
581
847
 
582
848
  if (!existingToolCall) {
583
849
  // New tool call starting
@@ -589,34 +855,31 @@ export class StreamProcessor {
589
855
  arguments: '',
590
856
  state: initialState,
591
857
  parsedArguments: undefined,
592
- index: chunk.index ?? this.toolCalls.size,
858
+ index: chunk.index ?? state.toolCalls.size,
593
859
  }
594
860
 
595
- this.toolCalls.set(toolCallId, newToolCall)
596
- this.toolCallOrder.push(toolCallId)
861
+ state.toolCalls.set(toolCallId, newToolCall)
862
+ state.toolCallOrder.push(toolCallId)
863
+
864
+ // Store mapping for TOOL_CALL_ARGS/END routing
865
+ this.toolCallToMessage.set(toolCallId, messageId)
597
866
 
598
867
  // Update UIMessage
599
- if (this.currentAssistantMessageId) {
600
- this.messages = updateToolCallPart(
601
- this.messages,
602
- this.currentAssistantMessageId,
603
- {
604
- id: chunk.toolCallId,
605
- name: chunk.toolName,
606
- arguments: '',
607
- state: initialState,
608
- },
609
- )
610
- this.emitMessagesChange()
868
+ this.messages = updateToolCallPart(this.messages, messageId, {
869
+ id: chunk.toolCallId,
870
+ name: chunk.toolName,
871
+ arguments: '',
872
+ state: initialState,
873
+ })
874
+ this.emitMessagesChange()
611
875
 
612
- // Emit granular event
613
- this.events.onToolCallStateChange?.(
614
- this.currentAssistantMessageId,
615
- chunk.toolCallId,
616
- initialState,
617
- '',
618
- )
619
- }
876
+ // Emit granular event
877
+ this.events.onToolCallStateChange?.(
878
+ messageId,
879
+ chunk.toolCallId,
880
+ initialState,
881
+ '',
882
+ )
620
883
  }
621
884
  }
622
885
 
@@ -636,47 +899,46 @@ export class StreamProcessor {
636
899
  chunk: Extract<StreamChunk, { type: 'TOOL_CALL_ARGS' }>,
637
900
  ): void {
638
901
  const toolCallId = chunk.toolCallId
639
- const existingToolCall = this.toolCalls.get(toolCallId)
902
+ const messageId = this.toolCallToMessage.get(toolCallId)
903
+ if (!messageId) return
640
904
 
641
- if (existingToolCall) {
642
- const wasAwaitingInput = existingToolCall.state === 'awaiting-input'
905
+ const state = this.getMessageState(messageId)
906
+ if (!state) return
643
907
 
644
- // Accumulate arguments from delta
645
- existingToolCall.arguments += chunk.delta || ''
908
+ const existingToolCall = state.toolCalls.get(toolCallId)
909
+ if (!existingToolCall) return
646
910
 
647
- // Update state
648
- if (wasAwaitingInput && chunk.delta) {
649
- existingToolCall.state = 'input-streaming'
650
- }
651
-
652
- // Try to parse the updated arguments
653
- existingToolCall.parsedArguments = this.jsonParser.parse(
654
- existingToolCall.arguments,
655
- )
911
+ const wasAwaitingInput = existingToolCall.state === 'awaiting-input'
656
912
 
657
- // Update UIMessage
658
- if (this.currentAssistantMessageId) {
659
- this.messages = updateToolCallPart(
660
- this.messages,
661
- this.currentAssistantMessageId,
662
- {
663
- id: existingToolCall.id,
664
- name: existingToolCall.name,
665
- arguments: existingToolCall.arguments,
666
- state: existingToolCall.state,
667
- },
668
- )
669
- this.emitMessagesChange()
913
+ // Accumulate arguments from delta
914
+ existingToolCall.arguments += chunk.delta || ''
670
915
 
671
- // Emit granular event
672
- this.events.onToolCallStateChange?.(
673
- this.currentAssistantMessageId,
674
- existingToolCall.id,
675
- existingToolCall.state,
676
- existingToolCall.arguments,
677
- )
678
- }
916
+ // Update state
917
+ if (wasAwaitingInput && chunk.delta) {
918
+ existingToolCall.state = 'input-streaming'
679
919
  }
920
+
921
+ // Try to parse the updated arguments
922
+ existingToolCall.parsedArguments = this.jsonParser.parse(
923
+ existingToolCall.arguments,
924
+ )
925
+
926
+ // Update UIMessage
927
+ this.messages = updateToolCallPart(this.messages, messageId, {
928
+ id: existingToolCall.id,
929
+ name: existingToolCall.name,
930
+ arguments: existingToolCall.arguments,
931
+ state: existingToolCall.state,
932
+ })
933
+ this.emitMessagesChange()
934
+
935
+ // Emit granular event
936
+ this.events.onToolCallStateChange?.(
937
+ messageId,
938
+ existingToolCall.id,
939
+ existingToolCall.state,
940
+ existingToolCall.arguments,
941
+ )
680
942
  }
681
943
 
682
944
  /**
@@ -696,11 +958,17 @@ export class StreamProcessor {
696
958
  private handleToolCallEndEvent(
697
959
  chunk: Extract<StreamChunk, { type: 'TOOL_CALL_END' }>,
698
960
  ): void {
961
+ const messageId = this.toolCallToMessage.get(chunk.toolCallId)
962
+ if (!messageId) return
963
+
964
+ const msgState = this.getMessageState(messageId)
965
+ if (!msgState) return
966
+
699
967
  // Transition the tool call to input-complete (the authoritative completion signal)
700
- const existingToolCall = this.toolCalls.get(chunk.toolCallId)
968
+ const existingToolCall = msgState.toolCalls.get(chunk.toolCallId)
701
969
  if (existingToolCall && existingToolCall.state !== 'input-complete') {
702
- const index = this.toolCallOrder.indexOf(chunk.toolCallId)
703
- this.completeToolCall(index, existingToolCall)
970
+ const index = msgState.toolCallOrder.indexOf(chunk.toolCallId)
971
+ this.completeToolCall(messageId, index, existingToolCall)
704
972
  // If TOOL_CALL_END provides parsed input, use it as the canonical parsed
705
973
  // arguments (overrides the accumulated string parse from completeToolCall)
706
974
  if (chunk.input !== undefined) {
@@ -708,10 +976,8 @@ export class StreamProcessor {
708
976
  }
709
977
  }
710
978
 
711
- // Update UIMessage if we have a current assistant message and a result
712
- if (this.currentAssistantMessageId && chunk.result) {
713
- const state: ToolResultState = 'complete'
714
-
979
+ // Update UIMessage if there's a result
980
+ if (chunk.result) {
715
981
  // Step 1: Update the tool-call part's output field (for UI consistency
716
982
  // with client tools — see GitHub issue #176)
717
983
  let output: unknown
@@ -727,23 +993,36 @@ export class StreamProcessor {
727
993
  )
728
994
 
729
995
  // Step 2: Create/update the tool-result part (for LLM conversation history)
996
+ const resultState: ToolResultState = 'complete'
730
997
  this.messages = updateToolResultPart(
731
998
  this.messages,
732
- this.currentAssistantMessageId,
999
+ messageId,
733
1000
  chunk.toolCallId,
734
1001
  chunk.result,
735
- state,
1002
+ resultState,
736
1003
  )
737
1004
  this.emitMessagesChange()
738
1005
  }
739
1006
  }
740
1007
 
1008
+ /**
1009
+ * Handle RUN_STARTED event.
1010
+ *
1011
+ * Registers the run so that RUN_FINISHED can determine whether other
1012
+ * runs are still active before finalizing.
1013
+ */
1014
+ private handleRunStartedEvent(
1015
+ chunk: Extract<StreamChunk, { type: 'RUN_STARTED' }>,
1016
+ ): void {
1017
+ this.activeRuns.add(chunk.runId)
1018
+ }
1019
+
741
1020
  /**
742
1021
  * Handle RUN_FINISHED event.
743
1022
  *
744
- * Records the finishReason and calls completeAllToolCalls() as a safety net
745
- * to force-complete any tool calls that didn't receive an explicit TOOL_CALL_END.
746
- * This handles cases like aborted streams or adapter bugs.
1023
+ * Records the finishReason and removes the run from activeRuns.
1024
+ * Only finalizes when no more runs are active, so that concurrent
1025
+ * runs don't interfere with each other.
747
1026
  *
748
1027
  * @see docs/chat-architecture.md#single-shot-tool-call-response — finishReason semantics
749
1028
  * @see docs/chat-architecture.md#adapter-contract — Why RUN_FINISHED is mandatory
@@ -752,8 +1031,13 @@ export class StreamProcessor {
752
1031
  chunk: Extract<StreamChunk, { type: 'RUN_FINISHED' }>,
753
1032
  ): void {
754
1033
  this.finishReason = chunk.finishReason
755
- this.isDone = true
756
- this.completeAllToolCalls()
1034
+ this.activeRuns.delete(chunk.runId)
1035
+
1036
+ if (this.activeRuns.size === 0) {
1037
+ this.isDone = true
1038
+ this.completeAllToolCalls()
1039
+ this.finalizeStream()
1040
+ }
757
1041
  }
758
1042
 
759
1043
  /**
@@ -763,8 +1047,12 @@ export class StreamProcessor {
763
1047
  chunk: Extract<StreamChunk, { type: 'RUN_ERROR' }>,
764
1048
  ): void {
765
1049
  this.hasError = true
1050
+ if (chunk.runId) {
1051
+ this.activeRuns.delete(chunk.runId)
1052
+ } else {
1053
+ this.activeRuns.clear()
1054
+ }
766
1055
  this.ensureAssistantMessage()
767
- // Emit error event
768
1056
  this.events.onError?.(new Error(chunk.error.message || 'An error occurred'))
769
1057
  }
770
1058
 
@@ -779,25 +1067,38 @@ export class StreamProcessor {
779
1067
  private handleStepFinishedEvent(
780
1068
  chunk: Extract<StreamChunk, { type: 'STEP_FINISHED' }>,
781
1069
  ): void {
782
- this.ensureAssistantMessage()
1070
+ const { messageId, state } = this.ensureAssistantMessage(
1071
+ this.getActiveAssistantMessageId() ?? undefined,
1072
+ )
1073
+
1074
+ const previous = state.thinkingContent
1075
+ let nextThinking = previous
1076
+
1077
+ // Prefer delta over content
1078
+ if (chunk.delta && chunk.delta !== '') {
1079
+ nextThinking = previous + chunk.delta
1080
+ } else if (chunk.content && chunk.content !== '') {
1081
+ if (chunk.content.startsWith(previous)) {
1082
+ nextThinking = chunk.content
1083
+ } else if (previous.startsWith(chunk.content)) {
1084
+ nextThinking = previous
1085
+ } else {
1086
+ nextThinking = previous + chunk.content
1087
+ }
1088
+ }
783
1089
 
784
- this.thinkingContent += chunk.delta
1090
+ state.thinkingContent = nextThinking
785
1091
 
786
1092
  // Update UIMessage
787
- if (this.currentAssistantMessageId) {
788
- this.messages = updateThinkingPart(
789
- this.messages,
790
- this.currentAssistantMessageId,
791
- this.thinkingContent,
792
- )
793
- this.emitMessagesChange()
1093
+ this.messages = updateThinkingPart(
1094
+ this.messages,
1095
+ messageId,
1096
+ state.thinkingContent,
1097
+ )
1098
+ this.emitMessagesChange()
794
1099
 
795
- // Emit granular event
796
- this.events.onThinkingUpdate?.(
797
- this.currentAssistantMessageId,
798
- this.thinkingContent,
799
- )
800
- }
1100
+ // Emit granular event
1101
+ this.events.onThinkingUpdate?.(messageId, state.thinkingContent)
801
1102
  }
802
1103
 
803
1104
  /**
@@ -813,6 +1114,8 @@ export class StreamProcessor {
813
1114
  private handleCustomEvent(
814
1115
  chunk: Extract<StreamChunk, { type: 'CUSTOM' }>,
815
1116
  ): void {
1117
+ const messageId = this.getActiveAssistantMessageId()
1118
+
816
1119
  // Handle client tool input availability - trigger client-side execution
817
1120
  if (chunk.name === 'tool-input-available' && chunk.value) {
818
1121
  const { toolCallId, toolName, input } = chunk.value as {
@@ -839,11 +1142,15 @@ export class StreamProcessor {
839
1142
  approval: { id: string; needsApproval: boolean }
840
1143
  }
841
1144
 
842
- // Update the tool call part with approval state
843
- if (this.currentAssistantMessageId) {
1145
+ // Resolve the message containing this tool call. After RUN_FINISHED,
1146
+ // activeMessageIds is cleared, so fall back to the toolCallToMessage map
1147
+ // which is populated during TOOL_CALL_START and preserved across finalize.
1148
+ const resolvedMessageId =
1149
+ messageId ?? this.toolCallToMessage.get(toolCallId)
1150
+ if (resolvedMessageId) {
844
1151
  this.messages = updateToolCallApproval(
845
1152
  this.messages,
846
- this.currentAssistantMessageId,
1153
+ resolvedMessageId,
847
1154
  toolCallId,
848
1155
  approval.id,
849
1156
  )
@@ -870,8 +1177,34 @@ export class StreamProcessor {
870
1177
  }
871
1178
  }
872
1179
 
1180
+ // ============================================
1181
+ // Internal Helpers
1182
+ // ============================================
1183
+
873
1184
  /**
874
- * Complete all tool calls — safety net for stream termination.
1185
+ * Detect if an incoming content chunk represents a NEW text segment
1186
+ */
1187
+ private isNewTextSegment(
1188
+ chunk: Extract<StreamChunk, { type: 'TEXT_MESSAGE_CONTENT' }>,
1189
+ previous: string,
1190
+ ): boolean {
1191
+ // Check if content is present (delta is always defined but may be empty string)
1192
+ if (chunk.content !== undefined) {
1193
+ if (chunk.content.length < previous.length) {
1194
+ return true
1195
+ }
1196
+ if (
1197
+ !chunk.content.startsWith(previous) &&
1198
+ !previous.startsWith(chunk.content)
1199
+ ) {
1200
+ return true
1201
+ }
1202
+ }
1203
+ return false
1204
+ }
1205
+
1206
+ /**
1207
+ * Complete all tool calls across all active messages — safety net for stream termination.
875
1208
  *
876
1209
  * Called by RUN_FINISHED and finalizeStream(). Force-transitions any tool call
877
1210
  * not yet in input-complete state. Handles cases where TOOL_CALL_END was
@@ -880,10 +1213,22 @@ export class StreamProcessor {
880
1213
  * @see docs/chat-architecture.md#single-shot-tool-call-response — Safety net behavior
881
1214
  */
882
1215
  private completeAllToolCalls(): void {
883
- this.toolCalls.forEach((toolCall, id) => {
1216
+ for (const messageId of this.activeMessageIds) {
1217
+ this.completeAllToolCallsForMessage(messageId)
1218
+ }
1219
+ }
1220
+
1221
+ /**
1222
+ * Complete all tool calls for a specific message
1223
+ */
1224
+ private completeAllToolCallsForMessage(messageId: string): void {
1225
+ const state = this.getMessageState(messageId)
1226
+ if (!state) return
1227
+
1228
+ state.toolCalls.forEach((toolCall, id) => {
884
1229
  if (toolCall.state !== 'input-complete') {
885
- const index = this.toolCallOrder.indexOf(id)
886
- this.completeToolCall(index, toolCall)
1230
+ const index = state.toolCallOrder.indexOf(id)
1231
+ this.completeToolCall(messageId, index, toolCall)
887
1232
  }
888
1233
  })
889
1234
  }
@@ -892,6 +1237,7 @@ export class StreamProcessor {
892
1237
  * Mark a tool call as complete and emit event
893
1238
  */
894
1239
  private completeToolCall(
1240
+ messageId: string,
895
1241
  _index: number,
896
1242
  toolCall: InternalToolCallState,
897
1243
  ): void {
@@ -901,31 +1247,25 @@ export class StreamProcessor {
901
1247
  toolCall.parsedArguments = this.jsonParser.parse(toolCall.arguments)
902
1248
 
903
1249
  // Update UIMessage
904
- if (this.currentAssistantMessageId) {
905
- this.messages = updateToolCallPart(
906
- this.messages,
907
- this.currentAssistantMessageId,
908
- {
909
- id: toolCall.id,
910
- name: toolCall.name,
911
- arguments: toolCall.arguments,
912
- state: 'input-complete',
913
- },
914
- )
915
- this.emitMessagesChange()
1250
+ this.messages = updateToolCallPart(this.messages, messageId, {
1251
+ id: toolCall.id,
1252
+ name: toolCall.name,
1253
+ arguments: toolCall.arguments,
1254
+ state: 'input-complete',
1255
+ })
1256
+ this.emitMessagesChange()
916
1257
 
917
- // Emit granular event
918
- this.events.onToolCallStateChange?.(
919
- this.currentAssistantMessageId,
920
- toolCall.id,
921
- 'input-complete',
922
- toolCall.arguments,
923
- )
924
- }
1258
+ // Emit granular event
1259
+ this.events.onToolCallStateChange?.(
1260
+ messageId,
1261
+ toolCall.id,
1262
+ 'input-complete',
1263
+ toolCall.arguments,
1264
+ )
925
1265
  }
926
1266
 
927
1267
  /**
928
- * Emit pending text update.
1268
+ * Emit pending text update for a specific message.
929
1269
  *
930
1270
  * Calls updateTextPart() which has critical append-vs-replace logic:
931
1271
  * - If last UIMessage part is TextPart → replaces its content (same segment).
@@ -933,24 +1273,22 @@ export class StreamProcessor {
933
1273
  *
934
1274
  * @see docs/chat-architecture.md#uimessage-part-ordering-invariants — Replace vs. push logic
935
1275
  */
936
- private emitTextUpdate(): void {
937
- this.lastEmittedText = this.currentSegmentText
1276
+ private emitTextUpdateForMessage(messageId: string): void {
1277
+ const state = this.getMessageState(messageId)
1278
+ if (!state) return
1279
+
1280
+ state.lastEmittedText = state.currentSegmentText
938
1281
 
939
1282
  // Update UIMessage
940
- if (this.currentAssistantMessageId) {
941
- this.messages = updateTextPart(
942
- this.messages,
943
- this.currentAssistantMessageId,
944
- this.currentSegmentText,
945
- )
946
- this.emitMessagesChange()
1283
+ this.messages = updateTextPart(
1284
+ this.messages,
1285
+ messageId,
1286
+ state.currentSegmentText,
1287
+ )
1288
+ this.emitMessagesChange()
947
1289
 
948
- // Emit granular event
949
- this.events.onTextUpdate?.(
950
- this.currentAssistantMessageId,
951
- this.currentSegmentText,
952
- )
953
- }
1290
+ // Emit granular event
1291
+ this.events.onTextUpdate?.(messageId, state.currentSegmentText)
954
1292
  }
955
1293
 
956
1294
  /**
@@ -970,81 +1308,116 @@ export class StreamProcessor {
970
1308
  * @see docs/chat-architecture.md#single-shot-text-response — Finalization step
971
1309
  */
972
1310
  finalizeStream(): void {
973
- // Safety net: complete any remaining tool calls (e.g. on network errors / aborted streams)
974
- this.completeAllToolCalls()
1311
+ let lastAssistantMessage: UIMessage | undefined
975
1312
 
976
- // Emit any pending text if not already emitted
977
- if (this.currentSegmentText !== this.lastEmittedText) {
978
- this.emitTextUpdate()
1313
+ // Finalize ALL active messages
1314
+ for (const messageId of this.activeMessageIds) {
1315
+ const state = this.getMessageState(messageId)
1316
+ if (!state) continue
1317
+
1318
+ // Complete any remaining tool calls
1319
+ this.completeAllToolCallsForMessage(messageId)
1320
+
1321
+ // Emit any pending text if not already emitted
1322
+ if (state.currentSegmentText !== state.lastEmittedText) {
1323
+ this.emitTextUpdateForMessage(messageId)
1324
+ }
1325
+
1326
+ state.isComplete = true
1327
+
1328
+ const msg = this.messages.find((m) => m.id === messageId)
1329
+ if (msg && msg.role === 'assistant') {
1330
+ lastAssistantMessage = msg
1331
+ }
979
1332
  }
980
1333
 
981
- // Remove the assistant message if it only contains whitespace text
982
- // (no tool calls, no meaningful content). This handles models like Gemini
983
- // that sometimes return just "\n" during auto-continuation.
1334
+ this.activeMessageIds.clear()
1335
+
1336
+ // Remove whitespace-only assistant messages (handles models like Gemini
1337
+ // that sometimes return just "\n" during auto-continuation).
984
1338
  // Preserve the message on errors so the UI can show error state.
985
- if (this.currentAssistantMessageId && !this.hasError) {
986
- const assistantMessage = this.messages.find(
987
- (m) => m.id === this.currentAssistantMessageId,
988
- )
989
- if (assistantMessage && this.isWhitespaceOnlyMessage(assistantMessage)) {
1339
+ if (lastAssistantMessage && !this.hasError) {
1340
+ if (this.isWhitespaceOnlyMessage(lastAssistantMessage)) {
990
1341
  this.messages = this.messages.filter(
991
- (m) => m.id !== this.currentAssistantMessageId,
1342
+ (m) => m.id !== lastAssistantMessage.id,
992
1343
  )
993
1344
  this.emitMessagesChange()
994
- this.currentAssistantMessageId = null
995
1345
  return
996
1346
  }
997
1347
  }
998
1348
 
999
- // Emit stream end event (only if a message was actually created)
1000
- if (this.currentAssistantMessageId) {
1001
- const assistantMessage = this.messages.find(
1002
- (m) => m.id === this.currentAssistantMessageId,
1003
- )
1004
- if (assistantMessage) {
1005
- this.events.onStreamEnd?.(assistantMessage)
1006
- }
1349
+ // Emit stream end for the last assistant message
1350
+ if (lastAssistantMessage) {
1351
+ this.events.onStreamEnd?.(lastAssistantMessage)
1007
1352
  }
1008
1353
  }
1009
1354
 
1010
1355
  /**
1011
- * Get completed tool calls in API format
1356
+ * Get completed tool calls in API format (aggregated across all messages)
1012
1357
  */
1013
1358
  private getCompletedToolCalls(): Array<ToolCall> {
1014
- return Array.from(this.toolCalls.values())
1015
- .filter((tc) => tc.state === 'input-complete')
1016
- .map((tc) => ({
1017
- id: tc.id,
1018
- type: 'function' as const,
1019
- function: {
1020
- name: tc.name,
1021
- arguments: tc.arguments,
1022
- },
1023
- }))
1359
+ const result: Array<ToolCall> = []
1360
+ for (const state of this.messageStates.values()) {
1361
+ for (const tc of state.toolCalls.values()) {
1362
+ if (tc.state === 'input-complete') {
1363
+ result.push({
1364
+ id: tc.id,
1365
+ type: 'function' as const,
1366
+ function: {
1367
+ name: tc.name,
1368
+ arguments: tc.arguments,
1369
+ },
1370
+ })
1371
+ }
1372
+ }
1373
+ }
1374
+ return result
1024
1375
  }
1025
1376
 
1026
1377
  /**
1027
- * Get current result
1378
+ * Get current result (aggregated across all messages)
1028
1379
  */
1029
1380
  private getResult(): ProcessorResult {
1030
1381
  const toolCalls = this.getCompletedToolCalls()
1382
+ let content = ''
1383
+ let thinking = ''
1384
+
1385
+ for (const state of this.messageStates.values()) {
1386
+ content += state.totalTextContent
1387
+ thinking += state.thinkingContent
1388
+ }
1389
+
1031
1390
  return {
1032
- content: this.totalTextContent,
1033
- thinking: this.thinkingContent || undefined,
1391
+ content,
1392
+ thinking: thinking || undefined,
1034
1393
  toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
1035
1394
  finishReason: this.finishReason,
1036
1395
  }
1037
1396
  }
1038
1397
 
1039
1398
  /**
1040
- * Get current processor state
1399
+ * Get current processor state (aggregated across all messages)
1041
1400
  */
1042
1401
  getState(): ProcessorState {
1402
+ let content = ''
1403
+ let thinking = ''
1404
+ const toolCalls = new Map<string, InternalToolCallState>()
1405
+ const toolCallOrder: Array<string> = []
1406
+
1407
+ for (const state of this.messageStates.values()) {
1408
+ content += state.totalTextContent
1409
+ thinking += state.thinkingContent
1410
+ for (const [id, tc] of state.toolCalls) {
1411
+ toolCalls.set(id, tc)
1412
+ }
1413
+ toolCallOrder.push(...state.toolCallOrder)
1414
+ }
1415
+
1043
1416
  return {
1044
- content: this.totalTextContent,
1045
- thinking: this.thinkingContent,
1046
- toolCalls: new Map(this.toolCalls),
1047
- toolCallOrder: [...this.toolCallOrder],
1417
+ content,
1418
+ thinking,
1419
+ toolCalls,
1420
+ toolCallOrder,
1048
1421
  finishReason: this.finishReason,
1049
1422
  done: this.isDone,
1050
1423
  }
@@ -1074,12 +1447,11 @@ export class StreamProcessor {
1074
1447
  * Reset stream state (but keep messages)
1075
1448
  */
1076
1449
  private resetStreamState(): void {
1077
- this.totalTextContent = ''
1078
- this.currentSegmentText = ''
1079
- this.lastEmittedText = ''
1080
- this.thinkingContent = ''
1081
- this.toolCalls.clear()
1082
- this.toolCallOrder = []
1450
+ this.messageStates.clear()
1451
+ this.activeMessageIds.clear()
1452
+ this.activeRuns.clear()
1453
+ this.toolCallToMessage.clear()
1454
+ this.pendingManualMessageId = null
1083
1455
  this.finishReason = null
1084
1456
  this.hasError = false
1085
1457
  this.isDone = false
@@ -1092,7 +1464,6 @@ export class StreamProcessor {
1092
1464
  reset(): void {
1093
1465
  this.resetStreamState()
1094
1466
  this.messages = []
1095
- this.currentAssistantMessageId = null
1096
1467
  }
1097
1468
 
1098
1469
  /**