@runtypelabs/persona 4.25.0 → 4.26.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (236) 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 +186 -8
  12. package/dist/approval-ui.d.ts +186 -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 +186 -8
  17. package/dist/artifacts-ui.d.ts +186 -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-GKPCIQPB.js +1 -0
  23. package/dist/chunk-HGUCGM3K.js +9 -0
  24. package/dist/chunk-IO5VVUKP.js +3 -0
  25. package/dist/chunk-J4J3Y2P2.js +1 -0
  26. package/dist/chunk-K6HKIP5K.js +1 -0
  27. package/dist/chunk-K7NA7G7J.js +1 -0
  28. package/dist/chunk-MXUTS3LB.js +1 -0
  29. package/dist/chunk-PDPVORUF.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-GOZWBDA5.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 +186 -8
  46. package/dist/context-mentions.d.ts +186 -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 +189 -31
  51. package/dist/event-stream-view.d.ts +189 -31
  52. package/dist/event-stream-view.js +1 -1
  53. package/dist/forms-ui.d.cts +227 -667
  54. package/dist/forms-ui.d.ts +227 -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 +233 -738
  65. package/dist/index.d.ts +233 -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 +186 -8
  80. package/dist/session-reconnect.d.ts +186 -8
  81. package/dist/smart-dom-reader.cjs +13 -13
  82. package/dist/smart-dom-reader.d.cts +186 -8
  83. package/dist/smart-dom-reader.d.ts +186 -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 +188 -8
  87. package/dist/theme-editor-preview.d.ts +188 -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 +187 -23
  91. package/dist/theme-editor.d.ts +187 -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-XLU6YK2N.js +17 -0
  98. package/dist/voice-runtime.cjs +17 -1
  99. package/dist/voice-runtime.d.cts +11656 -232
  100. package/dist/voice-runtime.d.ts +11656 -232
  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 +325 -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 +6 -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 +895 -1538
  151. package/src/session.voice.test.ts +1171 -5
  152. package/src/styles/widget.css +58 -0
  153. package/src/theme-editor/state.ts +91 -91
  154. package/src/types.ts +204 -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 +340 -2770
  172. package/src/utils/attachment-manager.ts +67 -67
  173. package/src/utils/constants.ts +3 -0
  174. package/src/utils/context-mention-controller.ts +323 -323
  175. package/src/utils/context-mention-manager.ts +40 -40
  176. package/src/utils/context-mention-orchestrator.ts +28 -0
  177. package/src/utils/event-stream-buffer.ts +50 -50
  178. package/src/utils/event-stream-store.ts +43 -43
  179. package/src/utils/throughput-tracker.ts +34 -34
  180. package/src/utils/virtual-scroller.ts +67 -67
  181. package/src/utils/visitor-store.test.ts +50 -0
  182. package/src/utils/visitor-store.ts +23 -5
  183. package/src/voice/__fixtures__/voice-wire/README.md +1 -0
  184. package/src/voice/__fixtures__/voice-wire/client/cancel.json +3 -0
  185. package/src/voice/__fixtures__/voice-wire/client/context.json +4 -0
  186. package/src/voice/__fixtures__/voice-wire/client/delegation_result.json +6 -0
  187. package/src/voice/__fixtures__/voice-wire/client/delegation_update.json +6 -0
  188. package/src/voice/__fixtures__/voice-wire/client/ping.json +3 -0
  189. package/src/voice/__fixtures__/voice-wire/client/playback_progress.json +5 -0
  190. package/src/voice/__fixtures__/voice-wire/client/start.json +3 -0
  191. package/src/voice/__fixtures__/voice-wire/server/artifact.json +11 -0
  192. package/src/voice/__fixtures__/voice-wire/server/attached.json +4 -0
  193. package/src/voice/__fixtures__/voice-wire/server/audio_clear.json +4 -0
  194. package/src/voice/__fixtures__/voice-wire/server/audio_end.json +3 -0
  195. package/src/voice/__fixtures__/voice-wire/server/delegation_cancelled.json +5 -0
  196. package/src/voice/__fixtures__/voice-wire/server/delegation_completed.json +8 -0
  197. package/src/voice/__fixtures__/voice-wire/server/delegation_started.json +15 -0
  198. package/src/voice/__fixtures__/voice-wire/server/error.json +6 -0
  199. package/src/voice/__fixtures__/voice-wire/server/metrics.json +10 -0
  200. package/src/voice/__fixtures__/voice-wire/server/pong.json +3 -0
  201. package/src/voice/__fixtures__/voice-wire/server/session_config.json +16 -0
  202. package/src/voice/__fixtures__/voice-wire/server/transcript_final.json +7 -0
  203. package/src/voice/__fixtures__/voice-wire/server/transcript_interim.json +4 -0
  204. package/src/voice/__fixtures__/voice-wire/server/transcript_partial.json +6 -0
  205. package/src/voice/__fixtures__/voice-wire/server/transcript_update.json +10 -0
  206. package/src/voice/__fixtures__/voice-wire/server/warning.json +5 -0
  207. package/src/voice/audio-playback-manager.test.ts +57 -0
  208. package/src/voice/audio-playback-manager.ts +120 -98
  209. package/src/voice/browser-voice-provider.ts +37 -37
  210. package/src/voice/fallback-speech-engine.ts +22 -16
  211. package/src/voice/keyed-voice-transcript.ts +314 -0
  212. package/src/voice/mic-state-styles.ts +182 -0
  213. package/src/voice/read-aloud-controller.ts +46 -44
  214. package/src/voice/runtype-speech-engine.ts +35 -33
  215. package/src/voice/runtype-voice-provider.ts +680 -189
  216. package/src/voice/session-voice-wiring.ts +266 -0
  217. package/src/voice/voice-delegation.test.ts +71 -0
  218. package/src/voice/voice-delegation.ts +364 -0
  219. package/src/voice/voice-turn-tracker.ts +6 -6
  220. package/src/voice/voice.test.ts +1060 -8
  221. package/src/voice/worklet-playback-engine.test.ts +81 -0
  222. package/src/voice/worklet-playback-engine.ts +21 -3
  223. package/src/voice-runtime-loader.ts +4 -1
  224. package/src/voice-runtime.ts +7 -0
  225. package/src/webmcp-runtime-entry.ts +41 -39
  226. package/dist/approval-ui-3NMYNXBD.js +0 -1
  227. package/dist/chunk-5EIIHQLQ.js +0 -1
  228. package/dist/chunk-7CO4T5KB.js +0 -1
  229. package/dist/chunk-EFJBUH5F.js +0 -1
  230. package/dist/chunk-R2TMIMDI.js +0 -3
  231. package/dist/chunk-UTVLOTWB.js +0 -9
  232. package/dist/context-mentions-45MODWOY.js +0 -161
  233. package/dist/event-stream-view-GL3ADRY2.js +0 -1
  234. package/dist/runtype-tts-entry-UJAEF7NZ.js +0 -1
  235. package/dist/voice-runtime-7DA7MJH4.js +0 -1
  236. package/dist/webmcp-runtime-JI7T4QYF.js +0 -6
package/src/client.ts CHANGED
@@ -1,10 +1,9 @@
1
- import type { RuntypeExecutionStreamEvent, RuntypeClientInitResponse } from "./generated/runtype-openapi-contract";
1
+ import type { RuntypeClientInitResponse } from "./generated/runtype-openapi-contract";
2
2
  import {
3
3
  AgentWidgetConfig,
4
4
  AgentWidgetMessage,
5
5
  AgentWidgetEvent,
6
6
  AgentWidgetStreamParser,
7
- AgentWidgetStreamParserResult,
8
7
  AgentWidgetContextProvider,
9
8
  AgentWidgetRequestMiddleware,
10
9
  AgentWidgetRequestPayload,
@@ -13,20 +12,15 @@ import {
13
12
  AgentWidgetSSEEventParser,
14
13
  AgentWidgetHeadersFunction,
15
14
  AgentWidgetSSEEventResult as _AgentWidgetSSEEventResult,
16
- StopReasonKind,
17
15
  ClientSession,
18
16
  ClientChatRequest,
19
17
  ClientToolDefinition,
20
18
  ClientFeedbackRequest,
21
19
  ClientFeedbackType,
22
- PersonaArtifactKind,
23
- PersonaArtifactFileMeta,
24
- ContentPart,
25
20
  PreparedClientSession,
26
21
  WidgetHistoryInternals,
27
22
  HistoryScope,
28
23
  HistoryIdentityStatus,
29
- HistoryConversationSummary,
30
24
  HistoryConversationPage,
31
25
  HistoryConversationDetail,
32
26
  HistoryDisplayProjection,
@@ -36,28 +30,26 @@ import {
36
30
  import {
37
31
  computeClientToolsFingerprint,
38
32
  getWebMcpToolDisplayTitle,
39
- isWebMcpToolName,
40
33
  loadWebMcpPolyfillModule,
41
34
  recordWebMcpToolDisplayTitles,
42
35
  } from "./webmcp-bridge";
43
36
  import { loadWebMcpRuntime } from "./webmcp-runtime-loader";
44
37
  import type { WebMcpBridge } from "./webmcp-runtime-entry";
45
- import {
46
- buildArtifactRefRawContent,
47
- resolveArtifactDisplayMode
48
- } from "./utils/artifact-display";
49
38
  import { resolveTarget } from "./utils/target";
50
39
  import { generateTurnId } from "./utils/message-id";
51
40
  import { builtInClientToolsForDispatch } from "./ask-user-question-tool";
52
41
  import { serializeWithToolPairs } from "./utils/tool-pair-replay";
53
42
  import {
54
- extractTextFromJson,
55
43
  createPlainTextParser,
56
44
  createJsonStreamParser,
57
45
  createRegexJsonParser,
58
46
  createXmlParser
59
47
  } from "./utils/formatting";
60
48
  import { VERSION } from "./version";
49
+ import { getClientStreamSync, loadClientStream } from "./client-stream-loader";
50
+ import { loadClientHistory } from "./client-history-loader";
51
+ import type { ClientHistoryHost } from "./client-history";
52
+ import type { ClientResumeHost } from "./client-resume";
61
53
 
62
54
  /** History transcripts stay on the wire shape; `utils/history-messages.ts` maps them. */
63
55
  export type { HistoryWireMessage } from "./utils/history-messages";
@@ -84,10 +76,10 @@ type DispatchOptions = {
84
76
  interrupt?: boolean;
85
77
  };
86
78
 
87
- type SSEHandler = (event: AgentWidgetEvent) => void;
79
+ export type SSEHandler = (event: AgentWidgetEvent) => void;
88
80
 
89
81
  const DEFAULT_ENDPOINT = "https://api.runtype.com/v1/dispatch";
90
- const DEFAULT_CLIENT_API_BASE = "https://api.runtype.com";
82
+ import { DEFAULT_CLIENT_API_BASE } from "./utils/constants";
91
83
 
92
84
  /** Branch on `code`, never on message text. */
93
85
  export type HistoryClientErrorCode =
@@ -134,68 +126,17 @@ export class HistoryClientError extends Error {
134
126
  }
135
127
  }
136
128
 
137
- /** Per-message / per-batch display-projection caps (contract facts #5, #15). */
138
- const DISPLAY_PROJECTION_MESSAGE_CAP = 32768;
139
- const DISPLAY_PROJECTION_BATCH_CAP = 49152;
140
-
141
- /** Normalize current history metadata without leaking wire-only aliases. */
142
- const normalizeHistorySummary = (raw: unknown): HistoryConversationSummary => {
143
- const row = (raw ?? {}) as Record<string, unknown>;
144
- const targetId = typeof row.targetId === "string" ? row.targetId : null;
145
- return {
146
- id: typeof row.id === "string" ? row.id : "",
147
- title: typeof row.title === "string" ? row.title : "",
148
- targetId,
149
- preview: typeof row.preview === "string" ? row.preview : null,
150
- messageCount: typeof row.messageCount === "number" ? row.messageCount : 0,
151
- createdAt: typeof row.createdAt === "string" ? row.createdAt : "",
152
- updatedAt: typeof row.updatedAt === "string" ? row.updatedAt : "",
153
- };
154
- };
155
-
156
129
  const identityReason = (status: HistoryIdentityStatus): string | undefined =>
157
130
  "reason" in status ? status.reason : undefined;
158
131
 
159
- const PROOF_NOT_ADMITTED_MESSAGE =
160
- "The identity proof was not admitted; account history is unavailable";
132
+
161
133
  const PROOF_REJECTED_MESSAGE = "The identity proof was rejected";
162
- const NO_VISITOR_CREDENTIAL_MESSAGE = "The request carried no visitor credential";
163
- const IDENTITY_MISMATCH_MESSAGE = "The stored visitor belongs to a different signed-in user";
164
134
 
165
135
  const isHistoryClientError = (
166
136
  error: unknown,
167
137
  code: HistoryClientErrorCode
168
138
  ): boolean => error instanceof HistoryClientError && error.code === code;
169
139
 
170
- /**
171
- * Derive a download filename for `agent_media` parts that are delivered
172
- * without one. Maps a few well-known MIME types to friendly extensions and
173
- * falls back to `attachment.<subtype>` (or just `attachment` for opaque
174
- * types like `application/octet-stream`).
175
- */
176
- function filenameFromMediaType(mediaType: string): string {
177
- // MIME types are case-insensitive (RFC 7231); compare against a lowercased
178
- // copy so callers that pass mixed casing still hit the friendly extensions.
179
- const lower = mediaType.toLowerCase();
180
- const knownExtensions: Record<string, string> = {
181
- "application/pdf": "pdf",
182
- "application/json": "json",
183
- "application/zip": "zip",
184
- "text/plain": "txt",
185
- "text/csv": "csv",
186
- "text/markdown": "md"
187
- };
188
- const ext = knownExtensions[lower];
189
- if (ext) return `attachment.${ext}`;
190
- const slash = lower.indexOf("/");
191
- if (slash > 0) {
192
- const subtype = lower.slice(slash + 1).split(";")[0]?.trim() ?? "";
193
- if (subtype && subtype !== "octet-stream" && /^[a-z0-9.+-]+$/i.test(subtype)) {
194
- return `attachment.${subtype}`;
195
- }
196
- }
197
- return "attachment";
198
- }
199
140
 
200
141
  /**
201
142
  * Check if a message has valid (non-empty) content for sending to the API.
@@ -203,6 +144,8 @@ function filenameFromMediaType(mediaType: string): string {
203
144
  *
204
145
  */
205
146
  const hasValidContent = (message: AgentWidgetMessage): boolean => {
147
+ // Display-only voice captions are never conversation.
148
+ if (message.voiceCaption) return false;
206
149
  // Check contentParts (multi-modal content)
207
150
  if (message.contentParts && message.contentParts.length > 0) {
208
151
  return true;
@@ -253,60 +196,27 @@ function getParserFromType(parserType?: "plain" | "json" | "regex-json" | "xml")
253
196
 
254
197
  export type SSEEventCallback = (eventType: string, payload: unknown) => void;
255
198
 
256
- const looksStructured = (value: string) =>
257
- value.startsWith("{") || value.startsWith("[") || value.startsWith("<");
258
-
259
- /**
260
- * Choose the best content source for sealed-segment reconciliation.
261
- * Prefers the final structured payload from step_complete when the raw
262
- * buffer is only a partial/unparseable prefix of the same structured format.
263
- */
264
- export function preferFinalStructuredContent(
265
- rawBuffer: string | undefined,
266
- finalString: string
267
- ): string {
268
- if (!rawBuffer) return finalString;
269
-
270
- const rawTrimmed = rawBuffer.trim();
271
- const finalTrimmed = finalString.trim();
272
- if (rawTrimmed.length === 0) return finalString;
273
- if (finalTrimmed.length === 0) return rawBuffer;
274
-
275
- const rawLooksStructured = looksStructured(rawTrimmed);
276
- const finalLooksStructured = looksStructured(finalTrimmed);
277
-
278
- if (!finalLooksStructured) return rawBuffer;
279
- if (!rawLooksStructured) return finalString;
280
- if (finalTrimmed === rawTrimmed) return finalString;
281
- if (finalTrimmed.startsWith(rawTrimmed)) return finalString;
282
-
283
- const rawJsonText = extractTextFromJson(rawBuffer);
284
- const finalJsonText = extractTextFromJson(finalString);
285
- if (finalJsonText !== null && rawJsonText === null) return finalString;
286
-
287
- return rawBuffer;
288
- }
289
199
 
290
200
  export class AgentWidgetClient {
291
- private readonly apiUrl: string;
292
- private readonly headers: Record<string, string>;
201
+ readonly #apiUrl: string;
202
+ readonly #headers: Record<string, string>;
293
203
  private readonly debug: boolean;
294
- private readonly createStreamParser: () => AgentWidgetStreamParser;
295
- private readonly contextProviders: AgentWidgetContextProvider[];
296
- private readonly requestMiddleware?: AgentWidgetRequestMiddleware;
297
- private readonly customFetch?: AgentWidgetCustomFetch;
298
- private readonly parseSSEEvent?: AgentWidgetSSEEventParser;
299
- private readonly getHeaders?: AgentWidgetHeadersFunction;
300
- private onSSEEvent?: SSEEventCallback;
204
+ readonly #createStreamParser: () => AgentWidgetStreamParser;
205
+ readonly #contextProviders: AgentWidgetContextProvider[];
206
+ readonly #requestMiddleware?: AgentWidgetRequestMiddleware;
207
+ readonly #customFetch?: AgentWidgetCustomFetch;
208
+ readonly #parseSSEEvent?: AgentWidgetSSEEventParser;
209
+ readonly #getHeaders?: AgentWidgetHeadersFunction;
210
+ #onSSEEvent?: SSEEventCallback;
301
211
 
302
212
  // Client token mode properties
303
213
  /**
304
214
  * Turn id of the newest client-token dispatch. A dispatch whose id no longer
305
215
  * matches is superseded, and every SSE frame it still receives is dropped
306
216
  */
307
- private currentClientTurnId: string | null = null;
217
+ #currentClientTurnId: string | null = null;
308
218
  private clientSession: ClientSession | null = null;
309
- private sessionInitPromise: Promise<ClientSession> | null = null;
219
+ #sessionInitPromise: Promise<ClientSession> | null = null;
310
220
  /**
311
221
  * Early-init latch for `warmSession()`. `false` = armed. Otherwise it holds
312
222
  * the session value (`null` or an expired session) the one early attempt was
@@ -314,7 +224,7 @@ export class AgentWidgetClient {
314
224
  * re-armed when a session is installed or cleared, so each session lifetime
315
225
  * gets one early init.
316
226
  */
317
- private sessionWarmLatch: ClientSession | null | false = false;
227
+ #sessionWarmLatch: ClientSession | null | false = false;
318
228
 
319
229
  // Diff-only / send-once WebMCP tool dispatch (client-token mode ONLY).
320
230
  // Fingerprint of the clientTools[] last *sent in full* and confirmed by a
@@ -330,32 +240,32 @@ export class AgentWidgetClient {
330
240
  // execution — so a later resume with an empty registry must still send the
331
241
  // explicit `clientTools: []` replace. Reset only by an explicit [] replace,
332
242
  // a session change, or a conversation reset.
333
- private sentNonEmptyClientToolsSessionId: string | null = null;
243
+ #sentNonEmptyClientToolsSessionId: string | null = null;
334
244
 
335
245
  // Visitor history (client-token mode ONLY). `historyUnavailable` latches for
336
246
  // the client's lifetime on a 403 `visitor_history_disabled`; `claimInFlight`
337
247
  // bounds the immediate first-conversation claim to one extra init.
338
- private historyUnavailable = false;
339
- private historyUnavailableWarned = false;
340
- private claimInFlight = false;
248
+ #historyUnavailable = false;
249
+ #historyUnavailableWarned = false;
250
+ #claimInFlight = false;
341
251
  // Evidence-based identity state. Null means "never moved off the resting
342
252
  // state", which is recomputed from config so `update()` stays honest.
343
- private historyIdentityStatus: HistoryIdentityStatus | null = null;
253
+ #historyIdentityStatus: HistoryIdentityStatus | null = null;
344
254
  // Fan-out beside the single internals callback: the Runtype history provider
345
255
  // bridges these into the generic seam. Cleared with the client instance.
346
- private historyIdentitySubscribers = new Set<
256
+ #historyIdentitySubscribers = new Set<
347
257
  (status: HistoryIdentityStatus) => void
348
258
  >();
349
- private historyAvailabilitySubscribers = new Set<(available: boolean) => void>();
259
+ #historyAvailabilitySubscribers = new Set<(available: boolean) => void>();
350
260
 
351
261
  // WebMCP: page-discovered tool consumption. The bridge runtime ships in the
352
262
  // lazy webmcp-runtime chunk; `webMcpBridge` stays null until the chunk is
353
263
  // adopted (and forever when `config.webmcp?.enabled !== true`). Dispatch
354
264
  // paths await `getWebMcpBridge()`; sync callers read the cached instance.
355
265
  private webMcpBridge: WebMcpBridge | null = null;
356
- private webMcpBridgePromise: Promise<WebMcpBridge | null> | null = null;
266
+ #webMcpBridgePromise: Promise<WebMcpBridge | null> | null = null;
357
267
  /** undefined = never set; null = explicitly cleared. Applied at adoption. */
358
- private pendingWebMcpConfirmHandler: WebMcpConfirmHandler | null | undefined;
268
+ #pendingWebMcpConfirmHandler: WebMcpConfirmHandler | null | undefined;
359
269
 
360
270
  constructor(
361
271
  private config: AgentWidgetConfig = {},
@@ -366,29 +276,29 @@ export class AgentWidgetClient {
366
276
  "[Persona] `target` is mutually exclusive with `agentId`, `flowId`, and `agent`. Set only one routing field.",
367
277
  );
368
278
  }
369
- this.apiUrl = config.apiUrl ?? DEFAULT_ENDPOINT;
370
- this.headers = {
279
+ this.#apiUrl = config.apiUrl ?? DEFAULT_ENDPOINT;
280
+ this.#headers = {
371
281
  "Content-Type": "application/json",
372
282
  "X-Persona-Version": VERSION,
373
283
  ...config.headers
374
284
  };
375
285
  this.debug = Boolean(config.debug);
376
286
  // Use custom stream parser if provided, otherwise use parserType, or fall back to plain text parser
377
- this.createStreamParser = config.streamParser ?? getParserFromType(config.parserType);
378
- this.contextProviders = config.contextProviders ?? [];
379
- this.requestMiddleware = config.requestMiddleware;
380
- this.customFetch = config.customFetch;
381
- this.parseSSEEvent = config.parseSSEEvent;
382
- this.getHeaders = config.getHeaders;
287
+ this.#createStreamParser = config.streamParser ?? getParserFromType(config.parserType);
288
+ this.#contextProviders = config.contextProviders ?? [];
289
+ this.#requestMiddleware = config.requestMiddleware;
290
+ this.#customFetch = config.customFetch;
291
+ this.#parseSSEEvent = config.parseSSEEvent;
292
+ this.#getHeaders = config.getHeaders;
383
293
  if (config.webmcp?.enabled === true) {
384
294
  // Kick the runtime chunk fetch now so the bridge is warm before the
385
295
  // first dispatch snapshot (which awaits it either way).
386
- this.webMcpBridgePromise = this.createWebMcpBridge(config.webmcp);
296
+ this.#webMcpBridgePromise = this.#createWebMcpBridge(config.webmcp);
387
297
  }
388
298
  }
389
299
 
390
300
  /** Load the lazy runtime chunk and construct the bridge with core-owned deps. */
391
- private createWebMcpBridge(
301
+ #createWebMcpBridge(
392
302
  webmcpConfig: NonNullable<AgentWidgetConfig["webmcp"]>
393
303
  ): Promise<WebMcpBridge | null> {
394
304
  return loadWebMcpRuntime()
@@ -398,8 +308,8 @@ export class AgentWidgetClient {
398
308
  getToolDisplayTitle: getWebMcpToolDisplayTitle,
399
309
  loadPolyfill: loadWebMcpPolyfillModule,
400
310
  });
401
- if (this.pendingWebMcpConfirmHandler !== undefined) {
402
- bridge.setConfirmHandler(this.pendingWebMcpConfirmHandler);
311
+ if (this.#pendingWebMcpConfirmHandler !== undefined) {
312
+ bridge.setConfirmHandler(this.#pendingWebMcpConfirmHandler);
403
313
  }
404
314
  this.webMcpBridge = bridge;
405
315
  return bridge;
@@ -407,7 +317,7 @@ export class AgentWidgetClient {
407
317
  .catch((err) => {
408
318
  // Failed chunk fetch: clear the memoized promise so the next dispatch
409
319
  // retries (the chunk loader clears its own rejection too).
410
- this.webMcpBridgePromise = null;
320
+ this.#webMcpBridgePromise = null;
411
321
  // Always surface this: a silently-absent bridge means empty tool
412
322
  // snapshots and failed webmcp resumes with no operator signal.
413
323
  console.warn("[Persona] Failed to load the WebMCP runtime chunk", err);
@@ -416,13 +326,13 @@ export class AgentWidgetClient {
416
326
  }
417
327
 
418
328
  /** Resolve the bridge, loading the runtime chunk on first use. */
419
- private getWebMcpBridge(): Promise<WebMcpBridge | null> {
329
+ #getWebMcpBridge(): Promise<WebMcpBridge | null> {
420
330
  if (this.webMcpBridge) return Promise.resolve(this.webMcpBridge);
421
331
  if (this.config.webmcp?.enabled !== true) return Promise.resolve(null);
422
- if (!this.webMcpBridgePromise) {
423
- this.webMcpBridgePromise = this.createWebMcpBridge(this.config.webmcp);
332
+ if (!this.#webMcpBridgePromise) {
333
+ this.#webMcpBridgePromise = this.#createWebMcpBridge(this.config.webmcp);
424
334
  }
425
- return this.webMcpBridgePromise;
335
+ return this.#webMcpBridgePromise;
426
336
  }
427
337
 
428
338
  /**
@@ -456,7 +366,7 @@ export class AgentWidgetClient {
456
366
  * Set callback for capturing raw SSE events
457
367
  */
458
368
  public setSSEEventCallback(callback: SSEEventCallback): void {
459
- this.onSSEEvent = callback;
369
+ this.#onSSEEvent = callback;
460
370
  }
461
371
 
462
372
  /**
@@ -466,7 +376,7 @@ export class AgentWidgetClient {
466
376
  */
467
377
  public setWebMcpConfirmHandler(handler: WebMcpConfirmHandler | null): void {
468
378
  // Queue for a bridge still in flight; apply immediately once adopted.
469
- this.pendingWebMcpConfirmHandler = handler;
379
+ this.#pendingWebMcpConfirmHandler = handler;
470
380
  this.webMcpBridge?.setConfirmHandler(handler);
471
381
  }
472
382
 
@@ -499,7 +409,7 @@ export class AgentWidgetClient {
499
409
  // have adopted yet, and returning null would misroute the call to the
500
410
  // legacy local-tool resume path.
501
411
  if (this.config.webmcp?.enabled !== true) return null;
502
- return this.getWebMcpBridge().then((bridge) =>
412
+ return this.#getWebMcpBridge().then((bridge) =>
503
413
  bridge
504
414
  ? bridge.executeToolCall(wireToolName, args, signal)
505
415
  : {
@@ -515,7 +425,7 @@ export class AgentWidgetClient {
515
425
  * Get the current SSE event callback (used to preserve across client recreation)
516
426
  */
517
427
  public getSSEEventCallback(): SSEEventCallback | undefined {
518
- return this.onSSEEvent;
428
+ return this.#onSSEEvent;
519
429
  }
520
430
 
521
431
  /**
@@ -532,7 +442,7 @@ export class AgentWidgetClient {
532
442
  * across `update()`; the `target`/explicit-field conflict is rejected in the
533
443
  * constructor, so at most one source is set here.
534
444
  */
535
- private routing(): {
445
+ #routing(): {
536
446
  agentId?: string;
537
447
  flowId?: string;
538
448
  targetPayload?: Record<string, unknown>;
@@ -551,7 +461,7 @@ export class AgentWidgetClient {
551
461
  * Check if operating in agent execution mode
552
462
  */
553
463
  public isAgentMode(): boolean {
554
- return !!(this.config.agent || this.routing().agentId);
464
+ return !!(this.config.agent || this.#routing().agentId);
555
465
  }
556
466
 
557
467
  /**
@@ -560,7 +470,7 @@ export class AgentWidgetClient {
560
470
  * pointed at the dispatch endpoint still resolves its sibling routes. Not
561
471
  * used for proxy-mode URLs, whose resume route is `${apiUrl}/resume`.
562
472
  */
563
- private clientApiBase(): string {
473
+ #clientApiBase(): string {
564
474
  return (
565
475
  this.config.apiUrl?.replace(/\/+$/, '').replace(/\/v1\/dispatch$/, '') ||
566
476
  DEFAULT_CLIENT_API_BASE
@@ -570,8 +480,8 @@ export class AgentWidgetClient {
570
480
  /**
571
481
  * Get the appropriate API URL based on mode
572
482
  */
573
- private getClientApiUrl(endpoint: 'init' | 'chat' | 'resume'): string {
574
- return `${this.clientApiBase()}/v1/client/${endpoint}`;
483
+ #getClientApiUrl(endpoint: 'init' | 'chat' | 'resume'): string {
484
+ return `${this.#clientApiBase()}/v1/client/${endpoint}`;
575
485
  }
576
486
 
577
487
  /**
@@ -600,7 +510,7 @@ export class AgentWidgetClient {
600
510
  );
601
511
  }
602
512
  const path =
603
- `${this.clientApiBase()}/v1/client/conversations/` +
513
+ `${this.#clientApiBase()}/v1/client/conversations/` +
604
514
  `${encodeURIComponent(conversationId)}/executions/` +
605
515
  `${encodeURIComponent(ctx.executionId)}/events`;
606
516
  const query = new URLSearchParams({
@@ -617,14 +527,18 @@ export class AgentWidgetClient {
617
527
  });
618
528
  if (response.status === 401 && !recovered) {
619
529
  recovered = true;
620
- session = await this.recoverFromUnauthorized(
621
- await this.readErrorCode(response),
530
+ const history = await loadClientHistory();
531
+ session = await history.recoverFromUnauthorized(
532
+ this.#historyHost(),
533
+ await history.readErrorCode(this.#historyHost(), response),
622
534
  null,
623
535
  true
624
536
  );
625
537
  continue;
626
538
  }
627
- if (!response.ok) throw await this.historyErrorFor(response, true);
539
+ if (!response.ok) {
540
+ throw await (await loadClientHistory()).historyErrorFor(this.#historyHost(), response, true);
541
+ }
628
542
  return response;
629
543
  }
630
544
  }
@@ -646,13 +560,13 @@ export class AgentWidgetClient {
646
560
  const current = this.clientSession;
647
561
  if (
648
562
  !this.isClientTokenMode() ||
649
- this.sessionInitPromise ||
563
+ this.#sessionInitPromise ||
650
564
  (current && new Date() < current.expiresAt) ||
651
- this.sessionWarmLatch === current
565
+ this.#sessionWarmLatch === current
652
566
  ) {
653
567
  return null;
654
568
  }
655
- this.sessionWarmLatch = current;
569
+ this.#sessionWarmLatch = current;
656
570
  // Swallowed on purpose: the send's own initSession() reports failures.
657
571
  return this.initSession().catch(() => null);
658
572
  }
@@ -676,19 +590,19 @@ export class AgentWidgetClient {
676
590
  }
677
591
 
678
592
  // Deduplicate concurrent init calls
679
- if (this.sessionInitPromise) {
680
- return this.sessionInitPromise;
593
+ if (this.#sessionInitPromise) {
594
+ return this.#sessionInitPromise;
681
595
  }
682
596
 
683
597
  // Callers that dedupe share this promise, so the stale check below covers
684
598
  // them too (the send reusing an early init included).
685
- const pending: Promise<ClientSession> = (this.sessionInitPromise = this._doInitSession()
599
+ const pending: Promise<ClientSession> = (this.#sessionInitPromise = this.#_doInitSession()
686
600
  .then((session) => {
687
601
  // A clear or replacement (credential change, start-new, proof re-init)
688
602
  // while this was in flight makes the result stale: never install it.
689
- if (this.sessionInitPromise !== pending) return this.initSession();
603
+ if (this.#sessionInitPromise !== pending) return this.initSession();
690
604
  this.clientSession = session;
691
- this.sessionWarmLatch = false;
605
+ this.#sessionWarmLatch = false;
692
606
  // A freshly-minted session must resend the full WebMCP tool list on its
693
607
  // next turn: drop any diff-only fingerprint cached under a prior session,
694
608
  // so we never claim "unchanged" against a session the server didn't store
@@ -699,17 +613,17 @@ export class AgentWidgetClient {
699
613
  return session;
700
614
  })
701
615
  .finally(() => {
702
- if (this.sessionInitPromise === pending) this.sessionInitPromise = null;
616
+ if (this.#sessionInitPromise === pending) this.#sessionInitPromise = null;
703
617
  }));
704
618
  return pending;
705
619
  }
706
620
 
707
621
  /** Visitor history rides on client-token init only, and latches off after a 403 degrade. */
708
- private isHistoryCapable(): boolean {
622
+ #isHistoryCapable(): boolean {
709
623
  return (
710
624
  this.config.features?.history?.enabled === true &&
711
625
  this.isClientTokenMode() &&
712
- !this.historyUnavailable
626
+ !this.#historyUnavailable
713
627
  );
714
628
  }
715
629
 
@@ -721,36 +635,36 @@ export class AgentWidgetClient {
721
635
  }
722
636
 
723
637
  /** One-way, client-lifetime latch: chat keeps working without history. */
724
- private markHistoryUnavailable(): void {
725
- if (this.historyUnavailable) return;
638
+ #markHistoryUnavailable(): void {
639
+ if (this.#historyUnavailable) return;
726
640
  // Announce before the latch flips, or the resting state already matches.
727
- this.setHistoryIdentityStatus({ state: 'unavailable', reason: 'history_disabled' });
728
- this.historyUnavailable = true;
729
- if (!this.historyUnavailableWarned && typeof console !== 'undefined') {
730
- this.historyUnavailableWarned = true;
641
+ this.#setHistoryIdentityStatus({ state: 'unavailable', reason: 'history_disabled' });
642
+ this.#historyUnavailable = true;
643
+ if (!this.#historyUnavailableWarned && typeof console !== 'undefined') {
644
+ this.#historyUnavailableWarned = true;
731
645
  // eslint-disable-next-line no-console
732
646
  console.warn(
733
647
  '[Persona] Visitor history is disabled for this surface; continuing without it.'
734
648
  );
735
649
  }
736
650
  this.historyInternals.onHistoryAvailabilityChanged?.(false);
737
- for (const subscriber of [...this.historyAvailabilitySubscribers]) {
651
+ for (const subscriber of [...this.#historyAvailabilitySubscribers]) {
738
652
  subscriber(false);
739
653
  }
740
654
  }
741
655
 
742
656
  /** Public, secret-free view of the current identity state. */
743
657
  public getHistoryIdentityStatus(): HistoryIdentityStatus {
744
- return this.historyIdentityStatus ?? this.restingIdentityStatus();
658
+ return this.#historyIdentityStatus ?? this.#restingIdentityStatus();
745
659
  }
746
660
 
747
661
  /** Deduped status notifications; carries no token, proof, or identity value. */
748
662
  public subscribeHistoryIdentityStatus(
749
663
  callback: (status: HistoryIdentityStatus) => void
750
664
  ): () => void {
751
- this.historyIdentitySubscribers.add(callback);
665
+ this.#historyIdentitySubscribers.add(callback);
752
666
  return () => {
753
- this.historyIdentitySubscribers.delete(callback);
667
+ this.#historyIdentitySubscribers.delete(callback);
754
668
  };
755
669
  }
756
670
 
@@ -758,32 +672,32 @@ export class AgentWidgetClient {
758
672
  public subscribeHistoryAvailability(
759
673
  callback: (available: boolean) => void
760
674
  ): () => void {
761
- this.historyAvailabilitySubscribers.add(callback);
762
- if (this.historyUnavailable) callback(false);
675
+ this.#historyAvailabilitySubscribers.add(callback);
676
+ if (this.#historyUnavailable) callback(false);
763
677
  return () => {
764
- this.historyAvailabilitySubscribers.delete(callback);
678
+ this.#historyAvailabilitySubscribers.delete(callback);
765
679
  };
766
680
  }
767
681
 
768
682
  /** Config-derived state before any per-operation evidence exists. */
769
- private restingIdentityStatus(): HistoryIdentityStatus {
683
+ #restingIdentityStatus(): HistoryIdentityStatus {
770
684
  if (!this.isClientTokenMode()) {
771
685
  return { state: 'unavailable', reason: 'ineligible_mode' };
772
686
  }
773
- if (this.config.features?.history?.enabled !== true || this.historyUnavailable) {
687
+ if (this.config.features?.history?.enabled !== true || this.#historyUnavailable) {
774
688
  return { state: 'unavailable', reason: 'history_disabled' };
775
689
  }
776
- return this.browserOnlyStatus();
690
+ return this.#browserOnlyStatus();
777
691
  }
778
692
 
779
693
  /** Keeps an already-observed browser_only reason; otherwise derives one. */
780
- private browserOnlyStatus(): HistoryIdentityStatus {
781
- const current = this.historyIdentityStatus;
694
+ #browserOnlyStatus(): HistoryIdentityStatus {
695
+ const current = this.#historyIdentityStatus;
782
696
  if (current?.state === 'browser_only') return current;
783
- return this.derivedBrowserOnly();
697
+ return this.#derivedBrowserOnly();
784
698
  }
785
699
 
786
- private derivedBrowserOnly(): HistoryIdentityStatus {
700
+ #derivedBrowserOnly(): HistoryIdentityStatus {
787
701
  if (this.config.features?.history?.scope === 'browser') {
788
702
  return { state: 'browser_only', reason: 'configured_browser_scope' };
789
703
  }
@@ -794,14 +708,14 @@ export class AgentWidgetClient {
794
708
  }
795
709
 
796
710
  /** Identical consecutive states are not re-announced. */
797
- private setHistoryIdentityStatus(next: HistoryIdentityStatus): void {
711
+ #setHistoryIdentityStatus(next: HistoryIdentityStatus): void {
798
712
  const current = this.getHistoryIdentityStatus();
799
713
  const unchanged =
800
714
  current.state === next.state && identityReason(current) === identityReason(next);
801
- this.historyIdentityStatus = next;
715
+ this.#historyIdentityStatus = next;
802
716
  if (unchanged) return;
803
717
  this.historyInternals.onHistoryIdentityStatusChanged?.(next);
804
- for (const subscriber of [...this.historyIdentitySubscribers]) subscriber(next);
718
+ for (const subscriber of [...this.#historyIdentitySubscribers]) subscriber(next);
805
719
  }
806
720
 
807
721
  /**
@@ -809,7 +723,7 @@ export class AgentWidgetClient {
809
723
  * `conversationId` and `sessionId` are mutually exclusive on the wire (strict
810
724
  * server union), so a resume never carries the stored session id.
811
725
  */
812
- private async createClientSession(opts: {
726
+ async #createClientSession(opts: {
813
727
  conversationId?: string;
814
728
  durableResume?: boolean;
815
729
  identityProof?: string | null;
@@ -817,7 +731,7 @@ export class AgentWidgetClient {
817
731
  omitVisitorFields?: boolean;
818
732
  signal?: AbortSignal;
819
733
  }): Promise<ClientSession> {
820
- const historyCapable = this.isHistoryCapable() && !opts.omitVisitorFields;
734
+ const historyCapable = this.#isHistoryCapable() && !opts.omitVisitorFields;
821
735
  // Recovery is negotiated independently from the history UI. New servers
822
736
  // return an explicit capability bit; old strict servers reject the
823
737
  // additive request field, which the fallback below handles once.
@@ -829,7 +743,7 @@ export class AgentWidgetClient {
829
743
  visitorToken = await this.readVisitorToken();
830
744
  }
831
745
 
832
- const routed = this.routing();
746
+ const routed = this.#routing();
833
747
  const sessionTargetId = routed.agentId ?? routed.flowId;
834
748
  const resumeConversationId =
835
749
  historyCapable || opts.durableResume ? opts.conversationId : undefined;
@@ -854,7 +768,7 @@ export class AgentWidgetClient {
854
768
  };
855
769
 
856
770
  opts.signal?.throwIfAborted();
857
- const response = await fetch(this.getClientApiUrl('init'), {
771
+ const response = await fetch(this.#getClientApiUrl('init'), {
858
772
  method: 'POST',
859
773
  headers: {
860
774
  'Content-Type': 'application/json',
@@ -867,7 +781,7 @@ export class AgentWidgetClient {
867
781
  if (!response.ok) {
868
782
  const error = await response.json().catch(() => ({ error: 'Session initialization failed' }));
869
783
  if (historyCapable && response.status === 403 && error.error === 'visitor_history_disabled') {
870
- this.markHistoryUnavailable();
784
+ this.#markHistoryUnavailable();
871
785
  // A resume must not silently degrade into some other conversation.
872
786
  if (resumeConversationId) {
873
787
  throw new HistoryClientError(
@@ -875,7 +789,7 @@ export class AgentWidgetClient {
875
789
  'Visitor history is disabled for this surface'
876
790
  );
877
791
  }
878
- return this.createClientSession({ ...opts, omitVisitorFields: true });
792
+ return this.#createClientSession({ ...opts, omitVisitorFields: true });
879
793
  }
880
794
  if (response.status === 403 && error.error === 'durable_recovery_disabled') {
881
795
  throw new HistoryClientError(
@@ -929,13 +843,13 @@ export class AgentWidgetClient {
929
843
  };
930
844
  }
931
845
 
932
- private async _doInitSession(): Promise<ClientSession> {
846
+ async #_doInitSession(): Promise<ClientSession> {
933
847
  await this.historyInternals.historyBootstrapReady;
934
848
  const previousConversationId = this.config.getStoredConversationId?.() || null;
935
849
  const durableResume =
936
850
  this.historyInternals.shouldResumeDurableConversation?.() === true;
937
- if (!this.isHistoryCapable() && !durableResume) {
938
- return this.ordinaryInit(previousConversationId, false);
851
+ if (!this.#isHistoryCapable() && !durableResume) {
852
+ return this.#ordinaryInit(previousConversationId, false);
939
853
  }
940
854
  const storedToken = await this.readVisitorToken();
941
855
 
@@ -943,14 +857,14 @@ export class AgentWidgetClient {
943
857
  // session id, so benign expiry never forks or wipes the conversation.
944
858
  if (previousConversationId && storedToken) {
945
859
  try {
946
- const resumed = await this.createClientSession({
860
+ const resumed = await this.#createClientSession({
947
861
  conversationId: previousConversationId,
948
862
  durableResume,
949
863
  });
950
864
  if (durableResume && resumed.durableRecovery?.enabled !== true) {
951
865
  this.historyInternals.setStoredResumableHandle?.(null);
952
866
  }
953
- return this.finishInit(resumed, previousConversationId, false);
867
+ return this.#finishInit(resumed, previousConversationId, false);
954
868
  } catch (error) {
955
869
  if (
956
870
  !isHistoryClientError(error, 'not_found') &&
@@ -963,11 +877,11 @@ export class AgentWidgetClient {
963
877
  this.historyInternals.setStoredResumableHandle?.(null);
964
878
  }
965
879
  // Record gone or credential dead: exactly one ordinary fallback, never a loop.
966
- return this.ordinaryInit(previousConversationId, true);
880
+ return this.#ordinaryInit(previousConversationId, true);
967
881
  }
968
882
  }
969
883
 
970
- return this.ordinaryInit(previousConversationId, false);
884
+ return this.#ordinaryInit(previousConversationId, false);
971
885
  }
972
886
 
973
887
  /**
@@ -975,27 +889,27 @@ export class AgentWidgetClient {
975
889
  * cross-tab first-init lock: a waiter re-reads inside the lock and joins the
976
890
  * winner's visitor instead of minting a second one.
977
891
  */
978
- private async ordinaryInit(
892
+ async #ordinaryInit(
979
893
  previousConversationId: string | null,
980
894
  continuityBroken: boolean
981
895
  ): Promise<ClientSession> {
982
896
  const store = this.historyInternals.visitorStore;
983
897
  if (store && !(await this.readVisitorToken())) {
984
898
  return store.withFirstInitLock(() =>
985
- this.doOrdinaryInit(previousConversationId, continuityBroken)
899
+ this.#doOrdinaryInit(previousConversationId, continuityBroken)
986
900
  );
987
901
  }
988
- return this.doOrdinaryInit(previousConversationId, continuityBroken);
902
+ return this.#doOrdinaryInit(previousConversationId, continuityBroken);
989
903
  }
990
904
 
991
- private async doOrdinaryInit(
905
+ async #doOrdinaryInit(
992
906
  previousConversationId: string | null,
993
907
  continuityBroken: boolean
994
908
  ): Promise<ClientSession> {
995
909
  const storedSessionId = this.config.getStoredSessionId?.() || null;
996
- const first = await this.createClientSession({ storedSessionId });
997
- const session = await this.claimFirstConversation(first);
998
- return this.finishInit(session, previousConversationId, continuityBroken);
910
+ const first = await this.#createClientSession({ storedSessionId });
911
+ const session = await this.#claimFirstConversation(first);
912
+ return this.#finishInit(session, previousConversationId, continuityBroken);
999
913
  }
1000
914
 
1001
915
  /**
@@ -1003,17 +917,17 @@ export class AgentWidgetClient {
1003
917
  * persists any replacement token (inside `createClientSession`), and installs
1004
918
  * the result as the live session.
1005
919
  */
1006
- private async reinitWithProof(proof: string): Promise<ClientSession> {
920
+ async #reinitWithProof(proof: string): Promise<ClientSession> {
1007
921
  const storedSessionId = this.config.getStoredSessionId?.() || null;
1008
922
  const previousConversationId = this.config.getStoredConversationId?.() || null;
1009
- const session = await this.createClientSession({
923
+ const session = await this.#createClientSession({
1010
924
  storedSessionId,
1011
925
  identityProof: proof,
1012
926
  });
1013
- const installed = this.finishInit(session, previousConversationId, false);
927
+ const installed = this.#finishInit(session, previousConversationId, false);
1014
928
  this.clientSession = installed;
1015
- this.sessionInitPromise = null;
1016
- this.sessionWarmLatch = false;
929
+ this.#sessionInitPromise = null;
930
+ this.#sessionWarmLatch = false;
1017
931
  this.resetClientToolsFingerprint();
1018
932
  return installed;
1019
933
  }
@@ -1022,22 +936,22 @@ export class AgentWidgetClient {
1022
936
  * A minted visitor means the record this session just created is still
1023
937
  * unowned; one immediate re-init with `{visitorToken, sessionId}` claims it.
1024
938
  */
1025
- private async claimFirstConversation(first: ClientSession): Promise<ClientSession> {
939
+ async #claimFirstConversation(first: ClientSession): Promise<ClientSession> {
1026
940
  if (
1027
941
  !first.visitor?.token ||
1028
- this.claimInFlight ||
1029
- (!this.isHistoryCapable() && first.durableRecovery?.enabled !== true)
942
+ this.#claimInFlight ||
943
+ (!this.#isHistoryCapable() && first.durableRecovery?.enabled !== true)
1030
944
  ) {
1031
945
  return first;
1032
946
  }
1033
- this.claimInFlight = true;
947
+ this.#claimInFlight = true;
1034
948
  try {
1035
- return await this.createClientSession({ storedSessionId: first.sessionId });
949
+ return await this.#createClientSession({ storedSessionId: first.sessionId });
1036
950
  } catch {
1037
951
  // Non-fatal: the next page load claims through the normal backend path.
1038
952
  return first;
1039
953
  } finally {
1040
- this.claimInFlight = false;
954
+ this.#claimInFlight = false;
1041
955
  }
1042
956
  }
1043
957
 
@@ -1045,7 +959,7 @@ export class AgentWidgetClient {
1045
959
  * Continuity guard + id persistence. A different record than the persisted
1046
960
  * one is a privacy transition, announced before the new id is written.
1047
961
  */
1048
- private finishInit(
962
+ #finishInit(
1049
963
  session: ClientSession,
1050
964
  previousConversationId: string | null,
1051
965
  continuityBroken: boolean
@@ -1072,7 +986,7 @@ export class AgentWidgetClient {
1072
986
  }
1073
987
 
1074
988
  /** Wrap an uncached init so nothing installs until the winner commits. */
1075
- private prepared(session: ClientSession): PreparedClientSession {
989
+ #prepared(session: ClientSession): PreparedClientSession {
1076
990
  let settled = false;
1077
991
  return {
1078
992
  session,
@@ -1080,7 +994,7 @@ export class AgentWidgetClient {
1080
994
  if (settled) return;
1081
995
  settled = true;
1082
996
  this.clientSession = session;
1083
- this.sessionWarmLatch = false;
997
+ this.#sessionWarmLatch = false;
1084
998
  this.resetClientToolsFingerprint();
1085
999
  },
1086
1000
  discard: () => {
@@ -1101,7 +1015,7 @@ export class AgentWidgetClient {
1101
1015
  if (!this.isClientTokenMode()) {
1102
1016
  throw new Error('prepareConversationSession() only available in client token mode');
1103
1017
  }
1104
- if (!this.isHistoryCapable()) {
1018
+ if (!this.#isHistoryCapable()) {
1105
1019
  throw new HistoryClientError(
1106
1020
  'history_disabled',
1107
1021
  'Visitor history is disabled for this surface'
@@ -1116,11 +1030,11 @@ export class AgentWidgetClient {
1116
1030
  'Reopening a conversation requires a stored visitor token or an identity proof'
1117
1031
  );
1118
1032
  }
1119
- const session = await this.createClientSession({
1033
+ const session = await this.#createClientSession({
1120
1034
  conversationId,
1121
1035
  identityProof: proof,
1122
1036
  });
1123
- return this.prepared(session);
1037
+ return this.#prepared(session);
1124
1038
  }
1125
1039
 
1126
1040
  /** Transactional new conversation: no stored session id, no conversation id. */
@@ -1129,8 +1043,8 @@ export class AgentWidgetClient {
1129
1043
  throw new Error('prepareNewConversationSession() only available in client token mode');
1130
1044
  }
1131
1045
  await this.historyInternals.historyBootstrapReady;
1132
- const session = await this.createClientSession({});
1133
- return this.prepared(session);
1046
+ const session = await this.#createClientSession({});
1047
+ return this.#prepared(session);
1134
1048
  }
1135
1049
 
1136
1050
  /**
@@ -1140,8 +1054,8 @@ export class AgentWidgetClient {
1140
1054
  */
1141
1055
  public handleExternalCredentialChange(): void {
1142
1056
  this.clearClientSession();
1143
- if (this.isHistoryCapable()) {
1144
- this.setHistoryIdentityStatus(this.derivedBrowserOnly());
1057
+ if (this.#isHistoryCapable()) {
1058
+ this.#setHistoryIdentityStatus(this.#derivedBrowserOnly());
1145
1059
  }
1146
1060
  }
1147
1061
 
@@ -1150,8 +1064,8 @@ export class AgentWidgetClient {
1150
1064
  */
1151
1065
  public clearClientSession(): void {
1152
1066
  this.clientSession = null;
1153
- this.sessionInitPromise = null;
1154
- this.sessionWarmLatch = false;
1067
+ this.#sessionInitPromise = null;
1068
+ this.#sessionWarmLatch = false;
1155
1069
  this.resetClientToolsFingerprint();
1156
1070
  }
1157
1071
 
@@ -1163,14 +1077,14 @@ export class AgentWidgetClient {
1163
1077
  public resetClientToolsFingerprint(): void {
1164
1078
  this.lastSentClientToolsFingerprint = null;
1165
1079
  this.clientToolsFingerprintSessionId = null;
1166
- this.sentNonEmptyClientToolsSessionId = null;
1080
+ this.#sentNonEmptyClientToolsSessionId = null;
1167
1081
  }
1168
1082
 
1169
1083
  /**
1170
1084
  * Get the feedback API URL
1171
1085
  */
1172
- private getFeedbackApiUrl(): string {
1173
- return `${this.clientApiBase()}/v1/client/feedback`;
1086
+ #getFeedbackApiUrl(): string {
1087
+ return `${this.#clientApiBase()}/v1/client/feedback`;
1174
1088
  }
1175
1089
 
1176
1090
  /**
@@ -1248,7 +1162,7 @@ export class AgentWidgetClient {
1248
1162
  ...(this.config.clientToken && { token: this.config.clientToken }),
1249
1163
  };
1250
1164
 
1251
- const response = await fetch(this.getFeedbackApiUrl(), {
1165
+ const response = await fetch(this.#getFeedbackApiUrl(), {
1252
1166
  method: 'POST',
1253
1167
  headers: {
1254
1168
  'Content-Type': 'application/json',
@@ -1262,7 +1176,7 @@ export class AgentWidgetClient {
1262
1176
 
1263
1177
  if (response.status === 401) {
1264
1178
  this.clientSession = null;
1265
- this.sessionWarmLatch = false;
1179
+ this.#sessionWarmLatch = false;
1266
1180
  this.config.onSessionExpired?.();
1267
1181
  throw new Error('Session expired. Please refresh to continue.');
1268
1182
  }
@@ -1330,32 +1244,11 @@ export class AgentWidgetClient {
1330
1244
  }
1331
1245
 
1332
1246
  // ==========================================================================
1333
- // Visitor conversation history REST (client token mode only)
1247
+ // Visitor conversation history REST (client token mode only). The bodies
1248
+ // live in `client-history.ts` (lazy `client-history.js` chunk on the CDN).
1334
1249
  // ==========================================================================
1335
1250
 
1336
- /** `/v1/client/<path>` with a query string; credentials ride in headers. */
1337
- private historyUrl(
1338
- path: string,
1339
- query: Record<string, string | number | undefined>
1340
- ): string {
1341
- const params = new URLSearchParams();
1342
- for (const [key, value] of Object.entries(query)) {
1343
- if (value === undefined || value === '') continue;
1344
- params.set(key, String(value));
1345
- }
1346
- const search = params.toString();
1347
- return `${this.clientApiBase()}/v1/client/${path}${search ? `?${search}` : ''}`;
1348
- }
1349
-
1350
- /** Explicit per-operation scope wins; otherwise config, then evidence. */
1351
- private resolveHistoryScope(requested?: HistoryScope): HistoryScope {
1352
- if (requested) return requested;
1353
- const configured = this.config.features?.history?.scope;
1354
- if (configured) return configured;
1355
- return this.config.getIdentityProof ? 'verified-user' : 'browser';
1356
- }
1357
-
1358
- private async resolveChatIdentityProof(signal?: AbortSignal): Promise<ClientChatRequest['identityProof']> {
1251
+ async #resolveChatIdentityProof(signal?: AbortSignal): Promise<ClientChatRequest['identityProof']> {
1359
1252
  signal?.throwIfAborted();
1360
1253
  const provider = this.config.identityProvider;
1361
1254
  if (provider === undefined) return undefined;
@@ -1389,317 +1282,58 @@ export class AgentWidgetClient {
1389
1282
  return { provider, token };
1390
1283
  }
1391
1284
 
1392
- /**
1393
- * Resolve the proof for one logical request. `null` is an intentional
1394
- * browser-scope fallback only while the visitor has never been bound.
1395
- */
1396
- private async resolveIdentityProof(
1397
- session: ClientSession,
1398
- track: boolean
1399
- ): Promise<string | null> {
1400
- const provider = this.config.getIdentityProof;
1401
- if (!provider) {
1402
- if (track) this.setHistoryIdentityStatus(this.browserOnlyStatus());
1403
- return null;
1404
- }
1405
- if (track) this.setHistoryIdentityStatus({ state: 'verifying' });
1406
- let proof: string | null;
1407
- try {
1408
- proof = (await provider()) ?? null;
1409
- } catch {
1410
- if (track) this.setHistoryIdentityStatus({ state: 'identity_provider_failed' });
1411
- throw new HistoryClientError(
1412
- 'identity_provider_failed',
1413
- 'The identity proof provider failed'
1414
- );
1415
- }
1416
- if (proof) return proof;
1417
- if (session.visitor?.endUserId != null) {
1418
- // Never downgrade a bound visitor into its own browser scope.
1419
- if (track) {
1420
- this.setHistoryIdentityStatus({
1421
- state: 'authentication_required',
1422
- reason: 'proof_unavailable_after_binding',
1423
- });
1424
- }
1425
- throw new HistoryClientError(
1426
- 'authentication_required',
1427
- 'This browser is bound to a signed-in user and needs a fresh identity proof'
1428
- );
1429
- }
1430
- if (track) {
1431
- this.setHistoryIdentityStatus({
1432
- state: 'browser_only',
1433
- reason: 'proof_unavailable_before_binding',
1434
- });
1435
- }
1436
- return null;
1437
- }
1438
-
1439
- /** Bind the visitor before the first verified request; admitted proofs only. */
1440
- private async bindIdentity(
1441
- session: ClientSession,
1442
- proof: string,
1443
- track: boolean
1444
- ): Promise<ClientSession> {
1445
- if (session.visitor?.endUserId != null) return session;
1446
- const bound = await this.reinitWithProof(proof);
1447
- const admitted =
1448
- bound.visitor?.identityStatus === 'admitted' && bound.visitor?.endUserId != null;
1449
- if (!admitted) {
1450
- // "ignored", or a pre-acknowledgement server that left endUserId null.
1451
- if (track) {
1452
- this.setHistoryIdentityStatus({
1453
- state: 'configuration_error',
1454
- reason: 'proof_not_admitted',
1455
- });
1456
- }
1457
- throw new HistoryClientError(
1458
- 'proof_not_admitted',
1459
- PROOF_NOT_ADMITTED_MESSAGE
1460
- );
1461
- }
1462
- return bound;
1463
- }
1464
-
1465
- private async readErrorCode(response: Response): Promise<string> {
1466
- const body = (await response.json().catch(() => ({}))) as Record<string, unknown>;
1467
- if (typeof body.error === 'string') return body.error;
1468
- if (typeof body.message === 'string') return body.message;
1469
- return '';
1470
- }
1471
-
1472
- /**
1473
- * Selective one-shot 401 recovery. Returns the session to retry under;
1474
- * anything unrecoverable throws typed and is never retried.
1475
- */
1476
- private async recoverFromUnauthorized(
1477
- reason: string,
1478
- proof: string | null,
1479
- track: boolean
1480
- ): Promise<ClientSession> {
1481
- const text = reason.toLowerCase();
1482
- if (text.includes('invalid_identity_proof')) {
1483
- if (track) {
1484
- this.setHistoryIdentityStatus({
1485
- state: 'authentication_required',
1486
- reason: 'invalid_identity_proof',
1487
- });
1488
- }
1489
- throw new HistoryClientError('invalid_identity_proof', PROOF_REJECTED_MESSAGE);
1490
- }
1491
- if (text.includes('visitor token required')) {
1492
- throw new HistoryClientError('visitor_token_missing', NO_VISITOR_CREDENTIAL_MESSAGE);
1493
- }
1494
- if (text.includes('visitor_identity_mismatch')) {
1495
- if (!proof) {
1496
- if (track) {
1497
- this.setHistoryIdentityStatus({
1498
- state: 'authentication_required',
1499
- reason: 'proof_unavailable_after_binding',
1500
- });
1501
- }
1502
- throw new HistoryClientError('visitor_identity_mismatch', IDENTITY_MISMATCH_MESSAGE);
1503
- }
1504
- // Same proof: binds this visitor, or gives a different person a clean one.
1505
- return this.reinitWithProof(proof);
1506
- }
1507
- if (text.includes('expired') || text.includes('not found')) {
1508
- this.clearClientSession();
1509
- return this.initSession();
1510
- }
1511
- throw new HistoryClientError('unauthorized', reason || 'History request was not authorized');
1512
- }
1513
-
1514
- private async historyErrorFor(
1515
- response: Response,
1516
- track: boolean
1517
- ): Promise<HistoryClientError> {
1518
- const code = await this.readErrorCode(response);
1519
- if (response.status === 404) {
1520
- return new HistoryClientError('not_found', 'Conversation not found');
1521
- }
1522
- if (response.status === 429) {
1523
- const header = Number.parseInt(response.headers.get('Retry-After') ?? '', 10);
1524
- return new HistoryClientError('rate_limited', 'Too many history requests', {
1525
- ...(Number.isFinite(header) ? { retryAfterSeconds: header } : {}),
1526
- });
1527
- }
1528
- if (response.status === 503 && code.includes('identity_proof_not_admitted')) {
1529
- if (track) {
1530
- this.setHistoryIdentityStatus({
1531
- state: 'configuration_error',
1532
- reason: 'proof_not_admitted',
1533
- });
1534
- }
1535
- return new HistoryClientError(
1536
- 'proof_not_admitted',
1537
- PROOF_NOT_ADMITTED_MESSAGE
1538
- );
1539
- }
1540
- if (response.status === 401) {
1541
- const text = code.toLowerCase();
1542
- if (text.includes('invalid_identity_proof')) {
1543
- if (track) {
1544
- this.setHistoryIdentityStatus({
1545
- state: 'authentication_required',
1546
- reason: 'invalid_identity_proof',
1547
- });
1548
- }
1549
- return new HistoryClientError('invalid_identity_proof', PROOF_REJECTED_MESSAGE);
1550
- }
1551
- if (text.includes('visitor token required')) {
1552
- return new HistoryClientError('visitor_token_missing', NO_VISITOR_CREDENTIAL_MESSAGE);
1553
- }
1554
- if (text.includes('visitor_identity_mismatch')) {
1555
- return new HistoryClientError('visitor_identity_mismatch', IDENTITY_MISMATCH_MESSAGE);
1556
- }
1557
- return new HistoryClientError('unauthorized', code || 'History request was not authorized');
1558
- }
1559
- return new HistoryClientError(
1560
- 'request_failed',
1561
- code || `History request failed (${response.status})`
1562
- );
1563
- }
1564
-
1565
- /**
1566
- * Validate `X-History-Identity-Status` before any body is committed: the
1567
- * per-operation acknowledgement, not a prior init, is what proves scope.
1568
- */
1569
- private commitIdentityAcknowledgement(
1570
- header: string | null,
1571
- proofSent: boolean,
1572
- track: boolean
1573
- ): void {
1574
- const value = header?.toLowerCase() ?? null;
1575
- if (proofSent) {
1576
- if (value === 'admitted') {
1577
- if (track) this.setHistoryIdentityStatus({ state: 'verified' });
1578
- return;
1579
- }
1580
- if (track) {
1581
- this.setHistoryIdentityStatus({
1582
- state: 'configuration_error',
1583
- reason: 'proof_not_admitted',
1584
- });
1585
- }
1586
- if (value === 'ignored') {
1587
- // The gate-off path must fail before I/O; a 2xx here is a broken server.
1588
- throw new HistoryClientError(
1589
- 'identity_contract_violation',
1590
- 'Server reported an ignored identity proof on a success response'
1591
- );
1592
- }
1593
- throw new HistoryClientError(
1594
- 'proof_not_admitted',
1595
- 'The response did not acknowledge the identity proof this request sent'
1596
- );
1597
- }
1598
- if (value === 'admitted') {
1599
- throw new HistoryClientError(
1600
- 'identity_contract_violation',
1601
- 'Server admitted an identity proof that was never sent'
1602
- );
1603
- }
1604
- // "not_provided", a missing header (rolling deploy), or an unknown value:
1605
- // no verified claim was made either way.
1606
- if (track) this.setHistoryIdentityStatus(this.browserOnlyStatus());
1607
- }
1608
-
1609
- /**
1610
- * Shared history transport: live session, `sessionId` query param,
1611
- * `X-Visitor-Token` header, one resolved proof, one-shot 401 recovery, and a
1612
- * credential-revision guard that discards a response the store outran.
1613
- */
1614
- private async historyFetch<T>(
1615
- path: string,
1616
- opts: {
1617
- method: 'GET' | 'POST' | 'PATCH' | 'DELETE';
1618
- query?: Record<string, string | number | undefined>;
1619
- scope?: HistoryScope;
1620
- body?: unknown;
1621
- /** Reset tolerates a missing credential; every other route requires one. */
1622
- visitorTokenOptional?: boolean;
1623
- keepalive?: boolean;
1624
- /** Transport ops validate the acknowledgement but publish no status. */
1625
- trackIdentity?: boolean;
1626
- }
1627
- ): Promise<T> {
1628
- this.assertHistoryUsable();
1629
- const track = opts.trackIdentity !== false;
1630
- let session = await this.initSession();
1631
-
1632
- // Resolved once per logical request: a recovery retry reuses the same proof
1633
- // so one action cannot switch identities midway.
1634
- let proof: string | null = null;
1635
- if (this.resolveHistoryScope(opts.scope) === 'verified-user') {
1636
- proof = await this.resolveIdentityProof(session, track);
1637
- if (proof) session = await this.bindIdentity(session, proof, track);
1638
- }
1639
-
1640
- const store = this.historyInternals.visitorStore;
1641
- let recovered = false;
1642
- for (;;) {
1643
- const capturedRevision = store?.revision() ?? 0;
1644
- const visitorToken = await this.readVisitorToken();
1645
- if (!visitorToken && !opts.visitorTokenOptional) {
1646
- throw new HistoryClientError(
1647
- 'visitor_token_missing',
1648
- 'No visitor credential is stored for this browser'
1649
- );
1650
- }
1651
- const response = await fetch(
1652
- this.historyUrl(path, { ...opts.query, sessionId: session.sessionId }),
1653
- {
1654
- method: opts.method,
1655
- headers: {
1656
- 'X-Persona-Version': VERSION,
1657
- ...(visitorToken ? { 'X-Visitor-Token': visitorToken } : {}),
1658
- ...(proof ? { 'X-Identity-Proof': proof } : {}),
1659
- ...(opts.body !== undefined ? { 'Content-Type': 'application/json' } : {}),
1660
- },
1661
- ...(opts.body !== undefined ? { body: JSON.stringify(opts.body) } : {}),
1662
- ...(opts.keepalive ? { keepalive: true } : {}),
1663
- }
1664
- );
1665
-
1666
- if (response.status === 401 && !recovered) {
1667
- recovered = true;
1668
- session = await this.recoverFromUnauthorized(
1669
- await this.readErrorCode(response),
1670
- proof,
1671
- track
1672
- );
1673
- continue;
1674
- }
1675
- if (!response.ok) {
1676
- throw await this.historyErrorFor(response, track);
1677
- }
1678
-
1679
- const acknowledgement = response.headers.get('X-History-Identity-Status');
1680
- const data = (await response.json().catch(() => undefined)) as T;
1681
- if (store && store.revision() !== capturedRevision) {
1682
- // Another tab reset or replaced the credential: this body is stale.
1683
- throw new HistoryClientError(
1684
- 'credential_changed',
1685
- 'The visitor credential changed while the request was in flight'
1686
- );
1687
- }
1688
- this.commitIdentityAcknowledgement(acknowledgement, proof !== null, track);
1689
- return data;
1690
- }
1285
+ // Live accessors for the approval / resume requests (`client-resume.ts`,
1286
+ // shipped in the lazy client-stream chunk on the CDN).
1287
+ #resumeHostCache: ClientResumeHost | null = null;
1288
+ #resumeHost(): ClientResumeHost {
1289
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
1290
+ const client = this;
1291
+ return (this.#resumeHostCache ??= {
1292
+ get config() {
1293
+ return client.config;
1294
+ },
1295
+ headers: this.#headers,
1296
+ getHeaders: this.#getHeaders,
1297
+ debug: this.debug,
1298
+ clientApiBase: () => client.#clientApiBase(),
1299
+ getClientApiUrl: (endpoint) => client.#getClientApiUrl(endpoint),
1300
+ getWebMcpBridge: () => client.#getWebMcpBridge(),
1301
+ sendWithClientToolsDiff: (sessionId, tools, doFetch, opts) =>
1302
+ client.#sendWithClientToolsDiff(sessionId, tools, doFetch, opts),
1303
+ initSession: () => client.initSession(),
1304
+ readVisitorToken: () => client.readVisitorToken(),
1305
+ isClientTokenMode: () => client.isClientTokenMode(),
1306
+ });
1691
1307
  }
1692
1308
 
1693
- private assertHistoryUsable(): void {
1694
- if (!this.isClientTokenMode()) {
1695
- throw new Error('Conversation history is only available in client token mode');
1696
- }
1697
- if (!this.isHistoryCapable()) {
1698
- throw new HistoryClientError(
1699
- 'history_disabled',
1700
- 'Visitor history is disabled for this surface'
1701
- );
1702
- }
1309
+ // Live accessors handed to the lazily loaded history REST functions
1310
+ // (`client-history.ts`); built once per client.
1311
+ #historyHostCache: ClientHistoryHost | null = null;
1312
+ #historyHost(): ClientHistoryHost {
1313
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
1314
+ const client = this;
1315
+ return (this.#historyHostCache ??= {
1316
+ HistoryClientError,
1317
+ get config() {
1318
+ return client.config;
1319
+ },
1320
+ get historyInternals() {
1321
+ return client.historyInternals;
1322
+ },
1323
+ get clientSession() {
1324
+ return client.clientSession;
1325
+ },
1326
+ clientApiBase: () => client.#clientApiBase(),
1327
+ setHistoryIdentityStatus: (next) => client.#setHistoryIdentityStatus(next),
1328
+ browserOnlyStatus: () => client.#browserOnlyStatus(),
1329
+ derivedBrowserOnly: () => client.#derivedBrowserOnly(),
1330
+ reinitWithProof: (proof) => client.#reinitWithProof(proof),
1331
+ isHistoryCapable: () => client.#isHistoryCapable(),
1332
+ clearClientSession: () => client.clearClientSession(),
1333
+ initSession: () => client.initSession(),
1334
+ readVisitorToken: () => client.readVisitorToken(),
1335
+ isClientTokenMode: () => client.isClientTokenMode(),
1336
+ });
1703
1337
  }
1704
1338
 
1705
1339
  /** One page of the visitor's conversations, newest first. */
@@ -1709,22 +1343,7 @@ export class AgentWidgetClient {
1709
1343
  targetId?: string;
1710
1344
  scope?: HistoryScope;
1711
1345
  }): Promise<HistoryConversationPage> {
1712
- const page = await this.historyFetch<{
1713
- data?: unknown[];
1714
- nextCursor?: string | null;
1715
- }>('conversations', {
1716
- method: 'GET',
1717
- query: {
1718
- ...(opts?.cursor ? { cursor: opts.cursor } : {}),
1719
- ...(opts?.limit !== undefined ? { limit: opts.limit } : {}),
1720
- ...(opts?.targetId ? { targetId: opts.targetId } : {}),
1721
- },
1722
- ...(opts?.scope ? { scope: opts.scope } : {}),
1723
- });
1724
- return {
1725
- data: (Array.isArray(page?.data) ? page.data : []).map(normalizeHistorySummary),
1726
- nextCursor: typeof page?.nextCursor === 'string' ? page.nextCursor : null,
1727
- };
1346
+ return (await loadClientHistory()).listConversations(this.#historyHost(), opts);
1728
1347
  }
1729
1348
 
1730
1349
  /**
@@ -1735,26 +1354,7 @@ export class AgentWidgetClient {
1735
1354
  conversationId: string,
1736
1355
  opts?: { messageCursor?: string; scope?: HistoryScope }
1737
1356
  ): Promise<HistoryConversationDetail> {
1738
- const detail = await this.historyFetch<Record<string, unknown>>(
1739
- `conversations/${encodeURIComponent(conversationId)}`,
1740
- {
1741
- method: 'GET',
1742
- query: {
1743
- ...(opts?.messageCursor ? { messageCursor: opts.messageCursor } : {}),
1744
- },
1745
- ...(opts?.scope ? { scope: opts.scope } : {}),
1746
- }
1747
- );
1748
- return {
1749
- summary: normalizeHistorySummary(detail),
1750
- messages: Array.isArray(detail?.messages)
1751
- ? (detail.messages as HistoryConversationDetail['messages'])
1752
- : [],
1753
- nextMessageCursor:
1754
- typeof detail?.nextMessageCursor === 'string' ? detail.nextMessageCursor : null,
1755
- conversationRevision:
1756
- typeof detail?.conversationRevision === 'string' ? detail.conversationRevision : null,
1757
- };
1357
+ return (await loadClientHistory()).getConversation(this.#historyHost(), conversationId, opts);
1758
1358
  }
1759
1359
 
1760
1360
  /**
@@ -1766,63 +1366,14 @@ export class AgentWidgetClient {
1766
1366
  conversationId: string,
1767
1367
  messages: HistoryDisplayProjection[]
1768
1368
  ): Promise<{ conversationRevision: string | null }> {
1769
- this.assertHistoryUsable();
1770
- let total = 0;
1771
- for (const message of messages) {
1772
- const size = message.displayContent.length;
1773
- if (size > DISPLAY_PROJECTION_MESSAGE_CAP) {
1774
- throw new HistoryClientError(
1775
- 'payload_too_large',
1776
- `displayContent exceeds the ${DISPLAY_PROJECTION_MESSAGE_CAP} character limit`
1777
- );
1778
- }
1779
- total += size;
1780
- }
1781
- if (total > DISPLAY_PROJECTION_BATCH_CAP) {
1782
- throw new HistoryClientError(
1783
- 'payload_too_large',
1784
- `Display projection batch exceeds the ${DISPLAY_PROJECTION_BATCH_CAP} character limit`
1785
- );
1786
- }
1787
-
1788
- await this.initSession();
1789
- const store = this.historyInternals.visitorStore;
1790
- const capturedRevision = store?.revision() ?? 0;
1791
- const result = await this.historyFetch<{ conversationRevision?: string }>(
1792
- `conversations/${encodeURIComponent(conversationId)}/display-projections`,
1793
- {
1794
- method: 'PATCH',
1795
- body: { messages },
1796
- scope: 'browser',
1797
- keepalive: total <= DISPLAY_PROJECTION_BATCH_CAP,
1798
- trackIdentity: false,
1799
- }
1800
- );
1801
- const conversationRevision =
1802
- typeof result?.conversationRevision === 'string' ? result.conversationRevision : null;
1803
- // Install only while the same record and the same credential are still live.
1804
- if (
1805
- conversationRevision &&
1806
- this.clientSession?.conversationId === conversationId &&
1807
- (store?.revision() ?? 0) === capturedRevision
1808
- ) {
1809
- this.historyInternals.setStoredConversationRevision?.(conversationRevision);
1810
- }
1811
- return { conversationRevision };
1369
+ return (await loadClientHistory()).finalizeDisplayProjections(this.#historyHost(), conversationId, messages);
1812
1370
  }
1813
1371
 
1814
1372
  public async deleteConversation(
1815
1373
  conversationId: string,
1816
1374
  opts?: { scope?: HistoryScope }
1817
1375
  ): Promise<{ deleted: number }> {
1818
- const result = await this.historyFetch<{ deleted?: number }>(
1819
- `conversations/${encodeURIComponent(conversationId)}`,
1820
- {
1821
- method: 'DELETE',
1822
- ...(opts?.scope ? { scope: opts.scope } : {}),
1823
- }
1824
- );
1825
- return { deleted: typeof result?.deleted === 'number' ? result.deleted : 0 };
1376
+ return (await loadClientHistory()).deleteConversation(this.#historyHost(), conversationId, opts);
1826
1377
  }
1827
1378
 
1828
1379
  /**
@@ -1834,14 +1385,7 @@ export class AgentWidgetClient {
1834
1385
  targetId?: string;
1835
1386
  scope?: HistoryScope;
1836
1387
  }): Promise<{ deleted: number }> {
1837
- const result = await this.historyFetch<{ deleted?: number }>('conversations', {
1838
- method: 'DELETE',
1839
- query: {
1840
- ...(opts?.targetId !== undefined ? { targetId: opts.targetId } : {}),
1841
- },
1842
- ...(opts?.scope ? { scope: opts.scope } : {}),
1843
- });
1844
- return { deleted: typeof result?.deleted === 'number' ? result.deleted : 0 };
1388
+ return (await loadClientHistory()).deleteAllConversations(this.#historyHost(), opts);
1845
1389
  }
1846
1390
 
1847
1391
  /**
@@ -1850,20 +1394,7 @@ export class AgentWidgetClient {
1850
1394
  * revocation still detaches this device.
1851
1395
  */
1852
1396
  public async resetVisitor(): Promise<{ reset: true }> {
1853
- this.assertHistoryUsable();
1854
- this.setHistoryIdentityStatus({ state: 'resetting' });
1855
- try {
1856
- await this.historyFetch<{ reset?: boolean }>('visitor/reset', {
1857
- method: 'POST',
1858
- scope: 'browser',
1859
- visitorTokenOptional: true,
1860
- });
1861
- } finally {
1862
- await this.historyInternals.visitorStore?.clear();
1863
- // Post-reset resting state: this browser is a never-bound visitor again.
1864
- this.setHistoryIdentityStatus(this.derivedBrowserOnly());
1865
- }
1866
- return { reset: true };
1397
+ return (await loadClientHistory()).resetVisitor(this.#historyHost());
1867
1398
  }
1868
1399
 
1869
1400
  /**
@@ -1871,24 +1402,27 @@ export class AgentWidgetClient {
1871
1402
  */
1872
1403
  public async dispatch(options: DispatchOptions, onEvent: SSEHandler) {
1873
1404
  options.signal?.throwIfAborted();
1405
+ // Fetch the stream processor in parallel with the request; a failure here
1406
+ // is retried by the awaited load in #streamResponse.
1407
+ loadClientStream().catch(() => {});
1874
1408
  if (this.isClientTokenMode()) {
1875
- return this.dispatchClientToken(options, onEvent);
1409
+ return this.#dispatchClientToken(options, onEvent);
1876
1410
  }
1877
1411
  if (this.isAgentMode()) {
1878
- return this.dispatchAgent(options, onEvent);
1412
+ return this.#dispatchAgent(options, onEvent);
1879
1413
  }
1880
- return this.dispatchProxy(options, onEvent);
1414
+ return this.#dispatchProxy(options, onEvent);
1881
1415
  }
1882
1416
 
1883
1417
  /**
1884
1418
  * Client token mode dispatch
1885
1419
  */
1886
- private async dispatchClientToken(options: DispatchOptions, onEvent: SSEHandler) {
1420
+ async #dispatchClientToken(options: DispatchOptions, onEvent: SSEHandler) {
1887
1421
  // Claim the turn before any await: a later dispatch that interrupts this one
1888
1422
  // takes the claim, and every event this call still receives is then stale.
1889
1423
  const turnId = generateTurnId();
1890
- this.currentClientTurnId = turnId;
1891
- const isCurrentTurn = () => this.currentClientTurnId === turnId;
1424
+ this.#currentClientTurnId = turnId;
1425
+ const isCurrentTurn = () => this.#currentClientTurnId === turnId;
1892
1426
  // Terminal frames of a superseded run must not reopen the composer or paint
1893
1427
  // into the new turn's bubble; status frames are equally misleading.
1894
1428
  const forward: SSEHandler = (event) => {
@@ -1913,8 +1447,8 @@ export class AgentWidgetClient {
1913
1447
  throw new Error('Renewing this conversation requires its visitor credential.');
1914
1448
  }
1915
1449
  assertCurrentTurn();
1916
- const renewed = await this.createClientSession({
1917
- ...(durable || this.isHistoryCapable()
1450
+ const renewed = await this.#createClientSession({
1451
+ ...(durable || this.#isHistoryCapable()
1918
1452
  ? { conversationId: previous.conversationId, durableResume: durable }
1919
1453
  : { storedSessionId: previous.sessionId }),
1920
1454
  signal: options.signal,
@@ -1926,9 +1460,9 @@ export class AgentWidgetClient {
1926
1460
  ) {
1927
1461
  throw new Error('Session renewal did not preserve this conversation.');
1928
1462
  }
1929
- session = this.finishInit(renewed, previous.conversationId ?? null, false);
1463
+ session = this.#finishInit(renewed, previous.conversationId ?? null, false);
1930
1464
  this.clientSession = session;
1931
- this.sessionWarmLatch = false;
1465
+ this.#sessionWarmLatch = false;
1932
1466
  this.resetClientToolsFingerprint();
1933
1467
  this.config.onSessionInit?.(session);
1934
1468
  };
@@ -1937,7 +1471,7 @@ export class AgentWidgetClient {
1937
1471
  }
1938
1472
 
1939
1473
  // Build the standard payload to get context/metadata from middleware
1940
- const basePayload = await this.buildPayload(options.messages);
1474
+ const basePayload = await this.#buildPayload(options.messages);
1941
1475
 
1942
1476
  // Build the chat request payload with message IDs for feedback tracking
1943
1477
  // Filter out sessionId from metadata if present (it's only for local storage)
@@ -1948,7 +1482,7 @@ export class AgentWidgetClient {
1948
1482
  : undefined;
1949
1483
 
1950
1484
  // Common (tools-independent) fields for the chat request.
1951
- const historyCapable = this.isHistoryCapable();
1485
+ const historyCapable = this.#isHistoryCapable();
1952
1486
  const baseChatRequest: Omit<ClientChatRequest, 'clientTools' | 'clientToolsFingerprint'> = {
1953
1487
  sessionId: session.sessionId,
1954
1488
  // Filter out messages with empty content to prevent validation errors
@@ -1988,9 +1522,9 @@ export class AgentWidgetClient {
1988
1522
  const recoveryVisitorToken =
1989
1523
  session.durableRecovery?.enabled === true ? await this.readVisitorToken() : null;
1990
1524
  assertCurrentTurn();
1991
- return this.sendWithClientToolsDiff(session.sessionId, basePayload.clientTools, async (toolFields) => {
1525
+ return this.#sendWithClientToolsDiff(session.sessionId, basePayload.clientTools, async (toolFields) => {
1992
1526
  assertCurrentTurn();
1993
- const identityProof = await this.resolveChatIdentityProof(options.signal);
1527
+ const identityProof = await this.#resolveChatIdentityProof(options.signal);
1994
1528
  assertCurrentTurn();
1995
1529
  const chatRequest: ClientChatRequest = {
1996
1530
  ...baseChatRequest,
@@ -2007,7 +1541,7 @@ export class AgentWidgetClient {
2007
1541
  });
2008
1542
  }
2009
1543
 
2010
- return fetch(this.getClientApiUrl('chat'), {
1544
+ return fetch(this.#getClientApiUrl('chat'), {
2011
1545
  method: 'POST',
2012
1546
  headers: {
2013
1547
  'Content-Type': 'application/json',
@@ -2090,7 +1624,7 @@ export class AgentWidgetClient {
2090
1624
 
2091
1625
  // Stream the response (same SSE handling as proxy mode)
2092
1626
  try {
2093
- await this.streamResponse(response.body, forward, options.assistantMessageId);
1627
+ await this.#streamResponse(response.body, forward, options.assistantMessageId);
2094
1628
  } finally {
2095
1629
  forward({ type: "status", status: "idle" });
2096
1630
  }
@@ -2111,10 +1645,10 @@ export class AgentWidgetClient {
2111
1645
  /**
2112
1646
  * Proxy mode dispatch (original implementation)
2113
1647
  */
2114
- private async dispatchProxy(options: DispatchOptions, onEvent: SSEHandler) {
1648
+ async #dispatchProxy(options: DispatchOptions, onEvent: SSEHandler) {
2115
1649
  onEvent({ type: "status", status: "connecting" });
2116
1650
 
2117
- const payload = await this.buildPayload(
1651
+ const payload = await this.#buildPayload(
2118
1652
  options.messages,
2119
1653
  options.composerOptions
2120
1654
  );
@@ -2125,10 +1659,10 @@ export class AgentWidgetClient {
2125
1659
  }
2126
1660
 
2127
1661
  // Build headers - merge static headers with dynamic headers if provided
2128
- let headers = { ...this.headers };
2129
- if (this.getHeaders) {
1662
+ let headers = { ...this.#headers };
1663
+ if (this.#getHeaders) {
2130
1664
  try {
2131
- const dynamicHeaders = await this.getHeaders();
1665
+ const dynamicHeaders = await this.#getHeaders();
2132
1666
  headers = { ...headers, ...dynamicHeaders };
2133
1667
  } catch (error) {
2134
1668
  if (typeof console !== "undefined") {
@@ -2140,10 +1674,10 @@ export class AgentWidgetClient {
2140
1674
 
2141
1675
  // Use customFetch if provided, otherwise use default fetch
2142
1676
  let response: Response;
2143
- if (this.customFetch) {
1677
+ if (this.#customFetch) {
2144
1678
  try {
2145
- response = await this.customFetch(
2146
- this.apiUrl,
1679
+ response = await this.#customFetch(
1680
+ this.#apiUrl,
2147
1681
  {
2148
1682
  method: "POST",
2149
1683
  headers,
@@ -2158,7 +1692,7 @@ export class AgentWidgetClient {
2158
1692
  throw err;
2159
1693
  }
2160
1694
  } else {
2161
- response = await fetch(this.apiUrl, {
1695
+ response = await fetch(this.#apiUrl, {
2162
1696
  method: "POST",
2163
1697
  headers,
2164
1698
  body: JSON.stringify(payload),
@@ -2176,7 +1710,7 @@ export class AgentWidgetClient {
2176
1710
 
2177
1711
  onEvent({ type: "status", status: "connected" });
2178
1712
  try {
2179
- await this.streamResponse(response.body, onEvent);
1713
+ await this.#streamResponse(response.body, onEvent);
2180
1714
  } finally {
2181
1715
  onEvent({ type: "status", status: "idle" });
2182
1716
  }
@@ -2185,10 +1719,10 @@ export class AgentWidgetClient {
2185
1719
  /**
2186
1720
  * Agent mode dispatch
2187
1721
  */
2188
- private async dispatchAgent(options: DispatchOptions, onEvent: SSEHandler) {
1722
+ async #dispatchAgent(options: DispatchOptions, onEvent: SSEHandler) {
2189
1723
  onEvent({ type: "status", status: "connecting" });
2190
1724
 
2191
- const payload = await this.buildAgentPayload(
1725
+ const payload = await this.#buildAgentPayload(
2192
1726
  options.messages,
2193
1727
  options.composerOptions
2194
1728
  );
@@ -2199,10 +1733,10 @@ export class AgentWidgetClient {
2199
1733
  }
2200
1734
 
2201
1735
  // Build headers - merge static headers with dynamic headers if provided
2202
- let headers = { ...this.headers };
2203
- if (this.getHeaders) {
1736
+ let headers = { ...this.#headers };
1737
+ if (this.#getHeaders) {
2204
1738
  try {
2205
- const dynamicHeaders = await this.getHeaders();
1739
+ const dynamicHeaders = await this.#getHeaders();
2206
1740
  headers = { ...headers, ...dynamicHeaders };
2207
1741
  } catch (error) {
2208
1742
  if (typeof console !== "undefined") {
@@ -2214,10 +1748,10 @@ export class AgentWidgetClient {
2214
1748
 
2215
1749
  // Use customFetch if provided, otherwise use default fetch
2216
1750
  let response: Response;
2217
- if (this.customFetch) {
1751
+ if (this.#customFetch) {
2218
1752
  try {
2219
- response = await this.customFetch(
2220
- this.apiUrl,
1753
+ response = await this.#customFetch(
1754
+ this.#apiUrl,
2221
1755
  {
2222
1756
  method: "POST",
2223
1757
  headers,
@@ -2232,7 +1766,7 @@ export class AgentWidgetClient {
2232
1766
  throw err;
2233
1767
  }
2234
1768
  } else {
2235
- response = await fetch(this.apiUrl, {
1769
+ response = await fetch(this.#apiUrl, {
2236
1770
  method: "POST",
2237
1771
  headers,
2238
1772
  body: JSON.stringify(payload),
@@ -2250,7 +1784,7 @@ export class AgentWidgetClient {
2250
1784
 
2251
1785
  onEvent({ type: "status", status: "connected" });
2252
1786
  try {
2253
- await this.streamResponse(response.body, onEvent, options.assistantMessageId);
1787
+ await this.#streamResponse(response.body, onEvent, options.assistantMessageId);
2254
1788
  } finally {
2255
1789
  onEvent({ type: "status", status: "idle" });
2256
1790
  }
@@ -2269,7 +1803,7 @@ export class AgentWidgetClient {
2269
1803
  ): Promise<void> {
2270
1804
  onEvent({ type: "status", status: "connected" });
2271
1805
  try {
2272
- await this.streamResponse(body, onEvent, assistantMessageId, seedContent);
1806
+ await this.#streamResponse(body, onEvent, assistantMessageId, seedContent);
2273
1807
  } finally {
2274
1808
  onEvent({ type: "status", status: "idle" });
2275
1809
  }
@@ -2278,31 +1812,20 @@ export class AgentWidgetClient {
2278
1812
  /**
2279
1813
  * Send an approval decision to the API and return the response
2280
1814
  * for streaming continuation.
1815
+ *
1816
+ * Routes by mode:
1817
+ * - **client-token mode**: POST `${apiBase}/v1/client/approve` with the
1818
+ * active `sessionId` and no Bearer key (runtypelabs/core#9518). Answers a
1819
+ * gate authored `tools.approval.approver: 'end-user'`; the SSE body has
1820
+ * the same shape as `/v1/client/resume`.
1821
+ * - **dispatch / proxy mode**: POST `${apiBase}/v1/agents/{agentId}/approve`
1822
+ * with the host's headers.
2281
1823
  */
2282
1824
  public async resolveApproval(
2283
1825
  approval: { agentId: string; executionId: string; approvalId: string },
2284
1826
  decision: 'approved' | 'denied'
2285
1827
  ): Promise<Response> {
2286
- const url = `${this.clientApiBase()}/v1/agents/${approval.agentId}/approve`;
2287
-
2288
- let headers: Record<string, string> = {
2289
- 'Content-Type': 'application/json',
2290
- ...this.headers
2291
- };
2292
- if (this.getHeaders) {
2293
- Object.assign(headers, await this.getHeaders());
2294
- }
2295
-
2296
- return fetch(url, {
2297
- method: 'POST',
2298
- headers,
2299
- body: JSON.stringify({
2300
- executionId: approval.executionId,
2301
- approvalId: approval.approvalId,
2302
- decision,
2303
- streamResponse: true,
2304
- }),
2305
- });
1828
+ return (await loadClientStream()).resolveApproval(this.#resumeHost(), approval, decision);
2306
1829
  }
2307
1830
 
2308
1831
  /**
@@ -2364,7 +1887,7 @@ export class AgentWidgetClient {
2364
1887
  * tools this turn", whereas on `/resume` absence means "keep the frozen
2365
1888
  * dispatch-time set".
2366
1889
  */
2367
- private async sendWithClientToolsDiff(
1890
+ async #sendWithClientToolsDiff(
2368
1891
  sessionId: string,
2369
1892
  fullClientTools: ClientToolDefinition[] | undefined,
2370
1893
  doFetch: (
@@ -2388,7 +1911,7 @@ export class AgentWidgetClient {
2388
1911
  const sendEmptyReplace =
2389
1912
  !hasClientTools &&
2390
1913
  opts?.emptyMeansReplace === true &&
2391
- this.sentNonEmptyClientToolsSessionId === sessionId;
1914
+ this.#sentNonEmptyClientToolsSessionId === sessionId;
2392
1915
 
2393
1916
  // `forceFull` flips to true after a 409 cache-miss so the single retry
2394
1917
  // resends the full list.
@@ -2427,11 +1950,11 @@ export class AgentWidgetClient {
2427
1950
  this.lastSentClientToolsFingerprint = clientToolsFingerprint ?? null;
2428
1951
  this.clientToolsFingerprintSessionId = sessionId;
2429
1952
  if (hasClientTools) {
2430
- this.sentNonEmptyClientToolsSessionId = sessionId;
1953
+ this.#sentNonEmptyClientToolsSessionId = sessionId;
2431
1954
  } else if (sendEmptyReplace) {
2432
1955
  // The explicit [] replaced the persisted set server-side; the
2433
1956
  // pending clear is done.
2434
- this.sentNonEmptyClientToolsSessionId = null;
1957
+ this.#sentNonEmptyClientToolsSessionId = null;
2435
1958
  }
2436
1959
  // Omitted-empty commits (chat with zero tools) leave the flag set:
2437
1960
  // they don't touch tools persisted for a paused execution.
@@ -2444,95 +1967,7 @@ export class AgentWidgetClient {
2444
1967
  toolOutputs: Record<string, unknown>,
2445
1968
  options?: { streamResponse?: boolean; signal?: AbortSignal; after?: string }
2446
1969
  ): Promise<Response> {
2447
- const isClientToken = this.isClientTokenMode();
2448
- const url = isClientToken
2449
- ? this.getClientApiUrl('resume')
2450
- : `${this.config.apiUrl?.replace(/\/+$/, '') || DEFAULT_CLIENT_API_BASE}/resume`;
2451
-
2452
- // The client-token resume route authenticates the session, not a Bearer
2453
- // key. A WebMCP approval can sit awaiting user input for a long time, so by
2454
- // the time we resume the original session may have expired. Re-validate (and
2455
- // silently re-init if needed) via initSession(): which returns the live
2456
- // session when `new Date() < expiresAt`, else mints a fresh one: instead of
2457
- // trusting the possibly-stale `this.clientSession`. (core#3889; BugBot
2458
- // PR #214 r3367875360.)
2459
- let resumeSessionId: string | undefined;
2460
- let resumeVisitorToken: string | null = null;
2461
- if (isClientToken) {
2462
- const session = await this.initSession();
2463
- resumeSessionId = session.sessionId;
2464
- if (session.durableRecovery?.enabled === true) {
2465
- resumeVisitorToken = await this.readVisitorToken();
2466
- }
2467
- }
2468
-
2469
- let headers: Record<string, string> = {
2470
- 'Content-Type': 'application/json',
2471
- ...(resumeVisitorToken ? { 'X-Visitor-Token': resumeVisitorToken } : {}),
2472
- ...this.headers
2473
- };
2474
- if (this.getHeaders) {
2475
- Object.assign(headers, await this.getHeaders());
2476
- }
2477
-
2478
- const body: Record<string, unknown> = {
2479
- executionId,
2480
- toolOutputs,
2481
- streamResponse: options?.streamResponse ?? true,
2482
- ...(options?.after ? { after: options.after } : {}),
2483
- };
2484
- // Thread the (refreshed) sessionId through like `/v1/client/chat` does.
2485
- if (resumeSessionId) {
2486
- body.sessionId = resumeSessionId;
2487
- }
2488
-
2489
- if (isClientToken && resumeSessionId) {
2490
- // Mid-run WebMCP tool refresh (runtypelabs/core#5361): the paused tool
2491
- // may have navigated the page, so the dispatch-time snapshot the server
2492
- // persisted can be stale. Re-snapshot the registry — the same built-in +
2493
- // bridge composition as the payload builders, so fingerprints computed
2494
- // here and on chat turns describe the same tool space — and ship it via
2495
- // the shared diff-only protocol. `emptyMeansReplace` sends an explicit
2496
- // `clientTools: []` when the registry vanished after a non-empty send,
2497
- // so the server replaces the persisted set instead of keeping it frozen.
2498
- const fullClientTools = [
2499
- ...builtInClientToolsForDispatch(this.config),
2500
- ...((await (await this.getWebMcpBridge())?.snapshotForDispatch()) ?? []),
2501
- ];
2502
- const { response, commit } = await this.sendWithClientToolsDiff(
2503
- resumeSessionId,
2504
- fullClientTools,
2505
- (toolFields) => {
2506
- const resumeRequest = { ...body, ...toolFields };
2507
- if (this.debug) {
2508
- // eslint-disable-next-line no-console
2509
- console.debug("[AgentWidgetClient] client token resume", resumeRequest);
2510
- }
2511
- return fetch(url, {
2512
- method: 'POST',
2513
- headers,
2514
- body: JSON.stringify(resumeRequest),
2515
- signal: options?.signal,
2516
- });
2517
- },
2518
- { emptyMeansReplace: true }
2519
- );
2520
- // The server stores the refreshed registry before running the
2521
- // continuation pipeline, so an OK response means it holds this set under
2522
- // this fingerprint. Mirror chat's commit-on-success discipline: a failed
2523
- // resume must not record a fingerprint the server never stored.
2524
- if (response.ok) {
2525
- commit();
2526
- }
2527
- return response;
2528
- }
2529
-
2530
- return fetch(url, {
2531
- method: 'POST',
2532
- headers,
2533
- body: JSON.stringify(body),
2534
- signal: options?.signal,
2535
- });
1970
+ return (await loadClientStream()).resumeFlow(this.#resumeHost(), executionId, toolOutputs, options);
2536
1971
  }
2537
1972
 
2538
1973
  /**
@@ -2544,7 +1979,7 @@ export class AgentWidgetClient {
2544
1979
  * The default model-visible path (`llmAppend`) already rode into the message's
2545
1980
  * `llmContent`/`contentParts`, so it needs nothing here.
2546
1981
  */
2547
- private latestMentionContext(
1982
+ #latestMentionContext(
2548
1983
  messages: AgentWidgetMessage[]
2549
1984
  ): Record<string, unknown> | null {
2550
1985
  for (let i = messages.length - 1; i >= 0; i--) {
@@ -2566,13 +2001,13 @@ export class AgentWidgetClient {
2566
2001
  * contributed, so callers can skip setting `payload.context`. Shared by the
2567
2002
  * agent and flow payload builders.
2568
2003
  */
2569
- private async buildContextAggregate(
2004
+ async #buildContextAggregate(
2570
2005
  messages: AgentWidgetMessage[]
2571
2006
  ): Promise<Record<string, unknown> | null> {
2572
2007
  const contextAggregate: Record<string, unknown> = {};
2573
- if (this.contextProviders.length) {
2008
+ if (this.#contextProviders.length) {
2574
2009
  await Promise.all(
2575
- this.contextProviders.map(async (provider) => {
2010
+ this.#contextProviders.map(async (provider) => {
2576
2011
  try {
2577
2012
  const result = await provider({
2578
2013
  messages,
@@ -2590,7 +2025,7 @@ export class AgentWidgetClient {
2590
2025
  })
2591
2026
  );
2592
2027
  }
2593
- const mentionContext = this.latestMentionContext(messages);
2028
+ const mentionContext = this.#latestMentionContext(messages);
2594
2029
  if (mentionContext) Object.assign(contextAggregate, mentionContext);
2595
2030
  return Object.keys(contextAggregate).length ? contextAggregate : null;
2596
2031
  }
@@ -2600,7 +2035,7 @@ export class AgentWidgetClient {
2600
2035
  * the wire entirely, so a widget with no picker and no modes sends exactly
2601
2036
  * what it sent before.
2602
2037
  */
2603
- private normalizeComposerOptions(
2038
+ #normalizeComposerOptions(
2604
2039
  options: ComposerOptionsPayload | undefined
2605
2040
  ): ComposerOptionsPayload | undefined {
2606
2041
  if (!options) return undefined;
@@ -2617,7 +2052,7 @@ export class AgentWidgetClient {
2617
2052
  * composer, but only for an id the host actually declared in
2618
2053
  * `composer.models`. Config is untouched; the mapping is per request.
2619
2054
  */
2620
- private applyInlineAgentModel(
2055
+ #applyInlineAgentModel(
2621
2056
  agent: AgentWidgetAgentRequestPayload["agent"],
2622
2057
  composerOptions: ComposerOptionsPayload | undefined
2623
2058
  ): AgentWidgetAgentRequestPayload["agent"] {
@@ -2629,11 +2064,11 @@ export class AgentWidgetClient {
2629
2064
  return declared ? { ...agent, model: selected } : agent;
2630
2065
  }
2631
2066
 
2632
- private async buildAgentPayload(
2067
+ async #buildAgentPayload(
2633
2068
  messages: AgentWidgetMessage[],
2634
2069
  composerOptions?: ComposerOptionsPayload
2635
2070
  ): Promise<AgentWidgetAgentRequestPayload> {
2636
- const routedAgentId = this.routing().agentId;
2071
+ const routedAgentId = this.#routing().agentId;
2637
2072
  if (!this.config.agent && !routedAgentId) {
2638
2073
  throw new Error('Agent configuration required for agent mode');
2639
2074
  }
@@ -2650,9 +2085,9 @@ export class AgentWidgetClient {
2650
2085
  : null
2651
2086
  );
2652
2087
 
2653
- const composer = this.normalizeComposerOptions(composerOptions);
2088
+ const composer = this.#normalizeComposerOptions(composerOptions);
2654
2089
  const payload: AgentWidgetAgentRequestPayload = {
2655
- agent: this.applyInlineAgentModel(
2090
+ agent: this.#applyInlineAgentModel(
2656
2091
  this.config.agent ?? { agentId: routedAgentId! },
2657
2092
  composer
2658
2093
  ),
@@ -2673,20 +2108,20 @@ export class AgentWidgetClient {
2673
2108
  // so dispatch microtask timing is unchanged.
2674
2109
  const clientTools = [
2675
2110
  ...builtInClientToolsForDispatch(this.config),
2676
- ...((await (await this.getWebMcpBridge())?.snapshotForDispatch()) ?? []),
2111
+ ...((await (await this.#getWebMcpBridge())?.snapshotForDispatch()) ?? []),
2677
2112
  ];
2678
2113
  if (clientTools.length > 0) {
2679
2114
  payload.clientTools = clientTools;
2680
2115
  }
2681
2116
 
2682
2117
  // Add context from providers + opt-in mention context.
2683
- const contextAggregate = await this.buildContextAggregate(messages);
2118
+ const contextAggregate = await this.#buildContextAggregate(messages);
2684
2119
  if (contextAggregate) payload.context = contextAggregate;
2685
2120
 
2686
2121
  return payload;
2687
2122
  }
2688
2123
 
2689
- private async buildPayload(
2124
+ async #buildPayload(
2690
2125
  messages: AgentWidgetMessage[],
2691
2126
  composerOptions?: ComposerOptionsPayload
2692
2127
  ): Promise<AgentWidgetRequestPayload> {
@@ -2699,7 +2134,7 @@ export class AgentWidgetClient {
2699
2134
  (message) => (hasValidContent(message) ? toPayloadMessage(message) : null)
2700
2135
  );
2701
2136
 
2702
- const routed = this.routing();
2137
+ const routed = this.#routing();
2703
2138
  const payload: AgentWidgetRequestPayload = {
2704
2139
  messages: normalizedMessages,
2705
2140
  ...(routed.agentId
@@ -2724,24 +2159,24 @@ export class AgentWidgetClient {
2724
2159
  // (flow-dispatch path).
2725
2160
  const clientTools = [
2726
2161
  ...builtInClientToolsForDispatch(this.config),
2727
- ...((await (await this.getWebMcpBridge())?.snapshotForDispatch()) ?? []),
2162
+ ...((await (await this.#getWebMcpBridge())?.snapshotForDispatch()) ?? []),
2728
2163
  ];
2729
2164
  if (clientTools.length > 0) {
2730
2165
  payload.clientTools = clientTools;
2731
2166
  }
2732
2167
 
2733
- const contextAggregate = await this.buildContextAggregate(messages);
2168
+ const contextAggregate = await this.#buildContextAggregate(messages);
2734
2169
  if (contextAggregate) payload.context = contextAggregate;
2735
2170
 
2736
2171
  // Its own field, never folded into `context`: a value in generic context
2737
2172
  // does not change inference, and each transport must decide explicitly.
2738
2173
  // Set before the middleware runs so hosts can read, rewrite, or drop it.
2739
- const composer = this.normalizeComposerOptions(composerOptions);
2174
+ const composer = this.#normalizeComposerOptions(composerOptions);
2740
2175
  if (composer) payload.composerOptions = composer;
2741
2176
 
2742
- if (this.requestMiddleware) {
2177
+ if (this.#requestMiddleware) {
2743
2178
  try {
2744
- const result = await this.requestMiddleware({
2179
+ const result = await this.#requestMiddleware({
2745
2180
  payload: { ...payload },
2746
2181
  config: this.config
2747
2182
  });
@@ -2774,1773 +2209,24 @@ export class AgentWidgetClient {
2774
2209
  return payload;
2775
2210
  }
2776
2211
 
2777
- /**
2778
- * Handle custom SSE event parsing via parseSSEEvent callback
2779
- * Returns true if event was handled, false otherwise
2780
- */
2781
- private async handleCustomSSEEvent(
2782
- payload: unknown,
2783
- onEvent: SSEHandler,
2784
- assistantMessageRef: { current: AgentWidgetMessage | null },
2785
- emitMessage: (msg: AgentWidgetMessage) => void,
2786
- nextSequence: () => number,
2787
- partIdState: { current: string | null }
2788
- ): Promise<boolean> {
2789
- if (!this.parseSSEEvent) return false;
2790
-
2791
- try {
2792
- const result = await this.parseSSEEvent(payload);
2793
- if (result === null) return false; // Event should be ignored
2794
-
2795
- const createNewAssistant = (partId?: string): AgentWidgetMessage => {
2796
- const msg: AgentWidgetMessage = {
2797
- id: `assistant-${Date.now()}-${Math.random().toString(16).slice(2)}`,
2798
- role: "assistant",
2799
- content: "",
2800
- createdAt: new Date().toISOString(),
2801
- streaming: true,
2802
- variant: "assistant",
2803
- sequence: nextSequence(),
2804
- ...(partId !== undefined && { partId })
2805
- };
2806
- assistantMessageRef.current = msg;
2807
- emitMessage(msg);
2808
- return msg;
2809
- };
2810
-
2811
- const ensureAssistant = (partId?: string) => {
2812
- if (assistantMessageRef.current) return assistantMessageRef.current;
2813
- return createNewAssistant(partId);
2814
- };
2815
-
2816
- if (result.text !== undefined) {
2817
- // partId-based message segmentation: when partId changes, seal current
2818
- // message and start a new one for chronological tool/text interleaving
2819
- if (result.partId !== undefined && partIdState.current !== null && result.partId !== partIdState.current) {
2820
- // Seal the current assistant message
2821
- if (assistantMessageRef.current) {
2822
- assistantMessageRef.current.streaming = false;
2823
- emitMessage(assistantMessageRef.current);
2824
- }
2825
- // Create a new assistant message for the new text segment
2826
- createNewAssistant(result.partId);
2827
- }
2828
-
2829
- // Update partId tracking (only when partId is provided: backward compatible)
2830
- if (result.partId !== undefined) {
2831
- partIdState.current = result.partId;
2832
- }
2833
-
2834
- const assistant = ensureAssistant(result.partId);
2835
- // Tag the message with partId if present and not already set
2836
- if (result.partId !== undefined && !assistant.partId) {
2837
- assistant.partId = result.partId;
2838
- }
2839
- assistant.content += result.text;
2840
- emitMessage(assistant);
2841
- }
2842
-
2843
- if (result.done) {
2844
- if (assistantMessageRef.current) {
2845
- assistantMessageRef.current.streaming = false;
2846
- emitMessage(assistantMessageRef.current);
2847
- }
2848
- partIdState.current = null;
2849
- onEvent({ type: "status", status: "idle" });
2850
- }
2851
-
2852
- if (result.error) {
2853
- partIdState.current = null;
2854
- onEvent({
2855
- type: "error",
2856
- error: new Error(result.error)
2857
- });
2858
- }
2859
-
2860
- return true; // Event was handled
2861
- } catch (error) {
2862
- if (typeof console !== "undefined") {
2863
- // eslint-disable-next-line no-console
2864
- console.error("[AgentWidget] parseSSEEvent error:", error);
2865
- }
2866
- return false;
2867
- }
2868
- }
2869
-
2870
- private async streamResponse(
2212
+ async #streamResponse(
2871
2213
  body: ReadableStream<Uint8Array>,
2872
2214
  onEvent: SSEHandler,
2873
2215
  assistantMessageId?: string,
2874
- // Durable reconnect: seed the assistant accumulator with the text already
2875
- // shown before the drop, so replayed post-cursor deltas APPEND to it
2876
- // instead of a fresh stream clobbering it (the replay carries only
2877
- // `seq > after`, i.e. the new deltas, not the full text).
2878
2216
  seedContent?: string
2879
2217
  ) {
2880
- const reader = body.getReader();
2881
- const decoder = new TextDecoder();
2882
- let buffer = "";
2883
-
2884
- const baseSequence = Date.now();
2885
- let sequenceCounter = 0;
2886
- const nextSequence = () => baseSequence + sequenceCounter++;
2887
-
2888
- const cloneMessage = (msg: AgentWidgetMessage): AgentWidgetMessage => {
2889
- const reasoning = msg.reasoning
2890
- ? {
2891
- ...msg.reasoning,
2892
- chunks: [...msg.reasoning.chunks]
2893
- }
2894
- : undefined;
2895
- const toolCall = msg.toolCall
2896
- ? {
2897
- ...msg.toolCall,
2898
- chunks: msg.toolCall.chunks ? [...msg.toolCall.chunks] : undefined
2899
- }
2900
- : undefined;
2901
- const tools = msg.tools
2902
- ? msg.tools.map((tool) => ({
2903
- ...tool,
2904
- chunks: tool.chunks ? [...tool.chunks] : undefined
2905
- }))
2906
- : undefined;
2907
-
2908
- return {
2909
- ...msg,
2910
- reasoning,
2911
- toolCall,
2912
- tools
2913
- };
2914
- };
2915
-
2916
- const shouldEmitMessage = (msg: AgentWidgetMessage): boolean => {
2917
- if (msg.role !== "assistant" || msg.variant) return true;
2918
-
2919
- const hasContentParts =
2920
- Array.isArray(msg.contentParts) && msg.contentParts.length > 0;
2921
- const hasRawContent =
2922
- typeof msg.rawContent === "string" && msg.rawContent.trim() !== "";
2923
- const hasVisibleText =
2924
- typeof msg.content === "string" && msg.content.trim() !== "";
2925
-
2926
- // Do not surface assistant text bubbles that only contain whitespace.
2927
- // Some providers emit newline-only text parts around a leading tool call;
2928
- // rendering those as normal messages creates an empty bubble above the
2929
- // tool card. Keep media/component/stop-reason messages renderable.
2930
- return hasVisibleText || hasContentParts || hasRawContent || Boolean(msg.stopReason);
2931
- };
2932
-
2933
- const emitMessage = (msg: AgentWidgetMessage) => {
2934
- if (!shouldEmitMessage(msg)) return;
2935
- onEvent({
2936
- type: "message",
2937
- message: cloneMessage(msg)
2938
- });
2939
- };
2940
-
2941
- let assistantMessage: AgentWidgetMessage | null = null;
2942
- // Tracks the most recently touched assistant text message for the
2943
- // current agent turn so `turn_complete.stopReason` can attach
2944
- // to the final visible text segment even after `assistantMessage`
2945
- // has been finalized at a tool-call boundary within the turn.
2946
- let lastAssistantInTurn: AgentWidgetMessage | null = null;
2947
- // Reference to track assistant message for custom event handler
2948
- const assistantMessageRef = { current: null as AgentWidgetMessage | null };
2949
- // Segmentation state for the `parseSSEEvent` extensibility callback (the
2950
- // consumer's own `partId` field) — independent of the wire.
2951
- const customParsePartId = { current: null as string | null };
2952
- // Unified text-channel block id (from `text_start`/`text_delta` `id`). Drives
2953
- // bubble-id segmentation on the wire in place of the legacy `partId`:
2954
- // a new block id means a new bubble, sealed at `text_complete`/tool boundaries.
2955
- let currentTextBlockId: string | null = null;
2956
- // Raw text accumulated for the open text block, on both the flow and agent
2957
- // paths — lets a whitespace-only flow block resolve without a stray bubble,
2958
- // and gives the structured-content parser the whole block on either path.
2959
- let pendingTextRaw = "";
2960
- // Nested flow-as-tool attribution (PR #4602): a text/reasoning block whose
2961
- // `parentToolCallId` matches a `tool_start.toolCallId` belongs to a flow
2962
- // running as that tool. Keyed by the wire block id, these route the block's
2963
- // deltas into a message tagged `agentMetadata.parentToolId` (the parent tool's
2964
- // row) instead of the top-level assistant/reasoning channel.
2965
- const nestedBlockParent = new Map<string, string>();
2966
- const nestedBlockMessages = new Map<string, AgentWidgetMessage>();
2967
- const nestedBlockRaw = new Map<string, string>();
2968
- const reasoningMessages = new Map<string, AgentWidgetMessage>();
2969
- const toolMessages = new Map<string, AgentWidgetMessage>();
2970
- const baseAssistantId = assistantMessageId;
2971
- let assistantIdConsumed = false;
2972
-
2973
- const ensureAssistantMessage = () => {
2974
- if (assistantMessage) return assistantMessage;
2975
- let id: string;
2976
- let initialContent = "";
2977
- const segment = currentTextBlockId;
2978
- if (!assistantIdConsumed && baseAssistantId) {
2979
- id = baseAssistantId;
2980
- assistantIdConsumed = true;
2981
- // First (and only) time we reuse the caller-supplied id: this is the
2982
- // bubble a durable reconnect resumes into, so continue its text.
2983
- initialContent = seedContent ?? "";
2984
- } else if (baseAssistantId && segment) {
2985
- id = `${baseAssistantId}_${segment}`;
2986
- } else {
2987
- id = `assistant-${Date.now()}-${Math.random().toString(16).slice(2)}`;
2988
- }
2989
- assistantMessage = {
2990
- id,
2991
- role: "assistant",
2992
- content: initialContent,
2993
- createdAt: new Date().toISOString(),
2994
- streaming: true,
2995
- sequence: nextSequence()
2996
- };
2997
- emitMessage(assistantMessage);
2998
- return assistantMessage;
2999
- };
3000
-
3001
- const ensureReasoningMessage = (reasoningId: string) => {
3002
- const existing = reasoningMessages.get(reasoningId);
3003
- if (existing) {
3004
- return existing;
3005
- }
3006
-
3007
- const message: AgentWidgetMessage = {
3008
- id: `reason-${reasoningId}`,
3009
- role: "assistant",
3010
- content: "",
3011
- createdAt: new Date().toISOString(),
3012
- streaming: true,
3013
- variant: "reasoning",
3014
- sequence: nextSequence(),
3015
- reasoning: {
3016
- id: reasoningId,
3017
- status: "streaming",
3018
- chunks: []
3019
- }
3020
- };
3021
-
3022
- reasoningMessages.set(reasoningId, message);
3023
- emitMessage(message);
3024
- return message;
3025
- };
3026
-
3027
- // Track tool call IDs for artifact emit tools so we can suppress their UI
3028
- const artifactToolCallIds = new Set<string>();
3029
- // Track artifact block messages (reference card or inline block) so we can
3030
- // update them on artifact_complete
3031
- const artifactCardMessages = new Map<string, AgentWidgetMessage>();
3032
- // Track artifact IDs that already have a reference card
3033
- const artifactIdsWithCards = new Set<string>();
3034
- // Accumulate artifact markdown content (and component props) for embedding
3035
- // in block props on complete. `props` accumulates across `artifact_update`
3036
- // events so an inline component block hydrates with its real props after a
3037
- // refresh (the session artifact registry, which also tracks them live, is
3038
- // not persisted).
3039
- const artifactContent = new Map<
3040
- string,
2218
+ const { streamResponse } = getClientStreamSync() ?? (await loadClientStream());
2219
+ return streamResponse(
3041
2220
  {
3042
- markdown: string;
3043
- title?: string;
3044
- file?: PersonaArtifactFileMeta;
3045
- props?: Record<string, unknown>;
3046
- }
3047
- >();
3048
- const isArtifactEmitToolName = (name: string | undefined): boolean => {
3049
- if (!name) return false;
3050
- const normalized = name.replace(/_+/g, "_").replace(/^_|_$/g, "");
3051
- return normalized === "emit_artifact_markdown" || normalized === "emit_artifact_component";
3052
- };
3053
-
3054
- const ensureToolMessage = (toolId: string) => {
3055
- const existing = toolMessages.get(toolId);
3056
- if (existing) {
3057
- return existing;
3058
- }
3059
-
3060
- const message: AgentWidgetMessage = {
3061
- id: `tool-${toolId}`,
3062
- role: "assistant",
3063
- content: "",
3064
- createdAt: new Date().toISOString(),
3065
- streaming: true,
3066
- variant: "tool",
3067
- sequence: nextSequence(),
3068
- toolCall: {
3069
- id: toolId,
3070
- status: "pending"
3071
- }
3072
- };
3073
-
3074
- toolMessages.set(toolId, message);
3075
- emitMessage(message);
3076
- return message;
3077
- };
3078
-
3079
- const resolveTimestamp = (value: unknown) => {
3080
- if (typeof value === "number" && Number.isFinite(value)) {
3081
- return value;
3082
- }
3083
- if (typeof value === "string") {
3084
- const parsed = Number(value);
3085
- if (!Number.isNaN(parsed) && Number.isFinite(parsed)) {
3086
- return parsed;
3087
- }
3088
- const dateParsed = Date.parse(value);
3089
- if (!Number.isNaN(dateParsed)) {
3090
- return dateParsed;
3091
- }
3092
- }
3093
- return Date.now();
3094
- };
3095
-
3096
- const ensureStringContent = (value: unknown): string => {
3097
- if (typeof value === "string") {
3098
- return value;
3099
- }
3100
- if (value === null || value === undefined) {
3101
- return "";
3102
- }
3103
- // Convert objects/arrays to JSON string
3104
- try {
3105
- return JSON.stringify(value);
3106
- } catch {
3107
- return String(value);
3108
- }
3109
- };
3110
-
3111
- // Maintain stateful stream parsers per message for incremental parsing
3112
- const streamParsers = new Map<string, AgentWidgetStreamParser>();
3113
- // Track accumulated raw content for structured formats (JSON, XML, etc.)
3114
- const rawContentBuffers = new Map<string, string>();
3115
- /** Reconcile a sealed text block with its authoritative structured response. */
3116
- const reconcileSealedAssistantWithFinalResponse = (
3117
- msg: AgentWidgetMessage,
3118
- finalContent: unknown
3119
- ) => {
3120
- const finalString = ensureStringContent(finalContent);
3121
- const rawBuffer = rawContentBuffers.get(msg.id);
3122
- const contentToProcess = preferFinalStructuredContent(rawBuffer, finalString);
3123
- msg.rawContent = contentToProcess;
3124
- const parser = streamParsers.get(msg.id);
3125
-
3126
- const mergeIfBetter = (mergedDisplay: string) => {
3127
- const cur = msg.content ?? "";
3128
- if (mergedDisplay.trim() === "") return;
3129
- // Only replace when empty, or when the stream left a strict prefix of the
3130
- // authoritative final (truncation). Do not use length alone: multi-segment
3131
- // flows can have a short last bubble whose content is not a prefix of the
3132
- // full step response.
3133
- if (
3134
- cur.trim().length === 0 ||
3135
- mergedDisplay.startsWith(cur) ||
3136
- mergedDisplay.trimStart().startsWith(cur.trim())
3137
- ) {
3138
- msg.content = mergedDisplay;
3139
- }
3140
- };
3141
-
3142
- const finalizeCleanup = () => {
3143
- if (parser) {
3144
- const closeResult = parser.close?.();
3145
- if (closeResult instanceof Promise) closeResult.catch(() => {});
3146
- }
3147
- streamParsers.delete(msg.id);
3148
- rawContentBuffers.delete(msg.id);
3149
- msg.streaming = false;
3150
- emitMessage(msg);
3151
- };
3152
-
3153
- if (!parser) {
3154
- mergeIfBetter(finalString);
3155
- finalizeCleanup();
3156
- return;
3157
- }
3158
-
3159
- // Prefer JSON fast path when the final payload is JSON-shaped
3160
- const extractedFromJson = extractTextFromJson(contentToProcess);
3161
- if (extractedFromJson !== null && extractedFromJson.trim() !== "") {
3162
- mergeIfBetter(extractedFromJson);
3163
- finalizeCleanup();
3164
- return;
3165
- }
3166
-
3167
- const bestDisplayText = (
3168
- result: AgentWidgetStreamParserResult | string | null
3169
- ): string => {
3170
- const text =
3171
- typeof result === "string" ? result : result?.text ?? null;
3172
- if (text !== null && text.trim() !== "") return text;
3173
- const extracted = parser.getExtractedText();
3174
- if (extracted !== null && extracted.trim() !== "") return extracted;
3175
- return finalString;
3176
- };
3177
-
3178
- let parsedResult: ReturnType<typeof parser.processChunk>;
3179
- try {
3180
- parsedResult = parser.processChunk(contentToProcess);
3181
- } catch {
3182
- mergeIfBetter(finalString);
3183
- finalizeCleanup();
3184
- return;
3185
- }
3186
-
3187
- if (parsedResult instanceof Promise) {
3188
- parsedResult
3189
- .then((result) => {
3190
- mergeIfBetter(bestDisplayText(result));
3191
- finalizeCleanup();
3192
- })
3193
- .catch(() => {
3194
- mergeIfBetter(finalString);
3195
- finalizeCleanup();
3196
- });
3197
- return;
3198
- }
3199
-
3200
- mergeIfBetter(bestDisplayText(parsedResult));
3201
- finalizeCleanup();
3202
- };
3203
-
3204
- // === Unified text channel ===
3205
- // Prompt-step and agent text both stream as `text_delta` blocks (segmented by
3206
- // `text_start`/`text_complete`) and can be structured JSON, so each block runs
3207
- // through the per-bubble structured-content parser. This is the legacy
3208
- // step_delta parser core, re-keyed from `partId` to the wire block-id bubble.
3209
- // The caller materializes the bubble lazily (whitespace-only blocks around
3210
- // tool boundaries never leave a stray bubble) and `step_complete.result.response`
3211
- // reconciles the authoritative final.
3212
- let lastSealedFlowBubble: AgentWidgetMessage | null = null;
3213
-
3214
- // Stream one accumulated chunk of block text through the parser, setting
3215
- // display `content` (extracted) + `rawContent` (raw) and emitting. Mirrors the
3216
- // legacy step_delta chunk path; plain text bypasses the structured parser.
3217
- const applyTextChunk = (
3218
- assistant: AgentWidgetMessage,
3219
- accumulatedRaw: string,
3220
- chunk: string,
3221
- chunkSeq: number | undefined
3222
- ) => {
3223
- assistant.rawContent = accumulatedRaw;
3224
- if (!streamParsers.has(assistant.id)) {
3225
- streamParsers.set(assistant.id, this.createStreamParser());
3226
- }
3227
- const parser = streamParsers.get(assistant.id)!;
3228
- const looksLikeJson =
3229
- accumulatedRaw.trim().startsWith("{") || accumulatedRaw.trim().startsWith("[");
3230
- if (looksLikeJson) {
3231
- rawContentBuffers.set(assistant.id, accumulatedRaw);
3232
- }
3233
- const isPlainTextParser = (parser as any).__isPlainTextParser === true;
3234
- if (isPlainTextParser) {
3235
- assistant.content =
3236
- chunkSeq !== undefined ? accumulatedRaw : assistant.content + chunk;
3237
- rawContentBuffers.delete(assistant.id);
3238
- streamParsers.delete(assistant.id);
3239
- assistant.rawContent = undefined;
3240
- emitMessage(assistant);
3241
- return;
3242
- }
3243
- const parsedResult = parser.processChunk(accumulatedRaw);
3244
- if (parsedResult instanceof Promise) {
3245
- parsedResult
3246
- .then((result) => {
3247
- const text = typeof result === "string" ? result : result?.text ?? null;
3248
- if (text !== null && text.trim() !== "") {
3249
- assistant.content = text;
3250
- emitMessage(assistant);
3251
- } else if (!looksLikeJson && !accumulatedRaw.trim().startsWith("<")) {
3252
- assistant.content =
3253
- chunkSeq !== undefined ? accumulatedRaw : assistant.content + chunk;
3254
- rawContentBuffers.delete(assistant.id);
3255
- streamParsers.delete(assistant.id);
3256
- assistant.rawContent = undefined;
3257
- emitMessage(assistant);
3258
- }
3259
- })
3260
- .catch(() => {
3261
- assistant.content =
3262
- chunkSeq !== undefined ? accumulatedRaw : assistant.content + chunk;
3263
- rawContentBuffers.delete(assistant.id);
3264
- streamParsers.delete(assistant.id);
3265
- assistant.rawContent = undefined;
3266
- emitMessage(assistant);
3267
- });
3268
- } else {
3269
- const text =
3270
- typeof parsedResult === "string" ? parsedResult : parsedResult?.text ?? null;
3271
- if (text !== null && text.trim() !== "") {
3272
- assistant.content = text;
3273
- emitMessage(assistant);
3274
- } else if (!looksLikeJson && !accumulatedRaw.trim().startsWith("<")) {
3275
- assistant.content =
3276
- chunkSeq !== undefined ? accumulatedRaw : assistant.content + chunk;
3277
- rawContentBuffers.delete(assistant.id);
3278
- streamParsers.delete(assistant.id);
3279
- assistant.rawContent = undefined;
3280
- emitMessage(assistant);
3281
- }
3282
- }
3283
- };
3284
-
3285
- // Seal a flow text block at `text_complete`: run final structured extraction
3286
- // off the accumulated raw buffer (U2: `text_complete.text` mirrors that raw
3287
- // buffer, so we never double-count), then finalize the bubble. The structured
3288
- // `step_complete.result.response` reconciles afterward.
3289
- const finalizeFlowTextBlock = (
3290
- assistant: AgentWidgetMessage,
3291
- finalContent?: unknown
3292
- ) => {
3293
- const effectiveFinal =
3294
- finalContent !== undefined && finalContent !== null
3295
- ? finalContent
3296
- : assistant.content;
3297
- if (
3298
- effectiveFinal === undefined ||
3299
- effectiveFinal === null ||
3300
- effectiveFinal === ""
3301
- ) {
3302
- assistant.streaming = false;
3303
- emitMessage(assistant);
3304
- return;
3305
- }
3306
- const rawBuffer = rawContentBuffers.get(assistant.id);
3307
- const contentToProcess = rawBuffer ?? ensureStringContent(effectiveFinal);
3308
- assistant.rawContent = contentToProcess;
3309
- const parser = streamParsers.get(assistant.id);
3310
- let extractedText: string | null = null;
3311
- let asyncPending = false;
3312
- if (parser) {
3313
- extractedText = parser.getExtractedText();
3314
- if (extractedText === null) {
3315
- extractedText = extractTextFromJson(contentToProcess);
3316
- }
3317
- if (extractedText === null) {
3318
- const parsedResult = parser.processChunk(contentToProcess);
3319
- if (parsedResult instanceof Promise) {
3320
- asyncPending = true;
3321
- parsedResult
3322
- .then((result) => {
3323
- const text =
3324
- typeof result === "string" ? result : result?.text ?? null;
3325
- if (text !== null) {
3326
- assistant.content = text;
3327
- assistant.streaming = false;
3328
- streamParsers.delete(assistant.id);
3329
- rawContentBuffers.delete(assistant.id);
3330
- emitMessage(assistant);
3331
- }
3332
- })
3333
- .catch(() => {});
3334
- } else {
3335
- extractedText =
3336
- typeof parsedResult === "string"
3337
- ? parsedResult
3338
- : parsedResult?.text ?? null;
3339
- }
3340
- }
3341
- }
3342
- if (!asyncPending) {
3343
- if (extractedText !== null && extractedText.trim() !== "") {
3344
- assistant.content = extractedText;
3345
- } else if (!rawContentBuffers.has(assistant.id)) {
3346
- assistant.content = ensureStringContent(effectiveFinal);
3347
- }
3348
- const parserToClose = streamParsers.get(assistant.id);
3349
- if (parserToClose) {
3350
- const closeResult = parserToClose.close?.();
3351
- if (closeResult instanceof Promise) closeResult.catch(() => {});
3352
- streamParsers.delete(assistant.id);
3353
- }
3354
- rawContentBuffers.delete(assistant.id);
3355
- assistant.streaming = false;
3356
- emitMessage(assistant);
3357
- }
3358
- };
3359
-
3360
- // Materialize (lazily) the message for a nested flow-as-tool block, tagged
3361
- // with the parent tool-call id so the UI renders it in the parent tool's row.
3362
- const ensureNestedBlockMessage = (
3363
- blockId: string,
3364
- parentToolCallId: string,
3365
- variant?: "reasoning"
3366
- ): AgentWidgetMessage => {
3367
- const existing = nestedBlockMessages.get(blockId);
3368
- if (existing) return existing;
3369
- const message: AgentWidgetMessage = {
3370
- id: `nested-${parentToolCallId}-${blockId}`,
3371
- role: "assistant",
3372
- content: "",
3373
- createdAt: new Date().toISOString(),
3374
- streaming: true,
3375
- sequence: nextSequence(),
3376
- ...(variant ? { variant } : {}),
3377
- ...(variant === "reasoning"
3378
- ? { reasoning: { id: blockId, status: "streaming", chunks: [] } }
3379
- : {}),
3380
- agentMetadata: { parentToolId: parentToolCallId },
3381
- };
3382
- nestedBlockMessages.set(blockId, message);
3383
- emitMessage(message);
3384
- return message;
3385
- };
3386
-
3387
- // Ready queue of parsed wire frames awaiting a drain. The API streams the
3388
- // unified wire vocabulary; each frame is parsed in the SSE loop
3389
- // below and rendered directly by the handler (no translation bridge), then
3390
- // pushed here. The wire stream is a single, in-order SSE connection, so
3391
- // frames drain straight through with no reordering.
3392
- const eventQueue: RuntypeExecutionStreamEvent[] = [];
3393
- // Declared here so later closures can reference it; assigned after all
3394
- // handler-scoped variables are initialised (before the SSE loop).
3395
- let drainReadyQueue: () => void;
3396
- // Per-stream media-block buffer: the media triad
3397
- // (media_start/media_delta/media_complete) is reassembled here into a single
3398
- // synthetic message at media_complete, keyed by the block id.
3399
- const mediaBuffers = new Map<
3400
- string,
3401
- { mediaType?: string; toolCallId?: string; parts: string[] }
3402
- >();
3403
- // Tracks the last iteration surfaced as a per-iteration message boundary, so
3404
- // `turn_start` advancing the iteration rotates the bubble in 'separate' mode.
3405
- let lastIterationSeen = 0;
3406
- // Execution kind, resolved from the leading `execution_start` frame. Drives
3407
- // the agent-vs-flow branches that the single wire vocabulary collapses.
3408
- let executionKind: "agent" | "flow" = "agent";
3409
- // Whether `executionKind` was set authoritatively by an `execution_start`
3410
- // frame. Continuation streams (e.g. a tool-driven `/resume`) do NOT re-emit
3411
- // `execution_start`, so a fresh `streamResponse` for the continuation starts
3412
- // with the default `"agent"`. For a flow that mis-routes the final
3413
- // prompt-step finalization and duplicates the last message (the streamed
3414
- // text block is sealed, then `step_complete.result.response` re-renders it as
3415
- // a second bubble). When `execution_start` is absent we recover the flow kind
3416
- // from the first flow `step_*` frame below.
3417
- let executionKindResolved = false;
3418
- // Open turn id (from `turn_start`). Unified text/reasoning deltas carry their
3419
- // own block id, not the turn id, so the turn id is threaded onto agentMetadata
3420
- // from here.
3421
- let openTurnId: string | null = null;
3422
- // Agent execution state tracking
3423
- let agentExecution: { executionId: string; agentId: string; agentName: string } | null = null;
3424
- const iterationDisplay = this.config.iterationDisplay ?? 'separate';
3425
-
3426
- // Drains the queued native events through the main event handler.
3427
- // Also invoked after the SSE loop exits so any events queued at
3428
- // end-of-stream are processed.
3429
- drainReadyQueue = () => {
3430
- for (let i = 0; i < eventQueue.length; i++) {
3431
- const payload = eventQueue[i];
3432
-
3433
- // Recover the execution kind on continuation streams that omit
3434
- // `execution_start` (e.g. a tool-driven `/resume`). Flow `step_*` frames
3435
- // carry a `stepType`; agent loops never do (they use `turn_*`). Without
3436
- // this, the continuation defaults to `"agent"` and a flow's final
3437
- // prompt-step finalization is duplicated. We only infer when no
3438
- // `execution_start` resolved the kind, so an explicit `agent` is never
3439
- // overridden.
3440
- if (
3441
- !executionKindResolved &&
3442
- executionKind !== "flow" &&
3443
- typeof (payload as { stepType?: unknown }).stepType === "string"
3444
- ) {
3445
- executionKind = "flow";
3446
- }
3447
-
3448
- if (payload.type === "reasoning_start") {
3449
- // Nested flow-as-tool thinking (PR #4602): route to the parent tool's row.
3450
- const rStartBlockId = typeof payload.id === "string" ? payload.id : null;
3451
- const rStartParent =
3452
- typeof payload.parentToolCallId === "string" && payload.parentToolCallId
3453
- ? payload.parentToolCallId
3454
- : null;
3455
- if (rStartBlockId && rStartParent) {
3456
- nestedBlockParent.set(rStartBlockId, rStartParent);
3457
- ensureNestedBlockMessage(rStartBlockId, rStartParent, "reasoning");
3458
- continue;
3459
- }
3460
- const reasoningId = payload.id;
3461
- const reasoningMessage = ensureReasoningMessage(reasoningId);
3462
- reasoningMessage.reasoning = reasoningMessage.reasoning ?? {
3463
- id: reasoningId,
3464
- status: "streaming",
3465
- chunks: []
3466
- };
3467
- reasoningMessage.reasoning.startedAt =
3468
- reasoningMessage.reasoning.startedAt ??
3469
- Date.now();
3470
- reasoningMessage.reasoning.completedAt = undefined;
3471
- reasoningMessage.reasoning.durationMs = undefined;
3472
- if (payload.scope === "loop" || payload.scope === "turn") {
3473
- reasoningMessage.reasoning.scope = payload.scope;
3474
- }
3475
- reasoningMessage.streaming = true;
3476
- reasoningMessage.reasoning.status = "streaming";
3477
- emitMessage(reasoningMessage);
3478
- } else if (payload.type === "reasoning_delta") {
3479
- // Nested flow-as-tool thinking: append to the parent-tool-row message.
3480
- const rDeltaBlockId = typeof payload.id === "string" ? payload.id : null;
3481
- if (
3482
- rDeltaBlockId &&
3483
- nestedBlockParent.has(rDeltaBlockId) &&
3484
- nestedBlockMessages.has(rDeltaBlockId)
3485
- ) {
3486
- const nested = nestedBlockMessages.get(rDeltaBlockId)!;
3487
- const nestedChunk = payload.delta;
3488
- if (nestedChunk && nested.reasoning) {
3489
- nested.reasoning.chunks.push(String(nestedChunk));
3490
- emitMessage(nested);
3491
- }
3492
- continue;
3493
- }
3494
- const reasoningId = payload.id;
3495
- const reasoningMessage = ensureReasoningMessage(reasoningId);
3496
- reasoningMessage.reasoning = reasoningMessage.reasoning ?? {
3497
- id: reasoningId,
3498
- status: "streaming",
3499
- chunks: []
3500
- };
3501
- reasoningMessage.reasoning.startedAt =
3502
- reasoningMessage.reasoning.startedAt ??
3503
- Date.now();
3504
- if (payload.delta) reasoningMessage.reasoning.chunks.push(payload.delta);
3505
- reasoningMessage.reasoning.status = "streaming";
3506
- reasoningMessage.streaming = true;
3507
- emitMessage(reasoningMessage);
3508
- } else if (payload.type === "reasoning_complete") {
3509
- // Nested flow-as-tool thinking close: seal the parent-tool-row message.
3510
- const rCompleteBlockId = typeof payload.id === "string" ? payload.id : null;
3511
- if (
3512
- rCompleteBlockId &&
3513
- nestedBlockParent.has(rCompleteBlockId) &&
3514
- nestedBlockMessages.has(rCompleteBlockId)
3515
- ) {
3516
- const nested = nestedBlockMessages.get(rCompleteBlockId)!;
3517
- if (nested.reasoning) {
3518
- const nestedReflection =
3519
- typeof payload.text === "string" ? payload.text : "";
3520
- if (nestedReflection && nested.reasoning.chunks.length === 0) {
3521
- nested.reasoning.chunks.push(nestedReflection);
3522
- }
3523
- nested.reasoning.status = "complete";
3524
- nested.streaming = false;
3525
- emitMessage(nested);
3526
- }
3527
- nestedBlockParent.delete(rCompleteBlockId);
3528
- nestedBlockMessages.delete(rCompleteBlockId);
3529
- continue;
3530
- }
3531
- const reasoningId = payload.id;
3532
- // A close carrying text (or scope:"loop") is a cross-iteration
3533
- // reflection fold (merged spec §4 E3): the API streams nothing for the
3534
- // block, then delivers the whole reflection as `text` on the close.
3535
- // Materialize a reasoning bubble even if no reasoning_start/delta opened
3536
- // one, and adopt the close text when the block streamed no chunks (the
3537
- // common reflection case, where reasoning_start opened an empty bubble).
3538
- const reflectionText = typeof payload.text === "string" ? payload.text : "";
3539
- if (!reasoningMessages.get(reasoningId) && (reflectionText || payload.scope === "loop")) {
3540
- ensureReasoningMessage(reasoningId);
3541
- }
3542
- const reasoningMessage = reasoningMessages.get(reasoningId);
3543
- if (reasoningMessage?.reasoning) {
3544
- if (payload.scope === "loop" || payload.scope === "turn") {
3545
- reasoningMessage.reasoning.scope = payload.scope;
3546
- }
3547
- if (reflectionText && reasoningMessage.reasoning.chunks.length === 0) {
3548
- reasoningMessage.reasoning.chunks.push(reflectionText);
3549
- }
3550
- reasoningMessage.reasoning.status = "complete";
3551
- reasoningMessage.reasoning.completedAt = Date.now();
3552
- const start = reasoningMessage.reasoning.startedAt ?? Date.now();
3553
- reasoningMessage.reasoning.durationMs = Math.max(
3554
- 0,
3555
- (reasoningMessage.reasoning.completedAt ?? Date.now()) - start
3556
- );
3557
- reasoningMessage.streaming = false;
3558
-
3559
- emitMessage(reasoningMessage);
3560
- }
3561
- } else if (payload.type === "tool_start") {
3562
- // Unified tool family (agent + flow). Seal any open assistant bubble so
3563
- // text→tool→text interleaves chronologically (the API also emits a
3564
- // text_complete here, so this is usually a no-op — kept for safety).
3565
- if (assistantMessage) {
3566
- (assistantMessage as AgentWidgetMessage).streaming = false;
3567
- emitMessage(assistantMessage as AgentWidgetMessage);
3568
- assistantMessage = null;
3569
- }
3570
- // Unified denormalizes `iteration` onto tool frames too (merged spec §2).
3571
- // Track it so media/reflection blocks — which carry no iteration of their
3572
- // own — can be stamped with the enclosing iteration even on tool-only
3573
- // turns that never emit a `turn_start`.
3574
- if (typeof payload.iteration === "number") lastIterationSeen = payload.iteration;
3575
- const toolId = payload.toolCallId;
3576
- const toolName = payload.toolName;
3577
- // Suppress tool UI for artifact emit tools: artifacts are handled via artifact_* events
3578
- if (isArtifactEmitToolName(toolName)) {
3579
- artifactToolCallIds.add(toolId);
3580
- continue;
3581
- }
3582
- const toolMessage = ensureToolMessage(toolId);
3583
- const tool = toolMessage.toolCall ?? {
3584
- id: toolId,
3585
- status: "pending"
3586
- };
3587
- tool.name = toolName ?? tool.name;
3588
- tool.status = "running";
3589
- tool.success = undefined;
3590
- tool.error = undefined;
3591
- tool.duration = undefined;
3592
- if (payload.parameters !== undefined) {
3593
- tool.args = payload.parameters;
3594
- }
3595
- tool.startedAt =
3596
- tool.startedAt ??
3597
- resolveTimestamp(payload.startedAt);
3598
- tool.completedAt = undefined;
3599
- tool.durationMs = undefined;
3600
- toolMessage.toolCall = tool;
3601
- toolMessage.streaming = true;
3602
- if (payload.executionId) {
3603
- toolMessage.agentMetadata = {
3604
- executionId: payload.executionId,
3605
- iteration: payload.iteration,
3606
- };
3607
- }
3608
- emitMessage(toolMessage);
3609
- } else if (payload.type === "tool_output_delta") {
3610
- const toolId = payload.toolCallId;
3611
- if (artifactToolCallIds.has(toolId)) continue;
3612
- const toolMessage = ensureToolMessage(toolId);
3613
- const tool = toolMessage.toolCall ?? {
3614
- id: toolId,
3615
- status: "running"
3616
- };
3617
- tool.startedAt =
3618
- tool.startedAt ??
3619
- Date.now();
3620
- const chunkText = payload.delta;
3621
- if (chunkText) {
3622
- tool.chunks = tool.chunks ?? [];
3623
- tool.chunks.push(String(chunkText));
3624
- }
3625
- tool.status = "running";
3626
- toolMessage.toolCall = tool;
3627
- toolMessage.streaming = true;
3628
- if (payload.executionId) {
3629
- toolMessage.agentMetadata = toolMessage.agentMetadata ?? {
3630
- executionId: payload.executionId,
3631
- iteration: lastIterationSeen,
3632
- };
3633
- }
3634
- emitMessage(toolMessage);
3635
- } else if (payload.type === "tool_complete") {
3636
- const toolId = payload.toolCallId;
3637
- if (artifactToolCallIds.has(toolId)) {
3638
- artifactToolCallIds.delete(toolId);
3639
- continue;
3640
- }
3641
- const toolMessage = ensureToolMessage(toolId);
3642
- const tool = toolMessage.toolCall ?? {
3643
- id: toolId,
3644
- status: "running"
3645
- };
3646
- tool.status = "complete";
3647
- if (payload.result !== undefined) {
3648
- tool.result = payload.result;
3649
- }
3650
- tool.name = payload.toolName ?? tool.name;
3651
- tool.success = payload.success;
3652
- tool.error = payload.error;
3653
- tool.completedAt = Date.now();
3654
- const durationValue = payload.executionTime;
3655
- if (typeof durationValue === "number") {
3656
- tool.durationMs = durationValue;
3657
- tool.duration = durationValue;
3658
- } else {
3659
- const start = tool.startedAt ?? Date.now();
3660
- tool.durationMs = Math.max(
3661
- 0,
3662
- (tool.completedAt ?? Date.now()) - start
3663
- );
3664
- }
3665
- toolMessage.toolCall = tool;
3666
- toolMessage.streaming = false;
3667
- if (payload.executionId) {
3668
- toolMessage.agentMetadata = toolMessage.agentMetadata ?? {
3669
- executionId: payload.executionId,
3670
- iteration: payload.iteration ?? lastIterationSeen,
3671
- };
3672
- }
3673
- emitMessage(toolMessage);
3674
- } else if (payload.type === "await" && payload.toolName) {
3675
- // Unified LOCAL tool pause for either dispatch kind. The execution
3676
- // waits for /resume with toolOutputs. Page tools with origin "webmcp"
3677
- // may carry a bare name, normalized to the internal webmcp: prefix.
3678
- //
3679
- // Upsert a fully-populated tool-variant message so the existing
3680
- // ask_user_question bubble + sheet paths fire. Mark the message with
3681
- // `awaitingLocalTool: true` so the UI knows to resolve via
3682
- // resumeFlow rather than the legacy sendMessage fallback.
3683
- //
3684
- // Key the message by the per-call `toolCallId` (provider `toolu_…`;
3685
- // core#3878) when present. Two PARALLEL calls to the SAME tool in one
3686
- // turn collapse to an identical `toolId` (`runtime_webmcp:<name>_<ms>`)
3687
- // and `index: 0`: only `toolCallId` distinguishes them. Keying on it
3688
- // (a) keeps the two awaits as DISTINCT messages with their own args
3689
- // instead of the second clobbering the first, and (b) merges each
3690
- // await into the matching `tool_start` bubble (also keyed by
3691
- // `toolCallId`). Fall back to the collapsed `toolId` for legacy
3692
- // servers that don't emit `toolCallId`.
3693
- const toolCallId: string | undefined =
3694
- typeof payload.toolCallId === "string" && payload.toolCallId.length > 0
3695
- ? (payload.toolCallId as string)
3696
- : undefined;
3697
- const toolId =
3698
- toolCallId ?? (payload.toolId as string) ?? `local-${nextSequence()}`;
3699
- const toolMessage = ensureToolMessage(toolId);
3700
- const rawToolName = payload.toolName as string;
3701
- // Page tools may arrive with a bare name; synthesize the
3702
- // `webmcp:` prefix so isWebMcpToolName (and the bridge's prefix-strip on
3703
- // resume) treat them identically to a flow `await`.
3704
- const toolName =
3705
- payload.origin === "webmcp" &&
3706
- !isWebMcpToolName(rawToolName)
3707
- ? `webmcp:${rawToolName}`
3708
- : rawToolName;
3709
- const webMcpTool = isWebMcpToolName(toolName);
3710
- const tool = toolMessage.toolCall ?? { id: toolId, status: "pending" as const };
3711
- tool.name = toolName;
3712
- tool.args = payload.parameters;
3713
- // WebMCP tools are executed asynchronously by the browser AFTER this
3714
- // `await` arrives. Keep them running until session.ts resolves
3715
- // the page tool and records its actual elapsed time. Other local
3716
- // tools (for example ask_user_question) keep the existing complete
3717
- // state because they are waiting for a user interaction, not an
3718
- // automatic page-tool execution.
3719
- tool.status = webMcpTool ? "running" : "complete";
3720
- tool.chunks = tool.chunks ?? [];
3721
- tool.startedAt =
3722
- tool.startedAt ??
3723
- resolveTimestamp(payload.awaitedAt);
3724
- if (webMcpTool) {
3725
- tool.completedAt = undefined;
3726
- tool.duration = undefined;
3727
- tool.durationMs = undefined;
3728
- } else {
3729
- tool.completedAt = tool.completedAt ?? tool.startedAt;
3730
- }
3731
- toolMessage.toolCall = tool;
3732
- toolMessage.streaming = false;
3733
- toolMessage.agentMetadata = {
3734
- ...toolMessage.agentMetadata,
3735
- executionId: (payload.executionId as string) ?? toolMessage.agentMetadata?.executionId,
3736
- awaitingLocalTool: true,
3737
- // Only set when the server emitted a real per-call id; its presence
3738
- // is what tells session.ts to batch + key `/resume` by id rather
3739
- // than by tool name (which can't represent two same-tool calls).
3740
- ...(toolCallId ? { webMcpToolCallId: toolCallId } : {}),
3741
- };
3742
- emitMessage(toolMessage);
3743
- } else if (payload.type === "text_start") {
3744
- // Nested flow-as-tool text (PR #4602): a `parentToolCallId` means this
3745
- // block belongs to a flow running as that tool — record the mapping and
3746
- // leave the top-level assistant bubble untouched (the nested deltas route
3747
- // into the parent tool's row).
3748
- const startBlockId = typeof payload.id === "string" ? payload.id : null;
3749
- const startParent =
3750
- typeof payload.parentToolCallId === "string" && payload.parentToolCallId
3751
- ? payload.parentToolCallId
3752
- : null;
3753
- if (startBlockId && startParent) {
3754
- nestedBlockParent.set(startBlockId, startParent);
3755
- continue;
3756
- }
3757
- // Unified text-channel block open. A new block id means a new bubble, so
3758
- // seal any open assistant bubble; the next text_delta creates a fresh one
3759
- // (lazily). The API emits a fresh block at every tool/media/approval/await
3760
- // boundary, so block-id keying drives segmentation — no partId.
3761
- const prev = assistantMessage as AgentWidgetMessage | null;
3762
- if (prev) {
3763
- // Normally text_complete already sealed the prior block; this is the
3764
- // defensive path if a producer opens a new block without closing.
3765
- if (executionKind === "flow") {
3766
- finalizeFlowTextBlock(prev);
3767
- lastSealedFlowBubble = prev;
3768
- } else {
3769
- prev.streaming = false;
3770
- emitMessage(prev);
3771
- }
3772
- assistantMessage = null;
3773
- }
3774
- currentTextBlockId =
3775
- typeof payload.id === "string" ? payload.id : currentTextBlockId;
3776
- pendingTextRaw = "";
3777
- } else if (payload.type === "text_delta") {
3778
- // Nested flow-as-tool text: route to the parent tool's row, through the
3779
- // same structured-content parser, never the top-level assistant channel.
3780
- const deltaBlockId = typeof payload.id === "string" ? payload.id : null;
3781
- const nestedParent = deltaBlockId
3782
- ? nestedBlockParent.get(deltaBlockId)
3783
- : undefined;
3784
- if (deltaBlockId && nestedParent) {
3785
- const nestedDelta =
3786
- typeof payload.delta === "string" ? payload.delta : "";
3787
- const nestedRaw = (nestedBlockRaw.get(deltaBlockId) ?? "") + nestedDelta;
3788
- nestedBlockRaw.set(deltaBlockId, nestedRaw);
3789
- if (nestedRaw.trim() === "") continue;
3790
- const nested = ensureNestedBlockMessage(deltaBlockId, nestedParent);
3791
- nested.agentMetadata = {
3792
- ...nested.agentMetadata,
3793
- executionId: payload.executionId,
3794
- parentToolId: nestedParent,
3795
- };
3796
- applyTextChunk(nested, nestedRaw, nestedDelta, undefined);
3797
- continue;
3798
- }
3799
- currentTextBlockId =
3800
- typeof payload.id === "string" ? payload.id : currentTextBlockId;
3801
- if (executionKind === "flow") {
3802
- // Flow prompt-step text can be structured JSON: accumulate the raw
3803
- // block and run it through the structured-content parser, keyed by the
3804
- // block-id bubble. Materialize lazily so a whitespace-only block
3805
- // (newlines around a tool boundary) never leaves a stray bubble.
3806
- const delta = typeof payload.delta === "string" ? payload.delta : "";
3807
- pendingTextRaw += delta;
3808
- if (pendingTextRaw.trim() === "") continue;
3809
- const assistant = ensureAssistantMessage();
3810
- assistant.agentMetadata = {
3811
- executionId: payload.executionId,
3812
- iteration: lastIterationSeen,
3813
- };
3814
- applyTextChunk(assistant, pendingTextRaw, delta, undefined);
3815
- lastAssistantInTurn = assistant;
3816
- continue;
3817
- }
3818
- // Agent text can be structured JSON too, whenever the host configures a
3819
- // custom `streamParser` (e.g. the page-context demo's `{"text": ...}` /
3820
- // `{"action": "add_to_cart", ...}` envelope). Accumulate the open block's
3821
- // raw text and run it through the same structured-content path the flow
3822
- // branch uses, so the parser sees the whole block and `rawContent` stays
3823
- // populated for the action manager. With the default plain-text parser
3824
- // this is byte-identical to appending the delta, so agent demos that
3825
- // stream prose are unaffected.
3826
- const agentDelta = typeof payload.delta === "string" ? payload.delta : "";
3827
- pendingTextRaw += agentDelta;
3828
- const assistant = ensureAssistantMessage();
3829
- assistant.agentMetadata = {
3830
- executionId: payload.executionId,
3831
- iteration: lastIterationSeen,
3832
- turnId: openTurnId ?? undefined,
3833
- agentName: agentExecution?.agentName
3834
- };
3835
- applyTextChunk(assistant, pendingTextRaw, agentDelta, undefined);
3836
- lastAssistantInTurn = assistant;
3837
- } else if (payload.type === "text_complete") {
3838
- // Nested flow-as-tool text block close: seal its parent-tool-row message.
3839
- const completeBlockId = typeof payload.id === "string" ? payload.id : null;
3840
- if (completeBlockId && nestedBlockParent.has(completeBlockId)) {
3841
- const nested = nestedBlockMessages.get(completeBlockId);
3842
- if (nested) finalizeFlowTextBlock(nested);
3843
- nestedBlockParent.delete(completeBlockId);
3844
- nestedBlockRaw.delete(completeBlockId);
3845
- nestedBlockMessages.delete(completeBlockId);
3846
- continue;
3847
- }
3848
- // Seal the current text block's bubble.
3849
- const prev = assistantMessage as AgentWidgetMessage | null;
3850
- if (prev) {
3851
- if (executionKind === "flow") {
3852
- // Final structured extraction off the accumulated raw buffer; the
3853
- // authoritative step_complete.result.response reconciles next.
3854
- finalizeFlowTextBlock(prev);
3855
- lastSealedFlowBubble = prev;
3856
- } else {
3857
- // U2: text_complete carries the assembled text, but the bubble already
3858
- // holds it from the deltas — only fall back to payload.text when no
3859
- // delta content was seen, never double-count.
3860
- if ((prev.content ?? "") === "" && typeof payload.text === "string") {
3861
- prev.content = payload.text;
3862
- }
3863
- prev.streaming = false;
3864
- emitMessage(prev);
3865
- }
3866
- assistantMessage = null;
3867
- }
3868
- currentTextBlockId = null;
3869
- pendingTextRaw = "";
3870
- } else if (payload.type === "step_complete") {
3871
- // Only process completions for prompt steps, not tool/context steps
3872
- if (payload.stepType === "tool") {
3873
- // Skip tool-related completions - they're handled by tool_complete
3874
- continue;
3875
- }
3876
-
3877
- // A failed step (`success:false`) — including the legacy `step_error`
3878
- // event, which the wire encoder folds into a failed `step_complete`
3879
- // — surfaces as a terminal error and finalizes the stream.
3880
- if (payload.success === false) {
3881
- const message = payload.error || "Step failed";
3882
- onEvent({ type: "error", error: new Error(message) });
3883
- const finalMsg = assistantMessage as AgentWidgetMessage | null;
3884
- if (finalMsg && finalMsg.streaming) {
3885
- finalMsg.streaming = false;
3886
- emitMessage(finalMsg);
3887
- }
3888
- onEvent({ type: "status", status: "idle" });
3889
- continue;
3890
- }
3891
-
3892
- // Unified flow: reconcile the just-sealed text block with the
3893
- // authoritative structured final (`result.response`). Displayed content
3894
- // stays as streamed — a multi-segment step keeps each bubble's own text;
3895
- // reconcile only fills/repairs the last sealed block and sets rawContent.
3896
- // A pure-tool / text-less step (no sealed flow bubble) completes silently.
3897
- {
3898
- const sealed = lastSealedFlowBubble;
3899
- lastSealedFlowBubble = null;
3900
- const flowStopReason = payload.stopReason as
3901
- | StopReasonKind
3902
- | undefined;
3903
- const result = payload.result && typeof payload.result === "object"
3904
- ? payload.result as Record<string, unknown>
3905
- : null;
3906
- const finalResponse = result?.response;
3907
- if (sealed) {
3908
- if (flowStopReason) sealed.stopReason = flowStopReason;
3909
- if (finalResponse !== undefined && finalResponse !== null) {
3910
- reconcileSealedAssistantWithFinalResponse(sealed, finalResponse);
3911
- } else if (sealed.streaming !== false) {
3912
- streamParsers.delete(sealed.id);
3913
- rawContentBuffers.delete(sealed.id);
3914
- sealed.streaming = false;
3915
- emitMessage(sealed);
3916
- }
3917
- } else {
3918
- // Buffered / dispatch-mode step: no streamed text block, but the step
3919
- // carries the final response (and/or a stopReason) — render it as the
3920
- // assistant message. An empty response + stopReason still surfaces a
3921
- // sealed bubble so the UI can show an affordance.
3922
- const hasResponse =
3923
- finalResponse !== undefined &&
3924
- finalResponse !== null &&
3925
- finalResponse !== "";
3926
- if (hasResponse || flowStopReason) {
3927
- const assistant = ensureAssistantMessage();
3928
- if (flowStopReason) assistant.stopReason = flowStopReason;
3929
- if (hasResponse) {
3930
- finalizeFlowTextBlock(assistant, finalResponse);
3931
- } else {
3932
- assistant.streaming = false;
3933
- emitMessage(assistant);
3934
- }
3935
- }
3936
- }
3937
- continue;
3938
- }
3939
- // ================================================================
3940
- // Agent Loop Execution Events
3941
- // ================================================================
3942
- } else if (payload.type === "execution_start") {
3943
- executionKind = payload.kind === "flow" ? "flow" : "agent";
3944
- executionKindResolved = true;
3945
- if (executionKind === "agent") {
3946
- agentExecution = {
3947
- executionId: payload.executionId,
3948
- agentId: payload.agentId ?? 'virtual',
3949
- agentName: payload.agentName ?? ''
3950
- };
3951
- }
3952
- } else if (payload.type === "turn_start") {
3953
- // Unified collapsed `agent_iteration_*` into a denormalized `iteration`
3954
- // field on the turn (merged spec §2). Reconstruct the per-iteration
3955
- // message boundary the 'separate' renderer keys off: when the iteration
3956
- // advances, seal the previous iteration's bubble and rotate to a new one.
3957
- const iteration =
3958
- typeof payload.iteration === "number" ? payload.iteration : lastIterationSeen;
3959
- if (iteration !== lastIterationSeen) {
3960
- if (iterationDisplay === 'separate' && iteration > 1) {
3961
- const prevMsg = assistantMessage as AgentWidgetMessage | null;
3962
- if (prevMsg) {
3963
- prevMsg.streaming = false;
3964
- emitMessage(prevMsg);
3965
- assistantMessage = null;
3966
- }
3967
- }
3968
- lastIterationSeen = iteration;
3969
- }
3970
- openTurnId = typeof payload.id === "string" ? payload.id : null;
3971
- // Reset the per-turn assistant tracker. lastAssistantInTurn is used by
3972
- // turn_complete to attach stopReason to the final text segment of the
3973
- // turn even if that segment was sealed by an intervening tool boundary.
3974
- lastAssistantInTurn = null;
3975
- } else if (payload.type === "tool_input_delta") {
3976
- // Streamed tool arguments (display-only; authoritative args ride
3977
- // tool_input_complete / tool_start).
3978
- const toolId = payload.toolCallId;
3979
- if (toolId) {
3980
- const toolMessage = toolMessages.get(toolId);
3981
- if (toolMessage?.toolCall) {
3982
- toolMessage.toolCall.chunks = toolMessage.toolCall.chunks ?? [];
3983
- toolMessage.toolCall.chunks.push(payload.delta ?? '');
3984
- emitMessage(toolMessage);
3985
- }
3986
- }
3987
- } else if (payload.type === "tool_input_complete") {
3988
- if (artifactToolCallIds.has(payload.toolCallId)) continue;
3989
- const toolMessage = ensureToolMessage(payload.toolCallId);
3990
- const tool = toolMessage.toolCall!;
3991
- tool.args = payload.parameters;
3992
- tool.name = payload.toolName ?? tool.name;
3993
- emitMessage(toolMessage);
3994
- } else if (payload.type === "turn_complete") {
3995
- // Reasoning is sealed by its own reasoning_complete on the wire
3996
- // vocabulary; this only attaches the turn-level stopReason to the
3997
- // assistant message produced by this turn. Falls back to
3998
- // lastAssistantInTurn when the bubble was sealed at a tool boundary
3999
- // mid-turn, so the notice still attaches to the final visible segment.
4000
- const turnStopReason = payload.stopReason as
4001
- | StopReasonKind
4002
- | undefined;
4003
- const stopReasonTarget = assistantMessage ?? lastAssistantInTurn;
4004
- if (turnStopReason && stopReasonTarget !== null) {
4005
- const turnId = payload.id;
4006
- const matchesTurn =
4007
- !turnId || stopReasonTarget.agentMetadata?.turnId === turnId;
4008
- if (matchesTurn) {
4009
- stopReasonTarget.stopReason = turnStopReason;
4010
- emitMessage(stopReasonTarget);
4011
- }
4012
- }
4013
- if (openTurnId === payload.id) openTurnId = null;
4014
- } else if (payload.type === "media_start") {
4015
- // Open a media block; buffer fragments until media_complete.
4016
- const id = String(payload.id);
4017
- mediaBuffers.set(id, {
4018
- mediaType: typeof payload.mediaType === "string" ? payload.mediaType : undefined,
4019
- toolCallId: payload.toolCallId,
4020
- parts: [],
4021
- });
4022
- } else if (payload.type === "media_delta") {
4023
- const buf = mediaBuffers.get(String(payload.id));
4024
- if (buf && typeof payload.delta === "string") buf.parts.push(payload.delta);
4025
- } else if (payload.type === "media_complete") {
4026
- // Reassemble the buffered media triad into a single AI SDK–aligned
4027
- // `MediaContentPart`, then render it as a synthetic assistant message
4028
- // inserted between the tool bubble and the next text turn:
4029
- // { type: 'media', data, mediaType } // AI SDK v6: base64
4030
- // { type: 'image-url', url, mediaType? } // AI SDK v3/v4
4031
- // { type: 'file-url', url, mediaType } // AI SDK v3/v4
4032
- const mediaBlockId = String(payload.id);
4033
- const buf = mediaBuffers.get(mediaBlockId);
4034
- mediaBuffers.delete(mediaBlockId);
4035
- const completeMediaType =
4036
- (typeof payload.mediaType === "string" ? payload.mediaType : undefined) ??
4037
- buf?.mediaType ??
4038
- "application/octet-stream";
4039
- const completeData = typeof payload.data === "string" ? payload.data : undefined;
4040
- const completeUrl =
4041
- typeof payload.url === "string"
4042
- ? payload.url
4043
- : buf && buf.parts.length > 0
4044
- ? buf.parts.join("")
4045
- : undefined;
4046
- let reconstructed: Record<string, unknown> | null = null;
4047
- if (completeData) {
4048
- reconstructed = { type: "media", data: completeData, mediaType: completeMediaType };
4049
- } else if (completeUrl) {
4050
- // The wire is mediaType-only; a URL part with no declared MIME
4051
- // arrives as the bare bucket hint "image" (per the API encoder). Treat
4052
- // that — and any real `image/*` — as a hosted image so we don't misroute
4053
- // generated images into the file bucket.
4054
- const lower = completeMediaType.toLowerCase();
4055
- const isImage = lower === "image" || lower.startsWith("image/");
4056
- reconstructed = {
4057
- type: isImage ? "image-url" : "file-url",
4058
- url: completeUrl,
4059
- mediaType: completeMediaType,
4060
- };
4061
- }
4062
- const mediaToolCallId = payload.toolCallId ?? buf?.toolCallId;
4063
- const rawMedia = reconstructed ? [reconstructed] : [];
4064
- const mediaContentParts: ContentPart[] = [];
4065
- for (const part of rawMedia) {
4066
- if (!part || typeof part !== "object") continue;
4067
- const rec = part as Record<string, unknown>;
4068
- const partType = typeof rec.type === "string" ? rec.type : undefined;
4069
-
4070
- // Resolve `(src, mediaType)` for the part.
4071
- // RFC 7231 says MIME types are case-insensitive, so we canonicalize
4072
- // to lowercase once here. That makes the `startsWith("image/")` /
4073
- // `"audio/"` / `"video/"` bucket checks robust to upstream tools
4074
- // that emit non-canonical casing like `Image/PNG`.
4075
- const rawMediaType =
4076
- typeof rec.mediaType === "string" ? rec.mediaType.toLowerCase() : "";
4077
- let src: string | null = null;
4078
- let mediaType = "";
4079
- if (partType === "media") {
4080
- const data = typeof rec.data === "string" ? rec.data : undefined;
4081
- if (!data) continue;
4082
- // Empty/missing mediaType yields `data:;base64,...` which RFC 2397
4083
- // resolves to `text/plain`: stamp a default so the data URI is
4084
- // well-formed and the part lands in the file bucket.
4085
- mediaType = rawMediaType.length > 0 ? rawMediaType : "application/octet-stream";
4086
- src = `data:${mediaType};base64,${data}`;
4087
- } else if (partType === "image-url") {
4088
- const url = typeof rec.url === "string" ? rec.url : undefined;
4089
- if (!url) continue;
4090
- mediaType = rawMediaType;
4091
- src = url;
4092
- } else if (partType === "file-url") {
4093
- const url = typeof rec.url === "string" ? rec.url : undefined;
4094
- if (!url) continue;
4095
- mediaType = rawMediaType;
4096
- src = url;
4097
- } else {
4098
- continue;
4099
- }
4100
- if (!src) continue;
4101
-
4102
- // Pick the right rendering bucket based on mediaType.
4103
- if (partType === "image-url" || mediaType.startsWith("image/")) {
4104
- mediaContentParts.push({
4105
- type: "image",
4106
- image: src,
4107
- // Only a real MIME (`image/png`) is a usable mimeType; the bare
4108
- // bucket hint "image" (a hosted URL with no declared type) is not.
4109
- ...(mediaType.includes("/") ? { mimeType: mediaType } : {}),
4110
- });
4111
- } else if (mediaType.startsWith("audio/")) {
4112
- mediaContentParts.push({
4113
- type: "audio",
4114
- audio: src,
4115
- mimeType: mediaType,
4116
- });
4117
- } else if (mediaType.startsWith("video/")) {
4118
- mediaContentParts.push({
4119
- type: "video",
4120
- video: src,
4121
- mimeType: mediaType,
4122
- });
4123
- } else {
4124
- const resolvedMediaType = mediaType || "application/octet-stream";
4125
- mediaContentParts.push({
4126
- type: "file",
4127
- data: src,
4128
- mimeType: resolvedMediaType,
4129
- filename: filenameFromMediaType(resolvedMediaType),
4130
- });
4131
- }
4132
- }
4133
-
4134
- if (mediaContentParts.length > 0) {
4135
- // Uniquify per emission. A tool may emit multiple `agent_media`
4136
- // events for the same `toolCallId` (e.g. streamed/batched media);
4137
- // sharing an id would let `emitMessage` merge them by id and
4138
- // overwrite the prior `contentParts`.
4139
- const seq = nextSequence();
4140
- const toolCallIdRaw = mediaToolCallId;
4141
- const mediaIdSuffix =
4142
- typeof toolCallIdRaw === "string" && toolCallIdRaw.length > 0
4143
- ? `${toolCallIdRaw}-${seq}`
4144
- : String(seq);
4145
- const mediaMessage: AgentWidgetMessage = {
4146
- id: `agent-media-${mediaIdSuffix}`,
4147
- role: "assistant",
4148
- content: "",
4149
- contentParts: mediaContentParts,
4150
- createdAt: new Date().toISOString(),
4151
- streaming: false,
4152
- sequence: seq,
4153
- agentMetadata: {
4154
- executionId: payload.executionId,
4155
- // Media blocks carry no iteration of their own; stamp the
4156
- // enclosing iteration tracked from turn/tool frames.
4157
- iteration: lastIterationSeen,
4158
- },
4159
- };
4160
- emitMessage(mediaMessage);
4161
-
4162
- // Seal any in-flight assistant text bubble before splitting the
4163
- // stream. Without this, an orphan bubble retains `streaming: true`
4164
- // forever: `execution_complete` only finalizes the latest
4165
- // `assistantMessage`, so the typing/caret indicator would stay on
4166
- // the prior bubble even though no more deltas will arrive.
4167
- const prevAssistant = assistantMessage as AgentWidgetMessage | null;
4168
- if (prevAssistant) {
4169
- prevAssistant.streaming = false;
4170
- emitMessage(prevAssistant);
4171
- }
4172
- assistantMessage = null;
4173
- assistantMessageRef.current = null;
4174
- }
4175
- } else if (payload.type === "execution_complete") {
4176
- // Finalize any still-open assistant message. Per-step reconciliation
4177
- // (step_complete.result.response) normally sealed the flow blocks
4178
- // already; this is the defensive close for an unterminated block, and
4179
- // for flow it runs the final structured extraction off the raw buffer.
4180
- const finalMsg = assistantMessage as AgentWidgetMessage | null;
4181
- if (finalMsg) {
4182
- if (payload.kind === "flow" && finalMsg.streaming !== false) {
4183
- finalizeFlowTextBlock(finalMsg);
4184
- } else {
4185
- finalMsg.streaming = false;
4186
- emitMessage(finalMsg);
4187
- }
4188
- assistantMessage = null;
4189
- }
4190
- currentTextBlockId = null;
4191
- pendingTextRaw = "";
4192
- lastSealedFlowBubble = null;
4193
-
4194
- // `terminal: true` marks this as a graceful finish (not a drop). The
4195
- // session uses it to distinguish the real end-of-turn from the plain
4196
- // `idle` the dispatch wrappers emit in their `finally` when a durable
4197
- // connection drops mid-stream (durable-reconnect drop detection).
4198
- onEvent({ type: "status", status: "idle", terminal: true });
4199
- } else if (payload.type === "execution_error") {
4200
- // Terminal failure. The non-terminal `error` is handled
4201
- // separately (recoverable → warn).
4202
- const errorMessage = typeof payload.error === 'string'
4203
- ? payload.error
4204
- : payload.error?.message ?? 'Execution error';
4205
- onEvent({
4206
- type: "error",
4207
- error: new Error(errorMessage)
4208
- });
4209
- } else if (payload.type === "ping") {
4210
- // Keep-alive heartbeat - no action needed
4211
- // ================================================================
4212
- // Tool Approval Events
4213
- // ================================================================
4214
- } else if (payload.type === "approval_start") {
4215
- const approvalId = payload.approvalId ?? `approval-${nextSequence()}`;
4216
- const approvalMessage: AgentWidgetMessage = {
4217
- id: `approval-${approvalId}`,
4218
- role: "assistant",
4219
- content: "",
4220
- createdAt: new Date().toISOString(),
4221
- streaming: false,
4222
- variant: "approval",
4223
- sequence: nextSequence(),
4224
- approval: {
4225
- id: approvalId,
4226
- status: "pending",
4227
- agentId: agentExecution?.agentId ?? 'virtual',
4228
- executionId: payload.executionId ?? agentExecution?.executionId ?? '',
4229
- toolName: payload.toolName ?? '',
4230
- toolType: payload.toolType,
4231
- description: payload.description ?? `Execute ${payload.toolName ?? 'tool'}`,
4232
- ...(typeof payload.reason === "string" && payload.reason
4233
- ? { reason: payload.reason }
4234
- : {}),
4235
- parameters: payload.parameters,
4236
- },
4237
- };
4238
- emitMessage(approvalMessage);
4239
- } else if (payload.type === "approval_complete") {
4240
- const approvalId = payload.approvalId;
4241
- if (approvalId) {
4242
- // Find and update the existing approval message
4243
- const approvalMessageId = `approval-${approvalId}`;
4244
- const existingMessage: AgentWidgetMessage = {
4245
- id: approvalMessageId,
4246
- role: "assistant",
4247
- content: "",
4248
- createdAt: new Date().toISOString(),
4249
- streaming: false,
4250
- variant: "approval",
4251
- sequence: nextSequence(),
4252
- approval: {
4253
- id: approvalId,
4254
- status: (payload.decision as "approved" | "denied") ?? "approved",
4255
- agentId: agentExecution?.agentId ?? 'virtual',
4256
- executionId: payload.executionId ?? agentExecution?.executionId ?? '',
4257
- toolName: '',
4258
- description: '',
4259
- resolvedAt: Date.now(),
4260
- },
4261
- };
4262
- emitMessage(existingMessage);
4263
- }
4264
- } else if (
4265
- payload.type === "artifact_start" ||
4266
- payload.type === "artifact_delta" ||
4267
- payload.type === "artifact_update" ||
4268
- payload.type === "artifact_complete"
4269
- ) {
4270
- if (payload.type === "artifact_start") {
4271
- const at = payload.artifactType as PersonaArtifactKind;
4272
- const artId = String(payload.id);
4273
- const artTitle = typeof payload.title === "string" ? payload.title : undefined;
4274
- // Additive `file` metadata: validate shape (object with string path +
4275
- // string mimeType; optional string language). Drop silently if malformed
4276
- // so old/new backends and unexpected payloads never break the stream.
4277
- const rawFile = payload.file;
4278
- let artFile: PersonaArtifactFileMeta | undefined;
4279
- if (
4280
- rawFile &&
4281
- typeof rawFile === "object" &&
4282
- !Array.isArray(rawFile) &&
4283
- typeof rawFile.path === "string" &&
4284
- typeof rawFile.mimeType === "string"
4285
- ) {
4286
- artFile = {
4287
- path: rawFile.path,
4288
- mimeType: rawFile.mimeType,
4289
- ...(typeof rawFile.language === "string" ? { language: rawFile.language } : {}),
4290
- };
4291
- }
4292
- onEvent({
4293
- type: "artifact_start",
4294
- id: artId,
4295
- artifactType: at,
4296
- title: artTitle,
4297
- component: typeof payload.component === "string" ? payload.component : undefined,
4298
- ...(artFile ? { file: artFile } : {})
4299
- });
4300
- artifactContent.set(artId, {
4301
- markdown: "",
4302
- title: artTitle,
4303
- file: artFile,
4304
- });
4305
- // Insert the in-thread artifact block once per ID.
4306
- // The resolved display mode picks the component:
4307
- // "card"/"panel" inject the reference card; "inline" injects the
4308
- // inline preview block. Both share the rawContent JSON-component
4309
- // shape so transcript persistence and hydration work unchanged.
4310
- if (!artifactIdsWithCards.has(artId)) {
4311
- artifactIdsWithCards.add(artId);
4312
- const displayMode = resolveArtifactDisplayMode(
4313
- this.config.features?.artifacts,
4314
- {
4315
- artifactType: at,
4316
- ...(artFile ? { file: artFile } : {}),
4317
- }
4318
- );
4319
- const artComponent =
4320
- typeof payload.component === "string" ? payload.component : undefined;
4321
- const cardMsg: AgentWidgetMessage = {
4322
- id: `artifact-ref-${artId}`,
4323
- role: "assistant",
4324
- content: "",
4325
- createdAt: new Date().toISOString(),
4326
- streaming: true,
4327
- sequence: nextSequence(),
4328
- rawContent: buildArtifactRefRawContent(displayMode, {
4329
- artifactId: artId,
4330
- title: artTitle,
4331
- artifactType: at,
4332
- status: "streaming",
4333
- ...(artFile ? { file: artFile } : {}),
4334
- ...(artComponent ? { component: artComponent } : {}),
4335
- }),
4336
- };
4337
- artifactCardMessages.set(artId, cardMsg);
4338
- emitMessage(cardMsg);
4339
- }
4340
- } else if (payload.type === "artifact_delta") {
4341
- const deltaId = String(payload.id);
4342
- const deltaText = typeof payload.delta === "string" ? payload.delta : String(payload.delta ?? "");
4343
- onEvent({
4344
- type: "artifact_delta",
4345
- id: deltaId,
4346
- artDelta: deltaText
4347
- });
4348
- const acc = artifactContent.get(deltaId);
4349
- if (acc) acc.markdown += deltaText;
4350
- } else if (payload.type === "artifact_update") {
4351
- const props =
4352
- payload.props && typeof payload.props === "object" && !Array.isArray(payload.props)
4353
- ? (payload.props as Record<string, unknown>)
4354
- : {};
4355
- onEvent({
4356
- type: "artifact_update",
4357
- id: String(payload.id),
4358
- props,
4359
- component: typeof payload.component === "string" ? payload.component : undefined
4360
- });
4361
- // Accumulate for hydration: embedded on artifact_complete so an
4362
- // inline component block re-renders with its real props after a
4363
- // refresh.
4364
- const updateAcc = artifactContent.get(String(payload.id));
4365
- if (updateAcc) {
4366
- updateAcc.props = { ...(updateAcc.props ?? {}), ...props };
4367
- }
4368
- } else if (payload.type === "artifact_complete") {
4369
- const artCompleteId = String(payload.id);
4370
- onEvent({ type: "artifact_complete", id: artCompleteId });
4371
- // Update the inline card to show completed state
4372
- const refMsg = artifactCardMessages.get(artCompleteId);
4373
- if (refMsg) {
4374
- refMsg.streaming = false;
4375
- try {
4376
- const parsed = JSON.parse(refMsg.rawContent ?? "{}");
4377
- if (parsed.props) {
4378
- parsed.props.status = "complete";
4379
- // Store markdown content in card props so download works after page refresh
4380
- const acc = artifactContent.get(artCompleteId);
4381
- if (acc?.markdown) {
4382
- parsed.props.markdown = acc.markdown;
4383
- }
4384
- // Persist file metadata too so the download path unfences correctly after refresh.
4385
- if (acc?.file) {
4386
- parsed.props.file = acc.file;
4387
- }
4388
- // Embed accumulated component props so an inline component
4389
- // block hydrates with its real props after a refresh. Only
4390
- // the inline block reads them; the card never does.
4391
- if (
4392
- parsed.component === "PersonaArtifactInline" &&
4393
- acc?.props &&
4394
- Object.keys(acc.props).length > 0
4395
- ) {
4396
- parsed.props.componentProps = acc.props;
4397
- }
4398
- }
4399
- refMsg.rawContent = JSON.stringify(parsed);
4400
- } catch { /* ignore parse errors */ }
4401
- artifactContent.delete(artCompleteId);
4402
- emitMessage(refMsg);
4403
- artifactCardMessages.delete(artCompleteId);
4404
- }
4405
- }
4406
- } else if (payload.type === "error") {
4407
- // Unified non-terminal error (merged spec). A bare `error` is
4408
- // recoverable by default — a transient notice such as "rate limited,
4409
- // retrying" — and the execution continues, so it must NOT surface as a
4410
- // fatal error or finalize the stream. The API routes terminal failures
4411
- // through `execution_error`. Only an explicit `recoverable: false`
4412
- // promotes an `error` to terminal.
4413
- if (
4414
- payload.recoverable === false &&
4415
- payload.error != null &&
4416
- payload.error !== ""
4417
- ) {
4418
- const errorMessage =
4419
- typeof payload.error === "string"
4420
- ? payload.error
4421
- : (payload.error as { message?: unknown })?.message != null
4422
- ? String((payload.error as { message?: unknown }).message)
4423
- : "Execution error";
4424
- onEvent({ type: "error", error: new Error(errorMessage) });
4425
- const finalMsg = assistantMessage as AgentWidgetMessage | null;
4426
- if (finalMsg && finalMsg.streaming) {
4427
- finalMsg.streaming = false;
4428
- emitMessage(finalMsg);
4429
- }
4430
- onEvent({ type: "status", status: "idle" });
4431
- }
4432
-
4433
- }
4434
- }
4435
- eventQueue.length = 0;
4436
- };
4437
-
4438
- // eslint-disable-next-line no-constant-condition
4439
- while (true) {
4440
- const { done, value } = await reader.read();
4441
- if (done) break;
4442
-
4443
- buffer += decoder.decode(value, { stream: true });
4444
- const events = buffer.split("\n\n");
4445
- buffer = events.pop() ?? "";
4446
-
4447
- for (const event of events) {
4448
- const lines = event.split("\n");
4449
- let eventType = "message";
4450
- let data = "";
4451
- // Durable-reconnect cursor: the SSE `id:` line (the durable row seq).
4452
- // Only durable, resumable agent executions stamp these (e.g. Claude
4453
- // Managed agents, or any async/background run the backend persists and
4454
- // can replay); other streams carry no cursor. We emit a `cursor`
4455
- // event AFTER the frame is fully parsed and dispatched, so the session's
4456
- // `lastEventId` only advances past frames it has actually applied, so the
4457
- // happy path has no dupes against the server's `seq > after` replay.
4458
- let frameId: string | null = null;
4459
-
4460
- for (const line of lines) {
4461
- if (line.startsWith("event:")) {
4462
- eventType = line.replace("event:", "").trim();
4463
- } else if (line.startsWith("data:")) {
4464
- data += line.replace("data:", "").trim();
4465
- } else if (line.startsWith("id:")) {
4466
- frameId = line.slice(3).trim();
4467
- }
4468
- }
4469
-
4470
- const advanceCursor = () => {
4471
- if (frameId !== null && frameId !== "") {
4472
- onEvent({ type: "cursor", id: frameId });
4473
- }
4474
- };
4475
-
4476
- // A frame with an `id:` but no `data:` (e.g. a bare keepalive line) is
4477
- // still a received durable row, so advance the cursor past it.
4478
- if (!data) {
4479
- advanceCursor();
4480
- continue;
4481
- }
4482
- let payload: unknown;
4483
- try {
4484
- payload = JSON.parse(data);
4485
- } catch (error) {
4486
- // Parse failure: the frame was NOT applied. Do NOT advance the cursor
4487
- // so a reconnect re-fetches this row.
4488
- onEvent({
4489
- type: "error",
4490
- error:
4491
- error instanceof Error
4492
- ? error
4493
- : new Error("Failed to parse chat stream payload")
4494
- });
4495
- continue;
4496
- }
4497
-
4498
- const record = payload !== null && typeof payload === "object" && !Array.isArray(payload)
4499
- ? payload as Record<string, unknown>
4500
- : null;
4501
- const payloadType = eventType !== "message"
4502
- ? eventType
4503
- : typeof record?.type === "string" ? record.type : "message";
4504
-
4505
- // Tap: capture raw SSE event for event stream inspector
4506
- this.onSSEEvent?.(payloadType, payload);
4507
-
4508
- // If custom SSE event parser is provided, try it first
4509
- if (this.parseSSEEvent) {
4510
- // Keep assistant message ref in sync
4511
- assistantMessageRef.current = assistantMessage;
4512
- const handled = await this.handleCustomSSEEvent(
4513
- payload,
4514
- onEvent,
4515
- assistantMessageRef,
4516
- emitMessage,
4517
- nextSequence,
4518
- customParsePartId
4519
- );
4520
- // Update assistantMessage from ref (in case it was created or replaced by partId segmentation)
4521
- if (assistantMessageRef.current && assistantMessageRef.current !== assistantMessage) {
4522
- assistantMessage = assistantMessageRef.current;
4523
- }
4524
- if (handled) {
4525
- advanceCursor();
4526
- continue; // Skip default handling if custom handler processed it
4527
- }
4528
- }
4529
-
4530
- // The wire is the wire vocabulary; the handler consumes it
4531
- // natively. The stream is single-connection and in order, so each frame
4532
- // drains straight through.
4533
- if (record) {
4534
- // The raw tap and custom parser above retain the original payload.
4535
- // Known native branches use the generated union; unknown event types
4536
- // fall through without rejecting a custom backend's stream.
4537
- eventQueue.push({ ...record, type: payloadType } as RuntypeExecutionStreamEvent);
4538
- drainReadyQueue();
4539
- }
4540
- advanceCursor();
4541
- }
4542
- }
4543
-
4544
- drainReadyQueue();
2221
+ config: () => this.config,
2222
+ createStreamParser: this.#createStreamParser,
2223
+ parseSSEEvent: this.#parseSSEEvent,
2224
+ onSSEEvent: (eventType, payload) => this.#onSSEEvent?.(eventType, payload),
2225
+ },
2226
+ body,
2227
+ onEvent,
2228
+ assistantMessageId,
2229
+ seedContent
2230
+ );
4545
2231
  }
4546
2232
  }