@tanstack/ai 0.4.1 → 0.5.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.
- package/dist/esm/activities/chat/index.js +3 -4
- package/dist/esm/activities/chat/index.js.map +1 -1
- package/dist/esm/activities/chat/messages.d.ts +13 -6
- package/dist/esm/activities/chat/messages.js +115 -86
- package/dist/esm/activities/chat/messages.js.map +1 -1
- package/dist/esm/activities/chat/stream/processor.d.ts +143 -26
- package/dist/esm/activities/chat/stream/processor.js +205 -77
- package/dist/esm/activities/chat/stream/processor.js.map +1 -1
- package/dist/esm/activities/chat/tools/tool-calls.js +2 -3
- package/dist/esm/activities/chat/tools/tool-calls.js.map +1 -1
- package/dist/esm/types.d.ts +3 -3
- package/package.json +1 -1
- package/src/activities/chat/index.ts +12 -9
- package/src/activities/chat/messages.ts +211 -138
- package/src/activities/chat/stream/processor.ts +240 -115
- package/src/activities/chat/tools/tool-calls.ts +1 -4
- package/src/types.ts +3 -3
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
* - Thinking/reasoning content
|
|
13
13
|
* - Recording/replay for testing
|
|
14
14
|
* - Event-driven architecture for UI updates
|
|
15
|
+
*
|
|
16
|
+
* @see docs/chat-architecture.md — Canonical reference for AG-UI chunk ordering,
|
|
17
|
+
* adapter contract, single-shot flows, and expected UIMessage output.
|
|
15
18
|
*/
|
|
16
19
|
import { generateMessageId, uiMessageToModelMessages } from '../messages.js'
|
|
17
20
|
import { defaultJSONParser } from './json-parser'
|
|
@@ -101,18 +104,18 @@ export interface StreamProcessorOptions {
|
|
|
101
104
|
* StreamProcessor - State machine for processing AI response streams
|
|
102
105
|
*
|
|
103
106
|
* Manages the full UIMessage[] conversation and emits events on changes.
|
|
107
|
+
* Trusts the adapter contract: adapters emit clean AG-UI events in the
|
|
108
|
+
* correct order.
|
|
104
109
|
*
|
|
105
110
|
* State tracking:
|
|
106
111
|
* - Full message array
|
|
107
112
|
* - Current assistant message being streamed
|
|
108
|
-
* - Text content accumulation
|
|
113
|
+
* - Text content accumulation (reset on TEXT_MESSAGE_START)
|
|
109
114
|
* - Multiple parallel tool calls
|
|
110
|
-
* - Tool call completion
|
|
115
|
+
* - Tool call completion via TOOL_CALL_END events
|
|
111
116
|
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
* 2. Text content arrives
|
|
115
|
-
* 3. Stream ends
|
|
117
|
+
* @see docs/chat-architecture.md#streamprocessor-internal-state — State field reference
|
|
118
|
+
* @see docs/chat-architecture.md#adapter-contract — What this class expects from adapters
|
|
116
119
|
*/
|
|
117
120
|
export class StreamProcessor {
|
|
118
121
|
private chunkStrategy: ChunkStrategy
|
|
@@ -134,9 +137,8 @@ export class StreamProcessor {
|
|
|
134
137
|
private toolCalls: Map<string, InternalToolCallState> = new Map()
|
|
135
138
|
private toolCallOrder: Array<string> = []
|
|
136
139
|
private finishReason: string | null = null
|
|
140
|
+
private hasError = false
|
|
137
141
|
private isDone = false
|
|
138
|
-
// Track if we've had tool calls since the last text segment started
|
|
139
|
-
private hasToolCallsSinceTextStart = false
|
|
140
142
|
|
|
141
143
|
// Recording
|
|
142
144
|
private recording: ChunkRecording | null = null
|
|
@@ -213,12 +215,52 @@ export class StreamProcessor {
|
|
|
213
215
|
}
|
|
214
216
|
|
|
215
217
|
/**
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
+
* Prepare for a new assistant message stream.
|
|
219
|
+
* Does NOT create the message immediately -- the message is created lazily
|
|
220
|
+
* when the first content-bearing chunk arrives via ensureAssistantMessage().
|
|
221
|
+
* This prevents empty assistant messages from flickering in the UI when
|
|
222
|
+
* auto-continuation produces no content.
|
|
218
223
|
*/
|
|
219
|
-
|
|
224
|
+
prepareAssistantMessage(): void {
|
|
220
225
|
// Reset stream state for new message
|
|
221
226
|
this.resetStreamState()
|
|
227
|
+
// Clear the current assistant message ID so ensureAssistantMessage()
|
|
228
|
+
// will create a fresh message on the first content chunk
|
|
229
|
+
this.currentAssistantMessageId = null
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* @deprecated Use prepareAssistantMessage() instead. This eagerly creates
|
|
234
|
+
* an assistant message which can cause empty message flicker.
|
|
235
|
+
*/
|
|
236
|
+
startAssistantMessage(): string {
|
|
237
|
+
this.prepareAssistantMessage()
|
|
238
|
+
return this.ensureAssistantMessage()
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Get the current assistant message ID (if one has been created).
|
|
243
|
+
* Returns null if prepareAssistantMessage() was called but no content
|
|
244
|
+
* has arrived yet.
|
|
245
|
+
*/
|
|
246
|
+
getCurrentAssistantMessageId(): string | null {
|
|
247
|
+
return this.currentAssistantMessageId
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Lazily create the assistant message if it hasn't been created yet.
|
|
252
|
+
* Called by content handlers on the first content-bearing chunk.
|
|
253
|
+
* Returns the message ID.
|
|
254
|
+
*
|
|
255
|
+
* Content-bearing chunks that trigger this:
|
|
256
|
+
* TEXT_MESSAGE_CONTENT, TOOL_CALL_START, STEP_FINISHED, RUN_ERROR.
|
|
257
|
+
*
|
|
258
|
+
* @see docs/chat-architecture.md#streamprocessor-internal-state — Lazy creation pattern
|
|
259
|
+
*/
|
|
260
|
+
private ensureAssistantMessage(): string {
|
|
261
|
+
if (this.currentAssistantMessageId) {
|
|
262
|
+
return this.currentAssistantMessageId
|
|
263
|
+
}
|
|
222
264
|
|
|
223
265
|
const assistantMessage: UIMessage = {
|
|
224
266
|
id: generateMessageId(),
|
|
@@ -398,7 +440,13 @@ export class StreamProcessor {
|
|
|
398
440
|
}
|
|
399
441
|
|
|
400
442
|
/**
|
|
401
|
-
* Process a single chunk from the stream
|
|
443
|
+
* Process a single chunk from the stream.
|
|
444
|
+
*
|
|
445
|
+
* Central dispatch for all AG-UI events. Each event type maps to a specific
|
|
446
|
+
* handler. Events not listed in the switch are intentionally ignored
|
|
447
|
+
* (RUN_STARTED, TEXT_MESSAGE_END, STEP_STARTED, STATE_SNAPSHOT, STATE_DELTA).
|
|
448
|
+
*
|
|
449
|
+
* @see docs/chat-architecture.md#adapter-contract — Expected event types and ordering
|
|
402
450
|
*/
|
|
403
451
|
processChunk(chunk: StreamChunk): void {
|
|
404
452
|
// Record chunk if enabled
|
|
@@ -412,6 +460,10 @@ export class StreamProcessor {
|
|
|
412
460
|
|
|
413
461
|
switch (chunk.type) {
|
|
414
462
|
// AG-UI Events
|
|
463
|
+
case 'TEXT_MESSAGE_START':
|
|
464
|
+
this.handleTextMessageStartEvent()
|
|
465
|
+
break
|
|
466
|
+
|
|
415
467
|
case 'TEXT_MESSAGE_CONTENT':
|
|
416
468
|
this.handleTextMessageContentEvent(chunk)
|
|
417
469
|
break
|
|
@@ -445,66 +497,53 @@ export class StreamProcessor {
|
|
|
445
497
|
break
|
|
446
498
|
|
|
447
499
|
default:
|
|
448
|
-
// RUN_STARTED,
|
|
500
|
+
// RUN_STARTED, TEXT_MESSAGE_END, STEP_STARTED,
|
|
449
501
|
// STATE_SNAPSHOT, STATE_DELTA - no special handling needed
|
|
450
502
|
break
|
|
451
503
|
}
|
|
452
504
|
}
|
|
453
505
|
|
|
454
506
|
/**
|
|
455
|
-
* Handle
|
|
507
|
+
* Handle TEXT_MESSAGE_START event — marks the beginning of a new text segment.
|
|
508
|
+
* Resets segment accumulation so text after tool calls starts fresh.
|
|
509
|
+
*
|
|
510
|
+
* This is the key mechanism for multi-segment text (text before and after tool
|
|
511
|
+
* calls becoming separate TextParts). Without this reset, all text would merge
|
|
512
|
+
* into a single TextPart and tool-call interleaving would be lost.
|
|
513
|
+
*
|
|
514
|
+
* @see docs/chat-architecture.md#single-shot-text-response — Step-by-step text processing
|
|
515
|
+
* @see docs/chat-architecture.md#text-then-tool-interleaving-single-shot — Multi-segment text
|
|
516
|
+
*/
|
|
517
|
+
private handleTextMessageStartEvent(): void {
|
|
518
|
+
// Emit any pending text from a previous segment before resetting
|
|
519
|
+
if (this.currentSegmentText !== this.lastEmittedText) {
|
|
520
|
+
this.emitTextUpdate()
|
|
521
|
+
}
|
|
522
|
+
this.currentSegmentText = ''
|
|
523
|
+
this.lastEmittedText = ''
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Handle TEXT_MESSAGE_CONTENT event.
|
|
528
|
+
*
|
|
529
|
+
* Accumulates delta into both currentSegmentText (for UI emission) and
|
|
530
|
+
* totalTextContent (for ProcessorResult). Lazily creates the assistant
|
|
531
|
+
* UIMessage on first content. Uses updateTextPart() which replaces the
|
|
532
|
+
* last TextPart or creates a new one depending on part ordering.
|
|
533
|
+
*
|
|
534
|
+
* @see docs/chat-architecture.md#single-shot-text-response — Text accumulation step-by-step
|
|
535
|
+
* @see docs/chat-architecture.md#uimessage-part-ordering-invariants — Replace vs. push logic
|
|
456
536
|
*/
|
|
457
537
|
private handleTextMessageContentEvent(
|
|
458
538
|
chunk: Extract<StreamChunk, { type: 'TEXT_MESSAGE_CONTENT' }>,
|
|
459
539
|
): void {
|
|
460
|
-
|
|
461
|
-
this.completeAllToolCalls()
|
|
462
|
-
|
|
463
|
-
const previousSegment = this.currentSegmentText
|
|
464
|
-
|
|
465
|
-
// Detect if this is a NEW text segment (after tool calls) vs continuation
|
|
466
|
-
const isNewSegment =
|
|
467
|
-
this.hasToolCallsSinceTextStart &&
|
|
468
|
-
previousSegment.length > 0 &&
|
|
469
|
-
this.isNewTextSegment(chunk, previousSegment)
|
|
470
|
-
|
|
471
|
-
if (isNewSegment) {
|
|
472
|
-
// Emit any accumulated text before starting new segment
|
|
473
|
-
if (previousSegment !== this.lastEmittedText) {
|
|
474
|
-
this.emitTextUpdate()
|
|
475
|
-
}
|
|
476
|
-
// Reset SEGMENT text accumulation for the new text segment after tool calls
|
|
477
|
-
this.currentSegmentText = ''
|
|
478
|
-
this.lastEmittedText = ''
|
|
479
|
-
this.hasToolCallsSinceTextStart = false
|
|
480
|
-
}
|
|
540
|
+
this.ensureAssistantMessage()
|
|
481
541
|
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
// Prefer delta over content - delta is the incremental change
|
|
486
|
-
if (chunk.delta !== '') {
|
|
487
|
-
nextText = currentText + chunk.delta
|
|
488
|
-
} else if (chunk.content && chunk.content !== '') {
|
|
489
|
-
// Fallback: use content if delta is not provided
|
|
490
|
-
if (chunk.content.startsWith(currentText)) {
|
|
491
|
-
nextText = chunk.content
|
|
492
|
-
} else if (currentText.startsWith(chunk.content)) {
|
|
493
|
-
nextText = currentText
|
|
494
|
-
} else {
|
|
495
|
-
nextText = currentText + chunk.content
|
|
496
|
-
}
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
// Calculate the delta for totalTextContent
|
|
500
|
-
const textDelta = nextText.slice(currentText.length)
|
|
501
|
-
this.currentSegmentText = nextText
|
|
502
|
-
this.totalTextContent += textDelta
|
|
542
|
+
this.currentSegmentText += chunk.delta
|
|
543
|
+
this.totalTextContent += chunk.delta
|
|
503
544
|
|
|
504
|
-
// Use delta for chunk strategy if available
|
|
505
|
-
const chunkPortion = chunk.delta || chunk.content || ''
|
|
506
545
|
const shouldEmit = this.chunkStrategy.shouldEmit(
|
|
507
|
-
|
|
546
|
+
chunk.delta,
|
|
508
547
|
this.currentSegmentText,
|
|
509
548
|
)
|
|
510
549
|
if (shouldEmit && this.currentSegmentText !== this.lastEmittedText) {
|
|
@@ -513,13 +552,22 @@ export class StreamProcessor {
|
|
|
513
552
|
}
|
|
514
553
|
|
|
515
554
|
/**
|
|
516
|
-
* Handle TOOL_CALL_START event
|
|
555
|
+
* Handle TOOL_CALL_START event.
|
|
556
|
+
*
|
|
557
|
+
* Creates a new InternalToolCallState entry in the toolCalls Map and appends
|
|
558
|
+
* a ToolCallPart to the UIMessage. Duplicate toolCallId is a no-op.
|
|
559
|
+
*
|
|
560
|
+
* CRITICAL: This MUST be received before any TOOL_CALL_ARGS for the same
|
|
561
|
+
* toolCallId. Args for unknown IDs are silently dropped.
|
|
562
|
+
*
|
|
563
|
+
* @see docs/chat-architecture.md#single-shot-tool-call-response — Tool call state transitions
|
|
564
|
+
* @see docs/chat-architecture.md#parallel-tool-calls-single-shot — Parallel tracking by ID
|
|
565
|
+
* @see docs/chat-architecture.md#adapter-contract — Ordering requirements
|
|
517
566
|
*/
|
|
518
567
|
private handleToolCallStartEvent(
|
|
519
568
|
chunk: Extract<StreamChunk, { type: 'TOOL_CALL_START' }>,
|
|
520
569
|
): void {
|
|
521
|
-
|
|
522
|
-
this.hasToolCallsSinceTextStart = true
|
|
570
|
+
this.ensureAssistantMessage()
|
|
523
571
|
|
|
524
572
|
const toolCallId = chunk.toolCallId
|
|
525
573
|
const existingToolCall = this.toolCalls.get(toolCallId)
|
|
@@ -566,7 +614,16 @@ export class StreamProcessor {
|
|
|
566
614
|
}
|
|
567
615
|
|
|
568
616
|
/**
|
|
569
|
-
* Handle TOOL_CALL_ARGS event
|
|
617
|
+
* Handle TOOL_CALL_ARGS event.
|
|
618
|
+
*
|
|
619
|
+
* Appends the delta to the tool call's accumulated arguments string.
|
|
620
|
+
* Transitions state from awaiting-input → input-streaming on first non-empty delta.
|
|
621
|
+
* Attempts partial JSON parse on each update for UI preview.
|
|
622
|
+
*
|
|
623
|
+
* If toolCallId is not found in the Map (no preceding TOOL_CALL_START),
|
|
624
|
+
* this event is silently dropped.
|
|
625
|
+
*
|
|
626
|
+
* @see docs/chat-architecture.md#single-shot-tool-call-response — Step-by-step tool call processing
|
|
570
627
|
*/
|
|
571
628
|
private handleToolCallArgsEvent(
|
|
572
629
|
chunk: Extract<StreamChunk, { type: 'TOOL_CALL_ARGS' }>,
|
|
@@ -616,15 +673,53 @@ export class StreamProcessor {
|
|
|
616
673
|
}
|
|
617
674
|
|
|
618
675
|
/**
|
|
619
|
-
* Handle TOOL_CALL_END event
|
|
676
|
+
* Handle TOOL_CALL_END event — authoritative signal that a tool call's input is finalized.
|
|
677
|
+
*
|
|
678
|
+
* This event has a DUAL ROLE:
|
|
679
|
+
* - Without `result`: Signals arguments are done (from adapter). Transitions to input-complete.
|
|
680
|
+
* - With `result`: Signals tool was executed and result is available (from TextEngine).
|
|
681
|
+
* Creates both output on the tool-call part AND a tool-result part.
|
|
682
|
+
*
|
|
683
|
+
* If `input` is provided, it overrides the accumulated string parse as the
|
|
684
|
+
* canonical parsed arguments.
|
|
685
|
+
*
|
|
686
|
+
* @see docs/chat-architecture.md#tool-results-and-the-tool_call_end-dual-role — Full explanation
|
|
687
|
+
* @see docs/chat-architecture.md#single-shot-tool-call-response — End-to-end flow
|
|
620
688
|
*/
|
|
621
689
|
private handleToolCallEndEvent(
|
|
622
690
|
chunk: Extract<StreamChunk, { type: 'TOOL_CALL_END' }>,
|
|
623
691
|
): void {
|
|
624
|
-
|
|
692
|
+
// Transition the tool call to input-complete (the authoritative completion signal)
|
|
693
|
+
const existingToolCall = this.toolCalls.get(chunk.toolCallId)
|
|
694
|
+
if (existingToolCall && existingToolCall.state !== 'input-complete') {
|
|
695
|
+
const index = this.toolCallOrder.indexOf(chunk.toolCallId)
|
|
696
|
+
this.completeToolCall(index, existingToolCall)
|
|
697
|
+
// If TOOL_CALL_END provides parsed input, use it as the canonical parsed
|
|
698
|
+
// arguments (overrides the accumulated string parse from completeToolCall)
|
|
699
|
+
if (chunk.input !== undefined) {
|
|
700
|
+
existingToolCall.parsedArguments = chunk.input
|
|
701
|
+
}
|
|
702
|
+
}
|
|
625
703
|
|
|
626
|
-
// Update UIMessage if we have a current assistant message
|
|
704
|
+
// Update UIMessage if we have a current assistant message and a result
|
|
627
705
|
if (this.currentAssistantMessageId && chunk.result) {
|
|
706
|
+
const state: ToolResultState = 'complete'
|
|
707
|
+
|
|
708
|
+
// Step 1: Update the tool-call part's output field (for UI consistency
|
|
709
|
+
// with client tools — see GitHub issue #176)
|
|
710
|
+
let output: unknown
|
|
711
|
+
try {
|
|
712
|
+
output = JSON.parse(chunk.result)
|
|
713
|
+
} catch {
|
|
714
|
+
output = chunk.result
|
|
715
|
+
}
|
|
716
|
+
this.messages = updateToolCallWithOutput(
|
|
717
|
+
this.messages,
|
|
718
|
+
chunk.toolCallId,
|
|
719
|
+
output,
|
|
720
|
+
)
|
|
721
|
+
|
|
722
|
+
// Step 2: Create/update the tool-result part (for LLM conversation history)
|
|
628
723
|
this.messages = updateToolResultPart(
|
|
629
724
|
this.messages,
|
|
630
725
|
this.currentAssistantMessageId,
|
|
@@ -637,7 +732,14 @@ export class StreamProcessor {
|
|
|
637
732
|
}
|
|
638
733
|
|
|
639
734
|
/**
|
|
640
|
-
* Handle RUN_FINISHED event
|
|
735
|
+
* Handle RUN_FINISHED event.
|
|
736
|
+
*
|
|
737
|
+
* Records the finishReason and calls completeAllToolCalls() as a safety net
|
|
738
|
+
* to force-complete any tool calls that didn't receive an explicit TOOL_CALL_END.
|
|
739
|
+
* This handles cases like aborted streams or adapter bugs.
|
|
740
|
+
*
|
|
741
|
+
* @see docs/chat-architecture.md#single-shot-tool-call-response — finishReason semantics
|
|
742
|
+
* @see docs/chat-architecture.md#adapter-contract — Why RUN_FINISHED is mandatory
|
|
641
743
|
*/
|
|
642
744
|
private handleRunFinishedEvent(
|
|
643
745
|
chunk: Extract<StreamChunk, { type: 'RUN_FINISHED' }>,
|
|
@@ -653,33 +755,26 @@ export class StreamProcessor {
|
|
|
653
755
|
private handleRunErrorEvent(
|
|
654
756
|
chunk: Extract<StreamChunk, { type: 'RUN_ERROR' }>,
|
|
655
757
|
): void {
|
|
758
|
+
this.hasError = true
|
|
759
|
+
this.ensureAssistantMessage()
|
|
656
760
|
// Emit error event
|
|
657
761
|
this.events.onError?.(new Error(chunk.error.message || 'An error occurred'))
|
|
658
762
|
}
|
|
659
763
|
|
|
660
764
|
/**
|
|
661
|
-
* Handle STEP_FINISHED event (for thinking/reasoning content)
|
|
765
|
+
* Handle STEP_FINISHED event (for thinking/reasoning content).
|
|
766
|
+
*
|
|
767
|
+
* Accumulates delta into thinkingContent and updates a single ThinkingPart
|
|
768
|
+
* in the UIMessage (replaced in-place, not appended).
|
|
769
|
+
*
|
|
770
|
+
* @see docs/chat-architecture.md#thinkingreasoning-content — Thinking flow
|
|
662
771
|
*/
|
|
663
772
|
private handleStepFinishedEvent(
|
|
664
773
|
chunk: Extract<StreamChunk, { type: 'STEP_FINISHED' }>,
|
|
665
774
|
): void {
|
|
666
|
-
|
|
667
|
-
let nextThinking = previous
|
|
668
|
-
|
|
669
|
-
// Prefer delta over content
|
|
670
|
-
if (chunk.delta && chunk.delta !== '') {
|
|
671
|
-
nextThinking = previous + chunk.delta
|
|
672
|
-
} else if (chunk.content && chunk.content !== '') {
|
|
673
|
-
if (chunk.content.startsWith(previous)) {
|
|
674
|
-
nextThinking = chunk.content
|
|
675
|
-
} else if (previous.startsWith(chunk.content)) {
|
|
676
|
-
nextThinking = previous
|
|
677
|
-
} else {
|
|
678
|
-
nextThinking = previous + chunk.content
|
|
679
|
-
}
|
|
680
|
-
}
|
|
775
|
+
this.ensureAssistantMessage()
|
|
681
776
|
|
|
682
|
-
this.thinkingContent
|
|
777
|
+
this.thinkingContent += chunk.delta
|
|
683
778
|
|
|
684
779
|
// Update UIMessage
|
|
685
780
|
if (this.currentAssistantMessageId) {
|
|
@@ -699,9 +794,14 @@ export class StreamProcessor {
|
|
|
699
794
|
}
|
|
700
795
|
|
|
701
796
|
/**
|
|
702
|
-
* Handle CUSTOM event
|
|
703
|
-
*
|
|
704
|
-
*
|
|
797
|
+
* Handle CUSTOM event.
|
|
798
|
+
*
|
|
799
|
+
* Handles special custom events emitted by the TextEngine (not adapters):
|
|
800
|
+
* - 'tool-input-available': Client tool needs execution. Fires onToolCall.
|
|
801
|
+
* - 'approval-requested': Tool needs user approval. Updates tool-call part
|
|
802
|
+
* state and fires onApprovalRequest.
|
|
803
|
+
*
|
|
804
|
+
* @see docs/chat-architecture.md#client-tools-and-approval-flows — Full flow details
|
|
705
805
|
*/
|
|
706
806
|
private handleCustomEvent(
|
|
707
807
|
chunk: Extract<StreamChunk, { type: 'CUSTOM' }>,
|
|
@@ -753,29 +853,13 @@ export class StreamProcessor {
|
|
|
753
853
|
}
|
|
754
854
|
|
|
755
855
|
/**
|
|
756
|
-
*
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
if (chunk.content !== undefined) {
|
|
764
|
-
if (chunk.content.length < previous.length) {
|
|
765
|
-
return true
|
|
766
|
-
}
|
|
767
|
-
if (
|
|
768
|
-
!chunk.content.startsWith(previous) &&
|
|
769
|
-
!previous.startsWith(chunk.content)
|
|
770
|
-
) {
|
|
771
|
-
return true
|
|
772
|
-
}
|
|
773
|
-
}
|
|
774
|
-
return false
|
|
775
|
-
}
|
|
776
|
-
|
|
777
|
-
/**
|
|
778
|
-
* Complete all tool calls
|
|
856
|
+
* Complete all tool calls — safety net for stream termination.
|
|
857
|
+
*
|
|
858
|
+
* Called by RUN_FINISHED and finalizeStream(). Force-transitions any tool call
|
|
859
|
+
* not yet in input-complete state. Handles cases where TOOL_CALL_END was
|
|
860
|
+
* missed (adapter bug, network error, aborted stream).
|
|
861
|
+
*
|
|
862
|
+
* @see docs/chat-architecture.md#single-shot-tool-call-response — Safety net behavior
|
|
779
863
|
*/
|
|
780
864
|
private completeAllToolCalls(): void {
|
|
781
865
|
this.toolCalls.forEach((toolCall, id) => {
|
|
@@ -823,7 +907,13 @@ export class StreamProcessor {
|
|
|
823
907
|
}
|
|
824
908
|
|
|
825
909
|
/**
|
|
826
|
-
* Emit pending text update
|
|
910
|
+
* Emit pending text update.
|
|
911
|
+
*
|
|
912
|
+
* Calls updateTextPart() which has critical append-vs-replace logic:
|
|
913
|
+
* - If last UIMessage part is TextPart → replaces its content (same segment).
|
|
914
|
+
* - If last part is anything else → pushes new TextPart (new segment after tools).
|
|
915
|
+
*
|
|
916
|
+
* @see docs/chat-architecture.md#uimessage-part-ordering-invariants — Replace vs. push logic
|
|
827
917
|
*/
|
|
828
918
|
private emitTextUpdate(): void {
|
|
829
919
|
this.lastEmittedText = this.currentSegmentText
|
|
@@ -853,10 +943,16 @@ export class StreamProcessor {
|
|
|
853
943
|
}
|
|
854
944
|
|
|
855
945
|
/**
|
|
856
|
-
* Finalize the stream
|
|
946
|
+
* Finalize the stream — complete all pending operations.
|
|
947
|
+
*
|
|
948
|
+
* Called when the async iterable ends (stream closed). Acts as the final
|
|
949
|
+
* safety net: completes any remaining tool calls, flushes un-emitted text,
|
|
950
|
+
* and fires onStreamEnd.
|
|
951
|
+
*
|
|
952
|
+
* @see docs/chat-architecture.md#single-shot-text-response — Finalization step
|
|
857
953
|
*/
|
|
858
954
|
finalizeStream(): void {
|
|
859
|
-
//
|
|
955
|
+
// Safety net: complete any remaining tool calls (e.g. on network errors / aborted streams)
|
|
860
956
|
this.completeAllToolCalls()
|
|
861
957
|
|
|
862
958
|
// Emit any pending text if not already emitted
|
|
@@ -864,7 +960,25 @@ export class StreamProcessor {
|
|
|
864
960
|
this.emitTextUpdate()
|
|
865
961
|
}
|
|
866
962
|
|
|
867
|
-
//
|
|
963
|
+
// Remove the assistant message if it only contains whitespace text
|
|
964
|
+
// (no tool calls, no meaningful content). This handles models like Gemini
|
|
965
|
+
// that sometimes return just "\n" during auto-continuation.
|
|
966
|
+
// Preserve the message on errors so the UI can show error state.
|
|
967
|
+
if (this.currentAssistantMessageId && !this.hasError) {
|
|
968
|
+
const assistantMessage = this.messages.find(
|
|
969
|
+
(m) => m.id === this.currentAssistantMessageId,
|
|
970
|
+
)
|
|
971
|
+
if (assistantMessage && this.isWhitespaceOnlyMessage(assistantMessage)) {
|
|
972
|
+
this.messages = this.messages.filter(
|
|
973
|
+
(m) => m.id !== this.currentAssistantMessageId,
|
|
974
|
+
)
|
|
975
|
+
this.emitMessagesChange()
|
|
976
|
+
this.currentAssistantMessageId = null
|
|
977
|
+
return
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
// Emit stream end event (only if a message was actually created)
|
|
868
982
|
if (this.currentAssistantMessageId) {
|
|
869
983
|
const assistantMessage = this.messages.find(
|
|
870
984
|
(m) => m.id === this.currentAssistantMessageId,
|
|
@@ -949,8 +1063,8 @@ export class StreamProcessor {
|
|
|
949
1063
|
this.toolCalls.clear()
|
|
950
1064
|
this.toolCallOrder = []
|
|
951
1065
|
this.finishReason = null
|
|
1066
|
+
this.hasError = false
|
|
952
1067
|
this.isDone = false
|
|
953
|
-
this.hasToolCallsSinceTextStart = false
|
|
954
1068
|
this.chunkStrategy.reset?.()
|
|
955
1069
|
}
|
|
956
1070
|
|
|
@@ -963,6 +1077,17 @@ export class StreamProcessor {
|
|
|
963
1077
|
this.currentAssistantMessageId = null
|
|
964
1078
|
}
|
|
965
1079
|
|
|
1080
|
+
/**
|
|
1081
|
+
* Check if a message contains only whitespace text and no other meaningful parts
|
|
1082
|
+
* (no tool calls, tool results, thinking, etc.)
|
|
1083
|
+
*/
|
|
1084
|
+
private isWhitespaceOnlyMessage(message: UIMessage): boolean {
|
|
1085
|
+
if (message.parts.length === 0) return false
|
|
1086
|
+
return message.parts.every(
|
|
1087
|
+
(part) => part.type === 'text' && part.content.trim() === '',
|
|
1088
|
+
)
|
|
1089
|
+
}
|
|
1090
|
+
|
|
966
1091
|
/**
|
|
967
1092
|
* Replay a recording through the processor
|
|
968
1093
|
*/
|
|
@@ -295,10 +295,7 @@ export async function executeToolCalls(
|
|
|
295
295
|
|
|
296
296
|
// Parse arguments, throwing error if invalid JSON
|
|
297
297
|
let input: unknown = {}
|
|
298
|
-
|
|
299
|
-
// Normalize "null" to "{}" — can occur when the model streams a tool_use
|
|
300
|
-
// block with no input_json_delta events (Anthropic adapter edge case)
|
|
301
|
-
if (argsStr === 'null') argsStr = '{}'
|
|
298
|
+
const argsStr = toolCall.function.arguments.trim() || '{}'
|
|
302
299
|
if (argsStr) {
|
|
303
300
|
try {
|
|
304
301
|
input = JSON.parse(argsStr)
|
package/src/types.ts
CHANGED
|
@@ -791,7 +791,7 @@ export interface TextMessageContentEvent extends BaseAGUIEvent {
|
|
|
791
791
|
messageId: string
|
|
792
792
|
/** The incremental content token */
|
|
793
793
|
delta: string
|
|
794
|
-
/** Full accumulated content so far */
|
|
794
|
+
/** Full accumulated content so far (optional, for debugging) */
|
|
795
795
|
content?: string
|
|
796
796
|
}
|
|
797
797
|
|
|
@@ -864,8 +864,8 @@ export interface StepFinishedEvent extends BaseAGUIEvent {
|
|
|
864
864
|
/** Step identifier */
|
|
865
865
|
stepId: string
|
|
866
866
|
/** Incremental thinking content */
|
|
867
|
-
delta
|
|
868
|
-
/** Full accumulated thinking content */
|
|
867
|
+
delta: string
|
|
868
|
+
/** Full accumulated thinking content (optional, for debugging) */
|
|
869
869
|
content?: string
|
|
870
870
|
}
|
|
871
871
|
|