@tanstack/ai 0.6.3 → 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.
- package/README.md +6 -6
- package/dist/esm/activities/chat/index.d.ts +20 -0
- package/dist/esm/activities/chat/index.js +248 -213
- package/dist/esm/activities/chat/index.js.map +1 -1
- package/dist/esm/activities/chat/middleware/compose.d.ts +66 -0
- package/dist/esm/activities/chat/middleware/compose.js +327 -0
- package/dist/esm/activities/chat/middleware/compose.js.map +1 -0
- package/dist/esm/activities/chat/middleware/index.d.ts +2 -0
- package/dist/esm/activities/chat/middleware/tool-cache-middleware.d.ts +89 -0
- package/dist/esm/activities/chat/middleware/tool-cache-middleware.js +76 -0
- package/dist/esm/activities/chat/middleware/tool-cache-middleware.js.map +1 -0
- package/dist/esm/activities/chat/middleware/types.d.ts +307 -0
- package/dist/esm/activities/chat/stream/processor.d.ts +64 -40
- package/dist/esm/activities/chat/stream/processor.js +466 -218
- package/dist/esm/activities/chat/stream/processor.js.map +1 -1
- package/dist/esm/activities/chat/stream/types.d.ts +17 -0
- package/dist/esm/activities/chat/tools/tool-calls.d.ts +16 -1
- package/dist/esm/activities/chat/tools/tool-calls.js +148 -64
- package/dist/esm/activities/chat/tools/tool-calls.js.map +1 -1
- package/dist/esm/activities/generateImage/index.js +1 -1
- package/dist/esm/activities/generateImage/index.js.map +1 -1
- package/dist/esm/activities/generateSpeech/index.js +1 -1
- package/dist/esm/activities/generateSpeech/index.js.map +1 -1
- package/dist/esm/activities/generateTranscription/index.js +1 -1
- package/dist/esm/activities/generateTranscription/index.js.map +1 -1
- package/dist/esm/activities/generateVideo/index.js +1 -1
- package/dist/esm/activities/generateVideo/index.js.map +1 -1
- package/dist/esm/activities/summarize/index.js +1 -1
- package/dist/esm/activities/summarize/index.js.map +1 -1
- package/dist/esm/index.d.ts +3 -1
- package/dist/esm/index.js +2 -2
- package/dist/esm/middlewares/content-guard.d.ts +77 -0
- package/dist/esm/middlewares/content-guard.js +155 -0
- package/dist/esm/middlewares/content-guard.js.map +1 -0
- package/dist/esm/middlewares/index.d.ts +2 -0
- package/dist/esm/middlewares/index.js +7 -0
- package/dist/esm/middlewares/index.js.map +1 -0
- package/dist/esm/middlewares/tool-cache.d.ts +1 -0
- package/dist/esm/realtime/index.d.ts +30 -0
- package/dist/esm/realtime/index.js +8 -0
- package/dist/esm/realtime/index.js.map +1 -0
- package/dist/esm/realtime/types.d.ts +234 -0
- package/dist/esm/types.d.ts +18 -4
- package/package.json +6 -6
- package/src/activities/chat/index.ts +322 -256
- package/src/activities/chat/middleware/compose.ts +392 -0
- package/src/activities/chat/middleware/index.ts +17 -0
- package/src/activities/chat/middleware/tool-cache-middleware.ts +189 -0
- package/src/activities/chat/middleware/types.ts +419 -0
- package/src/activities/chat/stream/processor.ts +630 -259
- package/src/activities/chat/stream/types.ts +18 -0
- package/src/activities/chat/tools/tool-calls.ts +225 -87
- package/src/activities/generateImage/index.ts +1 -1
- package/src/activities/generateSpeech/index.ts +1 -1
- package/src/activities/generateTranscription/index.ts +1 -1
- package/src/activities/generateVideo/index.ts +1 -1
- package/src/activities/summarize/index.ts +1 -1
- package/src/index.ts +41 -2
- package/src/middlewares/content-guard.ts +285 -0
- package/src/middlewares/index.ts +13 -0
- package/src/middlewares/tool-cache.ts +6 -0
- package/src/realtime/index.ts +38 -0
- package/src/realtime/types.ts +294 -0
- package/src/types.ts +19 -2
- package/dist/esm/event-client.d.ts +0 -394
- package/dist/esm/event-client.js +0 -13
- package/dist/esm/event-client.js.map +0 -1
- package/src/event-client.ts +0 -497
|
@@ -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
|
-
* -
|
|
120
|
-
* -
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
private
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
private
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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.
|
|
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,
|
|
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
|
-
//
|
|
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
|
-
*
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
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
|
-
*
|
|
522
|
-
*
|
|
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
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
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
|
-
|
|
530
|
-
|
|
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
|
-
|
|
550
|
-
|
|
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
|
-
|
|
554
|
-
|
|
811
|
+
chunkPortion,
|
|
812
|
+
state.currentSegmentText,
|
|
555
813
|
)
|
|
556
|
-
if (shouldEmit &&
|
|
557
|
-
this.
|
|
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
|
|
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 =
|
|
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 ??
|
|
858
|
+
index: chunk.index ?? state.toolCalls.size,
|
|
593
859
|
}
|
|
594
860
|
|
|
595
|
-
|
|
596
|
-
|
|
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
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
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
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
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
|
|
902
|
+
const messageId = this.toolCallToMessage.get(toolCallId)
|
|
903
|
+
if (!messageId) return
|
|
640
904
|
|
|
641
|
-
|
|
642
|
-
|
|
905
|
+
const state = this.getMessageState(messageId)
|
|
906
|
+
if (!state) return
|
|
643
907
|
|
|
644
|
-
|
|
645
|
-
|
|
908
|
+
const existingToolCall = state.toolCalls.get(toolCallId)
|
|
909
|
+
if (!existingToolCall) return
|
|
646
910
|
|
|
647
|
-
|
|
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
|
-
|
|
658
|
-
|
|
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
|
-
|
|
672
|
-
|
|
673
|
-
|
|
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 =
|
|
968
|
+
const existingToolCall = msgState.toolCalls.get(chunk.toolCallId)
|
|
701
969
|
if (existingToolCall && existingToolCall.state !== 'input-complete') {
|
|
702
|
-
const index =
|
|
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
|
|
712
|
-
if (
|
|
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
|
-
|
|
999
|
+
messageId,
|
|
733
1000
|
chunk.toolCallId,
|
|
734
1001
|
chunk.result,
|
|
735
|
-
|
|
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
|
|
745
|
-
*
|
|
746
|
-
*
|
|
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.
|
|
756
|
-
|
|
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
|
-
|
|
1090
|
+
state.thinkingContent = nextThinking
|
|
785
1091
|
|
|
786
1092
|
// Update UIMessage
|
|
787
|
-
|
|
788
|
-
this.messages
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
this.emitMessagesChange()
|
|
1093
|
+
this.messages = updateThinkingPart(
|
|
1094
|
+
this.messages,
|
|
1095
|
+
messageId,
|
|
1096
|
+
state.thinkingContent,
|
|
1097
|
+
)
|
|
1098
|
+
this.emitMessagesChange()
|
|
794
1099
|
|
|
795
|
-
|
|
796
|
-
|
|
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
|
-
//
|
|
843
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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.
|
|
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 =
|
|
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
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
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
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
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
|
|
937
|
-
|
|
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
|
-
|
|
941
|
-
this.messages
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
this.emitMessagesChange()
|
|
1283
|
+
this.messages = updateTextPart(
|
|
1284
|
+
this.messages,
|
|
1285
|
+
messageId,
|
|
1286
|
+
state.currentSegmentText,
|
|
1287
|
+
)
|
|
1288
|
+
this.emitMessagesChange()
|
|
947
1289
|
|
|
948
|
-
|
|
949
|
-
|
|
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
|
-
|
|
974
|
-
this.completeAllToolCalls()
|
|
1311
|
+
let lastAssistantMessage: UIMessage | undefined
|
|
975
1312
|
|
|
976
|
-
//
|
|
977
|
-
|
|
978
|
-
this.
|
|
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
|
-
|
|
982
|
-
|
|
983
|
-
//
|
|
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 (
|
|
986
|
-
|
|
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 !==
|
|
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
|
|
1000
|
-
if (
|
|
1001
|
-
|
|
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
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
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
|
|
1033
|
-
thinking:
|
|
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
|
|
1045
|
-
thinking
|
|
1046
|
-
toolCalls
|
|
1047
|
-
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.
|
|
1078
|
-
this.
|
|
1079
|
-
this.
|
|
1080
|
-
this.
|
|
1081
|
-
this.
|
|
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
|
/**
|