@runtypelabs/persona 4.24.0 → 4.26.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 (237) hide show
  1. package/dist/animations/glyph-cycle.d.cts +1 -1
  2. package/dist/animations/glyph-cycle.d.ts +1 -1
  3. package/dist/animations/{types-Db61aI9J.d.cts → types-BwKTZhg9.d.cts} +14 -0
  4. package/dist/animations/{types-Db61aI9J.d.ts → types-BwKTZhg9.d.ts} +14 -0
  5. package/dist/animations/wipe.d.cts +1 -1
  6. package/dist/animations/wipe.d.ts +1 -1
  7. package/dist/animations-extra.d.cts +14 -0
  8. package/dist/animations-extra.d.ts +14 -0
  9. package/dist/approval-ui-QAKWNFNM.js +1 -0
  10. package/dist/approval-ui.cjs +2 -2
  11. package/dist/approval-ui.d.cts +243 -8
  12. package/dist/approval-ui.d.ts +243 -8
  13. package/dist/approval-ui.js +2 -2
  14. package/dist/{artifacts-ui-IGB5YKLO.js → artifacts-ui-NJGMM27I.js} +1 -1
  15. package/dist/artifacts-ui.cjs +4 -4
  16. package/dist/artifacts-ui.d.cts +243 -8
  17. package/dist/artifacts-ui.d.ts +243 -8
  18. package/dist/artifacts-ui.js +5 -5
  19. package/dist/chunk-2ANTRKTV.js +1 -0
  20. package/dist/chunk-4HPTPUC6.js +1 -0
  21. package/dist/chunk-6V3MNEKB.js +1 -0
  22. package/dist/chunk-HGUCGM3K.js +9 -0
  23. package/dist/chunk-IO5VVUKP.js +3 -0
  24. package/dist/chunk-J4J3Y2P2.js +1 -0
  25. package/dist/chunk-K6HKIP5K.js +1 -0
  26. package/dist/chunk-K7NA7G7J.js +1 -0
  27. package/dist/chunk-MXUTS3LB.js +1 -0
  28. package/dist/chunk-PDPVORUF.js +1 -0
  29. package/dist/chunk-QPPJUSZH.js +1 -0
  30. package/dist/chunk-QRKGQPU7.js +1 -0
  31. package/dist/chunk-S6PBWE75.js +1 -0
  32. package/dist/chunk-T7QM7YMF.js +1 -0
  33. package/dist/chunk-TR2H475E.js +1 -0
  34. package/dist/chunk-UPO4GUFC.js +1 -0
  35. package/dist/chunk-ZC66NAIR.js +1 -0
  36. package/dist/client-history-YPEG45Q4.js +1 -0
  37. package/dist/client-history.js +1 -0
  38. package/dist/client-stream-42FOI3EC.js +4 -0
  39. package/dist/client-stream.js +5 -0
  40. package/dist/codegen.cjs +1 -1
  41. package/dist/codegen.js +1 -1
  42. package/dist/context-mentions-E7HJYWO6.js +161 -0
  43. package/dist/{context-mentions-inline-I2RDEBAK.js → context-mentions-inline-QVESLW55.js} +1 -1
  44. package/dist/context-mentions.cjs +7 -7
  45. package/dist/context-mentions.d.cts +243 -8
  46. package/dist/context-mentions.d.ts +243 -8
  47. package/dist/context-mentions.js +8 -8
  48. package/dist/event-stream-view-FOIJHFQO.js +1 -0
  49. package/dist/event-stream-view.cjs +1 -1
  50. package/dist/event-stream-view.d.cts +246 -31
  51. package/dist/event-stream-view.d.ts +246 -31
  52. package/dist/event-stream-view.js +1 -1
  53. package/dist/forms-ui.d.cts +295 -667
  54. package/dist/forms-ui.d.ts +295 -667
  55. package/dist/history-shell-QDQ44PSE.js +3 -0
  56. package/dist/history-shell.js +3 -0
  57. package/dist/{history-view-BD6OXLNA.js → history-view-NCFBI5GR.js} +2 -2
  58. package/dist/history-view.cjs +2 -2
  59. package/dist/history-view.d.cts +35 -1
  60. package/dist/history-view.d.ts +35 -1
  61. package/dist/history-view.js +2 -2
  62. package/dist/index.cjs +80 -74
  63. package/dist/index.cjs.map +1 -1
  64. package/dist/index.d.cts +301 -738
  65. package/dist/index.d.ts +301 -738
  66. package/dist/index.global.js +3 -215
  67. package/dist/index.global.js.map +1 -1
  68. package/dist/index.js +81 -75
  69. package/dist/index.js.map +1 -1
  70. package/dist/install.global.js +1 -1
  71. package/dist/install.global.js.map +1 -1
  72. package/dist/launcher.global.js +3 -67
  73. package/dist/launcher.global.js.map +1 -1
  74. package/dist/markdown-parsers.js +22 -22
  75. package/dist/runtype-tts-entry-NFBVWR7N.js +1 -0
  76. package/dist/runtype-tts.js +1 -1
  77. package/dist/session-actions-ISSKSI66.js +1 -0
  78. package/dist/session-actions.js +1 -0
  79. package/dist/session-reconnect.d.cts +243 -8
  80. package/dist/session-reconnect.d.ts +243 -8
  81. package/dist/smart-dom-reader.cjs +13 -13
  82. package/dist/smart-dom-reader.d.cts +243 -8
  83. package/dist/smart-dom-reader.d.ts +243 -8
  84. package/dist/smart-dom-reader.js +11 -11
  85. package/dist/theme-editor-preview.cjs +116 -98
  86. package/dist/theme-editor-preview.d.cts +245 -8
  87. package/dist/theme-editor-preview.d.ts +245 -8
  88. package/dist/theme-editor-preview.js +49 -54
  89. package/dist/theme-editor.cjs +2 -2
  90. package/dist/theme-editor.d.cts +244 -23
  91. package/dist/theme-editor.d.ts +244 -23
  92. package/dist/theme-editor.js +2 -2
  93. package/dist/theme-reference.d.cts +14 -0
  94. package/dist/theme-reference.d.ts +14 -0
  95. package/dist/ui-extras-entry-WAYFKVNW.js +3 -0
  96. package/dist/ui-extras.js +72 -0
  97. package/dist/voice-runtime-UOQOFAFY.js +17 -0
  98. package/dist/voice-runtime.cjs +17 -1
  99. package/dist/voice-runtime.d.cts +11643 -192
  100. package/dist/voice-runtime.d.ts +11643 -192
  101. package/dist/voice-runtime.js +17 -1
  102. package/dist/voice-worklet-player.cjs +19 -4
  103. package/dist/voice-worklet-player.d.cts +9 -10
  104. package/dist/voice-worklet-player.d.ts +9 -10
  105. package/dist/voice-worklet-player.js +19 -4
  106. package/dist/webmcp-polyfill.js +1 -1
  107. package/dist/webmcp-runtime-V6ZWW3PI.js +6 -0
  108. package/dist/webmcp-runtime.cjs +2 -2
  109. package/dist/webmcp-runtime.d.cts +1 -33
  110. package/dist/webmcp-runtime.d.ts +1 -33
  111. package/dist/webmcp-runtime.js +2 -2
  112. package/dist/widget.css +1 -1
  113. package/package.json +11 -5
  114. package/src/approval-paused-tool-settle.test.ts +221 -0
  115. package/src/client-approve.test.ts +311 -0
  116. package/src/client-history-eager.ts +9 -0
  117. package/src/client-history-loader.ts +23 -0
  118. package/src/client-history.ts +579 -0
  119. package/src/client-resume.ts +177 -0
  120. package/src/client-stream-eager.ts +12 -0
  121. package/src/client-stream-loader.ts +25 -0
  122. package/src/client-stream.ts +1880 -0
  123. package/src/client.test.ts +31 -1
  124. package/src/client.ts +293 -2607
  125. package/src/components/approval-actions.test.ts +27 -0
  126. package/src/components/approval-actions.ts +27 -3
  127. package/src/components/message-bubble.test.ts +19 -0
  128. package/src/components/message-bubble.ts +2 -0
  129. package/src/components/registry.ts +6 -6
  130. package/src/components/tool-bubble.test.ts +9 -0
  131. package/src/components/tool-bubble.ts +1 -0
  132. package/src/history-shell-bundle.test.ts +54 -0
  133. package/src/history-shell-eager.ts +12 -0
  134. package/src/history-shell-loader.ts +27 -0
  135. package/src/history-shell.ts +2362 -0
  136. package/src/history-view-loader.ts +2 -0
  137. package/src/history-view.ts +3 -0
  138. package/src/index-core.ts +8 -0
  139. package/src/index-global.ts +90 -248
  140. package/src/index.ts +13 -0
  141. package/src/install.ts +6 -4
  142. package/src/internal/runtype-history-provider.test.ts +1 -0
  143. package/src/internal/runtype-history-provider.ts +28 -13
  144. package/src/plugins/registry.ts +8 -8
  145. package/src/session-actions-chunk.test.ts +55 -0
  146. package/src/session-actions-eager.ts +11 -0
  147. package/src/session-actions-loader.ts +37 -0
  148. package/src/session-actions.ts +909 -0
  149. package/src/session-history.test.ts +3 -1
  150. package/src/session.ts +875 -1530
  151. package/src/session.voice.test.ts +1241 -5
  152. package/src/styles/widget.css +58 -0
  153. package/src/theme-editor/state.ts +91 -91
  154. package/src/types.ts +269 -5
  155. package/src/ui-ask-user-sheet.ts +382 -0
  156. package/src/ui-extras-eager.ts +10 -0
  157. package/src/ui-extras-entry.ts +9 -0
  158. package/src/ui-extras-loader.ts +26 -0
  159. package/src/ui.artifact-rail-resize.test.ts +2 -1
  160. package/src/ui.ask-user-chunk.test.ts +93 -0
  161. package/src/ui.history-chunk.test.ts +2 -1
  162. package/src/ui.history-config-provider.test.ts +2 -1
  163. package/src/ui.history-confirm-fallback.test.ts +142 -0
  164. package/src/ui.history-controller.test.ts +2 -1
  165. package/src/ui.history-lifecycle.test.ts +2 -1
  166. package/src/ui.history-render-hooks.test.ts +2 -1
  167. package/src/ui.history-shell.test.ts +2 -1
  168. package/src/ui.rail-overlay.test.ts +2 -1
  169. package/src/ui.rail-resize.test.ts +2 -1
  170. package/src/ui.rail-trigger-pending.test.ts +2 -1
  171. package/src/ui.ts +399 -2770
  172. package/src/ui.voice-prewarm.test.ts +222 -0
  173. package/src/utils/attachment-manager.ts +67 -67
  174. package/src/utils/constants.ts +3 -0
  175. package/src/utils/context-mention-controller.ts +323 -323
  176. package/src/utils/context-mention-manager.ts +40 -40
  177. package/src/utils/context-mention-orchestrator.ts +28 -0
  178. package/src/utils/event-stream-buffer.ts +50 -50
  179. package/src/utils/event-stream-store.ts +43 -43
  180. package/src/utils/throughput-tracker.ts +34 -34
  181. package/src/utils/virtual-scroller.ts +67 -67
  182. package/src/utils/visitor-store.test.ts +50 -0
  183. package/src/utils/visitor-store.ts +23 -5
  184. package/src/voice/__fixtures__/voice-wire/README.md +1 -0
  185. package/src/voice/__fixtures__/voice-wire/client/cancel.json +3 -0
  186. package/src/voice/__fixtures__/voice-wire/client/context.json +4 -0
  187. package/src/voice/__fixtures__/voice-wire/client/delegation_result.json +6 -0
  188. package/src/voice/__fixtures__/voice-wire/client/delegation_update.json +6 -0
  189. package/src/voice/__fixtures__/voice-wire/client/ping.json +3 -0
  190. package/src/voice/__fixtures__/voice-wire/client/playback_progress.json +5 -0
  191. package/src/voice/__fixtures__/voice-wire/client/start.json +3 -0
  192. package/src/voice/__fixtures__/voice-wire/server/artifact.json +11 -0
  193. package/src/voice/__fixtures__/voice-wire/server/attached.json +4 -0
  194. package/src/voice/__fixtures__/voice-wire/server/audio_clear.json +4 -0
  195. package/src/voice/__fixtures__/voice-wire/server/audio_end.json +3 -0
  196. package/src/voice/__fixtures__/voice-wire/server/delegation_cancelled.json +5 -0
  197. package/src/voice/__fixtures__/voice-wire/server/delegation_completed.json +8 -0
  198. package/src/voice/__fixtures__/voice-wire/server/delegation_started.json +15 -0
  199. package/src/voice/__fixtures__/voice-wire/server/error.json +6 -0
  200. package/src/voice/__fixtures__/voice-wire/server/metrics.json +10 -0
  201. package/src/voice/__fixtures__/voice-wire/server/pong.json +3 -0
  202. package/src/voice/__fixtures__/voice-wire/server/session_config.json +16 -0
  203. package/src/voice/__fixtures__/voice-wire/server/transcript_final.json +7 -0
  204. package/src/voice/__fixtures__/voice-wire/server/transcript_interim.json +4 -0
  205. package/src/voice/__fixtures__/voice-wire/server/transcript_partial.json +6 -0
  206. package/src/voice/__fixtures__/voice-wire/server/transcript_update.json +10 -0
  207. package/src/voice/__fixtures__/voice-wire/server/warning.json +5 -0
  208. package/src/voice/audio-playback-manager.test.ts +57 -0
  209. package/src/voice/audio-playback-manager.ts +120 -98
  210. package/src/voice/browser-voice-provider.ts +37 -37
  211. package/src/voice/fallback-speech-engine.ts +22 -16
  212. package/src/voice/keyed-voice-transcript.ts +314 -0
  213. package/src/voice/mic-state-styles.ts +182 -0
  214. package/src/voice/read-aloud-controller.ts +46 -44
  215. package/src/voice/runtype-speech-engine.ts +35 -33
  216. package/src/voice/runtype-voice-provider.ts +856 -164
  217. package/src/voice/session-voice-wiring.ts +266 -0
  218. package/src/voice/voice-delegation.test.ts +71 -0
  219. package/src/voice/voice-delegation.ts +364 -0
  220. package/src/voice/voice-turn-tracker.ts +6 -6
  221. package/src/voice/voice.test.ts +1250 -7
  222. package/src/voice/worklet-playback-engine.test.ts +81 -0
  223. package/src/voice/worklet-playback-engine.ts +21 -3
  224. package/src/voice-runtime-loader.ts +4 -1
  225. package/src/voice-runtime.ts +7 -0
  226. package/src/webmcp-runtime-entry.ts +41 -39
  227. package/dist/approval-ui-3NMYNXBD.js +0 -1
  228. package/dist/chunk-5EIIHQLQ.js +0 -1
  229. package/dist/chunk-7CO4T5KB.js +0 -1
  230. package/dist/chunk-EFJBUH5F.js +0 -1
  231. package/dist/chunk-R2TMIMDI.js +0 -3
  232. package/dist/chunk-UTVLOTWB.js +0 -9
  233. package/dist/context-mentions-45MODWOY.js +0 -161
  234. package/dist/event-stream-view-GL3ADRY2.js +0 -1
  235. package/dist/runtype-tts-entry-UJAEF7NZ.js +0 -1
  236. package/dist/voice-runtime-EFR3R5ES.js +0 -1
  237. package/dist/webmcp-runtime-JI7T4QYF.js +0 -6
@@ -0,0 +1,1880 @@
1
+ /**
2
+ * SSE stream processor for `AgentWidgetClient`: turns a dispatch response
3
+ * body into widget events (messages, reasoning, tools, artifacts, approvals).
4
+ *
5
+ * Split out of `client.ts` so the IIFE/CDN bundle can ship it as the lazy
6
+ * `client-stream.js` sibling chunk (see `client-stream-loader.ts`). It is
7
+ * only needed once a reply starts streaming; the client starts loading it
8
+ * when a dispatch begins, in parallel with the network request.
9
+ */
10
+ import type { RuntypeExecutionStreamEvent } from "./generated/runtype-openapi-contract";
11
+ import type {
12
+ AgentWidgetConfig,
13
+ AgentWidgetMessage,
14
+ AgentWidgetStreamParser,
15
+ AgentWidgetStreamParserResult,
16
+ AgentWidgetSSEEventParser,
17
+ StopReasonKind,
18
+ ContentPart,
19
+ PersonaArtifactKind,
20
+ PersonaArtifactFileMeta
21
+ } from "./types";
22
+ import type { SSEEventCallback, SSEHandler } from "./client";
23
+ import { isWebMcpToolName } from "./webmcp-bridge";
24
+ import {
25
+ buildArtifactRefRawContent,
26
+ resolveArtifactDisplayMode
27
+ } from "./utils/artifact-display";
28
+ import { extractTextFromJson } from "./utils/formatting";
29
+
30
+ /**
31
+ * Derive a download filename for `agent_media` parts that are delivered
32
+ * without one. Maps a few well-known MIME types to friendly extensions and
33
+ * falls back to `attachment.<subtype>` (or just `attachment` for opaque
34
+ * types like `application/octet-stream`).
35
+ */
36
+ function filenameFromMediaType(mediaType: string): string {
37
+ // MIME types are case-insensitive (RFC 7231); compare against a lowercased
38
+ // copy so callers that pass mixed casing still hit the friendly extensions.
39
+ const lower = mediaType.toLowerCase();
40
+ const knownExtensions: Record<string, string> = {
41
+ "application/pdf": "pdf",
42
+ "application/json": "json",
43
+ "application/zip": "zip",
44
+ "text/plain": "txt",
45
+ "text/csv": "csv",
46
+ "text/markdown": "md"
47
+ };
48
+ const ext = knownExtensions[lower];
49
+ if (ext) return `attachment.${ext}`;
50
+ const slash = lower.indexOf("/");
51
+ if (slash > 0) {
52
+ const subtype = lower.slice(slash + 1).split(";")[0]?.trim() ?? "";
53
+ if (subtype && subtype !== "octet-stream" && /^[a-z0-9.+-]+$/i.test(subtype)) {
54
+ return `attachment.${subtype}`;
55
+ }
56
+ }
57
+ return "attachment";
58
+ }
59
+
60
+ const looksStructured = (value: string) =>
61
+ value.startsWith("{") || value.startsWith("[") || value.startsWith("<");
62
+
63
+ /**
64
+ * Choose the best content source for sealed-segment reconciliation.
65
+ * Prefers the final structured payload from step_complete when the raw
66
+ * buffer is only a partial/unparseable prefix of the same structured format.
67
+ */
68
+ export function preferFinalStructuredContent(
69
+ rawBuffer: string | undefined,
70
+ finalString: string
71
+ ): string {
72
+ if (!rawBuffer) return finalString;
73
+
74
+ const rawTrimmed = rawBuffer.trim();
75
+ const finalTrimmed = finalString.trim();
76
+ if (rawTrimmed.length === 0) return finalString;
77
+ if (finalTrimmed.length === 0) return rawBuffer;
78
+
79
+ const rawLooksStructured = looksStructured(rawTrimmed);
80
+ const finalLooksStructured = looksStructured(finalTrimmed);
81
+
82
+ if (!finalLooksStructured) return rawBuffer;
83
+ if (!rawLooksStructured) return finalString;
84
+ if (finalTrimmed === rawTrimmed) return finalString;
85
+ if (finalTrimmed.startsWith(rawTrimmed)) return finalString;
86
+
87
+ const rawJsonText = extractTextFromJson(rawBuffer);
88
+ const finalJsonText = extractTextFromJson(finalString);
89
+ if (finalJsonText !== null && rawJsonText === null) return finalString;
90
+
91
+ return rawBuffer;
92
+ }
93
+
94
+ // Approval decisions and LOCAL-tool resumes ride the same lazy chunk: they
95
+ // only run after a stream paused, by which time this chunk is loaded.
96
+ export { resolveApproval, resumeFlow } from "./client-resume";
97
+
98
+ export type StreamContext = {
99
+ /** Read live: `updateConfig()` may swap the config mid-stream. */
100
+ config: () => AgentWidgetConfig;
101
+ createStreamParser: () => AgentWidgetStreamParser;
102
+ parseSSEEvent?: AgentWidgetSSEEventParser;
103
+ onSSEEvent?: SSEEventCallback;
104
+ };
105
+
106
+ /**
107
+ * Handle custom SSE event parsing via parseSSEEvent callback
108
+ * Returns true if event was handled, false otherwise
109
+ */
110
+ async function handleCustomSSEEvent(
111
+ ctx: StreamContext,
112
+ payload: unknown,
113
+ onEvent: SSEHandler,
114
+ assistantMessageRef: { current: AgentWidgetMessage | null },
115
+ emitMessage: (msg: AgentWidgetMessage) => void,
116
+ nextSequence: () => number,
117
+ partIdState: { current: string | null }
118
+ ): Promise<boolean> {
119
+ if (!ctx.parseSSEEvent) return false;
120
+
121
+ try {
122
+ const result = await ctx.parseSSEEvent(payload);
123
+ if (result === null) return false; // Event should be ignored
124
+
125
+ const createNewAssistant = (partId?: string): AgentWidgetMessage => {
126
+ const msg: AgentWidgetMessage = {
127
+ id: `assistant-${Date.now()}-${Math.random().toString(16).slice(2)}`,
128
+ role: "assistant",
129
+ content: "",
130
+ createdAt: new Date().toISOString(),
131
+ streaming: true,
132
+ variant: "assistant",
133
+ sequence: nextSequence(),
134
+ ...(partId !== undefined && { partId })
135
+ };
136
+ assistantMessageRef.current = msg;
137
+ emitMessage(msg);
138
+ return msg;
139
+ };
140
+
141
+ const ensureAssistant = (partId?: string) => {
142
+ if (assistantMessageRef.current) return assistantMessageRef.current;
143
+ return createNewAssistant(partId);
144
+ };
145
+
146
+ if (result.text !== undefined) {
147
+ // partId-based message segmentation: when partId changes, seal current
148
+ // message and start a new one for chronological tool/text interleaving
149
+ if (result.partId !== undefined && partIdState.current !== null && result.partId !== partIdState.current) {
150
+ // Seal the current assistant message
151
+ if (assistantMessageRef.current) {
152
+ assistantMessageRef.current.streaming = false;
153
+ emitMessage(assistantMessageRef.current);
154
+ }
155
+ // Create a new assistant message for the new text segment
156
+ createNewAssistant(result.partId);
157
+ }
158
+
159
+ // Update partId tracking (only when partId is provided: backward compatible)
160
+ if (result.partId !== undefined) {
161
+ partIdState.current = result.partId;
162
+ }
163
+
164
+ const assistant = ensureAssistant(result.partId);
165
+ // Tag the message with partId if present and not already set
166
+ if (result.partId !== undefined && !assistant.partId) {
167
+ assistant.partId = result.partId;
168
+ }
169
+ assistant.content += result.text;
170
+ emitMessage(assistant);
171
+ }
172
+
173
+ if (result.done) {
174
+ if (assistantMessageRef.current) {
175
+ assistantMessageRef.current.streaming = false;
176
+ emitMessage(assistantMessageRef.current);
177
+ }
178
+ partIdState.current = null;
179
+ onEvent({ type: "status", status: "idle" });
180
+ }
181
+
182
+ if (result.error) {
183
+ partIdState.current = null;
184
+ onEvent({
185
+ type: "error",
186
+ error: new Error(result.error)
187
+ });
188
+ }
189
+
190
+ return true; // Event was handled
191
+ } catch (error) {
192
+ if (typeof console !== "undefined") {
193
+ // eslint-disable-next-line no-console
194
+ console.error("[AgentWidget] parseSSEEvent error:", error);
195
+ }
196
+ return false;
197
+ }
198
+ }
199
+
200
+ export async function streamResponse(
201
+ ctx: StreamContext,
202
+ body: ReadableStream<Uint8Array>,
203
+ onEvent: SSEHandler,
204
+ assistantMessageId?: string,
205
+ // Durable reconnect: seed the assistant accumulator with the text already
206
+ // shown before the drop, so replayed post-cursor deltas APPEND to it
207
+ // instead of a fresh stream clobbering it (the replay carries only
208
+ // `seq > after`, i.e. the new deltas, not the full text).
209
+ seedContent?: string
210
+ ) {
211
+ const reader = body.getReader();
212
+ const decoder = new TextDecoder();
213
+ let buffer = "";
214
+
215
+ const baseSequence = Date.now();
216
+ let sequenceCounter = 0;
217
+ const nextSequence = () => baseSequence + sequenceCounter++;
218
+
219
+ const cloneMessage = (msg: AgentWidgetMessage): AgentWidgetMessage => {
220
+ const reasoning = msg.reasoning
221
+ ? {
222
+ ...msg.reasoning,
223
+ chunks: [...msg.reasoning.chunks]
224
+ }
225
+ : undefined;
226
+ const toolCall = msg.toolCall
227
+ ? {
228
+ ...msg.toolCall,
229
+ chunks: msg.toolCall.chunks ? [...msg.toolCall.chunks] : undefined
230
+ }
231
+ : undefined;
232
+ const tools = msg.tools
233
+ ? msg.tools.map((tool) => ({
234
+ ...tool,
235
+ chunks: tool.chunks ? [...tool.chunks] : undefined
236
+ }))
237
+ : undefined;
238
+
239
+ return {
240
+ ...msg,
241
+ reasoning,
242
+ toolCall,
243
+ tools
244
+ };
245
+ };
246
+
247
+ const shouldEmitMessage = (msg: AgentWidgetMessage): boolean => {
248
+ if (msg.role !== "assistant" || msg.variant) return true;
249
+
250
+ const hasContentParts =
251
+ Array.isArray(msg.contentParts) && msg.contentParts.length > 0;
252
+ const hasRawContent =
253
+ typeof msg.rawContent === "string" && msg.rawContent.trim() !== "";
254
+ const hasVisibleText =
255
+ typeof msg.content === "string" && msg.content.trim() !== "";
256
+
257
+ // Do not surface assistant text bubbles that only contain whitespace.
258
+ // Some providers emit newline-only text parts around a leading tool call;
259
+ // rendering those as normal messages creates an empty bubble above the
260
+ // tool card. Keep media/component/stop-reason messages renderable.
261
+ return hasVisibleText || hasContentParts || hasRawContent || Boolean(msg.stopReason);
262
+ };
263
+
264
+ const emitMessage = (msg: AgentWidgetMessage) => {
265
+ if (!shouldEmitMessage(msg)) return;
266
+ onEvent({
267
+ type: "message",
268
+ message: cloneMessage(msg)
269
+ });
270
+ };
271
+
272
+ let assistantMessage: AgentWidgetMessage | null = null;
273
+ // Tracks the most recently touched assistant text message for the
274
+ // current agent turn so `turn_complete.stopReason` can attach
275
+ // to the final visible text segment even after `assistantMessage`
276
+ // has been finalized at a tool-call boundary within the turn.
277
+ let lastAssistantInTurn: AgentWidgetMessage | null = null;
278
+ // Reference to track assistant message for custom event handler
279
+ const assistantMessageRef = { current: null as AgentWidgetMessage | null };
280
+ // Segmentation state for the `parseSSEEvent` extensibility callback (the
281
+ // consumer's own `partId` field) — independent of the wire.
282
+ const customParsePartId = { current: null as string | null };
283
+ // Unified text-channel block id (from `text_start`/`text_delta` `id`). Drives
284
+ // bubble-id segmentation on the wire in place of the legacy `partId`:
285
+ // a new block id means a new bubble, sealed at `text_complete`/tool boundaries.
286
+ let currentTextBlockId: string | null = null;
287
+ // Raw text accumulated for the open text block, on both the flow and agent
288
+ // paths — lets a whitespace-only flow block resolve without a stray bubble,
289
+ // and gives the structured-content parser the whole block on either path.
290
+ let pendingTextRaw = "";
291
+ // Nested flow-as-tool attribution (PR #4602): a text/reasoning block whose
292
+ // `parentToolCallId` matches a `tool_start.toolCallId` belongs to a flow
293
+ // running as that tool. Keyed by the wire block id, these route the block's
294
+ // deltas into a message tagged `agentMetadata.parentToolId` (the parent tool's
295
+ // row) instead of the top-level assistant/reasoning channel.
296
+ const nestedBlockParent = new Map<string, string>();
297
+ const nestedBlockMessages = new Map<string, AgentWidgetMessage>();
298
+ const nestedBlockRaw = new Map<string, string>();
299
+ const reasoningMessages = new Map<string, AgentWidgetMessage>();
300
+ const toolMessages = new Map<string, AgentWidgetMessage>();
301
+ const baseAssistantId = assistantMessageId;
302
+ let assistantIdConsumed = false;
303
+
304
+ const ensureAssistantMessage = () => {
305
+ if (assistantMessage) return assistantMessage;
306
+ let id: string;
307
+ let initialContent = "";
308
+ const segment = currentTextBlockId;
309
+ if (!assistantIdConsumed && baseAssistantId) {
310
+ id = baseAssistantId;
311
+ assistantIdConsumed = true;
312
+ // First (and only) time we reuse the caller-supplied id: this is the
313
+ // bubble a durable reconnect resumes into, so continue its text.
314
+ initialContent = seedContent ?? "";
315
+ } else if (baseAssistantId && segment) {
316
+ id = `${baseAssistantId}_${segment}`;
317
+ } else {
318
+ id = `assistant-${Date.now()}-${Math.random().toString(16).slice(2)}`;
319
+ }
320
+ assistantMessage = {
321
+ id,
322
+ role: "assistant",
323
+ content: initialContent,
324
+ createdAt: new Date().toISOString(),
325
+ streaming: true,
326
+ sequence: nextSequence()
327
+ };
328
+ emitMessage(assistantMessage);
329
+ return assistantMessage;
330
+ };
331
+
332
+ const ensureReasoningMessage = (reasoningId: string) => {
333
+ const existing = reasoningMessages.get(reasoningId);
334
+ if (existing) {
335
+ return existing;
336
+ }
337
+
338
+ const message: AgentWidgetMessage = {
339
+ id: `reason-${reasoningId}`,
340
+ role: "assistant",
341
+ content: "",
342
+ createdAt: new Date().toISOString(),
343
+ streaming: true,
344
+ variant: "reasoning",
345
+ sequence: nextSequence(),
346
+ reasoning: {
347
+ id: reasoningId,
348
+ status: "streaming",
349
+ chunks: []
350
+ }
351
+ };
352
+
353
+ reasoningMessages.set(reasoningId, message);
354
+ emitMessage(message);
355
+ return message;
356
+ };
357
+
358
+ // Track tool call IDs for artifact emit tools so we can suppress their UI
359
+ const artifactToolCallIds = new Set<string>();
360
+ // Track artifact block messages (reference card or inline block) so we can
361
+ // update them on artifact_complete
362
+ const artifactCardMessages = new Map<string, AgentWidgetMessage>();
363
+ // Track artifact IDs that already have a reference card
364
+ const artifactIdsWithCards = new Set<string>();
365
+ // Accumulate artifact markdown content (and component props) for embedding
366
+ // in block props on complete. `props` accumulates across `artifact_update`
367
+ // events so an inline component block hydrates with its real props after a
368
+ // refresh (the session artifact registry, which also tracks them live, is
369
+ // not persisted).
370
+ const artifactContent = new Map<
371
+ string,
372
+ {
373
+ markdown: string;
374
+ title?: string;
375
+ file?: PersonaArtifactFileMeta;
376
+ props?: Record<string, unknown>;
377
+ }
378
+ >();
379
+ const isArtifactEmitToolName = (name: string | undefined): boolean => {
380
+ if (!name) return false;
381
+ const normalized = name.replace(/_+/g, "_").replace(/^_|_$/g, "");
382
+ return normalized === "emit_artifact_markdown" || normalized === "emit_artifact_component";
383
+ };
384
+
385
+ const ensureToolMessage = (toolId: string) => {
386
+ const existing = toolMessages.get(toolId);
387
+ if (existing) {
388
+ return existing;
389
+ }
390
+
391
+ const message: AgentWidgetMessage = {
392
+ id: `tool-${toolId}`,
393
+ role: "assistant",
394
+ content: "",
395
+ createdAt: new Date().toISOString(),
396
+ streaming: true,
397
+ variant: "tool",
398
+ sequence: nextSequence(),
399
+ toolCall: {
400
+ id: toolId,
401
+ status: "pending"
402
+ }
403
+ };
404
+
405
+ toolMessages.set(toolId, message);
406
+ emitMessage(message);
407
+ return message;
408
+ };
409
+
410
+ const resolveTimestamp = (value: unknown) => {
411
+ if (typeof value === "number" && Number.isFinite(value)) {
412
+ return value;
413
+ }
414
+ if (typeof value === "string") {
415
+ const parsed = Number(value);
416
+ if (!Number.isNaN(parsed) && Number.isFinite(parsed)) {
417
+ return parsed;
418
+ }
419
+ const dateParsed = Date.parse(value);
420
+ if (!Number.isNaN(dateParsed)) {
421
+ return dateParsed;
422
+ }
423
+ }
424
+ return Date.now();
425
+ };
426
+
427
+ const ensureStringContent = (value: unknown): string => {
428
+ if (typeof value === "string") {
429
+ return value;
430
+ }
431
+ if (value === null || value === undefined) {
432
+ return "";
433
+ }
434
+ // Convert objects/arrays to JSON string
435
+ try {
436
+ return JSON.stringify(value);
437
+ } catch {
438
+ return String(value);
439
+ }
440
+ };
441
+
442
+ // Maintain stateful stream parsers per message for incremental parsing
443
+ const streamParsers = new Map<string, AgentWidgetStreamParser>();
444
+ // Track accumulated raw content for structured formats (JSON, XML, etc.)
445
+ const rawContentBuffers = new Map<string, string>();
446
+ /** Reconcile a sealed text block with its authoritative structured response. */
447
+ const reconcileSealedAssistantWithFinalResponse = (
448
+ msg: AgentWidgetMessage,
449
+ finalContent: unknown
450
+ ) => {
451
+ const finalString = ensureStringContent(finalContent);
452
+ const rawBuffer = rawContentBuffers.get(msg.id);
453
+ const contentToProcess = preferFinalStructuredContent(rawBuffer, finalString);
454
+ msg.rawContent = contentToProcess;
455
+ const parser = streamParsers.get(msg.id);
456
+
457
+ const mergeIfBetter = (mergedDisplay: string) => {
458
+ const cur = msg.content ?? "";
459
+ if (mergedDisplay.trim() === "") return;
460
+ // Only replace when empty, or when the stream left a strict prefix of the
461
+ // authoritative final (truncation). Do not use length alone: multi-segment
462
+ // flows can have a short last bubble whose content is not a prefix of the
463
+ // full step response.
464
+ if (
465
+ cur.trim().length === 0 ||
466
+ mergedDisplay.startsWith(cur) ||
467
+ mergedDisplay.trimStart().startsWith(cur.trim())
468
+ ) {
469
+ msg.content = mergedDisplay;
470
+ }
471
+ };
472
+
473
+ const finalizeCleanup = () => {
474
+ if (parser) {
475
+ const closeResult = parser.close?.();
476
+ if (closeResult instanceof Promise) closeResult.catch(() => {});
477
+ }
478
+ streamParsers.delete(msg.id);
479
+ rawContentBuffers.delete(msg.id);
480
+ msg.streaming = false;
481
+ emitMessage(msg);
482
+ };
483
+
484
+ if (!parser) {
485
+ mergeIfBetter(finalString);
486
+ finalizeCleanup();
487
+ return;
488
+ }
489
+
490
+ // Prefer JSON fast path when the final payload is JSON-shaped
491
+ const extractedFromJson = extractTextFromJson(contentToProcess);
492
+ if (extractedFromJson !== null && extractedFromJson.trim() !== "") {
493
+ mergeIfBetter(extractedFromJson);
494
+ finalizeCleanup();
495
+ return;
496
+ }
497
+
498
+ const bestDisplayText = (
499
+ result: AgentWidgetStreamParserResult | string | null
500
+ ): string => {
501
+ const text =
502
+ typeof result === "string" ? result : result?.text ?? null;
503
+ if (text !== null && text.trim() !== "") return text;
504
+ const extracted = parser.getExtractedText();
505
+ if (extracted !== null && extracted.trim() !== "") return extracted;
506
+ return finalString;
507
+ };
508
+
509
+ let parsedResult: ReturnType<typeof parser.processChunk>;
510
+ try {
511
+ parsedResult = parser.processChunk(contentToProcess);
512
+ } catch {
513
+ mergeIfBetter(finalString);
514
+ finalizeCleanup();
515
+ return;
516
+ }
517
+
518
+ if (parsedResult instanceof Promise) {
519
+ parsedResult
520
+ .then((result) => {
521
+ mergeIfBetter(bestDisplayText(result));
522
+ finalizeCleanup();
523
+ })
524
+ .catch(() => {
525
+ mergeIfBetter(finalString);
526
+ finalizeCleanup();
527
+ });
528
+ return;
529
+ }
530
+
531
+ mergeIfBetter(bestDisplayText(parsedResult));
532
+ finalizeCleanup();
533
+ };
534
+
535
+ // === Unified text channel ===
536
+ // Prompt-step and agent text both stream as `text_delta` blocks (segmented by
537
+ // `text_start`/`text_complete`) and can be structured JSON, so each block runs
538
+ // through the per-bubble structured-content parser. This is the legacy
539
+ // step_delta parser core, re-keyed from `partId` to the wire block-id bubble.
540
+ // The caller materializes the bubble lazily (whitespace-only blocks around
541
+ // tool boundaries never leave a stray bubble) and `step_complete.result.response`
542
+ // reconciles the authoritative final.
543
+ let lastSealedFlowBubble: AgentWidgetMessage | null = null;
544
+
545
+ // Stream one accumulated chunk of block text through the parser, setting
546
+ // display `content` (extracted) + `rawContent` (raw) and emitting. Mirrors the
547
+ // legacy step_delta chunk path; plain text bypasses the structured parser.
548
+ const applyTextChunk = (
549
+ assistant: AgentWidgetMessage,
550
+ accumulatedRaw: string,
551
+ chunk: string,
552
+ chunkSeq: number | undefined
553
+ ) => {
554
+ assistant.rawContent = accumulatedRaw;
555
+ if (!streamParsers.has(assistant.id)) {
556
+ streamParsers.set(assistant.id, ctx.createStreamParser());
557
+ }
558
+ const parser = streamParsers.get(assistant.id)!;
559
+ const looksLikeJson =
560
+ accumulatedRaw.trim().startsWith("{") || accumulatedRaw.trim().startsWith("[");
561
+ if (looksLikeJson) {
562
+ rawContentBuffers.set(assistant.id, accumulatedRaw);
563
+ }
564
+ const isPlainTextParser = (parser as any).__isPlainTextParser === true;
565
+ if (isPlainTextParser) {
566
+ assistant.content =
567
+ chunkSeq !== undefined ? accumulatedRaw : assistant.content + chunk;
568
+ rawContentBuffers.delete(assistant.id);
569
+ streamParsers.delete(assistant.id);
570
+ assistant.rawContent = undefined;
571
+ emitMessage(assistant);
572
+ return;
573
+ }
574
+ const parsedResult = parser.processChunk(accumulatedRaw);
575
+ if (parsedResult instanceof Promise) {
576
+ parsedResult
577
+ .then((result) => {
578
+ const text = typeof result === "string" ? result : result?.text ?? null;
579
+ if (text !== null && text.trim() !== "") {
580
+ assistant.content = text;
581
+ emitMessage(assistant);
582
+ } else if (!looksLikeJson && !accumulatedRaw.trim().startsWith("<")) {
583
+ assistant.content =
584
+ chunkSeq !== undefined ? accumulatedRaw : assistant.content + chunk;
585
+ rawContentBuffers.delete(assistant.id);
586
+ streamParsers.delete(assistant.id);
587
+ assistant.rawContent = undefined;
588
+ emitMessage(assistant);
589
+ }
590
+ })
591
+ .catch(() => {
592
+ assistant.content =
593
+ chunkSeq !== undefined ? accumulatedRaw : assistant.content + chunk;
594
+ rawContentBuffers.delete(assistant.id);
595
+ streamParsers.delete(assistant.id);
596
+ assistant.rawContent = undefined;
597
+ emitMessage(assistant);
598
+ });
599
+ } else {
600
+ const text =
601
+ typeof parsedResult === "string" ? parsedResult : parsedResult?.text ?? null;
602
+ if (text !== null && text.trim() !== "") {
603
+ assistant.content = text;
604
+ emitMessage(assistant);
605
+ } else if (!looksLikeJson && !accumulatedRaw.trim().startsWith("<")) {
606
+ assistant.content =
607
+ chunkSeq !== undefined ? accumulatedRaw : assistant.content + chunk;
608
+ rawContentBuffers.delete(assistant.id);
609
+ streamParsers.delete(assistant.id);
610
+ assistant.rawContent = undefined;
611
+ emitMessage(assistant);
612
+ }
613
+ }
614
+ };
615
+
616
+ // Seal a flow text block at `text_complete`: run final structured extraction
617
+ // off the accumulated raw buffer (U2: `text_complete.text` mirrors that raw
618
+ // buffer, so we never double-count), then finalize the bubble. The structured
619
+ // `step_complete.result.response` reconciles afterward.
620
+ const finalizeFlowTextBlock = (
621
+ assistant: AgentWidgetMessage,
622
+ finalContent?: unknown
623
+ ) => {
624
+ const effectiveFinal =
625
+ finalContent !== undefined && finalContent !== null
626
+ ? finalContent
627
+ : assistant.content;
628
+ if (
629
+ effectiveFinal === undefined ||
630
+ effectiveFinal === null ||
631
+ effectiveFinal === ""
632
+ ) {
633
+ assistant.streaming = false;
634
+ emitMessage(assistant);
635
+ return;
636
+ }
637
+ const rawBuffer = rawContentBuffers.get(assistant.id);
638
+ const contentToProcess = rawBuffer ?? ensureStringContent(effectiveFinal);
639
+ assistant.rawContent = contentToProcess;
640
+ const parser = streamParsers.get(assistant.id);
641
+ let extractedText: string | null = null;
642
+ let asyncPending = false;
643
+ if (parser) {
644
+ extractedText = parser.getExtractedText();
645
+ if (extractedText === null) {
646
+ extractedText = extractTextFromJson(contentToProcess);
647
+ }
648
+ if (extractedText === null) {
649
+ const parsedResult = parser.processChunk(contentToProcess);
650
+ if (parsedResult instanceof Promise) {
651
+ asyncPending = true;
652
+ parsedResult
653
+ .then((result) => {
654
+ const text =
655
+ typeof result === "string" ? result : result?.text ?? null;
656
+ if (text !== null) {
657
+ assistant.content = text;
658
+ assistant.streaming = false;
659
+ streamParsers.delete(assistant.id);
660
+ rawContentBuffers.delete(assistant.id);
661
+ emitMessage(assistant);
662
+ }
663
+ })
664
+ .catch(() => {});
665
+ } else {
666
+ extractedText =
667
+ typeof parsedResult === "string"
668
+ ? parsedResult
669
+ : parsedResult?.text ?? null;
670
+ }
671
+ }
672
+ }
673
+ if (!asyncPending) {
674
+ if (extractedText !== null && extractedText.trim() !== "") {
675
+ assistant.content = extractedText;
676
+ } else if (!rawContentBuffers.has(assistant.id)) {
677
+ assistant.content = ensureStringContent(effectiveFinal);
678
+ }
679
+ const parserToClose = streamParsers.get(assistant.id);
680
+ if (parserToClose) {
681
+ const closeResult = parserToClose.close?.();
682
+ if (closeResult instanceof Promise) closeResult.catch(() => {});
683
+ streamParsers.delete(assistant.id);
684
+ }
685
+ rawContentBuffers.delete(assistant.id);
686
+ assistant.streaming = false;
687
+ emitMessage(assistant);
688
+ }
689
+ };
690
+
691
+ // Materialize (lazily) the message for a nested flow-as-tool block, tagged
692
+ // with the parent tool-call id so the UI renders it in the parent tool's row.
693
+ const ensureNestedBlockMessage = (
694
+ blockId: string,
695
+ parentToolCallId: string,
696
+ variant?: "reasoning"
697
+ ): AgentWidgetMessage => {
698
+ const existing = nestedBlockMessages.get(blockId);
699
+ if (existing) return existing;
700
+ const message: AgentWidgetMessage = {
701
+ id: `nested-${parentToolCallId}-${blockId}`,
702
+ role: "assistant",
703
+ content: "",
704
+ createdAt: new Date().toISOString(),
705
+ streaming: true,
706
+ sequence: nextSequence(),
707
+ ...(variant ? { variant } : {}),
708
+ ...(variant === "reasoning"
709
+ ? { reasoning: { id: blockId, status: "streaming", chunks: [] } }
710
+ : {}),
711
+ agentMetadata: { parentToolId: parentToolCallId },
712
+ };
713
+ nestedBlockMessages.set(blockId, message);
714
+ emitMessage(message);
715
+ return message;
716
+ };
717
+
718
+ // Ready queue of parsed wire frames awaiting a drain. The API streams the
719
+ // unified wire vocabulary; each frame is parsed in the SSE loop
720
+ // below and rendered directly by the handler (no translation bridge), then
721
+ // pushed here. The wire stream is a single, in-order SSE connection, so
722
+ // frames drain straight through with no reordering.
723
+ const eventQueue: RuntypeExecutionStreamEvent[] = [];
724
+ // Declared here so later closures can reference it; assigned after all
725
+ // handler-scoped variables are initialised (before the SSE loop).
726
+ let drainReadyQueue: () => void;
727
+ // Per-stream media-block buffer: the media triad
728
+ // (media_start/media_delta/media_complete) is reassembled here into a single
729
+ // synthetic message at media_complete, keyed by the block id.
730
+ const mediaBuffers = new Map<
731
+ string,
732
+ { mediaType?: string; toolCallId?: string; parts: string[] }
733
+ >();
734
+ // Tracks the last iteration surfaced as a per-iteration message boundary, so
735
+ // `turn_start` advancing the iteration rotates the bubble in 'separate' mode.
736
+ let lastIterationSeen = 0;
737
+ // Execution kind, resolved from the leading `execution_start` frame. Drives
738
+ // the agent-vs-flow branches that the single wire vocabulary collapses.
739
+ let executionKind: "agent" | "flow" = "agent";
740
+ // Whether `executionKind` was set authoritatively by an `execution_start`
741
+ // frame. Continuation streams (e.g. a tool-driven `/resume`) do NOT re-emit
742
+ // `execution_start`, so a fresh `streamResponse` for the continuation starts
743
+ // with the default `"agent"`. For a flow that mis-routes the final
744
+ // prompt-step finalization and duplicates the last message (the streamed
745
+ // text block is sealed, then `step_complete.result.response` re-renders it as
746
+ // a second bubble). When `execution_start` is absent we recover the flow kind
747
+ // from the first flow `step_*` frame below.
748
+ let executionKindResolved = false;
749
+ // Open turn id (from `turn_start`). Unified text/reasoning deltas carry their
750
+ // own block id, not the turn id, so the turn id is threaded onto agentMetadata
751
+ // from here.
752
+ let openTurnId: string | null = null;
753
+ // Agent execution state tracking
754
+ let agentExecution: { executionId: string; agentId: string; agentName: string } | null = null;
755
+ const iterationDisplay = ctx.config().iterationDisplay ?? 'separate';
756
+
757
+ // Drains the queued native events through the main event handler.
758
+ // Also invoked after the SSE loop exits so any events queued at
759
+ // end-of-stream are processed.
760
+ drainReadyQueue = () => {
761
+ for (let i = 0; i < eventQueue.length; i++) {
762
+ const payload = eventQueue[i];
763
+
764
+ // Recover the execution kind on continuation streams that omit
765
+ // `execution_start` (e.g. a tool-driven `/resume`). Flow `step_*` frames
766
+ // carry a `stepType`; agent loops never do (they use `turn_*`). Without
767
+ // this, the continuation defaults to `"agent"` and a flow's final
768
+ // prompt-step finalization is duplicated. We only infer when no
769
+ // `execution_start` resolved the kind, so an explicit `agent` is never
770
+ // overridden.
771
+ if (
772
+ !executionKindResolved &&
773
+ executionKind !== "flow" &&
774
+ typeof (payload as { stepType?: unknown }).stepType === "string"
775
+ ) {
776
+ executionKind = "flow";
777
+ }
778
+
779
+ if (payload.type === "reasoning_start") {
780
+ // Nested flow-as-tool thinking (PR #4602): route to the parent tool's row.
781
+ const rStartBlockId = typeof payload.id === "string" ? payload.id : null;
782
+ const rStartParent =
783
+ typeof payload.parentToolCallId === "string" && payload.parentToolCallId
784
+ ? payload.parentToolCallId
785
+ : null;
786
+ if (rStartBlockId && rStartParent) {
787
+ nestedBlockParent.set(rStartBlockId, rStartParent);
788
+ ensureNestedBlockMessage(rStartBlockId, rStartParent, "reasoning");
789
+ continue;
790
+ }
791
+ const reasoningId = payload.id;
792
+ const reasoningMessage = ensureReasoningMessage(reasoningId);
793
+ reasoningMessage.reasoning = reasoningMessage.reasoning ?? {
794
+ id: reasoningId,
795
+ status: "streaming",
796
+ chunks: []
797
+ };
798
+ reasoningMessage.reasoning.startedAt =
799
+ reasoningMessage.reasoning.startedAt ??
800
+ Date.now();
801
+ reasoningMessage.reasoning.completedAt = undefined;
802
+ reasoningMessage.reasoning.durationMs = undefined;
803
+ if (payload.scope === "loop" || payload.scope === "turn") {
804
+ reasoningMessage.reasoning.scope = payload.scope;
805
+ }
806
+ reasoningMessage.streaming = true;
807
+ reasoningMessage.reasoning.status = "streaming";
808
+ emitMessage(reasoningMessage);
809
+ } else if (payload.type === "reasoning_delta") {
810
+ // Nested flow-as-tool thinking: append to the parent-tool-row message.
811
+ const rDeltaBlockId = typeof payload.id === "string" ? payload.id : null;
812
+ if (
813
+ rDeltaBlockId &&
814
+ nestedBlockParent.has(rDeltaBlockId) &&
815
+ nestedBlockMessages.has(rDeltaBlockId)
816
+ ) {
817
+ const nested = nestedBlockMessages.get(rDeltaBlockId)!;
818
+ const nestedChunk = payload.delta;
819
+ if (nestedChunk && nested.reasoning) {
820
+ nested.reasoning.chunks.push(String(nestedChunk));
821
+ emitMessage(nested);
822
+ }
823
+ continue;
824
+ }
825
+ const reasoningId = payload.id;
826
+ const reasoningMessage = ensureReasoningMessage(reasoningId);
827
+ reasoningMessage.reasoning = reasoningMessage.reasoning ?? {
828
+ id: reasoningId,
829
+ status: "streaming",
830
+ chunks: []
831
+ };
832
+ reasoningMessage.reasoning.startedAt =
833
+ reasoningMessage.reasoning.startedAt ??
834
+ Date.now();
835
+ if (payload.delta) reasoningMessage.reasoning.chunks.push(payload.delta);
836
+ reasoningMessage.reasoning.status = "streaming";
837
+ reasoningMessage.streaming = true;
838
+ emitMessage(reasoningMessage);
839
+ } else if (payload.type === "reasoning_complete") {
840
+ // Nested flow-as-tool thinking close: seal the parent-tool-row message.
841
+ const rCompleteBlockId = typeof payload.id === "string" ? payload.id : null;
842
+ if (
843
+ rCompleteBlockId &&
844
+ nestedBlockParent.has(rCompleteBlockId) &&
845
+ nestedBlockMessages.has(rCompleteBlockId)
846
+ ) {
847
+ const nested = nestedBlockMessages.get(rCompleteBlockId)!;
848
+ if (nested.reasoning) {
849
+ const nestedReflection =
850
+ typeof payload.text === "string" ? payload.text : "";
851
+ if (nestedReflection && nested.reasoning.chunks.length === 0) {
852
+ nested.reasoning.chunks.push(nestedReflection);
853
+ }
854
+ nested.reasoning.status = "complete";
855
+ nested.streaming = false;
856
+ emitMessage(nested);
857
+ }
858
+ nestedBlockParent.delete(rCompleteBlockId);
859
+ nestedBlockMessages.delete(rCompleteBlockId);
860
+ continue;
861
+ }
862
+ const reasoningId = payload.id;
863
+ // A close carrying text (or scope:"loop") is a cross-iteration
864
+ // reflection fold (merged spec §4 E3): the API streams nothing for the
865
+ // block, then delivers the whole reflection as `text` on the close.
866
+ // Materialize a reasoning bubble even if no reasoning_start/delta opened
867
+ // one, and adopt the close text when the block streamed no chunks (the
868
+ // common reflection case, where reasoning_start opened an empty bubble).
869
+ const reflectionText = typeof payload.text === "string" ? payload.text : "";
870
+ if (!reasoningMessages.get(reasoningId) && (reflectionText || payload.scope === "loop")) {
871
+ ensureReasoningMessage(reasoningId);
872
+ }
873
+ const reasoningMessage = reasoningMessages.get(reasoningId);
874
+ if (reasoningMessage?.reasoning) {
875
+ if (payload.scope === "loop" || payload.scope === "turn") {
876
+ reasoningMessage.reasoning.scope = payload.scope;
877
+ }
878
+ if (reflectionText && reasoningMessage.reasoning.chunks.length === 0) {
879
+ reasoningMessage.reasoning.chunks.push(reflectionText);
880
+ }
881
+ reasoningMessage.reasoning.status = "complete";
882
+ reasoningMessage.reasoning.completedAt = Date.now();
883
+ const start = reasoningMessage.reasoning.startedAt ?? Date.now();
884
+ reasoningMessage.reasoning.durationMs = Math.max(
885
+ 0,
886
+ (reasoningMessage.reasoning.completedAt ?? Date.now()) - start
887
+ );
888
+ reasoningMessage.streaming = false;
889
+
890
+ emitMessage(reasoningMessage);
891
+ }
892
+ } else if (payload.type === "tool_start") {
893
+ // Unified tool family (agent + flow). Seal any open assistant bubble so
894
+ // text→tool→text interleaves chronologically (the API also emits a
895
+ // text_complete here, so this is usually a no-op — kept for safety).
896
+ if (assistantMessage) {
897
+ (assistantMessage as AgentWidgetMessage).streaming = false;
898
+ emitMessage(assistantMessage as AgentWidgetMessage);
899
+ assistantMessage = null;
900
+ }
901
+ // Unified denormalizes `iteration` onto tool frames too (merged spec §2).
902
+ // Track it so media/reflection blocks — which carry no iteration of their
903
+ // own — can be stamped with the enclosing iteration even on tool-only
904
+ // turns that never emit a `turn_start`.
905
+ if (typeof payload.iteration === "number") lastIterationSeen = payload.iteration;
906
+ const toolId = payload.toolCallId;
907
+ const toolName = payload.toolName;
908
+ // Suppress tool UI for artifact emit tools: artifacts are handled via artifact_* events
909
+ if (isArtifactEmitToolName(toolName)) {
910
+ artifactToolCallIds.add(toolId);
911
+ continue;
912
+ }
913
+ const toolMessage = ensureToolMessage(toolId);
914
+ const tool = toolMessage.toolCall ?? {
915
+ id: toolId,
916
+ status: "pending"
917
+ };
918
+ tool.name = toolName ?? tool.name;
919
+ tool.status = "running";
920
+ tool.success = undefined;
921
+ tool.error = undefined;
922
+ tool.duration = undefined;
923
+ if (payload.parameters !== undefined) {
924
+ tool.args = payload.parameters;
925
+ }
926
+ tool.startedAt =
927
+ tool.startedAt ??
928
+ resolveTimestamp(payload.startedAt);
929
+ tool.completedAt = undefined;
930
+ tool.durationMs = undefined;
931
+ toolMessage.toolCall = tool;
932
+ toolMessage.streaming = true;
933
+ if (payload.executionId) {
934
+ toolMessage.agentMetadata = {
935
+ executionId: payload.executionId,
936
+ iteration: payload.iteration,
937
+ };
938
+ }
939
+ emitMessage(toolMessage);
940
+ } else if (payload.type === "tool_output_delta") {
941
+ const toolId = payload.toolCallId;
942
+ if (artifactToolCallIds.has(toolId)) continue;
943
+ const toolMessage = ensureToolMessage(toolId);
944
+ const tool = toolMessage.toolCall ?? {
945
+ id: toolId,
946
+ status: "running"
947
+ };
948
+ tool.startedAt =
949
+ tool.startedAt ??
950
+ Date.now();
951
+ const chunkText = payload.delta;
952
+ if (chunkText) {
953
+ tool.chunks = tool.chunks ?? [];
954
+ tool.chunks.push(String(chunkText));
955
+ }
956
+ tool.status = "running";
957
+ toolMessage.toolCall = tool;
958
+ toolMessage.streaming = true;
959
+ if (payload.executionId) {
960
+ toolMessage.agentMetadata = toolMessage.agentMetadata ?? {
961
+ executionId: payload.executionId,
962
+ iteration: lastIterationSeen,
963
+ };
964
+ }
965
+ emitMessage(toolMessage);
966
+ } else if (payload.type === "tool_complete") {
967
+ const toolId = payload.toolCallId;
968
+ if (artifactToolCallIds.has(toolId)) {
969
+ artifactToolCallIds.delete(toolId);
970
+ continue;
971
+ }
972
+ const toolMessage = ensureToolMessage(toolId);
973
+ const tool = toolMessage.toolCall ?? {
974
+ id: toolId,
975
+ status: "running"
976
+ };
977
+ tool.status = "complete";
978
+ if (payload.result !== undefined) {
979
+ tool.result = payload.result;
980
+ }
981
+ tool.name = payload.toolName ?? tool.name;
982
+ tool.success = payload.success;
983
+ tool.error = payload.error;
984
+ tool.completedAt = Date.now();
985
+ const durationValue = payload.executionTime;
986
+ if (typeof durationValue === "number") {
987
+ tool.durationMs = durationValue;
988
+ tool.duration = durationValue;
989
+ } else {
990
+ const start = tool.startedAt ?? Date.now();
991
+ tool.durationMs = Math.max(
992
+ 0,
993
+ (tool.completedAt ?? Date.now()) - start
994
+ );
995
+ }
996
+ toolMessage.toolCall = tool;
997
+ toolMessage.streaming = false;
998
+ if (payload.executionId) {
999
+ toolMessage.agentMetadata = toolMessage.agentMetadata ?? {
1000
+ executionId: payload.executionId,
1001
+ iteration: payload.iteration ?? lastIterationSeen,
1002
+ };
1003
+ }
1004
+ emitMessage(toolMessage);
1005
+ } else if (payload.type === "await" && payload.toolName) {
1006
+ // Unified LOCAL tool pause for either dispatch kind. The execution
1007
+ // waits for /resume with toolOutputs. Page tools with origin "webmcp"
1008
+ // may carry a bare name, normalized to the internal webmcp: prefix.
1009
+ //
1010
+ // Upsert a fully-populated tool-variant message so the existing
1011
+ // ask_user_question bubble + sheet paths fire. Mark the message with
1012
+ // `awaitingLocalTool: true` so the UI knows to resolve via
1013
+ // resumeFlow rather than the legacy sendMessage fallback.
1014
+ //
1015
+ // Key the message by the per-call `toolCallId` (provider `toolu_…`;
1016
+ // core#3878) when present. Two PARALLEL calls to the SAME tool in one
1017
+ // turn collapse to an identical `toolId` (`runtime_webmcp:<name>_<ms>`)
1018
+ // and `index: 0`: only `toolCallId` distinguishes them. Keying on it
1019
+ // (a) keeps the two awaits as DISTINCT messages with their own args
1020
+ // instead of the second clobbering the first, and (b) merges each
1021
+ // await into the matching `tool_start` bubble (also keyed by
1022
+ // `toolCallId`). Fall back to the collapsed `toolId` for legacy
1023
+ // servers that don't emit `toolCallId`.
1024
+ const toolCallId: string | undefined =
1025
+ typeof payload.toolCallId === "string" && payload.toolCallId.length > 0
1026
+ ? (payload.toolCallId as string)
1027
+ : undefined;
1028
+ const toolId =
1029
+ toolCallId ?? (payload.toolId as string) ?? `local-${nextSequence()}`;
1030
+ const toolMessage = ensureToolMessage(toolId);
1031
+ const rawToolName = payload.toolName as string;
1032
+ // Page tools may arrive with a bare name; synthesize the
1033
+ // `webmcp:` prefix so isWebMcpToolName (and the bridge's prefix-strip on
1034
+ // resume) treat them identically to a flow `await`.
1035
+ const toolName =
1036
+ payload.origin === "webmcp" &&
1037
+ !isWebMcpToolName(rawToolName)
1038
+ ? `webmcp:${rawToolName}`
1039
+ : rawToolName;
1040
+ const webMcpTool = isWebMcpToolName(toolName);
1041
+ const tool = toolMessage.toolCall ?? { id: toolId, status: "pending" as const };
1042
+ tool.name = toolName;
1043
+ tool.args = payload.parameters;
1044
+ // WebMCP tools are executed asynchronously by the browser AFTER this
1045
+ // `await` arrives. Keep them running until session.ts resolves
1046
+ // the page tool and records its actual elapsed time. Other local
1047
+ // tools (for example ask_user_question) keep the existing complete
1048
+ // state because they are waiting for a user interaction, not an
1049
+ // automatic page-tool execution.
1050
+ tool.status = webMcpTool ? "running" : "complete";
1051
+ tool.chunks = tool.chunks ?? [];
1052
+ tool.startedAt =
1053
+ tool.startedAt ??
1054
+ resolveTimestamp(payload.awaitedAt);
1055
+ if (webMcpTool) {
1056
+ tool.completedAt = undefined;
1057
+ tool.duration = undefined;
1058
+ tool.durationMs = undefined;
1059
+ } else {
1060
+ tool.completedAt = tool.completedAt ?? tool.startedAt;
1061
+ }
1062
+ toolMessage.toolCall = tool;
1063
+ toolMessage.streaming = false;
1064
+ toolMessage.agentMetadata = {
1065
+ ...toolMessage.agentMetadata,
1066
+ executionId: (payload.executionId as string) ?? toolMessage.agentMetadata?.executionId,
1067
+ awaitingLocalTool: true,
1068
+ // Only set when the server emitted a real per-call id; its presence
1069
+ // is what tells session.ts to batch + key `/resume` by id rather
1070
+ // than by tool name (which can't represent two same-tool calls).
1071
+ ...(toolCallId ? { webMcpToolCallId: toolCallId } : {}),
1072
+ };
1073
+ emitMessage(toolMessage);
1074
+ } else if (payload.type === "text_start") {
1075
+ // Nested flow-as-tool text (PR #4602): a `parentToolCallId` means this
1076
+ // block belongs to a flow running as that tool — record the mapping and
1077
+ // leave the top-level assistant bubble untouched (the nested deltas route
1078
+ // into the parent tool's row).
1079
+ const startBlockId = typeof payload.id === "string" ? payload.id : null;
1080
+ const startParent =
1081
+ typeof payload.parentToolCallId === "string" && payload.parentToolCallId
1082
+ ? payload.parentToolCallId
1083
+ : null;
1084
+ if (startBlockId && startParent) {
1085
+ nestedBlockParent.set(startBlockId, startParent);
1086
+ continue;
1087
+ }
1088
+ // Unified text-channel block open. A new block id means a new bubble, so
1089
+ // seal any open assistant bubble; the next text_delta creates a fresh one
1090
+ // (lazily). The API emits a fresh block at every tool/media/approval/await
1091
+ // boundary, so block-id keying drives segmentation — no partId.
1092
+ const prev = assistantMessage as AgentWidgetMessage | null;
1093
+ if (prev) {
1094
+ // Normally text_complete already sealed the prior block; this is the
1095
+ // defensive path if a producer opens a new block without closing.
1096
+ if (executionKind === "flow") {
1097
+ finalizeFlowTextBlock(prev);
1098
+ lastSealedFlowBubble = prev;
1099
+ } else {
1100
+ prev.streaming = false;
1101
+ emitMessage(prev);
1102
+ }
1103
+ assistantMessage = null;
1104
+ }
1105
+ currentTextBlockId =
1106
+ typeof payload.id === "string" ? payload.id : currentTextBlockId;
1107
+ pendingTextRaw = "";
1108
+ } else if (payload.type === "text_delta") {
1109
+ // Nested flow-as-tool text: route to the parent tool's row, through the
1110
+ // same structured-content parser, never the top-level assistant channel.
1111
+ const deltaBlockId = typeof payload.id === "string" ? payload.id : null;
1112
+ const nestedParent = deltaBlockId
1113
+ ? nestedBlockParent.get(deltaBlockId)
1114
+ : undefined;
1115
+ if (deltaBlockId && nestedParent) {
1116
+ const nestedDelta =
1117
+ typeof payload.delta === "string" ? payload.delta : "";
1118
+ const nestedRaw = (nestedBlockRaw.get(deltaBlockId) ?? "") + nestedDelta;
1119
+ nestedBlockRaw.set(deltaBlockId, nestedRaw);
1120
+ if (nestedRaw.trim() === "") continue;
1121
+ const nested = ensureNestedBlockMessage(deltaBlockId, nestedParent);
1122
+ nested.agentMetadata = {
1123
+ ...nested.agentMetadata,
1124
+ executionId: payload.executionId,
1125
+ parentToolId: nestedParent,
1126
+ };
1127
+ applyTextChunk(nested, nestedRaw, nestedDelta, undefined);
1128
+ continue;
1129
+ }
1130
+ currentTextBlockId =
1131
+ typeof payload.id === "string" ? payload.id : currentTextBlockId;
1132
+ if (executionKind === "flow") {
1133
+ // Flow prompt-step text can be structured JSON: accumulate the raw
1134
+ // block and run it through the structured-content parser, keyed by the
1135
+ // block-id bubble. Materialize lazily so a whitespace-only block
1136
+ // (newlines around a tool boundary) never leaves a stray bubble.
1137
+ const delta = typeof payload.delta === "string" ? payload.delta : "";
1138
+ pendingTextRaw += delta;
1139
+ if (pendingTextRaw.trim() === "") continue;
1140
+ const assistant = ensureAssistantMessage();
1141
+ assistant.agentMetadata = {
1142
+ executionId: payload.executionId,
1143
+ iteration: lastIterationSeen,
1144
+ };
1145
+ applyTextChunk(assistant, pendingTextRaw, delta, undefined);
1146
+ lastAssistantInTurn = assistant;
1147
+ continue;
1148
+ }
1149
+ // Agent text can be structured JSON too, whenever the host configures a
1150
+ // custom `streamParser` (e.g. the page-context demo's `{"text": ...}` /
1151
+ // `{"action": "add_to_cart", ...}` envelope). Accumulate the open block's
1152
+ // raw text and run it through the same structured-content path the flow
1153
+ // branch uses, so the parser sees the whole block and `rawContent` stays
1154
+ // populated for the action manager. With the default plain-text parser
1155
+ // this is byte-identical to appending the delta, so agent demos that
1156
+ // stream prose are unaffected.
1157
+ const agentDelta = typeof payload.delta === "string" ? payload.delta : "";
1158
+ pendingTextRaw += agentDelta;
1159
+ const assistant = ensureAssistantMessage();
1160
+ assistant.agentMetadata = {
1161
+ executionId: payload.executionId,
1162
+ iteration: lastIterationSeen,
1163
+ turnId: openTurnId ?? undefined,
1164
+ agentName: agentExecution?.agentName
1165
+ };
1166
+ applyTextChunk(assistant, pendingTextRaw, agentDelta, undefined);
1167
+ lastAssistantInTurn = assistant;
1168
+ } else if (payload.type === "text_complete") {
1169
+ // Nested flow-as-tool text block close: seal its parent-tool-row message.
1170
+ const completeBlockId = typeof payload.id === "string" ? payload.id : null;
1171
+ if (completeBlockId && nestedBlockParent.has(completeBlockId)) {
1172
+ const nested = nestedBlockMessages.get(completeBlockId);
1173
+ if (nested) finalizeFlowTextBlock(nested);
1174
+ nestedBlockParent.delete(completeBlockId);
1175
+ nestedBlockRaw.delete(completeBlockId);
1176
+ nestedBlockMessages.delete(completeBlockId);
1177
+ continue;
1178
+ }
1179
+ // Seal the current text block's bubble.
1180
+ const prev = assistantMessage as AgentWidgetMessage | null;
1181
+ if (prev) {
1182
+ if (executionKind === "flow") {
1183
+ // Final structured extraction off the accumulated raw buffer; the
1184
+ // authoritative step_complete.result.response reconciles next.
1185
+ finalizeFlowTextBlock(prev);
1186
+ lastSealedFlowBubble = prev;
1187
+ } else {
1188
+ // U2: text_complete carries the assembled text, but the bubble already
1189
+ // holds it from the deltas — only fall back to payload.text when no
1190
+ // delta content was seen, never double-count.
1191
+ if ((prev.content ?? "") === "" && typeof payload.text === "string") {
1192
+ prev.content = payload.text;
1193
+ }
1194
+ prev.streaming = false;
1195
+ emitMessage(prev);
1196
+ }
1197
+ assistantMessage = null;
1198
+ }
1199
+ currentTextBlockId = null;
1200
+ pendingTextRaw = "";
1201
+ } else if (payload.type === "step_complete") {
1202
+ // Only process completions for prompt steps, not tool/context steps
1203
+ if (payload.stepType === "tool") {
1204
+ // Skip tool-related completions - they're handled by tool_complete
1205
+ continue;
1206
+ }
1207
+
1208
+ // A failed step (`success:false`) — including the legacy `step_error`
1209
+ // event, which the wire encoder folds into a failed `step_complete`
1210
+ // — surfaces as a terminal error and finalizes the stream.
1211
+ if (payload.success === false) {
1212
+ const message = payload.error || "Step failed";
1213
+ onEvent({ type: "error", error: new Error(message) });
1214
+ const finalMsg = assistantMessage as AgentWidgetMessage | null;
1215
+ if (finalMsg && finalMsg.streaming) {
1216
+ finalMsg.streaming = false;
1217
+ emitMessage(finalMsg);
1218
+ }
1219
+ onEvent({ type: "status", status: "idle" });
1220
+ continue;
1221
+ }
1222
+
1223
+ // Unified flow: reconcile the just-sealed text block with the
1224
+ // authoritative structured final (`result.response`). Displayed content
1225
+ // stays as streamed — a multi-segment step keeps each bubble's own text;
1226
+ // reconcile only fills/repairs the last sealed block and sets rawContent.
1227
+ // A pure-tool / text-less step (no sealed flow bubble) completes silently.
1228
+ {
1229
+ const sealed = lastSealedFlowBubble;
1230
+ lastSealedFlowBubble = null;
1231
+ const flowStopReason = payload.stopReason as
1232
+ | StopReasonKind
1233
+ | undefined;
1234
+ const result = payload.result && typeof payload.result === "object"
1235
+ ? payload.result as Record<string, unknown>
1236
+ : null;
1237
+ const finalResponse = result?.response;
1238
+ if (sealed) {
1239
+ if (flowStopReason) sealed.stopReason = flowStopReason;
1240
+ if (finalResponse !== undefined && finalResponse !== null) {
1241
+ reconcileSealedAssistantWithFinalResponse(sealed, finalResponse);
1242
+ } else if (sealed.streaming !== false) {
1243
+ streamParsers.delete(sealed.id);
1244
+ rawContentBuffers.delete(sealed.id);
1245
+ sealed.streaming = false;
1246
+ emitMessage(sealed);
1247
+ }
1248
+ } else {
1249
+ // Buffered / dispatch-mode step: no streamed text block, but the step
1250
+ // carries the final response (and/or a stopReason) — render it as the
1251
+ // assistant message. An empty response + stopReason still surfaces a
1252
+ // sealed bubble so the UI can show an affordance.
1253
+ const hasResponse =
1254
+ finalResponse !== undefined &&
1255
+ finalResponse !== null &&
1256
+ finalResponse !== "";
1257
+ if (hasResponse || flowStopReason) {
1258
+ const assistant = ensureAssistantMessage();
1259
+ if (flowStopReason) assistant.stopReason = flowStopReason;
1260
+ if (hasResponse) {
1261
+ finalizeFlowTextBlock(assistant, finalResponse);
1262
+ } else {
1263
+ assistant.streaming = false;
1264
+ emitMessage(assistant);
1265
+ }
1266
+ }
1267
+ }
1268
+ continue;
1269
+ }
1270
+ // ================================================================
1271
+ // Agent Loop Execution Events
1272
+ // ================================================================
1273
+ } else if (payload.type === "execution_start") {
1274
+ executionKind = payload.kind === "flow" ? "flow" : "agent";
1275
+ executionKindResolved = true;
1276
+ if (executionKind === "agent") {
1277
+ agentExecution = {
1278
+ executionId: payload.executionId,
1279
+ agentId: payload.agentId ?? 'virtual',
1280
+ agentName: payload.agentName ?? ''
1281
+ };
1282
+ }
1283
+ } else if (payload.type === "turn_start") {
1284
+ // Unified collapsed `agent_iteration_*` into a denormalized `iteration`
1285
+ // field on the turn (merged spec §2). Reconstruct the per-iteration
1286
+ // message boundary the 'separate' renderer keys off: when the iteration
1287
+ // advances, seal the previous iteration's bubble and rotate to a new one.
1288
+ const iteration =
1289
+ typeof payload.iteration === "number" ? payload.iteration : lastIterationSeen;
1290
+ if (iteration !== lastIterationSeen) {
1291
+ if (iterationDisplay === 'separate' && iteration > 1) {
1292
+ const prevMsg = assistantMessage as AgentWidgetMessage | null;
1293
+ if (prevMsg) {
1294
+ prevMsg.streaming = false;
1295
+ emitMessage(prevMsg);
1296
+ assistantMessage = null;
1297
+ }
1298
+ }
1299
+ lastIterationSeen = iteration;
1300
+ }
1301
+ openTurnId = typeof payload.id === "string" ? payload.id : null;
1302
+ // Reset the per-turn assistant tracker. lastAssistantInTurn is used by
1303
+ // turn_complete to attach stopReason to the final text segment of the
1304
+ // turn even if that segment was sealed by an intervening tool boundary.
1305
+ lastAssistantInTurn = null;
1306
+ } else if (payload.type === "tool_input_delta") {
1307
+ // Streamed tool arguments (display-only; authoritative args ride
1308
+ // tool_input_complete / tool_start).
1309
+ const toolId = payload.toolCallId;
1310
+ if (toolId) {
1311
+ const toolMessage = toolMessages.get(toolId);
1312
+ if (toolMessage?.toolCall) {
1313
+ toolMessage.toolCall.chunks = toolMessage.toolCall.chunks ?? [];
1314
+ toolMessage.toolCall.chunks.push(payload.delta ?? '');
1315
+ emitMessage(toolMessage);
1316
+ }
1317
+ }
1318
+ } else if (payload.type === "tool_input_complete") {
1319
+ if (artifactToolCallIds.has(payload.toolCallId)) continue;
1320
+ const toolMessage = ensureToolMessage(payload.toolCallId);
1321
+ const tool = toolMessage.toolCall!;
1322
+ tool.args = payload.parameters;
1323
+ tool.name = payload.toolName ?? tool.name;
1324
+ emitMessage(toolMessage);
1325
+ } else if (payload.type === "turn_complete") {
1326
+ // Reasoning is sealed by its own reasoning_complete on the wire
1327
+ // vocabulary; this only attaches the turn-level stopReason to the
1328
+ // assistant message produced by this turn. Falls back to
1329
+ // lastAssistantInTurn when the bubble was sealed at a tool boundary
1330
+ // mid-turn, so the notice still attaches to the final visible segment.
1331
+ const turnStopReason = payload.stopReason as
1332
+ | StopReasonKind
1333
+ | undefined;
1334
+ const stopReasonTarget = assistantMessage ?? lastAssistantInTurn;
1335
+ if (turnStopReason && stopReasonTarget !== null) {
1336
+ const turnId = payload.id;
1337
+ const matchesTurn =
1338
+ !turnId || stopReasonTarget.agentMetadata?.turnId === turnId;
1339
+ if (matchesTurn) {
1340
+ stopReasonTarget.stopReason = turnStopReason;
1341
+ emitMessage(stopReasonTarget);
1342
+ }
1343
+ }
1344
+ if (openTurnId === payload.id) openTurnId = null;
1345
+ } else if (payload.type === "media_start") {
1346
+ // Open a media block; buffer fragments until media_complete.
1347
+ const id = String(payload.id);
1348
+ mediaBuffers.set(id, {
1349
+ mediaType: typeof payload.mediaType === "string" ? payload.mediaType : undefined,
1350
+ toolCallId: payload.toolCallId,
1351
+ parts: [],
1352
+ });
1353
+ } else if (payload.type === "media_delta") {
1354
+ const buf = mediaBuffers.get(String(payload.id));
1355
+ if (buf && typeof payload.delta === "string") buf.parts.push(payload.delta);
1356
+ } else if (payload.type === "media_complete") {
1357
+ // Reassemble the buffered media triad into a single AI SDK–aligned
1358
+ // `MediaContentPart`, then render it as a synthetic assistant message
1359
+ // inserted between the tool bubble and the next text turn:
1360
+ // { type: 'media', data, mediaType } // AI SDK v6: base64
1361
+ // { type: 'image-url', url, mediaType? } // AI SDK v3/v4
1362
+ // { type: 'file-url', url, mediaType } // AI SDK v3/v4
1363
+ const mediaBlockId = String(payload.id);
1364
+ const buf = mediaBuffers.get(mediaBlockId);
1365
+ mediaBuffers.delete(mediaBlockId);
1366
+ const completeMediaType =
1367
+ (typeof payload.mediaType === "string" ? payload.mediaType : undefined) ??
1368
+ buf?.mediaType ??
1369
+ "application/octet-stream";
1370
+ const completeData = typeof payload.data === "string" ? payload.data : undefined;
1371
+ const completeUrl =
1372
+ typeof payload.url === "string"
1373
+ ? payload.url
1374
+ : buf && buf.parts.length > 0
1375
+ ? buf.parts.join("")
1376
+ : undefined;
1377
+ let reconstructed: Record<string, unknown> | null = null;
1378
+ if (completeData) {
1379
+ reconstructed = { type: "media", data: completeData, mediaType: completeMediaType };
1380
+ } else if (completeUrl) {
1381
+ // The wire is mediaType-only; a URL part with no declared MIME
1382
+ // arrives as the bare bucket hint "image" (per the API encoder). Treat
1383
+ // that — and any real `image/*` — as a hosted image so we don't misroute
1384
+ // generated images into the file bucket.
1385
+ const lower = completeMediaType.toLowerCase();
1386
+ const isImage = lower === "image" || lower.startsWith("image/");
1387
+ reconstructed = {
1388
+ type: isImage ? "image-url" : "file-url",
1389
+ url: completeUrl,
1390
+ mediaType: completeMediaType,
1391
+ };
1392
+ }
1393
+ const mediaToolCallId = payload.toolCallId ?? buf?.toolCallId;
1394
+ const rawMedia = reconstructed ? [reconstructed] : [];
1395
+ const mediaContentParts: ContentPart[] = [];
1396
+ for (const part of rawMedia) {
1397
+ if (!part || typeof part !== "object") continue;
1398
+ const rec = part as Record<string, unknown>;
1399
+ const partType = typeof rec.type === "string" ? rec.type : undefined;
1400
+
1401
+ // Resolve `(src, mediaType)` for the part.
1402
+ // RFC 7231 says MIME types are case-insensitive, so we canonicalize
1403
+ // to lowercase once here. That makes the `startsWith("image/")` /
1404
+ // `"audio/"` / `"video/"` bucket checks robust to upstream tools
1405
+ // that emit non-canonical casing like `Image/PNG`.
1406
+ const rawMediaType =
1407
+ typeof rec.mediaType === "string" ? rec.mediaType.toLowerCase() : "";
1408
+ let src: string | null = null;
1409
+ let mediaType = "";
1410
+ if (partType === "media") {
1411
+ const data = typeof rec.data === "string" ? rec.data : undefined;
1412
+ if (!data) continue;
1413
+ // Empty/missing mediaType yields `data:;base64,...` which RFC 2397
1414
+ // resolves to `text/plain`: stamp a default so the data URI is
1415
+ // well-formed and the part lands in the file bucket.
1416
+ mediaType = rawMediaType.length > 0 ? rawMediaType : "application/octet-stream";
1417
+ src = `data:${mediaType};base64,${data}`;
1418
+ } else if (partType === "image-url") {
1419
+ const url = typeof rec.url === "string" ? rec.url : undefined;
1420
+ if (!url) continue;
1421
+ mediaType = rawMediaType;
1422
+ src = url;
1423
+ } else if (partType === "file-url") {
1424
+ const url = typeof rec.url === "string" ? rec.url : undefined;
1425
+ if (!url) continue;
1426
+ mediaType = rawMediaType;
1427
+ src = url;
1428
+ } else {
1429
+ continue;
1430
+ }
1431
+ if (!src) continue;
1432
+
1433
+ // Pick the right rendering bucket based on mediaType.
1434
+ if (partType === "image-url" || mediaType.startsWith("image/")) {
1435
+ mediaContentParts.push({
1436
+ type: "image",
1437
+ image: src,
1438
+ // Only a real MIME (`image/png`) is a usable mimeType; the bare
1439
+ // bucket hint "image" (a hosted URL with no declared type) is not.
1440
+ ...(mediaType.includes("/") ? { mimeType: mediaType } : {}),
1441
+ });
1442
+ } else if (mediaType.startsWith("audio/")) {
1443
+ mediaContentParts.push({
1444
+ type: "audio",
1445
+ audio: src,
1446
+ mimeType: mediaType,
1447
+ });
1448
+ } else if (mediaType.startsWith("video/")) {
1449
+ mediaContentParts.push({
1450
+ type: "video",
1451
+ video: src,
1452
+ mimeType: mediaType,
1453
+ });
1454
+ } else {
1455
+ const resolvedMediaType = mediaType || "application/octet-stream";
1456
+ mediaContentParts.push({
1457
+ type: "file",
1458
+ data: src,
1459
+ mimeType: resolvedMediaType,
1460
+ filename: filenameFromMediaType(resolvedMediaType),
1461
+ });
1462
+ }
1463
+ }
1464
+
1465
+ if (mediaContentParts.length > 0) {
1466
+ // Uniquify per emission. A tool may emit multiple `agent_media`
1467
+ // events for the same `toolCallId` (e.g. streamed/batched media);
1468
+ // sharing an id would let `emitMessage` merge them by id and
1469
+ // overwrite the prior `contentParts`.
1470
+ const seq = nextSequence();
1471
+ const toolCallIdRaw = mediaToolCallId;
1472
+ const mediaIdSuffix =
1473
+ typeof toolCallIdRaw === "string" && toolCallIdRaw.length > 0
1474
+ ? `${toolCallIdRaw}-${seq}`
1475
+ : String(seq);
1476
+ const mediaMessage: AgentWidgetMessage = {
1477
+ id: `agent-media-${mediaIdSuffix}`,
1478
+ role: "assistant",
1479
+ content: "",
1480
+ contentParts: mediaContentParts,
1481
+ createdAt: new Date().toISOString(),
1482
+ streaming: false,
1483
+ sequence: seq,
1484
+ agentMetadata: {
1485
+ executionId: payload.executionId,
1486
+ // Media blocks carry no iteration of their own; stamp the
1487
+ // enclosing iteration tracked from turn/tool frames.
1488
+ iteration: lastIterationSeen,
1489
+ },
1490
+ };
1491
+ emitMessage(mediaMessage);
1492
+
1493
+ // Seal any in-flight assistant text bubble before splitting the
1494
+ // stream. Without this, an orphan bubble retains `streaming: true`
1495
+ // forever: `execution_complete` only finalizes the latest
1496
+ // `assistantMessage`, so the typing/caret indicator would stay on
1497
+ // the prior bubble even though no more deltas will arrive.
1498
+ const prevAssistant = assistantMessage as AgentWidgetMessage | null;
1499
+ if (prevAssistant) {
1500
+ prevAssistant.streaming = false;
1501
+ emitMessage(prevAssistant);
1502
+ }
1503
+ assistantMessage = null;
1504
+ assistantMessageRef.current = null;
1505
+ }
1506
+ } else if (payload.type === "execution_complete") {
1507
+ // Finalize any still-open assistant message. Per-step reconciliation
1508
+ // (step_complete.result.response) normally sealed the flow blocks
1509
+ // already; this is the defensive close for an unterminated block, and
1510
+ // for flow it runs the final structured extraction off the raw buffer.
1511
+ const finalMsg = assistantMessage as AgentWidgetMessage | null;
1512
+ if (finalMsg) {
1513
+ if (payload.kind === "flow" && finalMsg.streaming !== false) {
1514
+ finalizeFlowTextBlock(finalMsg);
1515
+ } else {
1516
+ finalMsg.streaming = false;
1517
+ emitMessage(finalMsg);
1518
+ }
1519
+ assistantMessage = null;
1520
+ }
1521
+ currentTextBlockId = null;
1522
+ pendingTextRaw = "";
1523
+ lastSealedFlowBubble = null;
1524
+
1525
+ // `terminal: true` marks this as a graceful finish (not a drop). The
1526
+ // session uses it to distinguish the real end-of-turn from the plain
1527
+ // `idle` the dispatch wrappers emit in their `finally` when a durable
1528
+ // connection drops mid-stream (durable-reconnect drop detection).
1529
+ onEvent({ type: "status", status: "idle", terminal: true });
1530
+ } else if (payload.type === "execution_error") {
1531
+ // Terminal failure. The non-terminal `error` is handled
1532
+ // separately (recoverable → warn).
1533
+ const errorMessage = typeof payload.error === 'string'
1534
+ ? payload.error
1535
+ : payload.error?.message ?? 'Execution error';
1536
+ onEvent({
1537
+ type: "error",
1538
+ error: new Error(errorMessage)
1539
+ });
1540
+ } else if (payload.type === "ping") {
1541
+ // Keep-alive heartbeat - no action needed
1542
+ // ================================================================
1543
+ // Tool Approval Events
1544
+ // ================================================================
1545
+ } else if (payload.type === "approval_start") {
1546
+ const approvalId = payload.approvalId ?? `approval-${nextSequence()}`;
1547
+ const approvalMessage: AgentWidgetMessage = {
1548
+ id: `approval-${approvalId}`,
1549
+ role: "assistant",
1550
+ content: "",
1551
+ createdAt: new Date().toISOString(),
1552
+ streaming: false,
1553
+ variant: "approval",
1554
+ sequence: nextSequence(),
1555
+ approval: {
1556
+ id: approvalId,
1557
+ status: "pending",
1558
+ agentId: agentExecution?.agentId ?? 'virtual',
1559
+ executionId: payload.executionId ?? agentExecution?.executionId ?? '',
1560
+ toolName: payload.toolName ?? '',
1561
+ toolType: payload.toolType,
1562
+ description: payload.description ?? `Execute ${payload.toolName ?? 'tool'}`,
1563
+ ...(typeof payload.reason === "string" && payload.reason
1564
+ ? { reason: payload.reason }
1565
+ : {}),
1566
+ parameters: payload.parameters,
1567
+ ...(typeof payload.toolCallId === "string" && payload.toolCallId
1568
+ ? { toolCallId: payload.toolCallId }
1569
+ : {}),
1570
+ },
1571
+ };
1572
+ emitMessage(approvalMessage);
1573
+ } else if (payload.type === "approval_complete") {
1574
+ const approvalId = payload.approvalId;
1575
+ if (approvalId) {
1576
+ // Find and update the existing approval message
1577
+ const approvalMessageId = `approval-${approvalId}`;
1578
+ const existingMessage: AgentWidgetMessage = {
1579
+ id: approvalMessageId,
1580
+ role: "assistant",
1581
+ content: "",
1582
+ createdAt: new Date().toISOString(),
1583
+ streaming: false,
1584
+ variant: "approval",
1585
+ sequence: nextSequence(),
1586
+ approval: {
1587
+ id: approvalId,
1588
+ status: (payload.decision as "approved" | "denied") ?? "approved",
1589
+ agentId: agentExecution?.agentId ?? 'virtual',
1590
+ executionId: payload.executionId ?? agentExecution?.executionId ?? '',
1591
+ toolName: '',
1592
+ description: '',
1593
+ resolvedAt: Date.now(),
1594
+ },
1595
+ };
1596
+ emitMessage(existingMessage);
1597
+ }
1598
+ } else if (
1599
+ payload.type === "artifact_start" ||
1600
+ payload.type === "artifact_delta" ||
1601
+ payload.type === "artifact_update" ||
1602
+ payload.type === "artifact_complete"
1603
+ ) {
1604
+ if (payload.type === "artifact_start") {
1605
+ const at = payload.artifactType as PersonaArtifactKind;
1606
+ const artId = String(payload.id);
1607
+ const artTitle = typeof payload.title === "string" ? payload.title : undefined;
1608
+ // Additive `file` metadata: validate shape (object with string path +
1609
+ // string mimeType; optional string language). Drop silently if malformed
1610
+ // so old/new backends and unexpected payloads never break the stream.
1611
+ const rawFile = payload.file;
1612
+ let artFile: PersonaArtifactFileMeta | undefined;
1613
+ if (
1614
+ rawFile &&
1615
+ typeof rawFile === "object" &&
1616
+ !Array.isArray(rawFile) &&
1617
+ typeof rawFile.path === "string" &&
1618
+ typeof rawFile.mimeType === "string"
1619
+ ) {
1620
+ artFile = {
1621
+ path: rawFile.path,
1622
+ mimeType: rawFile.mimeType,
1623
+ ...(typeof rawFile.language === "string" ? { language: rawFile.language } : {}),
1624
+ };
1625
+ }
1626
+ onEvent({
1627
+ type: "artifact_start",
1628
+ id: artId,
1629
+ artifactType: at,
1630
+ title: artTitle,
1631
+ component: typeof payload.component === "string" ? payload.component : undefined,
1632
+ ...(artFile ? { file: artFile } : {})
1633
+ });
1634
+ artifactContent.set(artId, {
1635
+ markdown: "",
1636
+ title: artTitle,
1637
+ file: artFile,
1638
+ });
1639
+ // Insert the in-thread artifact block once per ID.
1640
+ // The resolved display mode picks the component:
1641
+ // "card"/"panel" inject the reference card; "inline" injects the
1642
+ // inline preview block. Both share the rawContent JSON-component
1643
+ // shape so transcript persistence and hydration work unchanged.
1644
+ if (!artifactIdsWithCards.has(artId)) {
1645
+ artifactIdsWithCards.add(artId);
1646
+ const displayMode = resolveArtifactDisplayMode(
1647
+ ctx.config().features?.artifacts,
1648
+ {
1649
+ artifactType: at,
1650
+ ...(artFile ? { file: artFile } : {}),
1651
+ }
1652
+ );
1653
+ const artComponent =
1654
+ typeof payload.component === "string" ? payload.component : undefined;
1655
+ const cardMsg: AgentWidgetMessage = {
1656
+ id: `artifact-ref-${artId}`,
1657
+ role: "assistant",
1658
+ content: "",
1659
+ createdAt: new Date().toISOString(),
1660
+ streaming: true,
1661
+ sequence: nextSequence(),
1662
+ rawContent: buildArtifactRefRawContent(displayMode, {
1663
+ artifactId: artId,
1664
+ title: artTitle,
1665
+ artifactType: at,
1666
+ status: "streaming",
1667
+ ...(artFile ? { file: artFile } : {}),
1668
+ ...(artComponent ? { component: artComponent } : {}),
1669
+ }),
1670
+ };
1671
+ artifactCardMessages.set(artId, cardMsg);
1672
+ emitMessage(cardMsg);
1673
+ }
1674
+ } else if (payload.type === "artifact_delta") {
1675
+ const deltaId = String(payload.id);
1676
+ const deltaText = typeof payload.delta === "string" ? payload.delta : String(payload.delta ?? "");
1677
+ onEvent({
1678
+ type: "artifact_delta",
1679
+ id: deltaId,
1680
+ artDelta: deltaText
1681
+ });
1682
+ const acc = artifactContent.get(deltaId);
1683
+ if (acc) acc.markdown += deltaText;
1684
+ } else if (payload.type === "artifact_update") {
1685
+ const props =
1686
+ payload.props && typeof payload.props === "object" && !Array.isArray(payload.props)
1687
+ ? (payload.props as Record<string, unknown>)
1688
+ : {};
1689
+ onEvent({
1690
+ type: "artifact_update",
1691
+ id: String(payload.id),
1692
+ props,
1693
+ component: typeof payload.component === "string" ? payload.component : undefined
1694
+ });
1695
+ // Accumulate for hydration: embedded on artifact_complete so an
1696
+ // inline component block re-renders with its real props after a
1697
+ // refresh.
1698
+ const updateAcc = artifactContent.get(String(payload.id));
1699
+ if (updateAcc) {
1700
+ updateAcc.props = { ...(updateAcc.props ?? {}), ...props };
1701
+ }
1702
+ } else if (payload.type === "artifact_complete") {
1703
+ const artCompleteId = String(payload.id);
1704
+ onEvent({ type: "artifact_complete", id: artCompleteId });
1705
+ // Update the inline card to show completed state
1706
+ const refMsg = artifactCardMessages.get(artCompleteId);
1707
+ if (refMsg) {
1708
+ refMsg.streaming = false;
1709
+ try {
1710
+ const parsed = JSON.parse(refMsg.rawContent ?? "{}");
1711
+ if (parsed.props) {
1712
+ parsed.props.status = "complete";
1713
+ // Store markdown content in card props so download works after page refresh
1714
+ const acc = artifactContent.get(artCompleteId);
1715
+ if (acc?.markdown) {
1716
+ parsed.props.markdown = acc.markdown;
1717
+ }
1718
+ // Persist file metadata too so the download path unfences correctly after refresh.
1719
+ if (acc?.file) {
1720
+ parsed.props.file = acc.file;
1721
+ }
1722
+ // Embed accumulated component props so an inline component
1723
+ // block hydrates with its real props after a refresh. Only
1724
+ // the inline block reads them; the card never does.
1725
+ if (
1726
+ parsed.component === "PersonaArtifactInline" &&
1727
+ acc?.props &&
1728
+ Object.keys(acc.props).length > 0
1729
+ ) {
1730
+ parsed.props.componentProps = acc.props;
1731
+ }
1732
+ }
1733
+ refMsg.rawContent = JSON.stringify(parsed);
1734
+ } catch { /* ignore parse errors */ }
1735
+ artifactContent.delete(artCompleteId);
1736
+ emitMessage(refMsg);
1737
+ artifactCardMessages.delete(artCompleteId);
1738
+ }
1739
+ }
1740
+ } else if (payload.type === "error") {
1741
+ // Unified non-terminal error (merged spec). A bare `error` is
1742
+ // recoverable by default — a transient notice such as "rate limited,
1743
+ // retrying" — and the execution continues, so it must NOT surface as a
1744
+ // fatal error or finalize the stream. The API routes terminal failures
1745
+ // through `execution_error`. Only an explicit `recoverable: false`
1746
+ // promotes an `error` to terminal.
1747
+ if (
1748
+ payload.recoverable === false &&
1749
+ payload.error != null &&
1750
+ payload.error !== ""
1751
+ ) {
1752
+ const errorMessage =
1753
+ typeof payload.error === "string"
1754
+ ? payload.error
1755
+ : (payload.error as { message?: unknown })?.message != null
1756
+ ? String((payload.error as { message?: unknown }).message)
1757
+ : "Execution error";
1758
+ onEvent({ type: "error", error: new Error(errorMessage) });
1759
+ const finalMsg = assistantMessage as AgentWidgetMessage | null;
1760
+ if (finalMsg && finalMsg.streaming) {
1761
+ finalMsg.streaming = false;
1762
+ emitMessage(finalMsg);
1763
+ }
1764
+ onEvent({ type: "status", status: "idle" });
1765
+ }
1766
+
1767
+ }
1768
+ }
1769
+ eventQueue.length = 0;
1770
+ };
1771
+
1772
+ // eslint-disable-next-line no-constant-condition
1773
+ while (true) {
1774
+ const { done, value } = await reader.read();
1775
+ if (done) break;
1776
+
1777
+ buffer += decoder.decode(value, { stream: true });
1778
+ const events = buffer.split("\n\n");
1779
+ buffer = events.pop() ?? "";
1780
+
1781
+ for (const event of events) {
1782
+ const lines = event.split("\n");
1783
+ let eventType = "message";
1784
+ let data = "";
1785
+ // Durable-reconnect cursor: the SSE `id:` line (the durable row seq).
1786
+ // Only durable, resumable agent executions stamp these (e.g. Claude
1787
+ // Managed agents, or any async/background run the backend persists and
1788
+ // can replay); other streams carry no cursor. We emit a `cursor`
1789
+ // event AFTER the frame is fully parsed and dispatched, so the session's
1790
+ // `lastEventId` only advances past frames it has actually applied, so the
1791
+ // happy path has no dupes against the server's `seq > after` replay.
1792
+ let frameId: string | null = null;
1793
+
1794
+ for (const line of lines) {
1795
+ if (line.startsWith("event:")) {
1796
+ eventType = line.replace("event:", "").trim();
1797
+ } else if (line.startsWith("data:")) {
1798
+ data += line.replace("data:", "").trim();
1799
+ } else if (line.startsWith("id:")) {
1800
+ frameId = line.slice(3).trim();
1801
+ }
1802
+ }
1803
+
1804
+ const advanceCursor = () => {
1805
+ if (frameId !== null && frameId !== "") {
1806
+ onEvent({ type: "cursor", id: frameId });
1807
+ }
1808
+ };
1809
+
1810
+ // A frame with an `id:` but no `data:` (e.g. a bare keepalive line) is
1811
+ // still a received durable row, so advance the cursor past it.
1812
+ if (!data) {
1813
+ advanceCursor();
1814
+ continue;
1815
+ }
1816
+ let payload: unknown;
1817
+ try {
1818
+ payload = JSON.parse(data);
1819
+ } catch (error) {
1820
+ // Parse failure: the frame was NOT applied. Do NOT advance the cursor
1821
+ // so a reconnect re-fetches this row.
1822
+ onEvent({
1823
+ type: "error",
1824
+ error:
1825
+ error instanceof Error
1826
+ ? error
1827
+ : new Error("Failed to parse chat stream payload")
1828
+ });
1829
+ continue;
1830
+ }
1831
+
1832
+ const record = payload !== null && typeof payload === "object" && !Array.isArray(payload)
1833
+ ? payload as Record<string, unknown>
1834
+ : null;
1835
+ const payloadType = eventType !== "message"
1836
+ ? eventType
1837
+ : typeof record?.type === "string" ? record.type : "message";
1838
+
1839
+ // Tap: capture raw SSE event for event stream inspector
1840
+ ctx.onSSEEvent?.(payloadType, payload);
1841
+
1842
+ // If custom SSE event parser is provided, try it first
1843
+ if (ctx.parseSSEEvent) {
1844
+ // Keep assistant message ref in sync
1845
+ assistantMessageRef.current = assistantMessage;
1846
+ const handled = await handleCustomSSEEvent(
1847
+ ctx,
1848
+ payload,
1849
+ onEvent,
1850
+ assistantMessageRef,
1851
+ emitMessage,
1852
+ nextSequence,
1853
+ customParsePartId
1854
+ );
1855
+ // Update assistantMessage from ref (in case it was created or replaced by partId segmentation)
1856
+ if (assistantMessageRef.current && assistantMessageRef.current !== assistantMessage) {
1857
+ assistantMessage = assistantMessageRef.current;
1858
+ }
1859
+ if (handled) {
1860
+ advanceCursor();
1861
+ continue; // Skip default handling if custom handler processed it
1862
+ }
1863
+ }
1864
+
1865
+ // The wire is the wire vocabulary; the handler consumes it
1866
+ // natively. The stream is single-connection and in order, so each frame
1867
+ // drains straight through.
1868
+ if (record) {
1869
+ // The raw tap and custom parser above retain the original payload.
1870
+ // Known native branches use the generated union; unknown event types
1871
+ // fall through without rejecting a custom backend's stream.
1872
+ eventQueue.push({ ...record, type: payloadType } as RuntypeExecutionStreamEvent);
1873
+ drainReadyQueue();
1874
+ }
1875
+ advanceCursor();
1876
+ }
1877
+ }
1878
+
1879
+ drainReadyQueue();
1880
+ }