@tanstack/ai 0.2.2 → 0.3.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 (44) hide show
  1. package/README.md +1 -0
  2. package/dist/esm/activities/chat/index.js +264 -186
  3. package/dist/esm/activities/chat/index.js.map +1 -1
  4. package/dist/esm/activities/chat/messages.js +22 -0
  5. package/dist/esm/activities/chat/messages.js.map +1 -1
  6. package/dist/esm/activities/chat/stream/index.d.ts +1 -1
  7. package/dist/esm/activities/chat/stream/processor.d.ts +22 -43
  8. package/dist/esm/activities/chat/stream/processor.js +93 -157
  9. package/dist/esm/activities/chat/stream/processor.js.map +1 -1
  10. package/dist/esm/activities/chat/tools/tool-calls.d.ts +22 -21
  11. package/dist/esm/activities/chat/tools/tool-calls.js +40 -27
  12. package/dist/esm/activities/chat/tools/tool-calls.js.map +1 -1
  13. package/dist/esm/activities/generateImage/index.js +41 -1
  14. package/dist/esm/activities/generateImage/index.js.map +1 -1
  15. package/dist/esm/activities/generateSpeech/index.js +33 -1
  16. package/dist/esm/activities/generateSpeech/index.js.map +1 -1
  17. package/dist/esm/activities/generateTranscription/index.js +29 -1
  18. package/dist/esm/activities/generateTranscription/index.js.map +1 -1
  19. package/dist/esm/activities/generateVideo/index.js +50 -0
  20. package/dist/esm/activities/generateVideo/index.js.map +1 -1
  21. package/dist/esm/activities/summarize/index.js +9 -8
  22. package/dist/esm/activities/summarize/index.js.map +1 -1
  23. package/dist/esm/event-client.d.ts +378 -254
  24. package/dist/esm/event-client.js.map +1 -1
  25. package/dist/esm/index.d.ts +2 -2
  26. package/dist/esm/stream-to-response.d.ts +1 -1
  27. package/dist/esm/stream-to-response.js +5 -3
  28. package/dist/esm/stream-to-response.js.map +1 -1
  29. package/dist/esm/types.d.ts +162 -46
  30. package/package.json +1 -1
  31. package/src/activities/chat/index.ts +362 -216
  32. package/src/activities/chat/messages.ts +40 -1
  33. package/src/activities/chat/stream/index.ts +1 -5
  34. package/src/activities/chat/stream/processor.ts +142 -270
  35. package/src/activities/chat/tools/tool-calls.ts +55 -49
  36. package/src/activities/generateImage/index.ts +47 -2
  37. package/src/activities/generateSpeech/index.ts +37 -1
  38. package/src/activities/generateTranscription/index.ts +33 -1
  39. package/src/activities/generateVideo/index.ts +54 -0
  40. package/src/activities/summarize/index.ts +10 -9
  41. package/src/event-client.ts +460 -256
  42. package/src/index.ts +2 -3
  43. package/src/stream-to-response.ts +8 -6
  44. package/src/types.ts +209 -65
@@ -14,6 +14,7 @@ import {
14
14
  parseWithStandardSchema,
15
15
  } from './tools/schema-converter'
16
16
  import { maxIterations as maxIterationsStrategy } from './agent-loop-strategies'
17
+ import { convertMessagesToModelMessages } from './messages'
17
18
  import type {
18
19
  ApprovalRequest,
19
20
  ClientToolRequest,
@@ -23,14 +24,18 @@ import type { AnyTextAdapter } from './adapter'
23
24
  import type {
24
25
  AgentLoopStrategy,
25
26
  ConstrainedModelMessage,
26
- DoneStreamChunk,
27
27
  InferSchemaType,
28
28
  ModelMessage,
29
+ RunFinishedEvent,
29
30
  SchemaInput,
30
31
  StreamChunk,
32
+ TextMessageContentEvent,
31
33
  TextOptions,
32
34
  Tool,
33
35
  ToolCall,
36
+ ToolCallArgsEvent,
37
+ ToolCallEndEvent,
38
+ ToolCallStartEvent,
34
39
  } from '../../types'
35
40
 
36
41
  // ===========================
@@ -213,11 +218,16 @@ class TextEngine<
213
218
  private totalChunkCount = 0
214
219
  private currentMessageId: string | null = null
215
220
  private accumulatedContent = ''
216
- private doneChunk: DoneStreamChunk | null = null
221
+ private eventOptions?: Record<string, unknown>
222
+ private eventToolNames?: Array<string>
223
+ private finishedEvent: RunFinishedEvent | null = null
217
224
  private shouldEmitStreamEnd = true
218
225
  private earlyTermination = false
219
226
  private toolPhase: ToolPhaseResult = 'continue'
220
227
  private cyclePhase: CyclePhase = 'processText'
228
+ // Client state extracted from initial messages (before conversion to ModelMessage)
229
+ private readonly initialApprovals: Map<string, boolean>
230
+ private readonly initialClientToolResults: Map<string, any>
221
231
 
222
232
  constructor(config: TextEngineConfig<TAdapter, TParams>) {
223
233
  this.adapter = config.adapter
@@ -228,7 +238,21 @@ class TextEngine<
228
238
  config.params.agentLoopStrategy || maxIterationsStrategy(5)
229
239
  this.toolCallManager = new ToolCallManager(this.tools)
230
240
  this.initialMessageCount = config.params.messages.length
231
- this.messages = config.params.messages
241
+
242
+ // Extract client state (approvals, client tool results) from original messages BEFORE conversion
243
+ // This preserves UIMessage parts data that would be lost during conversion to ModelMessage
244
+ const { approvals, clientToolResults } =
245
+ this.extractClientStateFromOriginalMessages(
246
+ config.params.messages as Array<any>,
247
+ )
248
+ this.initialApprovals = approvals
249
+ this.initialClientToolResults = clientToolResults
250
+
251
+ // Convert messages to ModelMessage format (handles both UIMessage and ModelMessage input)
252
+ // This ensures consistent internal format regardless of what the client sends
253
+ this.messages = convertMessagesToModelMessages(
254
+ config.params.messages as Array<any>,
255
+ )
232
256
  this.requestId = this.createId('chat')
233
257
  this.streamId = this.createId('stream')
234
258
  this.effectiveRequest = config.params.abortController
@@ -278,16 +302,7 @@ class TextEngine<
278
302
 
279
303
  private beforeRun(): void {
280
304
  this.streamStartTime = Date.now()
281
- const {
282
- model,
283
- tools,
284
- temperature,
285
- topP,
286
- maxTokens,
287
- metadata,
288
- modelOptions,
289
- conversationId,
290
- } = this.params
305
+ const { tools, temperature, topP, maxTokens, metadata } = this.params
291
306
 
292
307
  // Gather flattened options into an object for event emission
293
308
  const options: Record<string, unknown> = {}
@@ -296,26 +311,49 @@ class TextEngine<
296
311
  if (maxTokens !== undefined) options.maxTokens = maxTokens
297
312
  if (metadata !== undefined) options.metadata = metadata
298
313
 
299
- aiEventClient.emit('text:started', {
300
- requestId: this.requestId,
301
- streamId: this.streamId,
302
- model: model,
303
- provider: this.adapter.name,
304
- messageCount: this.initialMessageCount,
305
- hasTools: !!tools && tools.length > 0,
306
- streaming: true,
314
+ this.eventOptions = Object.keys(options).length > 0 ? options : undefined
315
+ this.eventToolNames = tools?.map((t) => t.name)
316
+
317
+ aiEventClient.emit('text:request:started', {
318
+ ...this.buildTextEventContext(),
307
319
  timestamp: Date.now(),
308
- clientId: conversationId,
309
- toolNames: tools?.map((t) => t.name),
310
- options: Object.keys(options).length > 0 ? options : undefined,
311
- modelOptions: modelOptions as Record<string, unknown> | undefined,
312
320
  })
313
321
 
314
- aiEventClient.emit('stream:started', {
315
- streamId: this.streamId,
316
- model,
317
- provider: this.adapter.name,
318
- timestamp: Date.now(),
322
+ // Always emit messages for tracking:
323
+ // - For existing conversations (with conversationId): only emit the latest user message
324
+ // - For new conversations (without conversationId): emit all messages for reconstruction
325
+ const messagesToEmit = this.params.conversationId
326
+ ? this.messages.slice(-1).filter((m) => m.role === 'user')
327
+ : this.messages
328
+
329
+ messagesToEmit.forEach((message, index) => {
330
+ const messageIndex = this.params.conversationId
331
+ ? this.messages.length - 1
332
+ : index
333
+ const messageId = this.createId('msg')
334
+ const baseContext = this.buildTextEventContext()
335
+ const content = this.getContentString(message.content)
336
+
337
+ aiEventClient.emit('text:message:created', {
338
+ ...baseContext,
339
+ messageId,
340
+ role: message.role,
341
+ content,
342
+ toolCalls: message.toolCalls,
343
+ messageIndex,
344
+ timestamp: Date.now(),
345
+ })
346
+
347
+ if (message.role === 'user') {
348
+ aiEventClient.emit('text:message:user', {
349
+ ...baseContext,
350
+ messageId,
351
+ role: 'user',
352
+ content,
353
+ messageIndex,
354
+ timestamp: Date.now(),
355
+ })
356
+ }
319
357
  })
320
358
  }
321
359
 
@@ -325,23 +363,13 @@ class TextEngine<
325
363
  }
326
364
 
327
365
  const now = Date.now()
328
-
329
- // Emit text:completed with final state
330
- aiEventClient.emit('text:completed', {
331
- requestId: this.requestId,
332
- streamId: this.streamId,
333
- model: this.params.model,
366
+ // Emit text:request:completed with final state
367
+ aiEventClient.emit('text:request:completed', {
368
+ ...this.buildTextEventContext(),
334
369
  content: this.accumulatedContent,
335
370
  messageId: this.currentMessageId || undefined,
336
371
  finishReason: this.lastFinishReason || undefined,
337
- usage: this.doneChunk?.usage,
338
- timestamp: now,
339
- })
340
-
341
- aiEventClient.emit('stream:ended', {
342
- requestId: this.requestId,
343
- streamId: this.streamId,
344
- totalChunks: this.totalChunkCount,
372
+ usage: this.finishedEvent?.usage,
345
373
  duration: now - this.streamStartTime,
346
374
  timestamp: now,
347
375
  })
@@ -366,7 +394,16 @@ class TextEngine<
366
394
  private beginIteration(): void {
367
395
  this.currentMessageId = this.createId('msg')
368
396
  this.accumulatedContent = ''
369
- this.doneChunk = null
397
+ this.finishedEvent = null
398
+
399
+ const baseContext = this.buildTextEventContext()
400
+ aiEventClient.emit('text:message:created', {
401
+ ...baseContext,
402
+ messageId: this.currentMessageId,
403
+ role: 'assistant',
404
+ content: '',
405
+ timestamp: Date.now(),
406
+ })
370
407
  }
371
408
 
372
409
  private async *streamModelResponse(): AsyncGenerator<StreamChunk> {
@@ -413,101 +450,96 @@ class TextEngine<
413
450
 
414
451
  private handleStreamChunk(chunk: StreamChunk): void {
415
452
  switch (chunk.type) {
416
- case 'content':
417
- this.handleContentChunk(chunk)
453
+ // AG-UI Events
454
+ case 'TEXT_MESSAGE_CONTENT':
455
+ this.handleTextMessageContentEvent(chunk)
456
+ break
457
+ case 'TOOL_CALL_START':
458
+ this.handleToolCallStartEvent(chunk)
418
459
  break
419
- case 'tool_call':
420
- this.handleToolCallChunk(chunk)
460
+ case 'TOOL_CALL_ARGS':
461
+ this.handleToolCallArgsEvent(chunk)
421
462
  break
422
- case 'tool_result':
423
- this.handleToolResultChunk(chunk)
463
+ case 'TOOL_CALL_END':
464
+ this.handleToolCallEndEvent(chunk)
424
465
  break
425
- case 'done':
426
- this.handleDoneChunk(chunk)
466
+ case 'RUN_FINISHED':
467
+ this.handleRunFinishedEvent(chunk)
427
468
  break
428
- case 'error':
429
- this.handleErrorChunk(chunk)
469
+ case 'RUN_ERROR':
470
+ this.handleRunErrorEvent(chunk)
430
471
  break
431
- case 'thinking':
432
- this.handleThinkingChunk(chunk)
472
+ case 'STEP_FINISHED':
473
+ this.handleStepFinishedEvent(chunk)
433
474
  break
475
+
434
476
  default:
477
+ // RUN_STARTED, TEXT_MESSAGE_START, TEXT_MESSAGE_END, STEP_STARTED,
478
+ // STATE_SNAPSHOT, STATE_DELTA, CUSTOM
479
+ // - no special handling needed in chat activity
435
480
  break
436
481
  }
437
482
  }
438
483
 
439
- private handleContentChunk(chunk: Extract<StreamChunk, { type: 'content' }>) {
440
- this.accumulatedContent = chunk.content
441
- aiEventClient.emit('stream:chunk:content', {
442
- streamId: this.streamId,
484
+ // ===========================
485
+ // AG-UI Event Handlers
486
+ // ===========================
487
+
488
+ private handleTextMessageContentEvent(chunk: TextMessageContentEvent): void {
489
+ if (chunk.content) {
490
+ this.accumulatedContent = chunk.content
491
+ } else {
492
+ this.accumulatedContent += chunk.delta
493
+ }
494
+ aiEventClient.emit('text:chunk:content', {
495
+ ...this.buildTextEventContext(),
443
496
  messageId: this.currentMessageId || undefined,
444
- content: chunk.content,
497
+ content: this.accumulatedContent,
445
498
  delta: chunk.delta,
446
499
  timestamp: Date.now(),
447
500
  })
448
501
  }
449
502
 
450
- private handleToolCallChunk(
451
- chunk: Extract<StreamChunk, { type: 'tool_call' }>,
452
- ): void {
453
- this.toolCallManager.addToolCallChunk(chunk)
454
- aiEventClient.emit('stream:chunk:tool-call', {
455
- streamId: this.streamId,
503
+ private handleToolCallStartEvent(chunk: ToolCallStartEvent): void {
504
+ this.toolCallManager.addToolCallStartEvent(chunk)
505
+ aiEventClient.emit('text:chunk:tool-call', {
506
+ ...this.buildTextEventContext(),
456
507
  messageId: this.currentMessageId || undefined,
457
- toolCallId: chunk.toolCall.id,
458
- toolName: chunk.toolCall.function.name,
459
- index: chunk.index,
460
- arguments: chunk.toolCall.function.arguments,
508
+ toolCallId: chunk.toolCallId,
509
+ toolName: chunk.toolName,
510
+ index: chunk.index ?? 0,
511
+ arguments: '',
461
512
  timestamp: Date.now(),
462
513
  })
463
514
  }
464
515
 
465
- private handleToolResultChunk(
466
- chunk: Extract<StreamChunk, { type: 'tool_result' }>,
467
- ): void {
468
- aiEventClient.emit('stream:chunk:tool-result', {
469
- streamId: this.streamId,
516
+ private handleToolCallArgsEvent(chunk: ToolCallArgsEvent): void {
517
+ this.toolCallManager.addToolCallArgsEvent(chunk)
518
+ aiEventClient.emit('text:chunk:tool-call', {
519
+ ...this.buildTextEventContext(),
470
520
  messageId: this.currentMessageId || undefined,
471
521
  toolCallId: chunk.toolCallId,
472
- result: chunk.content,
522
+ toolName: '',
523
+ index: 0,
524
+ arguments: chunk.delta,
473
525
  timestamp: Date.now(),
474
526
  })
475
527
  }
476
528
 
477
- private handleDoneChunk(chunk: DoneStreamChunk): void {
478
- // Don't overwrite a tool_calls finishReason with a stop finishReason
479
- // This can happen when adapters send multiple done chunks
480
- if (
481
- this.doneChunk?.finishReason === 'tool_calls' &&
482
- chunk.finishReason === 'stop'
483
- ) {
484
- // Still emit the event and update lastFinishReason, but don't overwrite doneChunk
485
- this.lastFinishReason = chunk.finishReason
486
- aiEventClient.emit('stream:chunk:done', {
487
- streamId: this.streamId,
488
- messageId: this.currentMessageId || undefined,
489
- finishReason: chunk.finishReason,
490
- usage: chunk.usage,
491
- timestamp: Date.now(),
492
- })
493
-
494
- if (chunk.usage) {
495
- aiEventClient.emit('usage:tokens', {
496
- requestId: this.requestId,
497
- streamId: this.streamId,
498
- messageId: this.currentMessageId || undefined,
499
- model: this.params.model,
500
- usage: chunk.usage,
501
- timestamp: Date.now(),
502
- })
503
- }
504
- return
505
- }
529
+ private handleToolCallEndEvent(chunk: ToolCallEndEvent): void {
530
+ this.toolCallManager.completeToolCall(chunk)
531
+ aiEventClient.emit('text:chunk:tool-result', {
532
+ ...this.buildTextEventContext(),
533
+ messageId: this.currentMessageId || undefined,
534
+ toolCallId: chunk.toolCallId,
535
+ result: chunk.result || '',
536
+ timestamp: Date.now(),
537
+ })
538
+ }
506
539
 
507
- this.doneChunk = chunk
508
- this.lastFinishReason = chunk.finishReason
509
- aiEventClient.emit('stream:chunk:done', {
510
- streamId: this.streamId,
540
+ private handleRunFinishedEvent(chunk: RunFinishedEvent): void {
541
+ aiEventClient.emit('text:chunk:done', {
542
+ ...this.buildTextEventContext(),
511
543
  messageId: this.currentMessageId || undefined,
512
544
  finishReason: chunk.finishReason,
513
545
  usage: chunk.usage,
@@ -515,22 +547,32 @@ class TextEngine<
515
547
  })
516
548
 
517
549
  if (chunk.usage) {
518
- aiEventClient.emit('usage:tokens', {
519
- requestId: this.requestId,
520
- streamId: this.streamId,
550
+ aiEventClient.emit('text:usage', {
551
+ ...this.buildTextEventContext(),
521
552
  messageId: this.currentMessageId || undefined,
522
- model: this.params.model,
523
553
  usage: chunk.usage,
524
554
  timestamp: Date.now(),
525
555
  })
526
556
  }
557
+
558
+ // Don't overwrite a tool_calls finishReason with a stop finishReason
559
+ if (
560
+ this.finishedEvent?.finishReason === 'tool_calls' &&
561
+ chunk.finishReason === 'stop'
562
+ ) {
563
+ this.lastFinishReason = chunk.finishReason
564
+ return
565
+ }
566
+
567
+ this.finishedEvent = chunk
568
+ this.lastFinishReason = chunk.finishReason
527
569
  }
528
570
 
529
- private handleErrorChunk(
530
- chunk: Extract<StreamChunk, { type: 'error' }>,
571
+ private handleRunErrorEvent(
572
+ chunk: Extract<StreamChunk, { type: 'RUN_ERROR' }>,
531
573
  ): void {
532
- aiEventClient.emit('stream:chunk:error', {
533
- streamId: this.streamId,
574
+ aiEventClient.emit('text:chunk:error', {
575
+ ...this.buildTextEventContext(),
534
576
  messageId: this.currentMessageId || undefined,
535
577
  error: chunk.error.message,
536
578
  timestamp: Date.now(),
@@ -539,16 +581,19 @@ class TextEngine<
539
581
  this.shouldEmitStreamEnd = false
540
582
  }
541
583
 
542
- private handleThinkingChunk(
543
- chunk: Extract<StreamChunk, { type: 'thinking' }>,
584
+ private handleStepFinishedEvent(
585
+ chunk: Extract<StreamChunk, { type: 'STEP_FINISHED' }>,
544
586
  ): void {
545
- aiEventClient.emit('stream:chunk:thinking', {
546
- streamId: this.streamId,
547
- messageId: this.currentMessageId || undefined,
548
- content: chunk.content,
549
- delta: chunk.delta,
550
- timestamp: Date.now(),
551
- })
587
+ // Handle thinking/reasoning content from STEP_FINISHED events
588
+ if (chunk.content || chunk.delta) {
589
+ aiEventClient.emit('text:chunk:thinking', {
590
+ ...this.buildTextEventContext(),
591
+ messageId: this.currentMessageId || undefined,
592
+ content: chunk.content || '',
593
+ delta: chunk.delta,
594
+ timestamp: Date.now(),
595
+ })
596
+ }
552
597
  }
553
598
 
554
599
  private async *checkForPendingToolCalls(): AsyncGenerator<
@@ -561,16 +606,7 @@ class TextEngine<
561
606
  return 'continue'
562
607
  }
563
608
 
564
- const doneChunk = this.createSyntheticDoneChunk()
565
-
566
- aiEventClient.emit('text:iteration', {
567
- requestId: this.requestId,
568
- streamId: this.streamId,
569
- iterationNumber: this.iterationCount + 1,
570
- messageCount: this.messages.length,
571
- toolCallCount: pendingToolCalls.length,
572
- timestamp: Date.now(),
573
- })
609
+ const finishEvent = this.createSyntheticFinishedEvent()
574
610
 
575
611
  const { approvals, clientToolResults } = this.collectClientState()
576
612
 
@@ -587,14 +623,14 @@ class TextEngine<
587
623
  ) {
588
624
  for (const chunk of this.emitApprovalRequests(
589
625
  executionResult.needsApproval,
590
- doneChunk,
626
+ finishEvent,
591
627
  )) {
592
628
  yield chunk
593
629
  }
594
630
 
595
631
  for (const chunk of this.emitClientToolInputs(
596
632
  executionResult.needsClientExecution,
597
- doneChunk,
633
+ finishEvent,
598
634
  )) {
599
635
  yield chunk
600
636
  }
@@ -605,7 +641,7 @@ class TextEngine<
605
641
 
606
642
  const toolResultChunks = this.emitToolResults(
607
643
  executionResult.results,
608
- doneChunk,
644
+ finishEvent,
609
645
  )
610
646
 
611
647
  for (const chunk of toolResultChunks) {
@@ -622,22 +658,13 @@ class TextEngine<
622
658
  }
623
659
 
624
660
  const toolCalls = this.toolCallManager.getToolCalls()
625
- const doneChunk = this.doneChunk
661
+ const finishEvent = this.finishedEvent
626
662
 
627
- if (!doneChunk || toolCalls.length === 0) {
663
+ if (!finishEvent || toolCalls.length === 0) {
628
664
  this.setToolPhase('stop')
629
665
  return
630
666
  }
631
667
 
632
- aiEventClient.emit('text:iteration', {
633
- requestId: this.requestId,
634
- streamId: this.streamId,
635
- iterationNumber: this.iterationCount + 1,
636
- messageCount: this.messages.length,
637
- toolCallCount: toolCalls.length,
638
- timestamp: Date.now(),
639
- })
640
-
641
668
  this.addAssistantToolCallMessage(toolCalls)
642
669
 
643
670
  const { approvals, clientToolResults } = this.collectClientState()
@@ -655,14 +682,14 @@ class TextEngine<
655
682
  ) {
656
683
  for (const chunk of this.emitApprovalRequests(
657
684
  executionResult.needsApproval,
658
- doneChunk,
685
+ finishEvent,
659
686
  )) {
660
687
  yield chunk
661
688
  }
662
689
 
663
690
  for (const chunk of this.emitClientToolInputs(
664
691
  executionResult.needsClientExecution,
665
- doneChunk,
692
+ finishEvent,
666
693
  )) {
667
694
  yield chunk
668
695
  }
@@ -673,7 +700,7 @@ class TextEngine<
673
700
 
674
701
  const toolResultChunks = this.emitToolResults(
675
702
  executionResult.results,
676
- doneChunk,
703
+ finishEvent,
677
704
  )
678
705
 
679
706
  for (const chunk of toolResultChunks) {
@@ -687,13 +714,14 @@ class TextEngine<
687
714
 
688
715
  private shouldExecuteToolPhase(): boolean {
689
716
  return (
690
- this.doneChunk?.finishReason === 'tool_calls' &&
717
+ this.finishedEvent?.finishReason === 'tool_calls' &&
691
718
  this.tools.length > 0 &&
692
719
  this.toolCallManager.hasToolCalls()
693
720
  )
694
721
  }
695
722
 
696
723
  private addAssistantToolCallMessage(toolCalls: Array<ToolCall>): void {
724
+ const messageId = this.currentMessageId ?? this.createId('msg')
697
725
  this.messages = [
698
726
  ...this.messages,
699
727
  {
@@ -702,36 +730,77 @@ class TextEngine<
702
730
  toolCalls,
703
731
  },
704
732
  ]
733
+
734
+ aiEventClient.emit('text:message:created', {
735
+ ...this.buildTextEventContext(),
736
+ messageId,
737
+ role: 'assistant',
738
+ content: this.accumulatedContent || '',
739
+ toolCalls,
740
+ timestamp: Date.now(),
741
+ })
705
742
  }
706
743
 
707
- private collectClientState(): {
744
+ /**
745
+ * Extract client state (approvals and client tool results) from original messages.
746
+ * This is called in the constructor BEFORE converting to ModelMessage format,
747
+ * because the parts array (which contains approval state) is lost during conversion.
748
+ */
749
+ private extractClientStateFromOriginalMessages(
750
+ originalMessages: Array<any>,
751
+ ): {
708
752
  approvals: Map<string, boolean>
709
753
  clientToolResults: Map<string, any>
710
754
  } {
711
755
  const approvals = new Map<string, boolean>()
712
756
  const clientToolResults = new Map<string, any>()
713
757
 
714
- for (const message of this.messages) {
715
- // todo remove any and fix this
716
- if (message.role === 'assistant' && (message as any).parts) {
717
- const parts = (message as any).parts
718
- for (const part of parts) {
719
- if (
720
- part.type === 'tool-call' &&
721
- part.state === 'approval-responded' &&
722
- part.approval
723
- ) {
724
- approvals.set(part.approval.id, part.approval.approved)
758
+ for (const message of originalMessages) {
759
+ // Check for UIMessage format (parts array) - extract client tool results and approvals
760
+ if (message.role === 'assistant' && message.parts) {
761
+ for (const part of message.parts) {
762
+ if (part.type === 'tool-call') {
763
+ // Extract client tool results (tools without approval that have output)
764
+ if (part.output !== undefined && !part.approval) {
765
+ clientToolResults.set(part.id, part.output)
766
+ }
767
+ // Extract approval responses from UIMessage format parts
768
+ if (
769
+ part.approval?.id &&
770
+ part.approval?.approved !== undefined &&
771
+ part.state === 'approval-responded'
772
+ ) {
773
+ approvals.set(part.approval.id, part.approval.approved)
774
+ }
725
775
  }
776
+ }
777
+ }
778
+ }
726
779
 
727
- if (
728
- part.type === 'tool-call' &&
729
- part.output !== undefined &&
730
- !part.approval
731
- ) {
732
- clientToolResults.set(part.id, part.output)
733
- }
780
+ return { approvals, clientToolResults }
781
+ }
782
+
783
+ private collectClientState(): {
784
+ approvals: Map<string, boolean>
785
+ clientToolResults: Map<string, any>
786
+ } {
787
+ // Start with the initial client state extracted from original messages
788
+ const approvals = new Map(this.initialApprovals)
789
+ const clientToolResults = new Map(this.initialClientToolResults)
790
+
791
+ // Also check current messages for any additional tool results (from server tools)
792
+ for (const message of this.messages) {
793
+ // Check for ModelMessage format (role: 'tool' messages contain tool results)
794
+ // This handles results sent back from the client after executing client-side tools
795
+ if (message.role === 'tool' && message.toolCallId) {
796
+ // Parse content back to original output (was stringified by uiMessageToModelMessages)
797
+ let output: unknown
798
+ try {
799
+ output = JSON.parse(message.content as string)
800
+ } catch {
801
+ output = message.content
734
802
  }
803
+ clientToolResults.set(message.toolCallId, output)
735
804
  }
736
805
  }
737
806
 
@@ -740,13 +809,13 @@ class TextEngine<
740
809
 
741
810
  private emitApprovalRequests(
742
811
  approvals: Array<ApprovalRequest>,
743
- doneChunk: DoneStreamChunk,
812
+ finishEvent: RunFinishedEvent,
744
813
  ): Array<StreamChunk> {
745
814
  const chunks: Array<StreamChunk> = []
746
815
 
747
816
  for (const approval of approvals) {
748
- aiEventClient.emit('stream:approval-requested', {
749
- streamId: this.streamId,
817
+ aiEventClient.emit('tools:approval:requested', {
818
+ ...this.buildTextEventContext(),
750
819
  messageId: this.currentMessageId || undefined,
751
820
  toolCallId: approval.toolCallId,
752
821
  toolName: approval.toolName,
@@ -755,17 +824,20 @@ class TextEngine<
755
824
  timestamp: Date.now(),
756
825
  })
757
826
 
827
+ // Emit a CUSTOM event for approval requests
758
828
  chunks.push({
759
- type: 'approval-requested',
760
- id: doneChunk.id,
761
- model: doneChunk.model,
829
+ type: 'CUSTOM',
762
830
  timestamp: Date.now(),
763
- toolCallId: approval.toolCallId,
764
- toolName: approval.toolName,
765
- input: approval.input,
766
- approval: {
767
- id: approval.approvalId,
768
- needsApproval: true,
831
+ model: finishEvent.model,
832
+ name: 'approval-requested',
833
+ data: {
834
+ toolCallId: approval.toolCallId,
835
+ toolName: approval.toolName,
836
+ input: approval.input,
837
+ approval: {
838
+ id: approval.approvalId,
839
+ needsApproval: true,
840
+ },
769
841
  },
770
842
  })
771
843
  }
@@ -775,13 +847,13 @@ class TextEngine<
775
847
 
776
848
  private emitClientToolInputs(
777
849
  clientRequests: Array<ClientToolRequest>,
778
- doneChunk: DoneStreamChunk,
850
+ finishEvent: RunFinishedEvent,
779
851
  ): Array<StreamChunk> {
780
852
  const chunks: Array<StreamChunk> = []
781
853
 
782
854
  for (const clientTool of clientRequests) {
783
- aiEventClient.emit('stream:tool-input-available', {
784
- streamId: this.streamId,
855
+ aiEventClient.emit('tools:input:available', {
856
+ ...this.buildTextEventContext(),
785
857
  messageId: this.currentMessageId || undefined,
786
858
  toolCallId: clientTool.toolCallId,
787
859
  toolName: clientTool.toolName,
@@ -789,14 +861,17 @@ class TextEngine<
789
861
  timestamp: Date.now(),
790
862
  })
791
863
 
864
+ // Emit a CUSTOM event for client tool inputs
792
865
  chunks.push({
793
- type: 'tool-input-available',
794
- id: doneChunk.id,
795
- model: doneChunk.model,
866
+ type: 'CUSTOM',
796
867
  timestamp: Date.now(),
797
- toolCallId: clientTool.toolCallId,
798
- toolName: clientTool.toolName,
799
- input: clientTool.input,
868
+ model: finishEvent.model,
869
+ name: 'tool-input-available',
870
+ data: {
871
+ toolCallId: clientTool.toolCallId,
872
+ toolName: clientTool.toolName,
873
+ input: clientTool.input,
874
+ },
800
875
  })
801
876
  }
802
877
 
@@ -805,14 +880,13 @@ class TextEngine<
805
880
 
806
881
  private emitToolResults(
807
882
  results: Array<ToolResult>,
808
- doneChunk: DoneStreamChunk,
883
+ finishEvent: RunFinishedEvent,
809
884
  ): Array<StreamChunk> {
810
885
  const chunks: Array<StreamChunk> = []
811
886
 
812
887
  for (const result of results) {
813
- aiEventClient.emit('tool:call-completed', {
814
- requestId: this.requestId,
815
- streamId: this.streamId,
888
+ aiEventClient.emit('tools:call:completed', {
889
+ ...this.buildTextEventContext(),
816
890
  messageId: this.currentMessageId || undefined,
817
891
  toolCallId: result.toolCallId,
818
892
  toolName: result.toolName,
@@ -822,16 +896,16 @@ class TextEngine<
822
896
  })
823
897
 
824
898
  const content = JSON.stringify(result.result)
825
- const chunk: Extract<StreamChunk, { type: 'tool_result' }> = {
826
- type: 'tool_result',
827
- id: doneChunk.id,
828
- model: doneChunk.model,
899
+
900
+ // Emit TOOL_CALL_END event
901
+ chunks.push({
902
+ type: 'TOOL_CALL_END',
829
903
  timestamp: Date.now(),
904
+ model: finishEvent.model,
830
905
  toolCallId: result.toolCallId,
831
- content,
832
- }
833
-
834
- chunks.push(chunk)
906
+ toolName: result.toolName,
907
+ result: content,
908
+ })
835
909
 
836
910
  this.messages = [
837
911
  ...this.messages,
@@ -841,17 +915,45 @@ class TextEngine<
841
915
  toolCallId: result.toolCallId,
842
916
  },
843
917
  ]
918
+
919
+ aiEventClient.emit('text:message:created', {
920
+ ...this.buildTextEventContext(),
921
+ messageId: this.createId('msg'),
922
+ role: 'tool',
923
+ content,
924
+ timestamp: Date.now(),
925
+ })
844
926
  }
845
927
 
846
928
  return chunks
847
929
  }
848
930
 
849
931
  private getPendingToolCallsFromMessages(): Array<ToolCall> {
850
- const completedToolIds = new Set(
851
- this.messages
852
- .filter((message) => message.role === 'tool' && message.toolCallId)
853
- .map((message) => message.toolCallId!), // toolCallId exists due to filter
854
- )
932
+ // Build a set of completed tool IDs, but exclude tools with pendingExecution marker
933
+ // (these are approved tools that still need to execute)
934
+ const completedToolIds = new Set<string>()
935
+
936
+ for (const message of this.messages) {
937
+ if (message.role === 'tool' && message.toolCallId) {
938
+ // Check if this is an approval response with pendingExecution marker
939
+ let hasPendingExecution = false
940
+ if (typeof message.content === 'string') {
941
+ try {
942
+ const parsed = JSON.parse(message.content)
943
+ if (parsed.pendingExecution === true) {
944
+ hasPendingExecution = true
945
+ }
946
+ } catch {
947
+ // Not JSON, treat as regular tool result
948
+ }
949
+ }
950
+
951
+ // Only mark as complete if NOT pending execution
952
+ if (!hasPendingExecution) {
953
+ completedToolIds.add(message.toolCallId)
954
+ }
955
+ }
956
+ }
855
957
 
856
958
  const pending: Array<ToolCall> = []
857
959
 
@@ -868,10 +970,10 @@ class TextEngine<
868
970
  return pending
869
971
  }
870
972
 
871
- private createSyntheticDoneChunk(): DoneStreamChunk {
973
+ private createSyntheticFinishedEvent(): RunFinishedEvent {
872
974
  return {
873
- type: 'done',
874
- id: this.createId('pending'),
975
+ type: 'RUN_FINISHED',
976
+ runId: this.createId('pending'),
875
977
  model: this.params.model,
876
978
  timestamp: Date.now(),
877
979
  finishReason: 'tool_calls',
@@ -896,6 +998,50 @@ class TextEngine<
896
998
  return !!this.effectiveSignal?.aborted
897
999
  }
898
1000
 
1001
+ private buildTextEventContext(): {
1002
+ requestId: string
1003
+ streamId: string
1004
+ provider: string
1005
+ model: string
1006
+ clientId?: string
1007
+ source?: 'client' | 'server'
1008
+ systemPrompts?: Array<string>
1009
+ toolNames?: Array<string>
1010
+ options?: Record<string, unknown>
1011
+ modelOptions?: Record<string, unknown>
1012
+ messageCount: number
1013
+ hasTools: boolean
1014
+ streaming: boolean
1015
+ } {
1016
+ return {
1017
+ requestId: this.requestId,
1018
+ streamId: this.streamId,
1019
+ provider: this.adapter.name,
1020
+ model: this.params.model,
1021
+ clientId: this.params.conversationId,
1022
+ source: 'server',
1023
+ systemPrompts:
1024
+ this.systemPrompts.length > 0 ? this.systemPrompts : undefined,
1025
+ toolNames: this.eventToolNames,
1026
+ options: this.eventOptions,
1027
+ modelOptions: this.params.modelOptions as
1028
+ | Record<string, unknown>
1029
+ | undefined,
1030
+ messageCount: this.initialMessageCount,
1031
+ hasTools: this.tools.length > 0,
1032
+ streaming: true,
1033
+ }
1034
+ }
1035
+
1036
+ private getContentString(content: ModelMessage['content']): string {
1037
+ if (typeof content === 'string') return content
1038
+ const text =
1039
+ content
1040
+ ?.map((part) => (part.type === 'text' ? part.content : ''))
1041
+ .join('') || ''
1042
+ return text
1043
+ }
1044
+
899
1045
  private setToolPhase(phase: ToolPhaseResult): void {
900
1046
  this.toolPhase = phase
901
1047
  if (phase === 'wait') {