@tanstack/ai 0.0.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.
Files changed (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +131 -0
  3. package/dist/esm/base-adapter.d.ts +35 -0
  4. package/dist/esm/base-adapter.js +12 -0
  5. package/dist/esm/base-adapter.js.map +1 -0
  6. package/dist/esm/core/chat-common-options.d.ts +52 -0
  7. package/dist/esm/core/chat.d.ts +30 -0
  8. package/dist/esm/core/chat.js +533 -0
  9. package/dist/esm/core/chat.js.map +1 -0
  10. package/dist/esm/core/embedding.d.ts +8 -0
  11. package/dist/esm/core/embedding.js +33 -0
  12. package/dist/esm/core/embedding.js.map +1 -0
  13. package/dist/esm/core/summarize.d.ts +9 -0
  14. package/dist/esm/core/summarize.js +36 -0
  15. package/dist/esm/core/summarize.js.map +1 -0
  16. package/dist/esm/event-client.d.ts +311 -0
  17. package/dist/esm/event-client.js +62 -0
  18. package/dist/esm/event-client.js.map +1 -0
  19. package/dist/esm/index.d.ts +16 -0
  20. package/dist/esm/index.js +50 -0
  21. package/dist/esm/index.js.map +1 -0
  22. package/dist/esm/message-converters.d.ts +52 -0
  23. package/dist/esm/message-converters.js +162 -0
  24. package/dist/esm/message-converters.js.map +1 -0
  25. package/dist/esm/stream/index.d.ts +11 -0
  26. package/dist/esm/stream/json-parser.d.ts +38 -0
  27. package/dist/esm/stream/json-parser.js +28 -0
  28. package/dist/esm/stream/json-parser.js.map +1 -0
  29. package/dist/esm/stream/message-updaters.d.ts +44 -0
  30. package/dist/esm/stream/message-updaters.js +141 -0
  31. package/dist/esm/stream/message-updaters.js.map +1 -0
  32. package/dist/esm/stream/processor.d.ts +242 -0
  33. package/dist/esm/stream/processor.js +693 -0
  34. package/dist/esm/stream/processor.js.map +1 -0
  35. package/dist/esm/stream/strategies.d.ts +43 -0
  36. package/dist/esm/stream/strategies.js +54 -0
  37. package/dist/esm/stream/strategies.js.map +1 -0
  38. package/dist/esm/stream/types.d.ts +71 -0
  39. package/dist/esm/tools/tool-calls.d.ts +112 -0
  40. package/dist/esm/tools/tool-calls.js +302 -0
  41. package/dist/esm/tools/tool-calls.js.map +1 -0
  42. package/dist/esm/tools/tool-definition.d.ts +125 -0
  43. package/dist/esm/tools/tool-definition.js +25 -0
  44. package/dist/esm/tools/tool-definition.js.map +1 -0
  45. package/dist/esm/tools/zod-converter.d.ts +30 -0
  46. package/dist/esm/tools/zod-converter.js +36 -0
  47. package/dist/esm/tools/zod-converter.js.map +1 -0
  48. package/dist/esm/types.d.ts +619 -0
  49. package/dist/esm/utilities/agent-loop-strategies.d.ts +59 -0
  50. package/dist/esm/utilities/agent-loop-strategies.js +23 -0
  51. package/dist/esm/utilities/agent-loop-strategies.js.map +1 -0
  52. package/dist/esm/utilities/chat-options.d.ts +6 -0
  53. package/dist/esm/utilities/chat-options.js +7 -0
  54. package/dist/esm/utilities/chat-options.js.map +1 -0
  55. package/dist/esm/utilities/messages.d.ts +30 -0
  56. package/dist/esm/utilities/messages.js +7 -0
  57. package/dist/esm/utilities/messages.js.map +1 -0
  58. package/dist/esm/utilities/stream-to-response.d.ts +48 -0
  59. package/dist/esm/utilities/stream-to-response.js +62 -0
  60. package/dist/esm/utilities/stream-to-response.js.map +1 -0
  61. package/package.json +65 -0
  62. package/src/base-adapter.ts +85 -0
  63. package/src/core/chat-common-options.ts +55 -0
  64. package/src/core/chat.ts +771 -0
  65. package/src/core/embedding.ts +54 -0
  66. package/src/core/summarize.ts +56 -0
  67. package/src/event-client.ts +389 -0
  68. package/src/index.ts +68 -0
  69. package/src/message-converters.ts +285 -0
  70. package/src/stream/index.ts +41 -0
  71. package/src/stream/json-parser.ts +58 -0
  72. package/src/stream/message-updaters.ts +275 -0
  73. package/src/stream/processor.ts +1092 -0
  74. package/src/stream/strategies.ts +78 -0
  75. package/src/stream/types.ts +94 -0
  76. package/src/tools/tool-calls.ts +471 -0
  77. package/src/tools/tool-definition.ts +206 -0
  78. package/src/tools/zod-converter.ts +85 -0
  79. package/src/types.ts +872 -0
  80. package/src/utilities/agent-loop-strategies.ts +85 -0
  81. package/src/utilities/chat-options.ts +35 -0
  82. package/src/utilities/messages.ts +63 -0
  83. package/src/utilities/stream-to-response.ts +116 -0
@@ -0,0 +1,1092 @@
1
+ /**
2
+ * Unified Stream Processor
3
+ *
4
+ * Core stream processing engine that manages the full UIMessage[] conversation.
5
+ * Single source of truth for message state.
6
+ *
7
+ * Handles:
8
+ * - Full conversation management (UIMessage[])
9
+ * - Text content accumulation with configurable chunking strategies
10
+ * - Parallel tool calls with lifecycle state tracking
11
+ * - Tool results and approval flows
12
+ * - Thinking/reasoning content
13
+ * - Recording/replay for testing
14
+ * - Event-driven architecture for UI updates
15
+ */
16
+ import {
17
+ generateMessageId,
18
+ uiMessageToModelMessages,
19
+ } from '../message-converters'
20
+ import { defaultJSONParser } from './json-parser'
21
+ import {
22
+ updateTextPart,
23
+ updateThinkingPart,
24
+ updateToolCallApproval,
25
+ updateToolCallApprovalResponse,
26
+ updateToolCallPart,
27
+ updateToolCallWithOutput,
28
+ updateToolResultPart,
29
+ } from './message-updaters'
30
+ import { ImmediateStrategy } from './strategies'
31
+ import type {
32
+ ChunkRecording,
33
+ ChunkStrategy,
34
+ InternalToolCallState,
35
+ ProcessorResult,
36
+ ProcessorState,
37
+ ToolCallState,
38
+ ToolResultState,
39
+ } from './types'
40
+ import type {
41
+ ModelMessage,
42
+ StreamChunk,
43
+ ToolCall,
44
+ ToolCallPart,
45
+ UIMessage,
46
+ } from '../types'
47
+
48
+ /**
49
+ * Events emitted by the StreamProcessor
50
+ */
51
+ export interface StreamProcessorEvents {
52
+ // State events - full array on any change
53
+ onMessagesChange?: (messages: Array<UIMessage>) => void
54
+
55
+ // Lifecycle events
56
+ onStreamStart?: () => void
57
+ onStreamEnd?: (message: UIMessage) => void
58
+ onError?: (error: Error) => void
59
+
60
+ // Interaction events - client must handle these
61
+ onToolCall?: (args: {
62
+ toolCallId: string
63
+ toolName: string
64
+ input: any
65
+ }) => void
66
+ onApprovalRequest?: (args: {
67
+ toolCallId: string
68
+ toolName: string
69
+ input: any
70
+ approvalId: string
71
+ }) => void
72
+
73
+ // Granular events for UI optimization (character-by-character, state tracking)
74
+ onTextUpdate?: (messageId: string, content: string) => void
75
+ onToolCallStateChange?: (
76
+ messageId: string,
77
+ toolCallId: string,
78
+ state: ToolCallState,
79
+ args: string,
80
+ ) => void
81
+ onThinkingUpdate?: (messageId: string, content: string) => void
82
+ }
83
+
84
+ /**
85
+ * Legacy handlers for backward compatibility
86
+ * These are the old callback-style handlers
87
+ */
88
+ export interface StreamProcessorHandlers {
89
+ onTextUpdate?: (content: string) => void
90
+ onThinkingUpdate?: (content: string) => void
91
+
92
+ // Tool call lifecycle handlers
93
+ onToolCallStart?: (index: number, id: string, name: string) => void
94
+ onToolCallDelta?: (index: number, args: string) => void
95
+ onToolCallComplete?: (
96
+ index: number,
97
+ id: string,
98
+ name: string,
99
+ args: string,
100
+ ) => void
101
+ onToolCallStateChange?: (
102
+ index: number,
103
+ id: string,
104
+ name: string,
105
+ state: ToolCallState,
106
+ args: string,
107
+ parsedArgs?: any,
108
+ ) => void
109
+
110
+ // Tool result handlers
111
+ onToolResultStateChange?: (
112
+ toolCallId: string,
113
+ content: string,
114
+ state: ToolResultState,
115
+ error?: string,
116
+ ) => void
117
+
118
+ // Approval/client tool handlers
119
+ onApprovalRequested?: (
120
+ toolCallId: string,
121
+ toolName: string,
122
+ input: any,
123
+ approvalId: string,
124
+ ) => void
125
+ onToolInputAvailable?: (
126
+ toolCallId: string,
127
+ toolName: string,
128
+ input: any,
129
+ ) => void
130
+
131
+ // Stream lifecycle
132
+ onStreamEnd?: (content: string, toolCalls?: Array<ToolCall>) => void
133
+ onError?: (error: { message: string; code?: string }) => void
134
+ }
135
+
136
+ /**
137
+ * Options for StreamProcessor
138
+ */
139
+ export interface StreamProcessorOptions {
140
+ chunkStrategy?: ChunkStrategy
141
+ /** New event-driven handlers */
142
+ events?: StreamProcessorEvents
143
+ /** Legacy callback handlers (for backward compatibility) */
144
+ handlers?: StreamProcessorHandlers
145
+ jsonParser?: {
146
+ parse: (jsonString: string) => any
147
+ }
148
+ /** Enable recording for replay testing */
149
+ recording?: boolean
150
+ /** Initial messages to populate the processor */
151
+ initialMessages?: Array<UIMessage>
152
+ }
153
+
154
+ /**
155
+ * StreamProcessor - State machine for processing AI response streams
156
+ *
157
+ * Manages the full UIMessage[] conversation and emits events on changes.
158
+ *
159
+ * State tracking:
160
+ * - Full message array
161
+ * - Current assistant message being streamed
162
+ * - Text content accumulation
163
+ * - Multiple parallel tool calls
164
+ * - Tool call completion detection
165
+ *
166
+ * Tool call completion is detected when:
167
+ * 1. A new tool call starts at a different index
168
+ * 2. Text content arrives
169
+ * 3. Stream ends
170
+ */
171
+ export class StreamProcessor {
172
+ private chunkStrategy: ChunkStrategy
173
+ private events: StreamProcessorEvents
174
+ private handlers: StreamProcessorHandlers
175
+ private jsonParser: { parse: (jsonString: string) => any }
176
+ private recordingEnabled: boolean
177
+
178
+ // Message state
179
+ private messages: Array<UIMessage> = []
180
+ private currentAssistantMessageId: string | null = null
181
+
182
+ // Stream state for current assistant message
183
+ // Total accumulated text across all segments (for the final result)
184
+ private totalTextContent = ''
185
+ // Current segment's text content (for onTextUpdate callbacks)
186
+ private currentSegmentText = ''
187
+ private lastEmittedText = ''
188
+ private thinkingContent = ''
189
+ private toolCalls: Map<string, InternalToolCallState> = new Map()
190
+ private toolCallOrder: Array<string> = []
191
+ private finishReason: string | null = null
192
+ private isDone = false
193
+ // Track if we've had tool calls since the last text segment started
194
+ private hasToolCallsSinceTextStart = false
195
+
196
+ // Recording
197
+ private recording: ChunkRecording | null = null
198
+ private recordingStartTime = 0
199
+
200
+ constructor(options: StreamProcessorOptions = {}) {
201
+ this.chunkStrategy = options.chunkStrategy || new ImmediateStrategy()
202
+ this.events = options.events || {}
203
+ this.handlers = options.handlers || {}
204
+ this.jsonParser = options.jsonParser || defaultJSONParser
205
+ this.recordingEnabled = options.recording ?? false
206
+
207
+ // Initialize with provided messages
208
+ if (options.initialMessages) {
209
+ this.messages = [...options.initialMessages]
210
+ }
211
+ }
212
+
213
+ // ============================================
214
+ // Message Management Methods
215
+ // ============================================
216
+
217
+ /**
218
+ * Set the messages array (e.g., from persisted state)
219
+ */
220
+ setMessages(messages: Array<UIMessage>): void {
221
+ this.messages = [...messages]
222
+ this.emitMessagesChange()
223
+ }
224
+
225
+ /**
226
+ * Add a user message to the conversation
227
+ */
228
+ addUserMessage(content: string): UIMessage {
229
+ const userMessage: UIMessage = {
230
+ id: generateMessageId(),
231
+ role: 'user',
232
+ parts: [{ type: 'text', content }],
233
+ createdAt: new Date(),
234
+ }
235
+
236
+ this.messages = [...this.messages, userMessage]
237
+ this.emitMessagesChange()
238
+
239
+ return userMessage
240
+ }
241
+
242
+ /**
243
+ * Start streaming a new assistant message
244
+ * Returns the message ID
245
+ */
246
+ startAssistantMessage(): string {
247
+ // Reset stream state for new message
248
+ this.resetStreamState()
249
+
250
+ const assistantMessage: UIMessage = {
251
+ id: generateMessageId(),
252
+ role: 'assistant',
253
+ parts: [],
254
+ createdAt: new Date(),
255
+ }
256
+
257
+ this.currentAssistantMessageId = assistantMessage.id
258
+ this.messages = [...this.messages, assistantMessage]
259
+
260
+ // Emit events
261
+ this.events.onStreamStart?.()
262
+ this.emitMessagesChange()
263
+
264
+ return assistantMessage.id
265
+ }
266
+
267
+ /**
268
+ * Add a tool result (called by client after handling onToolCall)
269
+ */
270
+ addToolResult(toolCallId: string, output: any, error?: string): void {
271
+ // Find the message containing this tool call
272
+ const messageWithToolCall = this.messages.find((msg) =>
273
+ msg.parts.some(
274
+ (p): p is ToolCallPart => p.type === 'tool-call' && p.id === toolCallId,
275
+ ),
276
+ )
277
+
278
+ if (!messageWithToolCall) {
279
+ console.warn(
280
+ `[StreamProcessor] Could not find message with tool call ${toolCallId}`,
281
+ )
282
+ return
283
+ }
284
+
285
+ // Step 1: Update the tool-call part's output field (for UI rendering)
286
+ let updatedMessages = updateToolCallWithOutput(
287
+ this.messages,
288
+ toolCallId,
289
+ output,
290
+ error ? 'input-complete' : undefined,
291
+ error,
292
+ )
293
+
294
+ // Step 2: Create a tool-result part (for LLM conversation history)
295
+ const content = typeof output === 'string' ? output : JSON.stringify(output)
296
+ const toolResultState: ToolResultState = error ? 'error' : 'complete'
297
+
298
+ updatedMessages = updateToolResultPart(
299
+ updatedMessages,
300
+ messageWithToolCall.id,
301
+ toolCallId,
302
+ content,
303
+ toolResultState,
304
+ error,
305
+ )
306
+
307
+ this.messages = updatedMessages
308
+ this.emitMessagesChange()
309
+ }
310
+
311
+ /**
312
+ * Add an approval response (called by client after handling onApprovalRequest)
313
+ */
314
+ addToolApprovalResponse(approvalId: string, approved: boolean): void {
315
+ this.messages = updateToolCallApprovalResponse(
316
+ this.messages,
317
+ approvalId,
318
+ approved,
319
+ )
320
+ this.emitMessagesChange()
321
+ }
322
+
323
+ /**
324
+ * Get the conversation as ModelMessages (for sending to LLM)
325
+ */
326
+ toModelMessages(): Array<ModelMessage> {
327
+ const modelMessages: Array<ModelMessage> = []
328
+ for (const msg of this.messages) {
329
+ modelMessages.push(...uiMessageToModelMessages(msg))
330
+ }
331
+ return modelMessages
332
+ }
333
+
334
+ /**
335
+ * Get current messages
336
+ */
337
+ getMessages(): Array<UIMessage> {
338
+ return this.messages
339
+ }
340
+
341
+ /**
342
+ * Check if all tool calls in the last assistant message are complete
343
+ * Useful for auto-continue logic
344
+ */
345
+ areAllToolsComplete(): boolean {
346
+ const lastAssistant = this.messages.findLast(
347
+ (m: UIMessage) => m.role === 'assistant',
348
+ )
349
+
350
+ if (!lastAssistant) return true
351
+
352
+ const toolParts = lastAssistant.parts.filter(
353
+ (p): p is ToolCallPart => p.type === 'tool-call',
354
+ )
355
+
356
+ if (toolParts.length === 0) return true
357
+
358
+ // All tool calls must be in a terminal state
359
+ return toolParts.every(
360
+ (part) =>
361
+ part.state === 'approval-responded' ||
362
+ (part.output !== undefined && !part.approval),
363
+ )
364
+ }
365
+
366
+ /**
367
+ * Remove messages after a certain index (for reload/retry)
368
+ */
369
+ removeMessagesAfter(index: number): void {
370
+ this.messages = this.messages.slice(0, index + 1)
371
+ this.emitMessagesChange()
372
+ }
373
+
374
+ /**
375
+ * Clear all messages
376
+ */
377
+ clearMessages(): void {
378
+ this.messages = []
379
+ this.currentAssistantMessageId = null
380
+ this.emitMessagesChange()
381
+ }
382
+
383
+ // ============================================
384
+ // Stream Processing Methods
385
+ // ============================================
386
+
387
+ /**
388
+ * Process a stream and emit events through handlers
389
+ */
390
+ async process(stream: AsyncIterable<any>): Promise<ProcessorResult> {
391
+ // Reset stream state (but keep messages)
392
+ this.resetStreamState()
393
+
394
+ // Start recording if enabled
395
+ if (this.recordingEnabled) {
396
+ this.startRecording()
397
+ }
398
+
399
+ // Process each chunk
400
+ for await (const chunk of stream) {
401
+ this.processChunk(chunk)
402
+ }
403
+
404
+ // Stream ended - finalize everything
405
+ this.finalizeStream()
406
+
407
+ // Finalize recording
408
+ if (this.recording) {
409
+ this.recording.result = this.getResult()
410
+ }
411
+
412
+ return this.getResult()
413
+ }
414
+
415
+ /**
416
+ * Process a single chunk from the stream
417
+ */
418
+ processChunk(chunk: StreamChunk): void {
419
+ // Record chunk if enabled
420
+ if (this.recording) {
421
+ this.recording.chunks.push({
422
+ chunk,
423
+ timestamp: Date.now(),
424
+ index: this.recording.chunks.length,
425
+ })
426
+ }
427
+
428
+ switch (chunk.type) {
429
+ case 'content':
430
+ this.handleContentChunk(chunk)
431
+ break
432
+
433
+ case 'tool_call':
434
+ this.handleToolCallChunk(chunk)
435
+ break
436
+
437
+ case 'tool_result':
438
+ this.handleToolResultChunk(chunk)
439
+ break
440
+
441
+ case 'done':
442
+ this.handleDoneChunk(chunk)
443
+ break
444
+
445
+ case 'error':
446
+ this.handleErrorChunk(chunk)
447
+ break
448
+
449
+ case 'thinking':
450
+ this.handleThinkingChunk(chunk)
451
+ break
452
+
453
+ case 'approval-requested':
454
+ this.handleApprovalRequestedChunk(chunk)
455
+ break
456
+
457
+ case 'tool-input-available':
458
+ this.handleToolInputAvailableChunk(chunk)
459
+ break
460
+
461
+ default:
462
+ // Unknown chunk type - ignore
463
+ break
464
+ }
465
+ }
466
+
467
+ /**
468
+ * Handle a content chunk
469
+ */
470
+ private handleContentChunk(
471
+ chunk: Extract<StreamChunk, { type: 'content' }>,
472
+ ): void {
473
+ // Content arriving means all current tool calls are complete
474
+ this.completeAllToolCalls()
475
+
476
+ const previousSegment = this.currentSegmentText
477
+
478
+ // Detect if this is a NEW text segment (after tool calls) vs continuation
479
+ const isNewSegment =
480
+ this.hasToolCallsSinceTextStart &&
481
+ previousSegment.length > 0 &&
482
+ this.isNewTextSegment(chunk, previousSegment)
483
+
484
+ if (isNewSegment) {
485
+ // Emit any accumulated text before starting new segment
486
+ if (previousSegment !== this.lastEmittedText) {
487
+ this.emitTextUpdate()
488
+ }
489
+ // Reset SEGMENT text accumulation for the new text segment after tool calls
490
+ this.currentSegmentText = ''
491
+ this.lastEmittedText = ''
492
+ this.hasToolCallsSinceTextStart = false
493
+ }
494
+
495
+ const currentText = this.currentSegmentText
496
+ let nextText = currentText
497
+
498
+ // Prefer delta over content - delta is the incremental change
499
+ if (chunk.delta !== '') {
500
+ nextText = currentText + chunk.delta
501
+ } else if (chunk.content !== '') {
502
+ // Fallback: use content if delta is not provided
503
+ if (chunk.content.startsWith(currentText)) {
504
+ nextText = chunk.content
505
+ } else if (currentText.startsWith(chunk.content)) {
506
+ nextText = currentText
507
+ } else {
508
+ nextText = currentText + chunk.content
509
+ }
510
+ }
511
+
512
+ // Calculate the delta for totalTextContent
513
+ const textDelta = nextText.slice(currentText.length)
514
+ this.currentSegmentText = nextText
515
+ this.totalTextContent += textDelta
516
+
517
+ // Use delta for chunk strategy if available
518
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
519
+ const chunkPortion = chunk.delta ?? chunk.content ?? ''
520
+ const shouldEmit = this.chunkStrategy.shouldEmit(
521
+ chunkPortion,
522
+ this.currentSegmentText,
523
+ )
524
+ if (shouldEmit && this.currentSegmentText !== this.lastEmittedText) {
525
+ this.emitTextUpdate()
526
+ }
527
+ }
528
+
529
+ /**
530
+ * Handle a tool call chunk
531
+ */
532
+ private handleToolCallChunk(
533
+ chunk: Extract<StreamChunk, { type: 'tool_call' }>,
534
+ ): void {
535
+ // Mark that we've seen tool calls since the last text segment
536
+ this.hasToolCallsSinceTextStart = true
537
+
538
+ const toolCallId = chunk.toolCall.id
539
+ const existingToolCall = this.toolCalls.get(toolCallId)
540
+
541
+ if (!existingToolCall) {
542
+ // New tool call starting
543
+ const initialState: ToolCallState = chunk.toolCall.function.arguments
544
+ ? 'input-streaming'
545
+ : 'awaiting-input'
546
+
547
+ const newToolCall: InternalToolCallState = {
548
+ id: chunk.toolCall.id,
549
+ name: chunk.toolCall.function.name,
550
+ arguments: chunk.toolCall.function.arguments || '',
551
+ state: initialState,
552
+ parsedArguments: undefined,
553
+ index: chunk.index,
554
+ }
555
+
556
+ // Try to parse the arguments
557
+ if (chunk.toolCall.function.arguments) {
558
+ newToolCall.parsedArguments = this.jsonParser.parse(
559
+ chunk.toolCall.function.arguments,
560
+ )
561
+ }
562
+
563
+ this.toolCalls.set(toolCallId, newToolCall)
564
+ this.toolCallOrder.push(toolCallId)
565
+
566
+ // Get actual index for this tool call (based on order)
567
+ const actualIndex = this.toolCallOrder.indexOf(toolCallId)
568
+
569
+ // Emit legacy lifecycle event
570
+ this.handlers.onToolCallStart?.(
571
+ actualIndex,
572
+ chunk.toolCall.id,
573
+ chunk.toolCall.function.name,
574
+ )
575
+
576
+ // Emit legacy state change event
577
+ this.handlers.onToolCallStateChange?.(
578
+ actualIndex,
579
+ chunk.toolCall.id,
580
+ chunk.toolCall.function.name,
581
+ initialState,
582
+ chunk.toolCall.function.arguments || '',
583
+ newToolCall.parsedArguments,
584
+ )
585
+
586
+ // Emit initial delta
587
+ if (chunk.toolCall.function.arguments) {
588
+ this.handlers.onToolCallDelta?.(
589
+ actualIndex,
590
+ chunk.toolCall.function.arguments,
591
+ )
592
+ }
593
+
594
+ // Update UIMessage
595
+ if (this.currentAssistantMessageId) {
596
+ this.messages = updateToolCallPart(
597
+ this.messages,
598
+ this.currentAssistantMessageId,
599
+ {
600
+ id: chunk.toolCall.id,
601
+ name: chunk.toolCall.function.name,
602
+ arguments: chunk.toolCall.function.arguments || '',
603
+ state: initialState,
604
+ },
605
+ )
606
+ this.emitMessagesChange()
607
+
608
+ // Emit new granular event
609
+ this.events.onToolCallStateChange?.(
610
+ this.currentAssistantMessageId,
611
+ chunk.toolCall.id,
612
+ initialState,
613
+ chunk.toolCall.function.arguments || '',
614
+ )
615
+ }
616
+ } else {
617
+ // Continuing existing tool call
618
+ const wasAwaitingInput = existingToolCall.state === 'awaiting-input'
619
+
620
+ existingToolCall.arguments += chunk.toolCall.function.arguments || ''
621
+
622
+ // Update state
623
+ if (wasAwaitingInput && chunk.toolCall.function.arguments) {
624
+ existingToolCall.state = 'input-streaming'
625
+ }
626
+
627
+ // Try to parse the updated arguments
628
+ existingToolCall.parsedArguments = this.jsonParser.parse(
629
+ existingToolCall.arguments,
630
+ )
631
+
632
+ // Get actual index for this tool call
633
+ const actualIndex = this.toolCallOrder.indexOf(toolCallId)
634
+
635
+ // Emit legacy state change event
636
+ this.handlers.onToolCallStateChange?.(
637
+ actualIndex,
638
+ existingToolCall.id,
639
+ existingToolCall.name,
640
+ existingToolCall.state,
641
+ existingToolCall.arguments,
642
+ existingToolCall.parsedArguments,
643
+ )
644
+
645
+ // Emit delta
646
+ if (chunk.toolCall.function.arguments) {
647
+ this.handlers.onToolCallDelta?.(
648
+ actualIndex,
649
+ chunk.toolCall.function.arguments,
650
+ )
651
+ }
652
+
653
+ // Update UIMessage
654
+ if (this.currentAssistantMessageId) {
655
+ this.messages = updateToolCallPart(
656
+ this.messages,
657
+ this.currentAssistantMessageId,
658
+ {
659
+ id: existingToolCall.id,
660
+ name: existingToolCall.name,
661
+ arguments: existingToolCall.arguments,
662
+ state: existingToolCall.state,
663
+ },
664
+ )
665
+ this.emitMessagesChange()
666
+
667
+ // Emit new granular event
668
+ this.events.onToolCallStateChange?.(
669
+ this.currentAssistantMessageId,
670
+ existingToolCall.id,
671
+ existingToolCall.state,
672
+ existingToolCall.arguments,
673
+ )
674
+ }
675
+ }
676
+ }
677
+
678
+ /**
679
+ * Handle a tool result chunk
680
+ */
681
+ private handleToolResultChunk(
682
+ chunk: Extract<StreamChunk, { type: 'tool_result' }>,
683
+ ): void {
684
+ const state: ToolResultState = 'complete'
685
+
686
+ // Emit legacy handler
687
+ this.handlers.onToolResultStateChange?.(
688
+ chunk.toolCallId,
689
+ chunk.content,
690
+ state,
691
+ )
692
+
693
+ // Update UIMessage if we have a current assistant message
694
+ if (this.currentAssistantMessageId) {
695
+ this.messages = updateToolResultPart(
696
+ this.messages,
697
+ this.currentAssistantMessageId,
698
+ chunk.toolCallId,
699
+ chunk.content,
700
+ state,
701
+ )
702
+ this.emitMessagesChange()
703
+ }
704
+ }
705
+
706
+ /**
707
+ * Handle a done chunk
708
+ */
709
+ private handleDoneChunk(chunk: Extract<StreamChunk, { type: 'done' }>): void {
710
+ this.finishReason = chunk.finishReason
711
+ this.isDone = true
712
+ this.completeAllToolCalls()
713
+ }
714
+
715
+ /**
716
+ * Handle an error chunk
717
+ */
718
+ private handleErrorChunk(
719
+ chunk: Extract<StreamChunk, { type: 'error' }>,
720
+ ): void {
721
+ // Emit legacy handler
722
+ this.handlers.onError?.(chunk.error)
723
+
724
+ // Emit new event
725
+ this.events.onError?.(new Error(chunk.error.message))
726
+ }
727
+
728
+ /**
729
+ * Handle a thinking chunk
730
+ */
731
+ private handleThinkingChunk(
732
+ chunk: Extract<StreamChunk, { type: 'thinking' }>,
733
+ ): void {
734
+ const previous = this.thinkingContent
735
+ let nextThinking = previous
736
+
737
+ // Prefer delta over content
738
+ if (chunk.delta !== '') {
739
+ nextThinking = previous + chunk.delta
740
+ } else if (chunk.content !== '') {
741
+ if (chunk.content.startsWith(previous)) {
742
+ nextThinking = chunk.content
743
+ } else if (previous.startsWith(chunk.content)) {
744
+ nextThinking = previous
745
+ } else {
746
+ nextThinking = previous + chunk.content
747
+ }
748
+ }
749
+
750
+ this.thinkingContent = nextThinking
751
+
752
+ // Emit legacy handler
753
+ this.handlers.onThinkingUpdate?.(this.thinkingContent)
754
+
755
+ // Update UIMessage
756
+ if (this.currentAssistantMessageId) {
757
+ this.messages = updateThinkingPart(
758
+ this.messages,
759
+ this.currentAssistantMessageId,
760
+ this.thinkingContent,
761
+ )
762
+ this.emitMessagesChange()
763
+
764
+ // Emit new granular event
765
+ this.events.onThinkingUpdate?.(
766
+ this.currentAssistantMessageId,
767
+ this.thinkingContent,
768
+ )
769
+ }
770
+ }
771
+
772
+ /**
773
+ * Handle an approval-requested chunk
774
+ */
775
+ private handleApprovalRequestedChunk(
776
+ chunk: Extract<StreamChunk, { type: 'approval-requested' }>,
777
+ ): void {
778
+ // Emit legacy handler
779
+ this.handlers.onApprovalRequested?.(
780
+ chunk.toolCallId,
781
+ chunk.toolName,
782
+ chunk.input,
783
+ chunk.approval.id,
784
+ )
785
+
786
+ // Update UIMessage with approval metadata
787
+ if (this.currentAssistantMessageId) {
788
+ this.messages = updateToolCallApproval(
789
+ this.messages,
790
+ this.currentAssistantMessageId,
791
+ chunk.toolCallId,
792
+ chunk.approval.id,
793
+ )
794
+ this.emitMessagesChange()
795
+ }
796
+
797
+ // Emit new event
798
+ this.events.onApprovalRequest?.({
799
+ toolCallId: chunk.toolCallId,
800
+ toolName: chunk.toolName,
801
+ input: chunk.input,
802
+ approvalId: chunk.approval.id,
803
+ })
804
+ }
805
+
806
+ /**
807
+ * Handle a tool-input-available chunk
808
+ */
809
+ private handleToolInputAvailableChunk(
810
+ chunk: Extract<StreamChunk, { type: 'tool-input-available' }>,
811
+ ): void {
812
+ // Emit legacy handler
813
+ this.handlers.onToolInputAvailable?.(
814
+ chunk.toolCallId,
815
+ chunk.toolName,
816
+ chunk.input,
817
+ )
818
+
819
+ // Emit new event
820
+ this.events.onToolCall?.({
821
+ toolCallId: chunk.toolCallId,
822
+ toolName: chunk.toolName,
823
+ input: chunk.input,
824
+ })
825
+ }
826
+
827
+ /**
828
+ * Detect if an incoming content chunk represents a NEW text segment
829
+ */
830
+ private isNewTextSegment(
831
+ chunk: Extract<StreamChunk, { type: 'content' }>,
832
+ previous: string,
833
+ ): boolean {
834
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
835
+ if (chunk.delta !== undefined && chunk.content !== undefined) {
836
+ if (chunk.content.length < previous.length) {
837
+ return true
838
+ }
839
+ if (
840
+ !chunk.content.startsWith(previous) &&
841
+ !previous.startsWith(chunk.content)
842
+ ) {
843
+ return true
844
+ }
845
+ }
846
+ return false
847
+ }
848
+
849
+ /**
850
+ * Complete all tool calls
851
+ */
852
+ private completeAllToolCalls(): void {
853
+ this.toolCalls.forEach((toolCall, id) => {
854
+ if (toolCall.state !== 'input-complete') {
855
+ const index = this.toolCallOrder.indexOf(id)
856
+ this.completeToolCall(index, toolCall)
857
+ }
858
+ })
859
+ }
860
+
861
+ /**
862
+ * Mark a tool call as complete and emit event
863
+ */
864
+ private completeToolCall(
865
+ index: number,
866
+ toolCall: InternalToolCallState,
867
+ ): void {
868
+ toolCall.state = 'input-complete'
869
+
870
+ // Try final parse
871
+ toolCall.parsedArguments = this.jsonParser.parse(toolCall.arguments)
872
+
873
+ // Emit legacy state change event
874
+ this.handlers.onToolCallStateChange?.(
875
+ index,
876
+ toolCall.id,
877
+ toolCall.name,
878
+ 'input-complete',
879
+ toolCall.arguments,
880
+ toolCall.parsedArguments,
881
+ )
882
+
883
+ // Emit legacy complete event
884
+ this.handlers.onToolCallComplete?.(
885
+ index,
886
+ toolCall.id,
887
+ toolCall.name,
888
+ toolCall.arguments,
889
+ )
890
+
891
+ // Update UIMessage
892
+ if (this.currentAssistantMessageId) {
893
+ this.messages = updateToolCallPart(
894
+ this.messages,
895
+ this.currentAssistantMessageId,
896
+ {
897
+ id: toolCall.id,
898
+ name: toolCall.name,
899
+ arguments: toolCall.arguments,
900
+ state: 'input-complete',
901
+ },
902
+ )
903
+ this.emitMessagesChange()
904
+
905
+ // Emit new granular event
906
+ this.events.onToolCallStateChange?.(
907
+ this.currentAssistantMessageId,
908
+ toolCall.id,
909
+ 'input-complete',
910
+ toolCall.arguments,
911
+ )
912
+ }
913
+ }
914
+
915
+ /**
916
+ * Emit pending text update
917
+ */
918
+ private emitTextUpdate(): void {
919
+ this.lastEmittedText = this.currentSegmentText
920
+
921
+ // Emit legacy handler
922
+ this.handlers.onTextUpdate?.(this.currentSegmentText)
923
+
924
+ // Update UIMessage
925
+ if (this.currentAssistantMessageId) {
926
+ this.messages = updateTextPart(
927
+ this.messages,
928
+ this.currentAssistantMessageId,
929
+ this.currentSegmentText,
930
+ )
931
+ this.emitMessagesChange()
932
+
933
+ // Emit new granular event
934
+ this.events.onTextUpdate?.(
935
+ this.currentAssistantMessageId,
936
+ this.currentSegmentText,
937
+ )
938
+ }
939
+ }
940
+
941
+ /**
942
+ * Emit messages change event
943
+ */
944
+ private emitMessagesChange(): void {
945
+ this.events.onMessagesChange?.([...this.messages])
946
+ }
947
+
948
+ /**
949
+ * Finalize the stream - complete all pending operations
950
+ */
951
+ finalizeStream(): void {
952
+ // Complete any remaining tool calls
953
+ this.completeAllToolCalls()
954
+
955
+ // Emit any pending text if not already emitted
956
+ if (this.currentSegmentText !== this.lastEmittedText) {
957
+ this.emitTextUpdate()
958
+ }
959
+
960
+ // Emit legacy stream end with total accumulated content
961
+ const toolCalls = this.getCompletedToolCalls()
962
+ this.handlers.onStreamEnd?.(
963
+ this.totalTextContent,
964
+ toolCalls.length > 0 ? toolCalls : undefined,
965
+ )
966
+
967
+ // Emit new stream end event
968
+ if (this.currentAssistantMessageId) {
969
+ const assistantMessage = this.messages.find(
970
+ (m) => m.id === this.currentAssistantMessageId,
971
+ )
972
+ if (assistantMessage) {
973
+ this.events.onStreamEnd?.(assistantMessage)
974
+ }
975
+ }
976
+ }
977
+
978
+ /**
979
+ * Get completed tool calls in API format
980
+ */
981
+ private getCompletedToolCalls(): Array<ToolCall> {
982
+ return Array.from(this.toolCalls.values())
983
+ .filter((tc) => tc.state === 'input-complete')
984
+ .map((tc) => ({
985
+ id: tc.id,
986
+ type: 'function' as const,
987
+ function: {
988
+ name: tc.name,
989
+ arguments: tc.arguments,
990
+ },
991
+ }))
992
+ }
993
+
994
+ /**
995
+ * Get current result
996
+ */
997
+ private getResult(): ProcessorResult {
998
+ const toolCalls = this.getCompletedToolCalls()
999
+ return {
1000
+ content: this.totalTextContent,
1001
+ thinking: this.thinkingContent || undefined,
1002
+ toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
1003
+ finishReason: this.finishReason,
1004
+ }
1005
+ }
1006
+
1007
+ /**
1008
+ * Get current processor state (legacy)
1009
+ */
1010
+ getState(): ProcessorState {
1011
+ return {
1012
+ content: this.totalTextContent,
1013
+ thinking: this.thinkingContent,
1014
+ toolCalls: new Map(this.toolCalls),
1015
+ toolCallOrder: [...this.toolCallOrder],
1016
+ finishReason: this.finishReason,
1017
+ done: this.isDone,
1018
+ }
1019
+ }
1020
+
1021
+ /**
1022
+ * Start recording chunks
1023
+ */
1024
+ startRecording(): void {
1025
+ this.recordingEnabled = true
1026
+ this.recordingStartTime = Date.now()
1027
+ this.recording = {
1028
+ version: '1.0',
1029
+ timestamp: this.recordingStartTime,
1030
+ chunks: [],
1031
+ }
1032
+ }
1033
+
1034
+ /**
1035
+ * Get the current recording
1036
+ */
1037
+ getRecording(): ChunkRecording | null {
1038
+ return this.recording
1039
+ }
1040
+
1041
+ /**
1042
+ * Reset stream state (but keep messages)
1043
+ */
1044
+ private resetStreamState(): void {
1045
+ this.totalTextContent = ''
1046
+ this.currentSegmentText = ''
1047
+ this.lastEmittedText = ''
1048
+ this.thinkingContent = ''
1049
+ this.toolCalls.clear()
1050
+ this.toolCallOrder = []
1051
+ this.finishReason = null
1052
+ this.isDone = false
1053
+ this.hasToolCallsSinceTextStart = false
1054
+ this.chunkStrategy.reset?.()
1055
+ }
1056
+
1057
+ /**
1058
+ * Full reset (including messages)
1059
+ */
1060
+ reset(): void {
1061
+ this.resetStreamState()
1062
+ this.messages = []
1063
+ this.currentAssistantMessageId = null
1064
+ }
1065
+
1066
+ /**
1067
+ * Replay a recording through the processor
1068
+ */
1069
+ static async replay(
1070
+ recording: ChunkRecording,
1071
+ options?: StreamProcessorOptions,
1072
+ ): Promise<ProcessorResult> {
1073
+ const processor = new StreamProcessor(options)
1074
+ return processor.process(createReplayStream(recording))
1075
+ }
1076
+ }
1077
+
1078
+ /**
1079
+ * Create an async iterable from a recording
1080
+ */
1081
+ export function createReplayStream(
1082
+ recording: ChunkRecording,
1083
+ ): AsyncIterable<StreamChunk> {
1084
+ return {
1085
+ // eslint-disable-next-line @typescript-eslint/require-await
1086
+ async *[Symbol.asyncIterator]() {
1087
+ for (const { chunk } of recording.chunks) {
1088
+ yield chunk
1089
+ }
1090
+ },
1091
+ }
1092
+ }