@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.
@@ -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 detection
115
+ * - Tool call completion via TOOL_CALL_END events
111
116
  *
112
- * Tool call completion is detected when:
113
- * 1. A new tool call starts at a different index
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
- * Start streaming a new assistant message
217
- * Returns the message ID
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
- startAssistantMessage(): string {
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, TEXT_MESSAGE_START, TEXT_MESSAGE_END, STEP_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 TEXT_MESSAGE_CONTENT event
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
- // Content arriving means all current tool calls are complete
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
- const currentText = this.currentSegmentText
483
- let nextText = currentText
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
- chunkPortion,
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
- // Mark that we've seen tool calls since the last text segment
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
- const state: ToolResultState = 'complete'
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
- const previous = this.thinkingContent
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 = nextThinking
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
- * Handles special custom events like 'tool-input-available' for client-side tool execution
704
- * and 'approval-requested' for tool approval flows
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
- * Detect if an incoming content chunk represents a NEW text segment
757
- */
758
- private isNewTextSegment(
759
- chunk: Extract<StreamChunk, { type: 'TEXT_MESSAGE_CONTENT' }>,
760
- previous: string,
761
- ): boolean {
762
- // Check if content is present (delta is always defined but may be empty string)
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 - complete all pending operations
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
- // Complete any remaining tool calls
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
- // Emit stream end event
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
- let argsStr = toolCall.function.arguments.trim() || '{}'
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?: string
868
- /** Full accumulated thinking content */
867
+ delta: string
868
+ /** Full accumulated thinking content (optional, for debugging) */
869
869
  content?: string
870
870
  }
871
871