@tanstack/ai 0.42.0 → 0.43.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 (272) hide show
  1. package/README.md +15 -1
  2. package/dist/esm/activities/chat/adapter.js +23 -16
  3. package/dist/esm/activities/chat/adapter.js.map +1 -1
  4. package/dist/esm/activities/chat/agent-loop-strategies.d.ts +5 -36
  5. package/dist/esm/activities/chat/agent-loop-strategies.js +75 -21
  6. package/dist/esm/activities/chat/agent-loop-strategies.js.map +1 -1
  7. package/dist/esm/activities/chat/cancel.d.ts +40 -0
  8. package/dist/esm/activities/chat/cancel.js +54 -0
  9. package/dist/esm/activities/chat/cancel.js.map +1 -0
  10. package/dist/esm/activities/chat/index.d.ts +28 -21
  11. package/dist/esm/activities/chat/index.js +2100 -1813
  12. package/dist/esm/activities/chat/index.js.map +1 -1
  13. package/dist/esm/activities/chat/mcp/manager.d.ts +2 -2
  14. package/dist/esm/activities/chat/mcp/manager.js +90 -77
  15. package/dist/esm/activities/chat/mcp/manager.js.map +1 -1
  16. package/dist/esm/activities/chat/mcp/types.d.ts +2 -2
  17. package/dist/esm/activities/chat/messages.js +397 -346
  18. package/dist/esm/activities/chat/messages.js.map +1 -1
  19. package/dist/esm/activities/chat/middleware/builder.js +17 -15
  20. package/dist/esm/activities/chat/middleware/builder.js.map +1 -1
  21. package/dist/esm/activities/chat/middleware/capabilities.js +78 -43
  22. package/dist/esm/activities/chat/middleware/capabilities.js.map +1 -1
  23. package/dist/esm/activities/chat/middleware/compose.d.ts +94 -1
  24. package/dist/esm/activities/chat/middleware/compose.js +623 -531
  25. package/dist/esm/activities/chat/middleware/compose.js.map +1 -1
  26. package/dist/esm/activities/chat/middleware/define.js +12 -5
  27. package/dist/esm/activities/chat/middleware/define.js.map +1 -1
  28. package/dist/esm/activities/chat/middleware/index.d.ts +5 -1
  29. package/dist/esm/activities/chat/middleware/locks.d.ts +50 -0
  30. package/dist/esm/activities/chat/middleware/locks.js +71 -0
  31. package/dist/esm/activities/chat/middleware/locks.js.map +1 -0
  32. package/dist/esm/activities/chat/middleware/pending-turn.d.ts +15 -0
  33. package/dist/esm/activities/chat/middleware/pending-turn.js +35 -0
  34. package/dist/esm/activities/chat/middleware/pending-turn.js.map +1 -0
  35. package/dist/esm/activities/chat/middleware/run-disconnect.d.ts +23 -0
  36. package/dist/esm/activities/chat/middleware/run-disconnect.js +42 -0
  37. package/dist/esm/activities/chat/middleware/run-disconnect.js.map +1 -0
  38. package/dist/esm/activities/chat/middleware/run-store.d.ts +283 -0
  39. package/dist/esm/activities/chat/middleware/run-store.js +176 -0
  40. package/dist/esm/activities/chat/middleware/run-store.js.map +1 -0
  41. package/dist/esm/activities/chat/middleware/sandbox-runtime.js +14 -8
  42. package/dist/esm/activities/chat/middleware/sandbox-runtime.js.map +1 -1
  43. package/dist/esm/activities/chat/middleware/tool-cache-middleware.js +79 -70
  44. package/dist/esm/activities/chat/middleware/tool-cache-middleware.js.map +1 -1
  45. package/dist/esm/activities/chat/middleware/types.d.ts +59 -2
  46. package/dist/esm/activities/chat/middleware/validate.js +23 -28
  47. package/dist/esm/activities/chat/middleware/validate.js.map +1 -1
  48. package/dist/esm/activities/chat/stream/json-parser.js +39 -25
  49. package/dist/esm/activities/chat/stream/json-parser.js.map +1 -1
  50. package/dist/esm/activities/chat/stream/message-updaters.js +275 -234
  51. package/dist/esm/activities/chat/stream/message-updaters.js.map +1 -1
  52. package/dist/esm/activities/chat/stream/processor.d.ts +24 -4
  53. package/dist/esm/activities/chat/stream/processor.js +1341 -1542
  54. package/dist/esm/activities/chat/stream/processor.js.map +1 -1
  55. package/dist/esm/activities/chat/stream/strategies.js +69 -53
  56. package/dist/esm/activities/chat/stream/strategies.js.map +1 -1
  57. package/dist/esm/activities/chat/tools/approval-schema.d.ts +19 -0
  58. package/dist/esm/activities/chat/tools/approval-schema.js +117 -0
  59. package/dist/esm/activities/chat/tools/approval-schema.js.map +1 -0
  60. package/dist/esm/activities/chat/tools/lazy-tool-manager.js +164 -191
  61. package/dist/esm/activities/chat/tools/lazy-tool-manager.js.map +1 -1
  62. package/dist/esm/activities/chat/tools/lazy-tools.js +24 -12
  63. package/dist/esm/activities/chat/tools/lazy-tools.js.map +1 -1
  64. package/dist/esm/activities/chat/tools/schema-converter.js +293 -146
  65. package/dist/esm/activities/chat/tools/schema-converter.js.map +1 -1
  66. package/dist/esm/activities/chat/tools/tool-calls.d.ts +18 -2
  67. package/dist/esm/activities/chat/tools/tool-calls.js +522 -531
  68. package/dist/esm/activities/chat/tools/tool-calls.js.map +1 -1
  69. package/dist/esm/activities/chat/tools/tool-definition.d.ts +75 -16
  70. package/dist/esm/activities/chat/tools/tool-definition.js +95 -23
  71. package/dist/esm/activities/chat/tools/tool-definition.js.map +1 -1
  72. package/dist/esm/activities/error-payload.js +85 -47
  73. package/dist/esm/activities/error-payload.js.map +1 -1
  74. package/dist/esm/activities/generateAudio/adapter.js +22 -15
  75. package/dist/esm/activities/generateAudio/adapter.js.map +1 -1
  76. package/dist/esm/activities/generateAudio/index.d.ts +4 -0
  77. package/dist/esm/activities/generateAudio/index.js +141 -105
  78. package/dist/esm/activities/generateAudio/index.js.map +1 -1
  79. package/dist/esm/activities/generateImage/adapter.js +22 -15
  80. package/dist/esm/activities/generateImage/adapter.js.map +1 -1
  81. package/dist/esm/activities/generateImage/index.d.ts +4 -0
  82. package/dist/esm/activities/generateImage/index.js +155 -111
  83. package/dist/esm/activities/generateImage/index.js.map +1 -1
  84. package/dist/esm/activities/generateSpeech/adapter.js +22 -15
  85. package/dist/esm/activities/generateSpeech/adapter.js.map +1 -1
  86. package/dist/esm/activities/generateSpeech/index.d.ts +4 -0
  87. package/dist/esm/activities/generateSpeech/index.js +159 -110
  88. package/dist/esm/activities/generateSpeech/index.js.map +1 -1
  89. package/dist/esm/activities/generateTranscription/adapter.js +22 -15
  90. package/dist/esm/activities/generateTranscription/adapter.js.map +1 -1
  91. package/dist/esm/activities/generateTranscription/index.d.ts +4 -0
  92. package/dist/esm/activities/generateTranscription/index.js +159 -100
  93. package/dist/esm/activities/generateTranscription/index.js.map +1 -1
  94. package/dist/esm/activities/generateVideo/adapter.js +36 -29
  95. package/dist/esm/activities/generateVideo/adapter.js.map +1 -1
  96. package/dist/esm/activities/generateVideo/index.d.ts +143 -19
  97. package/dist/esm/activities/generateVideo/index.js +456 -279
  98. package/dist/esm/activities/generateVideo/index.js.map +1 -1
  99. package/dist/esm/activities/generateVideo/snap.js +60 -48
  100. package/dist/esm/activities/generateVideo/snap.js.map +1 -1
  101. package/dist/esm/activities/index.js +8 -34
  102. package/dist/esm/activities/middleware/index.d.ts +1 -1
  103. package/dist/esm/activities/middleware/run.d.ts +10 -0
  104. package/dist/esm/activities/middleware/run.js +53 -29
  105. package/dist/esm/activities/middleware/run.js.map +1 -1
  106. package/dist/esm/activities/middleware/types.d.ts +44 -6
  107. package/dist/esm/activities/stream-generation-result.d.ts +4 -1
  108. package/dist/esm/activities/stream-generation-result.js +79 -44
  109. package/dist/esm/activities/stream-generation-result.js.map +1 -1
  110. package/dist/esm/activities/summarize/adapter.js +22 -15
  111. package/dist/esm/activities/summarize/adapter.js.map +1 -1
  112. package/dist/esm/activities/summarize/chat-stream-summarize.js +252 -202
  113. package/dist/esm/activities/summarize/chat-stream-summarize.js.map +1 -1
  114. package/dist/esm/activities/summarize/index.d.ts +27 -0
  115. package/dist/esm/activities/summarize/index.js +268 -102
  116. package/dist/esm/activities/summarize/index.js.map +1 -1
  117. package/dist/esm/adapter-internals.d.ts +2 -1
  118. package/dist/esm/adapter-internals.js +4 -11
  119. package/dist/esm/client.d.ts +25 -3
  120. package/dist/esm/client.js +131 -64
  121. package/dist/esm/client.js.map +1 -1
  122. package/dist/esm/custom-events.d.ts +76 -0
  123. package/dist/esm/custom-events.js +37 -0
  124. package/dist/esm/custom-events.js.map +1 -0
  125. package/dist/esm/delivery-detach.d.ts +50 -0
  126. package/dist/esm/delivery-detach.js +71 -0
  127. package/dist/esm/delivery-detach.js.map +1 -0
  128. package/dist/esm/delivery-disconnect.d.ts +62 -0
  129. package/dist/esm/delivery-disconnect.js +81 -0
  130. package/dist/esm/delivery-disconnect.js.map +1 -0
  131. package/dist/esm/extend-adapter.js +19 -17
  132. package/dist/esm/extend-adapter.js.map +1 -1
  133. package/dist/esm/index.d.ts +24 -6
  134. package/dist/esm/index.js +30 -98
  135. package/dist/esm/interrupt-resume.d.ts +71 -0
  136. package/dist/esm/interrupt-resume.js +438 -0
  137. package/dist/esm/interrupt-resume.js.map +1 -0
  138. package/dist/esm/interrupt-serialization.d.ts +12 -0
  139. package/dist/esm/interrupt-serialization.js +178 -0
  140. package/dist/esm/interrupt-serialization.js.map +1 -0
  141. package/dist/esm/interrupts.d.ts +84 -0
  142. package/dist/esm/interrupts.js +31 -0
  143. package/dist/esm/interrupts.js.map +1 -0
  144. package/dist/esm/locks.d.ts +10 -0
  145. package/dist/esm/locks.js +2 -0
  146. package/dist/esm/logger/console-logger.js +101 -78
  147. package/dist/esm/logger/console-logger.js.map +1 -1
  148. package/dist/esm/logger/internal-logger.js +104 -89
  149. package/dist/esm/logger/internal-logger.js.map +1 -1
  150. package/dist/esm/logger/resolve.js +54 -49
  151. package/dist/esm/logger/resolve.js.map +1 -1
  152. package/dist/esm/logger/types.d.ts +1 -1
  153. package/dist/esm/middlewares/content-guard.js +142 -148
  154. package/dist/esm/middlewares/content-guard.js.map +1 -1
  155. package/dist/esm/middlewares/index.js +2 -6
  156. package/dist/esm/middlewares/otel.js +598 -732
  157. package/dist/esm/middlewares/otel.js.map +1 -1
  158. package/dist/esm/middlewares/usage-attributes.js +47 -40
  159. package/dist/esm/middlewares/usage-attributes.js.map +1 -1
  160. package/dist/esm/realtime/event-emitter.js +24 -25
  161. package/dist/esm/realtime/event-emitter.js.map +1 -1
  162. package/dist/esm/realtime/index.d.ts +5 -9
  163. package/dist/esm/realtime/index.js +29 -6
  164. package/dist/esm/realtime/index.js.map +1 -1
  165. package/dist/esm/scope.d.ts +47 -0
  166. package/dist/esm/stream-durability.d.ts +171 -0
  167. package/dist/esm/stream-durability.js +295 -0
  168. package/dist/esm/stream-durability.js.map +1 -0
  169. package/dist/esm/stream-to-response.d.ts +178 -13
  170. package/dist/esm/stream-to-response.js +663 -115
  171. package/dist/esm/stream-to-response.js.map +1 -1
  172. package/dist/esm/strip-to-spec-middleware.js +30 -16
  173. package/dist/esm/strip-to-spec-middleware.js.map +1 -1
  174. package/dist/esm/system-prompts.js +27 -21
  175. package/dist/esm/system-prompts.js.map +1 -1
  176. package/dist/esm/tool-registry.js +72 -45
  177. package/dist/esm/tool-registry.js.map +1 -1
  178. package/dist/esm/tools/provider-tool.js +14 -5
  179. package/dist/esm/tools/provider-tool.js.map +1 -1
  180. package/dist/esm/types.d.ts +321 -42
  181. package/dist/esm/types.js +2 -0
  182. package/dist/esm/utilities/ag-ui-wire.js +79 -93
  183. package/dist/esm/utilities/ag-ui-wire.js.map +1 -1
  184. package/dist/esm/utilities/chat-params.d.ts +26 -4
  185. package/dist/esm/utilities/chat-params.js +218 -92
  186. package/dist/esm/utilities/chat-params.js.map +1 -1
  187. package/dist/esm/utilities/errors.js +28 -18
  188. package/dist/esm/utilities/errors.js.map +1 -1
  189. package/dist/esm/utilities/media-prompt.js +46 -41
  190. package/dist/esm/utilities/media-prompt.js.map +1 -1
  191. package/dist/esm/utilities/numbers.js +13 -10
  192. package/dist/esm/utilities/numbers.js.map +1 -1
  193. package/dist/esm/utilities/provider-executed.js +20 -11
  194. package/dist/esm/utilities/provider-executed.js.map +1 -1
  195. package/dist/esm/utilities/sampling-keys.js +31 -19
  196. package/dist/esm/utilities/sampling-keys.js.map +1 -1
  197. package/dist/esm/utilities/tool-result.js +42 -30
  198. package/dist/esm/utilities/tool-result.js.map +1 -1
  199. package/dist/esm/utilities/usage.js +27 -9
  200. package/dist/esm/utilities/usage.js.map +1 -1
  201. package/dist/esm/utils.js +26 -18
  202. package/dist/esm/utils.js.map +1 -1
  203. package/package.json +10 -6
  204. package/skills/ai-core/SKILL.md +69 -18
  205. package/skills/ai-core/adapter-configuration/SKILL.md +44 -21
  206. package/skills/ai-core/adapter-configuration/references/anthropic-adapter.md +1 -3
  207. package/skills/ai-core/adapter-configuration/references/byteplus-adapter.md +148 -0
  208. package/skills/ai-core/adapter-configuration/references/gemini-adapter.md +2 -6
  209. package/skills/ai-core/adapter-configuration/references/groq-adapter.md +2 -6
  210. package/skills/ai-core/adapter-configuration/references/openai-adapter.md +1 -3
  211. package/skills/ai-core/ag-ui-protocol/SKILL.md +1 -1
  212. package/skills/ai-core/chat-experience/SKILL.md +98 -11
  213. package/skills/ai-core/client-persistence/SKILL.md +277 -0
  214. package/skills/ai-core/custom-backend-integration/SKILL.md +1 -1
  215. package/skills/ai-core/debug-logging/SKILL.md +1 -1
  216. package/skills/ai-core/locks/SKILL.md +143 -0
  217. package/skills/ai-core/media-generation/SKILL.md +144 -12
  218. package/skills/ai-core/middleware/SKILL.md +258 -33
  219. package/skills/ai-core/structured-outputs/SKILL.md +1 -1
  220. package/skills/ai-core/tool-calling/SKILL.md +54 -61
  221. package/src/activities/chat/agent-loop-strategies.ts +5 -39
  222. package/src/activities/chat/cancel.ts +81 -0
  223. package/src/activities/chat/index.ts +1091 -200
  224. package/src/activities/chat/mcp/manager.ts +4 -4
  225. package/src/activities/chat/mcp/types.ts +2 -2
  226. package/src/activities/chat/messages.ts +5 -3
  227. package/src/activities/chat/middleware/builder.ts +1 -1
  228. package/src/activities/chat/middleware/compose.ts +186 -9
  229. package/src/activities/chat/middleware/index.ts +26 -0
  230. package/src/activities/chat/middleware/locks.ts +102 -0
  231. package/src/activities/chat/middleware/pending-turn.ts +47 -0
  232. package/src/activities/chat/middleware/run-disconnect.ts +62 -0
  233. package/src/activities/chat/middleware/run-store.ts +412 -0
  234. package/src/activities/chat/middleware/types.ts +62 -1
  235. package/src/activities/chat/stream/processor.ts +189 -5
  236. package/src/activities/chat/tools/approval-schema.ts +205 -0
  237. package/src/activities/chat/tools/tool-calls.ts +106 -13
  238. package/src/activities/chat/tools/tool-definition.ts +210 -39
  239. package/src/activities/generateAudio/index.ts +20 -3
  240. package/src/activities/generateImage/index.ts +20 -3
  241. package/src/activities/generateSpeech/index.ts +25 -3
  242. package/src/activities/generateTranscription/index.ts +26 -3
  243. package/src/activities/generateVideo/index.ts +345 -82
  244. package/src/activities/middleware/index.ts +2 -0
  245. package/src/activities/middleware/run.ts +31 -0
  246. package/src/activities/middleware/types.ts +49 -5
  247. package/src/activities/stream-generation-result.ts +30 -2
  248. package/src/activities/summarize/chat-stream-summarize.ts +5 -0
  249. package/src/activities/summarize/index.ts +200 -10
  250. package/src/adapter-internals.ts +10 -1
  251. package/src/client.ts +244 -0
  252. package/src/custom-events.ts +107 -0
  253. package/src/delivery-detach.ts +72 -0
  254. package/src/delivery-disconnect.ts +84 -0
  255. package/src/index.ts +138 -1
  256. package/src/interrupt-resume.ts +824 -0
  257. package/src/interrupt-serialization.ts +183 -0
  258. package/src/interrupts.ts +146 -0
  259. package/src/locks.ts +17 -0
  260. package/src/logger/types.ts +1 -1
  261. package/src/middlewares/otel.ts +1 -0
  262. package/src/realtime/index.ts +5 -9
  263. package/src/scope.ts +47 -0
  264. package/src/stream-durability.ts +598 -0
  265. package/src/stream-to-response.ts +1051 -95
  266. package/src/strip-to-spec-middleware.ts +3 -3
  267. package/src/types.ts +405 -45
  268. package/src/utilities/chat-params.ts +245 -55
  269. package/dist/esm/activities/index.js.map +0 -1
  270. package/dist/esm/adapter-internals.js.map +0 -1
  271. package/dist/esm/index.js.map +0 -1
  272. package/dist/esm/middlewares/index.js.map +0 -1
@@ -1,1548 +1,1347 @@
1
- import { generateMessageId, uiMessageToModelMessages, aguiSnapshotMessageToUIMessage } from "../messages.js";
2
1
  import { normalizeToolResult } from "../../../utilities/tool-result.js";
3
2
  import { isProviderExecutedToolCall } from "../../../utilities/provider-executed.js";
3
+ import { aguiSnapshotMessageToUIMessage, generateMessageId, uiMessageToModelMessages } from "../messages.js";
4
4
  import { defaultJSONParser } from "./json-parser.js";
5
- import { updateToolCallWithOutput, updateToolResultPart, updateToolCallApprovalResponse, appendStructuredOutputDelta, updateToolCallPart, errorStructuredOutputPart, updateThinkingPart, completeStructuredOutputPart, updateToolCallApproval, updateTextPart } from "./message-updaters.js";
5
+ import { appendStructuredOutputDelta, completeStructuredOutputPart, errorStructuredOutputPart, updateTextPart, updateThinkingPart, updateToolCallApproval, updateToolCallApprovalResponse, updateToolCallPart, updateToolCallWithOutput, updateToolResultPart } from "./message-updaters.js";
6
6
  import { ImmediateStrategy } from "./strategies.js";
7
- const STRUCTURED_OUTPUT_UPDATE_BATCH_SIZE = 12;
8
- class StreamProcessor {
9
- chunkStrategy;
10
- events;
11
- jsonParser;
12
- recordingEnabled;
13
- // Message state
14
- messages = [];
15
- // Per-message stream state
16
- messageStates = /* @__PURE__ */ new Map();
17
- activeMessageIds = /* @__PURE__ */ new Set();
18
- toolCallToMessage = /* @__PURE__ */ new Map();
19
- pendingManualMessageId = null;
20
- pendingThinkingStepId = null;
21
- structuredMessageIds = /* @__PURE__ */ new Set();
22
- structuredOutputUpdateBatches = /* @__PURE__ */ new Map();
23
- // Run tracking (for concurrent run safety)
24
- activeRuns = /* @__PURE__ */ new Set();
25
- // Shared stream state
26
- finishReason = null;
27
- hasError = false;
28
- isDone = false;
29
- // Recording
30
- recording = null;
31
- recordingStartTime = 0;
32
- constructor(options = {}) {
33
- this.chunkStrategy = options.chunkStrategy || new ImmediateStrategy();
34
- this.events = options.events || {};
35
- this.jsonParser = options.jsonParser || defaultJSONParser;
36
- this.recordingEnabled = options.recording ?? false;
37
- if (options.initialMessages) {
38
- this.messages = [...options.initialMessages];
39
- }
40
- }
41
- // ============================================
42
- // Message Management Methods
43
- // ============================================
44
- /**
45
- * Set the messages array (e.g., from persisted state)
46
- */
47
- setMessages(messages) {
48
- this.messages = [...messages];
49
- this.emitMessagesChange();
50
- }
51
- /**
52
- * Add a user message to the conversation.
53
- * Supports both simple string content and multimodal content arrays.
54
- *
55
- * @param content - The message content (string or array of content parts)
56
- * @param id - Optional custom message ID (generated if not provided)
57
- * @returns The created UIMessage
58
- *
59
- * @example
60
- * ```ts
61
- * // Simple text message
62
- * processor.addUserMessage('Hello!')
63
- *
64
- * // Multimodal message with image
65
- * processor.addUserMessage([
66
- * { type: 'text', content: 'What is in this image?' },
67
- * { type: 'image', source: { type: 'url', value: 'https://example.com/photo.jpg' } }
68
- * ])
69
- *
70
- * // With custom ID
71
- * processor.addUserMessage('Hello!', 'custom-id-123')
72
- * ```
73
- */
74
- addUserMessage(content, id) {
75
- const parts = typeof content === "string" ? [{ type: "text", content }] : content.map((part) => {
76
- return part;
77
- });
78
- const userMessage = {
79
- id: id ?? generateMessageId(),
80
- role: "user",
81
- parts,
82
- createdAt: /* @__PURE__ */ new Date()
83
- };
84
- this.messages = [...this.messages, userMessage];
85
- this.emitMessagesChange();
86
- return userMessage;
87
- }
88
- /**
89
- * Prepare for a new assistant message stream.
90
- * Does NOT create the message immediately -- the message is created lazily
91
- * when the first content-bearing chunk arrives via ensureAssistantMessage().
92
- * This prevents empty assistant messages from flickering in the UI when
93
- * auto-continuation produces no content.
94
- */
95
- prepareAssistantMessage() {
96
- this.resetStreamState();
97
- }
98
- /**
99
- * @deprecated Use prepareAssistantMessage() instead. This eagerly creates
100
- * an assistant message which can cause empty message flicker.
101
- */
102
- startAssistantMessage(messageId) {
103
- this.prepareAssistantMessage();
104
- const { messageId: id } = this.ensureAssistantMessage(messageId);
105
- this.pendingManualMessageId = id;
106
- return id;
107
- }
108
- /**
109
- * Get the current assistant message ID (if one has been created).
110
- * Returns null if prepareAssistantMessage() was called but no content
111
- * has arrived yet.
112
- */
113
- getCurrentAssistantMessageId() {
114
- let lastId = null;
115
- for (const [id, state] of this.messageStates) {
116
- if (state.role === "assistant") {
117
- lastId = id;
118
- }
119
- }
120
- return lastId;
121
- }
122
- /**
123
- * Add a tool result (called by client after handling onToolCall)
124
- */
125
- addToolResult(toolCallId, output, error) {
126
- const messageWithToolCall = this.messages.find(
127
- (msg) => msg.parts.some(
128
- (p) => p.type === "tool-call" && p.id === toolCallId
129
- )
130
- );
131
- if (!messageWithToolCall) {
132
- console.warn(
133
- `[StreamProcessor] Could not find message with tool call ${toolCallId}`
134
- );
135
- return;
136
- }
137
- let updatedMessages = updateToolCallWithOutput(
138
- this.messages,
139
- toolCallId,
140
- output,
141
- error ? "error" : void 0,
142
- error
143
- );
144
- const content = normalizeToolResult(output);
145
- const toolResultState = error ? "error" : "complete";
146
- updatedMessages = updateToolResultPart(
147
- updatedMessages,
148
- messageWithToolCall.id,
149
- toolCallId,
150
- content,
151
- toolResultState,
152
- error
153
- );
154
- this.messages = updatedMessages;
155
- this.emitMessagesChange();
156
- }
157
- /**
158
- * Add an approval response (called by client after handling onApprovalRequest)
159
- */
160
- addToolApprovalResponse(approvalId, approved) {
161
- this.messages = updateToolCallApprovalResponse(
162
- this.messages,
163
- approvalId,
164
- approved
165
- );
166
- this.emitMessagesChange();
167
- }
168
- /**
169
- * Get the conversation as ModelMessages (for sending to LLM)
170
- */
171
- toModelMessages() {
172
- const modelMessages = [];
173
- for (const msg of this.messages) {
174
- modelMessages.push(...uiMessageToModelMessages(msg));
175
- }
176
- return modelMessages;
177
- }
178
- /**
179
- * Get current messages
180
- */
181
- getMessages() {
182
- return this.messages;
183
- }
184
- /**
185
- * Check if all tool calls in the last assistant message are complete
186
- * Useful for auto-continue logic
187
- */
188
- areAllToolsComplete() {
189
- const lastAssistant = this.messages.findLast(
190
- (m) => m.role === "assistant"
191
- );
192
- if (!lastAssistant) return true;
193
- const toolParts = lastAssistant.parts.filter(
194
- (p) => p.type === "tool-call"
195
- );
196
- if (toolParts.length === 0) return true;
197
- const toolResultIds = new Set(
198
- lastAssistant.parts.filter((p) => p.type === "tool-result").map((p) => p.toolCallId)
199
- );
200
- return toolParts.every(
201
- (part) => part.state === "complete" || part.state === "approval-responded" || part.output !== void 0 && !part.approval || toolResultIds.has(part.id) || isProviderExecutedToolCall(part)
202
- );
203
- }
204
- /**
205
- * Remove messages after a certain index (for reload/retry)
206
- */
207
- removeMessagesAfter(index) {
208
- const keptIds = new Set(this.messages.slice(0, index + 1).map((m) => m.id));
209
- for (const id of this.structuredMessageIds) {
210
- if (!keptIds.has(id)) this.structuredMessageIds.delete(id);
211
- }
212
- for (const id of this.structuredOutputUpdateBatches.keys()) {
213
- if (!keptIds.has(id)) this.structuredOutputUpdateBatches.delete(id);
214
- }
215
- for (const id of this.messageStates.keys()) {
216
- if (!keptIds.has(id)) this.messageStates.delete(id);
217
- }
218
- for (const [toolCallId, msgId] of this.toolCallToMessage) {
219
- if (!keptIds.has(msgId)) this.toolCallToMessage.delete(toolCallId);
220
- }
221
- for (const id of this.activeMessageIds) {
222
- if (!keptIds.has(id)) this.activeMessageIds.delete(id);
223
- }
224
- this.messages = this.messages.slice(0, index + 1);
225
- this.emitMessagesChange();
226
- }
227
- /**
228
- * Clear all messages
229
- */
230
- clearMessages() {
231
- this.messages = [];
232
- this.messageStates.clear();
233
- this.activeMessageIds.clear();
234
- this.toolCallToMessage.clear();
235
- this.structuredMessageIds.clear();
236
- this.structuredOutputUpdateBatches.clear();
237
- this.pendingManualMessageId = null;
238
- this.emitMessagesChange();
239
- }
240
- // ============================================
241
- // Stream Processing Methods
242
- // ============================================
243
- /**
244
- * Process a stream and emit events through handlers
245
- */
246
- async process(stream) {
247
- this.resetStreamState();
248
- if (this.recordingEnabled) {
249
- this.startRecording();
250
- }
251
- for await (const chunk of stream) {
252
- this.processChunk(chunk);
253
- }
254
- this.finalizeStream();
255
- if (this.recording) {
256
- this.recording.result = this.getResult();
257
- }
258
- return this.getResult();
259
- }
260
- /**
261
- * Process a single chunk from the stream.
262
- *
263
- * Central dispatch for all AG-UI events. Each event type maps to a specific
264
- * handler. Events not listed in the switch are intentionally ignored
265
- * (STEP_STARTED, STATE_SNAPSHOT, STATE_DELTA).
266
- *
267
- * @see docs/chat-architecture.md#adapter-contract — Expected event types and ordering
268
- */
269
- processChunk(chunk) {
270
- if (this.recording) {
271
- this.recording.chunks.push({
272
- chunk,
273
- timestamp: Date.now(),
274
- index: this.recording.chunks.length
275
- });
276
- }
277
- const c = chunk;
278
- switch (c.type) {
279
- // AG-UI Events
280
- case "TEXT_MESSAGE_START":
281
- this.handleTextMessageStartEvent(
282
- chunk
283
- );
284
- break;
285
- case "TEXT_MESSAGE_CONTENT":
286
- this.handleTextMessageContentEvent(
287
- chunk
288
- );
289
- break;
290
- case "TEXT_MESSAGE_END":
291
- this.handleTextMessageEndEvent(
292
- chunk
293
- );
294
- break;
295
- case "TOOL_CALL_START":
296
- this.handleToolCallStartEvent(
297
- chunk
298
- );
299
- break;
300
- case "TOOL_CALL_ARGS":
301
- this.handleToolCallArgsEvent(
302
- chunk
303
- );
304
- break;
305
- case "TOOL_CALL_END":
306
- this.handleToolCallEndEvent(
307
- chunk
308
- );
309
- break;
310
- case "RUN_FINISHED":
311
- this.handleRunFinishedEvent(
312
- chunk
313
- );
314
- break;
315
- case "RUN_ERROR":
316
- this.handleRunErrorEvent(
317
- chunk
318
- );
319
- break;
320
- case "STEP_FINISHED":
321
- this.handleStepFinishedEvent(
322
- chunk
323
- );
324
- break;
325
- case "MESSAGES_SNAPSHOT":
326
- this.handleMessagesSnapshotEvent(
327
- chunk
328
- );
329
- break;
330
- case "CUSTOM":
331
- this.handleCustomEvent(
332
- chunk
333
- );
334
- break;
335
- case "RUN_STARTED":
336
- this.handleRunStartedEvent(
337
- chunk
338
- );
339
- break;
340
- case "REASONING_START":
341
- case "REASONING_MESSAGE_START":
342
- case "REASONING_MESSAGE_END":
343
- case "REASONING_END":
344
- break;
345
- case "REASONING_MESSAGE_CONTENT":
346
- this.handleReasoningMessageContentEvent(
347
- chunk
348
- );
349
- break;
350
- case "TOOL_CALL_RESULT":
351
- this.handleToolCallResultEvent(
352
- chunk
353
- );
354
- break;
355
- case "STEP_STARTED":
356
- this.handleStepStartedEvent(
357
- chunk
358
- );
359
- break;
360
- }
361
- }
362
- // ============================================
363
- // Per-Message State Helpers
364
- // ============================================
365
- /**
366
- * Create a new MessageStreamState for a message
367
- */
368
- createMessageState(messageId, role) {
369
- const state = {
370
- id: messageId,
371
- role,
372
- totalTextContent: "",
373
- currentSegmentText: "",
374
- lastEmittedText: "",
375
- hasSeenReasoningEvents: false,
376
- thinkingSteps: /* @__PURE__ */ new Map(),
377
- thinkingStepSignatures: /* @__PURE__ */ new Map(),
378
- thinkingStepOrder: [],
379
- currentThinkingStepId: null,
380
- toolCalls: /* @__PURE__ */ new Map(),
381
- toolCallOrder: [],
382
- hasToolCallsSinceTextStart: false,
383
- isComplete: false
384
- };
385
- this.messageStates.set(messageId, state);
386
- return state;
387
- }
388
- /**
389
- * Get the MessageStreamState for a message
390
- */
391
- getMessageState(messageId) {
392
- return this.messageStates.get(messageId);
393
- }
394
- /**
395
- * Promote a pending stepId from a STEP_STARTED that fired before the
396
- * assistant message existed onto the given message state, so the next
397
- * thinking event (STEP_FINISHED or REASONING_MESSAGE_CONTENT) attributes
398
- * to the correct step.
399
- */
400
- consumePendingThinkingStep(state) {
401
- if (!this.pendingThinkingStepId) return;
402
- const stepId = this.pendingThinkingStepId;
403
- state.currentThinkingStepId = stepId;
404
- if (!state.thinkingSteps.has(stepId)) {
405
- state.thinkingSteps.set(stepId, "");
406
- state.thinkingStepOrder.push(stepId);
407
- }
408
- this.pendingThinkingStepId = null;
409
- }
410
- /**
411
- * Get the most recent active assistant message ID.
412
- * Used as fallback for events that don't include a messageId.
413
- */
414
- getActiveAssistantMessageId() {
415
- const ids = Array.from(this.activeMessageIds).reverse();
416
- for (const id of ids) {
417
- const state = this.messageStates.get(id);
418
- if (state && state.role === "assistant") {
419
- return id;
420
- }
421
- }
422
- return null;
423
- }
424
- /**
425
- * Ensure an active assistant message exists, creating one if needed.
426
- * Used for backward compat when events arrive without prior TEXT_MESSAGE_START.
427
- *
428
- * On reconnect/resume, a TEXT_MESSAGE_CONTENT may arrive for a message that
429
- * already exists in this.messages (e.g. from initialMessages or a prior
430
- * MESSAGES_SNAPSHOT) but whose transient state was cleared. In that case we
431
- * hydrate state from the existing message rather than creating a duplicate.
432
- */
433
- ensureAssistantMessage(preferredId) {
434
- if (preferredId) {
435
- const state2 = this.getMessageState(preferredId);
436
- if (state2) return { messageId: preferredId, state: state2 };
437
- }
438
- const activeId = this.getActiveAssistantMessageId();
439
- if (activeId) {
440
- const state2 = this.getMessageState(activeId);
441
- if (state2) return { messageId: activeId, state: state2 };
442
- }
443
- if (preferredId) {
444
- const existingMsg = this.messages.find((m) => m.id === preferredId);
445
- if (existingMsg) {
446
- const state2 = this.createMessageState(preferredId, existingMsg.role);
447
- this.activeMessageIds.add(preferredId);
448
- const lastPart = existingMsg.parts.length > 0 ? existingMsg.parts[existingMsg.parts.length - 1] : null;
449
- if (lastPart && lastPart.type === "text") {
450
- state2.currentSegmentText = lastPart.content;
451
- state2.lastEmittedText = lastPart.content;
452
- state2.totalTextContent = lastPart.content;
453
- }
454
- return { messageId: preferredId, state: state2 };
455
- }
456
- }
457
- const id = preferredId || generateMessageId();
458
- const assistantMessage = {
459
- id,
460
- role: "assistant",
461
- parts: [],
462
- createdAt: /* @__PURE__ */ new Date()
463
- };
464
- this.messages = [...this.messages, assistantMessage];
465
- const state = this.createMessageState(id, "assistant");
466
- this.activeMessageIds.add(id);
467
- this.pendingManualMessageId = id;
468
- this.events.onStreamStart?.();
469
- this.emitMessagesChange();
470
- return { messageId: id, state };
471
- }
472
- // ============================================
473
- // Event Handlers
474
- // ============================================
475
- /**
476
- * Handle TEXT_MESSAGE_START event
477
- */
478
- handleTextMessageStartEvent(chunk) {
479
- const { messageId, role } = chunk;
480
- const uiRole = role === "user" || role === "system" ? role : "assistant";
481
- if (this.pendingManualMessageId) {
482
- const pendingId = this.pendingManualMessageId;
483
- this.pendingManualMessageId = null;
484
- if (pendingId !== messageId) {
485
- this.messages = this.messages.map(
486
- (msg) => msg.id === pendingId ? { ...msg, id: messageId } : msg
487
- );
488
- const existingState = this.messageStates.get(pendingId);
489
- if (existingState) {
490
- existingState.id = messageId;
491
- this.messageStates.delete(pendingId);
492
- this.messageStates.set(messageId, existingState);
493
- }
494
- this.activeMessageIds.delete(pendingId);
495
- this.activeMessageIds.add(messageId);
496
- }
497
- if (!this.messageStates.has(messageId)) {
498
- this.createMessageState(messageId, uiRole);
499
- this.activeMessageIds.add(messageId);
500
- }
501
- this.emitMessagesChange();
502
- return;
503
- }
504
- const existingMsg = this.messages.find((m) => m.id === messageId);
505
- if (existingMsg) {
506
- this.activeMessageIds.add(messageId);
507
- const existingState = this.messageStates.get(messageId);
508
- if (!existingState) {
509
- this.createMessageState(messageId, uiRole);
510
- } else {
511
- if (existingState.hasToolCallsSinceTextStart) {
512
- if (existingState.currentSegmentText !== existingState.lastEmittedText) {
513
- this.emitTextUpdateForMessage(messageId);
514
- }
515
- existingState.currentSegmentText = "";
516
- existingState.lastEmittedText = "";
517
- existingState.hasToolCallsSinceTextStart = false;
518
- }
519
- }
520
- return;
521
- }
522
- const newMessage = {
523
- id: messageId,
524
- role: uiRole,
525
- parts: [],
526
- createdAt: /* @__PURE__ */ new Date()
527
- };
528
- this.messages = [...this.messages, newMessage];
529
- this.createMessageState(messageId, uiRole);
530
- this.activeMessageIds.add(messageId);
531
- this.events.onStreamStart?.();
532
- this.emitMessagesChange();
533
- }
534
- /**
535
- * Handle TEXT_MESSAGE_END event
536
- */
537
- handleTextMessageEndEvent(chunk) {
538
- const { messageId } = chunk;
539
- const state = this.getMessageState(messageId);
540
- if (!state) return;
541
- if (state.isComplete) return;
542
- if (state.currentSegmentText !== state.lastEmittedText) {
543
- this.emitTextUpdateForMessage(messageId);
544
- }
545
- this.completeAllToolCallsForMessage(messageId);
546
- }
547
- /**
548
- * Handle MESSAGES_SNAPSHOT event
549
- */
550
- handleMessagesSnapshotEvent(chunk) {
551
- this.resetStreamState();
552
- const prevMessages = this.messages;
553
- const normalized = chunk.messages.map(aguiSnapshotMessageToUIMessage);
554
- this.messages = this.reconcileSnapshotToolCalls(normalized, prevMessages);
555
- this.emitMessagesChange();
556
- }
557
- /**
558
- * Reconcile a freshly normalized snapshot with the pre-snapshot message
559
- * state so unreconstructable tool-call metadata is preserved.
560
- *
561
- * Post-pass (a): anchor `tool-result`-only assistant messages (the shape
562
- * `aguiSnapshotMessageToUIMessage` emits for AG-UI `role: 'tool'` wire
563
- * messages) into the message containing the matching `tool-call` part, or —
564
- * when the snapshot supplies no such part — the nearest earlier anchorable
565
- * assistant message, matching the in-stream fan-out shape
566
- * `assistant: [text, tool-call, tool-result, ...]`. Detached messages with
567
- * no earlier anchorable assistant are kept verbatim.
568
- *
569
- * Post-pass (b): when a `tool-result` part references a `toolCallId` whose
570
- * `tool-call` part is absent from the snapshot, carry the `tool-call` part
571
- * forward from the pre-snapshot state (state and output untouched) so a
572
- * subsequent `addToolResult(toolCallId)` can still locate the call.
573
- */
574
- reconcileSnapshotToolCalls(snapshot, prevMessages) {
575
- const prevToolCalls = /* @__PURE__ */ new Map();
576
- for (const msg of prevMessages) {
577
- for (const part of msg.parts) {
578
- if (part.type === "tool-call") {
579
- prevToolCalls.set(part.id, part);
580
- }
581
- }
582
- }
583
- const snapshotToolCallIds = /* @__PURE__ */ new Set();
584
- for (const msg of snapshot) {
585
- for (const part of msg.parts) {
586
- if (part.type === "tool-call") {
587
- snapshotToolCallIds.add(part.id);
588
- }
589
- }
590
- }
591
- const reconciled = [];
592
- for (const msg of snapshot) {
593
- const toolResultPart = msg.role === "assistant" && msg.parts.length === 1 ? msg.parts.find((p) => p.type === "tool-result") : void 0;
594
- if (!toolResultPart) {
595
- reconciled.push(msg);
596
- continue;
597
- }
598
- const target = reconciled.findLast(
599
- (m) => m.parts.some(
600
- (p) => p.type === "tool-call" && p.id === toolResultPart.toolCallId
601
- )
602
- ) ?? reconciled.findLast(
603
- (m) => m.role === "assistant" && !(m.parts.length === 1 && m.parts[0]?.type === "tool-result")
604
- );
605
- if (!target) {
606
- if (!snapshotToolCallIds.has(toolResultPart.toolCallId)) {
607
- console.warn(
608
- `[StreamProcessor] MESSAGES_SNAPSHOT contains a tool-result for "${toolResultPart.toolCallId}" but no matching tool-call exists in the snapshot, and there is no assistant message to anchor into; addToolResult("${toolResultPart.toolCallId}") will not be able to locate this call`
609
- );
610
- }
611
- reconciled.push(msg);
612
- continue;
613
- }
614
- const parts = [...target.parts];
615
- if (!snapshotToolCallIds.has(toolResultPart.toolCallId) && !parts.some(
616
- (p) => p.type === "tool-call" && p.id === toolResultPart.toolCallId
617
- )) {
618
- const prev = prevToolCalls.get(toolResultPart.toolCallId);
619
- if (prev) {
620
- parts.push({ ...prev });
621
- snapshotToolCallIds.add(prev.id);
622
- } else {
623
- console.warn(
624
- `[StreamProcessor] MESSAGES_SNAPSHOT contains a tool-result for "${toolResultPart.toolCallId}" but no matching tool-call exists in the snapshot or the pre-snapshot state; addToolResult("${toolResultPart.toolCallId}") will not be able to locate this call`
625
- );
626
- }
627
- }
628
- parts.push(toolResultPart);
629
- target.parts = parts;
630
- }
631
- return reconciled;
632
- }
633
- /**
634
- * Handle TEXT_MESSAGE_CONTENT event.
635
- *
636
- * Accumulates delta into both currentSegmentText (for UI emission) and
637
- * totalTextContent (for ProcessorResult). Lazily creates the assistant
638
- * UIMessage on first content. Uses updateTextPart() which replaces the
639
- * last TextPart or creates a new one depending on part ordering.
640
- *
641
- * @see docs/chat-architecture.md#single-shot-text-response — Text accumulation step-by-step
642
- * @see docs/chat-architecture.md#uimessage-part-ordering-invariants — Replace vs. push logic
643
- */
644
- handleTextMessageContentEvent(chunk) {
645
- const { messageId, state } = this.ensureAssistantMessage(chunk.messageId);
646
- this.completeAllToolCallsForMessage(messageId);
647
- if (this.structuredMessageIds.has(messageId)) {
648
- let delta2 = chunk.delta || "";
649
- if (delta2 === "" && chunk.content !== void 0 && chunk.content !== "") {
650
- const existingRaw = (this.messages.find((m) => m.id === messageId)?.parts.find(
651
- (p) => p.type === "structured-output"
652
- ) ?? { raw: "" }).raw;
653
- if (chunk.content.startsWith(existingRaw)) {
654
- delta2 = chunk.content.slice(existingRaw.length);
655
- } else if (existingRaw.startsWith(chunk.content)) {
656
- delta2 = "";
657
- } else {
658
- delta2 = chunk.content;
659
- }
660
- }
661
- if (delta2 !== "") {
662
- this.messages = appendStructuredOutputDelta(
663
- this.messages,
664
- messageId,
665
- delta2
666
- );
667
- state.totalTextContent += delta2;
668
- this.queueStructuredOutputUpdate(messageId, delta2);
669
- this.emitMessagesChange();
670
- }
671
- return;
672
- }
673
- const previousSegment = state.currentSegmentText;
674
- const isNewSegment = state.hasToolCallsSinceTextStart && previousSegment.length > 0 && this.isNewTextSegment(chunk, previousSegment);
675
- if (isNewSegment) {
676
- if (previousSegment !== state.lastEmittedText) {
677
- this.emitTextUpdateForMessage(messageId);
678
- }
679
- state.currentSegmentText = "";
680
- state.lastEmittedText = "";
681
- state.hasToolCallsSinceTextStart = false;
682
- }
683
- const currentText = state.currentSegmentText;
684
- let nextText = currentText;
685
- const delta = chunk.delta || "";
686
- if (delta !== "") {
687
- nextText = currentText + delta;
688
- } else if (chunk.content !== void 0 && chunk.content !== "") {
689
- if (chunk.content.startsWith(currentText)) {
690
- nextText = chunk.content;
691
- } else if (currentText.startsWith(chunk.content)) {
692
- nextText = currentText;
693
- } else {
694
- nextText = currentText + chunk.content;
695
- }
696
- }
697
- const textDelta = nextText.slice(currentText.length);
698
- state.currentSegmentText = nextText;
699
- state.totalTextContent += textDelta;
700
- const chunkPortion = chunk.delta || chunk.content || "";
701
- const shouldEmit = this.chunkStrategy.shouldEmit(
702
- chunkPortion,
703
- state.currentSegmentText
704
- );
705
- if (shouldEmit && state.currentSegmentText !== state.lastEmittedText) {
706
- this.emitTextUpdateForMessage(messageId);
707
- }
708
- }
709
- /**
710
- * Handle TOOL_CALL_START event.
711
- *
712
- * Creates a new InternalToolCallState entry in the toolCalls Map and appends
713
- * a ToolCallPart to the UIMessage. Duplicate toolCallId is a no-op.
714
- *
715
- * CRITICAL: This MUST be received before any TOOL_CALL_ARGS for the same
716
- * toolCallId. Args for unknown IDs are silently dropped.
717
- *
718
- * @see docs/chat-architecture.md#single-shot-tool-call-response — Tool call state transitions
719
- * @see docs/chat-architecture.md#parallel-tool-calls-single-shot — Parallel tracking by ID
720
- * @see docs/chat-architecture.md#adapter-contract — Ordering requirements
721
- */
722
- handleToolCallStartEvent(chunk) {
723
- const targetMessageId = chunk.parentMessageId ?? this.getActiveAssistantMessageId();
724
- const { messageId, state } = this.ensureAssistantMessage(
725
- targetMessageId ?? void 0
726
- );
727
- state.hasToolCallsSinceTextStart = true;
728
- const toolCallId = chunk.toolCallId;
729
- const existingToolCall = state.toolCalls.get(toolCallId);
730
- if (!existingToolCall) {
731
- const initialState = "awaiting-input";
732
- const toolName = chunk.toolCallName ?? chunk.toolName;
733
- const chunkMetadata = chunk.metadata;
734
- const newToolCall = {
735
- id: chunk.toolCallId,
736
- name: toolName,
737
- arguments: "",
738
- state: initialState,
739
- parsedArguments: void 0,
740
- index: chunk.index ?? state.toolCalls.size,
741
- ...chunkMetadata !== void 0 && { metadata: chunkMetadata }
742
- };
743
- state.toolCalls.set(toolCallId, newToolCall);
744
- state.toolCallOrder.push(toolCallId);
745
- this.toolCallToMessage.set(toolCallId, messageId);
746
- this.messages = updateToolCallPart(this.messages, messageId, {
747
- id: chunk.toolCallId,
748
- name: toolName,
749
- arguments: "",
750
- state: initialState,
751
- ...chunkMetadata !== void 0 && { metadata: chunkMetadata }
752
- });
753
- this.emitMessagesChange();
754
- this.events.onToolCallStateChange?.(
755
- messageId,
756
- chunk.toolCallId,
757
- initialState,
758
- ""
759
- );
760
- }
761
- }
762
- /**
763
- * Handle TOOL_CALL_ARGS event.
764
- *
765
- * Appends the delta to the tool call's accumulated arguments string.
766
- * Transitions state from awaiting-input → input-streaming on first non-empty delta.
767
- * Attempts partial JSON parse on each update for UI preview.
768
- *
769
- * If toolCallId is not found in the Map (no preceding TOOL_CALL_START),
770
- * this event is silently dropped.
771
- *
772
- * @see docs/chat-architecture.md#single-shot-tool-call-response — Step-by-step tool call processing
773
- */
774
- handleToolCallArgsEvent(chunk) {
775
- const toolCallId = chunk.toolCallId;
776
- const messageId = this.toolCallToMessage.get(toolCallId);
777
- if (!messageId) return;
778
- const state = this.getMessageState(messageId);
779
- if (!state) return;
780
- const existingToolCall = state.toolCalls.get(toolCallId);
781
- if (!existingToolCall) return;
782
- const wasAwaitingInput = existingToolCall.state === "awaiting-input";
783
- existingToolCall.arguments += chunk.delta || "";
784
- if (wasAwaitingInput && chunk.delta) {
785
- existingToolCall.state = "input-streaming";
786
- }
787
- existingToolCall.parsedArguments = this.jsonParser.parse(
788
- existingToolCall.arguments
789
- );
790
- this.messages = updateToolCallPart(this.messages, messageId, {
791
- id: existingToolCall.id,
792
- name: existingToolCall.name,
793
- arguments: existingToolCall.arguments,
794
- state: existingToolCall.state
795
- });
796
- this.emitMessagesChange();
797
- this.events.onToolCallStateChange?.(
798
- messageId,
799
- existingToolCall.id,
800
- existingToolCall.state,
801
- existingToolCall.arguments
802
- );
803
- }
804
- /**
805
- * Handle TOOL_CALL_END event — authoritative signal that a tool call's input is finalized.
806
- *
807
- * This event has a DUAL ROLE:
808
- * - Without `result`: Signals arguments are done (from adapter). Transitions to input-complete.
809
- * - With `result`: Signals tool was executed and result is available (from TextEngine).
810
- * Creates both output on the tool-call part AND a tool-result part.
811
- *
812
- * If `input` is provided, it overrides the accumulated string parse as the
813
- * canonical parsed arguments.
814
- *
815
- * @see docs/chat-architecture.md#tool-results-and-the-tool_call_end-dual-role — Full explanation
816
- * @see docs/chat-architecture.md#single-shot-tool-call-response — End-to-end flow
817
- */
818
- handleToolCallEndEvent(chunk) {
819
- const messageId = this.toolCallToMessage.get(chunk.toolCallId);
820
- if (!messageId) return;
821
- const msgState = this.getMessageState(messageId);
822
- if (!msgState) return;
823
- const existingToolCall = msgState.toolCalls.get(chunk.toolCallId);
824
- if (existingToolCall && existingToolCall.state !== "input-complete") {
825
- if (chunk.input !== void 0 && !existingToolCall.arguments) {
826
- try {
827
- existingToolCall.arguments = JSON.stringify(chunk.input);
828
- } catch {
829
- }
830
- }
831
- const index = msgState.toolCallOrder.indexOf(chunk.toolCallId);
832
- this.completeToolCall(messageId, index, existingToolCall);
833
- if (chunk.input !== void 0) {
834
- existingToolCall.parsedArguments = chunk.input;
835
- this.messages = updateToolCallPart(this.messages, messageId, {
836
- id: existingToolCall.id,
837
- name: existingToolCall.name,
838
- arguments: existingToolCall.arguments,
839
- state: "input-complete",
840
- input: chunk.input,
841
- ...existingToolCall.metadata !== void 0 && {
842
- metadata: existingToolCall.metadata
843
- }
844
- });
845
- this.emitMessagesChange();
846
- }
847
- }
848
- if (chunk.result) {
849
- let output;
850
- if (Array.isArray(chunk.result)) {
851
- output = chunk.result;
852
- } else {
853
- try {
854
- output = JSON.parse(chunk.result);
855
- } catch {
856
- output = chunk.result;
857
- }
858
- }
859
- this.messages = updateToolCallWithOutput(
860
- this.messages,
861
- chunk.toolCallId,
862
- output,
863
- chunk.state === "output-error" ? "error" : void 0
864
- );
865
- const resultState = chunk.state === "output-error" ? "error" : "complete";
866
- this.messages = updateToolResultPart(
867
- this.messages,
868
- messageId,
869
- chunk.toolCallId,
870
- chunk.result,
871
- resultState,
872
- resultState === "error" ? this.extractToolResultError(output) : void 0
873
- );
874
- this.emitMessagesChange();
875
- }
876
- }
877
- extractToolResultError(output) {
878
- if (output && typeof output === "object" && "error" in output && typeof output.error === "string") {
879
- return output.error;
880
- }
881
- return typeof output === "string" ? output : "Tool execution failed";
882
- }
883
- /**
884
- * Handle TOOL_CALL_RESULT event (AG-UI spec).
885
- *
886
- * Creates a tool-result part and updates the tool-call output field,
887
- * mirroring the logic from TOOL_CALL_END when it carries a result.
888
- * This is the spec-compliant path for delivering tool results to the client.
889
- */
890
- handleToolCallResultEvent(chunk) {
891
- const messageId = this.toolCallToMessage.get(chunk.toolCallId);
892
- if (!messageId) return;
893
- let output;
894
- try {
895
- output = JSON.parse(chunk.content);
896
- } catch {
897
- output = chunk.content;
898
- }
899
- this.messages = updateToolCallWithOutput(
900
- this.messages,
901
- chunk.toolCallId,
902
- output,
903
- chunk.state === "output-error" ? "error" : void 0
904
- );
905
- const resultState = chunk.state === "output-error" ? "error" : "complete";
906
- this.messages = updateToolResultPart(
907
- this.messages,
908
- messageId,
909
- chunk.toolCallId,
910
- chunk.content,
911
- resultState,
912
- resultState === "error" ? this.extractToolResultError(output) : void 0
913
- );
914
- this.emitMessagesChange();
915
- }
916
- /**
917
- * Handle RUN_STARTED event.
918
- *
919
- * Registers the run so that RUN_FINISHED can determine whether other
920
- * runs are still active before finalizing.
921
- */
922
- handleRunStartedEvent(chunk) {
923
- this.activeRuns.add(chunk.runId);
924
- }
925
- /**
926
- * Handle RUN_FINISHED event.
927
- *
928
- * Records the finishReason and removes the run from activeRuns.
929
- * Only finalizes when no more runs are active, so that concurrent
930
- * runs don't interfere with each other.
931
- *
932
- * @see docs/chat-architecture.md#single-shot-tool-call-response — finishReason semantics
933
- * @see docs/chat-architecture.md#adapter-contract — Why RUN_FINISHED is mandatory
934
- */
935
- handleRunFinishedEvent(chunk) {
936
- this.finishReason = chunk.finishReason ?? null;
937
- this.activeRuns.delete(chunk.runId);
938
- if (this.activeRuns.size === 0) {
939
- this.isDone = true;
940
- this.completeAllToolCalls();
941
- this.finalizeStream();
942
- }
943
- }
944
- /**
945
- * Handle RUN_ERROR event
946
- */
947
- handleRunErrorEvent(chunk) {
948
- this.hasError = true;
949
- const runId = "runId" in chunk && typeof chunk.runId === "string" ? chunk.runId : void 0;
950
- if (runId) {
951
- this.activeRuns.delete(runId);
952
- } else {
953
- this.activeRuns.clear();
954
- }
955
- const { messageId } = this.ensureAssistantMessage();
956
- const errorMessage = chunk.message || chunk.error?.message || "An error occurred";
957
- if (!chunk.message && !chunk.error?.message) {
958
- console.error(
959
- "[StreamProcessor] RUN_ERROR with no message; original chunk:",
960
- chunk
961
- );
962
- }
963
- if (this.structuredMessageIds.has(messageId)) {
964
- this.flushStructuredOutputUpdate(messageId);
965
- this.messages = errorStructuredOutputPart(
966
- this.messages,
967
- messageId,
968
- errorMessage
969
- );
970
- this.structuredMessageIds.delete(messageId);
971
- this.emitStructuredOutputChange(messageId, "error");
972
- this.emitMessagesChange();
973
- }
974
- const error = new Error(errorMessage);
975
- const code = chunk.code ?? chunk.error?.code;
976
- if (code !== void 0) {
977
- Object.assign(error, { code });
978
- }
979
- if (chunk.rawEvent !== void 0) {
980
- Object.assign(error, { rawEvent: chunk.rawEvent });
981
- }
982
- this.events.onError?.(error);
983
- }
984
- /**
985
- * Handle STEP_STARTED event (for thinking/reasoning content).
986
- *
987
- * Records the stepId so that subsequent STEP_FINISHED deltas accumulate
988
- * into their own ThinkingPart. Does not create a message — the message
989
- * is lazily created when the first STEP_FINISHED content arrives.
990
- */
991
- handleStepStartedEvent(chunk) {
992
- const stepId = chunk.stepId ?? generateMessageId();
993
- const activeId = this.getActiveAssistantMessageId();
994
- if (activeId) {
995
- const state = this.getMessageState(activeId);
996
- if (state) {
997
- state.currentThinkingStepId = stepId;
998
- if (!state.thinkingSteps.has(stepId)) {
999
- state.thinkingSteps.set(stepId, "");
1000
- state.thinkingStepOrder.push(stepId);
1001
- }
1002
- this.pendingThinkingStepId = null;
1003
- return;
1004
- }
1005
- }
1006
- this.pendingThinkingStepId = stepId;
1007
- }
1008
- /**
1009
- * Handle STEP_FINISHED event (for thinking/reasoning content).
1010
- *
1011
- * Accumulates delta into the current thinking step's content and updates
1012
- * the corresponding ThinkingPart in the UIMessage.
1013
- *
1014
- * @see docs/chat-architecture.md#thinkingreasoning-content — Thinking flow
1015
- */
1016
- handleStepFinishedEvent(chunk) {
1017
- const { messageId, state } = this.ensureAssistantMessage(
1018
- this.getActiveAssistantMessageId() ?? void 0
1019
- );
1020
- if (state.hasSeenReasoningEvents) {
1021
- if (chunk.signature) {
1022
- const stepId2 = state.currentThinkingStepId ?? chunk.stepId;
1023
- if (!stepId2) return;
1024
- const thinking = state.thinkingSteps.get(stepId2);
1025
- if (thinking !== void 0) {
1026
- state.thinkingStepSignatures.set(stepId2, chunk.signature);
1027
- this.messages = updateThinkingPart(
1028
- this.messages,
1029
- messageId,
1030
- stepId2,
1031
- thinking,
1032
- chunk.signature
1033
- );
1034
- this.emitMessagesChange();
1035
- }
1036
- }
1037
- return;
1038
- }
1039
- this.consumePendingThinkingStep(state);
1040
- const stepId = state.currentThinkingStepId ?? chunk.stepId ?? generateMessageId();
1041
- if (!state.thinkingSteps.has(stepId)) {
1042
- state.thinkingSteps.set(stepId, "");
1043
- state.thinkingStepOrder.push(stepId);
1044
- state.currentThinkingStepId = stepId;
1045
- }
1046
- const previous = state.thinkingSteps.get(stepId) ?? "";
1047
- let nextThinking = previous;
1048
- if (chunk.delta && chunk.delta !== "") {
1049
- nextThinking = previous + chunk.delta;
1050
- } else if (chunk.content && chunk.content !== "") {
1051
- if (chunk.content.startsWith(previous)) {
1052
- nextThinking = chunk.content;
1053
- } else if (previous.startsWith(chunk.content)) {
1054
- nextThinking = previous;
1055
- } else {
1056
- nextThinking = previous + chunk.content;
1057
- }
1058
- }
1059
- state.thinkingSteps.set(stepId, nextThinking);
1060
- if (chunk.signature) {
1061
- state.thinkingStepSignatures.set(stepId, chunk.signature);
1062
- }
1063
- this.messages = updateThinkingPart(
1064
- this.messages,
1065
- messageId,
1066
- stepId,
1067
- nextThinking,
1068
- state.thinkingStepSignatures.get(stepId)
1069
- );
1070
- this.emitMessagesChange();
1071
- this.events.onThinkingUpdate?.(messageId, stepId, nextThinking);
1072
- }
1073
- /**
1074
- * Handle REASONING_MESSAGE_CONTENT event (AG-UI reasoning protocol).
1075
- *
1076
- * Accumulates reasoning delta into thinking content and updates the
1077
- * corresponding ThinkingPart in the UIMessage.
1078
- */
1079
- handleReasoningMessageContentEvent(chunk) {
1080
- const { messageId, state } = this.ensureAssistantMessage(
1081
- this.getActiveAssistantMessageId() ?? void 0
1082
- );
1083
- state.hasSeenReasoningEvents = true;
1084
- const delta = chunk.delta || "";
1085
- this.consumePendingThinkingStep(state);
1086
- const stepId = state.currentThinkingStepId ?? chunk.messageId;
1087
- if (!state.thinkingSteps.has(stepId)) {
1088
- state.thinkingSteps.set(stepId, "");
1089
- state.thinkingStepOrder.push(stepId);
1090
- state.currentThinkingStepId = stepId;
1091
- }
1092
- const nextThinking = (state.thinkingSteps.get(stepId) ?? "") + delta;
1093
- state.thinkingSteps.set(stepId, nextThinking);
1094
- this.messages = updateThinkingPart(
1095
- this.messages,
1096
- messageId,
1097
- stepId,
1098
- nextThinking,
1099
- state.thinkingStepSignatures.get(stepId)
1100
- );
1101
- this.emitMessagesChange();
1102
- this.events.onThinkingUpdate?.(messageId, stepId, nextThinking);
1103
- }
1104
- /**
1105
- * Handle CUSTOM event.
1106
- *
1107
- * Handles special custom events emitted by the TextEngine (not adapters):
1108
- * - 'tool-input-available': Client tool needs execution. Fires onToolCall.
1109
- * - 'approval-requested': Tool needs user approval. Updates tool-call part
1110
- * state and fires onApprovalRequest.
1111
- *
1112
- * @see docs/chat-architecture.md#client-tools-and-approval-flows — Full flow details
1113
- */
1114
- handleCustomEvent(chunk) {
1115
- const messageId = this.getActiveAssistantMessageId();
1116
- if (chunk.name === "structured-output.start" && chunk.value) {
1117
- const v = chunk.value;
1118
- const targetId = v.messageId ?? messageId;
1119
- if (targetId) {
1120
- this.ensureAssistantMessage(targetId);
1121
- this.structuredMessageIds.add(targetId);
1122
- this.structuredOutputUpdateBatches.delete(targetId);
1123
- this.events.onStructuredOutputChange?.({
1124
- phase: "start",
1125
- messageId: targetId,
1126
- status: "streaming",
1127
- raw: ""
1128
- });
1129
- }
1130
- return;
1131
- }
1132
- if (chunk.name === "structured-output.complete" && chunk.value) {
1133
- const v = chunk.value;
1134
- const targetId = v.messageId ?? messageId;
1135
- if (targetId) {
1136
- this.flushStructuredOutputUpdate(targetId);
1137
- this.messages = completeStructuredOutputPart(
1138
- this.messages,
1139
- targetId,
1140
- v.object,
1141
- v.raw ?? "",
1142
- v.reasoning
1143
- );
1144
- this.structuredMessageIds.delete(targetId);
1145
- this.emitStructuredOutputChange(targetId, "complete");
1146
- this.emitMessagesChange();
1147
- }
1148
- }
1149
- if (chunk.name === "tool-input-available" && chunk.value) {
1150
- const { toolCallId, toolName, input } = chunk.value;
1151
- this.events.onToolCall?.({
1152
- toolCallId,
1153
- toolName,
1154
- input
1155
- });
1156
- return;
1157
- }
1158
- if (chunk.name === "approval-requested" && chunk.value) {
1159
- const { toolCallId, toolName, input, approval } = chunk.value;
1160
- const resolvedMessageId = messageId ?? this.toolCallToMessage.get(toolCallId);
1161
- if (resolvedMessageId) {
1162
- this.messages = updateToolCallApproval(
1163
- this.messages,
1164
- resolvedMessageId,
1165
- toolCallId,
1166
- approval.id
1167
- );
1168
- this.emitMessagesChange();
1169
- }
1170
- this.events.onApprovalRequest?.({
1171
- toolCallId,
1172
- toolName,
1173
- input,
1174
- approvalId: approval.id
1175
- });
1176
- return;
1177
- }
1178
- if (chunk.name === "ui-resource" && chunk.value) {
1179
- const v = chunk.value;
1180
- const resolvedMessageId = this.toolCallToMessage.get(v.toolCallId) ?? messageId;
1181
- if (resolvedMessageId) {
1182
- const part = {
1183
- type: "ui-resource",
1184
- resource: v.resource,
1185
- toolCallId: v.toolCallId,
1186
- toolName: v.toolName,
1187
- ...v.serverId !== void 0 && { serverId: v.serverId },
1188
- ...v.meta !== void 0 && { meta: v.meta }
1189
- };
1190
- this.messages = this.messages.map(
1191
- (msg) => msg.id === resolvedMessageId ? { ...msg, parts: [...msg.parts, part] } : msg
1192
- );
1193
- this.emitMessagesChange();
1194
- } else {
1195
- console.warn(
1196
- `[mcp-apps] dropped ui-resource: no target message for toolCallId "${v.toolCallId}" (toolName "${v.toolName}")`
1197
- );
1198
- }
1199
- return;
1200
- }
1201
- if (this.events.onCustomEvent) {
1202
- const toolCallId = chunk.value && typeof chunk.value === "object" ? chunk.value.toolCallId : void 0;
1203
- this.events.onCustomEvent(chunk.name, chunk.value, { toolCallId });
1204
- }
1205
- }
1206
- // ============================================
1207
- // Internal Helpers
1208
- // ============================================
1209
- /**
1210
- * Detect if an incoming content chunk represents a NEW text segment
1211
- */
1212
- isNewTextSegment(chunk, previous) {
1213
- if (chunk.content !== void 0) {
1214
- if (chunk.content.length < previous.length) {
1215
- return true;
1216
- }
1217
- if (!chunk.content.startsWith(previous) && !previous.startsWith(chunk.content)) {
1218
- return true;
1219
- }
1220
- }
1221
- return false;
1222
- }
1223
- /**
1224
- * Complete all tool calls across all active messages — safety net for stream termination.
1225
- *
1226
- * Called by RUN_FINISHED and finalizeStream(). Force-transitions any tool call
1227
- * not yet in input-complete state. Handles cases where TOOL_CALL_END was
1228
- * missed (adapter bug, network error, aborted stream).
1229
- *
1230
- * @see docs/chat-architecture.md#single-shot-tool-call-response — Safety net behavior
1231
- */
1232
- completeAllToolCalls() {
1233
- for (const messageId of this.activeMessageIds) {
1234
- this.completeAllToolCallsForMessage(messageId);
1235
- }
1236
- }
1237
- /**
1238
- * Complete all tool calls for a specific message
1239
- */
1240
- completeAllToolCallsForMessage(messageId) {
1241
- const state = this.getMessageState(messageId);
1242
- if (!state) return;
1243
- state.toolCalls.forEach((toolCall, id) => {
1244
- if (toolCall.state !== "input-complete") {
1245
- const index = state.toolCallOrder.indexOf(id);
1246
- this.completeToolCall(messageId, index, toolCall);
1247
- }
1248
- });
1249
- }
1250
- /**
1251
- * Mark a tool call as complete and emit event
1252
- */
1253
- completeToolCall(messageId, _index, toolCall) {
1254
- toolCall.state = "input-complete";
1255
- toolCall.parsedArguments = this.jsonParser.parse(toolCall.arguments);
1256
- if (this.isToolCallPartErrored(toolCall.id)) {
1257
- return;
1258
- }
1259
- this.messages = updateToolCallPart(this.messages, messageId, {
1260
- id: toolCall.id,
1261
- name: toolCall.name,
1262
- arguments: toolCall.arguments,
1263
- state: "input-complete",
1264
- ...toolCall.parsedArguments !== void 0 && {
1265
- input: toolCall.parsedArguments
1266
- },
1267
- ...toolCall.metadata !== void 0 && { metadata: toolCall.metadata }
1268
- });
1269
- this.emitMessagesChange();
1270
- this.events.onToolCallStateChange?.(
1271
- messageId,
1272
- toolCall.id,
1273
- "input-complete",
1274
- toolCall.arguments
1275
- );
1276
- }
1277
- /**
1278
- * Whether the rendered tool-call part for the given id has reached the
1279
- * terminal 'error' state. Used to prevent the completion safety net from
1280
- * downgrading a failed call back to 'input-complete'.
1281
- */
1282
- isToolCallPartErrored(toolCallId) {
1283
- return this.messages.some(
1284
- (msg) => (
1285
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- `parts` is typed as required, but seeded ModelMessage-shaped messages can lack it at runtime.
1286
- msg.parts?.some(
1287
- (part) => part.type === "tool-call" && part.id === toolCallId && part.state === "error"
1288
- )
1289
- )
1290
- );
1291
- }
1292
- /**
1293
- * Emit pending text update for a specific message.
1294
- *
1295
- * Calls updateTextPart() which has critical append-vs-replace logic:
1296
- * - If last UIMessage part is TextPart → replaces its content (same segment).
1297
- * - If last part is anything else → pushes new TextPart (new segment after tools).
1298
- *
1299
- * @see docs/chat-architecture.md#uimessage-part-ordering-invariants — Replace vs. push logic
1300
- */
1301
- emitTextUpdateForMessage(messageId) {
1302
- const state = this.getMessageState(messageId);
1303
- if (!state) return;
1304
- state.lastEmittedText = state.currentSegmentText;
1305
- this.messages = updateTextPart(
1306
- this.messages,
1307
- messageId,
1308
- state.currentSegmentText
1309
- );
1310
- this.emitMessagesChange();
1311
- this.events.onTextUpdate?.(messageId, state.currentSegmentText);
1312
- }
1313
- queueStructuredOutputUpdate(messageId, delta) {
1314
- const existing = this.structuredOutputUpdateBatches.get(messageId);
1315
- const next = {
1316
- delta: `${existing?.delta ?? ""}${delta}`,
1317
- chunkCount: (existing?.chunkCount ?? 0) + 1
1318
- };
1319
- this.structuredOutputUpdateBatches.set(messageId, next);
1320
- if (next.chunkCount >= STRUCTURED_OUTPUT_UPDATE_BATCH_SIZE) {
1321
- this.flushStructuredOutputUpdate(messageId);
1322
- }
1323
- }
1324
- flushStructuredOutputUpdate(messageId) {
1325
- const batch = this.structuredOutputUpdateBatches.get(messageId);
1326
- if (!batch || batch.chunkCount === 0) return;
1327
- this.structuredOutputUpdateBatches.delete(messageId);
1328
- this.emitStructuredOutputChange(messageId, "update", batch.delta);
1329
- }
1330
- emitStructuredOutputChange(messageId, phase, delta) {
1331
- const part = this.messages.find((message) => message.id === messageId)?.parts.find(
1332
- (messagePart) => messagePart.type === "structured-output"
1333
- );
1334
- if (!part) return;
1335
- this.events.onStructuredOutputChange?.({
1336
- phase,
1337
- messageId,
1338
- status: part.status,
1339
- raw: part.raw,
1340
- ...part.partial !== void 0 ? { partial: part.partial } : {},
1341
- ...part.data !== void 0 ? { data: part.data } : {},
1342
- ...part.reasoning !== void 0 ? { reasoning: part.reasoning } : {},
1343
- ...part.errorMessage !== void 0 ? { errorMessage: part.errorMessage } : {},
1344
- ...delta !== void 0 ? { delta } : {}
1345
- });
1346
- }
1347
- /**
1348
- * Emit messages change event
1349
- */
1350
- emitMessagesChange() {
1351
- this.events.onMessagesChange?.([...this.messages]);
1352
- }
1353
- /**
1354
- * Finalize the stream — complete all pending operations.
1355
- *
1356
- * Called when the async iterable ends (stream closed). Acts as the final
1357
- * safety net: completes any remaining tool calls, flushes un-emitted text,
1358
- * and fires onStreamEnd.
1359
- *
1360
- * @see docs/chat-architecture.md#single-shot-text-response — Finalization step
1361
- */
1362
- finalizeStream() {
1363
- let lastAssistantMessage;
1364
- for (const messageId of this.activeMessageIds) {
1365
- const state = this.getMessageState(messageId);
1366
- if (!state) continue;
1367
- this.completeAllToolCallsForMessage(messageId);
1368
- if (state.currentSegmentText !== state.lastEmittedText) {
1369
- this.emitTextUpdateForMessage(messageId);
1370
- }
1371
- state.isComplete = true;
1372
- const msg = this.messages.find((m) => m.id === messageId);
1373
- if (msg && msg.role === "assistant") {
1374
- lastAssistantMessage = msg;
1375
- }
1376
- }
1377
- for (const messageId of this.structuredMessageIds) {
1378
- this.flushStructuredOutputUpdate(messageId);
1379
- this.messages = errorStructuredOutputPart(
1380
- this.messages,
1381
- messageId,
1382
- "Stream ended without structured-output.complete"
1383
- );
1384
- this.emitStructuredOutputChange(messageId, "error");
1385
- }
1386
- this.structuredMessageIds.clear();
1387
- this.structuredOutputUpdateBatches.clear();
1388
- this.activeMessageIds.clear();
1389
- if (lastAssistantMessage && !this.hasError) {
1390
- if (this.isWhitespaceOnlyMessage(lastAssistantMessage)) {
1391
- this.messages = this.messages.filter(
1392
- (m) => m.id !== lastAssistantMessage.id
1393
- );
1394
- this.emitMessagesChange();
1395
- return;
1396
- }
1397
- }
1398
- if (lastAssistantMessage) {
1399
- this.events.onStreamEnd?.(lastAssistantMessage);
1400
- }
1401
- }
1402
- /**
1403
- * Get completed tool calls in API format (aggregated across all messages)
1404
- */
1405
- getCompletedToolCalls() {
1406
- const result = [];
1407
- for (const state of this.messageStates.values()) {
1408
- for (const tc of state.toolCalls.values()) {
1409
- if (tc.state === "input-complete") {
1410
- result.push({
1411
- id: tc.id,
1412
- type: "function",
1413
- function: {
1414
- name: tc.name,
1415
- arguments: tc.arguments
1416
- },
1417
- // Preserve provider metadata (e.g. Gemini thoughtSignature) on
1418
- // ProcessorResult.toolCalls so callers using process()/getResult()
1419
- // get the same round-trip support as the streaming UI path.
1420
- ...tc.metadata !== void 0 && { metadata: tc.metadata }
1421
- });
1422
- }
1423
- }
1424
- }
1425
- return result;
1426
- }
1427
- /**
1428
- * Get current result (aggregated across all messages)
1429
- */
1430
- getResult() {
1431
- const toolCalls = this.getCompletedToolCalls();
1432
- let content = "";
1433
- let thinking = "";
1434
- for (const state of this.messageStates.values()) {
1435
- content += state.totalTextContent;
1436
- for (const stepId of state.thinkingStepOrder) {
1437
- thinking += state.thinkingSteps.get(stepId) ?? "";
1438
- }
1439
- }
1440
- return {
1441
- content,
1442
- thinking: thinking || void 0,
1443
- toolCalls: toolCalls.length > 0 ? toolCalls : void 0,
1444
- finishReason: this.finishReason
1445
- };
1446
- }
1447
- /**
1448
- * Get current processor state (aggregated across all messages)
1449
- */
1450
- getState() {
1451
- let content = "";
1452
- let thinking = "";
1453
- const toolCalls = /* @__PURE__ */ new Map();
1454
- const toolCallOrder = [];
1455
- for (const state of this.messageStates.values()) {
1456
- content += state.totalTextContent;
1457
- for (const stepId of state.thinkingStepOrder) {
1458
- thinking += state.thinkingSteps.get(stepId) ?? "";
1459
- }
1460
- for (const [id, tc] of state.toolCalls) {
1461
- toolCalls.set(id, tc);
1462
- }
1463
- toolCallOrder.push(...state.toolCallOrder);
1464
- }
1465
- return {
1466
- content,
1467
- thinking,
1468
- toolCalls,
1469
- toolCallOrder,
1470
- finishReason: this.finishReason,
1471
- done: this.isDone
1472
- };
1473
- }
1474
- /**
1475
- * Start recording chunks
1476
- */
1477
- startRecording() {
1478
- this.recordingEnabled = true;
1479
- this.recordingStartTime = Date.now();
1480
- this.recording = {
1481
- version: "1.0",
1482
- timestamp: this.recordingStartTime,
1483
- chunks: []
1484
- };
1485
- }
1486
- /**
1487
- * Get the current recording
1488
- */
1489
- getRecording() {
1490
- return this.recording;
1491
- }
1492
- /**
1493
- * Reset stream state (but keep messages)
1494
- */
1495
- resetStreamState() {
1496
- this.messageStates.clear();
1497
- this.activeMessageIds.clear();
1498
- this.activeRuns.clear();
1499
- this.toolCallToMessage.clear();
1500
- this.structuredMessageIds.clear();
1501
- this.structuredOutputUpdateBatches.clear();
1502
- this.pendingManualMessageId = null;
1503
- this.pendingThinkingStepId = null;
1504
- this.finishReason = null;
1505
- this.hasError = false;
1506
- this.isDone = false;
1507
- this.chunkStrategy.reset?.();
1508
- }
1509
- /**
1510
- * Full reset (including messages)
1511
- */
1512
- reset() {
1513
- this.resetStreamState();
1514
- this.messages = [];
1515
- }
1516
- /**
1517
- * Check if a message contains only whitespace text and no other meaningful parts
1518
- * (no tool calls, tool results, thinking, etc.)
1519
- */
1520
- isWhitespaceOnlyMessage(message) {
1521
- if (message.parts.length === 0) return false;
1522
- return message.parts.every(
1523
- (part) => part.type === "text" && part.content.trim() === ""
1524
- );
1525
- }
1526
- /**
1527
- * Replay a recording through the processor
1528
- */
1529
- static async replay(recording, options) {
1530
- const processor = new StreamProcessor(options);
1531
- return processor.process(createReplayStream(recording));
1532
- }
1533
- }
7
+ //#region src/activities/chat/stream/processor.ts
8
+ /**
9
+ * Unified Stream Processor
10
+ *
11
+ * Core stream processing engine that manages the full UIMessage[] conversation.
12
+ * Single source of truth for message state.
13
+ *
14
+ * Handles:
15
+ * - Full conversation management (UIMessage[])
16
+ * - Text content accumulation with configurable chunking strategies
17
+ * - Parallel tool calls with lifecycle state tracking
18
+ * - Tool results and approval flows
19
+ * - Thinking/reasoning content
20
+ * - Recording/replay for testing
21
+ * - Event-driven architecture for UI updates
22
+ * - Per-message stream state tracking for multi-message sessions
23
+ *
24
+ * @see docs/chat-architecture.md — Canonical reference for AG-UI chunk ordering,
25
+ * adapter contract, single-shot flows, and expected UIMessage output.
26
+ */
27
+ var STRUCTURED_OUTPUT_UPDATE_BATCH_SIZE = 12;
28
+ /**
29
+ * StreamProcessor - State machine for processing AI response streams
30
+ *
31
+ * Manages the full UIMessage[] conversation and emits events on changes.
32
+ * Trusts the adapter contract: adapters emit clean AG-UI events in the
33
+ * correct order.
34
+ *
35
+ * State tracking:
36
+ * - Full message array
37
+ * - Per-message stream state (text, tool calls, thinking)
38
+ * - Multiple concurrent message streams
39
+ * - Tool call completion via TOOL_CALL_END events
40
+ *
41
+ * @see docs/chat-architecture.md#streamprocessor-internal-state — State field reference
42
+ * @see docs/chat-architecture.md#adapter-contract — What this class expects from adapters
43
+ */
44
+ var StreamProcessor = class StreamProcessor {
45
+ chunkStrategy;
46
+ events;
47
+ jsonParser;
48
+ recordingEnabled;
49
+ messages = [];
50
+ messageStates = /* @__PURE__ */ new Map();
51
+ activeMessageIds = /* @__PURE__ */ new Set();
52
+ toolCallToMessage = /* @__PURE__ */ new Map();
53
+ pendingManualMessageId = null;
54
+ pendingThinkingStepId = null;
55
+ structuredMessageIds = /* @__PURE__ */ new Set();
56
+ structuredOutputUpdateBatches = /* @__PURE__ */ new Map();
57
+ activeRuns = /* @__PURE__ */ new Set();
58
+ finishReason = null;
59
+ hasError = false;
60
+ isDone = false;
61
+ recording = null;
62
+ recordingStartTime = 0;
63
+ constructor(options = {}) {
64
+ this.chunkStrategy = options.chunkStrategy || new ImmediateStrategy();
65
+ this.events = options.events || {};
66
+ this.jsonParser = options.jsonParser || defaultJSONParser;
67
+ this.recordingEnabled = options.recording ?? false;
68
+ if (options.initialMessages) this.messages = [...options.initialMessages];
69
+ }
70
+ /**
71
+ * Set the messages array (e.g., from persisted state)
72
+ */
73
+ setMessages(messages) {
74
+ this.messages = [...messages];
75
+ this.emitMessagesChange();
76
+ }
77
+ /**
78
+ * Add a user message to the conversation.
79
+ * Supports both simple string content and multimodal content arrays.
80
+ *
81
+ * @param content - The message content (string or array of content parts)
82
+ * @param id - Optional custom message ID (generated if not provided)
83
+ * @returns The created UIMessage
84
+ *
85
+ * @example
86
+ * ```ts
87
+ * // Simple text message
88
+ * processor.addUserMessage('Hello!')
89
+ *
90
+ * // Multimodal message with image
91
+ * processor.addUserMessage([
92
+ * { type: 'text', content: 'What is in this image?' },
93
+ * { type: 'image', source: { type: 'url', value: 'https://example.com/photo.jpg' } }
94
+ * ])
95
+ *
96
+ * // With custom ID
97
+ * processor.addUserMessage('Hello!', 'custom-id-123')
98
+ * ```
99
+ */
100
+ addUserMessage(content, id) {
101
+ const parts = typeof content === "string" ? [{
102
+ type: "text",
103
+ content
104
+ }] : content.map((part) => {
105
+ return part;
106
+ });
107
+ const userMessage = {
108
+ id: id ?? generateMessageId(),
109
+ role: "user",
110
+ parts,
111
+ createdAt: /* @__PURE__ */ new Date()
112
+ };
113
+ this.messages = [...this.messages, userMessage];
114
+ this.emitMessagesChange();
115
+ return userMessage;
116
+ }
117
+ /**
118
+ * Prepare for a new assistant message stream.
119
+ * Does NOT create the message immediately -- the message is created lazily
120
+ * when the first content-bearing chunk arrives via ensureAssistantMessage().
121
+ * This prevents empty assistant messages from flickering in the UI when
122
+ * auto-continuation produces no content.
123
+ */
124
+ prepareAssistantMessage() {
125
+ this.resetStreamState();
126
+ }
127
+ /**
128
+ * @deprecated Use prepareAssistantMessage() instead. This eagerly creates
129
+ * an assistant message which can cause empty message flicker.
130
+ */
131
+ startAssistantMessage(messageId) {
132
+ this.prepareAssistantMessage();
133
+ const { messageId: id } = this.ensureAssistantMessage(messageId);
134
+ this.pendingManualMessageId = id;
135
+ return id;
136
+ }
137
+ /**
138
+ * Get the current assistant message ID (if one has been created).
139
+ * Returns null if prepareAssistantMessage() was called but no content
140
+ * has arrived yet.
141
+ */
142
+ getCurrentAssistantMessageId() {
143
+ let lastId = null;
144
+ for (const [id, state] of this.messageStates) if (state.role === "assistant") lastId = id;
145
+ return lastId;
146
+ }
147
+ /**
148
+ * Add a tool result (called by client after handling onToolCall)
149
+ */
150
+ addToolResult(toolCallId, output, error) {
151
+ const messageWithToolCall = this.messages.find((msg) => msg.parts.some((p) => p.type === "tool-call" && p.id === toolCallId));
152
+ if (!messageWithToolCall) {
153
+ console.warn(`[StreamProcessor] Could not find message with tool call ${toolCallId}`);
154
+ return;
155
+ }
156
+ let updatedMessages = updateToolCallWithOutput(this.messages, toolCallId, output, error ? "error" : void 0, error);
157
+ const content = normalizeToolResult(output);
158
+ const toolResultState = error ? "error" : "complete";
159
+ updatedMessages = updateToolResultPart(updatedMessages, messageWithToolCall.id, toolCallId, content, toolResultState, error);
160
+ this.messages = updatedMessages;
161
+ this.emitMessagesChange();
162
+ }
163
+ /**
164
+ * Add an approval response (called by client after handling onApprovalRequest)
165
+ */
166
+ addToolApprovalResponse(approvalId, approved) {
167
+ this.messages = updateToolCallApprovalResponse(this.messages, approvalId, approved);
168
+ this.emitMessagesChange();
169
+ }
170
+ /**
171
+ * Get the conversation as ModelMessages (for sending to LLM)
172
+ */
173
+ toModelMessages() {
174
+ const modelMessages = [];
175
+ for (const msg of this.messages) modelMessages.push(...uiMessageToModelMessages(msg));
176
+ return modelMessages;
177
+ }
178
+ /**
179
+ * Get current messages
180
+ */
181
+ getMessages() {
182
+ return this.messages;
183
+ }
184
+ /**
185
+ * Check if all tool calls in the last assistant message are complete
186
+ * Useful for auto-continue logic
187
+ */
188
+ areAllToolsComplete() {
189
+ const lastAssistant = this.messages.findLast((m) => m.role === "assistant");
190
+ if (!lastAssistant) return true;
191
+ const toolParts = lastAssistant.parts.filter((p) => p.type === "tool-call");
192
+ if (toolParts.length === 0) return true;
193
+ const toolResultIds = new Set(lastAssistant.parts.filter((p) => p.type === "tool-result").map((p) => p.toolCallId));
194
+ return toolParts.every((part) => part.state === "complete" || part.state === "approval-responded" || part.output !== void 0 && !part.approval || toolResultIds.has(part.id) || isProviderExecutedToolCall(part));
195
+ }
196
+ /**
197
+ * Remove messages after a certain index (for reload/retry)
198
+ */
199
+ removeMessagesAfter(index) {
200
+ const keptIds = new Set(this.messages.slice(0, index + 1).map((m) => m.id));
201
+ for (const id of this.structuredMessageIds) if (!keptIds.has(id)) this.structuredMessageIds.delete(id);
202
+ for (const id of this.structuredOutputUpdateBatches.keys()) if (!keptIds.has(id)) this.structuredOutputUpdateBatches.delete(id);
203
+ for (const id of this.messageStates.keys()) if (!keptIds.has(id)) this.messageStates.delete(id);
204
+ for (const [toolCallId, msgId] of this.toolCallToMessage) if (!keptIds.has(msgId)) this.toolCallToMessage.delete(toolCallId);
205
+ for (const id of this.activeMessageIds) if (!keptIds.has(id)) this.activeMessageIds.delete(id);
206
+ this.messages = this.messages.slice(0, index + 1);
207
+ this.emitMessagesChange();
208
+ }
209
+ /**
210
+ * Clear all messages
211
+ */
212
+ clearMessages() {
213
+ this.messages = [];
214
+ this.messageStates.clear();
215
+ this.activeMessageIds.clear();
216
+ this.toolCallToMessage.clear();
217
+ this.structuredMessageIds.clear();
218
+ this.structuredOutputUpdateBatches.clear();
219
+ this.pendingManualMessageId = null;
220
+ this.emitMessagesChange();
221
+ }
222
+ /**
223
+ * Process a stream and emit events through handlers
224
+ */
225
+ async process(stream) {
226
+ this.resetStreamState();
227
+ if (this.recordingEnabled) this.startRecording();
228
+ for await (const chunk of stream) this.processChunk(chunk);
229
+ this.finalizeStream();
230
+ if (this.recording) this.recording.result = this.getResult();
231
+ return this.getResult();
232
+ }
233
+ /**
234
+ * Process a single chunk from the stream.
235
+ *
236
+ * Central dispatch for all AG-UI events. Each event type maps to a specific
237
+ * handler. Events not listed in the switch are intentionally ignored
238
+ * (STEP_STARTED, STATE_SNAPSHOT, STATE_DELTA).
239
+ *
240
+ * @see docs/chat-architecture.md#adapter-contract — Expected event types and ordering
241
+ */
242
+ processChunk(chunk) {
243
+ if (this.recording) this.recording.chunks.push({
244
+ chunk,
245
+ timestamp: Date.now(),
246
+ index: this.recording.chunks.length
247
+ });
248
+ switch (chunk.type) {
249
+ case "TEXT_MESSAGE_START":
250
+ this.handleTextMessageStartEvent(chunk);
251
+ break;
252
+ case "TEXT_MESSAGE_CONTENT":
253
+ this.handleTextMessageContentEvent(chunk);
254
+ break;
255
+ case "TEXT_MESSAGE_END":
256
+ this.handleTextMessageEndEvent(chunk);
257
+ break;
258
+ case "TOOL_CALL_START":
259
+ this.handleToolCallStartEvent(chunk);
260
+ break;
261
+ case "TOOL_CALL_ARGS":
262
+ this.handleToolCallArgsEvent(chunk);
263
+ break;
264
+ case "TOOL_CALL_END":
265
+ this.handleToolCallEndEvent(chunk);
266
+ break;
267
+ case "RUN_FINISHED":
268
+ this.handleRunFinishedEvent(chunk);
269
+ break;
270
+ case "RUN_ERROR":
271
+ this.handleRunErrorEvent(chunk);
272
+ break;
273
+ case "STEP_FINISHED":
274
+ this.handleStepFinishedEvent(chunk);
275
+ break;
276
+ case "MESSAGES_SNAPSHOT":
277
+ this.handleMessagesSnapshotEvent(chunk);
278
+ break;
279
+ case "CUSTOM":
280
+ this.handleCustomEvent(chunk);
281
+ break;
282
+ case "RUN_STARTED":
283
+ this.handleRunStartedEvent(chunk);
284
+ break;
285
+ case "REASONING_START":
286
+ case "REASONING_MESSAGE_START":
287
+ case "REASONING_MESSAGE_END":
288
+ case "REASONING_END": break;
289
+ case "REASONING_MESSAGE_CONTENT":
290
+ this.handleReasoningMessageContentEvent(chunk);
291
+ break;
292
+ case "TOOL_CALL_RESULT":
293
+ this.handleToolCallResultEvent(chunk);
294
+ break;
295
+ case "STEP_STARTED":
296
+ this.handleStepStartedEvent(chunk);
297
+ break;
298
+ default: break;
299
+ }
300
+ }
301
+ /**
302
+ * Create a new MessageStreamState for a message
303
+ */
304
+ createMessageState(messageId, role) {
305
+ const state = {
306
+ id: messageId,
307
+ role,
308
+ totalTextContent: "",
309
+ currentSegmentText: "",
310
+ lastEmittedText: "",
311
+ hasSeenReasoningEvents: false,
312
+ thinkingSteps: /* @__PURE__ */ new Map(),
313
+ thinkingStepSignatures: /* @__PURE__ */ new Map(),
314
+ thinkingStepOrder: [],
315
+ currentThinkingStepId: null,
316
+ toolCalls: /* @__PURE__ */ new Map(),
317
+ toolCallOrder: [],
318
+ hasToolCallsSinceTextStart: false,
319
+ isComplete: false
320
+ };
321
+ this.messageStates.set(messageId, state);
322
+ return state;
323
+ }
324
+ /**
325
+ * Get the MessageStreamState for a message
326
+ */
327
+ getMessageState(messageId) {
328
+ return this.messageStates.get(messageId);
329
+ }
330
+ /**
331
+ * Promote a pending stepId from a STEP_STARTED that fired before the
332
+ * assistant message existed onto the given message state, so the next
333
+ * thinking event (STEP_FINISHED or REASONING_MESSAGE_CONTENT) attributes
334
+ * to the correct step.
335
+ */
336
+ consumePendingThinkingStep(state) {
337
+ if (!this.pendingThinkingStepId) return;
338
+ const stepId = this.pendingThinkingStepId;
339
+ state.currentThinkingStepId = stepId;
340
+ if (!state.thinkingSteps.has(stepId)) {
341
+ state.thinkingSteps.set(stepId, "");
342
+ state.thinkingStepOrder.push(stepId);
343
+ }
344
+ this.pendingThinkingStepId = null;
345
+ }
346
+ /**
347
+ * Get the most recent active assistant message ID.
348
+ * Used as fallback for events that don't include a messageId.
349
+ */
350
+ getActiveAssistantMessageId() {
351
+ const ids = Array.from(this.activeMessageIds).reverse();
352
+ for (const id of ids) {
353
+ const state = this.messageStates.get(id);
354
+ if (state && state.role === "assistant") return id;
355
+ }
356
+ return null;
357
+ }
358
+ /**
359
+ * Ensure an active assistant message exists, creating one if needed.
360
+ * Used for backward compat when events arrive without prior TEXT_MESSAGE_START.
361
+ *
362
+ * On reconnect/resume, a TEXT_MESSAGE_CONTENT may arrive for a message that
363
+ * already exists in this.messages (e.g. from initialMessages or a prior
364
+ * MESSAGES_SNAPSHOT) but whose transient state was cleared. In that case we
365
+ * hydrate state from the existing message rather than creating a duplicate.
366
+ */
367
+ ensureAssistantMessage(preferredId) {
368
+ if (preferredId) {
369
+ const state = this.getMessageState(preferredId);
370
+ if (state) return {
371
+ messageId: preferredId,
372
+ state
373
+ };
374
+ }
375
+ const activeId = this.getActiveAssistantMessageId();
376
+ if (activeId) {
377
+ const state = this.getMessageState(activeId);
378
+ if (state) return {
379
+ messageId: activeId,
380
+ state
381
+ };
382
+ }
383
+ if (preferredId) {
384
+ const existingMsg = this.messages.find((m) => m.id === preferredId);
385
+ if (existingMsg) {
386
+ const state = this.createMessageState(preferredId, existingMsg.role);
387
+ this.activeMessageIds.add(preferredId);
388
+ const lastPart = existingMsg.parts.length > 0 ? existingMsg.parts[existingMsg.parts.length - 1] : null;
389
+ if (lastPart && lastPart.type === "text") {
390
+ state.currentSegmentText = lastPart.content;
391
+ state.lastEmittedText = lastPart.content;
392
+ state.totalTextContent = lastPart.content;
393
+ }
394
+ return {
395
+ messageId: preferredId,
396
+ state
397
+ };
398
+ }
399
+ }
400
+ const id = preferredId || generateMessageId();
401
+ const assistantMessage = {
402
+ id,
403
+ role: "assistant",
404
+ parts: [],
405
+ createdAt: /* @__PURE__ */ new Date()
406
+ };
407
+ this.messages = [...this.messages, assistantMessage];
408
+ const state = this.createMessageState(id, "assistant");
409
+ this.activeMessageIds.add(id);
410
+ this.pendingManualMessageId = id;
411
+ this.events.onStreamStart?.();
412
+ this.emitMessagesChange();
413
+ return {
414
+ messageId: id,
415
+ state
416
+ };
417
+ }
418
+ /**
419
+ * Handle TEXT_MESSAGE_START event
420
+ */
421
+ handleTextMessageStartEvent(chunk) {
422
+ const { messageId, role } = chunk;
423
+ const uiRole = role === "user" || role === "system" ? role : "assistant";
424
+ if (this.pendingManualMessageId) {
425
+ const pendingId = this.pendingManualMessageId;
426
+ this.pendingManualMessageId = null;
427
+ if (pendingId !== messageId) {
428
+ this.messages = this.messages.map((msg) => msg.id === pendingId ? {
429
+ ...msg,
430
+ id: messageId
431
+ } : msg);
432
+ const existingState = this.messageStates.get(pendingId);
433
+ if (existingState) {
434
+ existingState.id = messageId;
435
+ this.messageStates.delete(pendingId);
436
+ this.messageStates.set(messageId, existingState);
437
+ }
438
+ this.activeMessageIds.delete(pendingId);
439
+ this.activeMessageIds.add(messageId);
440
+ }
441
+ if (!this.messageStates.has(messageId)) {
442
+ this.createMessageState(messageId, uiRole);
443
+ this.activeMessageIds.add(messageId);
444
+ }
445
+ this.emitMessagesChange();
446
+ return;
447
+ }
448
+ if (this.messages.find((m) => m.id === messageId)) {
449
+ this.activeMessageIds.add(messageId);
450
+ const existingState = this.messageStates.get(messageId);
451
+ if (!existingState) this.createMessageState(messageId, uiRole);
452
+ else if (existingState.hasToolCallsSinceTextStart) {
453
+ if (existingState.currentSegmentText !== existingState.lastEmittedText) this.emitTextUpdateForMessage(messageId);
454
+ existingState.currentSegmentText = "";
455
+ existingState.lastEmittedText = "";
456
+ existingState.hasToolCallsSinceTextStart = false;
457
+ }
458
+ return;
459
+ }
460
+ const newMessage = {
461
+ id: messageId,
462
+ role: uiRole,
463
+ parts: [],
464
+ createdAt: /* @__PURE__ */ new Date()
465
+ };
466
+ this.messages = [...this.messages, newMessage];
467
+ this.createMessageState(messageId, uiRole);
468
+ this.activeMessageIds.add(messageId);
469
+ this.events.onStreamStart?.();
470
+ this.emitMessagesChange();
471
+ }
472
+ /**
473
+ * Handle TEXT_MESSAGE_END event
474
+ */
475
+ handleTextMessageEndEvent(chunk) {
476
+ const { messageId } = chunk;
477
+ const state = this.getMessageState(messageId);
478
+ if (!state) return;
479
+ if (state.isComplete) return;
480
+ if (state.currentSegmentText !== state.lastEmittedText) this.emitTextUpdateForMessage(messageId);
481
+ this.completeAllToolCallsForMessage(messageId);
482
+ }
483
+ /**
484
+ * Handle MESSAGES_SNAPSHOT event
485
+ */
486
+ handleMessagesSnapshotEvent(chunk) {
487
+ this.resetStreamState();
488
+ const prevMessages = this.messages;
489
+ const normalized = chunk.messages.map(aguiSnapshotMessageToUIMessage);
490
+ this.messages = this.reconcileSnapshotToolCalls(normalized, prevMessages);
491
+ this.emitMessagesChange();
492
+ }
493
+ /**
494
+ * Reconcile a freshly normalized snapshot with the pre-snapshot message
495
+ * state so unreconstructable tool-call metadata is preserved.
496
+ *
497
+ * Post-pass (a): anchor `tool-result`-only assistant messages (the shape
498
+ * `aguiSnapshotMessageToUIMessage` emits for AG-UI `role: 'tool'` wire
499
+ * messages) into the message containing the matching `tool-call` part, or —
500
+ * when the snapshot supplies no such part — the nearest earlier anchorable
501
+ * assistant message, matching the in-stream fan-out shape
502
+ * `assistant: [text, tool-call, tool-result, ...]`. Detached messages with
503
+ * no earlier anchorable assistant are kept verbatim.
504
+ *
505
+ * Post-pass (b): when a `tool-result` part references a `toolCallId` whose
506
+ * `tool-call` part is absent from the snapshot, carry the `tool-call` part
507
+ * forward from the pre-snapshot state (state and output untouched) so a
508
+ * subsequent `addToolResult(toolCallId)` can still locate the call.
509
+ *
510
+ * Post-pass (c): AG-UI wire snapshots rebuild `tool-call` parts as
511
+ * `input-complete` without `output` (ModelMessage has no result field on
512
+ * the call). After anchoring results, copy each `tool-result` onto its
513
+ * matching `tool-call` (and prefer pre-snapshot complete/output when the
514
+ * snapshot is poorer) so server tools keep the same UI shape as client tools.
515
+ */
516
+ reconcileSnapshotToolCalls(snapshot, prevMessages) {
517
+ const prevToolCalls = /* @__PURE__ */ new Map();
518
+ for (const msg of prevMessages) for (const part of msg.parts) if (part.type === "tool-call") prevToolCalls.set(part.id, part);
519
+ const snapshotToolCallIds = /* @__PURE__ */ new Set();
520
+ for (const msg of snapshot) for (const part of msg.parts) if (part.type === "tool-call") snapshotToolCallIds.add(part.id);
521
+ const reconciled = [];
522
+ for (const msg of snapshot) {
523
+ const toolResultPart = msg.role === "assistant" && msg.parts.length === 1 ? msg.parts.find((p) => p.type === "tool-result") : void 0;
524
+ if (!toolResultPart) {
525
+ reconciled.push(msg);
526
+ continue;
527
+ }
528
+ const target = reconciled.findLast((m) => m.parts.some((p) => p.type === "tool-call" && p.id === toolResultPart.toolCallId)) ?? reconciled.findLast((m) => m.role === "assistant" && !(m.parts.length === 1 && m.parts[0]?.type === "tool-result"));
529
+ if (!target) {
530
+ if (!snapshotToolCallIds.has(toolResultPart.toolCallId)) console.warn(`[StreamProcessor] MESSAGES_SNAPSHOT contains a tool-result for "${toolResultPart.toolCallId}" but no matching tool-call exists in the snapshot, and there is no assistant message to anchor into; addToolResult("${toolResultPart.toolCallId}") will not be able to locate this call`);
531
+ reconciled.push(msg);
532
+ continue;
533
+ }
534
+ const parts = [...target.parts];
535
+ if (!snapshotToolCallIds.has(toolResultPart.toolCallId) && !parts.some((p) => p.type === "tool-call" && p.id === toolResultPart.toolCallId)) {
536
+ const prev = prevToolCalls.get(toolResultPart.toolCallId);
537
+ if (prev) {
538
+ parts.push({ ...prev });
539
+ snapshotToolCallIds.add(prev.id);
540
+ } else console.warn(`[StreamProcessor] MESSAGES_SNAPSHOT contains a tool-result for "${toolResultPart.toolCallId}" but no matching tool-call exists in the snapshot or the pre-snapshot state; addToolResult("${toolResultPart.toolCallId}") will not be able to locate this call`);
541
+ }
542
+ parts.push(toolResultPart);
543
+ target.parts = parts;
544
+ }
545
+ return this.enrichSnapshotToolCallsFromResults(reconciled, prevToolCalls);
546
+ }
547
+ /**
548
+ * Post-pass (c): fold `tool-result` content into sibling `tool-call` parts
549
+ * and prefer pre-snapshot complete/output when the snapshot rebuilt a
550
+ * poorer `input-complete` call (AG-UI ModelMessage has no result on calls).
551
+ */
552
+ enrichSnapshotToolCallsFromResults(messages, prevToolCalls) {
553
+ const resultsByCallId = /* @__PURE__ */ new Map();
554
+ for (const msg of messages) for (const part of msg.parts) if (part.type === "tool-result") resultsByCallId.set(part.toolCallId, part);
555
+ return messages.map((msg) => {
556
+ const parts = msg.parts.map((part) => {
557
+ if (part.type !== "tool-call") return part;
558
+ const prev = prevToolCalls.get(part.id);
559
+ const result = resultsByCallId.get(part.id);
560
+ let next = part;
561
+ if (prev && (prev.output !== void 0 || prev.state === "complete" || prev.state === "error") && (part.output === void 0 || part.state === "input-complete" || part.state === "input-streaming" || part.state === "awaiting-input")) next = {
562
+ ...part,
563
+ ...prev.output !== void 0 ? { output: prev.output } : {},
564
+ state: prev.state,
565
+ ...prev.approval !== void 0 ? { approval: prev.approval } : {},
566
+ ...prev.metadata !== void 0 ? { metadata: prev.metadata } : {}
567
+ };
568
+ if (result && next.output === void 0) {
569
+ let output;
570
+ if (Array.isArray(result.content)) output = result.content;
571
+ else try {
572
+ output = JSON.parse(result.content);
573
+ } catch {
574
+ output = result.content;
575
+ }
576
+ const errorText = result.state === "error" ? this.extractToolResultError(output) : void 0;
577
+ next = {
578
+ ...next,
579
+ output: errorText ? { error: errorText } : output,
580
+ state: result.state === "error" ? "error" : "complete"
581
+ };
582
+ }
583
+ return next;
584
+ });
585
+ return parts.some((part, index) => part !== msg.parts[index]) ? {
586
+ ...msg,
587
+ parts
588
+ } : msg;
589
+ });
590
+ }
591
+ /**
592
+ * Handle TEXT_MESSAGE_CONTENT event.
593
+ *
594
+ * Accumulates delta into both currentSegmentText (for UI emission) and
595
+ * totalTextContent (for ProcessorResult). Lazily creates the assistant
596
+ * UIMessage on first content. Uses updateTextPart() which replaces the
597
+ * last TextPart or creates a new one depending on part ordering.
598
+ *
599
+ * @see docs/chat-architecture.md#single-shot-text-response — Text accumulation step-by-step
600
+ * @see docs/chat-architecture.md#uimessage-part-ordering-invariants — Replace vs. push logic
601
+ */
602
+ handleTextMessageContentEvent(chunk) {
603
+ const { messageId, state } = this.ensureAssistantMessage(chunk.messageId);
604
+ this.completeAllToolCallsForMessage(messageId);
605
+ if (this.structuredMessageIds.has(messageId)) {
606
+ let delta = chunk.delta || "";
607
+ if (delta === "" && chunk.content !== void 0 && chunk.content !== "") {
608
+ const existingRaw = (this.messages.find((m) => m.id === messageId)?.parts.find((p) => p.type === "structured-output") ?? { raw: "" }).raw;
609
+ if (chunk.content.startsWith(existingRaw)) delta = chunk.content.slice(existingRaw.length);
610
+ else if (existingRaw.startsWith(chunk.content)) delta = "";
611
+ else delta = chunk.content;
612
+ }
613
+ if (delta !== "") {
614
+ this.messages = appendStructuredOutputDelta(this.messages, messageId, delta);
615
+ state.totalTextContent += delta;
616
+ this.queueStructuredOutputUpdate(messageId, delta);
617
+ this.emitMessagesChange();
618
+ }
619
+ return;
620
+ }
621
+ const previousSegment = state.currentSegmentText;
622
+ if (state.hasToolCallsSinceTextStart && previousSegment.length > 0 && this.isNewTextSegment(chunk, previousSegment)) {
623
+ if (previousSegment !== state.lastEmittedText) this.emitTextUpdateForMessage(messageId);
624
+ state.currentSegmentText = "";
625
+ state.lastEmittedText = "";
626
+ state.hasToolCallsSinceTextStart = false;
627
+ }
628
+ const currentText = state.currentSegmentText;
629
+ let nextText = currentText;
630
+ const delta = chunk.delta || "";
631
+ if (delta !== "") nextText = currentText + delta;
632
+ else if (chunk.content !== void 0 && chunk.content !== "") if (chunk.content.startsWith(currentText)) nextText = chunk.content;
633
+ else if (currentText.startsWith(chunk.content)) nextText = currentText;
634
+ else nextText = currentText + chunk.content;
635
+ const textDelta = nextText.slice(currentText.length);
636
+ state.currentSegmentText = nextText;
637
+ state.totalTextContent += textDelta;
638
+ const chunkPortion = chunk.delta || chunk.content || "";
639
+ if (this.chunkStrategy.shouldEmit(chunkPortion, state.currentSegmentText) && state.currentSegmentText !== state.lastEmittedText) this.emitTextUpdateForMessage(messageId);
640
+ }
641
+ /**
642
+ * Handle TOOL_CALL_START event.
643
+ *
644
+ * Creates a new InternalToolCallState entry in the toolCalls Map and appends
645
+ * a ToolCallPart to the UIMessage. Duplicate toolCallId is a no-op.
646
+ *
647
+ * CRITICAL: This MUST be received before any TOOL_CALL_ARGS for the same
648
+ * toolCallId. Args for unknown IDs are silently dropped.
649
+ *
650
+ * @see docs/chat-architecture.md#single-shot-tool-call-response — Tool call state transitions
651
+ * @see docs/chat-architecture.md#parallel-tool-calls-single-shot — Parallel tracking by ID
652
+ * @see docs/chat-architecture.md#adapter-contract — Ordering requirements
653
+ */
654
+ handleToolCallStartEvent(chunk) {
655
+ const targetMessageId = chunk.parentMessageId ?? this.getActiveAssistantMessageId();
656
+ const { messageId, state } = this.ensureAssistantMessage(targetMessageId ?? void 0);
657
+ state.hasToolCallsSinceTextStart = true;
658
+ const toolCallId = chunk.toolCallId;
659
+ if (!state.toolCalls.get(toolCallId)) {
660
+ const initialState = "awaiting-input";
661
+ const toolName = chunk.toolCallName ?? chunk.toolName;
662
+ const chunkMetadata = chunk.metadata;
663
+ const newToolCall = {
664
+ id: chunk.toolCallId,
665
+ name: toolName,
666
+ arguments: "",
667
+ state: initialState,
668
+ parsedArguments: void 0,
669
+ index: chunk.index ?? state.toolCalls.size,
670
+ ...chunkMetadata !== void 0 && { metadata: chunkMetadata }
671
+ };
672
+ state.toolCalls.set(toolCallId, newToolCall);
673
+ state.toolCallOrder.push(toolCallId);
674
+ this.toolCallToMessage.set(toolCallId, messageId);
675
+ this.messages = updateToolCallPart(this.messages, messageId, {
676
+ id: chunk.toolCallId,
677
+ name: toolName,
678
+ arguments: "",
679
+ state: initialState,
680
+ ...chunkMetadata !== void 0 && { metadata: chunkMetadata }
681
+ });
682
+ this.emitMessagesChange();
683
+ this.events.onToolCallStateChange?.(messageId, chunk.toolCallId, initialState, "");
684
+ }
685
+ }
686
+ /**
687
+ * Handle TOOL_CALL_ARGS event.
688
+ *
689
+ * Appends the delta to the tool call's accumulated arguments string.
690
+ * Transitions state from awaiting-input → input-streaming on first non-empty delta.
691
+ * Attempts partial JSON parse on each update for UI preview.
692
+ *
693
+ * If toolCallId is not found in the Map (no preceding TOOL_CALL_START),
694
+ * this event is silently dropped.
695
+ *
696
+ * @see docs/chat-architecture.md#single-shot-tool-call-response — Step-by-step tool call processing
697
+ */
698
+ handleToolCallArgsEvent(chunk) {
699
+ const toolCallId = chunk.toolCallId;
700
+ const messageId = this.toolCallToMessage.get(toolCallId);
701
+ if (!messageId) return;
702
+ const state = this.getMessageState(messageId);
703
+ if (!state) return;
704
+ const existingToolCall = state.toolCalls.get(toolCallId);
705
+ if (!existingToolCall) return;
706
+ const wasAwaitingInput = existingToolCall.state === "awaiting-input";
707
+ existingToolCall.arguments += chunk.delta || "";
708
+ if (wasAwaitingInput && chunk.delta) existingToolCall.state = "input-streaming";
709
+ existingToolCall.parsedArguments = this.jsonParser.parse(existingToolCall.arguments);
710
+ this.messages = updateToolCallPart(this.messages, messageId, {
711
+ id: existingToolCall.id,
712
+ name: existingToolCall.name,
713
+ arguments: existingToolCall.arguments,
714
+ state: existingToolCall.state
715
+ });
716
+ this.emitMessagesChange();
717
+ this.events.onToolCallStateChange?.(messageId, existingToolCall.id, existingToolCall.state, existingToolCall.arguments);
718
+ }
719
+ /**
720
+ * Handle TOOL_CALL_END event — authoritative signal that a tool call's input is finalized.
721
+ *
722
+ * This event has a DUAL ROLE:
723
+ * - Without `result`: Signals arguments are done (from adapter). Transitions to input-complete.
724
+ * - With `result`: Signals tool was executed and result is available (from TextEngine).
725
+ * Creates both output on the tool-call part AND a tool-result part.
726
+ *
727
+ * If `input` is provided, it overrides the accumulated string parse as the
728
+ * canonical parsed arguments.
729
+ *
730
+ * @see docs/chat-architecture.md#tool-results-and-the-tool_call_end-dual-role — Full explanation
731
+ * @see docs/chat-architecture.md#single-shot-tool-call-response — End-to-end flow
732
+ */
733
+ handleToolCallEndEvent(chunk) {
734
+ const messageId = this.toolCallToMessage.get(chunk.toolCallId);
735
+ if (!messageId) return;
736
+ const msgState = this.getMessageState(messageId);
737
+ if (!msgState) return;
738
+ const existingToolCall = msgState.toolCalls.get(chunk.toolCallId);
739
+ if (existingToolCall && existingToolCall.state !== "input-complete") {
740
+ if (chunk.input !== void 0 && !existingToolCall.arguments) try {
741
+ existingToolCall.arguments = JSON.stringify(chunk.input);
742
+ } catch {}
743
+ const index = msgState.toolCallOrder.indexOf(chunk.toolCallId);
744
+ this.completeToolCall(messageId, index, existingToolCall);
745
+ if (chunk.input !== void 0) {
746
+ existingToolCall.parsedArguments = chunk.input;
747
+ this.messages = updateToolCallPart(this.messages, messageId, {
748
+ id: existingToolCall.id,
749
+ name: existingToolCall.name,
750
+ arguments: existingToolCall.arguments,
751
+ state: "input-complete",
752
+ input: chunk.input,
753
+ ...existingToolCall.metadata !== void 0 && { metadata: existingToolCall.metadata }
754
+ });
755
+ this.emitMessagesChange();
756
+ }
757
+ }
758
+ if (chunk.result) {
759
+ let output;
760
+ if (Array.isArray(chunk.result)) output = chunk.result;
761
+ else try {
762
+ output = JSON.parse(chunk.result);
763
+ } catch {
764
+ output = chunk.result;
765
+ }
766
+ this.messages = updateToolCallWithOutput(this.messages, chunk.toolCallId, output, chunk.state === "output-error" ? "error" : void 0);
767
+ const resultState = chunk.state === "output-error" ? "error" : "complete";
768
+ this.messages = updateToolResultPart(this.messages, messageId, chunk.toolCallId, chunk.result, resultState, resultState === "error" ? this.extractToolResultError(output) : void 0);
769
+ this.emitMessagesChange();
770
+ }
771
+ }
772
+ extractToolResultError(output) {
773
+ if (output && typeof output === "object" && "error" in output && typeof output.error === "string") return output.error;
774
+ return typeof output === "string" ? output : "Tool execution failed";
775
+ }
776
+ /**
777
+ * Handle TOOL_CALL_RESULT event (AG-UI spec).
778
+ *
779
+ * Creates a tool-result part and updates the tool-call output field,
780
+ * mirroring the logic from TOOL_CALL_END when it carries a result.
781
+ * This is the spec-compliant path for delivering tool results to the client.
782
+ */
783
+ handleToolCallResultEvent(chunk) {
784
+ const messageId = this.toolCallToMessage.get(chunk.toolCallId);
785
+ if (!messageId) return;
786
+ let output;
787
+ try {
788
+ output = JSON.parse(chunk.content);
789
+ } catch {
790
+ output = chunk.content;
791
+ }
792
+ this.messages = updateToolCallWithOutput(this.messages, chunk.toolCallId, output, chunk.state === "output-error" ? "error" : void 0);
793
+ const resultState = chunk.state === "output-error" ? "error" : "complete";
794
+ this.messages = updateToolResultPart(this.messages, messageId, chunk.toolCallId, chunk.content, resultState, resultState === "error" ? this.extractToolResultError(output) : void 0);
795
+ this.emitMessagesChange();
796
+ }
797
+ /**
798
+ * Handle RUN_STARTED event.
799
+ *
800
+ * Registers the run so that RUN_FINISHED can determine whether other
801
+ * runs are still active before finalizing.
802
+ */
803
+ handleRunStartedEvent(chunk) {
804
+ this.activeRuns.add(chunk.runId);
805
+ }
806
+ /**
807
+ * Handle RUN_FINISHED event.
808
+ *
809
+ * Records the finishReason and removes the run from activeRuns.
810
+ * Only finalizes when no more runs are active, so that concurrent
811
+ * runs don't interfere with each other.
812
+ *
813
+ * @see docs/chat-architecture.md#single-shot-tool-call-response — finishReason semantics
814
+ * @see docs/chat-architecture.md#adapter-contract — Why RUN_FINISHED is mandatory
815
+ */
816
+ handleRunFinishedEvent(chunk) {
817
+ this.finishReason = chunk.finishReason ?? null;
818
+ this.activeRuns.delete(chunk.runId);
819
+ if (chunk.outcome?.type === "interrupt") this.handleInterrupts(chunk.outcome.interrupts);
820
+ if (this.activeRuns.size === 0) {
821
+ this.isDone = true;
822
+ this.completeAllToolCalls();
823
+ this.finalizeStream();
824
+ }
825
+ }
826
+ handleInterrupts(interrupts) {
827
+ for (const interrupt of interrupts) {
828
+ const metadata = interrupt.metadata && typeof interrupt.metadata === "object" ? interrupt.metadata : {};
829
+ const kind = typeof metadata.kind === "string" ? metadata.kind : void 0;
830
+ const toolCallId = interrupt.toolCallId;
831
+ if (!toolCallId) continue;
832
+ const toolName = typeof metadata.toolName === "string" ? metadata.toolName : this.findToolCallName(toolCallId);
833
+ const input = Object.hasOwn(metadata, "input") ? metadata.input : {};
834
+ if (kind === "approval" || interrupt.reason === "approval_required") {
835
+ const resolvedMessageId = this.getActiveAssistantMessageId() ?? this.toolCallToMessage.get(toolCallId) ?? this.messages.find((m) => m.role === "assistant" && m.parts.some((p) => p.type === "tool-call" && p.id === toolCallId))?.id;
836
+ if (resolvedMessageId) {
837
+ this.messages = updateToolCallApproval(this.messages, resolvedMessageId, toolCallId, interrupt.id);
838
+ this.emitMessagesChange();
839
+ }
840
+ this.events.onApprovalRequest?.({
841
+ toolCallId,
842
+ toolName,
843
+ input,
844
+ approvalId: interrupt.id
845
+ });
846
+ continue;
847
+ }
848
+ if (kind === "client_tool" || interrupt.reason === "client_tool_input") this.events.onToolCall?.({
849
+ toolCallId,
850
+ toolName,
851
+ input
852
+ });
853
+ }
854
+ }
855
+ findToolCallName(toolCallId) {
856
+ for (const state of this.messageStates.values()) {
857
+ const toolCall = state.toolCalls.get(toolCallId);
858
+ if (toolCall) return toolCall.name;
859
+ }
860
+ return "";
861
+ }
862
+ /**
863
+ * Handle RUN_ERROR event
864
+ */
865
+ handleRunErrorEvent(chunk) {
866
+ this.hasError = true;
867
+ const runId = "runId" in chunk && typeof chunk.runId === "string" ? chunk.runId : void 0;
868
+ if (runId) this.activeRuns.delete(runId);
869
+ else this.activeRuns.clear();
870
+ const { messageId } = this.ensureAssistantMessage();
871
+ const errorMessage = chunk.message || chunk.error?.message || "An error occurred";
872
+ if (!chunk.message && !chunk.error?.message) console.error("[StreamProcessor] RUN_ERROR with no message; original chunk:", chunk);
873
+ if (this.structuredMessageIds.has(messageId)) {
874
+ this.flushStructuredOutputUpdate(messageId);
875
+ this.messages = errorStructuredOutputPart(this.messages, messageId, errorMessage);
876
+ this.structuredMessageIds.delete(messageId);
877
+ this.emitStructuredOutputChange(messageId, "error");
878
+ this.emitMessagesChange();
879
+ }
880
+ const error = new Error(errorMessage);
881
+ const code = chunk.code ?? chunk.error?.code;
882
+ if (code !== void 0) Object.assign(error, { code });
883
+ if (chunk.rawEvent !== void 0) Object.assign(error, { rawEvent: chunk.rawEvent });
884
+ this.events.onError?.(error);
885
+ }
886
+ /**
887
+ * Handle STEP_STARTED event (for thinking/reasoning content).
888
+ *
889
+ * Records the stepId so that subsequent STEP_FINISHED deltas accumulate
890
+ * into their own ThinkingPart. Does not create a message — the message
891
+ * is lazily created when the first STEP_FINISHED content arrives.
892
+ */
893
+ handleStepStartedEvent(chunk) {
894
+ const stepId = chunk.stepId ?? generateMessageId();
895
+ const activeId = this.getActiveAssistantMessageId();
896
+ if (activeId) {
897
+ const state = this.getMessageState(activeId);
898
+ if (state) {
899
+ state.currentThinkingStepId = stepId;
900
+ if (!state.thinkingSteps.has(stepId)) {
901
+ state.thinkingSteps.set(stepId, "");
902
+ state.thinkingStepOrder.push(stepId);
903
+ }
904
+ this.pendingThinkingStepId = null;
905
+ return;
906
+ }
907
+ }
908
+ this.pendingThinkingStepId = stepId;
909
+ }
910
+ /**
911
+ * Handle STEP_FINISHED event (for thinking/reasoning content).
912
+ *
913
+ * Accumulates delta into the current thinking step's content and updates
914
+ * the corresponding ThinkingPart in the UIMessage.
915
+ *
916
+ * @see docs/chat-architecture.md#thinkingreasoning-content — Thinking flow
917
+ */
918
+ handleStepFinishedEvent(chunk) {
919
+ const { messageId, state } = this.ensureAssistantMessage(this.getActiveAssistantMessageId() ?? void 0);
920
+ if (state.hasSeenReasoningEvents) {
921
+ if (chunk.signature) {
922
+ const stepId = state.currentThinkingStepId ?? chunk.stepId;
923
+ if (!stepId) return;
924
+ const thinking = state.thinkingSteps.get(stepId);
925
+ if (thinking !== void 0) {
926
+ state.thinkingStepSignatures.set(stepId, chunk.signature);
927
+ this.messages = updateThinkingPart(this.messages, messageId, stepId, thinking, chunk.signature);
928
+ this.emitMessagesChange();
929
+ }
930
+ }
931
+ return;
932
+ }
933
+ this.consumePendingThinkingStep(state);
934
+ const stepId = state.currentThinkingStepId ?? chunk.stepId ?? generateMessageId();
935
+ if (!state.thinkingSteps.has(stepId)) {
936
+ state.thinkingSteps.set(stepId, "");
937
+ state.thinkingStepOrder.push(stepId);
938
+ state.currentThinkingStepId = stepId;
939
+ }
940
+ const previous = state.thinkingSteps.get(stepId) ?? "";
941
+ let nextThinking = previous;
942
+ if (chunk.delta && chunk.delta !== "") nextThinking = previous + chunk.delta;
943
+ else if (chunk.content && chunk.content !== "") if (chunk.content.startsWith(previous)) nextThinking = chunk.content;
944
+ else if (previous.startsWith(chunk.content)) nextThinking = previous;
945
+ else nextThinking = previous + chunk.content;
946
+ state.thinkingSteps.set(stepId, nextThinking);
947
+ if (chunk.signature) state.thinkingStepSignatures.set(stepId, chunk.signature);
948
+ this.messages = updateThinkingPart(this.messages, messageId, stepId, nextThinking, state.thinkingStepSignatures.get(stepId));
949
+ this.emitMessagesChange();
950
+ this.events.onThinkingUpdate?.(messageId, stepId, nextThinking);
951
+ }
952
+ /**
953
+ * Handle REASONING_MESSAGE_CONTENT event (AG-UI reasoning protocol).
954
+ *
955
+ * Accumulates reasoning delta into thinking content and updates the
956
+ * corresponding ThinkingPart in the UIMessage.
957
+ */
958
+ handleReasoningMessageContentEvent(chunk) {
959
+ const { messageId, state } = this.ensureAssistantMessage(this.getActiveAssistantMessageId() ?? void 0);
960
+ state.hasSeenReasoningEvents = true;
961
+ const delta = chunk.delta || "";
962
+ this.consumePendingThinkingStep(state);
963
+ const stepId = state.currentThinkingStepId ?? chunk.messageId;
964
+ if (!state.thinkingSteps.has(stepId)) {
965
+ state.thinkingSteps.set(stepId, "");
966
+ state.thinkingStepOrder.push(stepId);
967
+ state.currentThinkingStepId = stepId;
968
+ }
969
+ const nextThinking = (state.thinkingSteps.get(stepId) ?? "") + delta;
970
+ state.thinkingSteps.set(stepId, nextThinking);
971
+ this.messages = updateThinkingPart(this.messages, messageId, stepId, nextThinking, state.thinkingStepSignatures.get(stepId));
972
+ this.emitMessagesChange();
973
+ this.events.onThinkingUpdate?.(messageId, stepId, nextThinking);
974
+ }
975
+ /**
976
+ * Handle CUSTOM event.
977
+ *
978
+ * Handles custom events consumed by the processor:
979
+ * - 'tool-input-available': Legacy/replay-compatible input for client tool
980
+ * execution. Fires onToolCall.
981
+ * - 'approval-requested': Legacy/replay-compatible input for tool approval.
982
+ * Updates tool-call part state and fires onApprovalRequest.
983
+ *
984
+ * Current core streams represent user-actionable waits through
985
+ * RUN_FINISHED.outcome.type === 'interrupt'; these custom events are not the
986
+ * source of truth for new emissions.
987
+ *
988
+ * @see docs/chat-architecture.md#client-tools-and-approval-flows — Full flow details
989
+ */
990
+ handleCustomEvent(chunk) {
991
+ const messageId = this.getActiveAssistantMessageId();
992
+ if (chunk.name === "structured-output.start" && chunk.value) {
993
+ const targetId = chunk.value.messageId ?? messageId;
994
+ if (targetId) {
995
+ this.ensureAssistantMessage(targetId);
996
+ this.structuredMessageIds.add(targetId);
997
+ this.structuredOutputUpdateBatches.delete(targetId);
998
+ this.events.onStructuredOutputChange?.({
999
+ phase: "start",
1000
+ messageId: targetId,
1001
+ status: "streaming",
1002
+ raw: ""
1003
+ });
1004
+ }
1005
+ return;
1006
+ }
1007
+ if (chunk.name === "structured-output.complete" && chunk.value) {
1008
+ const v = chunk.value;
1009
+ const targetId = v.messageId ?? messageId;
1010
+ if (targetId) {
1011
+ this.flushStructuredOutputUpdate(targetId);
1012
+ this.messages = completeStructuredOutputPart(this.messages, targetId, v.object, v.raw ?? "", v.reasoning);
1013
+ this.structuredMessageIds.delete(targetId);
1014
+ this.emitStructuredOutputChange(targetId, "complete");
1015
+ this.emitMessagesChange();
1016
+ }
1017
+ }
1018
+ if (chunk.name === "tool-input-available" && chunk.value) {
1019
+ const { toolCallId, toolName, input } = chunk.value;
1020
+ this.events.onToolCall?.({
1021
+ toolCallId,
1022
+ toolName,
1023
+ input
1024
+ });
1025
+ return;
1026
+ }
1027
+ if (chunk.name === "approval-requested" && chunk.value) {
1028
+ const { toolCallId, toolName, input, approval } = chunk.value;
1029
+ const resolvedMessageId = messageId ?? this.toolCallToMessage.get(toolCallId);
1030
+ if (resolvedMessageId) {
1031
+ this.messages = updateToolCallApproval(this.messages, resolvedMessageId, toolCallId, approval.id);
1032
+ this.emitMessagesChange();
1033
+ }
1034
+ this.events.onApprovalRequest?.({
1035
+ toolCallId,
1036
+ toolName,
1037
+ input,
1038
+ approvalId: approval.id
1039
+ });
1040
+ return;
1041
+ }
1042
+ if (chunk.name === "ui-resource" && chunk.value) {
1043
+ const v = chunk.value;
1044
+ const resolvedMessageId = this.toolCallToMessage.get(v.toolCallId) ?? messageId;
1045
+ if (resolvedMessageId) {
1046
+ const part = {
1047
+ type: "ui-resource",
1048
+ resource: v.resource,
1049
+ toolCallId: v.toolCallId,
1050
+ toolName: v.toolName,
1051
+ ...v.serverId !== void 0 && { serverId: v.serverId },
1052
+ ...v.meta !== void 0 && { meta: v.meta }
1053
+ };
1054
+ this.messages = this.messages.map((msg) => msg.id === resolvedMessageId ? {
1055
+ ...msg,
1056
+ parts: [...msg.parts, part]
1057
+ } : msg);
1058
+ this.emitMessagesChange();
1059
+ } else console.warn(`[mcp-apps] dropped ui-resource: no target message for toolCallId "${v.toolCallId}" (toolName "${v.toolName}")`);
1060
+ return;
1061
+ }
1062
+ if (this.events.onCustomEvent) {
1063
+ const toolCallId = chunk.value && typeof chunk.value === "object" ? chunk.value.toolCallId : void 0;
1064
+ this.events.onCustomEvent(chunk.name, chunk.value, { toolCallId });
1065
+ }
1066
+ }
1067
+ /**
1068
+ * Detect if an incoming content chunk represents a NEW text segment
1069
+ */
1070
+ isNewTextSegment(chunk, previous) {
1071
+ if (chunk.content !== void 0) {
1072
+ if (chunk.content.length < previous.length) return true;
1073
+ if (!chunk.content.startsWith(previous) && !previous.startsWith(chunk.content)) return true;
1074
+ }
1075
+ return false;
1076
+ }
1077
+ /**
1078
+ * Complete all tool calls across all active messages — safety net for stream termination.
1079
+ *
1080
+ * Called by RUN_FINISHED and finalizeStream(). Force-transitions any tool call
1081
+ * not yet in input-complete state. Handles cases where TOOL_CALL_END was
1082
+ * missed (adapter bug, network error, aborted stream).
1083
+ *
1084
+ * @see docs/chat-architecture.md#single-shot-tool-call-response — Safety net behavior
1085
+ */
1086
+ completeAllToolCalls() {
1087
+ for (const messageId of this.activeMessageIds) this.completeAllToolCallsForMessage(messageId);
1088
+ }
1089
+ /**
1090
+ * Complete all tool calls for a specific message
1091
+ */
1092
+ completeAllToolCallsForMessage(messageId) {
1093
+ const state = this.getMessageState(messageId);
1094
+ if (!state) return;
1095
+ state.toolCalls.forEach((toolCall, id) => {
1096
+ if (toolCall.state !== "input-complete") {
1097
+ const index = state.toolCallOrder.indexOf(id);
1098
+ this.completeToolCall(messageId, index, toolCall);
1099
+ }
1100
+ });
1101
+ }
1102
+ /**
1103
+ * Mark a tool call as complete and emit event
1104
+ */
1105
+ completeToolCall(messageId, _index, toolCall) {
1106
+ toolCall.state = "input-complete";
1107
+ toolCall.parsedArguments = this.jsonParser.parse(toolCall.arguments);
1108
+ if (this.isToolCallPartErrored(toolCall.id)) return;
1109
+ if (this.isToolCallPartAwaitingUserAction(toolCall.id)) return;
1110
+ this.messages = updateToolCallPart(this.messages, messageId, {
1111
+ id: toolCall.id,
1112
+ name: toolCall.name,
1113
+ arguments: toolCall.arguments,
1114
+ state: "input-complete",
1115
+ ...toolCall.parsedArguments !== void 0 && { input: toolCall.parsedArguments },
1116
+ ...toolCall.metadata !== void 0 && { metadata: toolCall.metadata }
1117
+ });
1118
+ this.emitMessagesChange();
1119
+ this.events.onToolCallStateChange?.(messageId, toolCall.id, "input-complete", toolCall.arguments);
1120
+ }
1121
+ isToolCallPartAwaitingUserAction(toolCallId) {
1122
+ return this.messages.some((msg) => msg.parts?.some((part) => part.type === "tool-call" && part.id === toolCallId && (part.state === "approval-requested" || part.state === "approval-responded")));
1123
+ }
1124
+ /**
1125
+ * Whether the rendered tool-call part for the given id has reached the
1126
+ * terminal 'error' state. Used to prevent the completion safety net from
1127
+ * downgrading a failed call back to 'input-complete'.
1128
+ */
1129
+ isToolCallPartErrored(toolCallId) {
1130
+ return this.messages.some((msg) => msg.parts?.some((part) => part.type === "tool-call" && part.id === toolCallId && part.state === "error"));
1131
+ }
1132
+ /**
1133
+ * Emit pending text update for a specific message.
1134
+ *
1135
+ * Calls updateTextPart() which has critical append-vs-replace logic:
1136
+ * - If last UIMessage part is TextPart → replaces its content (same segment).
1137
+ * - If last part is anything else → pushes new TextPart (new segment after tools).
1138
+ *
1139
+ * @see docs/chat-architecture.md#uimessage-part-ordering-invariants — Replace vs. push logic
1140
+ */
1141
+ emitTextUpdateForMessage(messageId) {
1142
+ const state = this.getMessageState(messageId);
1143
+ if (!state) return;
1144
+ state.lastEmittedText = state.currentSegmentText;
1145
+ this.messages = updateTextPart(this.messages, messageId, state.currentSegmentText);
1146
+ this.emitMessagesChange();
1147
+ this.events.onTextUpdate?.(messageId, state.currentSegmentText);
1148
+ }
1149
+ queueStructuredOutputUpdate(messageId, delta) {
1150
+ const existing = this.structuredOutputUpdateBatches.get(messageId);
1151
+ const next = {
1152
+ delta: `${existing?.delta ?? ""}${delta}`,
1153
+ chunkCount: (existing?.chunkCount ?? 0) + 1
1154
+ };
1155
+ this.structuredOutputUpdateBatches.set(messageId, next);
1156
+ if (next.chunkCount >= STRUCTURED_OUTPUT_UPDATE_BATCH_SIZE) this.flushStructuredOutputUpdate(messageId);
1157
+ }
1158
+ flushStructuredOutputUpdate(messageId) {
1159
+ const batch = this.structuredOutputUpdateBatches.get(messageId);
1160
+ if (!batch || batch.chunkCount === 0) return;
1161
+ this.structuredOutputUpdateBatches.delete(messageId);
1162
+ this.emitStructuredOutputChange(messageId, "update", batch.delta);
1163
+ }
1164
+ emitStructuredOutputChange(messageId, phase, delta) {
1165
+ const part = this.messages.find((message) => message.id === messageId)?.parts.find((messagePart) => messagePart.type === "structured-output");
1166
+ if (!part) return;
1167
+ this.events.onStructuredOutputChange?.({
1168
+ phase,
1169
+ messageId,
1170
+ status: part.status,
1171
+ raw: part.raw,
1172
+ ...part.partial !== void 0 ? { partial: part.partial } : {},
1173
+ ...part.data !== void 0 ? { data: part.data } : {},
1174
+ ...part.reasoning !== void 0 ? { reasoning: part.reasoning } : {},
1175
+ ...part.errorMessage !== void 0 ? { errorMessage: part.errorMessage } : {},
1176
+ ...delta !== void 0 ? { delta } : {}
1177
+ });
1178
+ }
1179
+ /**
1180
+ * Emit messages change event
1181
+ */
1182
+ emitMessagesChange() {
1183
+ this.events.onMessagesChange?.([...this.messages]);
1184
+ }
1185
+ /**
1186
+ * Finalize the stream — complete all pending operations.
1187
+ *
1188
+ * Called when the async iterable ends (stream closed). Acts as the final
1189
+ * safety net: completes any remaining tool calls, flushes un-emitted text,
1190
+ * and fires onStreamEnd.
1191
+ *
1192
+ * @see docs/chat-architecture.md#single-shot-text-response — Finalization step
1193
+ */
1194
+ finalizeStream() {
1195
+ let lastAssistantMessage;
1196
+ for (const messageId of this.activeMessageIds) {
1197
+ const state = this.getMessageState(messageId);
1198
+ if (!state) continue;
1199
+ this.completeAllToolCallsForMessage(messageId);
1200
+ if (state.currentSegmentText !== state.lastEmittedText) this.emitTextUpdateForMessage(messageId);
1201
+ state.isComplete = true;
1202
+ const msg = this.messages.find((m) => m.id === messageId);
1203
+ if (msg && msg.role === "assistant") lastAssistantMessage = msg;
1204
+ }
1205
+ for (const messageId of this.structuredMessageIds) {
1206
+ this.flushStructuredOutputUpdate(messageId);
1207
+ this.messages = errorStructuredOutputPart(this.messages, messageId, "Stream ended without structured-output.complete");
1208
+ this.emitStructuredOutputChange(messageId, "error");
1209
+ }
1210
+ this.structuredMessageIds.clear();
1211
+ this.structuredOutputUpdateBatches.clear();
1212
+ this.activeMessageIds.clear();
1213
+ if (lastAssistantMessage && !this.hasError) {
1214
+ if (this.isWhitespaceOnlyMessage(lastAssistantMessage)) {
1215
+ this.messages = this.messages.filter((m) => m.id !== lastAssistantMessage.id);
1216
+ this.emitMessagesChange();
1217
+ return;
1218
+ }
1219
+ }
1220
+ if (lastAssistantMessage) this.events.onStreamEnd?.(lastAssistantMessage);
1221
+ }
1222
+ /**
1223
+ * Get completed tool calls in API format (aggregated across all messages)
1224
+ */
1225
+ getCompletedToolCalls() {
1226
+ const result = [];
1227
+ for (const state of this.messageStates.values()) for (const tc of state.toolCalls.values()) if (tc.state === "input-complete") result.push({
1228
+ id: tc.id,
1229
+ type: "function",
1230
+ function: {
1231
+ name: tc.name,
1232
+ arguments: tc.arguments
1233
+ },
1234
+ ...tc.metadata !== void 0 && { metadata: tc.metadata }
1235
+ });
1236
+ return result;
1237
+ }
1238
+ /**
1239
+ * Get current result (aggregated across all messages)
1240
+ */
1241
+ getResult() {
1242
+ const toolCalls = this.getCompletedToolCalls();
1243
+ let content = "";
1244
+ let thinking = "";
1245
+ for (const state of this.messageStates.values()) {
1246
+ content += state.totalTextContent;
1247
+ for (const stepId of state.thinkingStepOrder) thinking += state.thinkingSteps.get(stepId) ?? "";
1248
+ }
1249
+ return {
1250
+ content,
1251
+ thinking: thinking || void 0,
1252
+ toolCalls: toolCalls.length > 0 ? toolCalls : void 0,
1253
+ finishReason: this.finishReason
1254
+ };
1255
+ }
1256
+ /**
1257
+ * Get current processor state (aggregated across all messages)
1258
+ */
1259
+ getState() {
1260
+ let content = "";
1261
+ let thinking = "";
1262
+ const toolCalls = /* @__PURE__ */ new Map();
1263
+ const toolCallOrder = [];
1264
+ for (const state of this.messageStates.values()) {
1265
+ content += state.totalTextContent;
1266
+ for (const stepId of state.thinkingStepOrder) thinking += state.thinkingSteps.get(stepId) ?? "";
1267
+ for (const [id, tc] of state.toolCalls) toolCalls.set(id, tc);
1268
+ toolCallOrder.push(...state.toolCallOrder);
1269
+ }
1270
+ return {
1271
+ content,
1272
+ thinking,
1273
+ toolCalls,
1274
+ toolCallOrder,
1275
+ finishReason: this.finishReason,
1276
+ done: this.isDone
1277
+ };
1278
+ }
1279
+ /**
1280
+ * Start recording chunks
1281
+ */
1282
+ startRecording() {
1283
+ this.recordingEnabled = true;
1284
+ this.recordingStartTime = Date.now();
1285
+ this.recording = {
1286
+ version: "1.0",
1287
+ timestamp: this.recordingStartTime,
1288
+ chunks: []
1289
+ };
1290
+ }
1291
+ /**
1292
+ * Get the current recording
1293
+ */
1294
+ getRecording() {
1295
+ return this.recording;
1296
+ }
1297
+ /**
1298
+ * Reset stream state (but keep messages)
1299
+ */
1300
+ resetStreamState() {
1301
+ this.messageStates.clear();
1302
+ this.activeMessageIds.clear();
1303
+ this.activeRuns.clear();
1304
+ this.toolCallToMessage.clear();
1305
+ this.structuredMessageIds.clear();
1306
+ this.structuredOutputUpdateBatches.clear();
1307
+ this.pendingManualMessageId = null;
1308
+ this.pendingThinkingStepId = null;
1309
+ this.finishReason = null;
1310
+ this.hasError = false;
1311
+ this.isDone = false;
1312
+ this.chunkStrategy.reset?.();
1313
+ }
1314
+ /**
1315
+ * Full reset (including messages)
1316
+ */
1317
+ reset() {
1318
+ this.resetStreamState();
1319
+ this.messages = [];
1320
+ }
1321
+ /**
1322
+ * Check if a message contains only whitespace text and no other meaningful parts
1323
+ * (no tool calls, tool results, thinking, etc.)
1324
+ */
1325
+ isWhitespaceOnlyMessage(message) {
1326
+ if (message.parts.length === 0) return false;
1327
+ return message.parts.every((part) => part.type === "text" && part.content.trim() === "");
1328
+ }
1329
+ /**
1330
+ * Replay a recording through the processor
1331
+ */
1332
+ static async replay(recording, options) {
1333
+ return new StreamProcessor(options).process(createReplayStream(recording));
1334
+ }
1335
+ };
1336
+ /**
1337
+ * Create an async iterable from a recording
1338
+ */
1534
1339
  function createReplayStream(recording) {
1535
- return {
1536
- // eslint-disable-next-line @typescript-eslint/require-await -- async generator required by AsyncIterable contract; body has no await
1537
- async *[Symbol.asyncIterator]() {
1538
- for (const { chunk } of recording.chunks) {
1539
- yield chunk;
1540
- }
1541
- }
1542
- };
1340
+ return { async *[Symbol.asyncIterator]() {
1341
+ for (const { chunk } of recording.chunks) yield chunk;
1342
+ } };
1543
1343
  }
1544
- export {
1545
- StreamProcessor,
1546
- createReplayStream
1547
- };
1548
- //# sourceMappingURL=processor.js.map
1344
+ //#endregion
1345
+ export { StreamProcessor, createReplayStream };
1346
+
1347
+ //# sourceMappingURL=processor.js.map