@runtypelabs/persona 4.25.0 → 4.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +180 -8
  12. package/dist/approval-ui.d.ts +180 -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 +180 -8
  17. package/dist/artifacts-ui.d.ts +180 -8
  18. package/dist/artifacts-ui.js +5 -5
  19. package/dist/chunk-2ANTRKTV.js +1 -0
  20. package/dist/chunk-4HPTPUC6.js +1 -0
  21. package/dist/chunk-6V3MNEKB.js +1 -0
  22. package/dist/chunk-HGUCGM3K.js +9 -0
  23. package/dist/chunk-IO5VVUKP.js +3 -0
  24. package/dist/chunk-J4J3Y2P2.js +1 -0
  25. package/dist/chunk-K6HKIP5K.js +1 -0
  26. package/dist/chunk-K7NA7G7J.js +1 -0
  27. package/dist/chunk-MXUTS3LB.js +1 -0
  28. package/dist/chunk-PDPVORUF.js +1 -0
  29. package/dist/chunk-QPPJUSZH.js +1 -0
  30. package/dist/chunk-QRKGQPU7.js +1 -0
  31. package/dist/chunk-S6PBWE75.js +1 -0
  32. package/dist/chunk-T7QM7YMF.js +1 -0
  33. package/dist/chunk-TR2H475E.js +1 -0
  34. package/dist/chunk-UPO4GUFC.js +1 -0
  35. package/dist/chunk-ZC66NAIR.js +1 -0
  36. package/dist/client-history-YPEG45Q4.js +1 -0
  37. package/dist/client-history.js +1 -0
  38. package/dist/client-stream-42FOI3EC.js +4 -0
  39. package/dist/client-stream.js +5 -0
  40. package/dist/codegen.cjs +1 -1
  41. package/dist/codegen.js +1 -1
  42. package/dist/context-mentions-E7HJYWO6.js +161 -0
  43. package/dist/{context-mentions-inline-I2RDEBAK.js → context-mentions-inline-QVESLW55.js} +1 -1
  44. package/dist/context-mentions.cjs +7 -7
  45. package/dist/context-mentions.d.cts +180 -8
  46. package/dist/context-mentions.d.ts +180 -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 +183 -31
  51. package/dist/event-stream-view.d.ts +183 -31
  52. package/dist/event-stream-view.js +1 -1
  53. package/dist/forms-ui.d.cts +221 -667
  54. package/dist/forms-ui.d.ts +221 -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 +227 -738
  65. package/dist/index.d.ts +227 -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 +180 -8
  80. package/dist/session-reconnect.d.ts +180 -8
  81. package/dist/smart-dom-reader.cjs +13 -13
  82. package/dist/smart-dom-reader.d.cts +180 -8
  83. package/dist/smart-dom-reader.d.ts +180 -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 +182 -8
  87. package/dist/theme-editor-preview.d.ts +182 -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 +181 -23
  91. package/dist/theme-editor.d.ts +181 -23
  92. package/dist/theme-editor.js +2 -2
  93. package/dist/theme-reference.d.cts +14 -0
  94. package/dist/theme-reference.d.ts +14 -0
  95. package/dist/ui-extras-entry-WAYFKVNW.js +3 -0
  96. package/dist/ui-extras.js +72 -0
  97. package/dist/voice-runtime-UOQOFAFY.js +17 -0
  98. package/dist/voice-runtime.cjs +17 -1
  99. package/dist/voice-runtime.d.cts +11650 -232
  100. package/dist/voice-runtime.d.ts +11650 -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 +221 -0
  115. package/src/client-approve.test.ts +311 -0
  116. package/src/client-history-eager.ts +9 -0
  117. package/src/client-history-loader.ts +23 -0
  118. package/src/client-history.ts +579 -0
  119. package/src/client-resume.ts +177 -0
  120. package/src/client-stream-eager.ts +12 -0
  121. package/src/client-stream-loader.ts +25 -0
  122. package/src/client-stream.ts +1880 -0
  123. package/src/client.test.ts +31 -1
  124. package/src/client.ts +293 -2607
  125. package/src/components/approval-actions.test.ts +27 -0
  126. package/src/components/approval-actions.ts +27 -3
  127. package/src/components/message-bubble.test.ts +19 -0
  128. package/src/components/message-bubble.ts +2 -0
  129. package/src/components/registry.ts +6 -6
  130. package/src/components/tool-bubble.test.ts +9 -0
  131. package/src/components/tool-bubble.ts +1 -0
  132. package/src/history-shell-bundle.test.ts +54 -0
  133. package/src/history-shell-eager.ts +12 -0
  134. package/src/history-shell-loader.ts +27 -0
  135. package/src/history-shell.ts +2362 -0
  136. package/src/history-view-loader.ts +2 -0
  137. package/src/history-view.ts +3 -0
  138. package/src/index-core.ts +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 +852 -1536
  151. package/src/session.voice.test.ts +1170 -5
  152. package/src/styles/widget.css +58 -0
  153. package/src/theme-editor/state.ts +91 -91
  154. package/src/types.ts +198 -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 +670 -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 +981 -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
@@ -291,7 +286,7 @@ export class AgentWidgetSession {
291
286
  private status: AgentWidgetSessionStatus = "idle";
292
287
  private streaming = false;
293
288
  private abortController: AbortController | null = null;
294
- private sequenceCounter = Date.now();
289
+ #sequenceCounter = Date.now();
295
290
 
296
291
  // Client token session management
297
292
  private clientSession: ClientSession | null = null;
@@ -307,23 +302,23 @@ export class AgentWidgetSession {
307
302
  // cursor climbs; cleared on a graceful terminal, a terminal error, or any
308
303
  // teardown. Drives both in-session reconnect and host persistence
309
304
  // (`onExecutionState`).
310
- private resumable: ResumableHandle | null = null;
305
+ #resumable: ResumableHandle | null = null;
311
306
  // The assistant message id of the current turn, so a reconnect keeps filling
312
307
  // the SAME bubble instead of opening a new one.
313
- private activeAssistantMessageId: string | null = null;
308
+ #activeAssistantMessageId: string | null = null;
314
309
  // True while a reconnect run is active (guards against re-entry from a second
315
310
  // drop and tells the dispatch/connectStream catches to stay quiet).
316
- private reconnecting = false;
311
+ #reconnecting = false;
317
312
  // The reconnect orchestration (backoff loop, wake listeners, give-up
318
313
  // finalizer) lives in a lazily-imported module so it stays out of bundles
319
314
  // that never opt into durable reconnect. Created on the first reconnect.
320
- private reconnectController: ReconnectController | null = null;
321
- private reconnectControllerPromise: Promise<ReconnectController> | null = null;
315
+ #reconnectController: ReconnectController | null = null;
316
+ #reconnectControllerPromise: Promise<ReconnectController> | null = null;
322
317
  // Trailing-edge throttle for `onExecutionState` so it isn't fired per delta.
323
- private executionStateTimer: ReturnType<typeof setTimeout> | null = null;
318
+ #executionStateTimer: ReturnType<typeof setTimeout> | null = null;
324
319
 
325
320
  private artifacts = new Map<string, PersonaArtifactRecord>();
326
- private selectedArtifactId: string | null = null;
321
+ #selectedArtifactId: string | null = null;
327
322
 
328
323
  // WebMCP dedupe: keys are `${executionId}:${toolCallId}` so they're
329
324
  // naturally scoped to a single dispatch. A later dispatch (new executionId)
@@ -361,7 +356,7 @@ export class AgentWidgetSession {
361
356
  // queueMicrotask captures the epoch at queue time and bails if it changed,
362
357
  // so a resolve queued just before a teardown can't escape it by installing a
363
358
  // fresh controller after the set was already cleared.
364
- private webMcpEpoch = 0;
359
+ #webMcpEpoch = 0;
365
360
  // WebMCP native approval-bubble gate. When no custom `webmcp.onConfirm` is
366
361
  // supplied, the bridge's confirm handler routes here: we inject an
367
362
  // approval-variant message and park the bridge on a Promise that resolves
@@ -369,7 +364,11 @@ export class AgentWidgetSession {
369
364
  // resolveWebMcpApproval). Resolvers are keyed by the approval message id.
370
365
  private webMcpApprovalResolvers: Map<string, (approved: boolean) => void> =
371
366
  new Map();
372
- private webMcpApprovalSeq = 0;
367
+ #webMcpApprovalSeq = 0;
368
+ // Per approval card: the token of its latest in-flight decision request.
369
+ // Only that request's failure may reopen the card (two decisions can share
370
+ // a Date.now()). Each entry is dropped once its request settles.
371
+ private approvalTokens = new Map<string, object>();
373
372
  // Parallel local-tool batching (core#3878). A single model turn can emit
374
373
  // multiple `await(local_tool_required)` events for ONE paused
375
374
  // executionId: including two PARALLEL calls to the SAME tool ("add SHOE-001
@@ -387,60 +386,60 @@ export class AgentWidgetSession {
387
386
  > = new Map();
388
387
 
389
388
  // Voice support
390
- private voiceProvider: VoiceProvider | null = null;
389
+ #voiceProvider: VoiceProvider | null = null;
391
390
  /** In-flight setupVoice chunk load; toggleVoice awaits it when racing setup. */
392
- private voiceSetupPromise: Promise<void> | null = null;
391
+ #voiceSetupPromise: Promise<void> | null = null;
393
392
  /** Bumped by setupVoice/cleanupVoice so a stale chunk load can't install. */
394
- private voiceSetupGeneration = 0;
393
+ #voiceSetupGeneration = 0;
395
394
  /** 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';
395
+ #voiceLevel = 0;
396
+ #voiceActive = false;
397
+ #voiceStatus: VoiceStatus = 'disconnected';
399
398
 
400
399
  // ── Visitor conversation history (D5) ──────────────────────────
401
400
  // 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;
401
+ #activeConversationId: string | null = null;
402
+ #activeConversationRevision: string | null = null;
403
+ #historyNextMessageCursor: string | null = null;
405
404
  /** Monotonic: a later selection supersedes every earlier fetch/prepare. */
406
- private historyOpenEpoch = 0;
405
+ #historyOpenEpoch = 0;
407
406
  /** 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;
407
+ #suppressWelcomeInjection = false;
408
+ #historyRecovery: "new_conversation_required" | null = null;
409
+ #historySendBlocked = false;
411
410
  /** Continuity wipe / boot reconciliation; dispatch waits on it. */
412
- private historyGate: Promise<void> | null = null;
411
+ #historyGate: Promise<void> | null = null;
413
412
  /** Deduped by `(id, cursor)` so repeated clicks issue one request. */
414
- private olderPageRequests = new Map<string, Promise<HistoryOlderPage>>();
413
+ #olderPageRequests = new Map<string, Promise<HistoryOlderPage>>();
415
414
  /** Serializes projection finalization against every other operation. */
416
- private projectionFinalizationPromise: Promise<void> | null = null;
415
+ #projectionFinalizationPromise: Promise<void> | null = null;
417
416
  /** Message id → projection the server has acknowledged (sent or finalized). */
418
- private acknowledgedProjections = new Map<string, string>();
419
- private pendingProjections: PendingDisplayProjections | null = null;
417
+ #acknowledgedProjections = new Map<string, string>();
418
+ #pendingProjections: PendingDisplayProjections | null = null;
420
419
  /** Persisted revision as it stood before boot init overwrote it. */
421
- private bootConversationRevision: string | null = null;
422
- private bootRevisionCaptured = false;
420
+ #bootConversationRevision: string | null = null;
421
+ #bootRevisionCaptured = false;
423
422
  /** Exactly one visitor-store subscription per instance; re-keyed with the store. */
424
- private visitorStoreUnsubscribe: (() => void) | null = null;
425
- private subscribedVisitorStore: VisitorStore | null = null;
423
+ #visitorStoreUnsubscribe: (() => void) | null = null;
424
+ #subscribedVisitorStore: VisitorStore | null = null;
426
425
  /** An external credential change happened; the next dispatch must re-init. */
427
- private credentialReinitPending = false;
428
- private credentialReinitPromise: Promise<void> | null = null;
426
+ #credentialReinitPending = false;
427
+ #credentialReinitPromise: Promise<void> | null = null;
429
428
 
430
429
  constructor(
431
430
  private config: AgentWidgetConfig = {},
432
431
  private callbacks: SessionCallbacks,
433
432
  private historyInternals: WidgetHistoryInternals = {}
434
433
  ) {
435
- this.historyInternals = this.composeHistoryInternals(historyInternals);
434
+ this.historyInternals = this.#composeHistoryInternals(historyInternals);
436
435
  this.messages = [...(config.initialMessages ?? [])].map((message) => ({
437
436
  ...message,
438
- sequence: message.sequence ?? this.nextSequence()
437
+ sequence: message.sequence ?? this.#nextSequence()
439
438
  }));
440
- this.messages = this.sortMessages(this.messages);
439
+ this.messages = this.#sortMessages(this.messages);
441
440
  this.client = new AgentWidgetClient(config, this.historyInternals);
442
- this.syncVisitorStoreSubscription();
443
- this.wireDefaultWebMcpConfirm();
441
+ this.#syncVisitorStoreSubscription();
442
+ this.#wireDefaultWebMcpConfirm();
444
443
 
445
444
  // Hydrate artifacts from config (mirrors `initialMessages`). Restored
446
445
  // records are forced to `status: "complete"`: a mid-stream artifact should
@@ -449,18 +448,18 @@ export class AgentWidgetSession {
449
448
  this.artifacts.set(rec.id, { ...rec, status: "complete" });
450
449
  }
451
450
  if (config.initialSelectedArtifactId != null) {
452
- this.selectedArtifactId = config.initialSelectedArtifactId;
451
+ this.#selectedArtifactId = config.initialSelectedArtifactId;
453
452
  }
454
453
 
455
454
  if (this.messages.length) {
456
455
  this.callbacks.onMessagesChanged([...this.messages]);
457
456
  }
458
457
  if (this.artifacts.size > 0) {
459
- this.emitArtifactsState();
458
+ this.#emitArtifactsState();
460
459
  }
461
460
  this.callbacks.onStatusChanged(this.status);
462
- this.prefetchRuntypeTts();
463
- this.prefetchVoiceRuntime();
461
+ this.#prefetchRuntypeTts();
462
+ this.#prefetchVoiceRuntime();
464
463
  }
465
464
 
466
465
  /**
@@ -470,7 +469,7 @@ export class AgentWidgetSession {
470
469
  * AudioContext still wait for their own triggers. Errors are swallowed: the
471
470
  * setupVoice path retries via the chunk loader's rejection-retry.
472
471
  */
473
- private prefetchVoiceRuntime(): void {
472
+ #prefetchVoiceRuntime(): void {
474
473
  if (!this.config.voiceRecognition?.provider) return;
475
474
  void loadVoiceRuntime().catch(() => {});
476
475
  }
@@ -484,7 +483,7 @@ export class AgentWidgetSession {
484
483
  * prefetch just means the chunk loads on first click instead (or, on a hard
485
484
  * failure, the browser-voice fallback kicks in).
486
485
  */
487
- private prefetchRuntypeTts(): void {
486
+ #prefetchRuntypeTts(): void {
488
487
  const tts = this.config.textToSpeech;
489
488
  if (tts?.provider !== "runtype" || tts.createEngine) return;
490
489
  // Only warm the chunk when the engine will actually be built (same gate as
@@ -543,14 +542,19 @@ export class AgentWidgetSession {
543
542
  * Check if voice is currently active
544
543
  */
545
544
  public isVoiceActive(): boolean {
546
- return this.voiceActive;
545
+ return this.#voiceActive;
547
546
  }
548
547
 
549
548
  /**
550
549
  * Get current voice status
551
550
  */
552
551
  public getVoiceStatus(): VoiceStatus {
553
- return this.voiceStatus;
552
+ return this.#voiceStatus;
553
+ }
554
+
555
+ /** The live call's AI-disclosure notice, if it shows one. */
556
+ public getVoiceDisclosure(): string | null {
557
+ return this.#voiceProvider?.getDisclosure?.() ?? null;
554
558
  }
555
559
 
556
560
  /**
@@ -562,12 +566,12 @@ export class AgentWidgetSession {
562
566
  * always report null and the UI falls back to a fixed midpoint.
563
567
  */
564
568
  public getVoiceLevel(): number | null {
565
- return this.voiceProvider?.onLevel ? this.voiceLevel : null;
569
+ return this.#voiceProvider?.onLevel ? this.#voiceLevel : null;
566
570
  }
567
571
 
568
572
  public getVoiceInterruptionMode(): "none" | "cancel" | "barge-in" {
569
- if (this.voiceProvider?.getInterruptionMode) {
570
- return this.voiceProvider.getInterruptionMode();
573
+ if (this.#voiceProvider?.getInterruptionMode) {
574
+ return this.#voiceProvider.getInterruptionMode();
571
575
  }
572
576
  return "none";
573
577
  }
@@ -577,29 +581,36 @@ export class AgentWidgetSession {
577
581
  * Returns to idle state.
578
582
  */
579
583
  public stopVoicePlayback(): void {
580
- if (this.voiceProvider?.stopPlayback) {
581
- this.voiceTurns.cancel();
582
- this.voiceProvider.stopPlayback();
584
+ if (this.#voiceProvider?.stopPlayback) {
585
+ this.#voiceTurns.cancel();
586
+ this.#keyedVoice?.cancel();
587
+ this.#voiceProvider.stopPlayback();
583
588
  }
584
589
  }
585
590
 
586
591
  /** Returns true if the barge-in mic stream is alive (hot mic between turns) */
587
592
  public isBargeInActive(): boolean {
588
- return this.voiceProvider?.isBargeInActive?.() ?? false;
593
+ return this.#voiceProvider?.isBargeInActive?.() ?? false;
589
594
  }
590
595
 
591
596
  /** Tear down the barge-in mic pipeline: "hang up" the always-on mic */
592
597
  public async deactivateBargeIn(): Promise<void> {
593
- if (this.voiceProvider?.deactivateBargeIn) {
594
- await this.voiceProvider.deactivateBargeIn();
598
+ if (this.#voiceProvider?.deactivateBargeIn) {
599
+ await this.#voiceProvider.deactivateBargeIn();
595
600
  }
596
601
  }
597
602
 
598
603
  // 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();
604
+ #pendingVoiceUserMessageId: string | null = null;
605
+ #pendingVoiceAssistantMessageId: string | null = null;
606
+ #voiceTurns = new VoiceTurnTracker();
607
+ #voiceDisconnectPromise: Promise<void> = Promise.resolve();
608
+ // Turn-keyed (full-duplex) transcript reconciler, from the lazy voice runtime.
609
+ #keyedVoice: KeyedVoiceTranscript | null = null;
610
+ // The delegated voice turn running through sendMessage: the assistant
611
+ // messages it streams, and whether it failed or was aborted.
612
+ #voiceDelegation: VoiceDelegationCapture | null = null;
613
+
603
614
 
604
615
  // Track message IDs where the Runtype provider already played TTS audio
605
616
  // so browser TTS doesn't double-speak them
@@ -609,7 +620,7 @@ export class AgentWidgetSession {
609
620
  // message is active, its play/pause state, and the speech engine. The engine
610
621
  // is resolved lazily on first playback (inside the user gesture) so a hosted
611
622
  // engine via `textToSpeech.createEngine` plugs in without changing this class.
612
- private readAloud = new ReadAloudController(() => this.createSpeechEngine());
623
+ private readAloud = new ReadAloudController(() => this.#createSpeechEngine());
613
624
 
614
625
  /**
615
626
  * Resolve the speech engine behind read-aloud (and auto-speak):
@@ -622,7 +633,7 @@ export class AgentWidgetSession {
622
633
  * Runtype voices the day the endpoint ships.
623
634
  * 3. Otherwise the browser Web Speech API engine.
624
635
  */
625
- private createSpeechEngine(): SpeechEngine | Promise<SpeechEngine> | null {
636
+ #createSpeechEngine(): SpeechEngine | Promise<SpeechEngine> | null {
626
637
  const tts = this.config.textToSpeech;
627
638
  if (tts?.createEngine) return tts.createEngine();
628
639
 
@@ -694,7 +705,7 @@ export class AgentWidgetSession {
694
705
  }
695
706
 
696
707
  this.cleanupVoice();
697
- const disconnected = this.voiceDisconnectPromise;
708
+ const disconnected = this.#voiceDisconnectPromise;
698
709
 
699
710
  // The provider runtime ships in the lazy voice-runtime chunk. The sync
700
711
  // signature is preserved: construction + wiring + connect() happen when
@@ -702,19 +713,19 @@ export class AgentWidgetSession {
702
713
  // provider is configured, so this is warm in practice). A stale
703
714
  // resolution (cleanupVoice or a newer setupVoice ran meanwhile) is
704
715
  // discarded via the generation token.
705
- const generation = ++this.voiceSetupGeneration;
706
- this.voiceSetupPromise = loadVoiceRuntime()
716
+ const generation = ++this.#voiceSetupGeneration;
717
+ this.#voiceSetupPromise = loadVoiceRuntime()
707
718
  .then(async (mod) => {
708
719
  await disconnected;
709
- if (generation !== this.voiceSetupGeneration) return;
710
- this.wireVoiceProvider(mod.createVoiceProvider(voiceConfig));
720
+ if (generation !== this.#voiceSetupGeneration) return;
721
+ this.#wireVoiceProvider(mod, mod.createVoiceProvider(voiceConfig));
711
722
  })
712
723
  .catch((error) => {
713
724
  console.error('Failed to setup voice:', error);
714
725
  })
715
726
  .finally(() => {
716
- if (generation === this.voiceSetupGeneration) {
717
- this.voiceSetupPromise = null;
727
+ if (generation === this.#voiceSetupGeneration) {
728
+ this.#voiceSetupPromise = null;
718
729
  }
719
730
  });
720
731
  } catch (error) {
@@ -722,165 +733,56 @@ export class AgentWidgetSession {
722
733
  }
723
734
  }
724
735
 
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
- }
736
+ /**
737
+ * Install a provider: the wiring itself ships in the lazy voice-runtime
738
+ * chunk (`voice/session-voice-wiring.ts`); this hands it the private state.
739
+ */
740
+ #wireVoiceProvider(mod: VoiceRuntimeModule, provider: VoiceProvider): void {
741
+ this.#voiceProvider = provider;
742
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
743
+ const session = this;
744
+ mod.wireSessionVoice(
745
+ {
746
+ session: this as unknown as VoiceWiringSession,
747
+ provider: () => session.#voiceProvider,
748
+ generation: () => session.#voiceSetupGeneration,
749
+ get keyed() {
750
+ return session.#keyedVoice;
751
+ },
752
+ set keyed(keyed) {
753
+ session.#keyedVoice = keyed;
754
+ },
755
+ get pendingUser() {
756
+ return session.#pendingVoiceUserMessageId;
757
+ },
758
+ set pendingUser(id) {
759
+ session.#pendingVoiceUserMessageId = id;
760
+ },
761
+ get pendingAssistant() {
762
+ return session.#pendingVoiceAssistantMessageId;
763
+ },
764
+ set pendingAssistant(id) {
765
+ session.#pendingVoiceAssistantMessageId = id;
766
+ },
767
+ turns: () => session.#voiceTurns,
768
+ busy: () => session.#chatTurnBusy(),
769
+ setStreaming: (streaming) => session.#setStreaming(streaming),
770
+ setDelegation: (capture) => {
771
+ session.#voiceDelegation = capture;
772
+ },
773
+ setLevel: (level) => {
774
+ session.#voiceLevel = level;
775
+ },
776
+ setStatus: (status) => {
777
+ session.#voiceStatus = status;
778
+ session.#voiceActive = status === 'listening';
779
+ },
780
+ settlePending: (final) => session.#settlePendingVoiceTurn(final),
781
+ },
782
+ provider,
783
+ mod.KeyedVoiceTranscript,
784
+ mod.createVoiceSessionBridge
785
+ );
884
786
  }
885
787
 
886
788
  /**
@@ -889,21 +791,21 @@ export class AgentWidgetSession {
889
791
  public async toggleVoice() {
890
792
  // A click can race the setup chunk load; wait for it rather than
891
793
  // erroring (prefetch at construction makes this window ~one microtask).
892
- if (!this.voiceProvider && this.voiceSetupPromise) {
893
- await this.voiceSetupPromise;
794
+ if (!this.#voiceProvider && this.#voiceSetupPromise) {
795
+ await this.#voiceSetupPromise;
894
796
  }
895
- if (!this.voiceProvider) {
797
+ if (!this.#voiceProvider) {
896
798
  console.error('Voice not configured');
897
799
  return;
898
800
  }
899
801
 
900
- if (this.voiceActive) {
901
- await this.voiceProvider.stopListening();
802
+ if (this.#voiceActive) {
803
+ await this.#voiceProvider.stopListening();
902
804
  } else {
903
805
  // Stop any in-progress TTS so the mic doesn't pick it up
904
806
  this.stopSpeaking();
905
807
  try {
906
- await this.voiceProvider.startListening();
808
+ await this.#voiceProvider.startListening();
907
809
  } catch (error) {
908
810
  console.error('Failed to start voice:', error);
909
811
  }
@@ -921,42 +823,55 @@ export class AgentWidgetSession {
921
823
  this.warmClientSession();
922
824
  return;
923
825
  }
924
- const generation = this.voiceSetupGeneration;
826
+ const generation = this.#voiceSetupGeneration;
925
827
  const run = () => {
926
- const provider = this.voiceProvider;
927
- if (!provider?.prewarm || generation !== this.voiceSetupGeneration) return;
928
- if (this.voiceActive || this.isBargeInActive()) return;
828
+ const provider = this.#voiceProvider;
829
+ if (!provider?.prewarm || generation !== this.#voiceSetupGeneration) return;
830
+ if (this.#voiceActive || this.isBargeInActive()) return;
929
831
  void Promise.resolve()
930
832
  .then(() => provider.prewarm?.())
931
833
  .catch(() => {});
932
834
  };
933
- if (!this.voiceProvider && this.voiceSetupPromise) {
934
- void this.voiceSetupPromise.then(run, () => {});
835
+ if (!this.#voiceProvider && this.#voiceSetupPromise) {
836
+ void this.#voiceSetupPromise.then(run, () => {});
935
837
  return;
936
838
  }
937
839
  run();
938
840
  }
939
841
 
842
+ /** A chat turn is streaming, resuming, or running local (WebMCP) tools. */
843
+ #chatTurnBusy(): boolean {
844
+ return (
845
+ !!this.abortController ||
846
+ this.webMcpResolveControllers.size > 0 ||
847
+ this.webMcpAwaitBatches.size > 0 ||
848
+ this.#reconnecting ||
849
+ this.status === 'resuming'
850
+ );
851
+ }
852
+
940
853
  /**
941
854
  * Cleanup voice resources
942
855
  */
943
856
  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);
857
+ const notifyDisconnected = this.#voiceProvider !== null;
858
+ this.#voiceSetupGeneration++;
859
+ this.#voiceSetupPromise = null;
860
+ if (this.#voiceProvider) {
861
+ const provider = this.#voiceProvider;
862
+ this.#voiceProvider = null;
863
+ this.#voiceDisconnectPromise = this.#disconnectVoice(provider);
951
864
  }
952
- this.voiceActive = false;
953
- this.voiceStatus = 'disconnected';
954
- this.settlePendingVoiceTurn(true);
955
- this.voiceTurns = new VoiceTurnTracker();
865
+ this.#voiceActive = false;
866
+ this.#voiceStatus = 'disconnected';
867
+ this.#settlePendingVoiceTurn(true);
868
+ this.#keyedVoice?.settle();
869
+ this.#keyedVoice = null;
870
+ this.#voiceTurns = new VoiceTurnTracker();
956
871
  if (notifyDisconnected) this.callbacks.onVoiceStatusChanged?.('disconnected');
957
872
  }
958
873
 
959
- private async disconnectVoice(provider: VoiceProvider): Promise<void> {
874
+ async #disconnectVoice(provider: VoiceProvider): Promise<void> {
960
875
  try {
961
876
  await provider.disconnect();
962
877
  } catch (error) {
@@ -964,13 +879,13 @@ export class AgentWidgetSession {
964
879
  }
965
880
  }
966
881
 
967
- private settlePendingVoiceTurn(includeUser: boolean): void {
968
- const userId = includeUser ? this.pendingVoiceUserMessageId : null;
969
- const assistantId = this.pendingVoiceAssistantMessageId;
882
+ #settlePendingVoiceTurn(includeUser: boolean): void {
883
+ const userId = includeUser ? this.#pendingVoiceUserMessageId : null;
884
+ const assistantId = this.#pendingVoiceAssistantMessageId;
970
885
  if (!userId && !assistantId) return;
971
- if (assistantId) this.voiceTurns.cancel();
972
- if (includeUser) this.pendingVoiceUserMessageId = null;
973
- this.pendingVoiceAssistantMessageId = null;
886
+ if (assistantId) this.#voiceTurns.cancel();
887
+ if (includeUser) this.#pendingVoiceUserMessageId = null;
888
+ this.#pendingVoiceAssistantMessageId = null;
974
889
  if (assistantId) this.ttsSpokenMessageIds.add(assistantId);
975
890
  this.messages = this.messages.flatMap((message) => {
976
891
  if (message.id !== userId && message.id !== assistantId) return [message];
@@ -978,7 +893,7 @@ export class AgentWidgetSession {
978
893
  return [{ ...message, streaming: false, voiceProcessing: false }];
979
894
  });
980
895
  this.callbacks.onMessagesChanged([...this.messages]);
981
- if (assistantId) this.setStreaming(false);
896
+ if (assistantId) this.#setStreaming(false);
982
897
  }
983
898
 
984
899
  /**
@@ -1004,7 +919,11 @@ export class AgentWidgetSession {
1004
919
  voiceId: providerConfig.runtype?.voiceId,
1005
920
  createPlaybackEngine: providerConfig.runtype?.createPlaybackEngine,
1006
921
  prewarmMode: providerConfig.runtype?.prewarmMode,
1007
- attachIdleMs: providerConfig.runtype?.attachIdleMs
922
+ attachIdleMs: providerConfig.runtype?.attachIdleMs,
923
+ clientDelegation: providerConfig.runtype?.clientDelegation,
924
+ callContext: providerConfig.runtype?.callContext,
925
+ approvalTimeoutMs: providerConfig.runtype?.approvalTimeoutMs,
926
+ disclosureText: providerConfig.runtype?.disclosureText
1008
927
  }
1009
928
  };
1010
929
 
@@ -1046,9 +965,9 @@ export class AgentWidgetSession {
1046
965
  await this.historyInternals.historyBootstrapReady;
1047
966
  // Snapshot before init overwrites it: boot reconciliation compares the
1048
967
  // init revision against what the LAST page load persisted.
1049
- this.bootConversationRevision =
968
+ this.#bootConversationRevision =
1050
969
  this.historyInternals.getStoredConversationRevision?.() ?? null;
1051
- this.bootRevisionCaptured = true;
970
+ this.#bootRevisionCaptured = true;
1052
971
  }
1053
972
 
1054
973
  try {
@@ -1085,7 +1004,7 @@ export class AgentWidgetSession {
1085
1004
  */
1086
1005
  public setClientSession(session: ClientSession, pristine?: boolean): void {
1087
1006
  this.clientSession = session;
1088
- this.injectWelcomeMessage(session, pristine);
1007
+ this.#injectWelcomeMessage(session, pristine);
1089
1008
  }
1090
1009
 
1091
1010
  /**
@@ -1093,11 +1012,11 @@ export class AgentWidgetSession {
1093
1012
  * reopening a stored conversation is not a new thread, and the hydrated
1094
1013
  * transcript would otherwise race a welcome bubble.
1095
1014
  */
1096
- private injectWelcomeMessage(
1015
+ #injectWelcomeMessage(
1097
1016
  session: ClientSession,
1098
1017
  pristine = !this.messages.length
1099
1018
  ): void {
1100
- if (this.suppressWelcomeInjection) return;
1019
+ if (this.#suppressWelcomeInjection) return;
1101
1020
  if (!session.config.welcomeMessage || !pristine) return;
1102
1021
  // A late welcome (the first send raced the init) sorts ahead of that turn.
1103
1022
  const first = this.messages[0];
@@ -1106,9 +1025,9 @@ export class AgentWidgetSession {
1106
1025
  role: "assistant",
1107
1026
  content: session.config.welcomeMessage,
1108
1027
  createdAt: first?.createdAt ?? new Date().toISOString(),
1109
- sequence: first ? (first.sequence ?? 0) - 1 : this.nextSequence()
1028
+ sequence: first ? (first.sequence ?? 0) - 1 : this.#nextSequence()
1110
1029
  };
1111
- this.appendMessage(welcomeMessage);
1030
+ this.#appendMessage(welcomeMessage);
1112
1031
  }
1113
1032
 
1114
1033
  /**
@@ -1184,17 +1103,17 @@ export class AgentWidgetSession {
1184
1103
  * client and any rebuilt one share the same store.
1185
1104
  */
1186
1105
  public setHistoryInternals(internals: WidgetHistoryInternals): void {
1187
- this.historyInternals = this.composeHistoryInternals(internals);
1106
+ this.historyInternals = this.#composeHistoryInternals(internals);
1188
1107
  this.client.setHistoryInternals(this.historyInternals);
1189
- this.syncVisitorStoreSubscription();
1108
+ this.#syncVisitorStoreSubscription();
1190
1109
  }
1191
1110
 
1192
1111
  /** Widget teardown: release the visitor-store subscription. */
1193
1112
  public destroy(): void {
1194
1113
  this.cleanupVoice();
1195
- this.visitorStoreUnsubscribe?.();
1196
- this.visitorStoreUnsubscribe = null;
1197
- this.subscribedVisitorStore = null;
1114
+ this.#visitorStoreUnsubscribe?.();
1115
+ this.#visitorStoreUnsubscribe = null;
1116
+ this.#subscribedVisitorStore = null;
1198
1117
  }
1199
1118
 
1200
1119
  // ==========================================================================
@@ -1206,25 +1125,25 @@ export class AgentWidgetSession {
1206
1125
  * supplied its own observer: a credential/record break must wipe local state
1207
1126
  * before the replacement session becomes sendable.
1208
1127
  */
1209
- private composeHistoryInternals(
1128
+ #composeHistoryInternals(
1210
1129
  internals: WidgetHistoryInternals
1211
1130
  ): WidgetHistoryInternals {
1212
1131
  const hostContinuity = internals.onHistoryContinuityChanged;
1213
1132
  return {
1214
1133
  ...internals,
1215
1134
  onHistoryContinuityChanged: (info) => {
1216
- this.handleHistoryContinuityChanged(info);
1135
+ this.#handleHistoryContinuityChanged(info);
1217
1136
  hostContinuity?.(info);
1218
1137
  },
1219
1138
  };
1220
1139
  }
1221
1140
 
1222
- private get historyProvider(): HistoryProvider | null {
1141
+ get #historyProvider(): HistoryProvider | null {
1223
1142
  return this.historyInternals.historyProvider ?? null;
1224
1143
  }
1225
1144
 
1226
- private requireHistoryProvider(): HistoryProvider {
1227
- const provider = this.historyProvider;
1145
+ #requireHistoryProvider(): HistoryProvider {
1146
+ const provider = this.#historyProvider;
1228
1147
  if (!provider) {
1229
1148
  throw new SessionHistoryError(
1230
1149
  "history_unavailable",
@@ -1238,8 +1157,8 @@ export class AgentWidgetSession {
1238
1157
  * One resolved scope per logical action. An explicitly requested scope the
1239
1158
  * provider cannot serve fails locally rather than silently downgrading.
1240
1159
  */
1241
- private historyContext(scope?: HistoryScope): HistoryOperationContext {
1242
- const provider = this.requireHistoryProvider();
1160
+ #historyContext(scope?: HistoryScope): HistoryOperationContext {
1161
+ const provider = this.#requireHistoryProvider();
1243
1162
  const requested = scope ?? this.config.features?.history?.scope;
1244
1163
  const derived: HistoryScope = this.config.getIdentityProof
1245
1164
  ? "verified-user"
@@ -1262,23 +1181,23 @@ export class AgentWidgetSession {
1262
1181
 
1263
1182
  public getHistoryState(): SessionHistoryState {
1264
1183
  return {
1265
- activeConversationId: this.activeConversationId,
1266
- conversationRevision: this.activeConversationRevision,
1267
- nextMessageCursor: this.historyNextMessageCursor,
1268
- recovery: this.historyRecovery,
1269
- sendBlocked: this.historySendBlocked,
1184
+ activeConversationId: this.#activeConversationId,
1185
+ conversationRevision: this.#activeConversationRevision,
1186
+ nextMessageCursor: this.#historyNextMessageCursor,
1187
+ recovery: this.#historyRecovery,
1188
+ sendBlocked: this.#historySendBlocked,
1270
1189
  };
1271
1190
  }
1272
1191
 
1273
1192
  public getActiveConversationId(): string | null {
1274
- return this.activeConversationId;
1193
+ return this.#activeConversationId;
1275
1194
  }
1276
1195
 
1277
- private emitHistoryState(): void {
1196
+ #emitHistoryState(): void {
1278
1197
  this.callbacks.onHistoryStateChanged?.(this.getHistoryState());
1279
1198
  }
1280
1199
 
1281
- private notifyHistory(
1200
+ #notifyHistory(
1282
1201
  code: SessionHistoryNoticeCode,
1283
1202
  message: string
1284
1203
  ): void {
@@ -1287,13 +1206,13 @@ export class AgentWidgetSession {
1287
1206
 
1288
1207
  /** Pending destructive/reconciliation work every other operation waits on. */
1289
1208
  public async awaitHistorySettled(): Promise<void> {
1290
- const gate = this.historyGate;
1209
+ const gate = this.#historyGate;
1291
1210
  if (gate) await gate.catch(() => {});
1292
- const finalization = this.projectionFinalizationPromise;
1211
+ const finalization = this.#projectionFinalizationPromise;
1293
1212
  if (finalization) await finalization.catch(() => {});
1294
1213
  }
1295
1214
 
1296
- private assertHistoryIdle(): void {
1215
+ #assertHistoryIdle(): void {
1297
1216
  if (this.streaming || this.status === "paused" || this.status === "resuming") {
1298
1217
  throw new SessionHistoryError(
1299
1218
  "conversation_busy",
@@ -1308,12 +1227,12 @@ export class AgentWidgetSession {
1308
1227
  targetId?: string;
1309
1228
  scope?: HistoryScope;
1310
1229
  }) {
1311
- const provider = this.requireHistoryProvider();
1230
+ const provider = this.#requireHistoryProvider();
1312
1231
  return provider.list({
1313
1232
  ...(opts?.cursor ? { cursor: opts.cursor } : {}),
1314
1233
  ...(opts?.limit !== undefined ? { limit: opts.limit } : {}),
1315
1234
  ...(opts?.targetId !== undefined ? { targetId: opts.targetId } : {}),
1316
- context: this.historyContext(opts?.scope),
1235
+ context: this.#historyContext(opts?.scope),
1317
1236
  });
1318
1237
  }
1319
1238
 
@@ -1329,46 +1248,46 @@ export class AgentWidgetSession {
1329
1248
  summary: HistoryConversationSummary;
1330
1249
  nextMessageCursor: string | null;
1331
1250
  }> {
1332
- const provider = this.requireHistoryProvider();
1333
- this.assertHistoryIdle();
1334
- const context = this.historyContext(opts?.scope);
1251
+ const provider = this.#requireHistoryProvider();
1252
+ this.#assertHistoryIdle();
1253
+ const context = this.#historyContext(opts?.scope);
1335
1254
  await this.awaitHistorySettled();
1336
- const epoch = ++this.historyOpenEpoch;
1255
+ const epoch = ++this.#historyOpenEpoch;
1337
1256
 
1338
1257
  const page = await provider.getPage(id, { context });
1339
- if (epoch !== this.historyOpenEpoch) {
1258
+ if (epoch !== this.#historyOpenEpoch) {
1340
1259
  throw new SessionHistoryError("superseded", "A later selection won");
1341
1260
  }
1342
1261
 
1343
1262
  const prepared = await provider.prepareOpen(id, { context });
1344
- if (epoch !== this.historyOpenEpoch) {
1263
+ if (epoch !== this.#historyOpenEpoch) {
1345
1264
  prepared.discard();
1346
1265
  throw new SessionHistoryError("superseded", "A later selection won");
1347
1266
  }
1348
1267
 
1349
1268
  // The reopened transcript is not a new conversation: never inject welcome.
1350
- this.suppressWelcomeInjection = true;
1269
+ this.#suppressWelcomeInjection = true;
1351
1270
  try {
1352
- await this.commitActivation(prepared);
1271
+ await this.#commitActivation(prepared);
1353
1272
  } finally {
1354
- this.suppressWelcomeInjection = false;
1273
+ this.#suppressWelcomeInjection = false;
1355
1274
  }
1356
1275
 
1357
- this.activeConversationId = prepared.conversationId || id;
1358
- this.activeConversationRevision =
1276
+ this.#activeConversationId = prepared.conversationId || id;
1277
+ this.#activeConversationRevision =
1359
1278
  prepared.conversationRevision || page.conversationRevision || null;
1360
- this.historyNextMessageCursor = page.nextCursor;
1361
- this.historyRecovery = null;
1362
- this.historySendBlocked = false;
1363
- this.resetConversationScopedState();
1279
+ this.#historyNextMessageCursor = page.nextCursor;
1280
+ this.#historyRecovery = null;
1281
+ this.#historySendBlocked = false;
1282
+ this.#resetConversationScopedState();
1364
1283
  // Server projections are by definition acknowledged.
1365
1284
  for (const message of page.messages) {
1366
- this.acknowledgedProjections.set(message.id, message.content);
1285
+ this.#acknowledgedProjections.set(message.id, message.content);
1367
1286
  }
1368
1287
  // Metadata first, transcript last: the transcript emit is what persists.
1369
- this.persistConversationMetadata();
1288
+ this.#persistConversationMetadata();
1370
1289
  this.hydrateMessages(page.messages);
1371
- this.emitHistoryState();
1290
+ this.#emitHistoryState();
1372
1291
  return { summary: page.summary, nextMessageCursor: page.nextCursor };
1373
1292
  }
1374
1293
 
@@ -1381,25 +1300,25 @@ export class AgentWidgetSession {
1381
1300
  cursor?: string | null,
1382
1301
  opts?: { scope?: HistoryScope }
1383
1302
  ): Promise<HistoryOlderPage> {
1384
- const provider = this.requireHistoryProvider();
1385
- const context = this.historyContext(opts?.scope);
1386
- const resolvedCursor = cursor ?? this.historyNextMessageCursor;
1303
+ const provider = this.#requireHistoryProvider();
1304
+ const context = this.#historyContext(opts?.scope);
1305
+ const resolvedCursor = cursor ?? this.#historyNextMessageCursor;
1387
1306
  if (!resolvedCursor) {
1388
1307
  return Promise.resolve({ messages: [], nextMessageCursor: null });
1389
1308
  }
1390
1309
  const key = `${id} ${resolvedCursor}`;
1391
- const inflight = this.olderPageRequests.get(key);
1310
+ const inflight = this.#olderPageRequests.get(key);
1392
1311
  if (inflight) return inflight;
1393
1312
 
1394
- const request = this.fetchOlderPage(provider, id, resolvedCursor, context)
1313
+ const request = this.#fetchOlderPage(provider, id, resolvedCursor, context)
1395
1314
  .finally(() => {
1396
- this.olderPageRequests.delete(key);
1315
+ this.#olderPageRequests.delete(key);
1397
1316
  });
1398
- this.olderPageRequests.set(key, request);
1317
+ this.#olderPageRequests.set(key, request);
1399
1318
  return request;
1400
1319
  }
1401
1320
 
1402
- private async fetchOlderPage(
1321
+ async #fetchOlderPage(
1403
1322
  provider: HistoryProvider,
1404
1323
  id: string,
1405
1324
  cursor: string,
@@ -1412,20 +1331,20 @@ export class AgentWidgetSession {
1412
1331
  context,
1413
1332
  });
1414
1333
  // A conversation switch during the fetch retires this page.
1415
- if (this.activeConversationId && this.activeConversationId !== id) {
1334
+ if (this.#activeConversationId && this.#activeConversationId !== id) {
1416
1335
  return { messages: [], nextMessageCursor: page.nextCursor };
1417
1336
  }
1418
1337
  const merged = mergeWireMessagesById(this.messages, page.messages);
1419
- this.messages = this.sortMessages(
1420
- merged.map((message) => this.ensureSequence(message))
1338
+ this.messages = this.#sortMessages(
1339
+ merged.map((message) => this.#ensureSequence(message))
1421
1340
  );
1422
- this.historyNextMessageCursor = page.nextCursor;
1341
+ this.#historyNextMessageCursor = page.nextCursor;
1423
1342
  for (const message of page.messages) {
1424
- this.acknowledgedProjections.set(message.id, message.content);
1343
+ this.#acknowledgedProjections.set(message.id, message.content);
1425
1344
  }
1426
1345
  this.historyInternals.setStoredMessageCursor?.(page.nextCursor);
1427
1346
  this.callbacks.onMessagesChanged([...this.messages]);
1428
- this.emitHistoryState();
1347
+ this.#emitHistoryState();
1429
1348
  return { messages: page.messages, nextMessageCursor: page.nextCursor };
1430
1349
  }
1431
1350
 
@@ -1436,34 +1355,34 @@ export class AgentWidgetSession {
1436
1355
  public async startNewConversation(opts?: {
1437
1356
  scope?: HistoryScope;
1438
1357
  }): Promise<void> {
1439
- const provider = this.requireHistoryProvider();
1440
- const context = this.historyContext(opts?.scope);
1358
+ const provider = this.#requireHistoryProvider();
1359
+ const context = this.#historyContext(opts?.scope);
1441
1360
  await this.awaitHistorySettled();
1442
- const epoch = ++this.historyOpenEpoch;
1361
+ const epoch = ++this.#historyOpenEpoch;
1443
1362
  const prepared = await provider.prepareStartNew({ context });
1444
- if (epoch !== this.historyOpenEpoch) {
1363
+ if (epoch !== this.#historyOpenEpoch) {
1445
1364
  prepared.discard();
1446
1365
  throw new SessionHistoryError("superseded", "A later action won");
1447
1366
  }
1448
- await this.installFreshConversation(prepared);
1367
+ await this.#installFreshConversation(prepared);
1449
1368
  }
1450
1369
 
1451
1370
  /**
1452
1371
  * Clear local + persisted conversation state, then commit the replacement.
1453
1372
  * Welcome injection is deliberately allowed once the transcript is empty.
1454
1373
  */
1455
- private async installFreshConversation(
1374
+ async #installFreshConversation(
1456
1375
  prepared: PreparedHistoryActivation
1457
1376
  ): 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();
1377
+ this.#discardConversationState();
1378
+ await this.#commitActivation(prepared);
1379
+ this.#activeConversationId = prepared.conversationId || null;
1380
+ this.#activeConversationRevision = prepared.conversationRevision || null;
1381
+ this.#historyNextMessageCursor = null;
1382
+ this.#historyRecovery = null;
1383
+ this.#historySendBlocked = false;
1384
+ this.#persistConversationMetadata();
1385
+ this.#emitHistoryState();
1467
1386
  }
1468
1387
 
1469
1388
  /**
@@ -1474,12 +1393,12 @@ export class AgentWidgetSession {
1474
1393
  id: string,
1475
1394
  opts?: { scope?: HistoryScope }
1476
1395
  ): Promise<void> {
1477
- const provider = this.requireHistoryProvider();
1478
- const context = this.historyContext(opts?.scope);
1396
+ const provider = this.#requireHistoryProvider();
1397
+ const context = this.#historyContext(opts?.scope);
1479
1398
  await this.awaitHistorySettled();
1480
1399
  await provider.delete(id, { context });
1481
- if (id !== this.activeConversationId) return;
1482
- await this.recoverFromDestroyedConversation(context);
1400
+ if (id !== this.#activeConversationId) return;
1401
+ await this.#recoverFromDestroyedConversation(context);
1483
1402
  }
1484
1403
 
1485
1404
  /** Visitor-scoped rename/star. Requires a provider with the update capability. */
@@ -1488,35 +1407,35 @@ export class AgentWidgetSession {
1488
1407
  patch: HistoryConversationPatch,
1489
1408
  opts?: { scope?: HistoryScope }
1490
1409
  ): Promise<HistoryConversationSummary> {
1491
- const provider = this.requireHistoryProvider();
1410
+ const provider = this.#requireHistoryProvider();
1492
1411
  if (!provider.update) {
1493
1412
  throw new SessionHistoryError(
1494
1413
  "history_unavailable",
1495
1414
  "This history provider cannot update conversations"
1496
1415
  );
1497
1416
  }
1498
- const context = this.historyContext(opts?.scope);
1417
+ const context = this.#historyContext(opts?.scope);
1499
1418
  await this.awaitHistorySettled();
1500
1419
  return provider.update(id, patch, { context });
1501
1420
  }
1502
1421
 
1503
1422
  /** True when the provider supports visitor-scoped rename/star. */
1504
1423
  public canUpdateConversations(): boolean {
1505
- return typeof this.historyProvider?.update === "function";
1424
+ return typeof this.#historyProvider?.update === "function";
1506
1425
  }
1507
1426
 
1508
1427
  public async clearConversationHistory(opts?: {
1509
1428
  targetId?: string;
1510
1429
  scope?: HistoryScope;
1511
1430
  }): Promise<{ deleted: number }> {
1512
- const provider = this.requireHistoryProvider();
1513
- const context = this.historyContext(opts?.scope);
1431
+ const provider = this.#requireHistoryProvider();
1432
+ const context = this.#historyContext(opts?.scope);
1514
1433
  await this.awaitHistorySettled();
1515
1434
  const result = await provider.deleteAll({
1516
1435
  ...(opts?.targetId !== undefined ? { targetId: opts.targetId } : {}),
1517
1436
  context,
1518
1437
  });
1519
- await this.recoverFromDestroyedConversation(context);
1438
+ await this.#recoverFromDestroyedConversation(context);
1520
1439
  return result;
1521
1440
  }
1522
1441
 
@@ -1527,65 +1446,65 @@ export class AgentWidgetSession {
1527
1446
  public async resetHistoryDevice(): Promise<{
1528
1447
  remoteRevocationConfirmed: boolean;
1529
1448
  }> {
1530
- const provider = this.requireHistoryProvider();
1449
+ const provider = this.#requireHistoryProvider();
1531
1450
  if (!provider.resetDevice) {
1532
1451
  throw new SessionHistoryError(
1533
1452
  "history_unavailable",
1534
1453
  "This history provider cannot reset the device"
1535
1454
  );
1536
1455
  }
1537
- this.historyOpenEpoch += 1;
1538
- this.projectionFinalizationPromise = null;
1456
+ this.#historyOpenEpoch += 1;
1457
+ this.#projectionFinalizationPromise = null;
1539
1458
  try {
1540
1459
  return await provider.resetDevice();
1541
1460
  } 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();
1461
+ this.#discardConversationState();
1462
+ this.#activeConversationId = null;
1463
+ this.#activeConversationRevision = null;
1464
+ this.#historyNextMessageCursor = null;
1465
+ this.#historyRecovery = null;
1466
+ this.#historySendBlocked = false;
1467
+ this.#emitHistoryState();
1549
1468
  }
1550
1469
  }
1551
1470
 
1552
- private async recoverFromDestroyedConversation(
1471
+ async #recoverFromDestroyedConversation(
1553
1472
  context: HistoryOperationContext
1554
1473
  ): Promise<void> {
1555
1474
  // 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);
1475
+ this.#historyOpenEpoch += 1;
1476
+ this.#projectionFinalizationPromise = null;
1477
+ this.#discardConversationState();
1478
+ this.#activeConversationId = null;
1479
+ this.#activeConversationRevision = null;
1480
+ this.#historyNextMessageCursor = null;
1481
+ this.#historySendBlocked = true;
1482
+ this.#historyRecovery = null;
1483
+ this.#emitHistoryState();
1484
+ await this.#prepareReplacementConversation(context);
1566
1485
  }
1567
1486
 
1568
1487
  /**
1569
1488
  * Never reinstalls the deleted session. A failed preparation leaves an
1570
1489
  * explicit recoverable state instead of the old transcript.
1571
1490
  */
1572
- private async prepareReplacementConversation(
1491
+ async #prepareReplacementConversation(
1573
1492
  context: HistoryOperationContext
1574
1493
  ): Promise<void> {
1575
- const provider = this.historyProvider;
1494
+ const provider = this.#historyProvider;
1576
1495
  if (!provider) {
1577
- this.historySendBlocked = false;
1578
- this.emitHistoryState();
1496
+ this.#historySendBlocked = false;
1497
+ this.#emitHistoryState();
1579
1498
  return;
1580
1499
  }
1581
1500
  try {
1582
1501
  const prepared = await provider.prepareStartNew({ context });
1583
- await this.installFreshConversation(prepared);
1502
+ await this.#installFreshConversation(prepared);
1584
1503
  } catch {
1585
- this.historySendBlocked = true;
1586
- this.historyRecovery = "new_conversation_required";
1587
- this.emitHistoryState();
1588
- this.notifyHistory(
1504
+ this.#historySendBlocked = true;
1505
+ this.#historyRecovery = "new_conversation_required";
1506
+ this.#emitHistoryState();
1507
+ this.#notifyHistory(
1589
1508
  "new_conversation_required",
1590
1509
  "That conversation was deleted. Start a new conversation to continue."
1591
1510
  );
@@ -1593,13 +1512,13 @@ export class AgentWidgetSession {
1593
1512
  }
1594
1513
 
1595
1514
  /** 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;
1515
+ async #retryRequiredConversation(): Promise<void> {
1516
+ if (this.#historyRecovery !== "new_conversation_required") return;
1517
+ const provider = this.#historyProvider;
1599
1518
  if (!provider) return;
1600
- this.historyRecovery = null;
1601
- await this.prepareReplacementConversation(this.historyContext());
1602
- if (this.historyRecovery === "new_conversation_required") {
1519
+ this.#historyRecovery = null;
1520
+ await this.#prepareReplacementConversation(this.#historyContext());
1521
+ if (this.#historyRecovery === "new_conversation_required") {
1603
1522
  throw new SessionHistoryError(
1604
1523
  "new_conversation_required",
1605
1524
  "A new conversation is required before sending"
@@ -1612,7 +1531,7 @@ export class AgentWidgetSession {
1612
1531
  * session binding (`bindActivatedSession`); the fallback covers a shell that
1613
1532
  * has not wired that callback yet.
1614
1533
  */
1615
- private async commitActivation(
1534
+ async #commitActivation(
1616
1535
  prepared: PreparedHistoryActivation
1617
1536
  ): Promise<void> {
1618
1537
  await prepared.commit();
@@ -1631,61 +1550,62 @@ export class AgentWidgetSession {
1631
1550
  this.clientSession = session;
1632
1551
  this.config.setStoredSessionId?.(session.sessionId);
1633
1552
  if (session.conversationId) {
1634
- this.activeConversationId = session.conversationId;
1635
- this.activeConversationRevision = session.conversationRevision ?? null;
1553
+ this.#activeConversationId = session.conversationId;
1554
+ this.#activeConversationRevision = session.conversationRevision ?? null;
1636
1555
  this.config.setStoredConversationId?.(session.conversationId);
1637
1556
  this.historyInternals.setStoredConversationRevision?.(
1638
1557
  session.conversationRevision ?? null
1639
1558
  );
1640
1559
  }
1641
- if (!this.suppressWelcomeInjection) this.injectWelcomeMessage(session);
1560
+ if (!this.#suppressWelcomeInjection) this.#injectWelcomeMessage(session);
1642
1561
  }
1643
1562
 
1644
- private persistConversationMetadata(): void {
1645
- if (this.activeConversationId) {
1646
- this.config.setStoredConversationId?.(this.activeConversationId);
1563
+ #persistConversationMetadata(): void {
1564
+ if (this.#activeConversationId) {
1565
+ this.config.setStoredConversationId?.(this.#activeConversationId);
1647
1566
  } else {
1648
1567
  this.config.clearStoredConversationId?.();
1649
1568
  }
1650
1569
  this.historyInternals.setStoredConversationRevision?.(
1651
- this.activeConversationRevision
1570
+ this.#activeConversationRevision
1652
1571
  );
1653
1572
  this.historyInternals.setStoredMessageCursor?.(
1654
- this.historyNextMessageCursor
1573
+ this.#historyNextMessageCursor
1655
1574
  );
1656
1575
  }
1657
1576
 
1658
1577
  /** Conversation-scoped caches that must not leak across records. */
1659
- private resetConversationScopedState(): void {
1578
+ #resetConversationScopedState(): void {
1660
1579
  this.historyInternals.setStoredResumableHandle?.(null);
1661
1580
  this.agentExecution = null;
1662
- this.clearArtifactState();
1581
+ this.#clearArtifactState();
1663
1582
  this.webMcpInflightKeys.clear();
1664
1583
  this.webMcpResolvedKeys.clear();
1665
1584
  this.client.resetClientToolsFingerprint();
1666
- this.acknowledgedProjections.clear();
1667
- this.setPendingProjections(null);
1585
+ this.#acknowledgedProjections.clear();
1586
+ this.#setPendingProjections(null);
1668
1587
  }
1669
1588
 
1670
1589
  /**
1671
1590
  * Irreversible local + persisted clear. Used by new-conversation, active
1672
1591
  * deletion, 410 recovery, reset, and the continuity wipe.
1673
1592
  */
1674
- private discardConversationState(): void {
1593
+ #discardConversationState(): void {
1675
1594
  this.stopSpeaking();
1676
1595
  this.abortController?.abort();
1677
1596
  this.abortController = null;
1678
- this.teardownReconnect();
1679
- this.abortWebMcpResolves();
1597
+ this.#teardownReconnect();
1598
+ this.#abortWebMcpResolves();
1599
+ this.#actionsEpoch++;
1680
1600
  this.messages = [];
1681
- this.resetConversationScopedState();
1682
- this.olderPageRequests.clear();
1601
+ this.#resetConversationScopedState();
1602
+ this.#olderPageRequests.clear();
1683
1603
  this.config.clearStoredSessionId?.();
1684
1604
  this.config.clearStoredConversationId?.();
1685
1605
  this.historyInternals.setStoredConversationRevision?.(null);
1686
1606
  this.historyInternals.setStoredMessageCursor?.(null);
1687
- this.setStreaming(false);
1688
- this.setStatus("idle");
1607
+ this.#setStreaming(false);
1608
+ this.#setStatus("idle");
1689
1609
  this.callbacks.onMessagesChanged([...this.messages]);
1690
1610
  }
1691
1611
 
@@ -1694,27 +1614,27 @@ export class AgentWidgetSession {
1694
1614
  * cross-tab convergence, or credential expiry selected a different record.
1695
1615
  * Invalidate synchronously; dispatch stays blocked until cleanup completes.
1696
1616
  */
1697
- private handleHistoryContinuityChanged(info: {
1617
+ #handleHistoryContinuityChanged(info: {
1698
1618
  previousConversationId: string | null;
1699
1619
  conversationId: string;
1700
1620
  }): 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(
1621
+ this.#historyOpenEpoch += 1;
1622
+ this.#projectionFinalizationPromise = null;
1623
+ this.#historySendBlocked = true;
1624
+ this.#discardConversationState();
1625
+ this.#activeConversationId = info.conversationId;
1626
+ this.#activeConversationRevision = null;
1627
+ this.#historyNextMessageCursor = null;
1628
+ this.#historyRecovery = null;
1629
+ this.#emitHistoryState();
1630
+ this.#notifyHistory(
1711
1631
  "history_continuity_reset",
1712
1632
  "This browser was reconnected to a different conversation, so the previous messages were cleared."
1713
1633
  );
1714
- this.historyGate = Promise.resolve().then(() => {
1715
- this.historySendBlocked = false;
1716
- this.historyGate = null;
1717
- this.emitHistoryState();
1634
+ this.#historyGate = Promise.resolve().then(() => {
1635
+ this.#historySendBlocked = false;
1636
+ this.#historyGate = null;
1637
+ this.#emitHistoryState();
1718
1638
  });
1719
1639
  }
1720
1640
 
@@ -1723,15 +1643,15 @@ export class AgentWidgetSession {
1723
1643
  * the store. Owned here so the transition sits beside the continuity wipe it
1724
1644
  * reuses.
1725
1645
  */
1726
- private syncVisitorStoreSubscription(): void {
1646
+ #syncVisitorStoreSubscription(): void {
1727
1647
  const store = this.historyInternals.visitorStore ?? null;
1728
- if (store === this.subscribedVisitorStore) return;
1729
- this.visitorStoreUnsubscribe?.();
1730
- this.visitorStoreUnsubscribe = null;
1731
- this.subscribedVisitorStore = store;
1648
+ if (store === this.#subscribedVisitorStore) return;
1649
+ this.#visitorStoreUnsubscribe?.();
1650
+ this.#visitorStoreUnsubscribe = null;
1651
+ this.#subscribedVisitorStore = store;
1732
1652
  if (!store) return;
1733
- this.visitorStoreUnsubscribe = store.subscribe((change) =>
1734
- this.handleExternalCredentialChange(change)
1653
+ this.#visitorStoreUnsubscribe = store.subscribe((change) =>
1654
+ this.#handleExternalCredentialChange(change)
1735
1655
  );
1736
1656
  }
1737
1657
 
@@ -1741,40 +1661,40 @@ export class AgentWidgetSession {
1741
1661
  * A "local" change is this tab's own mint/write and owns its init path; a
1742
1662
  * change out of an empty store is first-init convergence, not a break.
1743
1663
  */
1744
- private handleExternalCredentialChange(change: VisitorStoreChange): void {
1664
+ #handleExternalCredentialChange(change: VisitorStoreChange): void {
1745
1665
  if (change.source !== "external" || change.previousToken === null) return;
1746
1666
 
1747
1667
  // Nothing may reuse the session minted under the revoked credential.
1748
1668
  this.clientSession = null;
1749
1669
  this.client.handleExternalCredentialChange();
1750
- this.historyOpenEpoch += 1;
1751
- this.projectionFinalizationPromise = null;
1752
- this.credentialReinitPending = true;
1753
- this.credentialReinitPromise = null;
1670
+ this.#historyOpenEpoch += 1;
1671
+ this.#projectionFinalizationPromise = null;
1672
+ this.#credentialReinitPending = true;
1673
+ this.#credentialReinitPromise = null;
1754
1674
 
1755
1675
  const hadVisibleTranscript = this.messages.length > 0;
1756
1676
  const hadConversationState =
1757
1677
  hadVisibleTranscript ||
1758
- this.activeConversationId !== null ||
1678
+ this.#activeConversationId !== null ||
1759
1679
  (this.config.getStoredConversationId?.() ?? null) !== null;
1760
1680
  if (hadConversationState) {
1761
- this.discardConversationState();
1681
+ this.#discardConversationState();
1762
1682
  } else {
1763
1683
  // No conversation to wipe, but the revoked visitor's ids must not survive.
1764
1684
  this.config.clearStoredSessionId?.();
1765
1685
  this.config.clearStoredConversationId?.();
1766
- this.olderPageRequests.clear();
1767
- this.setPendingProjections(null);
1686
+ this.#olderPageRequests.clear();
1687
+ this.#setPendingProjections(null);
1768
1688
  }
1769
- this.activeConversationId = null;
1770
- this.activeConversationRevision = null;
1771
- this.historyNextMessageCursor = null;
1772
- this.historyRecovery = null;
1773
- this.emitHistoryState();
1689
+ this.#activeConversationId = null;
1690
+ this.#activeConversationRevision = null;
1691
+ this.#historyNextMessageCursor = null;
1692
+ this.#historyRecovery = null;
1693
+ this.#emitHistoryState();
1774
1694
  // Only a visible wipe announces: a silent reconcile must not push the open
1775
1695
  // history view into a refetch that would immediately re-mint a credential.
1776
1696
  if (hadVisibleTranscript) {
1777
- this.notifyHistory(
1697
+ this.#notifyHistory(
1778
1698
  "history_continuity_reset",
1779
1699
  "This browser's conversation history was reset in another tab, so the previous messages were cleared."
1780
1700
  );
@@ -1786,26 +1706,26 @@ export class AgentWidgetSession {
1786
1706
  * Deliberately lazy: re-initializing on the storage event itself would hand
1787
1707
  * an idle tab a fresh credential the visitor just deleted elsewhere.
1788
1708
  */
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;
1709
+ #completeCredentialReinit(): Promise<void> {
1710
+ if (!this.#credentialReinitPending) return Promise.resolve();
1711
+ if (!this.#credentialReinitPromise) {
1712
+ this.#credentialReinitPromise = this.#runCredentialReinit().finally(() => {
1713
+ this.#credentialReinitPromise = null;
1794
1714
  });
1795
1715
  }
1796
- return this.credentialReinitPromise;
1716
+ return this.#credentialReinitPromise;
1797
1717
  }
1798
1718
 
1799
- private async runCredentialReinit(): Promise<void> {
1719
+ async #runCredentialReinit(): Promise<void> {
1800
1720
  if (!this.isClientTokenMode()) {
1801
- this.credentialReinitPending = false;
1721
+ this.#credentialReinitPending = false;
1802
1722
  return;
1803
1723
  }
1804
1724
  try {
1805
1725
  // Reads the store again: a clear mints fresh, a replacement adopts the
1806
1726
  // sibling's token.
1807
1727
  this.bindActivatedSession(await this.client.initSession());
1808
- this.credentialReinitPending = false;
1728
+ this.#credentialReinitPending = false;
1809
1729
  } catch {
1810
1730
  // Still pending: the dispatch's own init reports the failure normally.
1811
1731
  }
@@ -1820,53 +1740,53 @@ export class AgentWidgetSession {
1820
1740
  public reconcileBootConversation(opts?: {
1821
1741
  scope?: HistoryScope;
1822
1742
  }): Promise<void> {
1823
- const provider = this.historyProvider;
1743
+ const provider = this.#historyProvider;
1824
1744
  const conversationId =
1825
1745
  this.clientSession?.conversationId ??
1826
1746
  this.config.getStoredConversationId?.() ??
1827
- this.activeConversationId;
1747
+ this.#activeConversationId;
1828
1748
  if (!provider || !conversationId) return Promise.resolve();
1829
1749
 
1830
- const context = this.historyContext(opts?.scope);
1831
- this.historySendBlocked = true;
1832
- this.emitHistoryState();
1833
- const run = this.runBootReconciliation(
1750
+ const context = this.#historyContext(opts?.scope);
1751
+ this.#historySendBlocked = true;
1752
+ this.#emitHistoryState();
1753
+ const run = this.#runBootReconciliation(
1834
1754
  provider,
1835
1755
  conversationId,
1836
1756
  context
1837
1757
  ).finally(() => {
1838
- this.historySendBlocked = this.historyRecovery !== null;
1839
- if (this.historyGate === run) this.historyGate = null;
1840
- this.emitHistoryState();
1758
+ this.#historySendBlocked = this.#historyRecovery !== null;
1759
+ if (this.#historyGate === run) this.#historyGate = null;
1760
+ this.#emitHistoryState();
1841
1761
  });
1842
- this.historyGate = run;
1762
+ this.#historyGate = run;
1843
1763
  return run;
1844
1764
  }
1845
1765
 
1846
- private async runBootReconciliation(
1766
+ async #runBootReconciliation(
1847
1767
  provider: HistoryProvider,
1848
1768
  conversationId: string,
1849
1769
  context: HistoryOperationContext
1850
1770
  ): Promise<void> {
1851
- this.activeConversationId = conversationId;
1771
+ this.#activeConversationId = conversationId;
1852
1772
  const initRevision =
1853
1773
  this.clientSession?.conversationRevision ??
1854
- this.activeConversationRevision ??
1774
+ this.#activeConversationRevision ??
1855
1775
  null;
1856
- const persistedRevision = this.bootRevisionCaptured
1857
- ? this.bootConversationRevision
1776
+ const persistedRevision = this.#bootRevisionCaptured
1777
+ ? this.#bootConversationRevision
1858
1778
  : (this.historyInternals.getStoredConversationRevision?.() ?? null);
1859
- this.bootRevisionCaptured = false;
1860
- this.historyNextMessageCursor =
1779
+ this.#bootRevisionCaptured = false;
1780
+ this.#historyNextMessageCursor =
1861
1781
  this.historyInternals.getStoredMessageCursor?.() ??
1862
- this.historyNextMessageCursor;
1782
+ this.#historyNextMessageCursor;
1863
1783
 
1864
1784
  // Any locally finalized projection still owed to the server goes first, so
1865
1785
  // display reconciliation cannot complete against a stale server record.
1866
1786
  await this.replayPendingProjections();
1867
1787
 
1868
1788
  if (initRevision && persistedRevision && initRevision === persistedRevision) {
1869
- this.activeConversationRevision = initRevision;
1789
+ this.#activeConversationRevision = initRevision;
1870
1790
  return;
1871
1791
  }
1872
1792
 
@@ -1878,9 +1798,9 @@ export class AgentWidgetSession {
1878
1798
  let reconciled: AgentWidgetMessage[];
1879
1799
  if (!overlaps) {
1880
1800
  // No shared id: the local transcript is not this record's newest window.
1881
- reconciled = page.messages.map((message) => this.ensureSequence(message));
1801
+ reconciled = page.messages.map((message) => this.#ensureSequence(message));
1882
1802
  } else {
1883
- const durableId = this.resumable ? this.activeAssistantMessageId : null;
1803
+ const durableId = this.#resumable ? this.#activeAssistantMessageId : null;
1884
1804
  const survivors = this.messages.filter((message) => {
1885
1805
  if (serverIds.has(message.id)) return true;
1886
1806
  if (message.id === durableId) return true;
@@ -1888,53 +1808,53 @@ export class AgentWidgetSession {
1888
1808
  return pageOldest !== undefined && message.createdAt < pageOldest;
1889
1809
  });
1890
1810
  reconciled = mergeWireMessagesById(survivors, page.messages).map(
1891
- (message) => this.ensureSequence(message)
1811
+ (message) => this.#ensureSequence(message)
1892
1812
  );
1893
1813
  }
1894
1814
 
1895
- this.historyNextMessageCursor = page.nextCursor;
1896
- this.activeConversationRevision =
1815
+ this.#historyNextMessageCursor = page.nextCursor;
1816
+ this.#activeConversationRevision =
1897
1817
  page.conversationRevision || initRevision || null;
1898
- this.acknowledgedProjections.clear();
1818
+ this.#acknowledgedProjections.clear();
1899
1819
  for (const message of page.messages) {
1900
- this.acknowledgedProjections.set(message.id, message.content);
1820
+ this.#acknowledgedProjections.set(message.id, message.content);
1901
1821
  }
1902
1822
  // Metadata first, transcript last: one persist covers both.
1903
- this.persistConversationMetadata();
1904
- this.messages = this.sortMessages(reconciled);
1823
+ this.#persistConversationMetadata();
1824
+ this.messages = this.#sortMessages(reconciled);
1905
1825
  this.callbacks.onMessagesChanged([...this.messages]);
1906
1826
  }
1907
1827
 
1908
1828
  // ── Display-projection finalization ────────────────────────────
1909
1829
 
1910
1830
  /** Runtype transport op: active session + browser scope, never a proof. */
1911
- private projectionFinalizationEnabled(): boolean {
1831
+ #projectionFinalizationEnabled(): boolean {
1912
1832
  return (
1913
1833
  this.config.features?.history?.enabled === true &&
1914
1834
  this.isClientTokenMode()
1915
1835
  );
1916
1836
  }
1917
1837
 
1918
- private setPendingProjections(pending: PendingDisplayProjections | null): void {
1919
- this.pendingProjections = pending;
1838
+ #setPendingProjections(pending: PendingDisplayProjections | null): void {
1839
+ this.#pendingProjections = pending;
1920
1840
  // Memory-only when the shell supplies no persistence seam.
1921
1841
  this.historyInternals.setPendingDisplayProjections?.(pending);
1922
1842
  }
1923
1843
 
1924
- private readPendingProjections(): PendingDisplayProjections | null {
1844
+ #readPendingProjections(): PendingDisplayProjections | null {
1925
1845
  return (
1926
1846
  this.historyInternals.getPendingDisplayProjections?.() ??
1927
- this.pendingProjections
1847
+ this.#pendingProjections
1928
1848
  );
1929
1849
  }
1930
1850
 
1931
1851
  /** Record what the dispatch payload carried as each message's projection. */
1932
- private recordDispatchedProjections(messages: AgentWidgetMessage[]): void {
1933
- if (!this.projectionFinalizationEnabled()) return;
1852
+ #recordDispatchedProjections(messages: AgentWidgetMessage[]): void {
1853
+ if (!this.#projectionFinalizationEnabled()) return;
1934
1854
  for (const message of messages) {
1935
1855
  const projection = divergentDisplayProjection(message);
1936
1856
  if (projection !== undefined) {
1937
- this.acknowledgedProjections.set(message.id, projection);
1857
+ this.#acknowledgedProjections.set(message.id, projection);
1938
1858
  }
1939
1859
  }
1940
1860
  }
@@ -1943,57 +1863,57 @@ export class AgentWidgetSession {
1943
1863
  * After a terminal assistant message, finalize any browser-derived projection
1944
1864
  * the server cannot have. Painting is never delayed by this.
1945
1865
  */
1946
- private scheduleProjectionFinalization(): void {
1947
- if (!this.projectionFinalizationEnabled()) return;
1948
- const conversationId = this.activeConversationId;
1866
+ #scheduleProjectionFinalization(): void {
1867
+ if (!this.#projectionFinalizationEnabled()) return;
1868
+ const conversationId = this.#activeConversationId;
1949
1869
  if (!conversationId) return;
1950
1870
  const batch: HistoryDisplayProjection[] = [];
1951
1871
  for (const message of this.messages) {
1952
1872
  if (message.role !== "assistant" || message.streaming) continue;
1953
1873
  const projection = divergentDisplayProjection(message);
1954
1874
  if (projection === undefined) continue;
1955
- if (this.acknowledgedProjections.get(message.id) === projection) continue;
1875
+ if (this.#acknowledgedProjections.get(message.id) === projection) continue;
1956
1876
  batch.push({ id: message.id, displayContent: projection });
1957
1877
  }
1958
1878
  if (batch.length === 0) return;
1959
- this.setPendingProjections({
1879
+ this.#setPendingProjections({
1960
1880
  conversationId,
1961
1881
  messageIds: batch.map((item) => item.id),
1962
1882
  });
1963
- this.enqueueProjectionFinalization(conversationId, batch);
1883
+ this.#enqueueProjectionFinalization(conversationId, batch);
1964
1884
  }
1965
1885
 
1966
1886
  /** Serialized: operations await this chain instead of racing it. */
1967
- private enqueueProjectionFinalization(
1887
+ #enqueueProjectionFinalization(
1968
1888
  conversationId: string,
1969
1889
  batch: HistoryDisplayProjection[]
1970
1890
  ): void {
1971
- const previous = this.projectionFinalizationPromise ?? Promise.resolve();
1891
+ const previous = this.#projectionFinalizationPromise ?? Promise.resolve();
1972
1892
  const chain = previous
1973
1893
  .catch(() => {})
1974
- .then(() => this.finalizeProjections(conversationId, batch, false))
1894
+ .then(() => this.#finalizeProjections(conversationId, batch, false))
1975
1895
  .finally(() => {
1976
- if (this.projectionFinalizationPromise === chain) {
1977
- this.projectionFinalizationPromise = null;
1896
+ if (this.#projectionFinalizationPromise === chain) {
1897
+ this.#projectionFinalizationPromise = null;
1978
1898
  }
1979
1899
  });
1980
- this.projectionFinalizationPromise = chain;
1900
+ this.#projectionFinalizationPromise = chain;
1981
1901
  }
1982
1902
 
1983
- private async finalizeProjections(
1903
+ async #finalizeProjections(
1984
1904
  conversationId: string,
1985
1905
  batch: HistoryDisplayProjection[],
1986
1906
  retried: boolean
1987
1907
  ): Promise<void> {
1988
1908
  // A switch/reset/delete already invalidated this marker.
1989
- if (this.activeConversationId !== conversationId) return;
1909
+ if (this.#activeConversationId !== conversationId) return;
1990
1910
  // Two terminal scans can enqueue the same batch before the first PATCH
1991
1911
  // acknowledges; the chain serializes them, so re-filter at run time.
1992
1912
  const unacknowledged = batch.filter(
1993
- (item) => this.acknowledgedProjections.get(item.id) !== item.displayContent
1913
+ (item) => this.#acknowledgedProjections.get(item.id) !== item.displayContent
1994
1914
  );
1995
1915
  if (unacknowledged.length === 0) {
1996
- this.clearFinalizedMarker(conversationId, batch);
1916
+ this.#clearFinalizedMarker(conversationId, batch);
1997
1917
  return;
1998
1918
  }
1999
1919
  try {
@@ -2002,15 +1922,15 @@ export class AgentWidgetSession {
2002
1922
  unacknowledged
2003
1923
  );
2004
1924
  for (const item of unacknowledged) {
2005
- this.acknowledgedProjections.set(item.id, item.displayContent);
1925
+ this.#acknowledgedProjections.set(item.id, item.displayContent);
2006
1926
  }
2007
- if (this.activeConversationId === conversationId && conversationRevision) {
2008
- this.activeConversationRevision = conversationRevision;
1927
+ if (this.#activeConversationId === conversationId && conversationRevision) {
1928
+ this.#activeConversationRevision = conversationRevision;
2009
1929
  }
2010
- this.clearFinalizedMarker(conversationId, batch);
1930
+ this.#clearFinalizedMarker(conversationId, batch);
2011
1931
  } catch (error) {
2012
- if (!retried && this.isTransientProjectionError(error)) {
2013
- await this.finalizeProjections(conversationId, unacknowledged, true);
1932
+ if (!retried && this.#isTransientProjectionError(error)) {
1933
+ await this.#finalizeProjections(conversationId, unacknowledged, true);
2014
1934
  return;
2015
1935
  }
2016
1936
  if (
@@ -2018,11 +1938,11 @@ export class AgentWidgetSession {
2018
1938
  (error.code === "not_found" || error.code === "conversation_deleted")
2019
1939
  ) {
2020
1940
  // The record is gone: drop the projection instead of retrying forever.
2021
- this.clearFinalizedMarker(conversationId, batch);
1941
+ this.#clearFinalizedMarker(conversationId, batch);
2022
1942
  return;
2023
1943
  }
2024
1944
  // Marker retained for the next boot/dispatch; the turn itself succeeded.
2025
- this.notifyHistory(
1945
+ this.#notifyHistory(
2026
1946
  "projection_finalization_failed",
2027
1947
  "Some message formatting could not be saved for history; it will be retried."
2028
1948
  );
@@ -2030,20 +1950,20 @@ export class AgentWidgetSession {
2030
1950
  }
2031
1951
 
2032
1952
  /** Transport-shaped failures only; a contract failure is not retried. */
2033
- private isTransientProjectionError(error: unknown): boolean {
1953
+ #isTransientProjectionError(error: unknown): boolean {
2034
1954
  if (error instanceof HistoryClientError) return error.code === "request_failed";
2035
1955
  return error instanceof Error;
2036
1956
  }
2037
1957
 
2038
- private clearFinalizedMarker(
1958
+ #clearFinalizedMarker(
2039
1959
  conversationId: string,
2040
1960
  batch: HistoryDisplayProjection[]
2041
1961
  ): void {
2042
- const pending = this.readPendingProjections();
1962
+ const pending = this.#readPendingProjections();
2043
1963
  if (!pending || pending.conversationId !== conversationId) return;
2044
1964
  const done = new Set(batch.map((item) => item.id));
2045
1965
  const remaining = pending.messageIds.filter((id) => !done.has(id));
2046
- this.setPendingProjections(
1966
+ this.#setPendingProjections(
2047
1967
  remaining.length ? { conversationId, messageIds: remaining } : null
2048
1968
  );
2049
1969
  }
@@ -2053,15 +1973,15 @@ export class AgentWidgetSession {
2053
1973
  * message content, then finalize before display reconciliation completes.
2054
1974
  */
2055
1975
  public async replayPendingProjections(): Promise<void> {
2056
- if (!this.projectionFinalizationEnabled()) return;
2057
- const pending = this.readPendingProjections();
1976
+ if (!this.#projectionFinalizationEnabled()) return;
1977
+ const pending = this.#readPendingProjections();
2058
1978
  if (!pending) return;
2059
1979
  if (
2060
- this.activeConversationId &&
2061
- pending.conversationId !== this.activeConversationId
1980
+ this.#activeConversationId &&
1981
+ pending.conversationId !== this.#activeConversationId
2062
1982
  ) {
2063
1983
  // Stale marker from another record: never replay it into this one.
2064
- this.setPendingProjections(null);
1984
+ this.#setPendingProjections(null);
2065
1985
  return;
2066
1986
  }
2067
1987
  const batch: HistoryDisplayProjection[] = [];
@@ -2071,33 +1991,33 @@ export class AgentWidgetSession {
2071
1991
  batch.push({ id, displayContent: message.content });
2072
1992
  }
2073
1993
  if (batch.length === 0) {
2074
- this.setPendingProjections(null);
1994
+ this.#setPendingProjections(null);
2075
1995
  return;
2076
1996
  }
2077
- this.enqueueProjectionFinalization(pending.conversationId, batch);
2078
- await this.projectionFinalizationPromise?.catch(() => {});
1997
+ this.#enqueueProjectionFinalization(pending.conversationId, batch);
1998
+ await this.#projectionFinalizationPromise?.catch(() => {});
2079
1999
  }
2080
2000
 
2081
2001
  /**
2082
2002
  * A local transcript mutation whose response carries no new revision: clear
2083
2003
  * the persisted one rather than guessing, so the next boot refreshes safely.
2084
2004
  */
2085
- private invalidateConversationRevision(): void {
2086
- if (!this.activeConversationId) return;
2087
- this.activeConversationRevision = null;
2005
+ #invalidateConversationRevision(): void {
2006
+ if (!this.#activeConversationId) return;
2007
+ this.#activeConversationRevision = null;
2088
2008
  this.historyInternals.setStoredConversationRevision?.(null);
2089
2009
  }
2090
2010
 
2091
2011
  /** Blocks a send until the owning history transition finishes. */
2092
- private async awaitHistorySendable(): Promise<void> {
2012
+ async #awaitHistorySendable(): Promise<void> {
2093
2013
  await this.awaitHistorySettled();
2094
- await this.completeCredentialReinit();
2095
- if (this.historyRecovery === "new_conversation_required") {
2096
- await this.retryRequiredConversation();
2014
+ await this.#completeCredentialReinit();
2015
+ if (this.#historyRecovery === "new_conversation_required") {
2016
+ await this.#retryRequiredConversation();
2097
2017
  }
2098
2018
  }
2099
2019
 
2100
- private static isConversationDeleted(error: unknown): boolean {
2020
+ static #isConversationDeleted(error: unknown): boolean {
2101
2021
  return (
2102
2022
  error instanceof HistoryClientError &&
2103
2023
  error.code === "conversation_deleted"
@@ -2108,14 +2028,14 @@ export class AgentWidgetSession {
2108
2028
  * Safe 410 recovery (D5). The old payload would recreate the transcript that
2109
2029
  * was just deleted, so the retry carries ONLY the just-submitted user turn.
2110
2030
  */
2111
- private async dispatchWithDeletedRecovery(
2031
+ async #dispatchWithDeletedRecovery(
2112
2032
  snapshot: AgentWidgetMessage[],
2113
2033
  controller: AbortController,
2114
2034
  assistantMessageId: string,
2115
2035
  userMessageId: string,
2116
2036
  turnOptions?: { composerOptions?: ComposerOptionsPayload; interrupt?: boolean }
2117
2037
  ): Promise<void> {
2118
- this.recordDispatchedProjections(snapshot);
2038
+ this.#recordDispatchedProjections(snapshot);
2119
2039
  try {
2120
2040
  await this.client.dispatch(
2121
2041
  {
@@ -2129,12 +2049,12 @@ export class AgentWidgetSession {
2129
2049
  );
2130
2050
  return;
2131
2051
  } catch (error) {
2132
- if (!AgentWidgetSession.isConversationDeleted(error)) throw error;
2133
- const replacement = await this.recoverFromDeletedConversation(userMessageId);
2052
+ if (!AgentWidgetSession.#isConversationDeleted(error)) throw error;
2053
+ const replacement = await this.#recoverFromDeletedConversation(userMessageId);
2134
2054
  // No recoverable turn (or no fresh record): surface the original 410.
2135
2055
  if (!replacement) throw error;
2136
2056
  // A second 410 propagates: exactly one recovery attempt per turn.
2137
- this.recordDispatchedProjections([replacement]);
2057
+ this.#recordDispatchedProjections([replacement]);
2138
2058
  await this.client.dispatch(
2139
2059
  {
2140
2060
  messages: [replacement],
@@ -2151,7 +2071,7 @@ export class AgentWidgetSession {
2151
2071
  * Capture only the newly submitted user message, destroy the deleted record's
2152
2072
  * local state, and initialize a fresh owned conversation to retry into.
2153
2073
  */
2154
- private async recoverFromDeletedConversation(
2074
+ async #recoverFromDeletedConversation(
2155
2075
  userMessageId: string
2156
2076
  ): Promise<AgentWidgetMessage | null> {
2157
2077
  const submitted = this.messages.find(
@@ -2160,44 +2080,44 @@ export class AgentWidgetSession {
2160
2080
  if (!submitted) return null;
2161
2081
  const captured: AgentWidgetMessage = { ...submitted };
2162
2082
 
2163
- this.historyOpenEpoch += 1;
2164
- this.projectionFinalizationPromise = null;
2165
- this.discardConversationState();
2166
- this.activeConversationId = null;
2167
- this.activeConversationRevision = null;
2168
- this.historyNextMessageCursor = null;
2083
+ this.#historyOpenEpoch += 1;
2084
+ this.#projectionFinalizationPromise = null;
2085
+ this.#discardConversationState();
2086
+ this.#activeConversationId = null;
2087
+ this.#activeConversationRevision = null;
2088
+ this.#historyNextMessageCursor = null;
2169
2089
 
2170
2090
  // The recovered turn is the whole transcript: no welcome bubble in front.
2171
- this.suppressWelcomeInjection = true;
2091
+ this.#suppressWelcomeInjection = true;
2172
2092
  try {
2173
- const provider = this.historyProvider;
2093
+ const provider = this.#historyProvider;
2174
2094
  if (provider) {
2175
2095
  const prepared = await provider.prepareStartNew({
2176
- context: this.historyContext(),
2096
+ context: this.#historyContext(),
2177
2097
  });
2178
- await this.installFreshConversation(prepared);
2098
+ await this.#installFreshConversation(prepared);
2179
2099
  } else {
2180
2100
  this.client.clearClientSession();
2181
2101
  const session = await this.client.initSession();
2182
2102
  this.bindActivatedSession(session);
2183
2103
  }
2184
2104
  } catch {
2185
- this.historySendBlocked = true;
2186
- this.historyRecovery = "new_conversation_required";
2187
- this.emitHistoryState();
2188
- this.notifyHistory(
2105
+ this.#historySendBlocked = true;
2106
+ this.#historyRecovery = "new_conversation_required";
2107
+ this.#emitHistoryState();
2108
+ this.#notifyHistory(
2189
2109
  "new_conversation_required",
2190
2110
  "That conversation was deleted. Start a new conversation to continue."
2191
2111
  );
2192
2112
  return null;
2193
2113
  } finally {
2194
- this.suppressWelcomeInjection = false;
2114
+ this.#suppressWelcomeInjection = false;
2195
2115
  }
2196
2116
 
2197
2117
  // Restore only the user's own turn: it is the entire new transcript.
2198
- this.appendMessage(captured);
2199
- this.setStreaming(true);
2200
- this.notifyHistory(
2118
+ this.#appendMessage(captured);
2119
+ this.#setStreaming(true);
2120
+ this.#notifyHistory(
2201
2121
  "conversation_deleted_recovered",
2202
2122
  "That conversation was deleted. Started a new conversation."
2203
2123
  );
@@ -2229,7 +2149,7 @@ export class AgentWidgetSession {
2229
2149
  // turn that's restyling the widget and strand the paused execution.
2230
2150
  if (!replaceClient) {
2231
2151
  this.client.updateConfig(merged);
2232
- if (artifactDisplayChanged) this.refreshArtifactReferenceBlocks();
2152
+ if (artifactDisplayChanged) this.#refreshArtifactReferenceBlocks();
2233
2153
  return;
2234
2154
  }
2235
2155
 
@@ -2240,13 +2160,13 @@ export class AgentWidgetSession {
2240
2160
  // fresh client: in client-token mode that would POST /resume without a
2241
2161
  // valid sessionId and strand the paused turn. Mirrors clearMessages' WebMCP
2242
2162
  // reset; the client swap already abandons any in-flight stream regardless.
2243
- this.abortWebMcpResolves();
2163
+ this.#abortWebMcpResolves();
2244
2164
  this.webMcpInflightKeys.clear();
2245
2165
  this.webMcpResolvedKeys.clear();
2246
2166
  const prevSSECallback = this.client.getSSEEventCallback();
2247
2167
  this.client = new AgentWidgetClient(this.config, this.historyInternals);
2248
- if (artifactDisplayChanged) this.refreshArtifactReferenceBlocks();
2249
- this.wireDefaultWebMcpConfirm();
2168
+ if (artifactDisplayChanged) this.#refreshArtifactReferenceBlocks();
2169
+ this.#wireDefaultWebMcpConfirm();
2250
2170
  if (prevSSECallback) {
2251
2171
  this.client.setSSEEventCallback(prevSSECallback);
2252
2172
  }
@@ -2307,6 +2227,7 @@ export class AgentWidgetSession {
2307
2227
  sequence,
2308
2228
  streaming = false,
2309
2229
  voiceProcessing,
2230
+ voiceCaption,
2310
2231
  rawContent
2311
2232
  } = options;
2312
2233
 
@@ -2324,12 +2245,13 @@ export class AgentWidgetSession {
2324
2245
  role,
2325
2246
  content,
2326
2247
  createdAt: createdAt ?? new Date().toISOString(),
2327
- sequence: sequence ?? this.nextSequence(),
2248
+ sequence: sequence ?? this.#nextSequence(),
2328
2249
  streaming,
2329
2250
  // Only include optional fields if provided
2330
2251
  ...(llmContent !== undefined && { llmContent }),
2331
2252
  ...(contentParts !== undefined && { contentParts }),
2332
2253
  ...(voiceProcessing !== undefined && { voiceProcessing }),
2254
+ ...(voiceCaption && { voiceCaption }),
2333
2255
  ...(rawContent !== undefined && { rawContent })
2334
2256
  };
2335
2257
 
@@ -2427,7 +2349,7 @@ export class AgentWidgetSession {
2427
2349
  role,
2428
2350
  content,
2429
2351
  createdAt: createdAt ?? new Date().toISOString(),
2430
- sequence: sequence ?? this.nextSequence(),
2352
+ sequence: sequence ?? this.#nextSequence(),
2431
2353
  streaming,
2432
2354
  ...(llmContent !== undefined && { llmContent }),
2433
2355
  ...(contentParts !== undefined && { contentParts }),
@@ -2439,7 +2361,7 @@ export class AgentWidgetSession {
2439
2361
  }
2440
2362
 
2441
2363
  // Add all messages, sort once, notify once
2442
- this.messages = this.sortMessages([...this.messages, ...results]);
2364
+ this.messages = this.#sortMessages([...this.messages, ...results]);
2443
2365
  this.callbacks.onMessagesChanged([...this.messages]);
2444
2366
 
2445
2367
  return results;
@@ -2500,7 +2422,7 @@ export class AgentWidgetSession {
2500
2422
  * client to namespace into the request `context`. Failures were already
2501
2423
  * dropped by `finalize()`.
2502
2424
  */
2503
- private async applyMentionBundle(
2425
+ async #applyMentionBundle(
2504
2426
  userMessage: AgentWidgetMessage,
2505
2427
  typedText: string,
2506
2428
  finalize: () => Promise<MentionSubmitBundle>
@@ -2598,6 +2520,13 @@ export class AgentWidgetSession {
2598
2520
  * (`composer.streamingSubmitBehavior: "interrupt"`).
2599
2521
  */
2600
2522
  interrupt?: boolean;
2523
+ /**
2524
+ * Internal (voice client delegation): this is the delegated voice turn.
2525
+ * `userMessageId` names an existing transcript bubble to submit as the
2526
+ * turn's user message instead of appending a new one, so the spoken
2527
+ * request appears (and is sent) exactly once.
2528
+ */
2529
+ voiceTurn?: { userMessageId?: string };
2601
2530
  }
2602
2531
  ) {
2603
2532
  const input = rawInput.trim();
@@ -2617,16 +2546,23 @@ export class AgentWidgetSession {
2617
2546
  )
2618
2547
  return;
2619
2548
 
2549
+ // Any other send replaces a delegated voice turn still in flight: that
2550
+ // turn fails, and this one's reply is neither its answer nor voice-spoken.
2551
+ if (this.#voiceDelegation && !options?.voiceTurn) {
2552
+ this.#voiceDelegation.failed = true;
2553
+ this.#voiceDelegation = null;
2554
+ }
2555
+
2620
2556
  // History transitions (continuity wipe, boot reconciliation, projection
2621
2557
  // finalization, replacement init, external credential change) own the
2622
2558
  // record this turn would land in.
2623
2559
  if (
2624
- this.historyGate ||
2625
- this.projectionFinalizationPromise ||
2626
- this.historySendBlocked ||
2627
- this.credentialReinitPending
2560
+ this.#historyGate ||
2561
+ this.#projectionFinalizationPromise ||
2562
+ this.#historySendBlocked ||
2563
+ this.#credentialReinitPending
2628
2564
  ) {
2629
- await this.awaitHistorySendable();
2565
+ await this.#awaitHistorySendable();
2630
2566
  }
2631
2567
 
2632
2568
  this.stopSpeaking();
@@ -2635,18 +2571,23 @@ export class AgentWidgetSession {
2635
2571
  // turn. Tear them down here (they own controllers separate from the shared
2636
2572
  // one) so a lingering resolve can't race the new dispatch or post a stale
2637
2573
  // /resume against a superseded execution.
2638
- this.abortWebMcpResolves();
2574
+ this.#abortWebMcpResolves();
2575
+ this.#actionsEpoch++;
2639
2576
  // A new turn also supersedes any pending durable reconnect from the prior
2640
2577
  // turn (cancels backoff/listeners, clears the old resume handle).
2641
- this.teardownReconnect();
2578
+ this.#teardownReconnect();
2642
2579
 
2580
+ const voiceBubbleId = options?.voiceTurn?.userMessageId;
2581
+ const voiceBubble = voiceBubbleId
2582
+ ? this.messages.find((m) => m.id === voiceBubbleId && m.role === "user")
2583
+ : undefined;
2643
2584
  // Generate IDs for both user message and expected assistant response
2644
- const userMessageId = generateUserMessageId();
2585
+ const userMessageId = voiceBubble?.id ?? generateUserMessageId();
2645
2586
  const assistantMessageId = generateAssistantMessageId();
2646
2587
  // The active assistant bubble for a durable reconnect is captured from the
2647
2588
  // real streamed message events (see handleEvent), not pre-assigned here:
2648
2589
  // the proxy path auto-generates a different id than `assistantMessageId`.
2649
- this.activeAssistantMessageId = null;
2590
+ this.#activeAssistantMessageId = null;
2650
2591
 
2651
2592
  // Fallback display text ONLY when the sole content is image attachments.
2652
2593
  // A mention/command-only submit (empty text + a chip) must NOT read as
@@ -2661,7 +2602,7 @@ export class AgentWidgetSession {
2661
2602
  role: "user",
2662
2603
  content: input || imageOnlyFallback, // Display text (fallback if only images)
2663
2604
  createdAt: new Date().toISOString(),
2664
- sequence: this.nextSequence(),
2605
+ sequence: this.#nextSequence(),
2665
2606
  viaVoice: options?.viaVoice || false,
2666
2607
  // Include contentParts if provided (for multi-modal messages)
2667
2608
  ...(options?.contentParts && options.contentParts.length > 0 && {
@@ -2681,8 +2622,17 @@ export class AgentWidgetSession {
2681
2622
  ...(options?.replayFields ?? {})
2682
2623
  };
2683
2624
 
2684
- this.appendMessage(userMessage);
2685
- this.setStreaming(true);
2625
+ if (voiceBubble) {
2626
+ // The transcript bubble is the user message: the model gets the request
2627
+ // text the voice model delegated, the bubble keeps what was heard.
2628
+ voiceBubble.viaVoice = true;
2629
+ delete voiceBubble.voiceCaption;
2630
+ if (voiceBubble.content.trim() !== input) voiceBubble.llmContent = input;
2631
+ this.callbacks.onMessagesChanged([...this.messages]);
2632
+ } else {
2633
+ this.#appendMessage(userMessage);
2634
+ }
2635
+ this.#setStreaming(true);
2686
2636
 
2687
2637
  // Assign the fresh controller BEFORE the mention await so cancel() (or a
2688
2638
  // superseding sendMessage) during finalize() aborts THIS turn, not a stale
@@ -2699,7 +2649,7 @@ export class AgentWidgetSession {
2699
2649
  if (options?.mentions) {
2700
2650
  const stored =
2701
2651
  this.messages.find((m) => m.id === userMessageId) ?? userMessage;
2702
- await this.applyMentionBundle(stored, input, options.mentions.finalize);
2652
+ await this.#applyMentionBundle(stored, input, options.mentions.finalize);
2703
2653
  // A cancel() or new sendMessage during finalize aborted this controller
2704
2654
  // (and replaced/nulled the shared ref). Bail without dispatching and
2705
2655
  // leave whatever idle/streaming state that caller already set.
@@ -2729,10 +2679,17 @@ export class AgentWidgetSession {
2729
2679
  }
2730
2680
  }
2731
2681
 
2732
- const snapshot = [...this.messages];
2682
+ // A submitted voice bubble may sit above captions or a turn that finished
2683
+ // while it waited: the request still ends on it.
2684
+ const snapshot = voiceBubble
2685
+ ? [
2686
+ ...this.messages.filter((m) => m !== voiceBubble),
2687
+ { ...voiceBubble, createdAt: new Date().toISOString() }
2688
+ ]
2689
+ : [...this.messages];
2733
2690
 
2734
2691
  try {
2735
- await this.dispatchWithDeletedRecovery(
2692
+ await this.#dispatchWithDeletedRecovery(
2736
2693
  snapshot,
2737
2694
  controller,
2738
2695
  assistantMessageId,
@@ -2747,7 +2704,8 @@ export class AgentWidgetSession {
2747
2704
  // first, which already flipped us into `resuming` and armed reconnect.
2748
2705
  // The subsequent dispatch rejection must NOT paint a dispatch-error
2749
2706
  // bubble: the turn is being resumed, not failed.
2750
- if (this.status === "resuming" || this.reconnecting) return;
2707
+ if (this.status === "resuming" || this.#reconnecting) return;
2708
+ if (this.#voiceDelegation) this.#voiceDelegation.failed = true;
2751
2709
  // Check if this is an abort error (user canceled, navigated away, etc.)
2752
2710
  // In these cases, don't show fallback - the request was intentionally interrupted
2753
2711
  const isAbortError =
@@ -2769,15 +2727,15 @@ export class AgentWidgetSession {
2769
2727
  role: "assistant",
2770
2728
  createdAt: new Date().toISOString(),
2771
2729
  content,
2772
- sequence: this.nextSequence()
2730
+ sequence: this.#nextSequence()
2773
2731
  };
2774
2732
 
2775
- this.appendMessage(fallback);
2733
+ this.#appendMessage(fallback);
2776
2734
  }
2777
2735
  }
2778
2736
 
2779
- this.setStatus("idle");
2780
- this.setStreaming(false);
2737
+ this.#setStatus("idle");
2738
+ this.#setStreaming(false);
2781
2739
  this.abortController = null;
2782
2740
 
2783
2741
  if (!isAbortError) {
@@ -2845,7 +2803,7 @@ export class AgentWidgetSession {
2845
2803
  };
2846
2804
 
2847
2805
  const composerOptions = replacement
2848
- ? this.toComposerOptionsPayload(replacement.options)
2806
+ ? this.#toComposerOptionsPayload(replacement.options)
2849
2807
  : stored.composerOptions;
2850
2808
  const contentSegments = replacement
2851
2809
  ? replacement.contentSegments
@@ -2872,7 +2830,7 @@ export class AgentWidgetSession {
2872
2830
  }
2873
2831
 
2874
2832
  /** Submission options → the wire payload shape; quote travels separately. */
2875
- private toComposerOptionsPayload(
2833
+ #toComposerOptionsPayload(
2876
2834
  options: { selectedModelId?: string; activeModeIds?: string[] } | undefined
2877
2835
  ): ComposerOptionsPayload | undefined {
2878
2836
  if (!options) return undefined;
@@ -2903,21 +2861,21 @@ export class AgentWidgetSession {
2903
2861
  // Same ownership rule as sendMessage: a pending history transition owns the
2904
2862
  // record this turn would land in.
2905
2863
  if (
2906
- this.historyGate ||
2907
- this.projectionFinalizationPromise ||
2908
- this.historySendBlocked ||
2909
- this.credentialReinitPending
2864
+ this.#historyGate ||
2865
+ this.#projectionFinalizationPromise ||
2866
+ this.#historySendBlocked ||
2867
+ this.#credentialReinitPending
2910
2868
  ) {
2911
- await this.awaitHistorySendable();
2869
+ await this.#awaitHistorySendable();
2912
2870
  }
2913
2871
 
2914
2872
  this.abortController?.abort();
2915
- this.teardownReconnect();
2873
+ this.#teardownReconnect();
2916
2874
 
2917
2875
  const assistantMessageId = generateAssistantMessageId();
2918
- this.activeAssistantMessageId = null;
2876
+ this.#activeAssistantMessageId = null;
2919
2877
 
2920
- this.setStreaming(true);
2878
+ this.#setStreaming(true);
2921
2879
 
2922
2880
  const controller = new AbortController();
2923
2881
  this.abortController = controller;
@@ -2934,7 +2892,7 @@ export class AgentWidgetSession {
2934
2892
  this.handleEvent
2935
2893
  );
2936
2894
  } catch (error) {
2937
- if (this.status === "resuming" || this.reconnecting) return;
2895
+ if (this.status === "resuming" || this.#reconnecting) return;
2938
2896
  // Check if this is an abort error (a prior in-flight stream was canceled,
2939
2897
  // the user navigated away, etc.). In these cases, don't show fallback or
2940
2898
  // fire onError - the request was intentionally interrupted.
@@ -2957,14 +2915,14 @@ export class AgentWidgetSession {
2957
2915
  role: "assistant",
2958
2916
  createdAt: new Date().toISOString(),
2959
2917
  content,
2960
- sequence: this.nextSequence()
2918
+ sequence: this.#nextSequence()
2961
2919
  };
2962
2920
 
2963
- this.appendMessage(fallback);
2921
+ this.#appendMessage(fallback);
2964
2922
  }
2965
2923
  }
2966
- this.setStatus("idle");
2967
- this.setStreaming(false);
2924
+ this.#setStatus("idle");
2925
+ this.#setStreaming(false);
2968
2926
  this.abortController = null;
2969
2927
  if (!isAbortError) {
2970
2928
  if (error instanceof Error) {
@@ -3008,7 +2966,7 @@ export class AgentWidgetSession {
3008
2966
  // Durable reconnect keeps filling the same bubble: track its id so the
3009
2967
  // cursor handler attaches to it.
3010
2968
  if (options?.preserveAssistantId && options.assistantMessageId) {
3011
- this.activeAssistantMessageId = options.assistantMessageId;
2969
+ this.#activeAssistantMessageId = options.assistantMessageId;
3012
2970
  }
3013
2971
 
3014
2972
  // Finalize any stale streaming messages from the previous stream
@@ -3028,7 +2986,7 @@ export class AgentWidgetSession {
3028
2986
  this.callbacks.onMessagesChanged([...this.messages]);
3029
2987
  }
3030
2988
 
3031
- this.setStreaming(true);
2989
+ this.#setStreaming(true);
3032
2990
 
3033
2991
  try {
3034
2992
  await this.client.processStream(
@@ -3041,14 +2999,14 @@ export class AgentWidgetSession {
3041
2999
  // During a durable reconnect a thrown resume stream is just another drop:
3042
3000
  // the reconnect loop owns the retry/backoff. Don't paint an error or tear
3043
3001
  // down, stay in `resuming`.
3044
- if (this.status === "resuming" || this.reconnecting) return;
3045
- this.setStatus("error");
3002
+ if (this.status === "resuming" || this.#reconnecting) return;
3003
+ this.#setStatus("error");
3046
3004
  // Mirror the idle/error handlers: a failed resume stream must not tear
3047
3005
  // down streaming/abortController while another WebMCP resolve is still
3048
3006
  // confirming or executing. The in-flight resolve's `finally` owns the
3049
3007
  // teardown once `webMcpResolveControllers` drains.
3050
3008
  if (this.webMcpResolveControllers.size === 0) {
3051
- this.setStreaming(false);
3009
+ this.#setStreaming(false);
3052
3010
  this.abortController = null;
3053
3011
  }
3054
3012
  this.callbacks.onError?.(
@@ -3063,7 +3021,7 @@ export class AgentWidgetSession {
3063
3021
  * this, the bridge falls back to a blunt `window.confirm`. Safe to call
3064
3022
  * repeatedly (e.g. after the client is re-created in `updateConfig`).
3065
3023
  */
3066
- private wireDefaultWebMcpConfirm(): void {
3024
+ #wireDefaultWebMcpConfirm(): void {
3067
3025
  const webmcp = this.config.webmcp;
3068
3026
  if (webmcp?.enabled === true && !webmcp.onConfirm) {
3069
3027
  this.client.setWebMcpConfirmHandler((info) =>
@@ -3092,7 +3050,7 @@ export class AgentWidgetSession {
3092
3050
  }
3093
3051
 
3094
3052
  const approval: AgentWidgetApproval = {
3095
- id: `webmcp-${++this.webMcpApprovalSeq}`,
3053
+ id: `webmcp-${++this.#webMcpApprovalSeq}`,
3096
3054
  status: "pending",
3097
3055
  agentId: "",
3098
3056
  executionId: "",
@@ -3148,133 +3106,58 @@ export class AgentWidgetSession {
3148
3106
  resolve(decision === "approved");
3149
3107
  }
3150
3108
 
3109
+ #actionsHost: SessionActionsHost | null = null;
3110
+ // Advanced when the turn is stopped or replaced (cancel, clear, new send,
3111
+ // hydrate, conversation switch). A resolve waiting on the lazy chunk checks
3112
+ // it after the load so a stopped turn never resumes late.
3113
+ #actionsEpoch = 0;
3114
+
3115
+ /**
3116
+ * Run a resolve path from `session-actions.ts` (the lazy
3117
+ * `session-actions.js` chunk in the IIFE build). Synchronous when the module
3118
+ * is already loaded or provided, so the call's timing matches an inline
3119
+ * method; otherwise it waits for the chunk and reports a failed load via
3120
+ * `onError` (the resolve never started, so no state needs unwinding). A turn
3121
+ * stopped or replaced while the chunk loads drops the resolve.
3122
+ */
3123
+ #withActions(
3124
+ run: (actions: SessionActionsModule, host: SessionActionsHost) => Promise<void>
3125
+ ): Promise<void> {
3126
+ const host = (this.#actionsHost ??= {
3127
+ s: this as unknown as SessionActionsInternals,
3128
+ setStreaming: (streaming) => this.#setStreaming(streaming),
3129
+ appendMessage: (message) => this.#appendMessage(message),
3130
+ nextSequence: () => this.#nextSequence(),
3131
+ settleApprovalPausedToolCall: (id) => this.#settleApprovalPausedToolCall(id),
3132
+ resumeAfter: (executionId) =>
3133
+ this.#resumable?.executionId === executionId
3134
+ ? this.#resumable.lastEventId
3135
+ : undefined,
3136
+ });
3137
+ const actions = getSessionActionsSync();
3138
+ if (actions) return run(actions, host);
3139
+ const epoch = this.#actionsEpoch;
3140
+ return loadSessionActions().then(
3141
+ (loaded) => (epoch === this.#actionsEpoch ? run(loaded, host) : undefined),
3142
+ (error) => {
3143
+ this.callbacks.onError?.(error instanceof Error ? error : new Error(String(error)));
3144
+ }
3145
+ );
3146
+ }
3147
+
3151
3148
  /**
3152
3149
  * Resolve a tool approval request (approve or deny).
3153
3150
  * Updates the approval message status, calls the API (or custom onDecision),
3154
3151
  * and pipes the response stream through connectStream().
3155
3152
  */
3156
- public async resolveApproval(
3153
+ public resolveApproval(
3157
3154
  approval: AgentWidgetApproval,
3158
3155
  decision: 'approved' | 'denied',
3159
3156
  options?: AgentWidgetApprovalDecisionOptions
3160
3157
  ): 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
- }
3158
+ return this.#withActions((actions, host) =>
3159
+ actions.resolveApproval(host, approval, decision, options)
3160
+ );
3278
3161
  }
3279
3162
 
3280
3163
  /**
@@ -3289,6 +3172,27 @@ export class AgentWidgetSession {
3289
3172
  * 3. Append a user-visible bubble with the answer text so the
3290
3173
  * transcript reads naturally.
3291
3174
  */
3175
+ public resolveAskUserQuestion(
3176
+ toolMessage: AgentWidgetMessage,
3177
+ answer: string | Record<string, string | string[]>
3178
+ ): Promise<void> {
3179
+ return this.#withActions((actions, host) =>
3180
+ actions.resolveAskUserQuestion(host, toolMessage, answer)
3181
+ );
3182
+ }
3183
+
3184
+ /**
3185
+ * Resolve a paused auto-resolving LOCAL tool call (`webmcp:*` page tools
3186
+ * and the built-in `suggest_replies`) and post the result to `/resume`.
3187
+ * Triggered automatically from `handleEvent`; idempotent on the message's
3188
+ * `toolCall.id`. See `resolveWebMcpToolCall` in `session-actions.ts`.
3189
+ */
3190
+ public resolveWebMcpToolCall(toolMessage: AgentWidgetMessage): Promise<void> {
3191
+ return this.#withActions((actions, host) =>
3192
+ actions.resolveWebMcpToolCall(host, toolMessage)
3193
+ );
3194
+ }
3195
+
3292
3196
  /**
3293
3197
  * Persist in-progress answers and the current page index for a multi-question
3294
3198
  * `ask_user_question` payload, so a refresh resumes on the same page with
@@ -3338,167 +3242,6 @@ export class AgentWidgetSession {
3338
3242
  });
3339
3243
  }
3340
3244
 
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
3245
  /**
3503
3246
  * Collect an auto-resolving LOCAL-tool `await` (`webmcp:*` page tools
3504
3247
  * and the built-in `suggest_replies`) into a per-executionId batch
@@ -3517,13 +3260,13 @@ export class AgentWidgetSession {
3517
3260
  *: route them straight to the single-call path, which surfaces the malformed
3518
3261
  * wire shape via `onError` / an `isError` resume.
3519
3262
  */
3520
- private enqueueWebMcpAwait(toolMessage: AgentWidgetMessage): void {
3263
+ #enqueueWebMcpAwait(toolMessage: AgentWidgetMessage): void {
3521
3264
  const executionId = toolMessage.agentMetadata?.executionId;
3522
3265
  const callId = toolMessage.toolCall?.id;
3523
3266
  if (!executionId || !callId) {
3524
- const queuedEpoch = this.webMcpEpoch;
3267
+ const queuedEpoch = this.#webMcpEpoch;
3525
3268
  queueMicrotask(() => {
3526
- if (queuedEpoch !== this.webMcpEpoch) return;
3269
+ if (queuedEpoch !== this.#webMcpEpoch) return;
3527
3270
  void this.resolveWebMcpToolCall(toolMessage);
3528
3271
  });
3529
3272
  return;
@@ -3555,13 +3298,13 @@ export class AgentWidgetSession {
3555
3298
  * abortController) settles before a resolve grabs them: the same ordering the
3556
3299
  * single-call resolve always relied on.
3557
3300
  */
3558
- private scheduleWebMcpBatchFlush(): void {
3301
+ #scheduleWebMcpBatchFlush(): void {
3559
3302
  if (this.webMcpAwaitBatches.size === 0) return;
3560
- const queuedEpoch = this.webMcpEpoch;
3303
+ const queuedEpoch = this.#webMcpEpoch;
3561
3304
  queueMicrotask(() => {
3562
- if (queuedEpoch !== this.webMcpEpoch) return;
3305
+ if (queuedEpoch !== this.#webMcpEpoch) return;
3563
3306
  for (const executionId of [...this.webMcpAwaitBatches.keys()]) {
3564
- this.flushWebMcpAwaitBatch(executionId);
3307
+ this.#flushWebMcpAwaitBatch(executionId);
3565
3308
  }
3566
3309
  });
3567
3310
  }
@@ -3574,7 +3317,7 @@ export class AgentWidgetSession {
3574
3317
  * so any later sibling re-emit (e.g. from a re-pause) forms a fresh batch
3575
3318
  * rather than mutating one already in flight.
3576
3319
  */
3577
- private flushWebMcpAwaitBatch(executionId: string): void {
3320
+ #flushWebMcpAwaitBatch(executionId: string): void {
3578
3321
  const batch = this.webMcpAwaitBatches.get(executionId);
3579
3322
  if (!batch) return;
3580
3323
  this.webMcpAwaitBatches.delete(executionId);
@@ -3582,498 +3325,9 @@ export class AgentWidgetSession {
3582
3325
  if (snapshots.length === 1) {
3583
3326
  void this.resolveWebMcpToolCall(snapshots[0]);
3584
3327
  } 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,
3772
- );
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
- ),
3328
+ void this.#withActions((actions, host) =>
3329
+ actions.resolveWebMcpToolCallBatch(host, executionId, snapshots)
3980
3330
  );
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
3331
  }
4078
3332
  }
4079
3333
 
@@ -4086,7 +3340,7 @@ export class AgentWidgetSession {
4086
3340
  * controller, so it can't escape this teardown. Called from every stop /
4087
3341
  * new-turn boundary (cancel, clearMessages, hydrateMessages, sendMessage).
4088
3342
  */
4089
- private abortWebMcpResolves(): void {
3343
+ #abortWebMcpResolves(): void {
4090
3344
  for (const controller of this.webMcpResolveControllers) {
4091
3345
  controller.abort();
4092
3346
  }
@@ -4110,7 +3364,7 @@ export class AgentWidgetSession {
4110
3364
  // being torn down, and a microtask-deferred flush must not survive. The
4111
3365
  // epoch bump below also strands an already-scheduled flush.
4112
3366
  this.webMcpAwaitBatches.clear();
4113
- this.webMcpEpoch++;
3367
+ this.#webMcpEpoch++;
4114
3368
  }
4115
3369
 
4116
3370
  public cancel() {
@@ -4118,32 +3372,34 @@ export class AgentWidgetSession {
4118
3372
  this.abortController = null;
4119
3373
  // A user stop also cancels any pending/in-flight durable reconnect and
4120
3374
  // clears the resume handle (the abort above already killed its fetch).
4121
- this.teardownReconnect();
3375
+ this.#teardownReconnect();
4122
3376
  // Tear down every in-flight WebMCP resolve (each owns its own controller,
4123
3377
  // independent of the shared one above). Clear the inflight set so retries
4124
3378
  // are possible if the user re-issues the same await context.
4125
- this.abortWebMcpResolves();
3379
+ this.#abortWebMcpResolves();
3380
+ this.#actionsEpoch++;
4126
3381
  this.webMcpInflightKeys.clear();
4127
3382
  // Stop any in-progress audio too: when the user hits "stop", they want
4128
3383
  // the assistant to actually stop talking, not just stop generating tokens.
4129
3384
  // Both helpers are safe no-ops when audio isn't configured.
4130
3385
  this.stopSpeaking();
4131
3386
  this.stopVoicePlayback();
4132
- this.setStreaming(false);
4133
- this.setStatus("idle");
3387
+ this.#setStreaming(false);
3388
+ this.#setStatus("idle");
4134
3389
  }
4135
3390
 
4136
3391
  public clearMessages() {
4137
3392
  this.stopSpeaking();
4138
3393
  this.abortController?.abort();
4139
3394
  this.abortController = null;
4140
- this.teardownReconnect();
3395
+ this.#teardownReconnect();
4141
3396
  // Tear down every in-flight WebMCP resolve too: their messages are about
4142
3397
  // to be wiped, and a microtask-deferred resolve must not survive the clear.
4143
- this.abortWebMcpResolves();
3398
+ this.#abortWebMcpResolves();
3399
+ this.#actionsEpoch++;
4144
3400
  this.messages = [];
4145
3401
  this.agentExecution = null;
4146
- this.clearArtifactState();
3402
+ this.#clearArtifactState();
4147
3403
  // Clearing messages also wipes the WebMCP dedupe state: a fresh
4148
3404
  // conversation should not refuse to call a webmcp:* tool just because
4149
3405
  // a tool with the same key resolved in the prior conversation.
@@ -4153,8 +3409,8 @@ export class AgentWidgetSession {
4153
3409
  // turn: drop the diff-only fingerprint cache (server keys by recordId, so
4154
3410
  // a new conversation has no stored set to match).
4155
3411
  this.client.resetClientToolsFingerprint();
4156
- this.setStreaming(false);
4157
- this.setStatus("idle");
3412
+ this.#setStreaming(false);
3413
+ this.#setStatus("idle");
4158
3414
  this.callbacks.onMessagesChanged([...this.messages]);
4159
3415
  }
4160
3416
 
@@ -4167,16 +3423,16 @@ export class AgentWidgetSession {
4167
3423
  }
4168
3424
 
4169
3425
  public getSelectedArtifactId(): string | null {
4170
- return this.selectedArtifactId;
3426
+ return this.#selectedArtifactId;
4171
3427
  }
4172
3428
 
4173
3429
  public selectArtifact(id: string | null): void {
4174
- this.selectedArtifactId = id;
4175
- this.emitArtifactsState();
3430
+ this.#selectedArtifactId = id;
3431
+ this.#emitArtifactsState();
4176
3432
  }
4177
3433
 
4178
3434
  public clearArtifacts(): void {
4179
- this.clearArtifactState();
3435
+ this.#clearArtifactState();
4180
3436
  }
4181
3437
 
4182
3438
  public upsertArtifact(manual: PersonaArtifactManualUpsert): PersonaArtifactRecord {
@@ -4204,10 +3460,10 @@ export class AgentWidgetSession {
4204
3460
  ...(manual.presentation ? { presentation: manual.presentation } : {})
4205
3461
  };
4206
3462
  this.artifacts.set(id, rec);
4207
- this.selectedArtifactId = id;
4208
- this.emitArtifactsState();
3463
+ this.#selectedArtifactId = id;
3464
+ this.#emitArtifactsState();
4209
3465
  if (manual.transcript !== false) {
4210
- this.injectArtifactRefBlock(rec);
3466
+ this.#injectArtifactRefBlock(rec);
4211
3467
  }
4212
3468
  return rec;
4213
3469
  }
@@ -4228,7 +3484,7 @@ export class AgentWidgetSession {
4228
3484
  * these props); without this the block would keep the first version's
4229
3485
  * content after a refresh.
4230
3486
  */
4231
- private injectArtifactRefBlock(rec: PersonaArtifactRecord): void {
3487
+ #injectArtifactRefBlock(rec: PersonaArtifactRecord): void {
4232
3488
  const refId = `artifact-ref-${rec.id}`;
4233
3489
  const displayMode = resolveArtifactDisplayMode(
4234
3490
  this.config.features?.artifacts,
@@ -4266,7 +3522,7 @@ export class AgentWidgetSession {
4266
3522
  * Re-materialize existing transcript artifact blocks after a live display
4267
3523
  * preference update. One messages callback keeps conversion atomic.
4268
3524
  */
4269
- private refreshArtifactReferenceBlocks(): void {
3525
+ #refreshArtifactReferenceBlocks(): void {
4270
3526
  let changed = false;
4271
3527
  for (const rec of this.artifacts.values()) {
4272
3528
  const message = this.messages.find((candidate) => candidate.id === `artifact-ref-${rec.id}`);
@@ -4294,21 +3550,21 @@ export class AgentWidgetSession {
4294
3550
  if (changed) this.callbacks.onMessagesChanged([...this.messages]);
4295
3551
  }
4296
3552
 
4297
- private clearArtifactState(): void {
4298
- if (this.artifacts.size === 0 && this.selectedArtifactId === null) return;
3553
+ #clearArtifactState(): void {
3554
+ if (this.artifacts.size === 0 && this.#selectedArtifactId === null) return;
4299
3555
  this.artifacts.clear();
4300
- this.selectedArtifactId = null;
4301
- this.emitArtifactsState();
3556
+ this.#selectedArtifactId = null;
3557
+ this.#emitArtifactsState();
4302
3558
  }
4303
3559
 
4304
- private emitArtifactsState(): void {
3560
+ #emitArtifactsState(): void {
4305
3561
  this.callbacks.onArtifactsState?.({
4306
3562
  artifacts: [...this.artifacts.values()],
4307
- selectedId: this.selectedArtifactId
3563
+ selectedId: this.#selectedArtifactId
4308
3564
  });
4309
3565
  }
4310
3566
 
4311
- private applyArtifactStreamEvent(ev: AgentWidgetEvent): void {
3567
+ #applyArtifactStreamEvent(ev: AgentWidgetEvent): void {
4312
3568
  switch (ev.type) {
4313
3569
  case "artifact_start": {
4314
3570
  if (ev.artifactType === "markdown") {
@@ -4330,7 +3586,7 @@ export class AgentWidgetSession {
4330
3586
  props: {}
4331
3587
  });
4332
3588
  }
4333
- this.selectedArtifactId = ev.id;
3589
+ this.#selectedArtifactId = ev.id;
4334
3590
  break;
4335
3591
  }
4336
3592
  case "artifact_delta": {
@@ -4356,7 +3612,7 @@ export class AgentWidgetSession {
4356
3612
  default:
4357
3613
  return;
4358
3614
  }
4359
- this.emitArtifactsState();
3615
+ this.#emitArtifactsState();
4360
3616
  }
4361
3617
 
4362
3618
  public hydrateMessages(messages: AgentWidgetMessage[]) {
@@ -4364,23 +3620,24 @@ export class AgentWidgetSession {
4364
3620
  this.abortController = null;
4365
3621
  // Hydration replaces the conversation: also cancel any pending reconnect and
4366
3622
  // clear the resume handle (a boot resume re-arms via resumeFromHandle after).
4367
- this.teardownReconnect();
3623
+ this.#teardownReconnect();
4368
3624
  // Hydration replaces the conversation: abort and forget every in-flight
4369
3625
  // WebMCP resolve; their messages are about to be replaced.
4370
- this.abortWebMcpResolves();
3626
+ this.#abortWebMcpResolves();
3627
+ this.#actionsEpoch++;
4371
3628
  // Wipe the WebMCP dedupe state alongside the message restore: the
4372
3629
  // incoming snapshot is treated as a fresh conversation context.
4373
3630
  this.webMcpInflightKeys.clear();
4374
3631
  this.webMcpResolvedKeys.clear();
4375
- this.messages = this.sortMessages(
3632
+ this.messages = this.#sortMessages(
4376
3633
  messages.map((message) => ({
4377
3634
  ...message,
4378
3635
  streaming: false,
4379
- sequence: message.sequence ?? this.nextSequence()
3636
+ sequence: message.sequence ?? this.#nextSequence()
4380
3637
  }))
4381
3638
  );
4382
- this.setStreaming(false);
4383
- this.setStatus("idle");
3639
+ this.#setStreaming(false);
3640
+ this.#setStatus("idle");
4384
3641
  this.callbacks.onMessagesChanged([...this.messages]);
4385
3642
  }
4386
3643
 
@@ -4392,14 +3649,36 @@ export class AgentWidgetSession {
4392
3649
  for (const rec of artifacts) {
4393
3650
  this.artifacts.set(rec.id, { ...rec, status: "complete" });
4394
3651
  }
4395
- this.selectedArtifactId = selectedId;
4396
- this.emitArtifactsState();
3652
+ this.#selectedArtifactId = selectedId;
3653
+ this.#emitArtifactsState();
4397
3654
  }
4398
3655
 
4399
3656
  private handleEvent = (event: AgentWidgetEvent) => {
3657
+ const delegation = this.#voiceDelegation;
3658
+ if (delegation) {
3659
+ if (event.type === "error" || (event.type === "status" && event.status === "error")) {
3660
+ delegation.failed = true;
3661
+ } else if (event.type === "message" && event.message.role === "assistant") {
3662
+ // The voice model reads this answer aloud: browser TTS must skip it,
3663
+ // unless the server dropped the result (then nobody speaks it).
3664
+ if (!delegation.dropped) this.ttsSpokenMessageIds.add(event.message.id);
3665
+ if (!delegation.ids.includes(event.message.id)) delegation.ids.push(event.message.id);
3666
+ }
3667
+ }
4400
3668
  if (event.type === "message") {
4401
3669
  this.upsertMessage(event.message);
4402
3670
 
3671
+ // A resolved approval reaches this handler only from the server's
3672
+ // `approval_complete` (resolveApproval's optimistic update upserts
3673
+ // directly), so it is safe to settle the paused tool bubble here.
3674
+ if (
3675
+ event.message.variant === "approval" &&
3676
+ event.message.approval &&
3677
+ event.message.approval.status !== "pending"
3678
+ ) {
3679
+ this.#settleApprovalPausedToolCall(event.message.id);
3680
+ }
3681
+
4403
3682
  // Track the open assistant text bubble's REAL streamed id so a durable
4404
3683
  // reconnect keeps filling the same message. The proxy path auto-generates
4405
3684
  // this id (it isn't the session's pre-generated one), so we must read it
@@ -4409,7 +3688,7 @@ export class AgentWidgetSession {
4409
3688
  !event.message.variant &&
4410
3689
  event.message.streaming
4411
3690
  ) {
4412
- this.activeAssistantMessageId = event.message.id;
3691
+ this.#activeAssistantMessageId = event.message.id;
4413
3692
  }
4414
3693
 
4415
3694
  // Local-tool auto-resolve: when a await emits a tool-variant
@@ -4436,6 +3715,16 @@ export class AgentWidgetSession {
4436
3715
  // the resolved `enabled` is false the widget neither renders chips nor
4437
3716
  // resumes: the same parked-execution posture as a server-declared
4438
3717
  // ask_user_question with its sheet disabled.
3718
+ // The agent paused for an approval or a local tool: warm the resolve
3719
+ // paths (the lazy `session-actions.js` chunk in the IIFE build) so the
3720
+ // resume doesn't wait on a fetch.
3721
+ if (
3722
+ event.message.variant === "approval" ||
3723
+ event.message.agentMetadata?.awaitingLocalTool === true
3724
+ ) {
3725
+ loadSessionActions().catch(() => {});
3726
+ }
3727
+
4439
3728
  const tc = event.message.toolCall;
4440
3729
  const autoResolvable =
4441
3730
  !!tc?.name &&
@@ -4450,7 +3739,7 @@ export class AgentWidgetSession {
4450
3739
  // it on the spot. Parallel same-tool calls (core#3878) arrive as
4451
3740
  // separate `await`s in the same stream; batching lets us post ONE
4452
3741
  // `/resume` keyed by per-call id (see `enqueueWebMcpAwait`).
4453
- this.enqueueWebMcpAwait(event.message);
3742
+ this.#enqueueWebMcpAwait(event.message);
4454
3743
  }
4455
3744
 
4456
3745
  // Track agent execution state from message metadata
@@ -4473,35 +3762,35 @@ export class AgentWidgetSession {
4473
3762
  // cursor and (lazily) form the resume handle once the executionId is
4474
3763
  // known. Only durable, resumable executions emit these, so this is the
4475
3764
  // natural gate: no `id:` lines → no handle → reconnect never arms.
4476
- this.trackCursor(event.id);
3765
+ this.#trackCursor(event.id);
4477
3766
  } else if (event.type === "status") {
4478
3767
  // A plain `idle` (no `terminal`) on the durable lane, while the run is
4479
3768
  // genuinely mid-turn, is a dropped connection, not a finish and not an
4480
3769
  // intentional `await` pause. Reconnect instead of finalizing.
4481
- if (event.status === "idle" && !event.terminal && this.isDurableDrop()) {
4482
- this.beginReconnect();
3770
+ if (event.status === "idle" && !event.terminal && this.#isDurableDrop()) {
3771
+ this.#beginReconnect();
4483
3772
  return;
4484
3773
  }
4485
- this.setStatus(event.status);
3774
+ this.#setStatus(event.status);
4486
3775
  if (event.status === "connecting") {
4487
- this.setStreaming(true);
3776
+ this.#setStreaming(true);
4488
3777
  } else if (event.status === "idle" || event.status === "error") {
4489
3778
  // A client-tool pause is an intentional stream end but NOT a terminal:
4490
3779
  // retain its cursor so `/client/resume` can attach after the await
4491
3780
  // frame instead of replaying the whole turn. Real terminals and
4492
3781
  // ordinary non-durable idles still clear the handle.
4493
- if (!this.isAwaitPending()) this.clearResumable();
3782
+ if (!this.#isAwaitPending()) this.#clearResumable();
4494
3783
  // Keep the typing indicator up while a WebMCP resolve is still in
4495
3784
  // flight: in a chained turn the intermediate resume stream ends with an
4496
3785
  // idle status, but the successor tool is still executing. The resolve's
4497
3786
  // own `finally` flips streaming off once the resolve set drains.
4498
3787
  if (this.webMcpResolveControllers.size === 0) {
4499
- this.setStreaming(false);
3788
+ this.#setStreaming(false);
4500
3789
  this.abortController = null;
4501
3790
  // The turn mutated the record and the chat response carries no
4502
3791
  // revision; finalize any browser-only projection right after.
4503
- this.invalidateConversationRevision();
4504
- this.scheduleProjectionFinalization();
3792
+ this.#invalidateConversationRevision();
3793
+ this.#scheduleProjectionFinalization();
4505
3794
  }
4506
3795
  // Mark agent execution as complete when streaming ends: UNLESS local
4507
3796
  // tools are still outstanding. A batched WebMCP resume is deferred to
@@ -4525,19 +3814,19 @@ export class AgentWidgetSession {
4525
3814
  // batched `/resume` per executionId (deferred: see
4526
3815
  // scheduleWebMcpBatchFlush). Runs AFTER the teardown above so a resolve
4527
3816
  // doesn't fight the end-of-stream streaming/abortController reset.
4528
- this.scheduleWebMcpBatchFlush();
3817
+ this.#scheduleWebMcpBatchFlush();
4529
3818
  }
4530
3819
  } else if (event.type === "error") {
4531
- this.setStatus("error");
3820
+ this.#setStatus("error");
4532
3821
  // Terminal error: drop the resume handle so no reconnect arms.
4533
- this.clearResumable();
3822
+ this.#clearResumable();
4534
3823
  // Mirror the idle/status handler: don't tear down streaming while a
4535
3824
  // WebMCP resolve is still confirming/executing on another stream: an
4536
3825
  // error on one chained resume stream must not hide the typing indicator
4537
3826
  // (or null a controller) for a sibling/successor resolve still in flight.
4538
3827
  // The resolve's own `finally` flips streaming off once the set drains.
4539
3828
  if (this.webMcpResolveControllers.size === 0) {
4540
- this.setStreaming(false);
3829
+ this.#setStreaming(false);
4541
3830
  this.abortController = null;
4542
3831
  }
4543
3832
  if (this.agentExecution?.status === 'running') {
@@ -4550,7 +3839,7 @@ export class AgentWidgetSession {
4550
3839
  event.type === "artifact_update" ||
4551
3840
  event.type === "artifact_complete"
4552
3841
  ) {
4553
- this.applyArtifactStreamEvent(event);
3842
+ this.#applyArtifactStreamEvent(event);
4554
3843
  }
4555
3844
  };
4556
3845
 
@@ -4562,24 +3851,24 @@ export class AgentWidgetSession {
4562
3851
  * frame's metadata, handled before this trailing cursor event) and the active
4563
3852
  * assistant message id.
4564
3853
  */
4565
- private trackCursor(id: string): void {
3854
+ #trackCursor(id: string): void {
4566
3855
  const executionId = this.agentExecution?.executionId;
4567
- if (!executionId || !this.activeAssistantMessageId) return;
3856
+ if (!executionId || !this.#activeAssistantMessageId) return;
4568
3857
  // Only track while the run is live. A graceful terminal sets the execution
4569
3858
  // to 'complete'/'error' and clears the handle BEFORE its own frame's
4570
3859
  // trailing cursor fires; without this guard that cursor would re-arm the
4571
3860
  // handle and the next plain `idle` would look like a spurious drop.
4572
3861
  if (this.agentExecution?.status !== "running") return;
4573
- const isNew = this.resumable === null;
4574
- this.resumable = {
3862
+ const isNew = this.#resumable === null;
3863
+ this.#resumable = {
4575
3864
  executionId,
4576
3865
  lastEventId: id,
4577
- assistantMessageId: this.activeAssistantMessageId,
3866
+ assistantMessageId: this.#activeAssistantMessageId,
4578
3867
  status: "running",
4579
3868
  };
4580
3869
  // Fire immediately when the handle first appears (so the host can persist
4581
3870
  // the executionId promptly); throttle subsequent cursor advances.
4582
- this.notifyExecutionState(isNew);
3871
+ this.#notifyExecutionState(isNew);
4583
3872
  }
4584
3873
 
4585
3874
  /**
@@ -4587,14 +3876,14 @@ export class AgentWidgetSession {
4587
3876
  * rather than a graceful finish or an intentional `await` pause? ALL must
4588
3877
  * hold (see plan §Design overview keystone 3).
4589
3878
  */
4590
- private isDurableDrop(): boolean {
3879
+ #isDurableDrop(): boolean {
4591
3880
  return (
4592
- this.resumable !== null &&
3881
+ this.#resumable !== null &&
4593
3882
  typeof this.config.reconnectStream === "function" &&
4594
3883
  this.abortController?.signal.aborted !== true &&
4595
3884
  this.webMcpResolveControllers.size === 0 &&
4596
3885
  this.webMcpAwaitBatches.size === 0 &&
4597
- !this.isAwaitPending()
3886
+ !this.#isAwaitPending()
4598
3887
  );
4599
3888
  }
4600
3889
 
@@ -4603,7 +3892,7 @@ export class AgentWidgetSession {
4603
3892
  * `ask_user_question` / local-tool await, or a pending approval). Those end
4604
3893
  * the stream with `idle` and no terminal too, but are NOT drops.
4605
3894
  */
4606
- private isAwaitPending(): boolean {
3895
+ #isAwaitPending(): boolean {
4607
3896
  return this.messages.some((m) => {
4608
3897
  if (
4609
3898
  m.agentMetadata?.awaitingLocalTool === true &&
@@ -4623,22 +3912,22 @@ export class AgentWidgetSession {
4623
3912
  * true, flips to `resuming`, and kicks the bounded backoff loop. Re-entrant
4624
3913
  * calls (a second drop while reconnecting) are no-ops.
4625
3914
  */
4626
- private beginReconnect(): void {
4627
- if (this.reconnecting) return;
4628
- if (!this.resumable || typeof this.config.reconnectStream !== "function") {
3915
+ #beginReconnect(): void {
3916
+ if (this.#reconnecting) return;
3917
+ if (!this.#resumable || typeof this.config.reconnectStream !== "function") {
4629
3918
  return;
4630
3919
  }
4631
3920
  // Flip the visible state synchronously — the bubble stays open and a
4632
3921
  // `resuming` status is observable immediately (e.g. right after
4633
3922
  // `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) => {
3923
+ this.#reconnecting = true;
3924
+ this.callbacks.onReconnect?.({ phase: "paused", handle: this.#resumable });
3925
+ this.#setStreaming(true);
3926
+ this.#setStatus("resuming");
3927
+ void this.#loadReconnectController().then((controller) => {
4639
3928
  // The run may have been torn down (new turn / cancel / hydrate) while the
4640
3929
  // module was loading; only start the loop if we're still reconnecting.
4641
- if (this.reconnecting && this.resumable) controller.begin();
3930
+ if (this.#reconnecting && this.#resumable) controller.begin();
4642
3931
  });
4643
3932
  }
4644
3933
 
@@ -4648,43 +3937,43 @@ export class AgentWidgetSession {
4648
3937
  * every hot path (`sendMessage` / `cancel` / `teardownReconnect`) so a widget
4649
3938
  * that never reconnects never pulls the chunk.
4650
3939
  */
4651
- private loadReconnectController(): Promise<ReconnectController> {
4652
- if (this.reconnectController) {
4653
- return Promise.resolve(this.reconnectController);
3940
+ #loadReconnectController(): Promise<ReconnectController> {
3941
+ if (this.#reconnectController) {
3942
+ return Promise.resolve(this.#reconnectController);
4654
3943
  }
4655
- if (!this.reconnectControllerPromise) {
4656
- this.reconnectControllerPromise = loadSessionReconnect().then(
3944
+ if (!this.#reconnectControllerPromise) {
3945
+ this.#reconnectControllerPromise = loadSessionReconnect().then(
4657
3946
  ({ createReconnectController }) => {
4658
3947
  const controller = createReconnectController(
4659
- this.buildReconnectHost()
3948
+ this.#buildReconnectHost()
4660
3949
  );
4661
- this.reconnectController = controller;
3950
+ this.#reconnectController = controller;
4662
3951
  return controller;
4663
3952
  }
4664
3953
  );
4665
3954
  // A failed chunk fetch must not poison the memoized promise: clear it so
4666
3955
  // the next reconnect trigger retries the load.
4667
- this.reconnectControllerPromise.catch(() => {
4668
- this.reconnectControllerPromise = null;
3956
+ this.#reconnectControllerPromise.catch(() => {
3957
+ this.#reconnectControllerPromise = null;
4669
3958
  });
4670
3959
  }
4671
- return this.reconnectControllerPromise;
3960
+ return this.#reconnectControllerPromise;
4672
3961
  }
4673
3962
 
4674
3963
  /** The narrow surface the lazily-loaded reconnect loop drives. */
4675
- private buildReconnectHost(): ReconnectHost {
3964
+ #buildReconnectHost(): ReconnectHost {
4676
3965
  const session = this;
4677
3966
  return {
4678
3967
  get config() {
4679
3968
  return session.config;
4680
3969
  },
4681
- getResumable: () => session.resumable,
4682
- clearResumable: () => session.clearResumable(),
3970
+ getResumable: () => session.#resumable,
3971
+ clearResumable: () => session.#clearResumable(),
4683
3972
  getStatus: () => session.status,
4684
- setStatus: (status) => session.setStatus(status),
4685
- setStreaming: (streaming) => session.setStreaming(streaming),
3973
+ setStatus: (status) => session.#setStatus(status),
3974
+ setStreaming: (streaming) => session.#setStreaming(streaming),
4686
3975
  setReconnecting: (value) => {
4687
- session.reconnecting = value;
3976
+ session.#reconnecting = value;
4688
3977
  },
4689
3978
  setAbortController: (controller) => {
4690
3979
  session.abortController = controller;
@@ -4699,8 +3988,8 @@ export class AgentWidgetSession {
4699
3988
  preserveAssistantId: true,
4700
3989
  seedContent,
4701
3990
  }),
4702
- appendMessage: (message) => session.appendMessage(message),
4703
- nextSequence: () => session.nextSequence(),
3991
+ appendMessage: (message) => session.#appendMessage(message),
3992
+ nextSequence: () => session.#nextSequence(),
4704
3993
  emitReconnect: (event) => session.callbacks.onReconnect?.(event),
4705
3994
  buildErrorContent: (message) =>
4706
3995
  buildDispatchErrorContent(
@@ -4713,13 +4002,13 @@ export class AgentWidgetSession {
4713
4002
 
4714
4003
  /** Public manual retry (e.g. a "Reconnect" button). */
4715
4004
  public reconnectNow(): void {
4716
- if (this.reconnecting) {
4005
+ if (this.#reconnecting) {
4717
4006
  // Already trying, so just short-circuit the current backoff. (No-op if the
4718
4007
  // controller is still loading; the first attempt hasn't slept yet.)
4719
- this.reconnectController?.wake();
4008
+ this.#reconnectController?.wake();
4720
4009
  return;
4721
4010
  }
4722
- this.beginReconnect();
4011
+ this.#beginReconnect();
4723
4012
  }
4724
4013
 
4725
4014
  /**
@@ -4729,21 +4018,21 @@ export class AgentWidgetSession {
4729
4018
  */
4730
4019
  public resumeFromHandle(resume: { executionId: string; after: string }): void {
4731
4020
  if (typeof this.config.reconnectStream !== "function") return;
4732
- if (this.reconnecting) return;
4021
+ if (this.#reconnecting) return;
4733
4022
 
4734
- let assistantId = this.reopenTrailingAssistant();
4023
+ let assistantId = this.#reopenTrailingAssistant();
4735
4024
  if (!assistantId) {
4736
4025
  assistantId = generateAssistantMessageId();
4737
- this.appendMessage({
4026
+ this.#appendMessage({
4738
4027
  id: assistantId,
4739
4028
  role: "assistant",
4740
4029
  content: "",
4741
4030
  createdAt: new Date().toISOString(),
4742
4031
  streaming: true,
4743
- sequence: this.nextSequence(),
4032
+ sequence: this.#nextSequence(),
4744
4033
  });
4745
4034
  }
4746
- this.activeAssistantMessageId = assistantId;
4035
+ this.#activeAssistantMessageId = assistantId;
4747
4036
  if (!this.agentExecution) {
4748
4037
  this.agentExecution = {
4749
4038
  executionId: resume.executionId,
@@ -4754,13 +4043,13 @@ export class AgentWidgetSession {
4754
4043
  maxTurns: 0,
4755
4044
  };
4756
4045
  }
4757
- this.resumable = {
4046
+ this.#resumable = {
4758
4047
  executionId: resume.executionId,
4759
4048
  lastEventId: resume.after,
4760
4049
  assistantMessageId: assistantId,
4761
4050
  status: "running",
4762
4051
  };
4763
- this.beginReconnect();
4052
+ this.#beginReconnect();
4764
4053
  }
4765
4054
 
4766
4055
  /**
@@ -4768,7 +4057,7 @@ export class AgentWidgetSession {
4768
4057
  * deltas append to it. Returns its id, or null if the last turn doesn't end
4769
4058
  * in a plain assistant message.
4770
4059
  */
4771
- private reopenTrailingAssistant(): string | null {
4060
+ #reopenTrailingAssistant(): string | null {
4772
4061
  for (let i = this.messages.length - 1; i >= 0; i--) {
4773
4062
  const m = this.messages[i];
4774
4063
  if (m.role === "assistant" && !m.variant) {
@@ -4786,20 +4075,20 @@ export class AgentWidgetSession {
4786
4075
  * on every new turn / cancel / hydrate, so it must NOT pull the reconnect
4787
4076
  * chunk: it only delegates to the controller if one was already created.
4788
4077
  */
4789
- private teardownReconnect(): void {
4790
- this.reconnecting = false;
4791
- this.reconnectController?.teardown();
4792
- this.clearResumable();
4078
+ #teardownReconnect(): void {
4079
+ this.#reconnecting = false;
4080
+ this.#reconnectController?.teardown();
4081
+ this.#clearResumable();
4793
4082
  }
4794
4083
 
4795
4084
  /** 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;
4085
+ #clearResumable(): void {
4086
+ if (this.#executionStateTimer) {
4087
+ clearTimeout(this.#executionStateTimer);
4088
+ this.#executionStateTimer = null;
4800
4089
  }
4801
- const had = this.resumable !== null;
4802
- this.resumable = null;
4090
+ const had = this.#resumable !== null;
4091
+ this.#resumable = null;
4803
4092
  if (had) this.config.onExecutionState?.(null);
4804
4093
  }
4805
4094
 
@@ -4808,36 +4097,36 @@ export class AgentWidgetSession {
4808
4097
  * create/clear; trailing-edge throttled on cursor advances so it isn't called
4809
4098
  * per delta.
4810
4099
  */
4811
- private notifyExecutionState(immediate: boolean): void {
4100
+ #notifyExecutionState(immediate: boolean): void {
4812
4101
  const cb = this.config.onExecutionState;
4813
4102
  if (!cb) return;
4814
4103
  if (immediate) {
4815
- if (this.executionStateTimer) {
4816
- clearTimeout(this.executionStateTimer);
4817
- this.executionStateTimer = null;
4104
+ if (this.#executionStateTimer) {
4105
+ clearTimeout(this.#executionStateTimer);
4106
+ this.#executionStateTimer = null;
4818
4107
  }
4819
- cb(this.resumable);
4108
+ cb(this.#resumable);
4820
4109
  return;
4821
4110
  }
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);
4111
+ if (this.#executionStateTimer) return; // a trailing call is already queued
4112
+ this.#executionStateTimer = setTimeout(() => {
4113
+ this.#executionStateTimer = null;
4114
+ this.config.onExecutionState?.(this.#resumable);
4826
4115
  }, 500);
4827
4116
  }
4828
4117
 
4829
4118
  /** The current durable resume handle, if any (read-only). */
4830
4119
  public getResumableHandle(): ResumableHandle | null {
4831
- return this.resumable;
4120
+ return this.#resumable;
4832
4121
  }
4833
4122
 
4834
- private setStatus(status: AgentWidgetSessionStatus) {
4123
+ #setStatus(status: AgentWidgetSessionStatus) {
4835
4124
  if (this.status === status) return;
4836
4125
  this.status = status;
4837
4126
  this.callbacks.onStatusChanged(status);
4838
4127
  }
4839
4128
 
4840
- private setStreaming(streaming: boolean) {
4129
+ #setStreaming(streaming: boolean) {
4841
4130
  if (this.streaming === streaming) return;
4842
4131
  const wasStreaming = this.streaming;
4843
4132
  this.streaming = streaming;
@@ -4853,7 +4142,8 @@ export class AgentWidgetSession {
4853
4142
  * Speak the latest assistant message using the Web Speech API
4854
4143
  * if text-to-speech is enabled in the config.
4855
4144
  */
4856
- private speakLatestAssistantMessage() {
4145
+ /** `only`: speak it only if the latest assistant message is one of these. */
4146
+ private speakLatestAssistantMessage(only?: string[]) {
4857
4147
  const ttsConfig = this.config.textToSpeech;
4858
4148
  if (!ttsConfig?.enabled) return;
4859
4149
 
@@ -4871,7 +4161,7 @@ export class AgentWidgetSession {
4871
4161
  .reverse()
4872
4162
  .find(m => m.role === 'assistant' && m.content && !m.voiceProcessing);
4873
4163
 
4874
- if (!lastAssistant) return;
4164
+ if (!lastAssistant || (only && !only.includes(lastAssistant.id))) return;
4875
4165
 
4876
4166
  // Skip if already spoken by Runtype provider's audio playback
4877
4167
  if (this.ttsSpokenMessageIds.has(lastAssistant.id)) {
@@ -4943,17 +4233,42 @@ export class AgentWidgetSession {
4943
4233
  }
4944
4234
  }
4945
4235
 
4946
- private appendMessage(message: AgentWidgetMessage) {
4947
- const withSequence = this.ensureSequence(message);
4948
- this.messages = this.sortMessages([...this.messages, withSequence]);
4236
+ /**
4237
+ * Settle the tool bubble an approval paused. The server resumes an approval
4238
+ * by re-running the agent, which re-issues the call under a new id, so no
4239
+ * terminal tool frame ever arrives for the paused one. Denied and timed-out
4240
+ * calls settle as failed; an approved call settles as superseded, since the
4241
+ * re-issued call carries its result. A call already complete is left alone.
4242
+ */
4243
+ #settleApprovalPausedToolCall(approvalMessageId: string) {
4244
+ const approval = this.messages.find((m) => m.id === approvalMessageId)?.approval;
4245
+ const id = approval?.toolCallId;
4246
+ const toolMessage = id && this.messages.find((m) => m.toolCall?.id === id);
4247
+ const tool = toolMessage && toolMessage.toolCall;
4248
+ if (!tool || tool.status === "complete" || approval.status === "pending") return;
4249
+ const settled: AgentWidgetToolCall = {
4250
+ ...tool,
4251
+ status: "complete",
4252
+ approvalStatus: approval.status,
4253
+ completedAt: Date.now(),
4254
+ ...(approval.status === "approved"
4255
+ ? { superseded: true }
4256
+ : { success: false, error: approval.status === "denied" ? "Denied" : "Approval timed out" }),
4257
+ };
4258
+ this.upsertMessage({ ...toolMessage, streaming: false, toolCall: settled });
4259
+ }
4260
+
4261
+ #appendMessage(message: AgentWidgetMessage) {
4262
+ const withSequence = this.#ensureSequence(message);
4263
+ this.messages = this.#sortMessages([...this.messages, withSequence]);
4949
4264
  this.callbacks.onMessagesChanged([...this.messages]);
4950
4265
  }
4951
4266
 
4952
4267
  private upsertMessage(message: AgentWidgetMessage) {
4953
- const withSequence = this.ensureSequence(message);
4268
+ const withSequence = this.#ensureSequence(message);
4954
4269
  const index = this.messages.findIndex((m) => m.id === withSequence.id);
4955
4270
  if (index === -1) {
4956
- this.appendMessage(withSequence);
4271
+ this.#appendMessage(withSequence);
4957
4272
  return;
4958
4273
  }
4959
4274
 
@@ -5034,6 +4349,7 @@ export class AgentWidgetSession {
5034
4349
  toolType: incoming.toolType ?? prior.toolType,
5035
4350
  reason: incoming.reason ?? prior.reason,
5036
4351
  parameters: incoming.parameters ?? prior.parameters,
4352
+ toolCallId: incoming.toolCallId ?? prior.toolCallId,
5037
4353
  };
5038
4354
  }
5039
4355
  // Auto-resolved local-tool equivalent (`webmcp:*` and the built-in
@@ -5080,25 +4396,25 @@ export class AgentWidgetSession {
5080
4396
  }
5081
4397
  return merged;
5082
4398
  });
5083
- this.messages = this.sortMessages(this.messages);
4399
+ this.messages = this.#sortMessages(this.messages);
5084
4400
  this.callbacks.onMessagesChanged([...this.messages]);
5085
4401
  }
5086
4402
 
5087
- private ensureSequence(message: AgentWidgetMessage): AgentWidgetMessage {
4403
+ #ensureSequence(message: AgentWidgetMessage): AgentWidgetMessage {
5088
4404
  if (message.sequence !== undefined) {
5089
4405
  return { ...message };
5090
4406
  }
5091
4407
  return {
5092
4408
  ...message,
5093
- sequence: this.nextSequence()
4409
+ sequence: this.#nextSequence()
5094
4410
  };
5095
4411
  }
5096
4412
 
5097
- private nextSequence() {
5098
- return this.sequenceCounter++;
4413
+ #nextSequence() {
4414
+ return this.#sequenceCounter++;
5099
4415
  }
5100
4416
 
5101
- private sortMessages(messages: AgentWidgetMessage[]) {
4417
+ #sortMessages(messages: AgentWidgetMessage[]) {
5102
4418
  return [...messages].sort((a, b) => {
5103
4419
  // Sort by createdAt timestamp first (chronological order)
5104
4420
  const timeA = new Date(a.createdAt).getTime();