@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/session.ts CHANGED
@@ -4,16 +4,22 @@ import {
4
4
  type SSEEventCallback,
5
5
  } from "./client";
6
6
  import { isWebMcpToolName } from "./webmcp-bridge";
7
+ import type { SessionActionsHost, SessionActionsInternals } from "./session-actions";
8
+ import {
9
+ getSessionActionsSync,
10
+ loadSessionActions,
11
+ type SessionActionsModule,
12
+ } from "./session-actions-loader";
7
13
  import {
8
14
  SUGGEST_REPLIES_TOOL_NAME,
9
15
  resolveFollowUpsFeature,
10
- suggestRepliesToolResult,
11
16
  } from "./suggest-replies-tool";
12
17
  import {
13
18
  AgentWidgetConfig,
14
19
  AgentWidgetEvent,
15
20
  AgentWidgetMessage,
16
21
  AgentWidgetApproval,
22
+ AgentWidgetToolCall,
17
23
  AgentWidgetApprovalDecisionOptions,
18
24
  WebMcpConfirmInfo,
19
25
  AgentExecutionState,
@@ -80,7 +86,10 @@ import {
80
86
  } from "./voice/read-aloud-controller";
81
87
  import { isVoiceSupportedProbe, usesSessionVoice, voiceConnectionChanged } from "./utils/voice-support";
82
88
  import { VoiceTurnTracker } from "./voice/voice-turn-tracker";
83
- import { loadVoiceRuntime } from "./voice-runtime-loader";
89
+ import type { KeyedVoiceTranscript } from "./voice/keyed-voice-transcript";
90
+ import type { VoiceDelegationCapture } from "./voice/voice-delegation";
91
+ import { loadVoiceRuntime, type VoiceRuntimeModule } from "./voice-runtime-loader";
92
+ import type { VoiceWiringSession } from "./voice/session-voice-wiring";
84
93
  import { resolveSpeakableText } from "./utils/speech-text";
85
94
  import { loadRuntypeTts } from "./voice/runtype-tts-loader";
86
95
  // Type-only (erased at build): the runtime reconnect machinery ships in the
@@ -158,7 +167,7 @@ function connectionConfigChanged(
158
167
  return CONNECTION_CONFIG_KEYS.some((key) => prev[key] !== next[key]);
159
168
  }
160
169
 
161
- type SessionCallbacks = {
170
+ export type SessionCallbacks = {
162
171
  onMessagesChanged: (messages: AgentWidgetMessage[]) => void;
163
172
  onStatusChanged: (status: AgentWidgetSessionStatus) => void;
164
173
  onStreamingChanged: (streaming: boolean) => void;
@@ -262,20 +271,6 @@ function buildDispatchErrorContent(
262
271
  return err.message ? `${base}\n\n_Details: ${err.message}_` : base;
263
272
  }
264
273
 
265
- const buildWebMcpErrorResult = (message: string) => ({
266
- isError: true,
267
- content: [{ type: "text" as const, text: message }],
268
- });
269
-
270
- const getWebMcpErrorMessage = (
271
- error: unknown,
272
- fallback = "WebMCP tool execution failed.",
273
- ): string => {
274
- if (error instanceof Error && error.message) return error.message;
275
- if (typeof error === "string" && error) return error;
276
- return fallback;
277
- };
278
-
279
274
  /**
280
275
  * Tool names whose `await` the widget resolves automatically (no user
281
276
  * pill click): `webmcp:*` page tools and the built-in fire-and-forget
@@ -290,8 +285,12 @@ export class AgentWidgetSession {
290
285
  private messages: AgentWidgetMessage[];
291
286
  private status: AgentWidgetSessionStatus = "idle";
292
287
  private streaming = false;
288
+ /** Approval messages decided on a stream, keyed to that stream so it settles only its own. */
289
+ #approvalSettlesAtStreamEnd = new Map<string, object | undefined>();
290
+ /** The `connectStream` call whose event is being handled, if any. */
291
+ #activeStreamToken: object | undefined;
293
292
  private abortController: AbortController | null = null;
294
- private sequenceCounter = Date.now();
293
+ #sequenceCounter = Date.now();
295
294
 
296
295
  // Client token session management
297
296
  private clientSession: ClientSession | null = null;
@@ -307,23 +306,23 @@ export class AgentWidgetSession {
307
306
  // cursor climbs; cleared on a graceful terminal, a terminal error, or any
308
307
  // teardown. Drives both in-session reconnect and host persistence
309
308
  // (`onExecutionState`).
310
- private resumable: ResumableHandle | null = null;
309
+ #resumable: ResumableHandle | null = null;
311
310
  // The assistant message id of the current turn, so a reconnect keeps filling
312
311
  // the SAME bubble instead of opening a new one.
313
- private activeAssistantMessageId: string | null = null;
312
+ #activeAssistantMessageId: string | null = null;
314
313
  // True while a reconnect run is active (guards against re-entry from a second
315
314
  // drop and tells the dispatch/connectStream catches to stay quiet).
316
- private reconnecting = false;
315
+ #reconnecting = false;
317
316
  // The reconnect orchestration (backoff loop, wake listeners, give-up
318
317
  // finalizer) lives in a lazily-imported module so it stays out of bundles
319
318
  // that never opt into durable reconnect. Created on the first reconnect.
320
- private reconnectController: ReconnectController | null = null;
321
- private reconnectControllerPromise: Promise<ReconnectController> | null = null;
319
+ #reconnectController: ReconnectController | null = null;
320
+ #reconnectControllerPromise: Promise<ReconnectController> | null = null;
322
321
  // Trailing-edge throttle for `onExecutionState` so it isn't fired per delta.
323
- private executionStateTimer: ReturnType<typeof setTimeout> | null = null;
322
+ #executionStateTimer: ReturnType<typeof setTimeout> | null = null;
324
323
 
325
324
  private artifacts = new Map<string, PersonaArtifactRecord>();
326
- private selectedArtifactId: string | null = null;
325
+ #selectedArtifactId: string | null = null;
327
326
 
328
327
  // WebMCP dedupe: keys are `${executionId}:${toolCallId}` so they're
329
328
  // naturally scoped to a single dispatch. A later dispatch (new executionId)
@@ -361,7 +360,7 @@ export class AgentWidgetSession {
361
360
  // queueMicrotask captures the epoch at queue time and bails if it changed,
362
361
  // so a resolve queued just before a teardown can't escape it by installing a
363
362
  // fresh controller after the set was already cleared.
364
- private webMcpEpoch = 0;
363
+ #webMcpEpoch = 0;
365
364
  // WebMCP native approval-bubble gate. When no custom `webmcp.onConfirm` is
366
365
  // supplied, the bridge's confirm handler routes here: we inject an
367
366
  // approval-variant message and park the bridge on a Promise that resolves
@@ -369,7 +368,11 @@ export class AgentWidgetSession {
369
368
  // resolveWebMcpApproval). Resolvers are keyed by the approval message id.
370
369
  private webMcpApprovalResolvers: Map<string, (approved: boolean) => void> =
371
370
  new Map();
372
- private webMcpApprovalSeq = 0;
371
+ #webMcpApprovalSeq = 0;
372
+ // Per approval card: the token of its latest in-flight decision request.
373
+ // Only that request's failure may reopen the card (two decisions can share
374
+ // a Date.now()). Each entry is dropped once its request settles.
375
+ private approvalTokens = new Map<string, object>();
373
376
  // Parallel local-tool batching (core#3878). A single model turn can emit
374
377
  // multiple `await(local_tool_required)` events for ONE paused
375
378
  // executionId: including two PARALLEL calls to the SAME tool ("add SHOE-001
@@ -387,60 +390,60 @@ export class AgentWidgetSession {
387
390
  > = new Map();
388
391
 
389
392
  // Voice support
390
- private voiceProvider: VoiceProvider | null = null;
393
+ #voiceProvider: VoiceProvider | null = null;
391
394
  /** In-flight setupVoice chunk load; toggleVoice awaits it when racing setup. */
392
- private voiceSetupPromise: Promise<void> | null = null;
395
+ #voiceSetupPromise: Promise<void> | null = null;
393
396
  /** Bumped by setupVoice/cleanupVoice so a stale chunk load can't install. */
394
- private voiceSetupGeneration = 0;
397
+ #voiceSetupGeneration = 0;
395
398
  /** Latest 0..1 capture amplitude pushed by a provider that reports one. */
396
- private voiceLevel = 0;
397
- private voiceActive = false;
398
- private voiceStatus: VoiceStatus = 'disconnected';
399
+ #voiceLevel = 0;
400
+ #voiceActive = false;
401
+ #voiceStatus: VoiceStatus = 'disconnected';
399
402
 
400
403
  // ── Visitor conversation history (D5) ──────────────────────────
401
404
  // Identity of the ONE currently open record; not a conversation→session map.
402
- private activeConversationId: string | null = null;
403
- private activeConversationRevision: string | null = null;
404
- private historyNextMessageCursor: string | null = null;
405
+ #activeConversationId: string | null = null;
406
+ #activeConversationRevision: string | null = null;
407
+ #historyNextMessageCursor: string | null = null;
405
408
  /** Monotonic: a later selection supersedes every earlier fetch/prepare. */
406
- private historyOpenEpoch = 0;
409
+ #historyOpenEpoch = 0;
407
410
  /** Blocks `setClientSession`'s welcome branch across a reopen re-init. */
408
- private suppressWelcomeInjection = false;
409
- private historyRecovery: "new_conversation_required" | null = null;
410
- private historySendBlocked = false;
411
+ #suppressWelcomeInjection = false;
412
+ #historyRecovery: "new_conversation_required" | null = null;
413
+ #historySendBlocked = false;
411
414
  /** Continuity wipe / boot reconciliation; dispatch waits on it. */
412
- private historyGate: Promise<void> | null = null;
415
+ #historyGate: Promise<void> | null = null;
413
416
  /** Deduped by `(id, cursor)` so repeated clicks issue one request. */
414
- private olderPageRequests = new Map<string, Promise<HistoryOlderPage>>();
417
+ #olderPageRequests = new Map<string, Promise<HistoryOlderPage>>();
415
418
  /** Serializes projection finalization against every other operation. */
416
- private projectionFinalizationPromise: Promise<void> | null = null;
419
+ #projectionFinalizationPromise: Promise<void> | null = null;
417
420
  /** Message id → projection the server has acknowledged (sent or finalized). */
418
- private acknowledgedProjections = new Map<string, string>();
419
- private pendingProjections: PendingDisplayProjections | null = null;
421
+ #acknowledgedProjections = new Map<string, string>();
422
+ #pendingProjections: PendingDisplayProjections | null = null;
420
423
  /** Persisted revision as it stood before boot init overwrote it. */
421
- private bootConversationRevision: string | null = null;
422
- private bootRevisionCaptured = false;
424
+ #bootConversationRevision: string | null = null;
425
+ #bootRevisionCaptured = false;
423
426
  /** Exactly one visitor-store subscription per instance; re-keyed with the store. */
424
- private visitorStoreUnsubscribe: (() => void) | null = null;
425
- private subscribedVisitorStore: VisitorStore | null = null;
427
+ #visitorStoreUnsubscribe: (() => void) | null = null;
428
+ #subscribedVisitorStore: VisitorStore | null = null;
426
429
  /** An external credential change happened; the next dispatch must re-init. */
427
- private credentialReinitPending = false;
428
- private credentialReinitPromise: Promise<void> | null = null;
430
+ #credentialReinitPending = false;
431
+ #credentialReinitPromise: Promise<void> | null = null;
429
432
 
430
433
  constructor(
431
434
  private config: AgentWidgetConfig = {},
432
435
  private callbacks: SessionCallbacks,
433
436
  private historyInternals: WidgetHistoryInternals = {}
434
437
  ) {
435
- this.historyInternals = this.composeHistoryInternals(historyInternals);
438
+ this.historyInternals = this.#composeHistoryInternals(historyInternals);
436
439
  this.messages = [...(config.initialMessages ?? [])].map((message) => ({
437
440
  ...message,
438
- sequence: message.sequence ?? this.nextSequence()
441
+ sequence: message.sequence ?? this.#nextSequence()
439
442
  }));
440
- this.messages = this.sortMessages(this.messages);
443
+ this.messages = this.#sortMessages(this.messages);
441
444
  this.client = new AgentWidgetClient(config, this.historyInternals);
442
- this.syncVisitorStoreSubscription();
443
- this.wireDefaultWebMcpConfirm();
445
+ this.#syncVisitorStoreSubscription();
446
+ this.#wireDefaultWebMcpConfirm();
444
447
 
445
448
  // Hydrate artifacts from config (mirrors `initialMessages`). Restored
446
449
  // records are forced to `status: "complete"`: a mid-stream artifact should
@@ -449,18 +452,18 @@ export class AgentWidgetSession {
449
452
  this.artifacts.set(rec.id, { ...rec, status: "complete" });
450
453
  }
451
454
  if (config.initialSelectedArtifactId != null) {
452
- this.selectedArtifactId = config.initialSelectedArtifactId;
455
+ this.#selectedArtifactId = config.initialSelectedArtifactId;
453
456
  }
454
457
 
455
458
  if (this.messages.length) {
456
459
  this.callbacks.onMessagesChanged([...this.messages]);
457
460
  }
458
461
  if (this.artifacts.size > 0) {
459
- this.emitArtifactsState();
462
+ this.#emitArtifactsState();
460
463
  }
461
464
  this.callbacks.onStatusChanged(this.status);
462
- this.prefetchRuntypeTts();
463
- this.prefetchVoiceRuntime();
465
+ this.#prefetchRuntypeTts();
466
+ this.#prefetchVoiceRuntime();
464
467
  }
465
468
 
466
469
  /**
@@ -470,7 +473,7 @@ export class AgentWidgetSession {
470
473
  * AudioContext still wait for their own triggers. Errors are swallowed: the
471
474
  * setupVoice path retries via the chunk loader's rejection-retry.
472
475
  */
473
- private prefetchVoiceRuntime(): void {
476
+ #prefetchVoiceRuntime(): void {
474
477
  if (!this.config.voiceRecognition?.provider) return;
475
478
  void loadVoiceRuntime().catch(() => {});
476
479
  }
@@ -484,7 +487,7 @@ export class AgentWidgetSession {
484
487
  * prefetch just means the chunk loads on first click instead (or, on a hard
485
488
  * failure, the browser-voice fallback kicks in).
486
489
  */
487
- private prefetchRuntypeTts(): void {
490
+ #prefetchRuntypeTts(): void {
488
491
  const tts = this.config.textToSpeech;
489
492
  if (tts?.provider !== "runtype" || tts.createEngine) return;
490
493
  // Only warm the chunk when the engine will actually be built (same gate as
@@ -543,14 +546,19 @@ export class AgentWidgetSession {
543
546
  * Check if voice is currently active
544
547
  */
545
548
  public isVoiceActive(): boolean {
546
- return this.voiceActive;
549
+ return this.#voiceActive;
547
550
  }
548
551
 
549
552
  /**
550
553
  * Get current voice status
551
554
  */
552
555
  public getVoiceStatus(): VoiceStatus {
553
- return this.voiceStatus;
556
+ return this.#voiceStatus;
557
+ }
558
+
559
+ /** The live call's AI-disclosure notice, if it shows one. */
560
+ public getVoiceDisclosure(): string | null {
561
+ return this.#voiceProvider?.getDisclosure?.() ?? null;
554
562
  }
555
563
 
556
564
  /**
@@ -562,12 +570,12 @@ export class AgentWidgetSession {
562
570
  * always report null and the UI falls back to a fixed midpoint.
563
571
  */
564
572
  public getVoiceLevel(): number | null {
565
- return this.voiceProvider?.onLevel ? this.voiceLevel : null;
573
+ return this.#voiceProvider?.onLevel ? this.#voiceLevel : null;
566
574
  }
567
575
 
568
576
  public getVoiceInterruptionMode(): "none" | "cancel" | "barge-in" {
569
- if (this.voiceProvider?.getInterruptionMode) {
570
- return this.voiceProvider.getInterruptionMode();
577
+ if (this.#voiceProvider?.getInterruptionMode) {
578
+ return this.#voiceProvider.getInterruptionMode();
571
579
  }
572
580
  return "none";
573
581
  }
@@ -577,29 +585,36 @@ export class AgentWidgetSession {
577
585
  * Returns to idle state.
578
586
  */
579
587
  public stopVoicePlayback(): void {
580
- if (this.voiceProvider?.stopPlayback) {
581
- this.voiceTurns.cancel();
582
- this.voiceProvider.stopPlayback();
588
+ if (this.#voiceProvider?.stopPlayback) {
589
+ this.#voiceTurns.cancel();
590
+ this.#keyedVoice?.cancel();
591
+ this.#voiceProvider.stopPlayback();
583
592
  }
584
593
  }
585
594
 
586
595
  /** Returns true if the barge-in mic stream is alive (hot mic between turns) */
587
596
  public isBargeInActive(): boolean {
588
- return this.voiceProvider?.isBargeInActive?.() ?? false;
597
+ return this.#voiceProvider?.isBargeInActive?.() ?? false;
589
598
  }
590
599
 
591
600
  /** Tear down the barge-in mic pipeline: "hang up" the always-on mic */
592
601
  public async deactivateBargeIn(): Promise<void> {
593
- if (this.voiceProvider?.deactivateBargeIn) {
594
- await this.voiceProvider.deactivateBargeIn();
602
+ if (this.#voiceProvider?.deactivateBargeIn) {
603
+ await this.#voiceProvider.deactivateBargeIn();
595
604
  }
596
605
  }
597
606
 
598
607
  // Pending placeholder IDs for Runtype two-phase voice flow
599
- private pendingVoiceUserMessageId: string | null = null;
600
- private pendingVoiceAssistantMessageId: string | null = null;
601
- private voiceTurns = new VoiceTurnTracker();
602
- private voiceDisconnectPromise: Promise<void> = Promise.resolve();
608
+ #pendingVoiceUserMessageId: string | null = null;
609
+ #pendingVoiceAssistantMessageId: string | null = null;
610
+ #voiceTurns = new VoiceTurnTracker();
611
+ #voiceDisconnectPromise: Promise<void> = Promise.resolve();
612
+ // Turn-keyed (full-duplex) transcript reconciler, from the lazy voice runtime.
613
+ #keyedVoice: KeyedVoiceTranscript | null = null;
614
+ // The delegated voice turn running through sendMessage: the assistant
615
+ // messages it streams, and whether it failed or was aborted.
616
+ #voiceDelegation: VoiceDelegationCapture | null = null;
617
+
603
618
 
604
619
  // Track message IDs where the Runtype provider already played TTS audio
605
620
  // so browser TTS doesn't double-speak them
@@ -609,7 +624,7 @@ export class AgentWidgetSession {
609
624
  // message is active, its play/pause state, and the speech engine. The engine
610
625
  // is resolved lazily on first playback (inside the user gesture) so a hosted
611
626
  // engine via `textToSpeech.createEngine` plugs in without changing this class.
612
- private readAloud = new ReadAloudController(() => this.createSpeechEngine());
627
+ private readAloud = new ReadAloudController(() => this.#createSpeechEngine());
613
628
 
614
629
  /**
615
630
  * Resolve the speech engine behind read-aloud (and auto-speak):
@@ -622,7 +637,7 @@ export class AgentWidgetSession {
622
637
  * Runtype voices the day the endpoint ships.
623
638
  * 3. Otherwise the browser Web Speech API engine.
624
639
  */
625
- private createSpeechEngine(): SpeechEngine | Promise<SpeechEngine> | null {
640
+ #createSpeechEngine(): SpeechEngine | Promise<SpeechEngine> | null {
626
641
  const tts = this.config.textToSpeech;
627
642
  if (tts?.createEngine) return tts.createEngine();
628
643
 
@@ -694,7 +709,7 @@ export class AgentWidgetSession {
694
709
  }
695
710
 
696
711
  this.cleanupVoice();
697
- const disconnected = this.voiceDisconnectPromise;
712
+ const disconnected = this.#voiceDisconnectPromise;
698
713
 
699
714
  // The provider runtime ships in the lazy voice-runtime chunk. The sync
700
715
  // signature is preserved: construction + wiring + connect() happen when
@@ -702,19 +717,19 @@ export class AgentWidgetSession {
702
717
  // provider is configured, so this is warm in practice). A stale
703
718
  // resolution (cleanupVoice or a newer setupVoice ran meanwhile) is
704
719
  // discarded via the generation token.
705
- const generation = ++this.voiceSetupGeneration;
706
- this.voiceSetupPromise = loadVoiceRuntime()
720
+ const generation = ++this.#voiceSetupGeneration;
721
+ this.#voiceSetupPromise = loadVoiceRuntime()
707
722
  .then(async (mod) => {
708
723
  await disconnected;
709
- if (generation !== this.voiceSetupGeneration) return;
710
- this.wireVoiceProvider(mod.createVoiceProvider(voiceConfig));
724
+ if (generation !== this.#voiceSetupGeneration) return;
725
+ this.#wireVoiceProvider(mod, mod.createVoiceProvider(voiceConfig));
711
726
  })
712
727
  .catch((error) => {
713
728
  console.error('Failed to setup voice:', error);
714
729
  })
715
730
  .finally(() => {
716
- if (generation === this.voiceSetupGeneration) {
717
- this.voiceSetupPromise = null;
731
+ if (generation === this.#voiceSetupGeneration) {
732
+ this.#voiceSetupPromise = null;
718
733
  }
719
734
  });
720
735
  } catch (error) {
@@ -722,165 +737,56 @@ export class AgentWidgetSession {
722
737
  }
723
738
  }
724
739
 
725
- /** Wire callbacks onto a freshly constructed provider and connect it. */
726
- private wireVoiceProvider(provider: VoiceProvider): void {
727
- try {
728
- this.voiceProvider = provider;
729
- const generation = this.voiceSetupGeneration;
730
- const isCurrent = () => this.voiceProvider === provider && generation === this.voiceSetupGeneration;
731
-
732
- // Read configurable text from widget config
733
- const voiceRecognitionConfig = this.config.voiceRecognition ?? {};
734
- const processingErrorText = voiceRecognitionConfig.processingErrorText ?? 'Voice processing failed. Please try again.';
735
-
736
- // STT-style providers (browser + bring-your-own `custom`) deliver a final
737
- // transcript that we send as a normal user message: the agent then runs
738
- // via the standard SSE chat path. Only the realtime `runtype` provider is
739
- // excluded here: it owns the whole turn and drives onTranscript below.
740
- this.voiceProvider.onResult((result) => {
741
- if (!isCurrent()) return;
742
- if (result.provider !== 'runtype') {
743
- if (result.text && result.text.trim()) {
744
- this.sendMessage(result.text, { viaVoice: true });
745
- }
746
- }
747
- });
748
-
749
- // Realtime (runtype) voice: drive the chat thread from streaming
750
- // transcript frames. Live interim user text grows in place; the user
751
- // message finalizes immediately on transcript_final{user}; the assistant
752
- // reply lands (a single block, synced with audio) on its final frame.
753
- // In-flight bubbles carry voiceProcessing=true so consumers can style
754
- // them via messageTransform; it clears once the text is final.
755
- if (this.voiceProvider.onTranscript) {
756
- this.voiceProvider.onTranscript((role, text, isFinal, metadata) => {
757
- if (!isCurrent()) return;
758
- if (role === 'user') {
759
- if (!this.pendingVoiceUserMessageId) {
760
- const msg = this.injectMessage({
761
- role: 'user',
762
- content: text,
763
- streaming: false,
764
- voiceProcessing: !isFinal
765
- });
766
- this.pendingVoiceUserMessageId = msg.id;
767
- } else {
768
- this.upsertMessage({
769
- id: this.pendingVoiceUserMessageId,
770
- role: 'user',
771
- content: text,
772
- createdAt: new Date().toISOString(),
773
- streaming: false,
774
- voiceProcessing: !isFinal
775
- });
776
- }
777
-
778
- if (isFinal) {
779
- this.voiceTurns.start(metadata?.turnId);
780
- // User finished: the agent is now thinking. Release the user
781
- // bubble (a new interim starts a fresh turn) and show a typing
782
- // indicator in a fresh assistant placeholder.
783
- this.pendingVoiceUserMessageId = null;
784
- const assistantMsg = this.injectMessage({
785
- role: 'assistant',
786
- content: '',
787
- streaming: true,
788
- voiceProcessing: true
789
- });
790
- this.pendingVoiceAssistantMessageId = assistantMsg.id;
791
- this.setStreaming(true);
792
- }
793
- } else {
794
- if (!this.voiceTurns.accepts(metadata?.turnId)) return;
795
- // assistant: runtype sends a single final; the isFinal=false path
796
- // is reserved for delta-streaming providers (future BYO).
797
- if (this.pendingVoiceAssistantMessageId) {
798
- this.upsertMessage({
799
- id: this.pendingVoiceAssistantMessageId,
800
- role: 'assistant',
801
- content: text,
802
- createdAt: new Date().toISOString(),
803
- streaming: !isFinal,
804
- voiceProcessing: !isFinal
805
- });
806
- } else {
807
- const msg = this.injectMessage({
808
- role: 'assistant',
809
- content: text,
810
- streaming: !isFinal,
811
- voiceProcessing: !isFinal
812
- });
813
- this.pendingVoiceAssistantMessageId = msg.id;
814
- }
815
-
816
- if (isFinal) {
817
- // The provider plays this reply's audio: mark it spoken so
818
- // browser TTS doesn't double-speak when streaming ends. Must run
819
- // BEFORE setStreaming(false), which triggers the TTS check.
820
- if (this.pendingVoiceAssistantMessageId) {
821
- this.ttsSpokenMessageIds.add(this.pendingVoiceAssistantMessageId);
822
- }
823
- this.setStreaming(false);
824
- this.pendingVoiceAssistantMessageId = null;
825
- }
826
- }
827
- });
828
- }
829
-
830
- // Live capture amplitude, when the provider owns an audio graph. Stored,
831
- // not forwarded per callback: the UI samples it on its own frame loop.
832
- if (this.voiceProvider.onLevel) {
833
- this.voiceProvider.onLevel((level) => {
834
- if (!isCurrent()) return;
835
- this.voiceLevel = Number.isFinite(level)
836
- ? Math.max(0, Math.min(1, level))
837
- : 0;
838
- });
839
- }
840
-
841
- // Surface per-turn latency metrics to the optional config hook.
842
- if (this.voiceProvider.onMetrics) {
843
- this.voiceProvider.onMetrics((metrics) => {
844
- if (!isCurrent()) return;
845
- this.config.voiceRecognition?.onMetrics?.(metrics);
846
- });
847
- }
848
-
849
- this.voiceProvider.onError((error) => {
850
- if (!isCurrent()) return;
851
- console.error('Voice error:', error);
852
-
853
- // If error occurs while placeholders are pending, update assistant with error text
854
- if (this.pendingVoiceAssistantMessageId) {
855
- this.upsertMessage({
856
- id: this.pendingVoiceAssistantMessageId,
857
- role: 'assistant',
858
- content: processingErrorText,
859
- createdAt: new Date().toISOString(),
860
- streaming: false,
861
- voiceProcessing: false
862
- });
863
- this.setStreaming(false);
864
- this.pendingVoiceUserMessageId = null;
865
- this.pendingVoiceAssistantMessageId = null;
866
- }
867
- });
868
-
869
- this.voiceProvider.onStatusChange((status) => {
870
- if (!isCurrent()) return;
871
- this.voiceStatus = status;
872
- this.voiceActive = status === 'listening';
873
- if (status === 'listening' || status === 'idle' || status === 'disconnected') {
874
- this.settlePendingVoiceTurn(status !== 'listening');
875
- }
876
- this.callbacks.onVoiceStatusChanged?.(status);
877
- });
878
-
879
- this.voiceProvider.connect();
880
-
881
- } catch (error) {
882
- console.error('Failed to setup voice:', error);
883
- }
740
+ /**
741
+ * Install a provider: the wiring itself ships in the lazy voice-runtime
742
+ * chunk (`voice/session-voice-wiring.ts`); this hands it the private state.
743
+ */
744
+ #wireVoiceProvider(mod: VoiceRuntimeModule, provider: VoiceProvider): void {
745
+ this.#voiceProvider = provider;
746
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
747
+ const session = this;
748
+ mod.wireSessionVoice(
749
+ {
750
+ session: this as unknown as VoiceWiringSession,
751
+ provider: () => session.#voiceProvider,
752
+ generation: () => session.#voiceSetupGeneration,
753
+ get keyed() {
754
+ return session.#keyedVoice;
755
+ },
756
+ set keyed(keyed) {
757
+ session.#keyedVoice = keyed;
758
+ },
759
+ get pendingUser() {
760
+ return session.#pendingVoiceUserMessageId;
761
+ },
762
+ set pendingUser(id) {
763
+ session.#pendingVoiceUserMessageId = id;
764
+ },
765
+ get pendingAssistant() {
766
+ return session.#pendingVoiceAssistantMessageId;
767
+ },
768
+ set pendingAssistant(id) {
769
+ session.#pendingVoiceAssistantMessageId = id;
770
+ },
771
+ turns: () => session.#voiceTurns,
772
+ busy: () => session.#chatTurnBusy(),
773
+ setStreaming: (streaming) => session.#setStreaming(streaming),
774
+ setDelegation: (capture) => {
775
+ session.#voiceDelegation = capture;
776
+ },
777
+ setLevel: (level) => {
778
+ session.#voiceLevel = level;
779
+ },
780
+ setStatus: (status) => {
781
+ session.#voiceStatus = status;
782
+ session.#voiceActive = status === 'listening';
783
+ },
784
+ settlePending: (final) => session.#settlePendingVoiceTurn(final),
785
+ },
786
+ provider,
787
+ mod.KeyedVoiceTranscript,
788
+ mod.createVoiceSessionBridge
789
+ );
884
790
  }
885
791
 
886
792
  /**
@@ -889,21 +795,21 @@ export class AgentWidgetSession {
889
795
  public async toggleVoice() {
890
796
  // A click can race the setup chunk load; wait for it rather than
891
797
  // erroring (prefetch at construction makes this window ~one microtask).
892
- if (!this.voiceProvider && this.voiceSetupPromise) {
893
- await this.voiceSetupPromise;
798
+ if (!this.#voiceProvider && this.#voiceSetupPromise) {
799
+ await this.#voiceSetupPromise;
894
800
  }
895
- if (!this.voiceProvider) {
801
+ if (!this.#voiceProvider) {
896
802
  console.error('Voice not configured');
897
803
  return;
898
804
  }
899
805
 
900
- if (this.voiceActive) {
901
- await this.voiceProvider.stopListening();
806
+ if (this.#voiceActive) {
807
+ await this.#voiceProvider.stopListening();
902
808
  } else {
903
809
  // Stop any in-progress TTS so the mic doesn't pick it up
904
810
  this.stopSpeaking();
905
811
  try {
906
- await this.voiceProvider.startListening();
812
+ await this.#voiceProvider.startListening();
907
813
  } catch (error) {
908
814
  console.error('Failed to start voice:', error);
909
815
  }
@@ -921,42 +827,55 @@ export class AgentWidgetSession {
921
827
  this.warmClientSession();
922
828
  return;
923
829
  }
924
- const generation = this.voiceSetupGeneration;
830
+ const generation = this.#voiceSetupGeneration;
925
831
  const run = () => {
926
- const provider = this.voiceProvider;
927
- if (!provider?.prewarm || generation !== this.voiceSetupGeneration) return;
928
- if (this.voiceActive || this.isBargeInActive()) return;
832
+ const provider = this.#voiceProvider;
833
+ if (!provider?.prewarm || generation !== this.#voiceSetupGeneration) return;
834
+ if (this.#voiceActive || this.isBargeInActive()) return;
929
835
  void Promise.resolve()
930
836
  .then(() => provider.prewarm?.())
931
837
  .catch(() => {});
932
838
  };
933
- if (!this.voiceProvider && this.voiceSetupPromise) {
934
- void this.voiceSetupPromise.then(run, () => {});
839
+ if (!this.#voiceProvider && this.#voiceSetupPromise) {
840
+ void this.#voiceSetupPromise.then(run, () => {});
935
841
  return;
936
842
  }
937
843
  run();
938
844
  }
939
845
 
846
+ /** A chat turn is streaming, resuming, or running local (WebMCP) tools. */
847
+ #chatTurnBusy(): boolean {
848
+ return (
849
+ !!this.abortController ||
850
+ this.webMcpResolveControllers.size > 0 ||
851
+ this.webMcpAwaitBatches.size > 0 ||
852
+ this.#reconnecting ||
853
+ this.status === 'resuming'
854
+ );
855
+ }
856
+
940
857
  /**
941
858
  * Cleanup voice resources
942
859
  */
943
860
  public cleanupVoice() {
944
- const notifyDisconnected = this.voiceProvider !== null;
945
- this.voiceSetupGeneration++;
946
- this.voiceSetupPromise = null;
947
- if (this.voiceProvider) {
948
- const provider = this.voiceProvider;
949
- this.voiceProvider = null;
950
- this.voiceDisconnectPromise = this.disconnectVoice(provider);
861
+ const notifyDisconnected = this.#voiceProvider !== null;
862
+ this.#voiceSetupGeneration++;
863
+ this.#voiceSetupPromise = null;
864
+ if (this.#voiceProvider) {
865
+ const provider = this.#voiceProvider;
866
+ this.#voiceProvider = null;
867
+ this.#voiceDisconnectPromise = this.#disconnectVoice(provider);
951
868
  }
952
- this.voiceActive = false;
953
- this.voiceStatus = 'disconnected';
954
- this.settlePendingVoiceTurn(true);
955
- this.voiceTurns = new VoiceTurnTracker();
869
+ this.#voiceActive = false;
870
+ this.#voiceStatus = 'disconnected';
871
+ this.#settlePendingVoiceTurn(true);
872
+ this.#keyedVoice?.settle();
873
+ this.#keyedVoice = null;
874
+ this.#voiceTurns = new VoiceTurnTracker();
956
875
  if (notifyDisconnected) this.callbacks.onVoiceStatusChanged?.('disconnected');
957
876
  }
958
877
 
959
- private async disconnectVoice(provider: VoiceProvider): Promise<void> {
878
+ async #disconnectVoice(provider: VoiceProvider): Promise<void> {
960
879
  try {
961
880
  await provider.disconnect();
962
881
  } catch (error) {
@@ -964,13 +883,13 @@ export class AgentWidgetSession {
964
883
  }
965
884
  }
966
885
 
967
- private settlePendingVoiceTurn(includeUser: boolean): void {
968
- const userId = includeUser ? this.pendingVoiceUserMessageId : null;
969
- const assistantId = this.pendingVoiceAssistantMessageId;
886
+ #settlePendingVoiceTurn(includeUser: boolean): void {
887
+ const userId = includeUser ? this.#pendingVoiceUserMessageId : null;
888
+ const assistantId = this.#pendingVoiceAssistantMessageId;
970
889
  if (!userId && !assistantId) return;
971
- if (assistantId) this.voiceTurns.cancel();
972
- if (includeUser) this.pendingVoiceUserMessageId = null;
973
- this.pendingVoiceAssistantMessageId = null;
890
+ if (assistantId) this.#voiceTurns.cancel();
891
+ if (includeUser) this.#pendingVoiceUserMessageId = null;
892
+ this.#pendingVoiceAssistantMessageId = null;
974
893
  if (assistantId) this.ttsSpokenMessageIds.add(assistantId);
975
894
  this.messages = this.messages.flatMap((message) => {
976
895
  if (message.id !== userId && message.id !== assistantId) return [message];
@@ -978,7 +897,7 @@ export class AgentWidgetSession {
978
897
  return [{ ...message, streaming: false, voiceProcessing: false }];
979
898
  });
980
899
  this.callbacks.onMessagesChanged([...this.messages]);
981
- if (assistantId) this.setStreaming(false);
900
+ if (assistantId) this.#setStreaming(false);
982
901
  }
983
902
 
984
903
  /**
@@ -1004,7 +923,11 @@ export class AgentWidgetSession {
1004
923
  voiceId: providerConfig.runtype?.voiceId,
1005
924
  createPlaybackEngine: providerConfig.runtype?.createPlaybackEngine,
1006
925
  prewarmMode: providerConfig.runtype?.prewarmMode,
1007
- attachIdleMs: providerConfig.runtype?.attachIdleMs
926
+ attachIdleMs: providerConfig.runtype?.attachIdleMs,
927
+ clientDelegation: providerConfig.runtype?.clientDelegation,
928
+ callContext: providerConfig.runtype?.callContext,
929
+ approvalTimeoutMs: providerConfig.runtype?.approvalTimeoutMs,
930
+ disclosureText: providerConfig.runtype?.disclosureText
1008
931
  }
1009
932
  };
1010
933
 
@@ -1046,9 +969,9 @@ export class AgentWidgetSession {
1046
969
  await this.historyInternals.historyBootstrapReady;
1047
970
  // Snapshot before init overwrites it: boot reconciliation compares the
1048
971
  // init revision against what the LAST page load persisted.
1049
- this.bootConversationRevision =
972
+ this.#bootConversationRevision =
1050
973
  this.historyInternals.getStoredConversationRevision?.() ?? null;
1051
- this.bootRevisionCaptured = true;
974
+ this.#bootRevisionCaptured = true;
1052
975
  }
1053
976
 
1054
977
  try {
@@ -1085,7 +1008,7 @@ export class AgentWidgetSession {
1085
1008
  */
1086
1009
  public setClientSession(session: ClientSession, pristine?: boolean): void {
1087
1010
  this.clientSession = session;
1088
- this.injectWelcomeMessage(session, pristine);
1011
+ this.#injectWelcomeMessage(session, pristine);
1089
1012
  }
1090
1013
 
1091
1014
  /**
@@ -1093,11 +1016,11 @@ export class AgentWidgetSession {
1093
1016
  * reopening a stored conversation is not a new thread, and the hydrated
1094
1017
  * transcript would otherwise race a welcome bubble.
1095
1018
  */
1096
- private injectWelcomeMessage(
1019
+ #injectWelcomeMessage(
1097
1020
  session: ClientSession,
1098
1021
  pristine = !this.messages.length
1099
1022
  ): void {
1100
- if (this.suppressWelcomeInjection) return;
1023
+ if (this.#suppressWelcomeInjection) return;
1101
1024
  if (!session.config.welcomeMessage || !pristine) return;
1102
1025
  // A late welcome (the first send raced the init) sorts ahead of that turn.
1103
1026
  const first = this.messages[0];
@@ -1106,9 +1029,9 @@ export class AgentWidgetSession {
1106
1029
  role: "assistant",
1107
1030
  content: session.config.welcomeMessage,
1108
1031
  createdAt: first?.createdAt ?? new Date().toISOString(),
1109
- sequence: first ? (first.sequence ?? 0) - 1 : this.nextSequence()
1032
+ sequence: first ? (first.sequence ?? 0) - 1 : this.#nextSequence()
1110
1033
  };
1111
- this.appendMessage(welcomeMessage);
1034
+ this.#appendMessage(welcomeMessage);
1112
1035
  }
1113
1036
 
1114
1037
  /**
@@ -1184,17 +1107,17 @@ export class AgentWidgetSession {
1184
1107
  * client and any rebuilt one share the same store.
1185
1108
  */
1186
1109
  public setHistoryInternals(internals: WidgetHistoryInternals): void {
1187
- this.historyInternals = this.composeHistoryInternals(internals);
1110
+ this.historyInternals = this.#composeHistoryInternals(internals);
1188
1111
  this.client.setHistoryInternals(this.historyInternals);
1189
- this.syncVisitorStoreSubscription();
1112
+ this.#syncVisitorStoreSubscription();
1190
1113
  }
1191
1114
 
1192
1115
  /** Widget teardown: release the visitor-store subscription. */
1193
1116
  public destroy(): void {
1194
1117
  this.cleanupVoice();
1195
- this.visitorStoreUnsubscribe?.();
1196
- this.visitorStoreUnsubscribe = null;
1197
- this.subscribedVisitorStore = null;
1118
+ this.#visitorStoreUnsubscribe?.();
1119
+ this.#visitorStoreUnsubscribe = null;
1120
+ this.#subscribedVisitorStore = null;
1198
1121
  }
1199
1122
 
1200
1123
  // ==========================================================================
@@ -1206,25 +1129,25 @@ export class AgentWidgetSession {
1206
1129
  * supplied its own observer: a credential/record break must wipe local state
1207
1130
  * before the replacement session becomes sendable.
1208
1131
  */
1209
- private composeHistoryInternals(
1132
+ #composeHistoryInternals(
1210
1133
  internals: WidgetHistoryInternals
1211
1134
  ): WidgetHistoryInternals {
1212
1135
  const hostContinuity = internals.onHistoryContinuityChanged;
1213
1136
  return {
1214
1137
  ...internals,
1215
1138
  onHistoryContinuityChanged: (info) => {
1216
- this.handleHistoryContinuityChanged(info);
1139
+ this.#handleHistoryContinuityChanged(info);
1217
1140
  hostContinuity?.(info);
1218
1141
  },
1219
1142
  };
1220
1143
  }
1221
1144
 
1222
- private get historyProvider(): HistoryProvider | null {
1145
+ get #historyProvider(): HistoryProvider | null {
1223
1146
  return this.historyInternals.historyProvider ?? null;
1224
1147
  }
1225
1148
 
1226
- private requireHistoryProvider(): HistoryProvider {
1227
- const provider = this.historyProvider;
1149
+ #requireHistoryProvider(): HistoryProvider {
1150
+ const provider = this.#historyProvider;
1228
1151
  if (!provider) {
1229
1152
  throw new SessionHistoryError(
1230
1153
  "history_unavailable",
@@ -1238,8 +1161,8 @@ export class AgentWidgetSession {
1238
1161
  * One resolved scope per logical action. An explicitly requested scope the
1239
1162
  * provider cannot serve fails locally rather than silently downgrading.
1240
1163
  */
1241
- private historyContext(scope?: HistoryScope): HistoryOperationContext {
1242
- const provider = this.requireHistoryProvider();
1164
+ #historyContext(scope?: HistoryScope): HistoryOperationContext {
1165
+ const provider = this.#requireHistoryProvider();
1243
1166
  const requested = scope ?? this.config.features?.history?.scope;
1244
1167
  const derived: HistoryScope = this.config.getIdentityProof
1245
1168
  ? "verified-user"
@@ -1262,23 +1185,23 @@ export class AgentWidgetSession {
1262
1185
 
1263
1186
  public getHistoryState(): SessionHistoryState {
1264
1187
  return {
1265
- activeConversationId: this.activeConversationId,
1266
- conversationRevision: this.activeConversationRevision,
1267
- nextMessageCursor: this.historyNextMessageCursor,
1268
- recovery: this.historyRecovery,
1269
- sendBlocked: this.historySendBlocked,
1188
+ activeConversationId: this.#activeConversationId,
1189
+ conversationRevision: this.#activeConversationRevision,
1190
+ nextMessageCursor: this.#historyNextMessageCursor,
1191
+ recovery: this.#historyRecovery,
1192
+ sendBlocked: this.#historySendBlocked,
1270
1193
  };
1271
1194
  }
1272
1195
 
1273
1196
  public getActiveConversationId(): string | null {
1274
- return this.activeConversationId;
1197
+ return this.#activeConversationId;
1275
1198
  }
1276
1199
 
1277
- private emitHistoryState(): void {
1200
+ #emitHistoryState(): void {
1278
1201
  this.callbacks.onHistoryStateChanged?.(this.getHistoryState());
1279
1202
  }
1280
1203
 
1281
- private notifyHistory(
1204
+ #notifyHistory(
1282
1205
  code: SessionHistoryNoticeCode,
1283
1206
  message: string
1284
1207
  ): void {
@@ -1287,13 +1210,13 @@ export class AgentWidgetSession {
1287
1210
 
1288
1211
  /** Pending destructive/reconciliation work every other operation waits on. */
1289
1212
  public async awaitHistorySettled(): Promise<void> {
1290
- const gate = this.historyGate;
1213
+ const gate = this.#historyGate;
1291
1214
  if (gate) await gate.catch(() => {});
1292
- const finalization = this.projectionFinalizationPromise;
1215
+ const finalization = this.#projectionFinalizationPromise;
1293
1216
  if (finalization) await finalization.catch(() => {});
1294
1217
  }
1295
1218
 
1296
- private assertHistoryIdle(): void {
1219
+ #assertHistoryIdle(): void {
1297
1220
  if (this.streaming || this.status === "paused" || this.status === "resuming") {
1298
1221
  throw new SessionHistoryError(
1299
1222
  "conversation_busy",
@@ -1308,12 +1231,12 @@ export class AgentWidgetSession {
1308
1231
  targetId?: string;
1309
1232
  scope?: HistoryScope;
1310
1233
  }) {
1311
- const provider = this.requireHistoryProvider();
1234
+ const provider = this.#requireHistoryProvider();
1312
1235
  return provider.list({
1313
1236
  ...(opts?.cursor ? { cursor: opts.cursor } : {}),
1314
1237
  ...(opts?.limit !== undefined ? { limit: opts.limit } : {}),
1315
1238
  ...(opts?.targetId !== undefined ? { targetId: opts.targetId } : {}),
1316
- context: this.historyContext(opts?.scope),
1239
+ context: this.#historyContext(opts?.scope),
1317
1240
  });
1318
1241
  }
1319
1242
 
@@ -1329,46 +1252,46 @@ export class AgentWidgetSession {
1329
1252
  summary: HistoryConversationSummary;
1330
1253
  nextMessageCursor: string | null;
1331
1254
  }> {
1332
- const provider = this.requireHistoryProvider();
1333
- this.assertHistoryIdle();
1334
- const context = this.historyContext(opts?.scope);
1255
+ const provider = this.#requireHistoryProvider();
1256
+ this.#assertHistoryIdle();
1257
+ const context = this.#historyContext(opts?.scope);
1335
1258
  await this.awaitHistorySettled();
1336
- const epoch = ++this.historyOpenEpoch;
1259
+ const epoch = ++this.#historyOpenEpoch;
1337
1260
 
1338
1261
  const page = await provider.getPage(id, { context });
1339
- if (epoch !== this.historyOpenEpoch) {
1262
+ if (epoch !== this.#historyOpenEpoch) {
1340
1263
  throw new SessionHistoryError("superseded", "A later selection won");
1341
1264
  }
1342
1265
 
1343
1266
  const prepared = await provider.prepareOpen(id, { context });
1344
- if (epoch !== this.historyOpenEpoch) {
1267
+ if (epoch !== this.#historyOpenEpoch) {
1345
1268
  prepared.discard();
1346
1269
  throw new SessionHistoryError("superseded", "A later selection won");
1347
1270
  }
1348
1271
 
1349
1272
  // The reopened transcript is not a new conversation: never inject welcome.
1350
- this.suppressWelcomeInjection = true;
1273
+ this.#suppressWelcomeInjection = true;
1351
1274
  try {
1352
- await this.commitActivation(prepared);
1275
+ await this.#commitActivation(prepared);
1353
1276
  } finally {
1354
- this.suppressWelcomeInjection = false;
1277
+ this.#suppressWelcomeInjection = false;
1355
1278
  }
1356
1279
 
1357
- this.activeConversationId = prepared.conversationId || id;
1358
- this.activeConversationRevision =
1280
+ this.#activeConversationId = prepared.conversationId || id;
1281
+ this.#activeConversationRevision =
1359
1282
  prepared.conversationRevision || page.conversationRevision || null;
1360
- this.historyNextMessageCursor = page.nextCursor;
1361
- this.historyRecovery = null;
1362
- this.historySendBlocked = false;
1363
- this.resetConversationScopedState();
1283
+ this.#historyNextMessageCursor = page.nextCursor;
1284
+ this.#historyRecovery = null;
1285
+ this.#historySendBlocked = false;
1286
+ this.#resetConversationScopedState();
1364
1287
  // Server projections are by definition acknowledged.
1365
1288
  for (const message of page.messages) {
1366
- this.acknowledgedProjections.set(message.id, message.content);
1289
+ this.#acknowledgedProjections.set(message.id, message.content);
1367
1290
  }
1368
1291
  // Metadata first, transcript last: the transcript emit is what persists.
1369
- this.persistConversationMetadata();
1292
+ this.#persistConversationMetadata();
1370
1293
  this.hydrateMessages(page.messages);
1371
- this.emitHistoryState();
1294
+ this.#emitHistoryState();
1372
1295
  return { summary: page.summary, nextMessageCursor: page.nextCursor };
1373
1296
  }
1374
1297
 
@@ -1381,25 +1304,25 @@ export class AgentWidgetSession {
1381
1304
  cursor?: string | null,
1382
1305
  opts?: { scope?: HistoryScope }
1383
1306
  ): Promise<HistoryOlderPage> {
1384
- const provider = this.requireHistoryProvider();
1385
- const context = this.historyContext(opts?.scope);
1386
- const resolvedCursor = cursor ?? this.historyNextMessageCursor;
1307
+ const provider = this.#requireHistoryProvider();
1308
+ const context = this.#historyContext(opts?.scope);
1309
+ const resolvedCursor = cursor ?? this.#historyNextMessageCursor;
1387
1310
  if (!resolvedCursor) {
1388
1311
  return Promise.resolve({ messages: [], nextMessageCursor: null });
1389
1312
  }
1390
1313
  const key = `${id} ${resolvedCursor}`;
1391
- const inflight = this.olderPageRequests.get(key);
1314
+ const inflight = this.#olderPageRequests.get(key);
1392
1315
  if (inflight) return inflight;
1393
1316
 
1394
- const request = this.fetchOlderPage(provider, id, resolvedCursor, context)
1317
+ const request = this.#fetchOlderPage(provider, id, resolvedCursor, context)
1395
1318
  .finally(() => {
1396
- this.olderPageRequests.delete(key);
1319
+ this.#olderPageRequests.delete(key);
1397
1320
  });
1398
- this.olderPageRequests.set(key, request);
1321
+ this.#olderPageRequests.set(key, request);
1399
1322
  return request;
1400
1323
  }
1401
1324
 
1402
- private async fetchOlderPage(
1325
+ async #fetchOlderPage(
1403
1326
  provider: HistoryProvider,
1404
1327
  id: string,
1405
1328
  cursor: string,
@@ -1412,20 +1335,20 @@ export class AgentWidgetSession {
1412
1335
  context,
1413
1336
  });
1414
1337
  // A conversation switch during the fetch retires this page.
1415
- if (this.activeConversationId && this.activeConversationId !== id) {
1338
+ if (this.#activeConversationId && this.#activeConversationId !== id) {
1416
1339
  return { messages: [], nextMessageCursor: page.nextCursor };
1417
1340
  }
1418
1341
  const merged = mergeWireMessagesById(this.messages, page.messages);
1419
- this.messages = this.sortMessages(
1420
- merged.map((message) => this.ensureSequence(message))
1342
+ this.messages = this.#sortMessages(
1343
+ merged.map((message) => this.#ensureSequence(message))
1421
1344
  );
1422
- this.historyNextMessageCursor = page.nextCursor;
1345
+ this.#historyNextMessageCursor = page.nextCursor;
1423
1346
  for (const message of page.messages) {
1424
- this.acknowledgedProjections.set(message.id, message.content);
1347
+ this.#acknowledgedProjections.set(message.id, message.content);
1425
1348
  }
1426
1349
  this.historyInternals.setStoredMessageCursor?.(page.nextCursor);
1427
1350
  this.callbacks.onMessagesChanged([...this.messages]);
1428
- this.emitHistoryState();
1351
+ this.#emitHistoryState();
1429
1352
  return { messages: page.messages, nextMessageCursor: page.nextCursor };
1430
1353
  }
1431
1354
 
@@ -1436,34 +1359,34 @@ export class AgentWidgetSession {
1436
1359
  public async startNewConversation(opts?: {
1437
1360
  scope?: HistoryScope;
1438
1361
  }): Promise<void> {
1439
- const provider = this.requireHistoryProvider();
1440
- const context = this.historyContext(opts?.scope);
1362
+ const provider = this.#requireHistoryProvider();
1363
+ const context = this.#historyContext(opts?.scope);
1441
1364
  await this.awaitHistorySettled();
1442
- const epoch = ++this.historyOpenEpoch;
1365
+ const epoch = ++this.#historyOpenEpoch;
1443
1366
  const prepared = await provider.prepareStartNew({ context });
1444
- if (epoch !== this.historyOpenEpoch) {
1367
+ if (epoch !== this.#historyOpenEpoch) {
1445
1368
  prepared.discard();
1446
1369
  throw new SessionHistoryError("superseded", "A later action won");
1447
1370
  }
1448
- await this.installFreshConversation(prepared);
1371
+ await this.#installFreshConversation(prepared);
1449
1372
  }
1450
1373
 
1451
1374
  /**
1452
1375
  * Clear local + persisted conversation state, then commit the replacement.
1453
1376
  * Welcome injection is deliberately allowed once the transcript is empty.
1454
1377
  */
1455
- private async installFreshConversation(
1378
+ async #installFreshConversation(
1456
1379
  prepared: PreparedHistoryActivation
1457
1380
  ): Promise<void> {
1458
- this.discardConversationState();
1459
- await this.commitActivation(prepared);
1460
- this.activeConversationId = prepared.conversationId || null;
1461
- this.activeConversationRevision = prepared.conversationRevision || null;
1462
- this.historyNextMessageCursor = null;
1463
- this.historyRecovery = null;
1464
- this.historySendBlocked = false;
1465
- this.persistConversationMetadata();
1466
- this.emitHistoryState();
1381
+ this.#discardConversationState();
1382
+ await this.#commitActivation(prepared);
1383
+ this.#activeConversationId = prepared.conversationId || null;
1384
+ this.#activeConversationRevision = prepared.conversationRevision || null;
1385
+ this.#historyNextMessageCursor = null;
1386
+ this.#historyRecovery = null;
1387
+ this.#historySendBlocked = false;
1388
+ this.#persistConversationMetadata();
1389
+ this.#emitHistoryState();
1467
1390
  }
1468
1391
 
1469
1392
  /**
@@ -1474,12 +1397,12 @@ export class AgentWidgetSession {
1474
1397
  id: string,
1475
1398
  opts?: { scope?: HistoryScope }
1476
1399
  ): Promise<void> {
1477
- const provider = this.requireHistoryProvider();
1478
- const context = this.historyContext(opts?.scope);
1400
+ const provider = this.#requireHistoryProvider();
1401
+ const context = this.#historyContext(opts?.scope);
1479
1402
  await this.awaitHistorySettled();
1480
1403
  await provider.delete(id, { context });
1481
- if (id !== this.activeConversationId) return;
1482
- await this.recoverFromDestroyedConversation(context);
1404
+ if (id !== this.#activeConversationId) return;
1405
+ await this.#recoverFromDestroyedConversation(context);
1483
1406
  }
1484
1407
 
1485
1408
  /** Visitor-scoped rename/star. Requires a provider with the update capability. */
@@ -1488,35 +1411,35 @@ export class AgentWidgetSession {
1488
1411
  patch: HistoryConversationPatch,
1489
1412
  opts?: { scope?: HistoryScope }
1490
1413
  ): Promise<HistoryConversationSummary> {
1491
- const provider = this.requireHistoryProvider();
1414
+ const provider = this.#requireHistoryProvider();
1492
1415
  if (!provider.update) {
1493
1416
  throw new SessionHistoryError(
1494
1417
  "history_unavailable",
1495
1418
  "This history provider cannot update conversations"
1496
1419
  );
1497
1420
  }
1498
- const context = this.historyContext(opts?.scope);
1421
+ const context = this.#historyContext(opts?.scope);
1499
1422
  await this.awaitHistorySettled();
1500
1423
  return provider.update(id, patch, { context });
1501
1424
  }
1502
1425
 
1503
1426
  /** True when the provider supports visitor-scoped rename/star. */
1504
1427
  public canUpdateConversations(): boolean {
1505
- return typeof this.historyProvider?.update === "function";
1428
+ return typeof this.#historyProvider?.update === "function";
1506
1429
  }
1507
1430
 
1508
1431
  public async clearConversationHistory(opts?: {
1509
1432
  targetId?: string;
1510
1433
  scope?: HistoryScope;
1511
1434
  }): Promise<{ deleted: number }> {
1512
- const provider = this.requireHistoryProvider();
1513
- const context = this.historyContext(opts?.scope);
1435
+ const provider = this.#requireHistoryProvider();
1436
+ const context = this.#historyContext(opts?.scope);
1514
1437
  await this.awaitHistorySettled();
1515
1438
  const result = await provider.deleteAll({
1516
1439
  ...(opts?.targetId !== undefined ? { targetId: opts.targetId } : {}),
1517
1440
  context,
1518
1441
  });
1519
- await this.recoverFromDestroyedConversation(context);
1442
+ await this.#recoverFromDestroyedConversation(context);
1520
1443
  return result;
1521
1444
  }
1522
1445
 
@@ -1527,65 +1450,65 @@ export class AgentWidgetSession {
1527
1450
  public async resetHistoryDevice(): Promise<{
1528
1451
  remoteRevocationConfirmed: boolean;
1529
1452
  }> {
1530
- const provider = this.requireHistoryProvider();
1453
+ const provider = this.#requireHistoryProvider();
1531
1454
  if (!provider.resetDevice) {
1532
1455
  throw new SessionHistoryError(
1533
1456
  "history_unavailable",
1534
1457
  "This history provider cannot reset the device"
1535
1458
  );
1536
1459
  }
1537
- this.historyOpenEpoch += 1;
1538
- this.projectionFinalizationPromise = null;
1460
+ this.#historyOpenEpoch += 1;
1461
+ this.#projectionFinalizationPromise = null;
1539
1462
  try {
1540
1463
  return await provider.resetDevice();
1541
1464
  } finally {
1542
- this.discardConversationState();
1543
- this.activeConversationId = null;
1544
- this.activeConversationRevision = null;
1545
- this.historyNextMessageCursor = null;
1546
- this.historyRecovery = null;
1547
- this.historySendBlocked = false;
1548
- this.emitHistoryState();
1465
+ this.#discardConversationState();
1466
+ this.#activeConversationId = null;
1467
+ this.#activeConversationRevision = null;
1468
+ this.#historyNextMessageCursor = null;
1469
+ this.#historyRecovery = null;
1470
+ this.#historySendBlocked = false;
1471
+ this.#emitHistoryState();
1549
1472
  }
1550
1473
  }
1551
1474
 
1552
- private async recoverFromDestroyedConversation(
1475
+ async #recoverFromDestroyedConversation(
1553
1476
  context: HistoryOperationContext
1554
1477
  ): Promise<void> {
1555
1478
  // Supersede anything still in flight for the record being destroyed.
1556
- this.historyOpenEpoch += 1;
1557
- this.projectionFinalizationPromise = null;
1558
- this.discardConversationState();
1559
- this.activeConversationId = null;
1560
- this.activeConversationRevision = null;
1561
- this.historyNextMessageCursor = null;
1562
- this.historySendBlocked = true;
1563
- this.historyRecovery = null;
1564
- this.emitHistoryState();
1565
- await this.prepareReplacementConversation(context);
1479
+ this.#historyOpenEpoch += 1;
1480
+ this.#projectionFinalizationPromise = null;
1481
+ this.#discardConversationState();
1482
+ this.#activeConversationId = null;
1483
+ this.#activeConversationRevision = null;
1484
+ this.#historyNextMessageCursor = null;
1485
+ this.#historySendBlocked = true;
1486
+ this.#historyRecovery = null;
1487
+ this.#emitHistoryState();
1488
+ await this.#prepareReplacementConversation(context);
1566
1489
  }
1567
1490
 
1568
1491
  /**
1569
1492
  * Never reinstalls the deleted session. A failed preparation leaves an
1570
1493
  * explicit recoverable state instead of the old transcript.
1571
1494
  */
1572
- private async prepareReplacementConversation(
1495
+ async #prepareReplacementConversation(
1573
1496
  context: HistoryOperationContext
1574
1497
  ): Promise<void> {
1575
- const provider = this.historyProvider;
1498
+ const provider = this.#historyProvider;
1576
1499
  if (!provider) {
1577
- this.historySendBlocked = false;
1578
- this.emitHistoryState();
1500
+ this.#historySendBlocked = false;
1501
+ this.#emitHistoryState();
1579
1502
  return;
1580
1503
  }
1581
1504
  try {
1582
1505
  const prepared = await provider.prepareStartNew({ context });
1583
- await this.installFreshConversation(prepared);
1506
+ await this.#installFreshConversation(prepared);
1584
1507
  } catch {
1585
- this.historySendBlocked = true;
1586
- this.historyRecovery = "new_conversation_required";
1587
- this.emitHistoryState();
1588
- this.notifyHistory(
1508
+ this.#historySendBlocked = true;
1509
+ this.#historyRecovery = "new_conversation_required";
1510
+ this.#emitHistoryState();
1511
+ this.#notifyHistory(
1589
1512
  "new_conversation_required",
1590
1513
  "That conversation was deleted. Start a new conversation to continue."
1591
1514
  );
@@ -1593,13 +1516,13 @@ export class AgentWidgetSession {
1593
1516
  }
1594
1517
 
1595
1518
  /** The next explicit action retries a failed replacement preparation. */
1596
- private async retryRequiredConversation(): Promise<void> {
1597
- if (this.historyRecovery !== "new_conversation_required") return;
1598
- const provider = this.historyProvider;
1519
+ async #retryRequiredConversation(): Promise<void> {
1520
+ if (this.#historyRecovery !== "new_conversation_required") return;
1521
+ const provider = this.#historyProvider;
1599
1522
  if (!provider) return;
1600
- this.historyRecovery = null;
1601
- await this.prepareReplacementConversation(this.historyContext());
1602
- if (this.historyRecovery === "new_conversation_required") {
1523
+ this.#historyRecovery = null;
1524
+ await this.#prepareReplacementConversation(this.#historyContext());
1525
+ if (this.#historyRecovery === "new_conversation_required") {
1603
1526
  throw new SessionHistoryError(
1604
1527
  "new_conversation_required",
1605
1528
  "A new conversation is required before sending"
@@ -1612,7 +1535,7 @@ export class AgentWidgetSession {
1612
1535
  * session binding (`bindActivatedSession`); the fallback covers a shell that
1613
1536
  * has not wired that callback yet.
1614
1537
  */
1615
- private async commitActivation(
1538
+ async #commitActivation(
1616
1539
  prepared: PreparedHistoryActivation
1617
1540
  ): Promise<void> {
1618
1541
  await prepared.commit();
@@ -1631,61 +1554,62 @@ export class AgentWidgetSession {
1631
1554
  this.clientSession = session;
1632
1555
  this.config.setStoredSessionId?.(session.sessionId);
1633
1556
  if (session.conversationId) {
1634
- this.activeConversationId = session.conversationId;
1635
- this.activeConversationRevision = session.conversationRevision ?? null;
1557
+ this.#activeConversationId = session.conversationId;
1558
+ this.#activeConversationRevision = session.conversationRevision ?? null;
1636
1559
  this.config.setStoredConversationId?.(session.conversationId);
1637
1560
  this.historyInternals.setStoredConversationRevision?.(
1638
1561
  session.conversationRevision ?? null
1639
1562
  );
1640
1563
  }
1641
- if (!this.suppressWelcomeInjection) this.injectWelcomeMessage(session);
1564
+ if (!this.#suppressWelcomeInjection) this.#injectWelcomeMessage(session);
1642
1565
  }
1643
1566
 
1644
- private persistConversationMetadata(): void {
1645
- if (this.activeConversationId) {
1646
- this.config.setStoredConversationId?.(this.activeConversationId);
1567
+ #persistConversationMetadata(): void {
1568
+ if (this.#activeConversationId) {
1569
+ this.config.setStoredConversationId?.(this.#activeConversationId);
1647
1570
  } else {
1648
1571
  this.config.clearStoredConversationId?.();
1649
1572
  }
1650
1573
  this.historyInternals.setStoredConversationRevision?.(
1651
- this.activeConversationRevision
1574
+ this.#activeConversationRevision
1652
1575
  );
1653
1576
  this.historyInternals.setStoredMessageCursor?.(
1654
- this.historyNextMessageCursor
1577
+ this.#historyNextMessageCursor
1655
1578
  );
1656
1579
  }
1657
1580
 
1658
1581
  /** Conversation-scoped caches that must not leak across records. */
1659
- private resetConversationScopedState(): void {
1582
+ #resetConversationScopedState(): void {
1660
1583
  this.historyInternals.setStoredResumableHandle?.(null);
1661
1584
  this.agentExecution = null;
1662
- this.clearArtifactState();
1585
+ this.#clearArtifactState();
1663
1586
  this.webMcpInflightKeys.clear();
1664
1587
  this.webMcpResolvedKeys.clear();
1665
1588
  this.client.resetClientToolsFingerprint();
1666
- this.acknowledgedProjections.clear();
1667
- this.setPendingProjections(null);
1589
+ this.#acknowledgedProjections.clear();
1590
+ this.#setPendingProjections(null);
1668
1591
  }
1669
1592
 
1670
1593
  /**
1671
1594
  * Irreversible local + persisted clear. Used by new-conversation, active
1672
1595
  * deletion, 410 recovery, reset, and the continuity wipe.
1673
1596
  */
1674
- private discardConversationState(): void {
1597
+ #discardConversationState(): void {
1675
1598
  this.stopSpeaking();
1676
1599
  this.abortController?.abort();
1677
1600
  this.abortController = null;
1678
- this.teardownReconnect();
1679
- this.abortWebMcpResolves();
1601
+ this.#teardownReconnect();
1602
+ this.#abortWebMcpResolves();
1603
+ this.#actionsEpoch++;
1680
1604
  this.messages = [];
1681
- this.resetConversationScopedState();
1682
- this.olderPageRequests.clear();
1605
+ this.#resetConversationScopedState();
1606
+ this.#olderPageRequests.clear();
1683
1607
  this.config.clearStoredSessionId?.();
1684
1608
  this.config.clearStoredConversationId?.();
1685
1609
  this.historyInternals.setStoredConversationRevision?.(null);
1686
1610
  this.historyInternals.setStoredMessageCursor?.(null);
1687
- this.setStreaming(false);
1688
- this.setStatus("idle");
1611
+ this.#setStreaming(false);
1612
+ this.#setStatus("idle");
1689
1613
  this.callbacks.onMessagesChanged([...this.messages]);
1690
1614
  }
1691
1615
 
@@ -1694,27 +1618,27 @@ export class AgentWidgetSession {
1694
1618
  * cross-tab convergence, or credential expiry selected a different record.
1695
1619
  * Invalidate synchronously; dispatch stays blocked until cleanup completes.
1696
1620
  */
1697
- private handleHistoryContinuityChanged(info: {
1621
+ #handleHistoryContinuityChanged(info: {
1698
1622
  previousConversationId: string | null;
1699
1623
  conversationId: string;
1700
1624
  }): void {
1701
- this.historyOpenEpoch += 1;
1702
- this.projectionFinalizationPromise = null;
1703
- this.historySendBlocked = true;
1704
- this.discardConversationState();
1705
- this.activeConversationId = info.conversationId;
1706
- this.activeConversationRevision = null;
1707
- this.historyNextMessageCursor = null;
1708
- this.historyRecovery = null;
1709
- this.emitHistoryState();
1710
- this.notifyHistory(
1625
+ this.#historyOpenEpoch += 1;
1626
+ this.#projectionFinalizationPromise = null;
1627
+ this.#historySendBlocked = true;
1628
+ this.#discardConversationState();
1629
+ this.#activeConversationId = info.conversationId;
1630
+ this.#activeConversationRevision = null;
1631
+ this.#historyNextMessageCursor = null;
1632
+ this.#historyRecovery = null;
1633
+ this.#emitHistoryState();
1634
+ this.#notifyHistory(
1711
1635
  "history_continuity_reset",
1712
1636
  "This browser was reconnected to a different conversation, so the previous messages were cleared."
1713
1637
  );
1714
- this.historyGate = Promise.resolve().then(() => {
1715
- this.historySendBlocked = false;
1716
- this.historyGate = null;
1717
- this.emitHistoryState();
1638
+ this.#historyGate = Promise.resolve().then(() => {
1639
+ this.#historySendBlocked = false;
1640
+ this.#historyGate = null;
1641
+ this.#emitHistoryState();
1718
1642
  });
1719
1643
  }
1720
1644
 
@@ -1723,15 +1647,15 @@ export class AgentWidgetSession {
1723
1647
  * the store. Owned here so the transition sits beside the continuity wipe it
1724
1648
  * reuses.
1725
1649
  */
1726
- private syncVisitorStoreSubscription(): void {
1650
+ #syncVisitorStoreSubscription(): void {
1727
1651
  const store = this.historyInternals.visitorStore ?? null;
1728
- if (store === this.subscribedVisitorStore) return;
1729
- this.visitorStoreUnsubscribe?.();
1730
- this.visitorStoreUnsubscribe = null;
1731
- this.subscribedVisitorStore = store;
1652
+ if (store === this.#subscribedVisitorStore) return;
1653
+ this.#visitorStoreUnsubscribe?.();
1654
+ this.#visitorStoreUnsubscribe = null;
1655
+ this.#subscribedVisitorStore = store;
1732
1656
  if (!store) return;
1733
- this.visitorStoreUnsubscribe = store.subscribe((change) =>
1734
- this.handleExternalCredentialChange(change)
1657
+ this.#visitorStoreUnsubscribe = store.subscribe((change) =>
1658
+ this.#handleExternalCredentialChange(change)
1735
1659
  );
1736
1660
  }
1737
1661
 
@@ -1741,40 +1665,40 @@ export class AgentWidgetSession {
1741
1665
  * A "local" change is this tab's own mint/write and owns its init path; a
1742
1666
  * change out of an empty store is first-init convergence, not a break.
1743
1667
  */
1744
- private handleExternalCredentialChange(change: VisitorStoreChange): void {
1668
+ #handleExternalCredentialChange(change: VisitorStoreChange): void {
1745
1669
  if (change.source !== "external" || change.previousToken === null) return;
1746
1670
 
1747
1671
  // Nothing may reuse the session minted under the revoked credential.
1748
1672
  this.clientSession = null;
1749
1673
  this.client.handleExternalCredentialChange();
1750
- this.historyOpenEpoch += 1;
1751
- this.projectionFinalizationPromise = null;
1752
- this.credentialReinitPending = true;
1753
- this.credentialReinitPromise = null;
1674
+ this.#historyOpenEpoch += 1;
1675
+ this.#projectionFinalizationPromise = null;
1676
+ this.#credentialReinitPending = true;
1677
+ this.#credentialReinitPromise = null;
1754
1678
 
1755
1679
  const hadVisibleTranscript = this.messages.length > 0;
1756
1680
  const hadConversationState =
1757
1681
  hadVisibleTranscript ||
1758
- this.activeConversationId !== null ||
1682
+ this.#activeConversationId !== null ||
1759
1683
  (this.config.getStoredConversationId?.() ?? null) !== null;
1760
1684
  if (hadConversationState) {
1761
- this.discardConversationState();
1685
+ this.#discardConversationState();
1762
1686
  } else {
1763
1687
  // No conversation to wipe, but the revoked visitor's ids must not survive.
1764
1688
  this.config.clearStoredSessionId?.();
1765
1689
  this.config.clearStoredConversationId?.();
1766
- this.olderPageRequests.clear();
1767
- this.setPendingProjections(null);
1690
+ this.#olderPageRequests.clear();
1691
+ this.#setPendingProjections(null);
1768
1692
  }
1769
- this.activeConversationId = null;
1770
- this.activeConversationRevision = null;
1771
- this.historyNextMessageCursor = null;
1772
- this.historyRecovery = null;
1773
- this.emitHistoryState();
1693
+ this.#activeConversationId = null;
1694
+ this.#activeConversationRevision = null;
1695
+ this.#historyNextMessageCursor = null;
1696
+ this.#historyRecovery = null;
1697
+ this.#emitHistoryState();
1774
1698
  // Only a visible wipe announces: a silent reconcile must not push the open
1775
1699
  // history view into a refetch that would immediately re-mint a credential.
1776
1700
  if (hadVisibleTranscript) {
1777
- this.notifyHistory(
1701
+ this.#notifyHistory(
1778
1702
  "history_continuity_reset",
1779
1703
  "This browser's conversation history was reset in another tab, so the previous messages were cleared."
1780
1704
  );
@@ -1786,26 +1710,26 @@ export class AgentWidgetSession {
1786
1710
  * Deliberately lazy: re-initializing on the storage event itself would hand
1787
1711
  * an idle tab a fresh credential the visitor just deleted elsewhere.
1788
1712
  */
1789
- private completeCredentialReinit(): Promise<void> {
1790
- if (!this.credentialReinitPending) return Promise.resolve();
1791
- if (!this.credentialReinitPromise) {
1792
- this.credentialReinitPromise = this.runCredentialReinit().finally(() => {
1793
- this.credentialReinitPromise = null;
1713
+ #completeCredentialReinit(): Promise<void> {
1714
+ if (!this.#credentialReinitPending) return Promise.resolve();
1715
+ if (!this.#credentialReinitPromise) {
1716
+ this.#credentialReinitPromise = this.#runCredentialReinit().finally(() => {
1717
+ this.#credentialReinitPromise = null;
1794
1718
  });
1795
1719
  }
1796
- return this.credentialReinitPromise;
1720
+ return this.#credentialReinitPromise;
1797
1721
  }
1798
1722
 
1799
- private async runCredentialReinit(): Promise<void> {
1723
+ async #runCredentialReinit(): Promise<void> {
1800
1724
  if (!this.isClientTokenMode()) {
1801
- this.credentialReinitPending = false;
1725
+ this.#credentialReinitPending = false;
1802
1726
  return;
1803
1727
  }
1804
1728
  try {
1805
1729
  // Reads the store again: a clear mints fresh, a replacement adopts the
1806
1730
  // sibling's token.
1807
1731
  this.bindActivatedSession(await this.client.initSession());
1808
- this.credentialReinitPending = false;
1732
+ this.#credentialReinitPending = false;
1809
1733
  } catch {
1810
1734
  // Still pending: the dispatch's own init reports the failure normally.
1811
1735
  }
@@ -1820,53 +1744,53 @@ export class AgentWidgetSession {
1820
1744
  public reconcileBootConversation(opts?: {
1821
1745
  scope?: HistoryScope;
1822
1746
  }): Promise<void> {
1823
- const provider = this.historyProvider;
1747
+ const provider = this.#historyProvider;
1824
1748
  const conversationId =
1825
1749
  this.clientSession?.conversationId ??
1826
1750
  this.config.getStoredConversationId?.() ??
1827
- this.activeConversationId;
1751
+ this.#activeConversationId;
1828
1752
  if (!provider || !conversationId) return Promise.resolve();
1829
1753
 
1830
- const context = this.historyContext(opts?.scope);
1831
- this.historySendBlocked = true;
1832
- this.emitHistoryState();
1833
- const run = this.runBootReconciliation(
1754
+ const context = this.#historyContext(opts?.scope);
1755
+ this.#historySendBlocked = true;
1756
+ this.#emitHistoryState();
1757
+ const run = this.#runBootReconciliation(
1834
1758
  provider,
1835
1759
  conversationId,
1836
1760
  context
1837
1761
  ).finally(() => {
1838
- this.historySendBlocked = this.historyRecovery !== null;
1839
- if (this.historyGate === run) this.historyGate = null;
1840
- this.emitHistoryState();
1762
+ this.#historySendBlocked = this.#historyRecovery !== null;
1763
+ if (this.#historyGate === run) this.#historyGate = null;
1764
+ this.#emitHistoryState();
1841
1765
  });
1842
- this.historyGate = run;
1766
+ this.#historyGate = run;
1843
1767
  return run;
1844
1768
  }
1845
1769
 
1846
- private async runBootReconciliation(
1770
+ async #runBootReconciliation(
1847
1771
  provider: HistoryProvider,
1848
1772
  conversationId: string,
1849
1773
  context: HistoryOperationContext
1850
1774
  ): Promise<void> {
1851
- this.activeConversationId = conversationId;
1775
+ this.#activeConversationId = conversationId;
1852
1776
  const initRevision =
1853
1777
  this.clientSession?.conversationRevision ??
1854
- this.activeConversationRevision ??
1778
+ this.#activeConversationRevision ??
1855
1779
  null;
1856
- const persistedRevision = this.bootRevisionCaptured
1857
- ? this.bootConversationRevision
1780
+ const persistedRevision = this.#bootRevisionCaptured
1781
+ ? this.#bootConversationRevision
1858
1782
  : (this.historyInternals.getStoredConversationRevision?.() ?? null);
1859
- this.bootRevisionCaptured = false;
1860
- this.historyNextMessageCursor =
1783
+ this.#bootRevisionCaptured = false;
1784
+ this.#historyNextMessageCursor =
1861
1785
  this.historyInternals.getStoredMessageCursor?.() ??
1862
- this.historyNextMessageCursor;
1786
+ this.#historyNextMessageCursor;
1863
1787
 
1864
1788
  // Any locally finalized projection still owed to the server goes first, so
1865
1789
  // display reconciliation cannot complete against a stale server record.
1866
1790
  await this.replayPendingProjections();
1867
1791
 
1868
1792
  if (initRevision && persistedRevision && initRevision === persistedRevision) {
1869
- this.activeConversationRevision = initRevision;
1793
+ this.#activeConversationRevision = initRevision;
1870
1794
  return;
1871
1795
  }
1872
1796
 
@@ -1878,9 +1802,9 @@ export class AgentWidgetSession {
1878
1802
  let reconciled: AgentWidgetMessage[];
1879
1803
  if (!overlaps) {
1880
1804
  // No shared id: the local transcript is not this record's newest window.
1881
- reconciled = page.messages.map((message) => this.ensureSequence(message));
1805
+ reconciled = page.messages.map((message) => this.#ensureSequence(message));
1882
1806
  } else {
1883
- const durableId = this.resumable ? this.activeAssistantMessageId : null;
1807
+ const durableId = this.#resumable ? this.#activeAssistantMessageId : null;
1884
1808
  const survivors = this.messages.filter((message) => {
1885
1809
  if (serverIds.has(message.id)) return true;
1886
1810
  if (message.id === durableId) return true;
@@ -1888,53 +1812,53 @@ export class AgentWidgetSession {
1888
1812
  return pageOldest !== undefined && message.createdAt < pageOldest;
1889
1813
  });
1890
1814
  reconciled = mergeWireMessagesById(survivors, page.messages).map(
1891
- (message) => this.ensureSequence(message)
1815
+ (message) => this.#ensureSequence(message)
1892
1816
  );
1893
1817
  }
1894
1818
 
1895
- this.historyNextMessageCursor = page.nextCursor;
1896
- this.activeConversationRevision =
1819
+ this.#historyNextMessageCursor = page.nextCursor;
1820
+ this.#activeConversationRevision =
1897
1821
  page.conversationRevision || initRevision || null;
1898
- this.acknowledgedProjections.clear();
1822
+ this.#acknowledgedProjections.clear();
1899
1823
  for (const message of page.messages) {
1900
- this.acknowledgedProjections.set(message.id, message.content);
1824
+ this.#acknowledgedProjections.set(message.id, message.content);
1901
1825
  }
1902
1826
  // Metadata first, transcript last: one persist covers both.
1903
- this.persistConversationMetadata();
1904
- this.messages = this.sortMessages(reconciled);
1827
+ this.#persistConversationMetadata();
1828
+ this.messages = this.#sortMessages(reconciled);
1905
1829
  this.callbacks.onMessagesChanged([...this.messages]);
1906
1830
  }
1907
1831
 
1908
1832
  // ── Display-projection finalization ────────────────────────────
1909
1833
 
1910
1834
  /** Runtype transport op: active session + browser scope, never a proof. */
1911
- private projectionFinalizationEnabled(): boolean {
1835
+ #projectionFinalizationEnabled(): boolean {
1912
1836
  return (
1913
1837
  this.config.features?.history?.enabled === true &&
1914
1838
  this.isClientTokenMode()
1915
1839
  );
1916
1840
  }
1917
1841
 
1918
- private setPendingProjections(pending: PendingDisplayProjections | null): void {
1919
- this.pendingProjections = pending;
1842
+ #setPendingProjections(pending: PendingDisplayProjections | null): void {
1843
+ this.#pendingProjections = pending;
1920
1844
  // Memory-only when the shell supplies no persistence seam.
1921
1845
  this.historyInternals.setPendingDisplayProjections?.(pending);
1922
1846
  }
1923
1847
 
1924
- private readPendingProjections(): PendingDisplayProjections | null {
1848
+ #readPendingProjections(): PendingDisplayProjections | null {
1925
1849
  return (
1926
1850
  this.historyInternals.getPendingDisplayProjections?.() ??
1927
- this.pendingProjections
1851
+ this.#pendingProjections
1928
1852
  );
1929
1853
  }
1930
1854
 
1931
1855
  /** Record what the dispatch payload carried as each message's projection. */
1932
- private recordDispatchedProjections(messages: AgentWidgetMessage[]): void {
1933
- if (!this.projectionFinalizationEnabled()) return;
1856
+ #recordDispatchedProjections(messages: AgentWidgetMessage[]): void {
1857
+ if (!this.#projectionFinalizationEnabled()) return;
1934
1858
  for (const message of messages) {
1935
1859
  const projection = divergentDisplayProjection(message);
1936
1860
  if (projection !== undefined) {
1937
- this.acknowledgedProjections.set(message.id, projection);
1861
+ this.#acknowledgedProjections.set(message.id, projection);
1938
1862
  }
1939
1863
  }
1940
1864
  }
@@ -1943,57 +1867,57 @@ export class AgentWidgetSession {
1943
1867
  * After a terminal assistant message, finalize any browser-derived projection
1944
1868
  * the server cannot have. Painting is never delayed by this.
1945
1869
  */
1946
- private scheduleProjectionFinalization(): void {
1947
- if (!this.projectionFinalizationEnabled()) return;
1948
- const conversationId = this.activeConversationId;
1870
+ #scheduleProjectionFinalization(): void {
1871
+ if (!this.#projectionFinalizationEnabled()) return;
1872
+ const conversationId = this.#activeConversationId;
1949
1873
  if (!conversationId) return;
1950
1874
  const batch: HistoryDisplayProjection[] = [];
1951
1875
  for (const message of this.messages) {
1952
1876
  if (message.role !== "assistant" || message.streaming) continue;
1953
1877
  const projection = divergentDisplayProjection(message);
1954
1878
  if (projection === undefined) continue;
1955
- if (this.acknowledgedProjections.get(message.id) === projection) continue;
1879
+ if (this.#acknowledgedProjections.get(message.id) === projection) continue;
1956
1880
  batch.push({ id: message.id, displayContent: projection });
1957
1881
  }
1958
1882
  if (batch.length === 0) return;
1959
- this.setPendingProjections({
1883
+ this.#setPendingProjections({
1960
1884
  conversationId,
1961
1885
  messageIds: batch.map((item) => item.id),
1962
1886
  });
1963
- this.enqueueProjectionFinalization(conversationId, batch);
1887
+ this.#enqueueProjectionFinalization(conversationId, batch);
1964
1888
  }
1965
1889
 
1966
1890
  /** Serialized: operations await this chain instead of racing it. */
1967
- private enqueueProjectionFinalization(
1891
+ #enqueueProjectionFinalization(
1968
1892
  conversationId: string,
1969
1893
  batch: HistoryDisplayProjection[]
1970
1894
  ): void {
1971
- const previous = this.projectionFinalizationPromise ?? Promise.resolve();
1895
+ const previous = this.#projectionFinalizationPromise ?? Promise.resolve();
1972
1896
  const chain = previous
1973
1897
  .catch(() => {})
1974
- .then(() => this.finalizeProjections(conversationId, batch, false))
1898
+ .then(() => this.#finalizeProjections(conversationId, batch, false))
1975
1899
  .finally(() => {
1976
- if (this.projectionFinalizationPromise === chain) {
1977
- this.projectionFinalizationPromise = null;
1900
+ if (this.#projectionFinalizationPromise === chain) {
1901
+ this.#projectionFinalizationPromise = null;
1978
1902
  }
1979
1903
  });
1980
- this.projectionFinalizationPromise = chain;
1904
+ this.#projectionFinalizationPromise = chain;
1981
1905
  }
1982
1906
 
1983
- private async finalizeProjections(
1907
+ async #finalizeProjections(
1984
1908
  conversationId: string,
1985
1909
  batch: HistoryDisplayProjection[],
1986
1910
  retried: boolean
1987
1911
  ): Promise<void> {
1988
1912
  // A switch/reset/delete already invalidated this marker.
1989
- if (this.activeConversationId !== conversationId) return;
1913
+ if (this.#activeConversationId !== conversationId) return;
1990
1914
  // Two terminal scans can enqueue the same batch before the first PATCH
1991
1915
  // acknowledges; the chain serializes them, so re-filter at run time.
1992
1916
  const unacknowledged = batch.filter(
1993
- (item) => this.acknowledgedProjections.get(item.id) !== item.displayContent
1917
+ (item) => this.#acknowledgedProjections.get(item.id) !== item.displayContent
1994
1918
  );
1995
1919
  if (unacknowledged.length === 0) {
1996
- this.clearFinalizedMarker(conversationId, batch);
1920
+ this.#clearFinalizedMarker(conversationId, batch);
1997
1921
  return;
1998
1922
  }
1999
1923
  try {
@@ -2002,15 +1926,15 @@ export class AgentWidgetSession {
2002
1926
  unacknowledged
2003
1927
  );
2004
1928
  for (const item of unacknowledged) {
2005
- this.acknowledgedProjections.set(item.id, item.displayContent);
1929
+ this.#acknowledgedProjections.set(item.id, item.displayContent);
2006
1930
  }
2007
- if (this.activeConversationId === conversationId && conversationRevision) {
2008
- this.activeConversationRevision = conversationRevision;
1931
+ if (this.#activeConversationId === conversationId && conversationRevision) {
1932
+ this.#activeConversationRevision = conversationRevision;
2009
1933
  }
2010
- this.clearFinalizedMarker(conversationId, batch);
1934
+ this.#clearFinalizedMarker(conversationId, batch);
2011
1935
  } catch (error) {
2012
- if (!retried && this.isTransientProjectionError(error)) {
2013
- await this.finalizeProjections(conversationId, unacknowledged, true);
1936
+ if (!retried && this.#isTransientProjectionError(error)) {
1937
+ await this.#finalizeProjections(conversationId, unacknowledged, true);
2014
1938
  return;
2015
1939
  }
2016
1940
  if (
@@ -2018,11 +1942,11 @@ export class AgentWidgetSession {
2018
1942
  (error.code === "not_found" || error.code === "conversation_deleted")
2019
1943
  ) {
2020
1944
  // The record is gone: drop the projection instead of retrying forever.
2021
- this.clearFinalizedMarker(conversationId, batch);
1945
+ this.#clearFinalizedMarker(conversationId, batch);
2022
1946
  return;
2023
1947
  }
2024
1948
  // Marker retained for the next boot/dispatch; the turn itself succeeded.
2025
- this.notifyHistory(
1949
+ this.#notifyHistory(
2026
1950
  "projection_finalization_failed",
2027
1951
  "Some message formatting could not be saved for history; it will be retried."
2028
1952
  );
@@ -2030,20 +1954,20 @@ export class AgentWidgetSession {
2030
1954
  }
2031
1955
 
2032
1956
  /** Transport-shaped failures only; a contract failure is not retried. */
2033
- private isTransientProjectionError(error: unknown): boolean {
1957
+ #isTransientProjectionError(error: unknown): boolean {
2034
1958
  if (error instanceof HistoryClientError) return error.code === "request_failed";
2035
1959
  return error instanceof Error;
2036
1960
  }
2037
1961
 
2038
- private clearFinalizedMarker(
1962
+ #clearFinalizedMarker(
2039
1963
  conversationId: string,
2040
1964
  batch: HistoryDisplayProjection[]
2041
1965
  ): void {
2042
- const pending = this.readPendingProjections();
1966
+ const pending = this.#readPendingProjections();
2043
1967
  if (!pending || pending.conversationId !== conversationId) return;
2044
1968
  const done = new Set(batch.map((item) => item.id));
2045
1969
  const remaining = pending.messageIds.filter((id) => !done.has(id));
2046
- this.setPendingProjections(
1970
+ this.#setPendingProjections(
2047
1971
  remaining.length ? { conversationId, messageIds: remaining } : null
2048
1972
  );
2049
1973
  }
@@ -2053,15 +1977,15 @@ export class AgentWidgetSession {
2053
1977
  * message content, then finalize before display reconciliation completes.
2054
1978
  */
2055
1979
  public async replayPendingProjections(): Promise<void> {
2056
- if (!this.projectionFinalizationEnabled()) return;
2057
- const pending = this.readPendingProjections();
1980
+ if (!this.#projectionFinalizationEnabled()) return;
1981
+ const pending = this.#readPendingProjections();
2058
1982
  if (!pending) return;
2059
1983
  if (
2060
- this.activeConversationId &&
2061
- pending.conversationId !== this.activeConversationId
1984
+ this.#activeConversationId &&
1985
+ pending.conversationId !== this.#activeConversationId
2062
1986
  ) {
2063
1987
  // Stale marker from another record: never replay it into this one.
2064
- this.setPendingProjections(null);
1988
+ this.#setPendingProjections(null);
2065
1989
  return;
2066
1990
  }
2067
1991
  const batch: HistoryDisplayProjection[] = [];
@@ -2071,33 +1995,33 @@ export class AgentWidgetSession {
2071
1995
  batch.push({ id, displayContent: message.content });
2072
1996
  }
2073
1997
  if (batch.length === 0) {
2074
- this.setPendingProjections(null);
1998
+ this.#setPendingProjections(null);
2075
1999
  return;
2076
2000
  }
2077
- this.enqueueProjectionFinalization(pending.conversationId, batch);
2078
- await this.projectionFinalizationPromise?.catch(() => {});
2001
+ this.#enqueueProjectionFinalization(pending.conversationId, batch);
2002
+ await this.#projectionFinalizationPromise?.catch(() => {});
2079
2003
  }
2080
2004
 
2081
2005
  /**
2082
2006
  * A local transcript mutation whose response carries no new revision: clear
2083
2007
  * the persisted one rather than guessing, so the next boot refreshes safely.
2084
2008
  */
2085
- private invalidateConversationRevision(): void {
2086
- if (!this.activeConversationId) return;
2087
- this.activeConversationRevision = null;
2009
+ #invalidateConversationRevision(): void {
2010
+ if (!this.#activeConversationId) return;
2011
+ this.#activeConversationRevision = null;
2088
2012
  this.historyInternals.setStoredConversationRevision?.(null);
2089
2013
  }
2090
2014
 
2091
2015
  /** Blocks a send until the owning history transition finishes. */
2092
- private async awaitHistorySendable(): Promise<void> {
2016
+ async #awaitHistorySendable(): Promise<void> {
2093
2017
  await this.awaitHistorySettled();
2094
- await this.completeCredentialReinit();
2095
- if (this.historyRecovery === "new_conversation_required") {
2096
- await this.retryRequiredConversation();
2018
+ await this.#completeCredentialReinit();
2019
+ if (this.#historyRecovery === "new_conversation_required") {
2020
+ await this.#retryRequiredConversation();
2097
2021
  }
2098
2022
  }
2099
2023
 
2100
- private static isConversationDeleted(error: unknown): boolean {
2024
+ static #isConversationDeleted(error: unknown): boolean {
2101
2025
  return (
2102
2026
  error instanceof HistoryClientError &&
2103
2027
  error.code === "conversation_deleted"
@@ -2108,14 +2032,14 @@ export class AgentWidgetSession {
2108
2032
  * Safe 410 recovery (D5). The old payload would recreate the transcript that
2109
2033
  * was just deleted, so the retry carries ONLY the just-submitted user turn.
2110
2034
  */
2111
- private async dispatchWithDeletedRecovery(
2035
+ async #dispatchWithDeletedRecovery(
2112
2036
  snapshot: AgentWidgetMessage[],
2113
2037
  controller: AbortController,
2114
2038
  assistantMessageId: string,
2115
2039
  userMessageId: string,
2116
2040
  turnOptions?: { composerOptions?: ComposerOptionsPayload; interrupt?: boolean }
2117
2041
  ): Promise<void> {
2118
- this.recordDispatchedProjections(snapshot);
2042
+ this.#recordDispatchedProjections(snapshot);
2119
2043
  try {
2120
2044
  await this.client.dispatch(
2121
2045
  {
@@ -2129,12 +2053,12 @@ export class AgentWidgetSession {
2129
2053
  );
2130
2054
  return;
2131
2055
  } catch (error) {
2132
- if (!AgentWidgetSession.isConversationDeleted(error)) throw error;
2133
- const replacement = await this.recoverFromDeletedConversation(userMessageId);
2056
+ if (!AgentWidgetSession.#isConversationDeleted(error)) throw error;
2057
+ const replacement = await this.#recoverFromDeletedConversation(userMessageId);
2134
2058
  // No recoverable turn (or no fresh record): surface the original 410.
2135
2059
  if (!replacement) throw error;
2136
2060
  // A second 410 propagates: exactly one recovery attempt per turn.
2137
- this.recordDispatchedProjections([replacement]);
2061
+ this.#recordDispatchedProjections([replacement]);
2138
2062
  await this.client.dispatch(
2139
2063
  {
2140
2064
  messages: [replacement],
@@ -2151,7 +2075,7 @@ export class AgentWidgetSession {
2151
2075
  * Capture only the newly submitted user message, destroy the deleted record's
2152
2076
  * local state, and initialize a fresh owned conversation to retry into.
2153
2077
  */
2154
- private async recoverFromDeletedConversation(
2078
+ async #recoverFromDeletedConversation(
2155
2079
  userMessageId: string
2156
2080
  ): Promise<AgentWidgetMessage | null> {
2157
2081
  const submitted = this.messages.find(
@@ -2160,44 +2084,44 @@ export class AgentWidgetSession {
2160
2084
  if (!submitted) return null;
2161
2085
  const captured: AgentWidgetMessage = { ...submitted };
2162
2086
 
2163
- this.historyOpenEpoch += 1;
2164
- this.projectionFinalizationPromise = null;
2165
- this.discardConversationState();
2166
- this.activeConversationId = null;
2167
- this.activeConversationRevision = null;
2168
- this.historyNextMessageCursor = null;
2087
+ this.#historyOpenEpoch += 1;
2088
+ this.#projectionFinalizationPromise = null;
2089
+ this.#discardConversationState();
2090
+ this.#activeConversationId = null;
2091
+ this.#activeConversationRevision = null;
2092
+ this.#historyNextMessageCursor = null;
2169
2093
 
2170
2094
  // The recovered turn is the whole transcript: no welcome bubble in front.
2171
- this.suppressWelcomeInjection = true;
2095
+ this.#suppressWelcomeInjection = true;
2172
2096
  try {
2173
- const provider = this.historyProvider;
2097
+ const provider = this.#historyProvider;
2174
2098
  if (provider) {
2175
2099
  const prepared = await provider.prepareStartNew({
2176
- context: this.historyContext(),
2100
+ context: this.#historyContext(),
2177
2101
  });
2178
- await this.installFreshConversation(prepared);
2102
+ await this.#installFreshConversation(prepared);
2179
2103
  } else {
2180
2104
  this.client.clearClientSession();
2181
2105
  const session = await this.client.initSession();
2182
2106
  this.bindActivatedSession(session);
2183
2107
  }
2184
2108
  } catch {
2185
- this.historySendBlocked = true;
2186
- this.historyRecovery = "new_conversation_required";
2187
- this.emitHistoryState();
2188
- this.notifyHistory(
2109
+ this.#historySendBlocked = true;
2110
+ this.#historyRecovery = "new_conversation_required";
2111
+ this.#emitHistoryState();
2112
+ this.#notifyHistory(
2189
2113
  "new_conversation_required",
2190
2114
  "That conversation was deleted. Start a new conversation to continue."
2191
2115
  );
2192
2116
  return null;
2193
2117
  } finally {
2194
- this.suppressWelcomeInjection = false;
2118
+ this.#suppressWelcomeInjection = false;
2195
2119
  }
2196
2120
 
2197
2121
  // Restore only the user's own turn: it is the entire new transcript.
2198
- this.appendMessage(captured);
2199
- this.setStreaming(true);
2200
- this.notifyHistory(
2122
+ this.#appendMessage(captured);
2123
+ this.#setStreaming(true);
2124
+ this.#notifyHistory(
2201
2125
  "conversation_deleted_recovered",
2202
2126
  "That conversation was deleted. Started a new conversation."
2203
2127
  );
@@ -2229,7 +2153,7 @@ export class AgentWidgetSession {
2229
2153
  // turn that's restyling the widget and strand the paused execution.
2230
2154
  if (!replaceClient) {
2231
2155
  this.client.updateConfig(merged);
2232
- if (artifactDisplayChanged) this.refreshArtifactReferenceBlocks();
2156
+ if (artifactDisplayChanged) this.#refreshArtifactReferenceBlocks();
2233
2157
  return;
2234
2158
  }
2235
2159
 
@@ -2240,13 +2164,13 @@ export class AgentWidgetSession {
2240
2164
  // fresh client: in client-token mode that would POST /resume without a
2241
2165
  // valid sessionId and strand the paused turn. Mirrors clearMessages' WebMCP
2242
2166
  // reset; the client swap already abandons any in-flight stream regardless.
2243
- this.abortWebMcpResolves();
2167
+ this.#abortWebMcpResolves();
2244
2168
  this.webMcpInflightKeys.clear();
2245
2169
  this.webMcpResolvedKeys.clear();
2246
2170
  const prevSSECallback = this.client.getSSEEventCallback();
2247
2171
  this.client = new AgentWidgetClient(this.config, this.historyInternals);
2248
- if (artifactDisplayChanged) this.refreshArtifactReferenceBlocks();
2249
- this.wireDefaultWebMcpConfirm();
2172
+ if (artifactDisplayChanged) this.#refreshArtifactReferenceBlocks();
2173
+ this.#wireDefaultWebMcpConfirm();
2250
2174
  if (prevSSECallback) {
2251
2175
  this.client.setSSEEventCallback(prevSSECallback);
2252
2176
  }
@@ -2307,6 +2231,7 @@ export class AgentWidgetSession {
2307
2231
  sequence,
2308
2232
  streaming = false,
2309
2233
  voiceProcessing,
2234
+ voiceCaption,
2310
2235
  rawContent
2311
2236
  } = options;
2312
2237
 
@@ -2324,12 +2249,13 @@ export class AgentWidgetSession {
2324
2249
  role,
2325
2250
  content,
2326
2251
  createdAt: createdAt ?? new Date().toISOString(),
2327
- sequence: sequence ?? this.nextSequence(),
2252
+ sequence: sequence ?? this.#nextSequence(),
2328
2253
  streaming,
2329
2254
  // Only include optional fields if provided
2330
2255
  ...(llmContent !== undefined && { llmContent }),
2331
2256
  ...(contentParts !== undefined && { contentParts }),
2332
2257
  ...(voiceProcessing !== undefined && { voiceProcessing }),
2258
+ ...(voiceCaption && { voiceCaption }),
2333
2259
  ...(rawContent !== undefined && { rawContent })
2334
2260
  };
2335
2261
 
@@ -2427,7 +2353,7 @@ export class AgentWidgetSession {
2427
2353
  role,
2428
2354
  content,
2429
2355
  createdAt: createdAt ?? new Date().toISOString(),
2430
- sequence: sequence ?? this.nextSequence(),
2356
+ sequence: sequence ?? this.#nextSequence(),
2431
2357
  streaming,
2432
2358
  ...(llmContent !== undefined && { llmContent }),
2433
2359
  ...(contentParts !== undefined && { contentParts }),
@@ -2439,7 +2365,7 @@ export class AgentWidgetSession {
2439
2365
  }
2440
2366
 
2441
2367
  // Add all messages, sort once, notify once
2442
- this.messages = this.sortMessages([...this.messages, ...results]);
2368
+ this.messages = this.#sortMessages([...this.messages, ...results]);
2443
2369
  this.callbacks.onMessagesChanged([...this.messages]);
2444
2370
 
2445
2371
  return results;
@@ -2500,7 +2426,7 @@ export class AgentWidgetSession {
2500
2426
  * client to namespace into the request `context`. Failures were already
2501
2427
  * dropped by `finalize()`.
2502
2428
  */
2503
- private async applyMentionBundle(
2429
+ async #applyMentionBundle(
2504
2430
  userMessage: AgentWidgetMessage,
2505
2431
  typedText: string,
2506
2432
  finalize: () => Promise<MentionSubmitBundle>
@@ -2598,6 +2524,13 @@ export class AgentWidgetSession {
2598
2524
  * (`composer.streamingSubmitBehavior: "interrupt"`).
2599
2525
  */
2600
2526
  interrupt?: boolean;
2527
+ /**
2528
+ * Internal (voice client delegation): this is the delegated voice turn.
2529
+ * `userMessageId` names an existing transcript bubble to submit as the
2530
+ * turn's user message instead of appending a new one, so the spoken
2531
+ * request appears (and is sent) exactly once.
2532
+ */
2533
+ voiceTurn?: { userMessageId?: string };
2601
2534
  }
2602
2535
  ) {
2603
2536
  const input = rawInput.trim();
@@ -2617,16 +2550,23 @@ export class AgentWidgetSession {
2617
2550
  )
2618
2551
  return;
2619
2552
 
2553
+ // Any other send replaces a delegated voice turn still in flight: that
2554
+ // turn fails, and this one's reply is neither its answer nor voice-spoken.
2555
+ if (this.#voiceDelegation && !options?.voiceTurn) {
2556
+ this.#voiceDelegation.failed = true;
2557
+ this.#voiceDelegation = null;
2558
+ }
2559
+
2620
2560
  // History transitions (continuity wipe, boot reconciliation, projection
2621
2561
  // finalization, replacement init, external credential change) own the
2622
2562
  // record this turn would land in.
2623
2563
  if (
2624
- this.historyGate ||
2625
- this.projectionFinalizationPromise ||
2626
- this.historySendBlocked ||
2627
- this.credentialReinitPending
2564
+ this.#historyGate ||
2565
+ this.#projectionFinalizationPromise ||
2566
+ this.#historySendBlocked ||
2567
+ this.#credentialReinitPending
2628
2568
  ) {
2629
- await this.awaitHistorySendable();
2569
+ await this.#awaitHistorySendable();
2630
2570
  }
2631
2571
 
2632
2572
  this.stopSpeaking();
@@ -2635,18 +2575,23 @@ export class AgentWidgetSession {
2635
2575
  // turn. Tear them down here (they own controllers separate from the shared
2636
2576
  // one) so a lingering resolve can't race the new dispatch or post a stale
2637
2577
  // /resume against a superseded execution.
2638
- this.abortWebMcpResolves();
2578
+ this.#abortWebMcpResolves();
2579
+ this.#actionsEpoch++;
2639
2580
  // A new turn also supersedes any pending durable reconnect from the prior
2640
2581
  // turn (cancels backoff/listeners, clears the old resume handle).
2641
- this.teardownReconnect();
2582
+ this.#teardownReconnect();
2642
2583
 
2584
+ const voiceBubbleId = options?.voiceTurn?.userMessageId;
2585
+ const voiceBubble = voiceBubbleId
2586
+ ? this.messages.find((m) => m.id === voiceBubbleId && m.role === "user")
2587
+ : undefined;
2643
2588
  // Generate IDs for both user message and expected assistant response
2644
- const userMessageId = generateUserMessageId();
2589
+ const userMessageId = voiceBubble?.id ?? generateUserMessageId();
2645
2590
  const assistantMessageId = generateAssistantMessageId();
2646
2591
  // The active assistant bubble for a durable reconnect is captured from the
2647
2592
  // real streamed message events (see handleEvent), not pre-assigned here:
2648
2593
  // the proxy path auto-generates a different id than `assistantMessageId`.
2649
- this.activeAssistantMessageId = null;
2594
+ this.#activeAssistantMessageId = null;
2650
2595
 
2651
2596
  // Fallback display text ONLY when the sole content is image attachments.
2652
2597
  // A mention/command-only submit (empty text + a chip) must NOT read as
@@ -2661,7 +2606,7 @@ export class AgentWidgetSession {
2661
2606
  role: "user",
2662
2607
  content: input || imageOnlyFallback, // Display text (fallback if only images)
2663
2608
  createdAt: new Date().toISOString(),
2664
- sequence: this.nextSequence(),
2609
+ sequence: this.#nextSequence(),
2665
2610
  viaVoice: options?.viaVoice || false,
2666
2611
  // Include contentParts if provided (for multi-modal messages)
2667
2612
  ...(options?.contentParts && options.contentParts.length > 0 && {
@@ -2681,8 +2626,17 @@ export class AgentWidgetSession {
2681
2626
  ...(options?.replayFields ?? {})
2682
2627
  };
2683
2628
 
2684
- this.appendMessage(userMessage);
2685
- this.setStreaming(true);
2629
+ if (voiceBubble) {
2630
+ // The transcript bubble is the user message: the model gets the request
2631
+ // text the voice model delegated, the bubble keeps what was heard.
2632
+ voiceBubble.viaVoice = true;
2633
+ delete voiceBubble.voiceCaption;
2634
+ if (voiceBubble.content.trim() !== input) voiceBubble.llmContent = input;
2635
+ this.callbacks.onMessagesChanged([...this.messages]);
2636
+ } else {
2637
+ this.#appendMessage(userMessage);
2638
+ }
2639
+ this.#setStreaming(true);
2686
2640
 
2687
2641
  // Assign the fresh controller BEFORE the mention await so cancel() (or a
2688
2642
  // superseding sendMessage) during finalize() aborts THIS turn, not a stale
@@ -2699,7 +2653,7 @@ export class AgentWidgetSession {
2699
2653
  if (options?.mentions) {
2700
2654
  const stored =
2701
2655
  this.messages.find((m) => m.id === userMessageId) ?? userMessage;
2702
- await this.applyMentionBundle(stored, input, options.mentions.finalize);
2656
+ await this.#applyMentionBundle(stored, input, options.mentions.finalize);
2703
2657
  // A cancel() or new sendMessage during finalize aborted this controller
2704
2658
  // (and replaced/nulled the shared ref). Bail without dispatching and
2705
2659
  // leave whatever idle/streaming state that caller already set.
@@ -2729,10 +2683,17 @@ export class AgentWidgetSession {
2729
2683
  }
2730
2684
  }
2731
2685
 
2732
- const snapshot = [...this.messages];
2686
+ // A submitted voice bubble may sit above captions or a turn that finished
2687
+ // while it waited: the request still ends on it.
2688
+ const snapshot = voiceBubble
2689
+ ? [
2690
+ ...this.messages.filter((m) => m !== voiceBubble),
2691
+ { ...voiceBubble, createdAt: new Date().toISOString() }
2692
+ ]
2693
+ : [...this.messages];
2733
2694
 
2734
2695
  try {
2735
- await this.dispatchWithDeletedRecovery(
2696
+ await this.#dispatchWithDeletedRecovery(
2736
2697
  snapshot,
2737
2698
  controller,
2738
2699
  assistantMessageId,
@@ -2747,7 +2708,8 @@ export class AgentWidgetSession {
2747
2708
  // first, which already flipped us into `resuming` and armed reconnect.
2748
2709
  // The subsequent dispatch rejection must NOT paint a dispatch-error
2749
2710
  // bubble: the turn is being resumed, not failed.
2750
- if (this.status === "resuming" || this.reconnecting) return;
2711
+ if (this.status === "resuming" || this.#reconnecting) return;
2712
+ if (this.#voiceDelegation) this.#voiceDelegation.failed = true;
2751
2713
  // Check if this is an abort error (user canceled, navigated away, etc.)
2752
2714
  // In these cases, don't show fallback - the request was intentionally interrupted
2753
2715
  const isAbortError =
@@ -2769,15 +2731,15 @@ export class AgentWidgetSession {
2769
2731
  role: "assistant",
2770
2732
  createdAt: new Date().toISOString(),
2771
2733
  content,
2772
- sequence: this.nextSequence()
2734
+ sequence: this.#nextSequence()
2773
2735
  };
2774
2736
 
2775
- this.appendMessage(fallback);
2737
+ this.#appendMessage(fallback);
2776
2738
  }
2777
2739
  }
2778
2740
 
2779
- this.setStatus("idle");
2780
- this.setStreaming(false);
2741
+ this.#setStatus("idle");
2742
+ this.#setStreaming(false);
2781
2743
  this.abortController = null;
2782
2744
 
2783
2745
  if (!isAbortError) {
@@ -2845,7 +2807,7 @@ export class AgentWidgetSession {
2845
2807
  };
2846
2808
 
2847
2809
  const composerOptions = replacement
2848
- ? this.toComposerOptionsPayload(replacement.options)
2810
+ ? this.#toComposerOptionsPayload(replacement.options)
2849
2811
  : stored.composerOptions;
2850
2812
  const contentSegments = replacement
2851
2813
  ? replacement.contentSegments
@@ -2872,7 +2834,7 @@ export class AgentWidgetSession {
2872
2834
  }
2873
2835
 
2874
2836
  /** Submission options → the wire payload shape; quote travels separately. */
2875
- private toComposerOptionsPayload(
2837
+ #toComposerOptionsPayload(
2876
2838
  options: { selectedModelId?: string; activeModeIds?: string[] } | undefined
2877
2839
  ): ComposerOptionsPayload | undefined {
2878
2840
  if (!options) return undefined;
@@ -2903,21 +2865,21 @@ export class AgentWidgetSession {
2903
2865
  // Same ownership rule as sendMessage: a pending history transition owns the
2904
2866
  // record this turn would land in.
2905
2867
  if (
2906
- this.historyGate ||
2907
- this.projectionFinalizationPromise ||
2908
- this.historySendBlocked ||
2909
- this.credentialReinitPending
2868
+ this.#historyGate ||
2869
+ this.#projectionFinalizationPromise ||
2870
+ this.#historySendBlocked ||
2871
+ this.#credentialReinitPending
2910
2872
  ) {
2911
- await this.awaitHistorySendable();
2873
+ await this.#awaitHistorySendable();
2912
2874
  }
2913
2875
 
2914
2876
  this.abortController?.abort();
2915
- this.teardownReconnect();
2877
+ this.#teardownReconnect();
2916
2878
 
2917
2879
  const assistantMessageId = generateAssistantMessageId();
2918
- this.activeAssistantMessageId = null;
2880
+ this.#activeAssistantMessageId = null;
2919
2881
 
2920
- this.setStreaming(true);
2882
+ this.#setStreaming(true);
2921
2883
 
2922
2884
  const controller = new AbortController();
2923
2885
  this.abortController = controller;
@@ -2934,7 +2896,7 @@ export class AgentWidgetSession {
2934
2896
  this.handleEvent
2935
2897
  );
2936
2898
  } catch (error) {
2937
- if (this.status === "resuming" || this.reconnecting) return;
2899
+ if (this.status === "resuming" || this.#reconnecting) return;
2938
2900
  // Check if this is an abort error (a prior in-flight stream was canceled,
2939
2901
  // the user navigated away, etc.). In these cases, don't show fallback or
2940
2902
  // fire onError - the request was intentionally interrupted.
@@ -2957,14 +2919,14 @@ export class AgentWidgetSession {
2957
2919
  role: "assistant",
2958
2920
  createdAt: new Date().toISOString(),
2959
2921
  content,
2960
- sequence: this.nextSequence()
2922
+ sequence: this.#nextSequence()
2961
2923
  };
2962
2924
 
2963
- this.appendMessage(fallback);
2925
+ this.#appendMessage(fallback);
2964
2926
  }
2965
2927
  }
2966
- this.setStatus("idle");
2967
- this.setStreaming(false);
2928
+ this.#setStatus("idle");
2929
+ this.#setStreaming(false);
2968
2930
  this.abortController = null;
2969
2931
  if (!isAbortError) {
2970
2932
  if (error instanceof Error) {
@@ -3008,7 +2970,7 @@ export class AgentWidgetSession {
3008
2970
  // Durable reconnect keeps filling the same bubble: track its id so the
3009
2971
  // cursor handler attaches to it.
3010
2972
  if (options?.preserveAssistantId && options.assistantMessageId) {
3011
- this.activeAssistantMessageId = options.assistantMessageId;
2973
+ this.#activeAssistantMessageId = options.assistantMessageId;
3012
2974
  }
3013
2975
 
3014
2976
  // Finalize any stale streaming messages from the previous stream
@@ -3028,27 +2990,42 @@ export class AgentWidgetSession {
3028
2990
  this.callbacks.onMessagesChanged([...this.messages]);
3029
2991
  }
3030
2992
 
3031
- this.setStreaming(true);
2993
+ this.#setStreaming(true);
3032
2994
 
2995
+ const streamToken = {};
3033
2996
  try {
3034
2997
  await this.client.processStream(
3035
2998
  stream,
3036
- this.handleEvent,
2999
+ (event) => {
3000
+ const previous = this.#activeStreamToken;
3001
+ this.#activeStreamToken = streamToken;
3002
+ try {
3003
+ this.handleEvent(event);
3004
+ } finally {
3005
+ this.#activeStreamToken = previous;
3006
+ }
3007
+ },
3037
3008
  options?.assistantMessageId,
3038
3009
  options?.seedContent
3039
3010
  );
3011
+ this.#flushApprovalSettles(streamToken);
3040
3012
  } catch (error) {
3041
3013
  // During a durable reconnect a thrown resume stream is just another drop:
3042
3014
  // the reconnect loop owns the retry/backoff. Don't paint an error or tear
3043
- // down, stay in `resuming`.
3044
- if (this.status === "resuming" || this.reconnecting) return;
3045
- this.setStatus("error");
3015
+ // down, stay in `resuming`. The reconnect may still deliver the paused
3016
+ // call's result, so its approval settles wait for streaming to end.
3017
+ if (this.status === "resuming" || this.#reconnecting) {
3018
+ this.#releaseApprovalSettles(streamToken);
3019
+ return;
3020
+ }
3021
+ this.#flushApprovalSettles(streamToken);
3022
+ this.#setStatus("error");
3046
3023
  // Mirror the idle/error handlers: a failed resume stream must not tear
3047
3024
  // down streaming/abortController while another WebMCP resolve is still
3048
3025
  // confirming or executing. The in-flight resolve's `finally` owns the
3049
3026
  // teardown once `webMcpResolveControllers` drains.
3050
3027
  if (this.webMcpResolveControllers.size === 0) {
3051
- this.setStreaming(false);
3028
+ this.#setStreaming(false);
3052
3029
  this.abortController = null;
3053
3030
  }
3054
3031
  this.callbacks.onError?.(
@@ -3063,7 +3040,7 @@ export class AgentWidgetSession {
3063
3040
  * this, the bridge falls back to a blunt `window.confirm`. Safe to call
3064
3041
  * repeatedly (e.g. after the client is re-created in `updateConfig`).
3065
3042
  */
3066
- private wireDefaultWebMcpConfirm(): void {
3043
+ #wireDefaultWebMcpConfirm(): void {
3067
3044
  const webmcp = this.config.webmcp;
3068
3045
  if (webmcp?.enabled === true && !webmcp.onConfirm) {
3069
3046
  this.client.setWebMcpConfirmHandler((info) =>
@@ -3092,7 +3069,7 @@ export class AgentWidgetSession {
3092
3069
  }
3093
3070
 
3094
3071
  const approval: AgentWidgetApproval = {
3095
- id: `webmcp-${++this.webMcpApprovalSeq}`,
3072
+ id: `webmcp-${++this.#webMcpApprovalSeq}`,
3096
3073
  status: "pending",
3097
3074
  agentId: "",
3098
3075
  executionId: "",
@@ -3148,133 +3125,58 @@ export class AgentWidgetSession {
3148
3125
  resolve(decision === "approved");
3149
3126
  }
3150
3127
 
3128
+ #actionsHost: SessionActionsHost | null = null;
3129
+ // Advanced when the turn is stopped or replaced (cancel, clear, new send,
3130
+ // hydrate, conversation switch). A resolve waiting on the lazy chunk checks
3131
+ // it after the load so a stopped turn never resumes late.
3132
+ #actionsEpoch = 0;
3133
+
3134
+ /**
3135
+ * Run a resolve path from `session-actions.ts` (the lazy
3136
+ * `session-actions.js` chunk in the IIFE build). Synchronous when the module
3137
+ * is already loaded or provided, so the call's timing matches an inline
3138
+ * method; otherwise it waits for the chunk and reports a failed load via
3139
+ * `onError` (the resolve never started, so no state needs unwinding). A turn
3140
+ * stopped or replaced while the chunk loads drops the resolve.
3141
+ */
3142
+ #withActions(
3143
+ run: (actions: SessionActionsModule, host: SessionActionsHost) => Promise<void>
3144
+ ): Promise<void> {
3145
+ const host = (this.#actionsHost ??= {
3146
+ s: this as unknown as SessionActionsInternals,
3147
+ setStreaming: (streaming) => this.#setStreaming(streaming),
3148
+ appendMessage: (message) => this.#appendMessage(message),
3149
+ nextSequence: () => this.#nextSequence(),
3150
+ settleApprovalPausedToolCall: (id) => this.#settleApprovalPausedToolCall(id),
3151
+ resumeAfter: (executionId) =>
3152
+ this.#resumable?.executionId === executionId
3153
+ ? this.#resumable.lastEventId
3154
+ : undefined,
3155
+ });
3156
+ const actions = getSessionActionsSync();
3157
+ if (actions) return run(actions, host);
3158
+ const epoch = this.#actionsEpoch;
3159
+ return loadSessionActions().then(
3160
+ (loaded) => (epoch === this.#actionsEpoch ? run(loaded, host) : undefined),
3161
+ (error) => {
3162
+ this.callbacks.onError?.(error instanceof Error ? error : new Error(String(error)));
3163
+ }
3164
+ );
3165
+ }
3166
+
3151
3167
  /**
3152
3168
  * Resolve a tool approval request (approve or deny).
3153
3169
  * Updates the approval message status, calls the API (or custom onDecision),
3154
3170
  * and pipes the response stream through connectStream().
3155
3171
  */
3156
- public async resolveApproval(
3172
+ public resolveApproval(
3157
3173
  approval: AgentWidgetApproval,
3158
3174
  decision: 'approved' | 'denied',
3159
3175
  options?: AgentWidgetApprovalDecisionOptions
3160
3176
  ): Promise<void> {
3161
- // 1. Update approval message status immediately for responsive UI
3162
- const approvalMessageId = `approval-${approval.id}`;
3163
- const updatedApproval: AgentWidgetApproval = {
3164
- ...approval,
3165
- status: decision,
3166
- resolvedAt: Date.now(),
3167
- };
3168
- // Anchor the bubble where the agent paused for permission. An approval is a
3169
- // timeline checkpoint, not a "now" event, so resolving it must preserve the
3170
- // original message's createdAt/sequence: otherwise sortMessages (which
3171
- // orders by createdAt first) would re-stamp it to now and float it past any
3172
- // message created later (e.g. a long-pending approval resolved after more
3173
- // conversation, or restored/replayed transcripts).
3174
- const existing = this.messages.find((m) => m.id === approvalMessageId);
3175
- const updatedMessage: AgentWidgetMessage = {
3176
- id: approvalMessageId,
3177
- role: "assistant",
3178
- content: "",
3179
- createdAt: existing?.createdAt ?? new Date().toISOString(),
3180
- ...(existing?.sequence !== undefined ? { sequence: existing.sequence } : {}),
3181
- streaming: false,
3182
- variant: "approval",
3183
- approval: updatedApproval,
3184
- };
3185
- this.upsertMessage(updatedMessage);
3186
-
3187
- // Show the standalone typing indicator immediately while we wait for the
3188
- // approval round-trip. Install an abortController so cancel() works during
3189
- // the silent gap. See `resolveAskUserQuestion` for the same pattern.
3190
- this.abortController?.abort();
3191
- this.abortController = new AbortController();
3192
- this.setStreaming(true);
3193
-
3194
- // 2. Call onDecision callback if provided, otherwise use client.resolveApproval()
3195
- const approvalConfig = this.config.approval;
3196
- const onDecision = approvalConfig && typeof approvalConfig === 'object' ? approvalConfig.onDecision : undefined;
3197
-
3198
- try {
3199
- let response: Response | ReadableStream<Uint8Array> | void;
3200
-
3201
- if (onDecision) {
3202
- response = await onDecision(
3203
- {
3204
- approvalId: approval.id,
3205
- executionId: approval.executionId,
3206
- agentId: approval.agentId,
3207
- toolName: approval.toolName,
3208
- },
3209
- decision,
3210
- options
3211
- );
3212
- } else {
3213
- response = await this.client.resolveApproval(
3214
- {
3215
- agentId: approval.agentId,
3216
- executionId: approval.executionId,
3217
- approvalId: approval.id,
3218
- },
3219
- decision
3220
- );
3221
- }
3222
-
3223
- // 3. Pipe through connectStream if we got a response with a body
3224
- if (response) {
3225
- let stream: ReadableStream<Uint8Array> | null = null;
3226
- if (response instanceof Response) {
3227
- if (!response.ok) {
3228
- const errorData = await response.json().catch(() => null);
3229
- throw new Error(
3230
- errorData?.error ?? `Approval request failed: ${response.status}`
3231
- );
3232
- }
3233
- stream = response.body;
3234
- } else if (response instanceof ReadableStream) {
3235
- stream = response;
3236
- }
3237
-
3238
- if (stream) {
3239
- await this.connectStream(stream, { allowReentry: true });
3240
- } else {
3241
- if (decision === 'denied') {
3242
- // No stream body for denied: inject a denial message
3243
- this.appendMessage({
3244
- id: `denial-${approval.id}`,
3245
- role: "assistant",
3246
- content: "Tool execution was denied by user.",
3247
- createdAt: new Date().toISOString(),
3248
- streaming: false,
3249
- sequence: this.nextSequence(),
3250
- });
3251
- }
3252
- // No body to pipe: drop the pre-set streaming flag so the indicator
3253
- // doesn't linger forever.
3254
- this.setStreaming(false);
3255
- this.abortController = null;
3256
- }
3257
- } else {
3258
- // onDecision returned void / no response: drop the pre-set flag.
3259
- this.setStreaming(false);
3260
- this.abortController = null;
3261
- }
3262
- } catch (error) {
3263
- const isAbortError =
3264
- error instanceof Error &&
3265
- (error.name === 'AbortError' ||
3266
- error.message.includes('aborted') ||
3267
- error.message.includes('abort'));
3268
-
3269
- this.setStreaming(false);
3270
- this.abortController = null;
3271
-
3272
- if (!isAbortError) {
3273
- this.callbacks.onError?.(
3274
- error instanceof Error ? error : new Error(String(error))
3275
- );
3276
- }
3277
- }
3177
+ return this.#withActions((actions, host) =>
3178
+ actions.resolveApproval(host, approval, decision, options)
3179
+ );
3278
3180
  }
3279
3181
 
3280
3182
  /**
@@ -3289,6 +3191,27 @@ export class AgentWidgetSession {
3289
3191
  * 3. Append a user-visible bubble with the answer text so the
3290
3192
  * transcript reads naturally.
3291
3193
  */
3194
+ public resolveAskUserQuestion(
3195
+ toolMessage: AgentWidgetMessage,
3196
+ answer: string | Record<string, string | string[]>
3197
+ ): Promise<void> {
3198
+ return this.#withActions((actions, host) =>
3199
+ actions.resolveAskUserQuestion(host, toolMessage, answer)
3200
+ );
3201
+ }
3202
+
3203
+ /**
3204
+ * Resolve a paused auto-resolving LOCAL tool call (`webmcp:*` page tools
3205
+ * and the built-in `suggest_replies`) and post the result to `/resume`.
3206
+ * Triggered automatically from `handleEvent`; idempotent on the message's
3207
+ * `toolCall.id`. See `resolveWebMcpToolCall` in `session-actions.ts`.
3208
+ */
3209
+ public resolveWebMcpToolCall(toolMessage: AgentWidgetMessage): Promise<void> {
3210
+ return this.#withActions((actions, host) =>
3211
+ actions.resolveWebMcpToolCall(host, toolMessage)
3212
+ );
3213
+ }
3214
+
3292
3215
  /**
3293
3216
  * Persist in-progress answers and the current page index for a multi-question
3294
3217
  * `ask_user_question` payload, so a refresh resumes on the same page with
@@ -3338,167 +3261,6 @@ export class AgentWidgetSession {
3338
3261
  });
3339
3262
  }
3340
3263
 
3341
- public async resolveAskUserQuestion(
3342
- toolMessage: AgentWidgetMessage,
3343
- answer: string | Record<string, string | string[]>
3344
- ): Promise<void> {
3345
- // Idempotent: guards against rapid double-clicks on answer pills before
3346
- // the re-render swaps the card to its collapsed/answered state.
3347
- const live = this.messages.find((m) => m.id === toolMessage.id);
3348
- if (live?.agentMetadata?.askUserQuestionAnswered === true) return;
3349
-
3350
- const executionId = toolMessage.agentMetadata?.executionId;
3351
- const toolName = toolMessage.toolCall?.name;
3352
- if (!executionId || !toolName) {
3353
- this.callbacks.onError?.(
3354
- new Error(
3355
- "resolveAskUserQuestion: message is missing executionId or toolCall.name"
3356
- )
3357
- );
3358
- return;
3359
- }
3360
-
3361
- // Flip answered flag first so the next render skips the sheet re-mount,
3362
- // avoiding the race between removeAskUserQuestionSheet's 180ms slide-out
3363
- // timer and the renders that fire as the resume stream lands. Pass the
3364
- // structured answer Record (when present) so it's atomically persisted
3365
- // alongside the flag: the answered-state review card depends on
3366
- // `agentMetadata.askUserQuestionAnswers` being populated at render time.
3367
- //
3368
- // For single-question payloads, callers (built-in pick handler, plugins)
3369
- // resolve with a plain string. Derive a `{ [questionText]: answer }` Record
3370
- // from the toolCall args so the answered-card render path is consistent
3371
- // with grouped flows.
3372
- let structuredAnswers: Record<string, string | string[]> | undefined =
3373
- typeof answer === "string" ? undefined : answer;
3374
- if (structuredAnswers === undefined && typeof answer === "string") {
3375
- const args = toolMessage.toolCall?.args as
3376
- | { questions?: Array<{ question?: unknown }> }
3377
- | undefined;
3378
- const questions = Array.isArray(args?.questions) ? args!.questions : [];
3379
- if (questions.length === 1) {
3380
- const qText = typeof questions[0]?.question === "string"
3381
- ? (questions[0].question as string)
3382
- : "";
3383
- if (qText) structuredAnswers = { [qText]: answer };
3384
- }
3385
- }
3386
- this.markAskUserQuestionResolved(toolMessage, structuredAnswers);
3387
-
3388
- // Show the standalone typing indicator immediately: the network round-trip
3389
- // to /resume is otherwise silent, which reads as broken. The render
3390
- // condition in ui.ts already shows the indicator once streaming flips true
3391
- // and the last message is a user bubble (the answer we inject below).
3392
- // Install an abortController so cancel() works during this silent gap.
3393
- this.abortController?.abort();
3394
- this.abortController = new AbortController();
3395
- this.setStreaming(true);
3396
-
3397
- // Inject Q→A pair messages: one assistant bubble per question, one user
3398
- // bubble per answer, so the transcript reads like a normal conversation.
3399
- // The original ask_user_question tool message is suppressed by the
3400
- // renderer once `askUserQuestionAnswered` is true. Skipped questions get
3401
- // a muted italic `*Skipped*` user bubble (rendered through the standard
3402
- // markdown pipeline).
3403
- const toolCallId = toolMessage.toolCall!.id;
3404
- const args = toolMessage.toolCall?.args as
3405
- | { questions?: Array<{ question?: unknown; header?: unknown }> }
3406
- | undefined;
3407
- const questions = Array.isArray(args?.questions) ? args!.questions : [];
3408
- if (questions.length === 0) {
3409
- const fallback =
3410
- typeof answer === "string"
3411
- ? answer
3412
- : Object.entries(answer)
3413
- .map(
3414
- ([q, v]) => `${q}: ${Array.isArray(v) ? v.join(", ") : v}`
3415
- )
3416
- .join(" | ");
3417
- this.appendMessage({
3418
- id: `ask-user-answer-${toolCallId}`,
3419
- role: "user",
3420
- content: fallback,
3421
- createdAt: new Date().toISOString(),
3422
- streaming: false,
3423
- sequence: this.nextSequence(),
3424
- });
3425
- } else {
3426
- const stored = structuredAnswers ?? {};
3427
- questions.forEach((p, i) => {
3428
- const qText = typeof p?.question === "string" ? p.question : "";
3429
- if (!qText) return;
3430
- const ans = stored[qText];
3431
- const answerStr = Array.isArray(ans)
3432
- ? ans.join(", ")
3433
- : typeof ans === "string"
3434
- ? ans
3435
- : "";
3436
- this.appendMessage({
3437
- id: `ask-user-q-${toolCallId}-${i}`,
3438
- role: "assistant",
3439
- content: qText,
3440
- createdAt: new Date().toISOString(),
3441
- streaming: false,
3442
- sequence: this.nextSequence(),
3443
- });
3444
- this.appendMessage({
3445
- id: `ask-user-a-${toolCallId}-${i}`,
3446
- role: "user",
3447
- content: answerStr || "*Skipped*",
3448
- createdAt: new Date().toISOString(),
3449
- streaming: false,
3450
- sequence: this.nextSequence(),
3451
- });
3452
- });
3453
- }
3454
-
3455
- try {
3456
- const response = await this.client.resumeFlow(
3457
- executionId,
3458
- { [toolName]: answer },
3459
- {
3460
- after:
3461
- this.resumable?.executionId === executionId
3462
- ? this.resumable.lastEventId
3463
- : undefined,
3464
- },
3465
- );
3466
-
3467
- if (!response.ok) {
3468
- const errorData = await response.json().catch(() => null);
3469
- throw new Error(
3470
- errorData?.error ?? `Resume failed: ${response.status}`
3471
- );
3472
- }
3473
-
3474
- if (response.body) {
3475
- await this.connectStream(response.body, { allowReentry: true });
3476
- } else {
3477
- // No body to pipe: drop the pre-set streaming flag so the indicator
3478
- // doesn't linger forever.
3479
- this.setStreaming(false);
3480
- this.abortController = null;
3481
- }
3482
- } catch (error) {
3483
- // Mirror sendMessage: a cancel() during the await aborts the controller
3484
- // and surfaces an AbortError: don't treat that as a real failure.
3485
- const isAbortError =
3486
- error instanceof Error &&
3487
- (error.name === 'AbortError' ||
3488
- error.message.includes('aborted') ||
3489
- error.message.includes('abort'));
3490
-
3491
- this.setStreaming(false);
3492
- this.abortController = null;
3493
-
3494
- if (!isAbortError) {
3495
- this.callbacks.onError?.(
3496
- error instanceof Error ? error : new Error(String(error))
3497
- );
3498
- }
3499
- }
3500
- }
3501
-
3502
3264
  /**
3503
3265
  * Collect an auto-resolving LOCAL-tool `await` (`webmcp:*` page tools
3504
3266
  * and the built-in `suggest_replies`) into a per-executionId batch
@@ -3517,13 +3279,13 @@ export class AgentWidgetSession {
3517
3279
  *: route them straight to the single-call path, which surfaces the malformed
3518
3280
  * wire shape via `onError` / an `isError` resume.
3519
3281
  */
3520
- private enqueueWebMcpAwait(toolMessage: AgentWidgetMessage): void {
3282
+ #enqueueWebMcpAwait(toolMessage: AgentWidgetMessage): void {
3521
3283
  const executionId = toolMessage.agentMetadata?.executionId;
3522
3284
  const callId = toolMessage.toolCall?.id;
3523
3285
  if (!executionId || !callId) {
3524
- const queuedEpoch = this.webMcpEpoch;
3286
+ const queuedEpoch = this.#webMcpEpoch;
3525
3287
  queueMicrotask(() => {
3526
- if (queuedEpoch !== this.webMcpEpoch) return;
3288
+ if (queuedEpoch !== this.#webMcpEpoch) return;
3527
3289
  void this.resolveWebMcpToolCall(toolMessage);
3528
3290
  });
3529
3291
  return;
@@ -3555,13 +3317,13 @@ export class AgentWidgetSession {
3555
3317
  * abortController) settles before a resolve grabs them: the same ordering the
3556
3318
  * single-call resolve always relied on.
3557
3319
  */
3558
- private scheduleWebMcpBatchFlush(): void {
3320
+ #scheduleWebMcpBatchFlush(): void {
3559
3321
  if (this.webMcpAwaitBatches.size === 0) return;
3560
- const queuedEpoch = this.webMcpEpoch;
3322
+ const queuedEpoch = this.#webMcpEpoch;
3561
3323
  queueMicrotask(() => {
3562
- if (queuedEpoch !== this.webMcpEpoch) return;
3324
+ if (queuedEpoch !== this.#webMcpEpoch) return;
3563
3325
  for (const executionId of [...this.webMcpAwaitBatches.keys()]) {
3564
- this.flushWebMcpAwaitBatch(executionId);
3326
+ this.#flushWebMcpAwaitBatch(executionId);
3565
3327
  }
3566
3328
  });
3567
3329
  }
@@ -3574,7 +3336,7 @@ export class AgentWidgetSession {
3574
3336
  * so any later sibling re-emit (e.g. from a re-pause) forms a fresh batch
3575
3337
  * rather than mutating one already in flight.
3576
3338
  */
3577
- private flushWebMcpAwaitBatch(executionId: string): void {
3339
+ #flushWebMcpAwaitBatch(executionId: string): void {
3578
3340
  const batch = this.webMcpAwaitBatches.get(executionId);
3579
3341
  if (!batch) return;
3580
3342
  this.webMcpAwaitBatches.delete(executionId);
@@ -3582,498 +3344,9 @@ export class AgentWidgetSession {
3582
3344
  if (snapshots.length === 1) {
3583
3345
  void this.resolveWebMcpToolCall(snapshots[0]);
3584
3346
  } else if (snapshots.length > 1) {
3585
- void this.resolveWebMcpToolCallBatch(executionId, snapshots);
3586
- }
3587
- }
3588
-
3589
- private resolveWebMcpToolStartedAt(
3590
- toolMessage: AgentWidgetMessage,
3591
- ): number {
3592
- const stored = this.messages.find((m) => m.id === toolMessage.id);
3593
- const candidates = [
3594
- stored?.toolCall?.startedAt,
3595
- toolMessage.toolCall?.startedAt,
3596
- ];
3597
- for (const candidate of candidates) {
3598
- if (typeof candidate === "number" && Number.isFinite(candidate)) {
3599
- return candidate;
3600
- }
3601
- }
3602
- return Date.now();
3603
- }
3604
-
3605
- /**
3606
- * Persisted-resolution guard for `suggest_replies`. The in-memory dedupe
3607
- * sets (`webMcpInflightKeys` / `webMcpResolvedKeys`) are cleared by
3608
- * hydrateMessages/clearMessages/cancel, but `suggestRepliesResolved`
3609
- * survives on the stored message, so a stale `await` re-emit after a
3610
- * hydration must not re-POST `/resume` for an already-resolved call (the
3611
- * historical double-resume failure mode the batching work exists to avoid).
3612
- * Checks the LIVE message first; the handleEvent snapshot is a fresh wire
3613
- * skeleton whose metadata never carries the flag.
3614
- */
3615
- private isSuggestRepliesAlreadyResolved(
3616
- toolMessage: AgentWidgetMessage,
3617
- ): boolean {
3618
- if (toolMessage.toolCall?.name !== SUGGEST_REPLIES_TOOL_NAME) return false;
3619
- const stored = this.messages.find((m) => m.id === toolMessage.id);
3620
- return (
3621
- (stored ?? toolMessage).agentMetadata?.suggestRepliesResolved === true
3622
- );
3623
- }
3624
-
3625
- private markWebMcpToolRunning(
3626
- toolMessage: AgentWidgetMessage,
3627
- ): number {
3628
- const startedAt = this.resolveWebMcpToolStartedAt(toolMessage);
3629
- this.upsertMessage({
3630
- ...toolMessage,
3631
- streaming: true,
3632
- agentMetadata: {
3633
- ...toolMessage.agentMetadata,
3634
- awaitingLocalTool: false,
3635
- },
3636
- toolCall: toolMessage.toolCall
3637
- ? {
3638
- ...toolMessage.toolCall,
3639
- status: "running",
3640
- startedAt,
3641
- completedAt: undefined,
3642
- duration: undefined,
3643
- durationMs: undefined,
3644
- }
3645
- : toolMessage.toolCall,
3646
- });
3647
- return startedAt;
3648
- }
3649
-
3650
- private markWebMcpToolComplete(
3651
- toolMessage: AgentWidgetMessage,
3652
- result: unknown,
3653
- startedAt: number,
3654
- completedAt = Date.now(),
3655
- extraMetadata?: Partial<
3656
- NonNullable<AgentWidgetMessage["agentMetadata"]>
3657
- >,
3658
- ): void {
3659
- // A teardown such as clearMessages()/hydrateMessages()/new send can remove
3660
- // the bubble while an aborted WebMCP promise is settling. Never resurrect a
3661
- // cleared message just to mark the old resolve complete.
3662
- if (!this.messages.some((message) => message.id === toolMessage.id)) return;
3663
- this.upsertMessage({
3664
- ...toolMessage,
3665
- streaming: false,
3666
- agentMetadata: {
3667
- ...toolMessage.agentMetadata,
3668
- awaitingLocalTool: false,
3669
- ...extraMetadata,
3670
- },
3671
- toolCall: toolMessage.toolCall
3672
- ? {
3673
- ...toolMessage.toolCall,
3674
- status: "complete",
3675
- result,
3676
- startedAt,
3677
- completedAt,
3678
- duration: undefined,
3679
- durationMs: Math.max(0, completedAt - startedAt),
3680
- }
3681
- : toolMessage.toolCall,
3682
- });
3683
- }
3684
-
3685
- /**
3686
- * Resolve one or more parallel local-tool awaits sharing one paused
3687
- * executionId with a SINGLE `/resume` (core#3878); `resolveWebMcpToolCall`
3688
- * delegates size-1 resolves here after its guards. By default each call is
3689
- * executed against the page registry concurrently: every gated call renders
3690
- * its own native approval bubble, and a sibling's confirm Promise never
3691
- * blocks another's execution. With `webmcp.execution: "sequential"` the
3692
- * calls run one at a time in emission order instead (see the config docs
3693
- * for when that matters). Outputs are keyed by per-call `webMcpToolCallId`
3694
- * (server prefers it over tool name; name-keying remains the fallback for
3695
- * legacy single/distinct-tool turns), so two calls to the SAME tool no longer
3696
- * collide. The server is tolerant: any call we omit (declined-after-abort,
3697
- * dedupe, exec failure) simply re-pauses and is retried on its re-emit.
3698
- *
3699
- * Owns the dedupe / abort / streaming machinery for both routes; resolved
3700
- * keys are marked on the shared resume POST's HTTP OK.
3701
- */
3702
- private async resolveWebMcpToolCallBatch(
3703
- executionId: string,
3704
- snapshots: AgentWidgetMessage[],
3705
- ): Promise<void> {
3706
- type ExecutedWebMcpTool = {
3707
- dedupeKey: string;
3708
- resumeKey: string;
3709
- output: unknown;
3710
- toolMessage: AgentWidgetMessage;
3711
- startedAt: number;
3712
- completedAt: number;
3713
- };
3714
- const claimedKeys: string[] = [];
3715
- // One controller per batch, shared by every execute and the resume fetch.
3716
- // Teardown (`abortWebMcpResolves`) only ever aborts the whole set, so
3717
- // finer granularity buys nothing; one registration per resolve op also
3718
- // keeps `webMcpResolveControllers.size` === in-flight resolve count.
3719
- const batchController = new AbortController();
3720
- this.webMcpResolveControllers.add(batchController);
3721
- this.setStreaming(true);
3722
-
3723
- // Phase 1: execute every pending call. A null result means the call was
3724
- // deduped, aborted, or threw; it's omitted from the resume and (per the
3725
- // tolerant server) re-pauses for retry.
3726
- const executeOne = async (
3727
- toolMessage: AgentWidgetMessage,
3728
- ): Promise<ExecutedWebMcpTool | null> => {
3729
- const wireToolName = toolMessage.toolCall?.name;
3730
- const callId = toolMessage.toolCall?.id;
3731
- if (!wireToolName || !callId) return null;
3732
-
3733
- const dedupeKey = `${executionId}:${callId}`;
3734
- if (
3735
- this.webMcpInflightKeys.has(dedupeKey) ||
3736
- this.webMcpResolvedKeys.has(dedupeKey) ||
3737
- this.isSuggestRepliesAlreadyResolved(toolMessage)
3738
- ) {
3739
- return null;
3740
- }
3741
- this.webMcpInflightKeys.add(dedupeKey);
3742
- claimedKeys.push(dedupeKey);
3743
-
3744
- // Clear the awaiting flag and keep the tool bubble running while the
3745
- // browser-side WebMCP promise is in flight. The initial `await`
3746
- // only means the server paused for a local tool; it is not completion.
3747
- const startedAt = this.markWebMcpToolRunning(toolMessage);
3748
-
3749
- // Per-call id wins for resume keying; fall back to the wire tool name
3750
- // for legacy servers that don't emit `webMcpToolCallId`.
3751
- const resumeKey =
3752
- toolMessage.agentMetadata?.webMcpToolCallId ?? wireToolName;
3753
-
3754
- // Built-in fire-and-forget tool: no bridge, no confirm gate, no
3755
- // browser-side execution: the chips render from the message list and
3756
- // the canned output joins the batch's single /resume.
3757
- if (wireToolName === SUGGEST_REPLIES_TOOL_NAME) {
3758
- return {
3759
- dedupeKey,
3760
- resumeKey,
3761
- output: suggestRepliesToolResult(),
3762
- toolMessage,
3763
- startedAt,
3764
- completedAt: Date.now(),
3765
- };
3766
- }
3767
-
3768
- const execPromise = this.client.executeWebMcpToolCall(
3769
- wireToolName,
3770
- toolMessage.toolCall?.args,
3771
- batchController.signal,
3347
+ void this.#withActions((actions, host) =>
3348
+ actions.resolveWebMcpToolCallBatch(host, executionId, snapshots)
3772
3349
  );
3773
-
3774
- let output: unknown;
3775
- if (!execPromise) {
3776
- output = {
3777
- isError: true,
3778
- content: [
3779
- { type: "text", text: "WebMCP not enabled on this widget." },
3780
- ],
3781
- };
3782
- } else {
3783
- try {
3784
- output = await execPromise;
3785
- } catch (error) {
3786
- const isAbortError =
3787
- error instanceof Error &&
3788
- (error.name === "AbortError" ||
3789
- error.message.includes("aborted") ||
3790
- error.message.includes("abort"));
3791
- if (!isAbortError) {
3792
- this.callbacks.onError?.(
3793
- error instanceof Error ? error : new Error(String(error)),
3794
- );
3795
- }
3796
- this.markWebMcpToolComplete(
3797
- toolMessage,
3798
- buildWebMcpErrorResult(
3799
- isAbortError
3800
- ? "Aborted by cancel()"
3801
- : getWebMcpErrorMessage(error),
3802
- ),
3803
- startedAt,
3804
- );
3805
- // Release the dedupe claim so a re-emit can retry this call.
3806
- this.webMcpInflightKeys.delete(dedupeKey);
3807
- return null;
3808
- }
3809
- }
3810
- if (batchController.signal.aborted) {
3811
- this.markWebMcpToolComplete(
3812
- toolMessage,
3813
- buildWebMcpErrorResult("Aborted by cancel()"),
3814
- startedAt,
3815
- );
3816
- this.webMcpInflightKeys.delete(dedupeKey);
3817
- return null;
3818
- }
3819
- return {
3820
- dedupeKey,
3821
- resumeKey,
3822
- output,
3823
- toolMessage,
3824
- startedAt,
3825
- completedAt: Date.now(),
3826
- };
3827
- };
3828
-
3829
- // Parallel (default): a gated sibling's approval never blocks another's
3830
- // execution. Sequential: one at a time in emission order, so tools that
3831
- // share page state (a canvas, a form) don't interleave; the next call
3832
- // starts only once the previous one has settled, approval included.
3833
- let executed: Array<ExecutedWebMcpTool | null>;
3834
- if (this.config.webmcp?.execution === "sequential") {
3835
- executed = [];
3836
- for (const toolMessage of snapshots) {
3837
- executed.push(await executeOne(toolMessage));
3838
- }
3839
- } else {
3840
- executed = await Promise.all(snapshots.map(executeOne));
3841
- }
3842
-
3843
- let ready: ExecutedWebMcpTool[] = [];
3844
- try {
3845
- ready = executed.filter((r): r is ExecutedWebMcpTool => r !== null);
3846
- // Everything deduped/aborted/failed: nothing to post.
3847
- if (ready.length === 0) return;
3848
-
3849
- const toolOutputs: Record<string, unknown> = {};
3850
- for (const r of ready) {
3851
- // Two omitted-on-collision safety: if two calls somehow resolve to the
3852
- // same key (only possible on a legacy name fallback), last write wins: // the server re-pauses the unrepresented call for retry.
3853
- toolOutputs[r.resumeKey] = r.output;
3854
- }
3855
-
3856
- const response = await this.client.resumeFlow(executionId, toolOutputs, {
3857
- signal: batchController.signal,
3858
- after:
3859
- this.resumable?.executionId === executionId
3860
- ? this.resumable.lastEventId
3861
- : undefined,
3862
- });
3863
- if (!response.ok) {
3864
- const errorData = await response.json().catch(() => null);
3865
- throw new Error(errorData?.error ?? `Resume failed: ${response.status}`);
3866
- }
3867
- // Server accepted the batch: mark every included call resolved so stale
3868
- // re-emits don't re-execute the page tool, then complete each bubble.
3869
- // Do this only after /resume HTTP success; if /resume fails, the server
3870
- // may still be paused and the retry path must not show a final result.
3871
- const batch = ready[0]!.dedupeKey;
3872
- for (const r of ready) {
3873
- this.webMcpResolvedKeys.add(r.dedupeKey);
3874
- const toolName = r.toolMessage.toolCall?.name;
3875
- const toolCallId = r.toolMessage.agentMetadata?.webMcpToolCallId;
3876
- this.markWebMcpToolComplete(
3877
- r.toolMessage,
3878
- r.output,
3879
- r.startedAt,
3880
- r.completedAt,
3881
- {
3882
- ...(toolName === SUGGEST_REPLIES_TOOL_NAME
3883
- ? { suggestRepliesResolved: true }
3884
- : {}),
3885
- // Only a provider call id can be replayed: a legacy name-keyed
3886
- // resume has nothing the model's transcript can reference.
3887
- ...(toolName && toolCallId
3888
- ? {
3889
- clientToolAnswer: {
3890
- toolCallId,
3891
- toolName,
3892
- args: r.toolMessage.toolCall?.args,
3893
- result: r.output,
3894
- batch,
3895
- },
3896
- }
3897
- : {}),
3898
- },
3899
- );
3900
- }
3901
- if (response.body) {
3902
- await this.connectStream(response.body, { allowReentry: true });
3903
- }
3904
- } catch (error) {
3905
- const isAbortError =
3906
- error instanceof Error &&
3907
- (error.name === "AbortError" ||
3908
- error.message.includes("aborted") ||
3909
- error.message.includes("abort"));
3910
- if (!isAbortError) {
3911
- this.callbacks.onError?.(
3912
- error instanceof Error ? error : new Error(String(error)),
3913
- );
3914
- } else {
3915
- for (const r of ready) {
3916
- this.markWebMcpToolComplete(
3917
- r.toolMessage,
3918
- buildWebMcpErrorResult("Aborted by cancel()"),
3919
- r.startedAt,
3920
- );
3921
- }
3922
- }
3923
- } finally {
3924
- for (const key of claimedKeys) {
3925
- this.webMcpInflightKeys.delete(key);
3926
- }
3927
- this.webMcpResolveControllers.delete(batchController);
3928
- if (this.webMcpResolveControllers.size === 0 && !this.abortController) {
3929
- this.setStreaming(false);
3930
- }
3931
- }
3932
- }
3933
-
3934
- /**
3935
- * Resolve a paused auto-resolving LOCAL tool call and post the result to
3936
- * `/resume`: `webmcp:*` calls execute against the host page's tool
3937
- * registry; the built-in `suggest_replies` skips execution entirely and
3938
- * resumes with a canned "shown" result (the chips render from the message
3939
- * list, not from this resolve).
3940
- *
3941
- * Triggered automatically from `handleEvent` when an `await`-derived
3942
- * message arrives for such a tool: the user does not click a pill; the
3943
- * bridge's confirm-bubble gate (WebMCP only) is the only interactive
3944
- * surface.
3945
- *
3946
- * Idempotent on the message's `toolCall.id`: re-emits of the same await
3947
- * (e.g. from message coalescing) won't double-fire `tool.execute`. Failure
3948
- * modes, declined, timed out, throw, unknown tool, all resolve into a
3949
- * `{ isError: true, content: [...] }` payload that resumes the dispatch
3950
- * cleanly so the agent can recover.
3951
- *
3952
- * After the malformed-wire guards this is a thin gate over
3953
- * `resolveWebMcpToolCallBatch` with a size-1 batch: the dedupe check runs
3954
- * synchronously here so a stale re-emit never toggles streaming.
3955
- */
3956
- public async resolveWebMcpToolCall(
3957
- toolMessage: AgentWidgetMessage,
3958
- ): Promise<void> {
3959
- const executionId = toolMessage.agentMetadata?.executionId;
3960
- const wireToolName = toolMessage.toolCall?.name;
3961
- const toolCallId = toolMessage.toolCall?.id;
3962
-
3963
- // Malformed await wire shapes shouldn't silently strand the
3964
- // server-side dispatch. Three failure modes:
3965
- // - no executionId: no /resume target exists; surface to the host
3966
- // via onError so an operator can react. This is a server-side
3967
- // wire-shape bug: Persona can't recover it from the client.
3968
- // - no wireToolName: defensive guard: handleEvent only calls us
3969
- // for an auto-resolving local tool name (`webmcp:*` or
3970
- // `suggest_replies`), so this path indicates a direct caller
3971
- // misuse. Silent return.
3972
- // - no toolCallId: dedupe key falls apart, but the server can still
3973
- // advance if we post an isError for the wireToolName. Do that
3974
- // and bail before the dedupe path.
3975
- if (!executionId) {
3976
- this.callbacks.onError?.(
3977
- new Error(
3978
- "WebMCP await missing executionId: dispatch left paused.",
3979
- ),
3980
- );
3981
- return;
3982
- }
3983
- if (!wireToolName) return;
3984
- if (!toolCallId) {
3985
- // No toolCall.id → no per-call dedupe key. Fall back to a synthetic
3986
- // `(executionId):(wireToolName)` so identical malformed re-emits don't
3987
- // re-POST /resume. Idempotent on duplicate bad payloads.
3988
- const malformedKey = `${executionId}:__no_tool_id__:${wireToolName}`;
3989
- if (
3990
- this.webMcpInflightKeys.has(malformedKey) ||
3991
- this.webMcpResolvedKeys.has(malformedKey)
3992
- ) {
3993
- return;
3994
- }
3995
- this.webMcpInflightKeys.add(malformedKey);
3996
- try {
3997
- await this.resumeWithToolOutput(executionId, wireToolName, {
3998
- isError: true,
3999
- content: [
4000
- {
4001
- type: "text",
4002
- text: "WebMCP await missing toolCall.id: cannot execute the page tool.",
4003
- },
4004
- ],
4005
- });
4006
- this.webMcpResolvedKeys.add(malformedKey);
4007
- } catch (error) {
4008
- this.callbacks.onError?.(
4009
- error instanceof Error ? error : new Error(String(error)),
4010
- );
4011
- } finally {
4012
- this.webMcpInflightKeys.delete(malformedKey);
4013
- }
4014
- return;
4015
- }
4016
-
4017
- // Dedupe key scoped by executionId: see `webMcpInflightKeys` doc comment
4018
- // for the failure-recovery + cross-dispatch rationale. The persisted
4019
- // `suggestRepliesResolved` guard backs the in-memory sets across
4020
- // hydrations. Checked synchronously HERE so a stale re-emit stays a pure
4021
- // no-op (no streaming toggle, no controller registration); the batch path
4022
- // re-checks under its own inflight claim.
4023
- const dedupeKey = `${executionId}:${toolCallId}`;
4024
- if (
4025
- this.webMcpInflightKeys.has(dedupeKey) ||
4026
- this.webMcpResolvedKeys.has(dedupeKey) ||
4027
- this.isSuggestRepliesAlreadyResolved(toolMessage)
4028
- ) {
4029
- return;
4030
- }
4031
- return this.resolveWebMcpToolCallBatch(executionId, [toolMessage]);
4032
- }
4033
-
4034
- /**
4035
- * POST `/resume` with a SINGLE tool's output and pipe the resulting SSE
4036
- * stream back through `connectStream`. Shared by every single-call local-tool
4037
- * resolve path (ask_user_question and single WebMCP calls). Parallel WebMCP
4038
- * calls use `resolveWebMcpToolCallBatch`, which posts one resume for many.
4039
- *
4040
- * `resumeKey` is the `toolOutputs` map key: the per-call `webMcpToolCallId`
4041
- * for WebMCP (core#3878), or the tool name for ask_user_question / legacy
4042
- * servers. `onHttpOk` runs synchronously between the HTTP-status check and the
4043
- * stream pipe; it lets the WebMCP resolve path commit the dedupe flag at
4044
- * "server accepted the answer" rather than "stream finished cleanly".
4045
- */
4046
- private async resumeWithToolOutput(
4047
- executionId: string,
4048
- resumeKey: string,
4049
- output: unknown,
4050
- options?: { onHttpOk?: () => void; signal?: AbortSignal },
4051
- ): Promise<void> {
4052
- const response = await this.client.resumeFlow(
4053
- executionId,
4054
- { [resumeKey]: output },
4055
- {
4056
- signal: options?.signal,
4057
- after:
4058
- this.resumable?.executionId === executionId
4059
- ? this.resumable.lastEventId
4060
- : undefined,
4061
- },
4062
- );
4063
- if (!response.ok) {
4064
- const errorData = await response.json().catch(() => null);
4065
- throw new Error(errorData?.error ?? `Resume failed: ${response.status}`);
4066
- }
4067
- options?.onHttpOk?.();
4068
- if (response.body) {
4069
- await this.connectStream(response.body, { allowReentry: true });
4070
- } else if (this.webMcpResolveControllers.size === 0) {
4071
- // No stream to pipe. Clear streaming only when no WebMCP resolve is in
4072
- // flight: for a WebMCP caller the current resolve's controller is still
4073
- // in the set, so its own `finally` (gated on the set draining) owns the
4074
- // teardown. Non-WebMCP callers (ask_user_question) keep the old behavior.
4075
- this.setStreaming(false);
4076
- this.abortController = null;
4077
3350
  }
4078
3351
  }
4079
3352
 
@@ -4086,7 +3359,7 @@ export class AgentWidgetSession {
4086
3359
  * controller, so it can't escape this teardown. Called from every stop /
4087
3360
  * new-turn boundary (cancel, clearMessages, hydrateMessages, sendMessage).
4088
3361
  */
4089
- private abortWebMcpResolves(): void {
3362
+ #abortWebMcpResolves(): void {
4090
3363
  for (const controller of this.webMcpResolveControllers) {
4091
3364
  controller.abort();
4092
3365
  }
@@ -4110,7 +3383,7 @@ export class AgentWidgetSession {
4110
3383
  // being torn down, and a microtask-deferred flush must not survive. The
4111
3384
  // epoch bump below also strands an already-scheduled flush.
4112
3385
  this.webMcpAwaitBatches.clear();
4113
- this.webMcpEpoch++;
3386
+ this.#webMcpEpoch++;
4114
3387
  }
4115
3388
 
4116
3389
  public cancel() {
@@ -4118,32 +3391,34 @@ export class AgentWidgetSession {
4118
3391
  this.abortController = null;
4119
3392
  // A user stop also cancels any pending/in-flight durable reconnect and
4120
3393
  // clears the resume handle (the abort above already killed its fetch).
4121
- this.teardownReconnect();
3394
+ this.#teardownReconnect();
4122
3395
  // Tear down every in-flight WebMCP resolve (each owns its own controller,
4123
3396
  // independent of the shared one above). Clear the inflight set so retries
4124
3397
  // are possible if the user re-issues the same await context.
4125
- this.abortWebMcpResolves();
3398
+ this.#abortWebMcpResolves();
3399
+ this.#actionsEpoch++;
4126
3400
  this.webMcpInflightKeys.clear();
4127
3401
  // Stop any in-progress audio too: when the user hits "stop", they want
4128
3402
  // the assistant to actually stop talking, not just stop generating tokens.
4129
3403
  // Both helpers are safe no-ops when audio isn't configured.
4130
3404
  this.stopSpeaking();
4131
3405
  this.stopVoicePlayback();
4132
- this.setStreaming(false);
4133
- this.setStatus("idle");
3406
+ this.#setStreaming(false);
3407
+ this.#setStatus("idle");
4134
3408
  }
4135
3409
 
4136
3410
  public clearMessages() {
4137
3411
  this.stopSpeaking();
4138
3412
  this.abortController?.abort();
4139
3413
  this.abortController = null;
4140
- this.teardownReconnect();
3414
+ this.#teardownReconnect();
4141
3415
  // Tear down every in-flight WebMCP resolve too: their messages are about
4142
3416
  // to be wiped, and a microtask-deferred resolve must not survive the clear.
4143
- this.abortWebMcpResolves();
3417
+ this.#abortWebMcpResolves();
3418
+ this.#actionsEpoch++;
4144
3419
  this.messages = [];
4145
3420
  this.agentExecution = null;
4146
- this.clearArtifactState();
3421
+ this.#clearArtifactState();
4147
3422
  // Clearing messages also wipes the WebMCP dedupe state: a fresh
4148
3423
  // conversation should not refuse to call a webmcp:* tool just because
4149
3424
  // a tool with the same key resolved in the prior conversation.
@@ -4153,8 +3428,8 @@ export class AgentWidgetSession {
4153
3428
  // turn: drop the diff-only fingerprint cache (server keys by recordId, so
4154
3429
  // a new conversation has no stored set to match).
4155
3430
  this.client.resetClientToolsFingerprint();
4156
- this.setStreaming(false);
4157
- this.setStatus("idle");
3431
+ this.#setStreaming(false);
3432
+ this.#setStatus("idle");
4158
3433
  this.callbacks.onMessagesChanged([...this.messages]);
4159
3434
  }
4160
3435
 
@@ -4167,16 +3442,16 @@ export class AgentWidgetSession {
4167
3442
  }
4168
3443
 
4169
3444
  public getSelectedArtifactId(): string | null {
4170
- return this.selectedArtifactId;
3445
+ return this.#selectedArtifactId;
4171
3446
  }
4172
3447
 
4173
3448
  public selectArtifact(id: string | null): void {
4174
- this.selectedArtifactId = id;
4175
- this.emitArtifactsState();
3449
+ this.#selectedArtifactId = id;
3450
+ this.#emitArtifactsState();
4176
3451
  }
4177
3452
 
4178
3453
  public clearArtifacts(): void {
4179
- this.clearArtifactState();
3454
+ this.#clearArtifactState();
4180
3455
  }
4181
3456
 
4182
3457
  public upsertArtifact(manual: PersonaArtifactManualUpsert): PersonaArtifactRecord {
@@ -4204,10 +3479,10 @@ export class AgentWidgetSession {
4204
3479
  ...(manual.presentation ? { presentation: manual.presentation } : {})
4205
3480
  };
4206
3481
  this.artifacts.set(id, rec);
4207
- this.selectedArtifactId = id;
4208
- this.emitArtifactsState();
3482
+ this.#selectedArtifactId = id;
3483
+ this.#emitArtifactsState();
4209
3484
  if (manual.transcript !== false) {
4210
- this.injectArtifactRefBlock(rec);
3485
+ this.#injectArtifactRefBlock(rec);
4211
3486
  }
4212
3487
  return rec;
4213
3488
  }
@@ -4228,7 +3503,7 @@ export class AgentWidgetSession {
4228
3503
  * these props); without this the block would keep the first version's
4229
3504
  * content after a refresh.
4230
3505
  */
4231
- private injectArtifactRefBlock(rec: PersonaArtifactRecord): void {
3506
+ #injectArtifactRefBlock(rec: PersonaArtifactRecord): void {
4232
3507
  const refId = `artifact-ref-${rec.id}`;
4233
3508
  const displayMode = resolveArtifactDisplayMode(
4234
3509
  this.config.features?.artifacts,
@@ -4266,7 +3541,7 @@ export class AgentWidgetSession {
4266
3541
  * Re-materialize existing transcript artifact blocks after a live display
4267
3542
  * preference update. One messages callback keeps conversion atomic.
4268
3543
  */
4269
- private refreshArtifactReferenceBlocks(): void {
3544
+ #refreshArtifactReferenceBlocks(): void {
4270
3545
  let changed = false;
4271
3546
  for (const rec of this.artifacts.values()) {
4272
3547
  const message = this.messages.find((candidate) => candidate.id === `artifact-ref-${rec.id}`);
@@ -4294,21 +3569,21 @@ export class AgentWidgetSession {
4294
3569
  if (changed) this.callbacks.onMessagesChanged([...this.messages]);
4295
3570
  }
4296
3571
 
4297
- private clearArtifactState(): void {
4298
- if (this.artifacts.size === 0 && this.selectedArtifactId === null) return;
3572
+ #clearArtifactState(): void {
3573
+ if (this.artifacts.size === 0 && this.#selectedArtifactId === null) return;
4299
3574
  this.artifacts.clear();
4300
- this.selectedArtifactId = null;
4301
- this.emitArtifactsState();
3575
+ this.#selectedArtifactId = null;
3576
+ this.#emitArtifactsState();
4302
3577
  }
4303
3578
 
4304
- private emitArtifactsState(): void {
3579
+ #emitArtifactsState(): void {
4305
3580
  this.callbacks.onArtifactsState?.({
4306
3581
  artifacts: [...this.artifacts.values()],
4307
- selectedId: this.selectedArtifactId
3582
+ selectedId: this.#selectedArtifactId
4308
3583
  });
4309
3584
  }
4310
3585
 
4311
- private applyArtifactStreamEvent(ev: AgentWidgetEvent): void {
3586
+ #applyArtifactStreamEvent(ev: AgentWidgetEvent): void {
4312
3587
  switch (ev.type) {
4313
3588
  case "artifact_start": {
4314
3589
  if (ev.artifactType === "markdown") {
@@ -4330,7 +3605,7 @@ export class AgentWidgetSession {
4330
3605
  props: {}
4331
3606
  });
4332
3607
  }
4333
- this.selectedArtifactId = ev.id;
3608
+ this.#selectedArtifactId = ev.id;
4334
3609
  break;
4335
3610
  }
4336
3611
  case "artifact_delta": {
@@ -4356,7 +3631,7 @@ export class AgentWidgetSession {
4356
3631
  default:
4357
3632
  return;
4358
3633
  }
4359
- this.emitArtifactsState();
3634
+ this.#emitArtifactsState();
4360
3635
  }
4361
3636
 
4362
3637
  public hydrateMessages(messages: AgentWidgetMessage[]) {
@@ -4364,23 +3639,24 @@ export class AgentWidgetSession {
4364
3639
  this.abortController = null;
4365
3640
  // Hydration replaces the conversation: also cancel any pending reconnect and
4366
3641
  // clear the resume handle (a boot resume re-arms via resumeFromHandle after).
4367
- this.teardownReconnect();
3642
+ this.#teardownReconnect();
4368
3643
  // Hydration replaces the conversation: abort and forget every in-flight
4369
3644
  // WebMCP resolve; their messages are about to be replaced.
4370
- this.abortWebMcpResolves();
3645
+ this.#abortWebMcpResolves();
3646
+ this.#actionsEpoch++;
4371
3647
  // Wipe the WebMCP dedupe state alongside the message restore: the
4372
3648
  // incoming snapshot is treated as a fresh conversation context.
4373
3649
  this.webMcpInflightKeys.clear();
4374
3650
  this.webMcpResolvedKeys.clear();
4375
- this.messages = this.sortMessages(
3651
+ this.messages = this.#sortMessages(
4376
3652
  messages.map((message) => ({
4377
3653
  ...message,
4378
3654
  streaming: false,
4379
- sequence: message.sequence ?? this.nextSequence()
3655
+ sequence: message.sequence ?? this.#nextSequence()
4380
3656
  }))
4381
3657
  );
4382
- this.setStreaming(false);
4383
- this.setStatus("idle");
3658
+ this.#setStreaming(false);
3659
+ this.#setStatus("idle");
4384
3660
  this.callbacks.onMessagesChanged([...this.messages]);
4385
3661
  }
4386
3662
 
@@ -4392,14 +3668,37 @@ export class AgentWidgetSession {
4392
3668
  for (const rec of artifacts) {
4393
3669
  this.artifacts.set(rec.id, { ...rec, status: "complete" });
4394
3670
  }
4395
- this.selectedArtifactId = selectedId;
4396
- this.emitArtifactsState();
3671
+ this.#selectedArtifactId = selectedId;
3672
+ this.#emitArtifactsState();
4397
3673
  }
4398
3674
 
4399
3675
  private handleEvent = (event: AgentWidgetEvent) => {
3676
+ const delegation = this.#voiceDelegation;
3677
+ if (delegation) {
3678
+ if (event.type === "error" || (event.type === "status" && event.status === "error")) {
3679
+ delegation.failed = true;
3680
+ } else if (event.type === "message" && event.message.role === "assistant") {
3681
+ // The voice model reads this answer aloud: browser TTS must skip it,
3682
+ // unless the server dropped the result (then nobody speaks it).
3683
+ if (!delegation.dropped) this.ttsSpokenMessageIds.add(event.message.id);
3684
+ if (!delegation.ids.includes(event.message.id)) delegation.ids.push(event.message.id);
3685
+ }
3686
+ }
4400
3687
  if (event.type === "message") {
4401
3688
  this.upsertMessage(event.message);
4402
3689
 
3690
+ // A resolved approval reaches this handler only from the server's
3691
+ // `approval_complete` (resolveApproval's optimistic update upserts
3692
+ // directly). The server may now finish the paused call itself on this
3693
+ // stream, so the bubble settles at stream end only if it is still open.
3694
+ if (
3695
+ event.message.variant === "approval" &&
3696
+ event.message.approval &&
3697
+ event.message.approval.status !== "pending"
3698
+ ) {
3699
+ this.#approvalSettlesAtStreamEnd.set(event.message.id, this.#activeStreamToken);
3700
+ }
3701
+
4403
3702
  // Track the open assistant text bubble's REAL streamed id so a durable
4404
3703
  // reconnect keeps filling the same message. The proxy path auto-generates
4405
3704
  // this id (it isn't the session's pre-generated one), so we must read it
@@ -4409,7 +3708,7 @@ export class AgentWidgetSession {
4409
3708
  !event.message.variant &&
4410
3709
  event.message.streaming
4411
3710
  ) {
4412
- this.activeAssistantMessageId = event.message.id;
3711
+ this.#activeAssistantMessageId = event.message.id;
4413
3712
  }
4414
3713
 
4415
3714
  // Local-tool auto-resolve: when a await emits a tool-variant
@@ -4436,6 +3735,16 @@ export class AgentWidgetSession {
4436
3735
  // the resolved `enabled` is false the widget neither renders chips nor
4437
3736
  // resumes: the same parked-execution posture as a server-declared
4438
3737
  // ask_user_question with its sheet disabled.
3738
+ // The agent paused for an approval or a local tool: warm the resolve
3739
+ // paths (the lazy `session-actions.js` chunk in the IIFE build) so the
3740
+ // resume doesn't wait on a fetch.
3741
+ if (
3742
+ event.message.variant === "approval" ||
3743
+ event.message.agentMetadata?.awaitingLocalTool === true
3744
+ ) {
3745
+ loadSessionActions().catch(() => {});
3746
+ }
3747
+
4439
3748
  const tc = event.message.toolCall;
4440
3749
  const autoResolvable =
4441
3750
  !!tc?.name &&
@@ -4450,7 +3759,7 @@ export class AgentWidgetSession {
4450
3759
  // it on the spot. Parallel same-tool calls (core#3878) arrive as
4451
3760
  // separate `await`s in the same stream; batching lets us post ONE
4452
3761
  // `/resume` keyed by per-call id (see `enqueueWebMcpAwait`).
4453
- this.enqueueWebMcpAwait(event.message);
3762
+ this.#enqueueWebMcpAwait(event.message);
4454
3763
  }
4455
3764
 
4456
3765
  // Track agent execution state from message metadata
@@ -4473,35 +3782,35 @@ export class AgentWidgetSession {
4473
3782
  // cursor and (lazily) form the resume handle once the executionId is
4474
3783
  // known. Only durable, resumable executions emit these, so this is the
4475
3784
  // natural gate: no `id:` lines → no handle → reconnect never arms.
4476
- this.trackCursor(event.id);
3785
+ this.#trackCursor(event.id);
4477
3786
  } else if (event.type === "status") {
4478
3787
  // A plain `idle` (no `terminal`) on the durable lane, while the run is
4479
3788
  // genuinely mid-turn, is a dropped connection, not a finish and not an
4480
3789
  // intentional `await` pause. Reconnect instead of finalizing.
4481
- if (event.status === "idle" && !event.terminal && this.isDurableDrop()) {
4482
- this.beginReconnect();
3790
+ if (event.status === "idle" && !event.terminal && this.#isDurableDrop()) {
3791
+ this.#beginReconnect();
4483
3792
  return;
4484
3793
  }
4485
- this.setStatus(event.status);
3794
+ this.#setStatus(event.status);
4486
3795
  if (event.status === "connecting") {
4487
- this.setStreaming(true);
3796
+ this.#setStreaming(true);
4488
3797
  } else if (event.status === "idle" || event.status === "error") {
4489
3798
  // A client-tool pause is an intentional stream end but NOT a terminal:
4490
3799
  // retain its cursor so `/client/resume` can attach after the await
4491
3800
  // frame instead of replaying the whole turn. Real terminals and
4492
3801
  // ordinary non-durable idles still clear the handle.
4493
- if (!this.isAwaitPending()) this.clearResumable();
3802
+ if (!this.#isAwaitPending()) this.#clearResumable();
4494
3803
  // Keep the typing indicator up while a WebMCP resolve is still in
4495
3804
  // flight: in a chained turn the intermediate resume stream ends with an
4496
3805
  // idle status, but the successor tool is still executing. The resolve's
4497
3806
  // own `finally` flips streaming off once the resolve set drains.
4498
3807
  if (this.webMcpResolveControllers.size === 0) {
4499
- this.setStreaming(false);
3808
+ this.#setStreaming(false);
4500
3809
  this.abortController = null;
4501
3810
  // The turn mutated the record and the chat response carries no
4502
3811
  // revision; finalize any browser-only projection right after.
4503
- this.invalidateConversationRevision();
4504
- this.scheduleProjectionFinalization();
3812
+ this.#invalidateConversationRevision();
3813
+ this.#scheduleProjectionFinalization();
4505
3814
  }
4506
3815
  // Mark agent execution as complete when streaming ends: UNLESS local
4507
3816
  // tools are still outstanding. A batched WebMCP resume is deferred to
@@ -4525,19 +3834,19 @@ export class AgentWidgetSession {
4525
3834
  // batched `/resume` per executionId (deferred: see
4526
3835
  // scheduleWebMcpBatchFlush). Runs AFTER the teardown above so a resolve
4527
3836
  // doesn't fight the end-of-stream streaming/abortController reset.
4528
- this.scheduleWebMcpBatchFlush();
3837
+ this.#scheduleWebMcpBatchFlush();
4529
3838
  }
4530
3839
  } else if (event.type === "error") {
4531
- this.setStatus("error");
3840
+ this.#setStatus("error");
4532
3841
  // Terminal error: drop the resume handle so no reconnect arms.
4533
- this.clearResumable();
3842
+ this.#clearResumable();
4534
3843
  // Mirror the idle/status handler: don't tear down streaming while a
4535
3844
  // WebMCP resolve is still confirming/executing on another stream: an
4536
3845
  // error on one chained resume stream must not hide the typing indicator
4537
3846
  // (or null a controller) for a sibling/successor resolve still in flight.
4538
3847
  // The resolve's own `finally` flips streaming off once the set drains.
4539
3848
  if (this.webMcpResolveControllers.size === 0) {
4540
- this.setStreaming(false);
3849
+ this.#setStreaming(false);
4541
3850
  this.abortController = null;
4542
3851
  }
4543
3852
  if (this.agentExecution?.status === 'running') {
@@ -4550,7 +3859,7 @@ export class AgentWidgetSession {
4550
3859
  event.type === "artifact_update" ||
4551
3860
  event.type === "artifact_complete"
4552
3861
  ) {
4553
- this.applyArtifactStreamEvent(event);
3862
+ this.#applyArtifactStreamEvent(event);
4554
3863
  }
4555
3864
  };
4556
3865
 
@@ -4562,24 +3871,24 @@ export class AgentWidgetSession {
4562
3871
  * frame's metadata, handled before this trailing cursor event) and the active
4563
3872
  * assistant message id.
4564
3873
  */
4565
- private trackCursor(id: string): void {
3874
+ #trackCursor(id: string): void {
4566
3875
  const executionId = this.agentExecution?.executionId;
4567
- if (!executionId || !this.activeAssistantMessageId) return;
3876
+ if (!executionId || !this.#activeAssistantMessageId) return;
4568
3877
  // Only track while the run is live. A graceful terminal sets the execution
4569
3878
  // to 'complete'/'error' and clears the handle BEFORE its own frame's
4570
3879
  // trailing cursor fires; without this guard that cursor would re-arm the
4571
3880
  // handle and the next plain `idle` would look like a spurious drop.
4572
3881
  if (this.agentExecution?.status !== "running") return;
4573
- const isNew = this.resumable === null;
4574
- this.resumable = {
3882
+ const isNew = this.#resumable === null;
3883
+ this.#resumable = {
4575
3884
  executionId,
4576
3885
  lastEventId: id,
4577
- assistantMessageId: this.activeAssistantMessageId,
3886
+ assistantMessageId: this.#activeAssistantMessageId,
4578
3887
  status: "running",
4579
3888
  };
4580
3889
  // Fire immediately when the handle first appears (so the host can persist
4581
3890
  // the executionId promptly); throttle subsequent cursor advances.
4582
- this.notifyExecutionState(isNew);
3891
+ this.#notifyExecutionState(isNew);
4583
3892
  }
4584
3893
 
4585
3894
  /**
@@ -4587,14 +3896,14 @@ export class AgentWidgetSession {
4587
3896
  * rather than a graceful finish or an intentional `await` pause? ALL must
4588
3897
  * hold (see plan §Design overview keystone 3).
4589
3898
  */
4590
- private isDurableDrop(): boolean {
3899
+ #isDurableDrop(): boolean {
4591
3900
  return (
4592
- this.resumable !== null &&
3901
+ this.#resumable !== null &&
4593
3902
  typeof this.config.reconnectStream === "function" &&
4594
3903
  this.abortController?.signal.aborted !== true &&
4595
3904
  this.webMcpResolveControllers.size === 0 &&
4596
3905
  this.webMcpAwaitBatches.size === 0 &&
4597
- !this.isAwaitPending()
3906
+ !this.#isAwaitPending()
4598
3907
  );
4599
3908
  }
4600
3909
 
@@ -4603,7 +3912,7 @@ export class AgentWidgetSession {
4603
3912
  * `ask_user_question` / local-tool await, or a pending approval). Those end
4604
3913
  * the stream with `idle` and no terminal too, but are NOT drops.
4605
3914
  */
4606
- private isAwaitPending(): boolean {
3915
+ #isAwaitPending(): boolean {
4607
3916
  return this.messages.some((m) => {
4608
3917
  if (
4609
3918
  m.agentMetadata?.awaitingLocalTool === true &&
@@ -4623,22 +3932,22 @@ export class AgentWidgetSession {
4623
3932
  * true, flips to `resuming`, and kicks the bounded backoff loop. Re-entrant
4624
3933
  * calls (a second drop while reconnecting) are no-ops.
4625
3934
  */
4626
- private beginReconnect(): void {
4627
- if (this.reconnecting) return;
4628
- if (!this.resumable || typeof this.config.reconnectStream !== "function") {
3935
+ #beginReconnect(): void {
3936
+ if (this.#reconnecting) return;
3937
+ if (!this.#resumable || typeof this.config.reconnectStream !== "function") {
4629
3938
  return;
4630
3939
  }
4631
3940
  // Flip the visible state synchronously — the bubble stays open and a
4632
3941
  // `resuming` status is observable immediately (e.g. right after
4633
3942
  // `resumeFromHandle`). The heavy backoff loop is loaded lazily below.
4634
- this.reconnecting = true;
4635
- this.callbacks.onReconnect?.({ phase: "paused", handle: this.resumable });
4636
- this.setStreaming(true);
4637
- this.setStatus("resuming");
4638
- void this.loadReconnectController().then((controller) => {
3943
+ this.#reconnecting = true;
3944
+ this.callbacks.onReconnect?.({ phase: "paused", handle: this.#resumable });
3945
+ this.#setStreaming(true);
3946
+ this.#setStatus("resuming");
3947
+ void this.#loadReconnectController().then((controller) => {
4639
3948
  // The run may have been torn down (new turn / cancel / hydrate) while the
4640
3949
  // module was loading; only start the loop if we're still reconnecting.
4641
- if (this.reconnecting && this.resumable) controller.begin();
3950
+ if (this.#reconnecting && this.#resumable) controller.begin();
4642
3951
  });
4643
3952
  }
4644
3953
 
@@ -4648,43 +3957,43 @@ export class AgentWidgetSession {
4648
3957
  * every hot path (`sendMessage` / `cancel` / `teardownReconnect`) so a widget
4649
3958
  * that never reconnects never pulls the chunk.
4650
3959
  */
4651
- private loadReconnectController(): Promise<ReconnectController> {
4652
- if (this.reconnectController) {
4653
- return Promise.resolve(this.reconnectController);
3960
+ #loadReconnectController(): Promise<ReconnectController> {
3961
+ if (this.#reconnectController) {
3962
+ return Promise.resolve(this.#reconnectController);
4654
3963
  }
4655
- if (!this.reconnectControllerPromise) {
4656
- this.reconnectControllerPromise = loadSessionReconnect().then(
3964
+ if (!this.#reconnectControllerPromise) {
3965
+ this.#reconnectControllerPromise = loadSessionReconnect().then(
4657
3966
  ({ createReconnectController }) => {
4658
3967
  const controller = createReconnectController(
4659
- this.buildReconnectHost()
3968
+ this.#buildReconnectHost()
4660
3969
  );
4661
- this.reconnectController = controller;
3970
+ this.#reconnectController = controller;
4662
3971
  return controller;
4663
3972
  }
4664
3973
  );
4665
3974
  // A failed chunk fetch must not poison the memoized promise: clear it so
4666
3975
  // the next reconnect trigger retries the load.
4667
- this.reconnectControllerPromise.catch(() => {
4668
- this.reconnectControllerPromise = null;
3976
+ this.#reconnectControllerPromise.catch(() => {
3977
+ this.#reconnectControllerPromise = null;
4669
3978
  });
4670
3979
  }
4671
- return this.reconnectControllerPromise;
3980
+ return this.#reconnectControllerPromise;
4672
3981
  }
4673
3982
 
4674
3983
  /** The narrow surface the lazily-loaded reconnect loop drives. */
4675
- private buildReconnectHost(): ReconnectHost {
3984
+ #buildReconnectHost(): ReconnectHost {
4676
3985
  const session = this;
4677
3986
  return {
4678
3987
  get config() {
4679
3988
  return session.config;
4680
3989
  },
4681
- getResumable: () => session.resumable,
4682
- clearResumable: () => session.clearResumable(),
3990
+ getResumable: () => session.#resumable,
3991
+ clearResumable: () => session.#clearResumable(),
4683
3992
  getStatus: () => session.status,
4684
- setStatus: (status) => session.setStatus(status),
4685
- setStreaming: (streaming) => session.setStreaming(streaming),
3993
+ setStatus: (status) => session.#setStatus(status),
3994
+ setStreaming: (streaming) => session.#setStreaming(streaming),
4686
3995
  setReconnecting: (value) => {
4687
- session.reconnecting = value;
3996
+ session.#reconnecting = value;
4688
3997
  },
4689
3998
  setAbortController: (controller) => {
4690
3999
  session.abortController = controller;
@@ -4699,8 +4008,8 @@ export class AgentWidgetSession {
4699
4008
  preserveAssistantId: true,
4700
4009
  seedContent,
4701
4010
  }),
4702
- appendMessage: (message) => session.appendMessage(message),
4703
- nextSequence: () => session.nextSequence(),
4011
+ appendMessage: (message) => session.#appendMessage(message),
4012
+ nextSequence: () => session.#nextSequence(),
4704
4013
  emitReconnect: (event) => session.callbacks.onReconnect?.(event),
4705
4014
  buildErrorContent: (message) =>
4706
4015
  buildDispatchErrorContent(
@@ -4713,13 +4022,13 @@ export class AgentWidgetSession {
4713
4022
 
4714
4023
  /** Public manual retry (e.g. a "Reconnect" button). */
4715
4024
  public reconnectNow(): void {
4716
- if (this.reconnecting) {
4025
+ if (this.#reconnecting) {
4717
4026
  // Already trying, so just short-circuit the current backoff. (No-op if the
4718
4027
  // controller is still loading; the first attempt hasn't slept yet.)
4719
- this.reconnectController?.wake();
4028
+ this.#reconnectController?.wake();
4720
4029
  return;
4721
4030
  }
4722
- this.beginReconnect();
4031
+ this.#beginReconnect();
4723
4032
  }
4724
4033
 
4725
4034
  /**
@@ -4729,21 +4038,21 @@ export class AgentWidgetSession {
4729
4038
  */
4730
4039
  public resumeFromHandle(resume: { executionId: string; after: string }): void {
4731
4040
  if (typeof this.config.reconnectStream !== "function") return;
4732
- if (this.reconnecting) return;
4041
+ if (this.#reconnecting) return;
4733
4042
 
4734
- let assistantId = this.reopenTrailingAssistant();
4043
+ let assistantId = this.#reopenTrailingAssistant();
4735
4044
  if (!assistantId) {
4736
4045
  assistantId = generateAssistantMessageId();
4737
- this.appendMessage({
4046
+ this.#appendMessage({
4738
4047
  id: assistantId,
4739
4048
  role: "assistant",
4740
4049
  content: "",
4741
4050
  createdAt: new Date().toISOString(),
4742
4051
  streaming: true,
4743
- sequence: this.nextSequence(),
4052
+ sequence: this.#nextSequence(),
4744
4053
  });
4745
4054
  }
4746
- this.activeAssistantMessageId = assistantId;
4055
+ this.#activeAssistantMessageId = assistantId;
4747
4056
  if (!this.agentExecution) {
4748
4057
  this.agentExecution = {
4749
4058
  executionId: resume.executionId,
@@ -4754,13 +4063,13 @@ export class AgentWidgetSession {
4754
4063
  maxTurns: 0,
4755
4064
  };
4756
4065
  }
4757
- this.resumable = {
4066
+ this.#resumable = {
4758
4067
  executionId: resume.executionId,
4759
4068
  lastEventId: resume.after,
4760
4069
  assistantMessageId: assistantId,
4761
4070
  status: "running",
4762
4071
  };
4763
- this.beginReconnect();
4072
+ this.#beginReconnect();
4764
4073
  }
4765
4074
 
4766
4075
  /**
@@ -4768,7 +4077,7 @@ export class AgentWidgetSession {
4768
4077
  * deltas append to it. Returns its id, or null if the last turn doesn't end
4769
4078
  * in a plain assistant message.
4770
4079
  */
4771
- private reopenTrailingAssistant(): string | null {
4080
+ #reopenTrailingAssistant(): string | null {
4772
4081
  for (let i = this.messages.length - 1; i >= 0; i--) {
4773
4082
  const m = this.messages[i];
4774
4083
  if (m.role === "assistant" && !m.variant) {
@@ -4786,20 +4095,20 @@ export class AgentWidgetSession {
4786
4095
  * on every new turn / cancel / hydrate, so it must NOT pull the reconnect
4787
4096
  * chunk: it only delegates to the controller if one was already created.
4788
4097
  */
4789
- private teardownReconnect(): void {
4790
- this.reconnecting = false;
4791
- this.reconnectController?.teardown();
4792
- this.clearResumable();
4098
+ #teardownReconnect(): void {
4099
+ this.#reconnecting = false;
4100
+ this.#reconnectController?.teardown();
4101
+ this.#clearResumable();
4793
4102
  }
4794
4103
 
4795
4104
  /** Drop the resume handle and notify the host (`onExecutionState(null)`). */
4796
- private clearResumable(): void {
4797
- if (this.executionStateTimer) {
4798
- clearTimeout(this.executionStateTimer);
4799
- this.executionStateTimer = null;
4105
+ #clearResumable(): void {
4106
+ if (this.#executionStateTimer) {
4107
+ clearTimeout(this.#executionStateTimer);
4108
+ this.#executionStateTimer = null;
4800
4109
  }
4801
- const had = this.resumable !== null;
4802
- this.resumable = null;
4110
+ const had = this.#resumable !== null;
4111
+ this.#resumable = null;
4803
4112
  if (had) this.config.onExecutionState?.(null);
4804
4113
  }
4805
4114
 
@@ -4808,36 +4117,36 @@ export class AgentWidgetSession {
4808
4117
  * create/clear; trailing-edge throttled on cursor advances so it isn't called
4809
4118
  * per delta.
4810
4119
  */
4811
- private notifyExecutionState(immediate: boolean): void {
4120
+ #notifyExecutionState(immediate: boolean): void {
4812
4121
  const cb = this.config.onExecutionState;
4813
4122
  if (!cb) return;
4814
4123
  if (immediate) {
4815
- if (this.executionStateTimer) {
4816
- clearTimeout(this.executionStateTimer);
4817
- this.executionStateTimer = null;
4124
+ if (this.#executionStateTimer) {
4125
+ clearTimeout(this.#executionStateTimer);
4126
+ this.#executionStateTimer = null;
4818
4127
  }
4819
- cb(this.resumable);
4128
+ cb(this.#resumable);
4820
4129
  return;
4821
4130
  }
4822
- if (this.executionStateTimer) return; // a trailing call is already queued
4823
- this.executionStateTimer = setTimeout(() => {
4824
- this.executionStateTimer = null;
4825
- this.config.onExecutionState?.(this.resumable);
4131
+ if (this.#executionStateTimer) return; // a trailing call is already queued
4132
+ this.#executionStateTimer = setTimeout(() => {
4133
+ this.#executionStateTimer = null;
4134
+ this.config.onExecutionState?.(this.#resumable);
4826
4135
  }, 500);
4827
4136
  }
4828
4137
 
4829
4138
  /** The current durable resume handle, if any (read-only). */
4830
4139
  public getResumableHandle(): ResumableHandle | null {
4831
- return this.resumable;
4140
+ return this.#resumable;
4832
4141
  }
4833
4142
 
4834
- private setStatus(status: AgentWidgetSessionStatus) {
4143
+ #setStatus(status: AgentWidgetSessionStatus) {
4835
4144
  if (this.status === status) return;
4836
4145
  this.status = status;
4837
4146
  this.callbacks.onStatusChanged(status);
4838
4147
  }
4839
4148
 
4840
- private setStreaming(streaming: boolean) {
4149
+ #setStreaming(streaming: boolean) {
4841
4150
  if (this.streaming === streaming) return;
4842
4151
  const wasStreaming = this.streaming;
4843
4152
  this.streaming = streaming;
@@ -4845,6 +4154,7 @@ export class AgentWidgetSession {
4845
4154
 
4846
4155
  // Speak the latest assistant message when streaming completes
4847
4156
  if (wasStreaming && !streaming) {
4157
+ this.#flushApprovalSettles(undefined);
4848
4158
  this.speakLatestAssistantMessage();
4849
4159
  }
4850
4160
  }
@@ -4853,7 +4163,8 @@ export class AgentWidgetSession {
4853
4163
  * Speak the latest assistant message using the Web Speech API
4854
4164
  * if text-to-speech is enabled in the config.
4855
4165
  */
4856
- private speakLatestAssistantMessage() {
4166
+ /** `only`: speak it only if the latest assistant message is one of these. */
4167
+ private speakLatestAssistantMessage(only?: string[]) {
4857
4168
  const ttsConfig = this.config.textToSpeech;
4858
4169
  if (!ttsConfig?.enabled) return;
4859
4170
 
@@ -4871,7 +4182,7 @@ export class AgentWidgetSession {
4871
4182
  .reverse()
4872
4183
  .find(m => m.role === 'assistant' && m.content && !m.voiceProcessing);
4873
4184
 
4874
- if (!lastAssistant) return;
4185
+ if (!lastAssistant || (only && !only.includes(lastAssistant.id))) return;
4875
4186
 
4876
4187
  // Skip if already spoken by Runtype provider's audio playback
4877
4188
  if (this.ttsSpokenMessageIds.has(lastAssistant.id)) {
@@ -4943,17 +4254,62 @@ export class AgentWidgetSession {
4943
4254
  }
4944
4255
  }
4945
4256
 
4946
- private appendMessage(message: AgentWidgetMessage) {
4947
- const withSequence = this.ensureSequence(message);
4948
- this.messages = this.sortMessages([...this.messages, withSequence]);
4257
+ /**
4258
+ * Settle the approval-paused bubbles one stream decided but did not
4259
+ * complete. `undefined` settles those no live `connectStream` owns.
4260
+ */
4261
+ #flushApprovalSettles(streamToken: object | undefined) {
4262
+ for (const [id, token] of [...this.#approvalSettlesAtStreamEnd]) {
4263
+ if (token !== streamToken) continue;
4264
+ this.#approvalSettlesAtStreamEnd.delete(id);
4265
+ this.#settleApprovalPausedToolCall(id);
4266
+ }
4267
+ }
4268
+
4269
+ /** Hand a dropped stream's pending settles to the end of all streaming. */
4270
+ #releaseApprovalSettles(streamToken: object) {
4271
+ for (const [id, token] of this.#approvalSettlesAtStreamEnd) {
4272
+ if (token === streamToken) this.#approvalSettlesAtStreamEnd.set(id, undefined);
4273
+ }
4274
+ }
4275
+
4276
+ /**
4277
+ * Settle the tool bubble an approval paused when no terminal tool frame
4278
+ * arrived for it. Current servers run the paused call itself under its
4279
+ * original id; older ones re-issue it under a new id. Denied and timed-out
4280
+ * calls settle as failed; an approved call settles as superseded. A call
4281
+ * already complete is left alone.
4282
+ */
4283
+
4284
+ #settleApprovalPausedToolCall(approvalMessageId: string) {
4285
+ const approval = this.messages.find((m) => m.id === approvalMessageId)?.approval;
4286
+ const id = approval?.toolCallId;
4287
+ const toolMessage = id && this.messages.find((m) => m.toolCall?.id === id);
4288
+ const tool = toolMessage && toolMessage.toolCall;
4289
+ if (!tool || tool.status === "complete" || approval.status === "pending") return;
4290
+ const settled: AgentWidgetToolCall = {
4291
+ ...tool,
4292
+ status: "complete",
4293
+ approvalStatus: approval.status,
4294
+ completedAt: Date.now(),
4295
+ ...(approval.status === "approved"
4296
+ ? { superseded: true }
4297
+ : { success: false, error: approval.status === "denied" ? "Denied" : "Approval timed out" }),
4298
+ };
4299
+ this.upsertMessage({ ...toolMessage, streaming: false, toolCall: settled });
4300
+ }
4301
+
4302
+ #appendMessage(message: AgentWidgetMessage) {
4303
+ const withSequence = this.#ensureSequence(message);
4304
+ this.messages = this.#sortMessages([...this.messages, withSequence]);
4949
4305
  this.callbacks.onMessagesChanged([...this.messages]);
4950
4306
  }
4951
4307
 
4952
4308
  private upsertMessage(message: AgentWidgetMessage) {
4953
- const withSequence = this.ensureSequence(message);
4309
+ const withSequence = this.#ensureSequence(message);
4954
4310
  const index = this.messages.findIndex((m) => m.id === withSequence.id);
4955
4311
  if (index === -1) {
4956
- this.appendMessage(withSequence);
4312
+ this.#appendMessage(withSequence);
4957
4313
  return;
4958
4314
  }
4959
4315
 
@@ -5034,6 +4390,7 @@ export class AgentWidgetSession {
5034
4390
  toolType: incoming.toolType ?? prior.toolType,
5035
4391
  reason: incoming.reason ?? prior.reason,
5036
4392
  parameters: incoming.parameters ?? prior.parameters,
4393
+ toolCallId: incoming.toolCallId ?? prior.toolCallId,
5037
4394
  };
5038
4395
  }
5039
4396
  // Auto-resolved local-tool equivalent (`webmcp:*` and the built-in
@@ -5080,25 +4437,25 @@ export class AgentWidgetSession {
5080
4437
  }
5081
4438
  return merged;
5082
4439
  });
5083
- this.messages = this.sortMessages(this.messages);
4440
+ this.messages = this.#sortMessages(this.messages);
5084
4441
  this.callbacks.onMessagesChanged([...this.messages]);
5085
4442
  }
5086
4443
 
5087
- private ensureSequence(message: AgentWidgetMessage): AgentWidgetMessage {
4444
+ #ensureSequence(message: AgentWidgetMessage): AgentWidgetMessage {
5088
4445
  if (message.sequence !== undefined) {
5089
4446
  return { ...message };
5090
4447
  }
5091
4448
  return {
5092
4449
  ...message,
5093
- sequence: this.nextSequence()
4450
+ sequence: this.#nextSequence()
5094
4451
  };
5095
4452
  }
5096
4453
 
5097
- private nextSequence() {
5098
- return this.sequenceCounter++;
4454
+ #nextSequence() {
4455
+ return this.#sequenceCounter++;
5099
4456
  }
5100
4457
 
5101
- private sortMessages(messages: AgentWidgetMessage[]) {
4458
+ #sortMessages(messages: AgentWidgetMessage[]) {
5102
4459
  return [...messages].sort((a, b) => {
5103
4460
  // Sort by createdAt timestamp first (chronological order)
5104
4461
  const timeA = new Date(a.createdAt).getTime();