@tanstack/ai 0.8.0 → 0.9.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.
@@ -5,13 +5,11 @@ import { ImmediateStrategy } from "./strategies.js";
5
5
  class StreamProcessor {
6
6
  constructor(options = {}) {
7
7
  this.messages = [];
8
- this.currentAssistantMessageId = null;
9
- this.totalTextContent = "";
10
- this.currentSegmentText = "";
11
- this.lastEmittedText = "";
12
- this.thinkingContent = "";
13
- this.toolCalls = /* @__PURE__ */ new Map();
14
- this.toolCallOrder = [];
8
+ this.messageStates = /* @__PURE__ */ new Map();
9
+ this.activeMessageIds = /* @__PURE__ */ new Set();
10
+ this.toolCallToMessage = /* @__PURE__ */ new Map();
11
+ this.pendingManualMessageId = null;
12
+ this.activeRuns = /* @__PURE__ */ new Set();
15
13
  this.finishReason = null;
16
14
  this.hasError = false;
17
15
  this.isDone = false;
@@ -81,15 +79,16 @@ class StreamProcessor {
81
79
  */
82
80
  prepareAssistantMessage() {
83
81
  this.resetStreamState();
84
- this.currentAssistantMessageId = null;
85
82
  }
86
83
  /**
87
84
  * @deprecated Use prepareAssistantMessage() instead. This eagerly creates
88
85
  * an assistant message which can cause empty message flicker.
89
86
  */
90
- startAssistantMessage() {
87
+ startAssistantMessage(messageId) {
91
88
  this.prepareAssistantMessage();
92
- return this.ensureAssistantMessage();
89
+ const { messageId: id } = this.ensureAssistantMessage(messageId);
90
+ this.pendingManualMessageId = id;
91
+ return id;
93
92
  }
94
93
  /**
95
94
  * Get the current assistant message ID (if one has been created).
@@ -97,33 +96,13 @@ class StreamProcessor {
97
96
  * has arrived yet.
98
97
  */
99
98
  getCurrentAssistantMessageId() {
100
- return this.currentAssistantMessageId;
101
- }
102
- /**
103
- * Lazily create the assistant message if it hasn't been created yet.
104
- * Called by content handlers on the first content-bearing chunk.
105
- * Returns the message ID.
106
- *
107
- * Content-bearing chunks that trigger this:
108
- * TEXT_MESSAGE_CONTENT, TOOL_CALL_START, STEP_FINISHED, RUN_ERROR.
109
- *
110
- * @see docs/chat-architecture.md#streamprocessor-internal-state — Lazy creation pattern
111
- */
112
- ensureAssistantMessage() {
113
- if (this.currentAssistantMessageId) {
114
- return this.currentAssistantMessageId;
99
+ let lastId = null;
100
+ for (const [id, state] of this.messageStates) {
101
+ if (state.role === "assistant") {
102
+ lastId = id;
103
+ }
115
104
  }
116
- const assistantMessage = {
117
- id: generateMessageId(),
118
- role: "assistant",
119
- parts: [],
120
- createdAt: /* @__PURE__ */ new Date()
121
- };
122
- this.currentAssistantMessageId = assistantMessage.id;
123
- this.messages = [...this.messages, assistantMessage];
124
- this.events.onStreamStart?.();
125
- this.emitMessagesChange();
126
- return assistantMessage.id;
105
+ return lastId;
127
106
  }
128
107
  /**
129
108
  * Add a tool result (called by client after handling onToolCall)
@@ -219,7 +198,10 @@ class StreamProcessor {
219
198
  */
220
199
  clearMessages() {
221
200
  this.messages = [];
222
- this.currentAssistantMessageId = null;
201
+ this.messageStates.clear();
202
+ this.activeMessageIds.clear();
203
+ this.toolCallToMessage.clear();
204
+ this.pendingManualMessageId = null;
223
205
  this.emitMessagesChange();
224
206
  }
225
207
  // ============================================
@@ -247,7 +229,7 @@ class StreamProcessor {
247
229
  *
248
230
  * Central dispatch for all AG-UI events. Each event type maps to a specific
249
231
  * handler. Events not listed in the switch are intentionally ignored
250
- * (RUN_STARTED, TEXT_MESSAGE_END, STEP_STARTED, STATE_SNAPSHOT, STATE_DELTA).
232
+ * (RUN_STARTED, STEP_STARTED, STATE_DELTA).
251
233
  *
252
234
  * @see docs/chat-architecture.md#adapter-contract — Expected event types and ordering
253
235
  */
@@ -262,11 +244,14 @@ class StreamProcessor {
262
244
  switch (chunk.type) {
263
245
  // AG-UI Events
264
246
  case "TEXT_MESSAGE_START":
265
- this.handleTextMessageStartEvent();
247
+ this.handleTextMessageStartEvent(chunk);
266
248
  break;
267
249
  case "TEXT_MESSAGE_CONTENT":
268
250
  this.handleTextMessageContentEvent(chunk);
269
251
  break;
252
+ case "TEXT_MESSAGE_END":
253
+ this.handleTextMessageEndEvent(chunk);
254
+ break;
270
255
  case "TOOL_CALL_START":
271
256
  this.handleToolCallStartEvent(chunk);
272
257
  break;
@@ -285,28 +270,190 @@ class StreamProcessor {
285
270
  case "STEP_FINISHED":
286
271
  this.handleStepFinishedEvent(chunk);
287
272
  break;
273
+ case "MESSAGES_SNAPSHOT":
274
+ this.handleMessagesSnapshotEvent(chunk);
275
+ break;
288
276
  case "CUSTOM":
289
277
  this.handleCustomEvent(chunk);
290
278
  break;
279
+ case "RUN_STARTED":
280
+ this.handleRunStartedEvent(chunk);
281
+ break;
291
282
  }
292
283
  }
284
+ // ============================================
285
+ // Per-Message State Helpers
286
+ // ============================================
293
287
  /**
294
- * Handle TEXT_MESSAGE_START event — marks the beginning of a new text segment.
295
- * Resets segment accumulation so text after tool calls starts fresh.
296
- *
297
- * This is the key mechanism for multi-segment text (text before and after tool
298
- * calls becoming separate TextParts). Without this reset, all text would merge
299
- * into a single TextPart and tool-call interleaving would be lost.
288
+ * Create a new MessageStreamState for a message
289
+ */
290
+ createMessageState(messageId, role) {
291
+ const state = {
292
+ id: messageId,
293
+ role,
294
+ totalTextContent: "",
295
+ currentSegmentText: "",
296
+ lastEmittedText: "",
297
+ thinkingContent: "",
298
+ toolCalls: /* @__PURE__ */ new Map(),
299
+ toolCallOrder: [],
300
+ hasToolCallsSinceTextStart: false,
301
+ isComplete: false
302
+ };
303
+ this.messageStates.set(messageId, state);
304
+ return state;
305
+ }
306
+ /**
307
+ * Get the MessageStreamState for a message
308
+ */
309
+ getMessageState(messageId) {
310
+ return this.messageStates.get(messageId);
311
+ }
312
+ /**
313
+ * Get the most recent active assistant message ID.
314
+ * Used as fallback for events that don't include a messageId.
315
+ */
316
+ getActiveAssistantMessageId() {
317
+ const ids = Array.from(this.activeMessageIds);
318
+ for (let i = ids.length - 1; i >= 0; i--) {
319
+ const id = ids[i];
320
+ const state = this.messageStates.get(id);
321
+ if (state && state.role === "assistant") {
322
+ return id;
323
+ }
324
+ }
325
+ return null;
326
+ }
327
+ /**
328
+ * Ensure an active assistant message exists, creating one if needed.
329
+ * Used for backward compat when events arrive without prior TEXT_MESSAGE_START.
300
330
  *
301
- * @see docs/chat-architecture.md#single-shot-text-response — Step-by-step text processing
302
- * @see docs/chat-architecture.md#text-then-tool-interleaving-single-shot — Multi-segment text
331
+ * On reconnect/resume, a TEXT_MESSAGE_CONTENT may arrive for a message that
332
+ * already exists in this.messages (e.g. from initialMessages or a prior
333
+ * MESSAGES_SNAPSHOT) but whose transient state was cleared. In that case we
334
+ * hydrate state from the existing message rather than creating a duplicate.
303
335
  */
304
- handleTextMessageStartEvent() {
305
- if (this.currentSegmentText !== this.lastEmittedText) {
306
- this.emitTextUpdate();
336
+ ensureAssistantMessage(preferredId) {
337
+ if (preferredId) {
338
+ const state2 = this.getMessageState(preferredId);
339
+ if (state2) return { messageId: preferredId, state: state2 };
307
340
  }
308
- this.currentSegmentText = "";
309
- this.lastEmittedText = "";
341
+ const activeId = this.getActiveAssistantMessageId();
342
+ if (activeId) {
343
+ const state2 = this.getMessageState(activeId);
344
+ return { messageId: activeId, state: state2 };
345
+ }
346
+ if (preferredId) {
347
+ const existingMsg = this.messages.find((m) => m.id === preferredId);
348
+ if (existingMsg) {
349
+ const state2 = this.createMessageState(preferredId, existingMsg.role);
350
+ this.activeMessageIds.add(preferredId);
351
+ const lastPart = existingMsg.parts.length > 0 ? existingMsg.parts[existingMsg.parts.length - 1] : null;
352
+ if (lastPart && lastPart.type === "text") {
353
+ state2.currentSegmentText = lastPart.content;
354
+ state2.lastEmittedText = lastPart.content;
355
+ state2.totalTextContent = lastPart.content;
356
+ }
357
+ return { messageId: preferredId, state: state2 };
358
+ }
359
+ }
360
+ const id = preferredId || generateMessageId();
361
+ const assistantMessage = {
362
+ id,
363
+ role: "assistant",
364
+ parts: [],
365
+ createdAt: /* @__PURE__ */ new Date()
366
+ };
367
+ this.messages = [...this.messages, assistantMessage];
368
+ const state = this.createMessageState(id, "assistant");
369
+ this.activeMessageIds.add(id);
370
+ this.pendingManualMessageId = id;
371
+ this.events.onStreamStart?.();
372
+ this.emitMessagesChange();
373
+ return { messageId: id, state };
374
+ }
375
+ // ============================================
376
+ // Event Handlers
377
+ // ============================================
378
+ /**
379
+ * Handle TEXT_MESSAGE_START event
380
+ */
381
+ handleTextMessageStartEvent(chunk) {
382
+ const { messageId, role } = chunk;
383
+ const uiRole = role === "tool" ? "assistant" : role;
384
+ if (this.pendingManualMessageId) {
385
+ const pendingId = this.pendingManualMessageId;
386
+ this.pendingManualMessageId = null;
387
+ if (pendingId !== messageId) {
388
+ this.messages = this.messages.map(
389
+ (msg) => msg.id === pendingId ? { ...msg, id: messageId } : msg
390
+ );
391
+ const existingState = this.messageStates.get(pendingId);
392
+ if (existingState) {
393
+ existingState.id = messageId;
394
+ this.messageStates.delete(pendingId);
395
+ this.messageStates.set(messageId, existingState);
396
+ }
397
+ this.activeMessageIds.delete(pendingId);
398
+ this.activeMessageIds.add(messageId);
399
+ }
400
+ if (!this.messageStates.has(messageId)) {
401
+ this.createMessageState(messageId, uiRole);
402
+ this.activeMessageIds.add(messageId);
403
+ }
404
+ this.emitMessagesChange();
405
+ return;
406
+ }
407
+ const existingMsg = this.messages.find((m) => m.id === messageId);
408
+ if (existingMsg) {
409
+ this.activeMessageIds.add(messageId);
410
+ if (!this.messageStates.has(messageId)) {
411
+ this.createMessageState(messageId, uiRole);
412
+ } else {
413
+ const existingState = this.messageStates.get(messageId);
414
+ if (existingState.hasToolCallsSinceTextStart) {
415
+ if (existingState.currentSegmentText !== existingState.lastEmittedText) {
416
+ this.emitTextUpdateForMessage(messageId);
417
+ }
418
+ existingState.currentSegmentText = "";
419
+ existingState.lastEmittedText = "";
420
+ existingState.hasToolCallsSinceTextStart = false;
421
+ }
422
+ }
423
+ return;
424
+ }
425
+ const newMessage = {
426
+ id: messageId,
427
+ role: uiRole,
428
+ parts: [],
429
+ createdAt: /* @__PURE__ */ new Date()
430
+ };
431
+ this.messages = [...this.messages, newMessage];
432
+ this.createMessageState(messageId, uiRole);
433
+ this.activeMessageIds.add(messageId);
434
+ this.events.onStreamStart?.();
435
+ this.emitMessagesChange();
436
+ }
437
+ /**
438
+ * Handle TEXT_MESSAGE_END event
439
+ */
440
+ handleTextMessageEndEvent(chunk) {
441
+ const { messageId } = chunk;
442
+ const state = this.getMessageState(messageId);
443
+ if (!state) return;
444
+ if (state.isComplete) return;
445
+ if (state.currentSegmentText !== state.lastEmittedText) {
446
+ this.emitTextUpdateForMessage(messageId);
447
+ }
448
+ this.completeAllToolCallsForMessage(messageId);
449
+ }
450
+ /**
451
+ * Handle MESSAGES_SNAPSHOT event
452
+ */
453
+ handleMessagesSnapshotEvent(chunk) {
454
+ this.resetStreamState();
455
+ this.messages = [...chunk.messages];
456
+ this.emitMessagesChange();
310
457
  }
311
458
  /**
312
459
  * Handle TEXT_MESSAGE_CONTENT event.
@@ -320,15 +467,42 @@ class StreamProcessor {
320
467
  * @see docs/chat-architecture.md#uimessage-part-ordering-invariants — Replace vs. push logic
321
468
  */
322
469
  handleTextMessageContentEvent(chunk) {
323
- this.ensureAssistantMessage();
324
- this.currentSegmentText += chunk.delta;
325
- this.totalTextContent += chunk.delta;
470
+ const { messageId, state } = this.ensureAssistantMessage(chunk.messageId);
471
+ this.completeAllToolCallsForMessage(messageId);
472
+ const previousSegment = state.currentSegmentText;
473
+ const isNewSegment = state.hasToolCallsSinceTextStart && previousSegment.length > 0 && this.isNewTextSegment(chunk, previousSegment);
474
+ if (isNewSegment) {
475
+ if (previousSegment !== state.lastEmittedText) {
476
+ this.emitTextUpdateForMessage(messageId);
477
+ }
478
+ state.currentSegmentText = "";
479
+ state.lastEmittedText = "";
480
+ state.hasToolCallsSinceTextStart = false;
481
+ }
482
+ const currentText = state.currentSegmentText;
483
+ let nextText = currentText;
484
+ const delta = chunk.delta || "";
485
+ if (delta !== "") {
486
+ nextText = currentText + delta;
487
+ } else if (chunk.content !== void 0 && chunk.content !== "") {
488
+ if (chunk.content.startsWith(currentText)) {
489
+ nextText = chunk.content;
490
+ } else if (currentText.startsWith(chunk.content)) {
491
+ nextText = currentText;
492
+ } else {
493
+ nextText = currentText + chunk.content;
494
+ }
495
+ }
496
+ const textDelta = nextText.slice(currentText.length);
497
+ state.currentSegmentText = nextText;
498
+ state.totalTextContent += textDelta;
499
+ const chunkPortion = chunk.delta || chunk.content || "";
326
500
  const shouldEmit = this.chunkStrategy.shouldEmit(
327
- chunk.delta,
328
- this.currentSegmentText
501
+ chunkPortion,
502
+ state.currentSegmentText
329
503
  );
330
- if (shouldEmit && this.currentSegmentText !== this.lastEmittedText) {
331
- this.emitTextUpdate();
504
+ if (shouldEmit && state.currentSegmentText !== state.lastEmittedText) {
505
+ this.emitTextUpdateForMessage(messageId);
332
506
  }
333
507
  }
334
508
  /**
@@ -345,9 +519,13 @@ class StreamProcessor {
345
519
  * @see docs/chat-architecture.md#adapter-contract — Ordering requirements
346
520
  */
347
521
  handleToolCallStartEvent(chunk) {
348
- this.ensureAssistantMessage();
522
+ const targetMessageId = chunk.parentMessageId ?? this.getActiveAssistantMessageId();
523
+ const { messageId, state } = this.ensureAssistantMessage(
524
+ targetMessageId ?? void 0
525
+ );
526
+ state.hasToolCallsSinceTextStart = true;
349
527
  const toolCallId = chunk.toolCallId;
350
- const existingToolCall = this.toolCalls.get(toolCallId);
528
+ const existingToolCall = state.toolCalls.get(toolCallId);
351
529
  if (!existingToolCall) {
352
530
  const initialState = "awaiting-input";
353
531
  const newToolCall = {
@@ -356,29 +534,24 @@ class StreamProcessor {
356
534
  arguments: "",
357
535
  state: initialState,
358
536
  parsedArguments: void 0,
359
- index: chunk.index ?? this.toolCalls.size
537
+ index: chunk.index ?? state.toolCalls.size
360
538
  };
361
- this.toolCalls.set(toolCallId, newToolCall);
362
- this.toolCallOrder.push(toolCallId);
363
- if (this.currentAssistantMessageId) {
364
- this.messages = updateToolCallPart(
365
- this.messages,
366
- this.currentAssistantMessageId,
367
- {
368
- id: chunk.toolCallId,
369
- name: chunk.toolName,
370
- arguments: "",
371
- state: initialState
372
- }
373
- );
374
- this.emitMessagesChange();
375
- this.events.onToolCallStateChange?.(
376
- this.currentAssistantMessageId,
377
- chunk.toolCallId,
378
- initialState,
379
- ""
380
- );
381
- }
539
+ state.toolCalls.set(toolCallId, newToolCall);
540
+ state.toolCallOrder.push(toolCallId);
541
+ this.toolCallToMessage.set(toolCallId, messageId);
542
+ this.messages = updateToolCallPart(this.messages, messageId, {
543
+ id: chunk.toolCallId,
544
+ name: chunk.toolName,
545
+ arguments: "",
546
+ state: initialState
547
+ });
548
+ this.emitMessagesChange();
549
+ this.events.onToolCallStateChange?.(
550
+ messageId,
551
+ chunk.toolCallId,
552
+ initialState,
553
+ ""
554
+ );
382
555
  }
383
556
  }
384
557
  /**
@@ -395,36 +568,33 @@ class StreamProcessor {
395
568
  */
396
569
  handleToolCallArgsEvent(chunk) {
397
570
  const toolCallId = chunk.toolCallId;
398
- const existingToolCall = this.toolCalls.get(toolCallId);
399
- if (existingToolCall) {
400
- const wasAwaitingInput = existingToolCall.state === "awaiting-input";
401
- existingToolCall.arguments += chunk.delta || "";
402
- if (wasAwaitingInput && chunk.delta) {
403
- existingToolCall.state = "input-streaming";
404
- }
405
- existingToolCall.parsedArguments = this.jsonParser.parse(
406
- existingToolCall.arguments
407
- );
408
- if (this.currentAssistantMessageId) {
409
- this.messages = updateToolCallPart(
410
- this.messages,
411
- this.currentAssistantMessageId,
412
- {
413
- id: existingToolCall.id,
414
- name: existingToolCall.name,
415
- arguments: existingToolCall.arguments,
416
- state: existingToolCall.state
417
- }
418
- );
419
- this.emitMessagesChange();
420
- this.events.onToolCallStateChange?.(
421
- this.currentAssistantMessageId,
422
- existingToolCall.id,
423
- existingToolCall.state,
424
- existingToolCall.arguments
425
- );
426
- }
571
+ const messageId = this.toolCallToMessage.get(toolCallId);
572
+ if (!messageId) return;
573
+ const state = this.getMessageState(messageId);
574
+ if (!state) return;
575
+ const existingToolCall = state.toolCalls.get(toolCallId);
576
+ if (!existingToolCall) return;
577
+ const wasAwaitingInput = existingToolCall.state === "awaiting-input";
578
+ existingToolCall.arguments += chunk.delta || "";
579
+ if (wasAwaitingInput && chunk.delta) {
580
+ existingToolCall.state = "input-streaming";
427
581
  }
582
+ existingToolCall.parsedArguments = this.jsonParser.parse(
583
+ existingToolCall.arguments
584
+ );
585
+ this.messages = updateToolCallPart(this.messages, messageId, {
586
+ id: existingToolCall.id,
587
+ name: existingToolCall.name,
588
+ arguments: existingToolCall.arguments,
589
+ state: existingToolCall.state
590
+ });
591
+ this.emitMessagesChange();
592
+ this.events.onToolCallStateChange?.(
593
+ messageId,
594
+ existingToolCall.id,
595
+ existingToolCall.state,
596
+ existingToolCall.arguments
597
+ );
428
598
  }
429
599
  /**
430
600
  * Handle TOOL_CALL_END event — authoritative signal that a tool call's input is finalized.
@@ -441,16 +611,19 @@ class StreamProcessor {
441
611
  * @see docs/chat-architecture.md#single-shot-tool-call-response — End-to-end flow
442
612
  */
443
613
  handleToolCallEndEvent(chunk) {
444
- const existingToolCall = this.toolCalls.get(chunk.toolCallId);
614
+ const messageId = this.toolCallToMessage.get(chunk.toolCallId);
615
+ if (!messageId) return;
616
+ const msgState = this.getMessageState(messageId);
617
+ if (!msgState) return;
618
+ const existingToolCall = msgState.toolCalls.get(chunk.toolCallId);
445
619
  if (existingToolCall && existingToolCall.state !== "input-complete") {
446
- const index = this.toolCallOrder.indexOf(chunk.toolCallId);
447
- this.completeToolCall(index, existingToolCall);
620
+ const index = msgState.toolCallOrder.indexOf(chunk.toolCallId);
621
+ this.completeToolCall(messageId, index, existingToolCall);
448
622
  if (chunk.input !== void 0) {
449
623
  existingToolCall.parsedArguments = chunk.input;
450
624
  }
451
625
  }
452
- if (this.currentAssistantMessageId && chunk.result) {
453
- const state = "complete";
626
+ if (chunk.result) {
454
627
  let output;
455
628
  try {
456
629
  output = JSON.parse(chunk.result);
@@ -462,36 +635,55 @@ class StreamProcessor {
462
635
  chunk.toolCallId,
463
636
  output
464
637
  );
638
+ const resultState = "complete";
465
639
  this.messages = updateToolResultPart(
466
640
  this.messages,
467
- this.currentAssistantMessageId,
641
+ messageId,
468
642
  chunk.toolCallId,
469
643
  chunk.result,
470
- state
644
+ resultState
471
645
  );
472
646
  this.emitMessagesChange();
473
647
  }
474
648
  }
649
+ /**
650
+ * Handle RUN_STARTED event.
651
+ *
652
+ * Registers the run so that RUN_FINISHED can determine whether other
653
+ * runs are still active before finalizing.
654
+ */
655
+ handleRunStartedEvent(chunk) {
656
+ this.activeRuns.add(chunk.runId);
657
+ }
475
658
  /**
476
659
  * Handle RUN_FINISHED event.
477
660
  *
478
- * Records the finishReason and calls completeAllToolCalls() as a safety net
479
- * to force-complete any tool calls that didn't receive an explicit TOOL_CALL_END.
480
- * This handles cases like aborted streams or adapter bugs.
661
+ * Records the finishReason and removes the run from activeRuns.
662
+ * Only finalizes when no more runs are active, so that concurrent
663
+ * runs don't interfere with each other.
481
664
  *
482
665
  * @see docs/chat-architecture.md#single-shot-tool-call-response — finishReason semantics
483
666
  * @see docs/chat-architecture.md#adapter-contract — Why RUN_FINISHED is mandatory
484
667
  */
485
668
  handleRunFinishedEvent(chunk) {
486
669
  this.finishReason = chunk.finishReason;
487
- this.isDone = true;
488
- this.completeAllToolCalls();
670
+ this.activeRuns.delete(chunk.runId);
671
+ if (this.activeRuns.size === 0) {
672
+ this.isDone = true;
673
+ this.completeAllToolCalls();
674
+ this.finalizeStream();
675
+ }
489
676
  }
490
677
  /**
491
678
  * Handle RUN_ERROR event
492
679
  */
493
680
  handleRunErrorEvent(chunk) {
494
681
  this.hasError = true;
682
+ if (chunk.runId) {
683
+ this.activeRuns.delete(chunk.runId);
684
+ } else {
685
+ this.activeRuns.clear();
686
+ }
495
687
  this.ensureAssistantMessage();
496
688
  this.events.onError?.(new Error(chunk.error.message || "An error occurred"));
497
689
  }
@@ -504,20 +696,30 @@ class StreamProcessor {
504
696
  * @see docs/chat-architecture.md#thinkingreasoning-content — Thinking flow
505
697
  */
506
698
  handleStepFinishedEvent(chunk) {
507
- this.ensureAssistantMessage();
508
- this.thinkingContent += chunk.delta;
509
- if (this.currentAssistantMessageId) {
510
- this.messages = updateThinkingPart(
511
- this.messages,
512
- this.currentAssistantMessageId,
513
- this.thinkingContent
514
- );
515
- this.emitMessagesChange();
516
- this.events.onThinkingUpdate?.(
517
- this.currentAssistantMessageId,
518
- this.thinkingContent
519
- );
699
+ const { messageId, state } = this.ensureAssistantMessage(
700
+ this.getActiveAssistantMessageId() ?? void 0
701
+ );
702
+ const previous = state.thinkingContent;
703
+ let nextThinking = previous;
704
+ if (chunk.delta && chunk.delta !== "") {
705
+ nextThinking = previous + chunk.delta;
706
+ } else if (chunk.content && chunk.content !== "") {
707
+ if (chunk.content.startsWith(previous)) {
708
+ nextThinking = chunk.content;
709
+ } else if (previous.startsWith(chunk.content)) {
710
+ nextThinking = previous;
711
+ } else {
712
+ nextThinking = previous + chunk.content;
713
+ }
520
714
  }
715
+ state.thinkingContent = nextThinking;
716
+ this.messages = updateThinkingPart(
717
+ this.messages,
718
+ messageId,
719
+ state.thinkingContent
720
+ );
721
+ this.emitMessagesChange();
722
+ this.events.onThinkingUpdate?.(messageId, state.thinkingContent);
521
723
  }
522
724
  /**
523
725
  * Handle CUSTOM event.
@@ -530,6 +732,7 @@ class StreamProcessor {
530
732
  * @see docs/chat-architecture.md#client-tools-and-approval-flows — Full flow details
531
733
  */
532
734
  handleCustomEvent(chunk) {
735
+ const messageId = this.getActiveAssistantMessageId();
533
736
  if (chunk.name === "tool-input-available" && chunk.value) {
534
737
  const { toolCallId, toolName, input } = chunk.value;
535
738
  this.events.onToolCall?.({
@@ -541,10 +744,11 @@ class StreamProcessor {
541
744
  }
542
745
  if (chunk.name === "approval-requested" && chunk.value) {
543
746
  const { toolCallId, toolName, input, approval } = chunk.value;
544
- if (this.currentAssistantMessageId) {
747
+ const resolvedMessageId = messageId ?? this.toolCallToMessage.get(toolCallId);
748
+ if (resolvedMessageId) {
545
749
  this.messages = updateToolCallApproval(
546
750
  this.messages,
547
- this.currentAssistantMessageId,
751
+ resolvedMessageId,
548
752
  toolCallId,
549
753
  approval.id
550
754
  );
@@ -563,8 +767,25 @@ class StreamProcessor {
563
767
  this.events.onCustomEvent(chunk.name, chunk.value, { toolCallId });
564
768
  }
565
769
  }
770
+ // ============================================
771
+ // Internal Helpers
772
+ // ============================================
566
773
  /**
567
- * Complete all tool calls — safety net for stream termination.
774
+ * Detect if an incoming content chunk represents a NEW text segment
775
+ */
776
+ isNewTextSegment(chunk, previous) {
777
+ if (chunk.content !== void 0) {
778
+ if (chunk.content.length < previous.length) {
779
+ return true;
780
+ }
781
+ if (!chunk.content.startsWith(previous) && !previous.startsWith(chunk.content)) {
782
+ return true;
783
+ }
784
+ }
785
+ return false;
786
+ }
787
+ /**
788
+ * Complete all tool calls across all active messages — safety net for stream termination.
568
789
  *
569
790
  * Called by RUN_FINISHED and finalizeStream(). Force-transitions any tool call
570
791
  * not yet in input-complete state. Handles cases where TOOL_CALL_END was
@@ -573,41 +794,45 @@ class StreamProcessor {
573
794
  * @see docs/chat-architecture.md#single-shot-tool-call-response — Safety net behavior
574
795
  */
575
796
  completeAllToolCalls() {
576
- this.toolCalls.forEach((toolCall, id) => {
797
+ for (const messageId of this.activeMessageIds) {
798
+ this.completeAllToolCallsForMessage(messageId);
799
+ }
800
+ }
801
+ /**
802
+ * Complete all tool calls for a specific message
803
+ */
804
+ completeAllToolCallsForMessage(messageId) {
805
+ const state = this.getMessageState(messageId);
806
+ if (!state) return;
807
+ state.toolCalls.forEach((toolCall, id) => {
577
808
  if (toolCall.state !== "input-complete") {
578
- const index = this.toolCallOrder.indexOf(id);
579
- this.completeToolCall(index, toolCall);
809
+ const index = state.toolCallOrder.indexOf(id);
810
+ this.completeToolCall(messageId, index, toolCall);
580
811
  }
581
812
  });
582
813
  }
583
814
  /**
584
815
  * Mark a tool call as complete and emit event
585
816
  */
586
- completeToolCall(_index, toolCall) {
817
+ completeToolCall(messageId, _index, toolCall) {
587
818
  toolCall.state = "input-complete";
588
819
  toolCall.parsedArguments = this.jsonParser.parse(toolCall.arguments);
589
- if (this.currentAssistantMessageId) {
590
- this.messages = updateToolCallPart(
591
- this.messages,
592
- this.currentAssistantMessageId,
593
- {
594
- id: toolCall.id,
595
- name: toolCall.name,
596
- arguments: toolCall.arguments,
597
- state: "input-complete"
598
- }
599
- );
600
- this.emitMessagesChange();
601
- this.events.onToolCallStateChange?.(
602
- this.currentAssistantMessageId,
603
- toolCall.id,
604
- "input-complete",
605
- toolCall.arguments
606
- );
607
- }
820
+ this.messages = updateToolCallPart(this.messages, messageId, {
821
+ id: toolCall.id,
822
+ name: toolCall.name,
823
+ arguments: toolCall.arguments,
824
+ state: "input-complete"
825
+ });
826
+ this.emitMessagesChange();
827
+ this.events.onToolCallStateChange?.(
828
+ messageId,
829
+ toolCall.id,
830
+ "input-complete",
831
+ toolCall.arguments
832
+ );
608
833
  }
609
834
  /**
610
- * Emit pending text update.
835
+ * Emit pending text update for a specific message.
611
836
  *
612
837
  * Calls updateTextPart() which has critical append-vs-replace logic:
613
838
  * - If last UIMessage part is TextPart → replaces its content (same segment).
@@ -615,20 +840,17 @@ class StreamProcessor {
615
840
  *
616
841
  * @see docs/chat-architecture.md#uimessage-part-ordering-invariants — Replace vs. push logic
617
842
  */
618
- emitTextUpdate() {
619
- this.lastEmittedText = this.currentSegmentText;
620
- if (this.currentAssistantMessageId) {
621
- this.messages = updateTextPart(
622
- this.messages,
623
- this.currentAssistantMessageId,
624
- this.currentSegmentText
625
- );
626
- this.emitMessagesChange();
627
- this.events.onTextUpdate?.(
628
- this.currentAssistantMessageId,
629
- this.currentSegmentText
630
- );
631
- }
843
+ emitTextUpdateForMessage(messageId) {
844
+ const state = this.getMessageState(messageId);
845
+ if (!state) return;
846
+ state.lastEmittedText = state.currentSegmentText;
847
+ this.messages = updateTextPart(
848
+ this.messages,
849
+ messageId,
850
+ state.currentSegmentText
851
+ );
852
+ this.emitMessagesChange();
853
+ this.events.onTextUpdate?.(messageId, state.currentSegmentText);
632
854
  }
633
855
  /**
634
856
  * Emit messages change event
@@ -646,66 +868,94 @@ class StreamProcessor {
646
868
  * @see docs/chat-architecture.md#single-shot-text-response — Finalization step
647
869
  */
648
870
  finalizeStream() {
649
- this.completeAllToolCalls();
650
- if (this.currentSegmentText !== this.lastEmittedText) {
651
- this.emitTextUpdate();
871
+ let lastAssistantMessage;
872
+ for (const messageId of this.activeMessageIds) {
873
+ const state = this.getMessageState(messageId);
874
+ if (!state) continue;
875
+ this.completeAllToolCallsForMessage(messageId);
876
+ if (state.currentSegmentText !== state.lastEmittedText) {
877
+ this.emitTextUpdateForMessage(messageId);
878
+ }
879
+ state.isComplete = true;
880
+ const msg = this.messages.find((m) => m.id === messageId);
881
+ if (msg && msg.role === "assistant") {
882
+ lastAssistantMessage = msg;
883
+ }
652
884
  }
653
- if (this.currentAssistantMessageId && !this.hasError) {
654
- const assistantMessage = this.messages.find(
655
- (m) => m.id === this.currentAssistantMessageId
656
- );
657
- if (assistantMessage && this.isWhitespaceOnlyMessage(assistantMessage)) {
885
+ this.activeMessageIds.clear();
886
+ if (lastAssistantMessage && !this.hasError) {
887
+ if (this.isWhitespaceOnlyMessage(lastAssistantMessage)) {
658
888
  this.messages = this.messages.filter(
659
- (m) => m.id !== this.currentAssistantMessageId
889
+ (m) => m.id !== lastAssistantMessage.id
660
890
  );
661
891
  this.emitMessagesChange();
662
- this.currentAssistantMessageId = null;
663
892
  return;
664
893
  }
665
894
  }
666
- if (this.currentAssistantMessageId) {
667
- const assistantMessage = this.messages.find(
668
- (m) => m.id === this.currentAssistantMessageId
669
- );
670
- if (assistantMessage) {
671
- this.events.onStreamEnd?.(assistantMessage);
672
- }
895
+ if (lastAssistantMessage) {
896
+ this.events.onStreamEnd?.(lastAssistantMessage);
673
897
  }
674
898
  }
675
899
  /**
676
- * Get completed tool calls in API format
900
+ * Get completed tool calls in API format (aggregated across all messages)
677
901
  */
678
902
  getCompletedToolCalls() {
679
- return Array.from(this.toolCalls.values()).filter((tc) => tc.state === "input-complete").map((tc) => ({
680
- id: tc.id,
681
- type: "function",
682
- function: {
683
- name: tc.name,
684
- arguments: tc.arguments
903
+ const result = [];
904
+ for (const state of this.messageStates.values()) {
905
+ for (const tc of state.toolCalls.values()) {
906
+ if (tc.state === "input-complete") {
907
+ result.push({
908
+ id: tc.id,
909
+ type: "function",
910
+ function: {
911
+ name: tc.name,
912
+ arguments: tc.arguments
913
+ }
914
+ });
915
+ }
685
916
  }
686
- }));
917
+ }
918
+ return result;
687
919
  }
688
920
  /**
689
- * Get current result
921
+ * Get current result (aggregated across all messages)
690
922
  */
691
923
  getResult() {
692
924
  const toolCalls = this.getCompletedToolCalls();
925
+ let content = "";
926
+ let thinking = "";
927
+ for (const state of this.messageStates.values()) {
928
+ content += state.totalTextContent;
929
+ thinking += state.thinkingContent;
930
+ }
693
931
  return {
694
- content: this.totalTextContent,
695
- thinking: this.thinkingContent || void 0,
932
+ content,
933
+ thinking: thinking || void 0,
696
934
  toolCalls: toolCalls.length > 0 ? toolCalls : void 0,
697
935
  finishReason: this.finishReason
698
936
  };
699
937
  }
700
938
  /**
701
- * Get current processor state
939
+ * Get current processor state (aggregated across all messages)
702
940
  */
703
941
  getState() {
942
+ let content = "";
943
+ let thinking = "";
944
+ const toolCalls = /* @__PURE__ */ new Map();
945
+ const toolCallOrder = [];
946
+ for (const state of this.messageStates.values()) {
947
+ content += state.totalTextContent;
948
+ thinking += state.thinkingContent;
949
+ for (const [id, tc] of state.toolCalls) {
950
+ toolCalls.set(id, tc);
951
+ }
952
+ toolCallOrder.push(...state.toolCallOrder);
953
+ }
704
954
  return {
705
- content: this.totalTextContent,
706
- thinking: this.thinkingContent,
707
- toolCalls: new Map(this.toolCalls),
708
- toolCallOrder: [...this.toolCallOrder],
955
+ content,
956
+ thinking,
957
+ toolCalls,
958
+ toolCallOrder,
709
959
  finishReason: this.finishReason,
710
960
  done: this.isDone
711
961
  };
@@ -732,12 +982,11 @@ class StreamProcessor {
732
982
  * Reset stream state (but keep messages)
733
983
  */
734
984
  resetStreamState() {
735
- this.totalTextContent = "";
736
- this.currentSegmentText = "";
737
- this.lastEmittedText = "";
738
- this.thinkingContent = "";
739
- this.toolCalls.clear();
740
- this.toolCallOrder = [];
985
+ this.messageStates.clear();
986
+ this.activeMessageIds.clear();
987
+ this.activeRuns.clear();
988
+ this.toolCallToMessage.clear();
989
+ this.pendingManualMessageId = null;
741
990
  this.finishReason = null;
742
991
  this.hasError = false;
743
992
  this.isDone = false;
@@ -749,7 +998,6 @@ class StreamProcessor {
749
998
  reset() {
750
999
  this.resetStreamState();
751
1000
  this.messages = [];
752
- this.currentAssistantMessageId = null;
753
1001
  }
754
1002
  /**
755
1003
  * Check if a message contains only whitespace text and no other meaningful parts