@tanstack/ai 0.47.2 → 0.48.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/README.md +2 -1
  2. package/dist/esm/activities/chat/adapter.d.ts +5 -4
  3. package/dist/esm/activities/chat/adapter.js.map +1 -1
  4. package/dist/esm/activities/chat/index.d.ts +4 -7
  5. package/dist/esm/activities/chat/index.js +201 -243
  6. package/dist/esm/activities/chat/index.js.map +1 -1
  7. package/dist/esm/activities/chat/messages.js +126 -31
  8. package/dist/esm/activities/chat/messages.js.map +1 -1
  9. package/dist/esm/activities/chat/stream/processor.d.ts +38 -17
  10. package/dist/esm/activities/chat/stream/processor.js +197 -110
  11. package/dist/esm/activities/chat/stream/processor.js.map +1 -1
  12. package/dist/esm/activities/chat/tools/tool-calls.d.ts +3 -2
  13. package/dist/esm/activities/chat/tools/tool-calls.js +15 -10
  14. package/dist/esm/activities/chat/tools/tool-calls.js.map +1 -1
  15. package/dist/esm/activities/generateVideo/index.js +6 -6
  16. package/dist/esm/activities/generateVideo/index.js.map +1 -1
  17. package/dist/esm/activities/stream-generation-result.js +7 -8
  18. package/dist/esm/activities/stream-generation-result.js.map +1 -1
  19. package/dist/esm/activities/summarize/chat-stream-summarize.d.ts +2 -1
  20. package/dist/esm/activities/summarize/chat-stream-summarize.js +59 -54
  21. package/dist/esm/activities/summarize/chat-stream-summarize.js.map +1 -1
  22. package/dist/esm/adapter-internals.d.ts +2 -0
  23. package/dist/esm/adapter-internals.js +3 -1
  24. package/dist/esm/client.d.ts +8 -1
  25. package/dist/esm/client.js +7 -2
  26. package/dist/esm/client.js.map +1 -1
  27. package/dist/esm/index.d.ts +6 -0
  28. package/dist/esm/index.js +6 -2
  29. package/dist/esm/middlewares/otel.js +12 -5
  30. package/dist/esm/middlewares/otel.js.map +1 -1
  31. package/dist/esm/stream-to-response.js +13 -6
  32. package/dist/esm/stream-to-response.js.map +1 -1
  33. package/dist/esm/stream-to-websocket.js +4 -2
  34. package/dist/esm/stream-to-websocket.js.map +1 -1
  35. package/dist/esm/strip-to-spec-middleware.d.ts +10 -13
  36. package/dist/esm/strip-to-spec-middleware.js +24 -22
  37. package/dist/esm/strip-to-spec-middleware.js.map +1 -1
  38. package/dist/esm/types.d.ts +82 -305
  39. package/dist/esm/utilities/adapter-yield-chunk.d.ts +31 -0
  40. package/dist/esm/utilities/ag-ui-usage.d.ts +24 -0
  41. package/dist/esm/utilities/ag-ui-usage.js +66 -0
  42. package/dist/esm/utilities/ag-ui-usage.js.map +1 -0
  43. package/dist/esm/utilities/ag-ui-wire.d.ts +14 -7
  44. package/dist/esm/utilities/ag-ui-wire.js +71 -30
  45. package/dist/esm/utilities/ag-ui-wire.js.map +1 -1
  46. package/dist/esm/utilities/chat-params.d.ts +3 -3
  47. package/dist/esm/utilities/chat-params.js +10 -30
  48. package/dist/esm/utilities/chat-params.js.map +1 -1
  49. package/dist/esm/utilities/chunk-ids.d.ts +5 -0
  50. package/dist/esm/utilities/chunk-ids.js +25 -0
  51. package/dist/esm/utilities/chunk-ids.js.map +1 -0
  52. package/dist/esm/utilities/errors.d.ts +9 -0
  53. package/dist/esm/utilities/errors.js +13 -1
  54. package/dist/esm/utilities/errors.js.map +1 -1
  55. package/dist/esm/utilities/merge-metadata.d.ts +14 -0
  56. package/dist/esm/utilities/merge-metadata.js +43 -0
  57. package/dist/esm/utilities/merge-metadata.js.map +1 -0
  58. package/dist/esm/utilities/normalize-stream-chunk.d.ts +3 -0
  59. package/dist/esm/utilities/normalize-stream-chunk.js +100 -0
  60. package/dist/esm/utilities/normalize-stream-chunk.js.map +1 -0
  61. package/dist/esm/utilities/reasoning-encrypted-value.d.ts +8 -0
  62. package/dist/esm/utilities/reasoning-encrypted-value.js +16 -0
  63. package/dist/esm/utilities/reasoning-encrypted-value.js.map +1 -0
  64. package/dist/esm/utilities/restore-inbound-chunk.d.ts +15 -0
  65. package/dist/esm/utilities/restore-inbound-chunk.js +43 -0
  66. package/dist/esm/utilities/restore-inbound-chunk.js.map +1 -0
  67. package/dist/esm/utilities/spec-event-keys.d.ts +2 -0
  68. package/dist/esm/utilities/spec-event-keys.js +56 -0
  69. package/dist/esm/utilities/spec-event-keys.js.map +1 -0
  70. package/dist/esm/utilities/structured-output-events.d.ts +3 -3
  71. package/dist/esm/utilities/structured-output-events.js.map +1 -1
  72. package/package.json +3 -3
  73. package/src/activities/chat/adapter.ts +4 -4
  74. package/src/activities/chat/index.ts +398 -400
  75. package/src/activities/chat/messages.ts +183 -36
  76. package/src/activities/chat/stream/processor.ts +313 -239
  77. package/src/activities/chat/tools/tool-calls.ts +23 -22
  78. package/src/activities/generateVideo/index.ts +7 -6
  79. package/src/activities/stream-generation-result.ts +8 -12
  80. package/src/activities/summarize/chat-stream-summarize.ts +98 -70
  81. package/src/adapter-internals.ts +2 -0
  82. package/src/client.ts +13 -0
  83. package/src/index.ts +6 -0
  84. package/src/middlewares/otel.ts +16 -8
  85. package/src/stream-to-response.ts +16 -4
  86. package/src/stream-to-websocket.ts +3 -1
  87. package/src/strip-to-spec-middleware.ts +48 -24
  88. package/src/types.ts +109 -393
  89. package/src/utilities/adapter-yield-chunk.ts +30 -0
  90. package/src/utilities/ag-ui-usage.test.ts +194 -0
  91. package/src/utilities/ag-ui-usage.ts +148 -0
  92. package/src/utilities/ag-ui-wire.ts +149 -31
  93. package/src/utilities/chat-params.ts +22 -39
  94. package/src/utilities/chunk-ids.ts +24 -0
  95. package/src/utilities/errors.ts +23 -0
  96. package/src/utilities/merge-metadata.test.ts +117 -0
  97. package/src/utilities/merge-metadata.ts +59 -0
  98. package/src/utilities/normalize-stream-chunk.test.ts +423 -0
  99. package/src/utilities/normalize-stream-chunk.ts +186 -0
  100. package/src/utilities/reasoning-encrypted-value.ts +18 -0
  101. package/src/utilities/restore-inbound-chunk.test.ts +133 -0
  102. package/src/utilities/restore-inbound-chunk.ts +72 -0
  103. package/src/utilities/spec-event-keys.test.ts +34 -0
  104. package/src/utilities/spec-event-keys.ts +74 -0
  105. package/src/utilities/structured-output-events.ts +3 -3
@@ -7,7 +7,6 @@
7
7
 
8
8
  import { devtoolsMiddleware } from '@tanstack/ai-event-client'
9
9
  import { undoNullWidening } from '@tanstack/ai-utils'
10
- import { stripToSpecMiddleware } from '../../strip-to-spec-middleware'
11
10
  import { streamToText } from '../../stream-to-response.js'
12
11
  import { resolveDebugOption } from '../../logger/resolve'
13
12
  import { EventType } from '../../types'
@@ -33,6 +32,15 @@ import {
33
32
  canonicalInterruptJson,
34
33
  digestInterruptJson,
35
34
  } from '../../interrupt-serialization'
35
+ import { rebuildTokenUsage } from '../../utilities/ag-ui-usage'
36
+ import { uiMessagesToWire } from '../../utilities/ag-ui-wire'
37
+ import {
38
+ tanstackMetadata,
39
+ withTanstackMetadata,
40
+ } from '../../utilities/merge-metadata'
41
+ import { normalizeStreamChunk } from '../../utilities/normalize-stream-chunk'
42
+ import { restorePublicUsage } from '../../utilities/restore-inbound-chunk'
43
+ import type { AdapterYieldChunk } from '../../utilities/adapter-yield-chunk'
36
44
  import { normalizeToolResult } from '../../utilities/tool-result'
37
45
  import { isProviderExecutedToolCall } from '../../utilities/provider-executed'
38
46
  import { LazyToolManager } from './tools/lazy-tool-manager'
@@ -57,7 +65,7 @@ import { isCancelRequestedReason } from './cancel'
57
65
  import {
58
66
  convertMessagesToModelMessages,
59
67
  generateMessageId,
60
- modelMessageToUIMessage,
68
+ modelMessagesToUIMessages,
61
69
  safeJsonStringify,
62
70
  } from './messages'
63
71
  import { MiddlewareRunner } from './middleware/compose'
@@ -88,6 +96,7 @@ import type {
88
96
  import type {
89
97
  AgentLoopStrategy,
90
98
  AnyTool,
99
+ ChatStream,
91
100
  ConstrainedModelMessage,
92
101
  CustomEvent,
93
102
  InferSchemaType,
@@ -107,9 +116,10 @@ import type {
107
116
  TextOptions,
108
117
  ToolCall,
109
118
  ToolCallArgsEvent,
119
+ ReasoningEncryptedValueEvent,
110
120
  ToolCallEndEvent,
121
+ ToolCallResultEvent,
111
122
  ToolCallStartEvent,
112
- TypedStreamChunk,
113
123
  UIMessage,
114
124
  } from '../../types'
115
125
  import type {
@@ -514,7 +524,7 @@ export interface TextActivityOptions<
514
524
  outputSchema?: TSchema
515
525
  /**
516
526
  * Whether to stream the text result.
517
- * When true (default), returns an AsyncIterable<TypedStreamChunk<TTools>> for streaming output.
527
+ * When true (default), returns a ChatStream for streaming output.
518
528
  * When false, returns a Promise<string> with the collected text content.
519
529
  *
520
530
  * Note: If outputSchema is provided, this option is ignored and the result
@@ -636,10 +646,7 @@ export function createChatOptions<
636
646
  * - If outputSchema is provided without explicit stream:true:
637
647
  * Promise<InferSchemaType<TSchema>>.
638
648
  * - If stream is explicitly false (no schema): Promise<string>.
639
- * - Otherwise (default): AsyncIterable<TypedStreamChunk<TTools>>.
640
- *
641
- * When tools with typed schemas are provided, the stream chunks include
642
- * type-safe `toolName` and `input` fields on tool call events.
649
+ * - Otherwise (default): ChatStream.
643
650
  *
644
651
  * `[TStream] extends [true]` is used (not `TStream extends true`) so that the
645
652
  * default `boolean` value of `TStream` does *not* match the streaming branch.
@@ -659,13 +666,9 @@ export type TextActivityResult<
659
666
  : Promise<InferSchemaType<TSchema>>
660
667
  : [TStream] extends [false]
661
668
  ? Promise<string>
662
- : AsyncIterable<
663
- TypedStreamChunk<
664
- TTools extends ReadonlyArray<AnyTool>
665
- ? TTools
666
- : ReadonlyArray<AnyTool>
667
- >
668
- >
669
+ : TTools extends infer _TTools
670
+ ? ChatStream
671
+ : ChatStream
669
672
 
670
673
  // ===========================
671
674
  // ChatEngine Implementation
@@ -804,7 +807,6 @@ class TextEngine<
804
807
  []
805
808
  private currentThinkingContent = ''
806
809
  private currentThinkingSignature = ''
807
- private hasSeenReasoningEvents = false
808
810
  private eventOptions?: Record<string, unknown> | undefined
809
811
  private eventToolNames?: Array<string>
810
812
  private finishedEvent: RunFinishedEvent | null = null
@@ -963,17 +965,12 @@ class TextEngine<
963
965
  this.runIdOverride = config.params.runId
964
966
  this.parentRunIdOverride = config.params.parentRunId
965
967
 
966
- // Initialize middleware — devtools first, strip-to-spec always last.
967
- // handleStreamChunk processes raw chunks BEFORE middleware, so internal
968
- // state management sees extended fields (finishReason, delta, toolCallName, etc.).
969
- // The strip middleware ensures the yielded public stream is AG-UI spec-compliant.
968
+ // Initialize middleware — devtools first. Spec stripping for the AG-UI
969
+ // wire happens in toServerSentEventsStream, so in-process chat()
970
+ // consumers still see TokenUsage and tool aliases.
970
971
  const allMiddleware: Array<
971
972
  ChatMiddleware<TContext, InterruptDefinition<any, any, any, any>>
972
- > = [
973
- devtoolsMiddleware(),
974
- ...(config.middleware || []),
975
- stripToSpecMiddleware(),
976
- ]
973
+ > = [devtoolsMiddleware(), ...(config.middleware || [])]
977
974
  this.middlewareRunner = new MiddlewareRunner(allMiddleware, logger)
978
975
  this.middlewareAbortController = new AbortController()
979
976
  this.toolAbortSignal = combineAbortSignals(
@@ -1156,7 +1153,10 @@ class TextEngine<
1156
1153
  finishReason: this.lastFinishReason,
1157
1154
  duration: Date.now() - this.streamStartTime,
1158
1155
  content: this.accumulatedContent,
1159
- usage: this.finishedEvent?.usage,
1156
+ usage: rebuildTokenUsage(
1157
+ this.finishedEvent?.usage,
1158
+ tanstackMetadata(this.finishedEvent ?? undefined)?.usage,
1159
+ ),
1160
1160
  })
1161
1161
  }
1162
1162
  return
@@ -1304,7 +1304,10 @@ class TextEngine<
1304
1304
  finishReason: this.lastFinishReason,
1305
1305
  duration: Date.now() - this.streamStartTime,
1306
1306
  content: this.accumulatedContent,
1307
- usage: this.finishedEvent?.usage,
1307
+ usage: rebuildTokenUsage(
1308
+ this.finishedEvent?.usage,
1309
+ tanstackMetadata(this.finishedEvent ?? undefined)?.usage,
1310
+ ),
1308
1311
  })
1309
1312
  }
1310
1313
  }
@@ -1316,18 +1319,17 @@ class TextEngine<
1316
1319
  typeof error.continuationRunId === 'string'
1317
1320
  ) {
1318
1321
  this.terminalHookCalled = true
1319
- yield {
1322
+ yield* this.pipeThroughMiddleware({
1320
1323
  type: EventType.RUN_FINISHED,
1321
1324
  timestamp: Date.now(),
1322
1325
  threadId: this.threadId,
1323
1326
  runId: this.runIdOverride ?? this.requestId,
1324
- finishReason: 'stop',
1325
1327
  outcome: { type: 'success' },
1326
1328
  result: {
1327
1329
  replayed: true,
1328
1330
  continuationRunId: error.continuationRunId,
1329
1331
  },
1330
- }
1332
+ })
1331
1333
  return
1332
1334
  }
1333
1335
  const interruptFailure = structuralInterruptFailure(error)
@@ -1429,7 +1431,7 @@ class TextEngine<
1429
1431
  this.accumulatedThinking = []
1430
1432
  this.currentThinkingContent = ''
1431
1433
  this.currentThinkingSignature = ''
1432
- this.hasSeenReasoningEvents = false
1434
+
1433
1435
  this.finishedEvent = null
1434
1436
  this.streamedToolErrorResults.clear()
1435
1437
 
@@ -1494,7 +1496,7 @@ class TextEngine<
1494
1496
  )
1495
1497
  }
1496
1498
 
1497
- for await (const chunk of this.adapter.chatStream({
1499
+ for await (const raw of this.adapter.chatStream({
1498
1500
  model: this.params.model,
1499
1501
  messages: this.messages,
1500
1502
  tools: toolsWithJsonSchemas,
@@ -1518,9 +1520,7 @@ class TextEngine<
1518
1520
 
1519
1521
  this.totalChunkCount++
1520
1522
 
1521
- // Process the original (unstripped) chunk for internal state management
1522
- // BEFORE middleware, so fields like finishReason, delta, etc. are available
1523
- this.handleStreamChunk(chunk)
1523
+ this.handleStreamChunk(raw)
1524
1524
 
1525
1525
  // Native combined mode: synthesize `structured-output.start` BEFORE
1526
1526
  // the first TEXT_MESSAGE_START so the client-side StreamProcessor
@@ -1530,11 +1530,11 @@ class TextEngine<
1530
1530
  // and emitting at run-start would wrap tool-call commentary into a
1531
1531
  // structured-output part too.
1532
1532
  if (
1533
- chunk.type === EventType.CUSTOM &&
1534
- chunk.name === 'structured-output.start'
1533
+ raw.type === EventType.CUSTOM &&
1534
+ raw.name === 'structured-output.start'
1535
1535
  ) {
1536
1536
  this.combinedStartEmitted = true
1537
- const startValue = chunk.value
1537
+ const startValue = raw.value
1538
1538
  if (
1539
1539
  startValue &&
1540
1540
  typeof startValue === 'object' &&
@@ -1546,27 +1546,27 @@ class TextEngine<
1546
1546
  }
1547
1547
  }
1548
1548
 
1549
- let outboundChunk: StreamChunk = chunk
1549
+ let outboundChunk: AdapterYieldChunk = raw
1550
1550
  if (
1551
1551
  this.finalStructuredOutput?.source === 'event' &&
1552
- chunk.type === EventType.CUSTOM &&
1553
- chunk.name === 'structured-output.complete'
1552
+ raw.type === EventType.CUSTOM &&
1553
+ raw.name === 'structured-output.complete'
1554
1554
  ) {
1555
- const parsed = readStructuredOutputCompleteValue(chunk.value)
1555
+ const parsed = readStructuredOutputCompleteValue(raw.value)
1556
1556
  if (parsed) {
1557
1557
  const object = this.finalStructuredOutput.normalize
1558
1558
  ? this.finalStructuredOutput.normalize(parsed.object)
1559
1559
  : parsed.object
1560
1560
  this.structuredOutputResult = { data: object, rawText: parsed.raw }
1561
1561
  this.combinedCompleteEmitted = true
1562
- const value = chunk.value
1562
+ const value = raw.value
1563
1563
  const completeMessageId = readCustomEventMessageId(value)
1564
1564
  if (completeMessageId) {
1565
1565
  this.combinedStructuredMessageId = completeMessageId
1566
1566
  this.captureStructuredOutputMessageIdentity(completeMessageId)
1567
1567
  }
1568
1568
  if (object !== parsed.object && value && typeof value === 'object') {
1569
- outboundChunk = { ...chunk, value: { ...value, object } }
1569
+ outboundChunk = { ...raw, value: { ...value, object } }
1570
1570
  }
1571
1571
  }
1572
1572
  }
@@ -1576,34 +1576,27 @@ class TextEngine<
1576
1576
  this.finalStructuredOutput.yieldChunks &&
1577
1577
  this.finalStructuredOutput.source !== 'event' &&
1578
1578
  !this.combinedStartEmitted &&
1579
- chunk.type === EventType.TEXT_MESSAGE_START
1579
+ raw.type === EventType.TEXT_MESSAGE_START
1580
1580
  ) {
1581
1581
  this.combinedStartEmitted = true
1582
1582
  const messageId =
1583
- typeof chunk.messageId === 'string' && chunk.messageId !== ''
1584
- ? chunk.messageId
1583
+ typeof raw.messageId === 'string' && raw.messageId !== ''
1584
+ ? raw.messageId
1585
1585
  : generateMessageId()
1586
1586
  this.combinedStructuredMessageId = messageId
1587
1587
  const synthStart: StreamChunk = {
1588
1588
  type: EventType.CUSTOM,
1589
1589
  name: 'structured-output.start',
1590
1590
  value: { messageId },
1591
- model: this.params.model,
1592
1591
  timestamp: Date.now(),
1593
- threadId: this.threadId,
1594
- ...(this.runIdOverride ? { runId: this.runIdOverride } : {}),
1595
1592
  }
1596
1593
  const synthOutputs = await this.middlewareRunner.runOnChunk(
1597
1594
  this.middlewareCtx,
1598
1595
  synthStart,
1599
1596
  )
1600
- for (const outputChunk of synthOutputs) {
1601
- yield outputChunk
1602
- this.middlewareCtx.chunkIndex++
1603
- }
1597
+ yield* this.emitPublicChunks(synthOutputs)
1604
1598
  }
1605
1599
 
1606
- // Pipe chunk through middleware (devtools middleware observes; strip-to-spec cleans)
1607
1600
  const outputChunks = await this.middlewareRunner.runOnChunk(
1608
1601
  this.middlewareCtx,
1609
1602
  outboundChunk,
@@ -1620,32 +1613,36 @@ class TextEngine<
1620
1613
  this.finalStructuredOutput.yieldChunks &&
1621
1614
  this.finalStructuredOutput.nativeCombined !== true
1622
1615
  for (const outputChunk of outputChunks) {
1623
- if (
1624
- suppressAgentLifecycle &&
1625
- (outputChunk.type === EventType.RUN_STARTED ||
1626
- outputChunk.type === EventType.RUN_FINISHED)
1627
- ) {
1628
- continue
1629
- }
1630
- if (outputChunk.type === EventType.RUN_FINISHED) {
1631
- this.deferredModelRunFinishedChunks.push(outputChunk)
1632
- continue
1633
- }
1634
- if (this.shouldDeferToolCallRunFinished(outputChunk)) {
1635
- this.deferredToolCallRunFinishedChunks.push(outputChunk)
1636
- continue
1637
- }
1638
- if (outputChunk.type === EventType.RUN_STARTED) {
1639
- this.hasPublicRunStarted = true
1616
+ for (const spec of normalizeStreamChunk(
1617
+ outputChunk as AdapterYieldChunk,
1618
+ )) {
1619
+ restorePublicUsage(spec)
1620
+ if (
1621
+ suppressAgentLifecycle &&
1622
+ (spec.type === EventType.RUN_STARTED ||
1623
+ spec.type === EventType.RUN_FINISHED)
1624
+ ) {
1625
+ continue
1626
+ }
1627
+ if (spec.type === EventType.RUN_FINISHED) {
1628
+ this.deferredModelRunFinishedChunks.push(spec)
1629
+ continue
1630
+ }
1631
+ if (this.shouldDeferToolCallRunFinished(spec)) {
1632
+ this.deferredToolCallRunFinishedChunks.push(spec)
1633
+ continue
1634
+ }
1635
+ if (spec.type === EventType.RUN_STARTED) {
1636
+ this.hasPublicRunStarted = true
1637
+ }
1638
+ this.logger.output(`type=${spec.type}`, { chunk: spec })
1639
+ yield spec
1640
+ this.middlewareCtx.chunkIndex++
1640
1641
  }
1641
- this.logger.output(`type=${outputChunk.type}`, { chunk: outputChunk })
1642
- yield outputChunk
1643
- this.middlewareCtx.chunkIndex++
1644
1642
  }
1645
1643
 
1646
- // Handle usage via middleware
1647
- if (chunk.type === 'RUN_FINISHED' && chunk.usage) {
1648
- await this.middlewareRunner.runOnUsage(this.middlewareCtx, chunk.usage)
1644
+ if (raw.type === EventType.RUN_FINISHED) {
1645
+ await this.runOnUsageFromChunk(raw)
1649
1646
  }
1650
1647
 
1651
1648
  // Drain any sandbox.file events emitted while processing this chunk.
@@ -1660,7 +1657,7 @@ class TextEngine<
1660
1657
  yield* this.drainSandboxFileQueue()
1661
1658
  }
1662
1659
 
1663
- private handleStreamChunk(chunk: StreamChunk): void {
1660
+ private handleStreamChunk(chunk: AdapterYieldChunk): void {
1664
1661
  // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check -- AG-UI EventType enum members vs string-literal case labels; default branch handles untraced events.
1665
1662
  switch (chunk.type) {
1666
1663
  // AG-UI Events
@@ -1681,6 +1678,9 @@ class TextEngine<
1681
1678
  case 'TOOL_CALL_END':
1682
1679
  this.handleToolCallEndEvent(chunk)
1683
1680
  break
1681
+ case 'TOOL_CALL_RESULT':
1682
+ this.handleToolCallResultEvent(chunk)
1683
+ break
1684
1684
  case 'RUN_FINISHED':
1685
1685
  this.handleRunFinishedEvent(chunk)
1686
1686
  break
@@ -1698,8 +1698,8 @@ class TextEngine<
1698
1698
  this.handleReasoningMessageContentEvent(chunk)
1699
1699
  break
1700
1700
 
1701
- case 'TOOL_CALL_RESULT':
1702
- // Tool result is already added to messages in buildToolResultChunks
1701
+ case 'REASONING_ENCRYPTED_VALUE':
1702
+ this.handleReasoningEncryptedValueEvent(chunk)
1703
1703
  break
1704
1704
 
1705
1705
  case 'REASONING_START':
@@ -1721,8 +1721,12 @@ class TextEngine<
1721
1721
  // ===========================
1722
1722
 
1723
1723
  private handleTextMessageContentEvent(chunk: TextMessageContentEvent): void {
1724
- if (chunk.content) {
1725
- this.accumulatedContent = chunk.content
1724
+ const extra = chunk as AdapterYieldChunk
1725
+ // Adapters still emit leftover cumulative `content` on RAW yields.
1726
+ // Alignment suppresses already-delivered deltas, so this snapshot is
1727
+ // what a takeover saves as the full assistant text.
1728
+ if (typeof extra.content === 'string' && extra.content !== '') {
1729
+ this.accumulatedContent = extra.content
1726
1730
  } else {
1727
1731
  this.accumulatedContent += chunk.delta
1728
1732
  }
@@ -1751,6 +1755,26 @@ class TextEngine<
1751
1755
  this.captureStreamMessageIdentity(chunk.parentMessageId)
1752
1756
  }
1753
1757
  this.toolCallManager.addToolCallStartEvent(chunk)
1758
+ const metadata = chunk.metadata
1759
+ const thoughtSignature =
1760
+ metadata != null &&
1761
+ typeof metadata === 'object' &&
1762
+ 'thoughtSignature' in metadata &&
1763
+ typeof metadata.thoughtSignature === 'string' &&
1764
+ metadata.thoughtSignature !== ''
1765
+ ? metadata.thoughtSignature
1766
+ : undefined
1767
+ if (thoughtSignature === undefined) return
1768
+ const call = this.toolCallManager
1769
+ .getToolCalls()
1770
+ .find((candidate) => candidate.id === chunk.toolCallId)
1771
+ if (!call) return
1772
+ call.metadata = {
1773
+ ...(call.metadata != null && typeof call.metadata === 'object'
1774
+ ? call.metadata
1775
+ : {}),
1776
+ thoughtSignature,
1777
+ }
1754
1778
  }
1755
1779
 
1756
1780
  private handleToolCallArgsEvent(chunk: ToolCallArgsEvent): void {
@@ -1759,7 +1783,23 @@ class TextEngine<
1759
1783
 
1760
1784
  private handleToolCallEndEvent(chunk: ToolCallEndEvent): void {
1761
1785
  this.toolCallManager.completeToolCall(chunk)
1762
- if (chunk.state !== 'output-error' || chunk.result === undefined) return
1786
+ const end = chunk as AdapterYieldChunk
1787
+ const state = end.state ?? tanstackMetadata(end)?.state
1788
+ if (state !== 'output-error' || end.result === undefined) return
1789
+ this.handleToolCallResultEvent({
1790
+ type: EventType.TOOL_CALL_RESULT,
1791
+ toolCallId: chunk.toolCallId,
1792
+ content: Array.isArray(end.result)
1793
+ ? JSON.stringify(end.result)
1794
+ : end.result,
1795
+ messageId: chunk.toolCallId,
1796
+ metadata: { tanstack: { state: 'output-error' } },
1797
+ })
1798
+ }
1799
+
1800
+ private handleToolCallResultEvent(chunk: ToolCallResultEvent): void {
1801
+ const isOutputError = tanstackMetadata(chunk)?.state === 'output-error'
1802
+ if (!isOutputError) return
1763
1803
 
1764
1804
  const toolCall = this.toolCallManager
1765
1805
  .getToolCalls()
@@ -1769,15 +1809,35 @@ class TextEngine<
1769
1809
  this.streamedToolErrorResults.set(chunk.toolCallId, {
1770
1810
  toolCallId: chunk.toolCallId,
1771
1811
  toolName: toolCall.function.name,
1772
- result: chunk.result,
1773
- ...(chunk.input !== undefined && { input: chunk.input }),
1812
+ result: chunk.content,
1774
1813
  state: 'output-error',
1775
1814
  })
1776
1815
  }
1777
1816
 
1778
- private handleRunFinishedEvent(chunk: RunFinishedEvent): void {
1779
- this.finishedEvent = chunk
1780
- this.lastFinishReason = chunk.finishReason ?? null
1817
+ private handleRunFinishedEvent(chunk: AdapterYieldChunk): void {
1818
+ this.finishedEvent = chunk as RunFinishedEvent
1819
+ const raw = chunk
1820
+ const top = raw.finishReason
1821
+ this.lastFinishReason =
1822
+ top === 'stop' ||
1823
+ top === 'length' ||
1824
+ top === 'content_filter' ||
1825
+ top === 'tool_calls' ||
1826
+ top === null
1827
+ ? top
1828
+ : (tanstackMetadata(chunk)?.finishReason ?? null)
1829
+ }
1830
+
1831
+ private async runOnUsageFromChunk(
1832
+ chunk: RunFinishedEvent | AdapterYieldChunk,
1833
+ ): Promise<void> {
1834
+ const rebuilt = rebuildTokenUsage(
1835
+ chunk.usage,
1836
+ tanstackMetadata(chunk)?.usage,
1837
+ )
1838
+ if (rebuilt) {
1839
+ await this.middlewareRunner.runOnUsage(this.middlewareCtx, rebuilt)
1840
+ }
1781
1841
  }
1782
1842
 
1783
1843
  private handleRunErrorEvent(
@@ -1785,14 +1845,10 @@ class TextEngine<
1785
1845
  ): void {
1786
1846
  this.earlyTermination = true
1787
1847
  if (this.finalizationError === null) {
1788
- const message = chunk.message || chunk.error?.message || 'Run failed'
1848
+ const message = chunk.message || 'Run failed'
1789
1849
  this.finalizationError = {
1790
1850
  message,
1791
- ...(chunk.code !== undefined
1792
- ? { code: chunk.code }
1793
- : chunk.error?.code !== undefined
1794
- ? { code: chunk.error.code }
1795
- : {}),
1851
+ ...(chunk.code !== undefined ? { code: chunk.code } : {}),
1796
1852
  }
1797
1853
  }
1798
1854
  }
@@ -1814,21 +1870,8 @@ class TextEngine<
1814
1870
  this.finalizeCurrentThinkingStep()
1815
1871
  }
1816
1872
 
1817
- private handleStepFinishedEvent(
1818
- chunk: Extract<StreamChunk, { type: 'STEP_FINISHED' }>,
1819
- ): void {
1820
- if (!this.hasSeenReasoningEvents) {
1821
- if (chunk.delta) {
1822
- this.currentThinkingContent += chunk.delta
1823
- } else if (chunk.content) {
1824
- if (chunk.content.startsWith(this.currentThinkingContent)) {
1825
- this.currentThinkingContent = chunk.content
1826
- } else if (!this.currentThinkingContent.startsWith(chunk.content)) {
1827
- this.currentThinkingContent += chunk.content
1828
- }
1829
- }
1830
- }
1831
- if (chunk.signature) {
1873
+ private handleStepFinishedEvent(chunk: AdapterYieldChunk): void {
1874
+ if (typeof chunk.signature === 'string' && chunk.signature !== '') {
1832
1875
  this.currentThinkingSignature = chunk.signature
1833
1876
  }
1834
1877
  }
@@ -1836,10 +1879,29 @@ class TextEngine<
1836
1879
  private handleReasoningMessageContentEvent(
1837
1880
  chunk: Extract<StreamChunk, { type: 'REASONING_MESSAGE_CONTENT' }>,
1838
1881
  ): void {
1839
- this.hasSeenReasoningEvents = true
1840
1882
  this.currentThinkingContent += chunk.delta
1841
1883
  }
1842
1884
 
1885
+ private handleReasoningEncryptedValueEvent(
1886
+ chunk: ReasoningEncryptedValueEvent,
1887
+ ): void {
1888
+ if (chunk.subtype === 'tool-call') {
1889
+ const call = this.messages
1890
+ .flatMap((message) => message.toolCalls ?? [])
1891
+ .find((toolCall) => toolCall.id === chunk.entityId)
1892
+ if (call) {
1893
+ call.metadata = {
1894
+ ...(call.metadata != null && typeof call.metadata === 'object'
1895
+ ? call.metadata
1896
+ : {}),
1897
+ thoughtSignature: chunk.encryptedValue,
1898
+ }
1899
+ }
1900
+ return
1901
+ }
1902
+ this.currentThinkingSignature = chunk.encryptedValue
1903
+ }
1904
+
1843
1905
  /**
1844
1906
  * Tools available for execution this turn. The discovery tool is dropped
1845
1907
  * from the advertised set (`this.tools`) once every lazy tool is discovered,
@@ -2244,7 +2306,7 @@ class TextEngine<
2244
2306
  private shouldDeferToolCallRunFinished(chunk: StreamChunk): boolean {
2245
2307
  return (
2246
2308
  chunk.type === EventType.RUN_FINISHED &&
2247
- this.finishedEvent?.finishReason === 'tool_calls' &&
2309
+ this.lastFinishReason === 'tool_calls' &&
2248
2310
  this.tools.length > 0 &&
2249
2311
  this.toolCallManager.hasToolCalls()
2250
2312
  )
@@ -2276,7 +2338,6 @@ class TextEngine<
2276
2338
  type: EventType.RUN_STARTED,
2277
2339
  runId: finishEvent.runId,
2278
2340
  threadId: finishEvent.threadId,
2279
- model: finishEvent.model,
2280
2341
  timestamp: Date.now(),
2281
2342
  })
2282
2343
  }
@@ -2289,10 +2350,7 @@ class TextEngine<
2289
2350
  // `stop` is a finished run, not another tool cycle. `tool_calls` here
2290
2351
  // makes the client auto-send after afterTools, so reject looks stuck.
2291
2352
  this.lastFinishReason = 'stop'
2292
- const finishEvent = {
2293
- ...this.createSyntheticFinishedEvent(),
2294
- finishReason: 'stop' as const,
2295
- }
2353
+ const finishEvent = this.createSyntheticFinishedEvent('stop')
2296
2354
  yield* this.emitSyntheticRunStarted(finishEvent)
2297
2355
  yield* this.pipeThroughMiddleware({
2298
2356
  ...finishEvent,
@@ -2307,7 +2365,7 @@ class TextEngine<
2307
2365
 
2308
2366
  private shouldExecuteToolPhase(): boolean {
2309
2367
  return (
2310
- this.finishedEvent?.finishReason === 'tool_calls' &&
2368
+ this.lastFinishReason === 'tool_calls' &&
2311
2369
  this.tools.length > 0 &&
2312
2370
  this.toolCallManager.hasToolCalls()
2313
2371
  )
@@ -2689,41 +2747,18 @@ class TextEngine<
2689
2747
  }
2690
2748
 
2691
2749
  private buildMessagesSnapshotChunk(): StreamChunk {
2692
- const messages: MessagesSnapshotEvent['messages'] = this.messages.map(
2693
- (message, index) => {
2694
- const content =
2695
- typeof message.content === 'string'
2696
- ? message.content
2697
- : message.content === null
2698
- ? undefined
2699
- : JSON.stringify(message.content)
2700
- const id =
2701
- message.id ||
2702
- `snapshot_${this.runIdOverride ?? this.requestId}_${index}`
2703
- const parts =
2704
- message.role === 'assistant' &&
2705
- (message.thinking?.length || message.structuredOutput)
2706
- ? modelMessageToUIMessage(message, id).parts
2707
- : undefined
2708
- return {
2709
- id,
2710
- role: message.role,
2711
- ...(content !== undefined ? { content } : {}),
2712
- ...(parts ? { parts } : {}),
2713
- ...('toolCalls' in message && message.toolCalls
2714
- ? { toolCalls: message.toolCalls }
2715
- : {}),
2716
- ...('toolCallId' in message && message.toolCallId
2717
- ? { toolCallId: message.toolCallId }
2718
- : {}),
2719
- } as MessagesSnapshotEvent['messages'][number]
2720
- },
2721
- )
2750
+ const withIds = this.messages.map((message, index) => ({
2751
+ ...message,
2752
+ id:
2753
+ message.id ||
2754
+ `snapshot_${this.runIdOverride ?? this.requestId}_${index}`,
2755
+ }))
2722
2756
  return {
2723
2757
  type: EventType.MESSAGES_SNAPSHOT,
2724
2758
  timestamp: Date.now(),
2725
- model: this.params.model,
2726
- messages,
2759
+ messages: uiMessagesToWire(
2760
+ modelMessagesToUIMessages(withIds),
2761
+ ) as MessagesSnapshotEvent['messages'],
2727
2762
  }
2728
2763
  }
2729
2764
 
@@ -2803,18 +2838,21 @@ class TextEngine<
2803
2838
 
2804
2839
  private buildInterruptRunErrorChunk(error: unknown): StreamChunk {
2805
2840
  const failure = this.interruptFailure(error)
2806
- return {
2807
- type: EventType.RUN_ERROR,
2808
- timestamp: Date.now(),
2809
- runId: this.runIdOverride ?? this.requestId,
2810
- threadId: this.threadId,
2811
- message: failure.message,
2812
- code: failure.code,
2813
- error: { message: failure.message, code: failure.code },
2814
- ...(failure.errors !== undefined
2815
- ? { 'tanstack:interruptErrors': failure.errors }
2816
- : {}),
2817
- }
2841
+ return withTanstackMetadata(
2842
+ {
2843
+ type: EventType.RUN_ERROR,
2844
+ timestamp: Date.now(),
2845
+ message: failure.message,
2846
+ code: failure.code,
2847
+ },
2848
+ {
2849
+ runId: this.runIdOverride ?? this.requestId,
2850
+ threadId: this.threadId,
2851
+ ...(failure.errors !== undefined
2852
+ ? { interruptErrors: failure.errors }
2853
+ : {}),
2854
+ },
2855
+ ) as StreamChunk
2818
2856
  }
2819
2857
 
2820
2858
  private async *emitInterruptRunError(
@@ -2852,10 +2890,11 @@ class TextEngine<
2852
2890
  )
2853
2891
  let terminalOutputs: Array<StreamChunk>
2854
2892
  try {
2855
- terminalOutputs = await this.middlewareRunner.runOnChunk(
2856
- this.middlewareCtx,
2857
- terminal,
2858
- )
2893
+ terminalOutputs = [
2894
+ ...this.emitPublicChunks(
2895
+ await this.middlewareRunner.runOnChunk(this.middlewareCtx, terminal),
2896
+ ),
2897
+ ]
2859
2898
  } catch (error) {
2860
2899
  yield* this.emitInterruptRunError(error)
2861
2900
  return false
@@ -2866,13 +2905,11 @@ class TextEngine<
2866
2905
  yield* this.pipeThroughMiddleware({
2867
2906
  type: EventType.STATE_SNAPSHOT,
2868
2907
  timestamp: Date.now(),
2869
- model: this.params.model,
2870
2908
  snapshot: this.params.state,
2871
2909
  })
2872
2910
  }
2873
2911
  for (const output of terminalOutputs) {
2874
2912
  yield this.publicInterruptTerminal(output)
2875
- this.middlewareCtx.chunkIndex++
2876
2913
  }
2877
2914
  return true
2878
2915
  }
@@ -3016,18 +3053,17 @@ class TextEngine<
3016
3053
 
3017
3054
  private buildToolResultChunks(
3018
3055
  results: Array<ToolResult>,
3019
- finishEvent: RunFinishedEvent,
3056
+ _finishEvent: RunFinishedEvent,
3020
3057
  argsMap?: Map<string, string>,
3021
- ): Array<StreamChunk> {
3022
- const chunks: Array<StreamChunk> = []
3058
+ ): Array<AdapterYieldChunk> {
3059
+ const chunks: Array<AdapterYieldChunk> = []
3023
3060
 
3024
3061
  for (const result of results) {
3025
3062
  // `content` is the canonical value for the tool `ModelMessage` — it may
3026
3063
  // be an `Array<ContentPart>` (multimodal) which the adapters convert to
3027
3064
  // structured provider output on the next iteration. `wireContent` is the
3028
- // string form emitted on the AG-UI stream events (TOOL_CALL_END.result /
3029
- // TOOL_CALL_RESULT.content are string-only per the AG-UI spec); the
3030
- // multimodal array travels via the message itself, not the wire event.
3065
+ // string form emitted on TOOL_CALL_RESULT.content (string-only per the
3066
+ // AG-UI spec); the multimodal array travels via the message itself.
3031
3067
  const content = normalizeToolResult(result.result)
3032
3068
  const wireContent =
3033
3069
  typeof content === 'string' ? content : JSON.stringify(content)
@@ -3035,12 +3071,10 @@ class TextEngine<
3035
3071
  // argsMap is set only on continuation re-executions, where the adapter
3036
3072
  // never streamed these calls. Otherwise it already emitted END, so a
3037
3073
  // second one here would be an orphan that fails verifyEvents (#519).
3038
- // When we do emit END, attach parsed `input`/`output` for TypedStreamChunk.
3039
3074
  if (argsMap) {
3040
3075
  chunks.push({
3041
- type: 'TOOL_CALL_START',
3076
+ type: EventType.TOOL_CALL_START,
3042
3077
  timestamp: Date.now(),
3043
- model: finishEvent.model,
3044
3078
  toolCallId: result.toolCallId,
3045
3079
  toolCallName: result.toolName,
3046
3080
  toolName: result.toolName,
@@ -3048,39 +3082,35 @@ class TextEngine<
3048
3082
 
3049
3083
  const args = argsMap.get(result.toolCallId) ?? '{}'
3050
3084
  chunks.push({
3051
- type: 'TOOL_CALL_ARGS',
3085
+ type: EventType.TOOL_CALL_ARGS,
3052
3086
  timestamp: Date.now(),
3053
- model: finishEvent.model,
3054
3087
  toolCallId: result.toolCallId,
3055
3088
  delta: args,
3056
- args,
3057
- } as StreamChunk)
3089
+ })
3058
3090
 
3059
3091
  chunks.push({
3060
- type: 'TOOL_CALL_END',
3092
+ type: EventType.TOOL_CALL_END,
3061
3093
  timestamp: Date.now(),
3062
- model: finishEvent.model,
3063
3094
  toolCallId: result.toolCallId,
3064
- toolCallName: result.toolName,
3065
- toolName: result.toolName,
3066
- result: wireContent,
3067
- ...(result.input !== undefined && { input: result.input }),
3068
- ...(result.output !== undefined && { output: result.output }),
3069
- ...(result.state !== undefined && { state: result.state }),
3070
3095
  })
3071
3096
  }
3072
3097
 
3073
- // AG-UI spec TOOL_CALL_RESULT event (content is string-only per spec)
3074
- chunks.push({
3075
- type: 'TOOL_CALL_RESULT',
3098
+ const parentMessageId = [...this.messages]
3099
+ .reverse()
3100
+ .find((message) => message.role === 'assistant')?.id
3101
+ const resultChunk = {
3102
+ type: EventType.TOOL_CALL_RESULT,
3076
3103
  timestamp: Date.now(),
3077
- model: finishEvent.model,
3078
- messageId: this.createId('tool-result'),
3104
+ messageId: parentMessageId || result.toolCallId,
3079
3105
  toolCallId: result.toolCallId,
3080
3106
  content: wireContent,
3081
- role: 'tool',
3082
- ...(result.state !== undefined && { state: result.state }),
3083
- } as StreamChunk)
3107
+ role: 'tool' as const,
3108
+ }
3109
+ chunks.push(
3110
+ (result.state === 'output-error'
3111
+ ? withTanstackMetadata(resultChunk, { state: result.state })
3112
+ : resultChunk) as StreamChunk,
3113
+ )
3084
3114
 
3085
3115
  // If a placeholder tool message exists for this toolCallId (created by
3086
3116
  // uiMessageToModelMessages for an approval-responded part with no
@@ -3218,15 +3248,18 @@ class TextEngine<
3218
3248
  return pending
3219
3249
  }
3220
3250
 
3221
- private createSyntheticFinishedEvent(): RunFinishedEvent {
3222
- return {
3223
- type: 'RUN_FINISHED',
3224
- runId: this.runIdOverride ?? this.requestId,
3225
- threadId: this.threadId,
3226
- model: this.params.model,
3227
- timestamp: Date.now(),
3228
- finishReason: 'tool_calls',
3229
- } as RunFinishedEvent
3251
+ private createSyntheticFinishedEvent(
3252
+ finishReason: 'stop' | 'tool_calls' = 'tool_calls',
3253
+ ): RunFinishedEvent {
3254
+ return withTanstackMetadata(
3255
+ {
3256
+ type: EventType.RUN_FINISHED,
3257
+ runId: this.runIdOverride ?? this.requestId,
3258
+ threadId: this.threadId,
3259
+ timestamp: Date.now(),
3260
+ },
3261
+ { finishReason, model: this.params.model },
3262
+ ) as RunFinishedEvent
3230
3263
  }
3231
3264
 
3232
3265
  private async shouldContinue(): Promise<boolean> {
@@ -3505,14 +3538,11 @@ class TextEngine<
3505
3538
  type: EventType.CUSTOM,
3506
3539
  name: 'structured-output.start',
3507
3540
  value: { messageId: idForStart },
3508
- model: this.params.model,
3509
3541
  timestamp,
3510
- threadId: this.threadId,
3511
- ...(this.runIdOverride ? { runId: this.runIdOverride } : {}),
3512
3542
  }
3513
3543
  }
3514
3544
 
3515
- const pipeThroughMiddleware = async (
3545
+ const runChunkMiddleware = (
3516
3546
  synthChunk: StreamChunk,
3517
3547
  ): Promise<Array<StreamChunk>> =>
3518
3548
  this.middlewareRunner.runOnChunk(this.middlewareCtx, synthChunk)
@@ -3522,152 +3552,153 @@ class TextEngine<
3522
3552
  let runErrorYielded = false
3523
3553
 
3524
3554
  // Pipe chunks through middleware; yield to consumer only when yieldChunks=true
3525
- for await (const chunk of providerStream) {
3555
+ for await (const raw of providerStream) {
3526
3556
  // Honor cancellation between chunks (mirrors streamModelResponse).
3527
3557
  if (this.isCancelled()) {
3528
3558
  break
3529
3559
  }
3530
3560
 
3531
- // Detect adapter-emitted structured-output.start so we don't duplicate
3532
- if (
3533
- !startEmitted &&
3534
- chunk.type === EventType.CUSTOM &&
3535
- chunk.name === 'structured-output.start'
3536
- ) {
3537
- startEmitted = true
3538
- }
3539
-
3540
- // Capture the assistant messageId off any text-message event so the
3541
- // synthesized start (when needed) uses the SAME id the deltas carry
3542
- if (!structuredMessageId) {
3543
- const extracted = extractMessageId(chunk)
3544
- if (extracted) {
3545
- structuredMessageId = extracted
3546
- this.captureStructuredOutputMessageIdentity(extracted)
3547
- }
3548
- }
3549
-
3550
- // Synthesis only matters for the streaming client path — the agentic
3551
- // Promise path consumes chunks internally and returns a Promise, so
3552
- // there's no client-side StreamProcessor to route deltas for.
3553
- if (this.finalStructuredOutput.yieldChunks) {
3554
- // Synthesize start before the FIRST TEXT_MESSAGE_* event
3561
+ {
3562
+ const chunk = raw
3563
+ // Detect adapter-emitted structured-output.start so we don't duplicate
3555
3564
  if (
3556
3565
  !startEmitted &&
3557
- (chunk.type === EventType.TEXT_MESSAGE_START ||
3558
- chunk.type === EventType.TEXT_MESSAGE_CONTENT ||
3559
- chunk.type === EventType.TEXT_MESSAGE_END)
3566
+ chunk.type === EventType.CUSTOM &&
3567
+ chunk.name === 'structured-output.start'
3560
3568
  ) {
3561
3569
  startEmitted = true
3562
- const synthStart = buildSynthesizedStart(chunk.timestamp)
3563
- const synthOutputs = await pipeThroughMiddleware(synthStart)
3564
- for (const outputChunk of synthOutputs) {
3565
- yield outputChunk
3566
- this.middlewareCtx.chunkIndex++
3567
- }
3568
3570
  }
3569
3571
 
3570
- // Synthesize start before a pre-delta RUN_ERROR so the client can
3571
- // construct an errored placeholder structured-output part instead
3572
- // of a silent UI.
3573
- if (!startEmitted && chunk.type === EventType.RUN_ERROR) {
3574
- startEmitted = true
3575
- const synthStart = buildSynthesizedStart(chunk.timestamp)
3576
- const synthOutputs = await pipeThroughMiddleware(synthStart)
3577
- for (const outputChunk of synthOutputs) {
3578
- yield outputChunk
3579
- this.middlewareCtx.chunkIndex++
3572
+ // Capture the assistant messageId off any text-message event so the
3573
+ // synthesized start (when needed) uses the SAME id the deltas carry
3574
+ if (!structuredMessageId) {
3575
+ const extracted = extractMessageId(chunk)
3576
+ if (extracted) {
3577
+ structuredMessageId = extracted
3578
+ this.captureStructuredOutputMessageIdentity(extracted)
3580
3579
  }
3581
3580
  }
3582
- }
3583
3581
 
3584
- // 7a. Targeted state updates only.
3585
- // We deliberately do NOT call `handleStreamChunk(chunk)` here — that
3586
- // would mutate agent-loop state with finalization data:
3587
- // - TEXT_MESSAGE_CONTENT deltas would pollute `accumulatedContent`
3588
- // (raw JSON would leak into `info.content` on onFinish)
3589
- // - RUN_FINISHED would overwrite `finishedEvent` + `lastFinishReason`
3590
- // (finalization's 'stop' would overwrite the agent-loop's real
3591
- // finish reason)
3592
- // - STEP_FINISHED would pollute `currentThinkingContent`
3593
- // Finalization is a separate phase from the agent loop; its state must
3594
- // not cross-contaminate. The explicit branches below capture the only
3595
- // bits we actually need from this stream.
3596
- // All narrowing below is via the discriminated-union `chunk.type`
3597
- // — no `as` casts.
3598
-
3599
- // The chunk forwarded to middleware/consumers. Replaced below only for
3600
- // the structured-output.complete event, whose `object` we normalize
3601
- // (un-widen) so streaming consumers see the same cleaned payload the
3602
- // Promise<T> path validates and returns.
3603
- let outboundChunk: StreamChunk = chunk
3582
+ // Synthesis only matters for the streaming client path — the agentic
3583
+ // Promise path consumes chunks internally and returns a Promise, so
3584
+ // there's no client-side StreamProcessor to route deltas for.
3585
+ if (this.finalStructuredOutput.yieldChunks) {
3586
+ // Synthesize start before the FIRST TEXT_MESSAGE_* event
3587
+ if (
3588
+ !startEmitted &&
3589
+ (chunk.type === EventType.TEXT_MESSAGE_START ||
3590
+ chunk.type === EventType.TEXT_MESSAGE_CONTENT ||
3591
+ chunk.type === EventType.TEXT_MESSAGE_END)
3592
+ ) {
3593
+ startEmitted = true
3594
+ const synthStart = buildSynthesizedStart(chunk.timestamp)
3595
+ yield* this.emitPublicChunks(await runChunkMiddleware(synthStart))
3596
+ }
3604
3597
 
3605
- if (
3606
- chunk.type === EventType.CUSTOM &&
3607
- chunk.name === 'structured-output.complete'
3608
- ) {
3609
- const parsed = readStructuredOutputCompleteValue(chunk.value)
3610
- if (parsed) {
3611
- const object = this.finalStructuredOutput.normalize
3612
- ? this.finalStructuredOutput.normalize(parsed.object)
3613
- : parsed.object
3614
- this.structuredOutputResult = {
3615
- data: object,
3616
- rawText: parsed.raw,
3617
- ...(parsed.reasoning !== undefined
3618
- ? { reasoning: parsed.reasoning }
3619
- : {}),
3598
+ // Synthesize start before a pre-delta RUN_ERROR so the client can
3599
+ // construct an errored placeholder structured-output part instead
3600
+ // of a silent UI.
3601
+ if (!startEmitted && chunk.type === EventType.RUN_ERROR) {
3602
+ startEmitted = true
3603
+ const synthStart = buildSynthesizedStart(chunk.timestamp)
3604
+ yield* this.emitPublicChunks(await runChunkMiddleware(synthStart))
3620
3605
  }
3621
- // Rewrite the outbound event so the yielded chunk carries the
3622
- // normalized object (the original `chunk.value` still holds the
3623
- // widened one). Preserve every other field — `raw`, `reasoning` —
3624
- // by spreading the original value.
3625
- const value = chunk.value
3626
- if (object !== parsed.object && value && typeof value === 'object') {
3627
- outboundChunk = { ...chunk, value: { ...value, object } }
3606
+ }
3607
+
3608
+ // 7a. Targeted state updates only.
3609
+ // We deliberately do NOT call `handleStreamChunk(chunk)` here — that
3610
+ // would mutate agent-loop state with finalization data:
3611
+ // - TEXT_MESSAGE_CONTENT deltas would pollute `accumulatedContent`
3612
+ // (raw JSON would leak into `info.content` on onFinish)
3613
+ // - RUN_FINISHED would overwrite `finishedEvent` + `lastFinishReason`
3614
+ // (finalization's 'stop' would overwrite the agent-loop's real
3615
+ // finish reason)
3616
+ // - STEP_FINISHED would pollute `currentThinkingContent`
3617
+ // Finalization is a separate phase from the agent loop; its state must
3618
+ // not cross-contaminate. The explicit branches below capture the only
3619
+ // bits we actually need from this stream.
3620
+ // All narrowing below is via the discriminated-union `chunk.type`
3621
+ // — no `as` casts.
3622
+
3623
+ // The chunk forwarded to middleware/consumers. Replaced below only for
3624
+ // the structured-output.complete event, whose `object` we normalize
3625
+ // (un-widen) so streaming consumers see the same cleaned payload the
3626
+ // Promise<T> path validates and returns.
3627
+ let outboundChunk: StreamChunk = chunk
3628
+
3629
+ if (
3630
+ chunk.type === EventType.CUSTOM &&
3631
+ chunk.name === 'structured-output.complete'
3632
+ ) {
3633
+ const parsed = readStructuredOutputCompleteValue(chunk.value)
3634
+ if (parsed) {
3635
+ const object = this.finalStructuredOutput.normalize
3636
+ ? this.finalStructuredOutput.normalize(parsed.object)
3637
+ : parsed.object
3638
+ this.structuredOutputResult = {
3639
+ data: object,
3640
+ rawText: parsed.raw,
3641
+ ...(parsed.reasoning !== undefined
3642
+ ? { reasoning: parsed.reasoning }
3643
+ : {}),
3644
+ }
3645
+ // Rewrite the outbound event so the yielded chunk carries the
3646
+ // normalized object (the original `chunk.value` still holds the
3647
+ // widened one). Preserve every other field — `raw`, `reasoning` —
3648
+ // by spreading the original value.
3649
+ const value = chunk.value
3650
+ if (
3651
+ object !== parsed.object &&
3652
+ value &&
3653
+ typeof value === 'object'
3654
+ ) {
3655
+ outboundChunk = { ...chunk, value: { ...value, object } }
3656
+ }
3628
3657
  }
3629
3658
  }
3630
- }
3631
3659
 
3632
- if (chunk.type === EventType.RUN_FINISHED && chunk.usage) {
3633
- // RunFinishedEvent already exposes `usage` after type narrowing.
3634
- await this.middlewareRunner.runOnUsage(this.middlewareCtx, chunk.usage)
3635
- }
3660
+ if (chunk.type === EventType.RUN_FINISHED) {
3661
+ await this.runOnUsageFromChunk(chunk)
3662
+ }
3636
3663
 
3637
- if (chunk.type === EventType.RUN_ERROR) {
3638
- // RunErrorEvent already exposes `message` and `code` after narrowing.
3639
- this.finalizationError = {
3640
- message: chunk.message,
3641
- ...(chunk.code ? { code: chunk.code } : {}),
3642
- ...(fallbackAdapterError !== undefined
3643
- ? { cause: fallbackAdapterError }
3644
- : {}),
3664
+ if (chunk.type === EventType.RUN_ERROR) {
3665
+ // RunErrorEvent already exposes `message` and `code` after narrowing.
3666
+ this.finalizationError = {
3667
+ message: chunk.message,
3668
+ ...(chunk.code ? { code: chunk.code } : {}),
3669
+ ...(fallbackAdapterError !== undefined
3670
+ ? { cause: fallbackAdapterError }
3671
+ : {}),
3672
+ }
3645
3673
  }
3646
- }
3647
3674
 
3648
- // 7b. Pipe through middleware
3649
- const outputChunks = await this.middlewareRunner.runOnChunk(
3650
- this.middlewareCtx,
3651
- outboundChunk,
3652
- )
3675
+ // 7b. Pipe through middleware
3676
+ const outputChunks = await this.middlewareRunner.runOnChunk(
3677
+ this.middlewareCtx,
3678
+ outboundChunk,
3679
+ )
3653
3680
 
3654
- // 7c. Decide consumer visibility — only yieldChunks=true callers get them.
3655
- // We do NOT strip the finalization stream's RUN_STARTED/RUN_FINISHED:
3656
- // they are the single outer lifecycle pair the consumer sees (the
3657
- // agent-loop's pair was suppressed in streamModelResponse when
3658
- // finalStructuredOutput.yieldChunks is true).
3659
- if (this.finalStructuredOutput.yieldChunks) {
3660
- for (const outputChunk of outputChunks) {
3661
- if (outputChunk.type === EventType.RUN_ERROR) {
3662
- runErrorYielded = true
3681
+ // 7c. Decide consumer visibility — only yieldChunks=true callers get them.
3682
+ // We do NOT strip the finalization stream's RUN_STARTED/RUN_FINISHED:
3683
+ // they are the single outer lifecycle pair the consumer sees (the
3684
+ // agent-loop's pair was suppressed in streamModelResponse when
3685
+ // finalStructuredOutput.yieldChunks is true).
3686
+ if (this.finalStructuredOutput.yieldChunks) {
3687
+ for (const spec of this.emitPublicChunks(outputChunks)) {
3688
+ if (spec.type === EventType.RUN_ERROR) {
3689
+ runErrorYielded = true
3690
+ }
3691
+ yield spec
3663
3692
  }
3664
- yield outputChunk
3665
- this.middlewareCtx.chunkIndex++
3693
+ }
3694
+
3695
+ // 7d. Terminate on error
3696
+ if (this.finalizationError) {
3697
+ break
3666
3698
  }
3667
3699
  }
3668
3700
 
3669
- // 7d. Terminate on error
3670
- if (this.finalizationError) {
3701
+ if (this.isCancelled() || this.finalizationError) {
3671
3702
  break
3672
3703
  }
3673
3704
  }
@@ -3730,39 +3761,21 @@ class TextEngine<
3730
3761
  // `StructuredOutputPart` instead of dropping it as an orphan error.
3731
3762
  if (!startEmitted) {
3732
3763
  const synthStart = buildSynthesizedStart()
3733
- const startOutputs = await pipeThroughMiddleware(synthStart)
3734
- for (const outputChunk of startOutputs) {
3735
- yield outputChunk
3736
- this.middlewareCtx.chunkIndex++
3737
- }
3764
+ yield* this.emitPublicChunks(await runChunkMiddleware(synthStart))
3738
3765
  startEmitted = true
3739
3766
  }
3740
3767
 
3741
3768
  const errChunk: StreamChunk = {
3742
3769
  type: EventType.RUN_ERROR,
3743
- runId: this.runIdOverride ?? this.requestId,
3744
- model: this.params.model,
3745
3770
  timestamp: Date.now(),
3746
- threadId: this.threadId,
3747
3771
  message: this.finalizationError.message,
3748
3772
  ...(this.finalizationError.code
3749
3773
  ? { code: this.finalizationError.code }
3750
3774
  : {}),
3751
- error: {
3752
- message: this.finalizationError.message,
3753
- ...(this.finalizationError.code
3754
- ? { code: this.finalizationError.code }
3755
- : {}),
3756
- },
3757
3775
  }
3758
- const outputChunks = await this.middlewareRunner.runOnChunk(
3759
- this.middlewareCtx,
3760
- errChunk,
3776
+ yield* this.emitPublicChunks(
3777
+ await this.middlewareRunner.runOnChunk(this.middlewareCtx, errChunk),
3761
3778
  )
3762
- for (const outputChunk of outputChunks) {
3763
- yield outputChunk
3764
- this.middlewareCtx.chunkIndex++
3765
- }
3766
3779
  }
3767
3780
  }
3768
3781
 
@@ -3878,19 +3891,11 @@ class TextEngine<
3878
3891
  type: EventType.CUSTOM,
3879
3892
  name: 'structured-output.start',
3880
3893
  value: { messageId },
3881
- model: this.params.model,
3882
3894
  timestamp: Date.now(),
3883
- threadId: this.threadId,
3884
- ...(this.runIdOverride ? { runId: this.runIdOverride } : {}),
3885
3895
  }
3886
- const startOutputs = await this.middlewareRunner.runOnChunk(
3887
- this.middlewareCtx,
3888
- synthStart,
3896
+ yield* this.emitPublicChunks(
3897
+ await this.middlewareRunner.runOnChunk(this.middlewareCtx, synthStart),
3889
3898
  )
3890
- for (const outputChunk of startOutputs) {
3891
- yield outputChunk
3892
- this.middlewareCtx.chunkIndex++
3893
- }
3894
3899
  }
3895
3900
 
3896
3901
  // On success, emit the synthetic `structured-output.complete` carrying
@@ -3915,19 +3920,14 @@ class TextEngine<
3915
3920
  ? { messageId: this.combinedStructuredMessageId }
3916
3921
  : {}),
3917
3922
  },
3918
- model: this.params.model,
3919
3923
  timestamp: Date.now(),
3920
- threadId: this.threadId,
3921
- ...(this.runIdOverride ? { runId: this.runIdOverride } : {}),
3922
3924
  }
3923
- const completeOutputs = await this.middlewareRunner.runOnChunk(
3924
- this.middlewareCtx,
3925
- completeChunk,
3925
+ yield* this.emitPublicChunks(
3926
+ await this.middlewareRunner.runOnChunk(
3927
+ this.middlewareCtx,
3928
+ completeChunk,
3929
+ ),
3926
3930
  )
3927
- for (const outputChunk of completeOutputs) {
3928
- yield outputChunk
3929
- this.middlewareCtx.chunkIndex++
3930
- }
3931
3931
  }
3932
3932
 
3933
3933
  // On failure, emit a synthetic RUN_ERROR so the streaming consumer's
@@ -3935,29 +3935,15 @@ class TextEngine<
3935
3935
  if (this.finalizationError) {
3936
3936
  const errChunk: StreamChunk = {
3937
3937
  type: EventType.RUN_ERROR,
3938
- runId: this.runIdOverride ?? this.requestId,
3939
- model: this.params.model,
3940
3938
  timestamp: Date.now(),
3941
- threadId: this.threadId,
3942
3939
  message: this.finalizationError.message,
3943
3940
  ...(this.finalizationError.code
3944
3941
  ? { code: this.finalizationError.code }
3945
3942
  : {}),
3946
- error: {
3947
- message: this.finalizationError.message,
3948
- ...(this.finalizationError.code
3949
- ? { code: this.finalizationError.code }
3950
- : {}),
3951
- },
3952
3943
  }
3953
- const errOutputs = await this.middlewareRunner.runOnChunk(
3954
- this.middlewareCtx,
3955
- errChunk,
3944
+ yield* this.emitPublicChunks(
3945
+ await this.middlewareRunner.runOnChunk(this.middlewareCtx, errChunk),
3956
3946
  )
3957
- for (const outputChunk of errOutputs) {
3958
- yield outputChunk
3959
- this.middlewareCtx.chunkIndex++
3960
- }
3961
3947
  }
3962
3948
  }
3963
3949
 
@@ -4404,23 +4390,36 @@ class TextEngine<
4404
4390
  }
4405
4391
 
4406
4392
  /**
4407
- * Pipe a single chunk through the middleware pipeline (strip-to-spec, devtools, etc.)
4408
- * and yield all resulting output chunks.
4393
+ * Spec-normalize middleware output, then yield to the public iterable.
4394
+ * Engine state and `onChunk` already saw the raw chunk.
4395
+ */
4396
+ private *emitPublicChunks(
4397
+ outputs: Array<StreamChunk>,
4398
+ ): Generator<StreamChunk, void, void> {
4399
+ for (const output of outputs) {
4400
+ for (const spec of normalizeStreamChunk(output as AdapterYieldChunk)) {
4401
+ restorePublicUsage(spec)
4402
+ if (spec.type === EventType.RUN_STARTED) {
4403
+ this.hasPublicRunStarted = true
4404
+ }
4405
+ yield spec
4406
+ this.middlewareCtx.chunkIndex++
4407
+ }
4408
+ }
4409
+ }
4410
+
4411
+ /**
4412
+ * Pipe a single internal chunk through middleware, then spec-normalize
4413
+ * before the public `for await` stream.
4409
4414
  */
4410
4415
  private async *pipeThroughMiddleware(
4411
- chunk: StreamChunk,
4416
+ chunk: AdapterYieldChunk,
4412
4417
  ): AsyncGenerator<StreamChunk, void, void> {
4413
- const outputChunks = await this.middlewareRunner.runOnChunk(
4418
+ const afterMw = await this.middlewareRunner.runOnChunk(
4414
4419
  this.middlewareCtx,
4415
4420
  chunk,
4416
4421
  )
4417
- for (const outputChunk of outputChunks) {
4418
- if (outputChunk.type === EventType.RUN_STARTED) {
4419
- this.hasPublicRunStarted = true
4420
- }
4421
- yield outputChunk
4422
- this.middlewareCtx.chunkIndex++
4423
- }
4422
+ yield* this.emitPublicChunks(afterMw)
4424
4423
  }
4425
4424
 
4426
4425
  /**
@@ -4470,9 +4469,8 @@ class TextEngine<
4470
4469
  value: Record<string, any>,
4471
4470
  ): CustomEvent {
4472
4471
  return {
4473
- type: 'CUSTOM',
4472
+ type: EventType.CUSTOM,
4474
4473
  timestamp: Date.now(),
4475
- model: this.params.model,
4476
4474
  name: eventName,
4477
4475
  value,
4478
4476
  }
@@ -4983,7 +4981,7 @@ async function* fallbackStructuredOutputStream(
4983
4981
  adapter: AnyTextAdapter,
4984
4982
  options: StructuredOutputOptions<Record<string, unknown>>,
4985
4983
  onAdapterError?: (err: unknown) => void,
4986
- ): AsyncIterable<StreamChunk> {
4984
+ ): AsyncIterable<AdapterYieldChunk> {
4987
4985
  const { chatOptions } = options
4988
4986
  // Synthesize run/thread/message IDs only when the caller didn't supply them.
4989
4987
  // Prefix `fallback-` (not `mock-`) because this is production fallback code