@runtypelabs/persona 4.24.0 → 4.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (237) hide show
  1. package/dist/animations/glyph-cycle.d.cts +1 -1
  2. package/dist/animations/glyph-cycle.d.ts +1 -1
  3. package/dist/animations/{types-Db61aI9J.d.cts → types-BwKTZhg9.d.cts} +14 -0
  4. package/dist/animations/{types-Db61aI9J.d.ts → types-BwKTZhg9.d.ts} +14 -0
  5. package/dist/animations/wipe.d.cts +1 -1
  6. package/dist/animations/wipe.d.ts +1 -1
  7. package/dist/animations-extra.d.cts +14 -0
  8. package/dist/animations-extra.d.ts +14 -0
  9. package/dist/approval-ui-QAKWNFNM.js +1 -0
  10. package/dist/approval-ui.cjs +2 -2
  11. package/dist/approval-ui.d.cts +243 -8
  12. package/dist/approval-ui.d.ts +243 -8
  13. package/dist/approval-ui.js +2 -2
  14. package/dist/{artifacts-ui-IGB5YKLO.js → artifacts-ui-NJGMM27I.js} +1 -1
  15. package/dist/artifacts-ui.cjs +4 -4
  16. package/dist/artifacts-ui.d.cts +243 -8
  17. package/dist/artifacts-ui.d.ts +243 -8
  18. package/dist/artifacts-ui.js +5 -5
  19. package/dist/chunk-2ANTRKTV.js +1 -0
  20. package/dist/chunk-4HPTPUC6.js +1 -0
  21. package/dist/chunk-6V3MNEKB.js +1 -0
  22. package/dist/chunk-HGUCGM3K.js +9 -0
  23. package/dist/chunk-IO5VVUKP.js +3 -0
  24. package/dist/chunk-J4J3Y2P2.js +1 -0
  25. package/dist/chunk-K6HKIP5K.js +1 -0
  26. package/dist/chunk-K7NA7G7J.js +1 -0
  27. package/dist/chunk-MXUTS3LB.js +1 -0
  28. package/dist/chunk-PDPVORUF.js +1 -0
  29. package/dist/chunk-QPPJUSZH.js +1 -0
  30. package/dist/chunk-QRKGQPU7.js +1 -0
  31. package/dist/chunk-S6PBWE75.js +1 -0
  32. package/dist/chunk-T7QM7YMF.js +1 -0
  33. package/dist/chunk-TR2H475E.js +1 -0
  34. package/dist/chunk-UPO4GUFC.js +1 -0
  35. package/dist/chunk-ZC66NAIR.js +1 -0
  36. package/dist/client-history-YPEG45Q4.js +1 -0
  37. package/dist/client-history.js +1 -0
  38. package/dist/client-stream-42FOI3EC.js +4 -0
  39. package/dist/client-stream.js +5 -0
  40. package/dist/codegen.cjs +1 -1
  41. package/dist/codegen.js +1 -1
  42. package/dist/context-mentions-E7HJYWO6.js +161 -0
  43. package/dist/{context-mentions-inline-I2RDEBAK.js → context-mentions-inline-QVESLW55.js} +1 -1
  44. package/dist/context-mentions.cjs +7 -7
  45. package/dist/context-mentions.d.cts +243 -8
  46. package/dist/context-mentions.d.ts +243 -8
  47. package/dist/context-mentions.js +8 -8
  48. package/dist/event-stream-view-FOIJHFQO.js +1 -0
  49. package/dist/event-stream-view.cjs +1 -1
  50. package/dist/event-stream-view.d.cts +246 -31
  51. package/dist/event-stream-view.d.ts +246 -31
  52. package/dist/event-stream-view.js +1 -1
  53. package/dist/forms-ui.d.cts +295 -667
  54. package/dist/forms-ui.d.ts +295 -667
  55. package/dist/history-shell-QDQ44PSE.js +3 -0
  56. package/dist/history-shell.js +3 -0
  57. package/dist/{history-view-BD6OXLNA.js → history-view-NCFBI5GR.js} +2 -2
  58. package/dist/history-view.cjs +2 -2
  59. package/dist/history-view.d.cts +35 -1
  60. package/dist/history-view.d.ts +35 -1
  61. package/dist/history-view.js +2 -2
  62. package/dist/index.cjs +80 -74
  63. package/dist/index.cjs.map +1 -1
  64. package/dist/index.d.cts +301 -738
  65. package/dist/index.d.ts +301 -738
  66. package/dist/index.global.js +3 -215
  67. package/dist/index.global.js.map +1 -1
  68. package/dist/index.js +81 -75
  69. package/dist/index.js.map +1 -1
  70. package/dist/install.global.js +1 -1
  71. package/dist/install.global.js.map +1 -1
  72. package/dist/launcher.global.js +3 -67
  73. package/dist/launcher.global.js.map +1 -1
  74. package/dist/markdown-parsers.js +22 -22
  75. package/dist/runtype-tts-entry-NFBVWR7N.js +1 -0
  76. package/dist/runtype-tts.js +1 -1
  77. package/dist/session-actions-ISSKSI66.js +1 -0
  78. package/dist/session-actions.js +1 -0
  79. package/dist/session-reconnect.d.cts +243 -8
  80. package/dist/session-reconnect.d.ts +243 -8
  81. package/dist/smart-dom-reader.cjs +13 -13
  82. package/dist/smart-dom-reader.d.cts +243 -8
  83. package/dist/smart-dom-reader.d.ts +243 -8
  84. package/dist/smart-dom-reader.js +11 -11
  85. package/dist/theme-editor-preview.cjs +116 -98
  86. package/dist/theme-editor-preview.d.cts +245 -8
  87. package/dist/theme-editor-preview.d.ts +245 -8
  88. package/dist/theme-editor-preview.js +49 -54
  89. package/dist/theme-editor.cjs +2 -2
  90. package/dist/theme-editor.d.cts +244 -23
  91. package/dist/theme-editor.d.ts +244 -23
  92. package/dist/theme-editor.js +2 -2
  93. package/dist/theme-reference.d.cts +14 -0
  94. package/dist/theme-reference.d.ts +14 -0
  95. package/dist/ui-extras-entry-WAYFKVNW.js +3 -0
  96. package/dist/ui-extras.js +72 -0
  97. package/dist/voice-runtime-UOQOFAFY.js +17 -0
  98. package/dist/voice-runtime.cjs +17 -1
  99. package/dist/voice-runtime.d.cts +11643 -192
  100. package/dist/voice-runtime.d.ts +11643 -192
  101. package/dist/voice-runtime.js +17 -1
  102. package/dist/voice-worklet-player.cjs +19 -4
  103. package/dist/voice-worklet-player.d.cts +9 -10
  104. package/dist/voice-worklet-player.d.ts +9 -10
  105. package/dist/voice-worklet-player.js +19 -4
  106. package/dist/webmcp-polyfill.js +1 -1
  107. package/dist/webmcp-runtime-V6ZWW3PI.js +6 -0
  108. package/dist/webmcp-runtime.cjs +2 -2
  109. package/dist/webmcp-runtime.d.cts +1 -33
  110. package/dist/webmcp-runtime.d.ts +1 -33
  111. package/dist/webmcp-runtime.js +2 -2
  112. package/dist/widget.css +1 -1
  113. package/package.json +11 -5
  114. package/src/approval-paused-tool-settle.test.ts +221 -0
  115. package/src/client-approve.test.ts +311 -0
  116. package/src/client-history-eager.ts +9 -0
  117. package/src/client-history-loader.ts +23 -0
  118. package/src/client-history.ts +579 -0
  119. package/src/client-resume.ts +177 -0
  120. package/src/client-stream-eager.ts +12 -0
  121. package/src/client-stream-loader.ts +25 -0
  122. package/src/client-stream.ts +1880 -0
  123. package/src/client.test.ts +31 -1
  124. package/src/client.ts +293 -2607
  125. package/src/components/approval-actions.test.ts +27 -0
  126. package/src/components/approval-actions.ts +27 -3
  127. package/src/components/message-bubble.test.ts +19 -0
  128. package/src/components/message-bubble.ts +2 -0
  129. package/src/components/registry.ts +6 -6
  130. package/src/components/tool-bubble.test.ts +9 -0
  131. package/src/components/tool-bubble.ts +1 -0
  132. package/src/history-shell-bundle.test.ts +54 -0
  133. package/src/history-shell-eager.ts +12 -0
  134. package/src/history-shell-loader.ts +27 -0
  135. package/src/history-shell.ts +2362 -0
  136. package/src/history-view-loader.ts +2 -0
  137. package/src/history-view.ts +3 -0
  138. package/src/index-core.ts +8 -0
  139. package/src/index-global.ts +90 -248
  140. package/src/index.ts +13 -0
  141. package/src/install.ts +6 -4
  142. package/src/internal/runtype-history-provider.test.ts +1 -0
  143. package/src/internal/runtype-history-provider.ts +28 -13
  144. package/src/plugins/registry.ts +8 -8
  145. package/src/session-actions-chunk.test.ts +55 -0
  146. package/src/session-actions-eager.ts +11 -0
  147. package/src/session-actions-loader.ts +37 -0
  148. package/src/session-actions.ts +909 -0
  149. package/src/session-history.test.ts +3 -1
  150. package/src/session.ts +875 -1530
  151. package/src/session.voice.test.ts +1241 -5
  152. package/src/styles/widget.css +58 -0
  153. package/src/theme-editor/state.ts +91 -91
  154. package/src/types.ts +269 -5
  155. package/src/ui-ask-user-sheet.ts +382 -0
  156. package/src/ui-extras-eager.ts +10 -0
  157. package/src/ui-extras-entry.ts +9 -0
  158. package/src/ui-extras-loader.ts +26 -0
  159. package/src/ui.artifact-rail-resize.test.ts +2 -1
  160. package/src/ui.ask-user-chunk.test.ts +93 -0
  161. package/src/ui.history-chunk.test.ts +2 -1
  162. package/src/ui.history-config-provider.test.ts +2 -1
  163. package/src/ui.history-confirm-fallback.test.ts +142 -0
  164. package/src/ui.history-controller.test.ts +2 -1
  165. package/src/ui.history-lifecycle.test.ts +2 -1
  166. package/src/ui.history-render-hooks.test.ts +2 -1
  167. package/src/ui.history-shell.test.ts +2 -1
  168. package/src/ui.rail-overlay.test.ts +2 -1
  169. package/src/ui.rail-resize.test.ts +2 -1
  170. package/src/ui.rail-trigger-pending.test.ts +2 -1
  171. package/src/ui.ts +399 -2770
  172. package/src/ui.voice-prewarm.test.ts +222 -0
  173. package/src/utils/attachment-manager.ts +67 -67
  174. package/src/utils/constants.ts +3 -0
  175. package/src/utils/context-mention-controller.ts +323 -323
  176. package/src/utils/context-mention-manager.ts +40 -40
  177. package/src/utils/context-mention-orchestrator.ts +28 -0
  178. package/src/utils/event-stream-buffer.ts +50 -50
  179. package/src/utils/event-stream-store.ts +43 -43
  180. package/src/utils/throughput-tracker.ts +34 -34
  181. package/src/utils/virtual-scroller.ts +67 -67
  182. package/src/utils/visitor-store.test.ts +50 -0
  183. package/src/utils/visitor-store.ts +23 -5
  184. package/src/voice/__fixtures__/voice-wire/README.md +1 -0
  185. package/src/voice/__fixtures__/voice-wire/client/cancel.json +3 -0
  186. package/src/voice/__fixtures__/voice-wire/client/context.json +4 -0
  187. package/src/voice/__fixtures__/voice-wire/client/delegation_result.json +6 -0
  188. package/src/voice/__fixtures__/voice-wire/client/delegation_update.json +6 -0
  189. package/src/voice/__fixtures__/voice-wire/client/ping.json +3 -0
  190. package/src/voice/__fixtures__/voice-wire/client/playback_progress.json +5 -0
  191. package/src/voice/__fixtures__/voice-wire/client/start.json +3 -0
  192. package/src/voice/__fixtures__/voice-wire/server/artifact.json +11 -0
  193. package/src/voice/__fixtures__/voice-wire/server/attached.json +4 -0
  194. package/src/voice/__fixtures__/voice-wire/server/audio_clear.json +4 -0
  195. package/src/voice/__fixtures__/voice-wire/server/audio_end.json +3 -0
  196. package/src/voice/__fixtures__/voice-wire/server/delegation_cancelled.json +5 -0
  197. package/src/voice/__fixtures__/voice-wire/server/delegation_completed.json +8 -0
  198. package/src/voice/__fixtures__/voice-wire/server/delegation_started.json +15 -0
  199. package/src/voice/__fixtures__/voice-wire/server/error.json +6 -0
  200. package/src/voice/__fixtures__/voice-wire/server/metrics.json +10 -0
  201. package/src/voice/__fixtures__/voice-wire/server/pong.json +3 -0
  202. package/src/voice/__fixtures__/voice-wire/server/session_config.json +16 -0
  203. package/src/voice/__fixtures__/voice-wire/server/transcript_final.json +7 -0
  204. package/src/voice/__fixtures__/voice-wire/server/transcript_interim.json +4 -0
  205. package/src/voice/__fixtures__/voice-wire/server/transcript_partial.json +6 -0
  206. package/src/voice/__fixtures__/voice-wire/server/transcript_update.json +10 -0
  207. package/src/voice/__fixtures__/voice-wire/server/warning.json +5 -0
  208. package/src/voice/audio-playback-manager.test.ts +57 -0
  209. package/src/voice/audio-playback-manager.ts +120 -98
  210. package/src/voice/browser-voice-provider.ts +37 -37
  211. package/src/voice/fallback-speech-engine.ts +22 -16
  212. package/src/voice/keyed-voice-transcript.ts +314 -0
  213. package/src/voice/mic-state-styles.ts +182 -0
  214. package/src/voice/read-aloud-controller.ts +46 -44
  215. package/src/voice/runtype-speech-engine.ts +35 -33
  216. package/src/voice/runtype-voice-provider.ts +856 -164
  217. package/src/voice/session-voice-wiring.ts +266 -0
  218. package/src/voice/voice-delegation.test.ts +71 -0
  219. package/src/voice/voice-delegation.ts +364 -0
  220. package/src/voice/voice-turn-tracker.ts +6 -6
  221. package/src/voice/voice.test.ts +1250 -7
  222. package/src/voice/worklet-playback-engine.test.ts +81 -0
  223. package/src/voice/worklet-playback-engine.ts +21 -3
  224. package/src/voice-runtime-loader.ts +4 -1
  225. package/src/voice-runtime.ts +7 -0
  226. package/src/webmcp-runtime-entry.ts +41 -39
  227. package/dist/approval-ui-3NMYNXBD.js +0 -1
  228. package/dist/chunk-5EIIHQLQ.js +0 -1
  229. package/dist/chunk-7CO4T5KB.js +0 -1
  230. package/dist/chunk-EFJBUH5F.js +0 -1
  231. package/dist/chunk-R2TMIMDI.js +0 -3
  232. package/dist/chunk-UTVLOTWB.js +0 -9
  233. package/dist/context-mentions-45MODWOY.js +0 -161
  234. package/dist/event-stream-view-GL3ADRY2.js +0 -1
  235. package/dist/runtype-tts-entry-UJAEF7NZ.js +0 -1
  236. package/dist/voice-runtime-EFR3R5ES.js +0 -1
  237. package/dist/webmcp-runtime-JI7T4QYF.js +0 -6
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,47 +791,87 @@ 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
  }
910
812
  }
911
813
  }
912
814
 
815
+ /**
816
+ * Warm the voice path ahead of the mic click (`voiceRecognition.prewarm`).
817
+ * Session-owned providers (`runtype`, `custom`) get `provider.prewarm()` once
818
+ * the lazy voice runtime has installed them; the browser (Web Speech) path
819
+ * warms the client-token session instead. Fire-and-forget and silent.
820
+ */
821
+ public prewarmVoice(): void {
822
+ if (!usesSessionVoice(this.config.voiceRecognition?.provider)) {
823
+ this.warmClientSession();
824
+ return;
825
+ }
826
+ const generation = this.#voiceSetupGeneration;
827
+ const run = () => {
828
+ const provider = this.#voiceProvider;
829
+ if (!provider?.prewarm || generation !== this.#voiceSetupGeneration) return;
830
+ if (this.#voiceActive || this.isBargeInActive()) return;
831
+ void Promise.resolve()
832
+ .then(() => provider.prewarm?.())
833
+ .catch(() => {});
834
+ };
835
+ if (!this.#voiceProvider && this.#voiceSetupPromise) {
836
+ void this.#voiceSetupPromise.then(run, () => {});
837
+ return;
838
+ }
839
+ run();
840
+ }
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
+
913
853
  /**
914
854
  * Cleanup voice resources
915
855
  */
916
856
  public cleanupVoice() {
917
- const notifyDisconnected = this.voiceProvider !== null;
918
- this.voiceSetupGeneration++;
919
- this.voiceSetupPromise = null;
920
- if (this.voiceProvider) {
921
- const provider = this.voiceProvider;
922
- this.voiceProvider = null;
923
- 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);
924
864
  }
925
- this.voiceActive = false;
926
- this.voiceStatus = 'disconnected';
927
- this.settlePendingVoiceTurn(true);
928
- 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();
929
871
  if (notifyDisconnected) this.callbacks.onVoiceStatusChanged?.('disconnected');
930
872
  }
931
873
 
932
- private async disconnectVoice(provider: VoiceProvider): Promise<void> {
874
+ async #disconnectVoice(provider: VoiceProvider): Promise<void> {
933
875
  try {
934
876
  await provider.disconnect();
935
877
  } catch (error) {
@@ -937,13 +879,13 @@ export class AgentWidgetSession {
937
879
  }
938
880
  }
939
881
 
940
- private settlePendingVoiceTurn(includeUser: boolean): void {
941
- const userId = includeUser ? this.pendingVoiceUserMessageId : null;
942
- const assistantId = this.pendingVoiceAssistantMessageId;
882
+ #settlePendingVoiceTurn(includeUser: boolean): void {
883
+ const userId = includeUser ? this.#pendingVoiceUserMessageId : null;
884
+ const assistantId = this.#pendingVoiceAssistantMessageId;
943
885
  if (!userId && !assistantId) return;
944
- if (assistantId) this.voiceTurns.cancel();
945
- if (includeUser) this.pendingVoiceUserMessageId = null;
946
- this.pendingVoiceAssistantMessageId = null;
886
+ if (assistantId) this.#voiceTurns.cancel();
887
+ if (includeUser) this.#pendingVoiceUserMessageId = null;
888
+ this.#pendingVoiceAssistantMessageId = null;
947
889
  if (assistantId) this.ttsSpokenMessageIds.add(assistantId);
948
890
  this.messages = this.messages.flatMap((message) => {
949
891
  if (message.id !== userId && message.id !== assistantId) return [message];
@@ -951,7 +893,7 @@ export class AgentWidgetSession {
951
893
  return [{ ...message, streaming: false, voiceProcessing: false }];
952
894
  });
953
895
  this.callbacks.onMessagesChanged([...this.messages]);
954
- if (assistantId) this.setStreaming(false);
896
+ if (assistantId) this.#setStreaming(false);
955
897
  }
956
898
 
957
899
  /**
@@ -975,7 +917,13 @@ export class AgentWidgetSession {
975
917
  clientToken: providerConfig.runtype?.clientToken ?? this.config.clientToken,
976
918
  host: providerConfig.runtype?.host ?? this.config.apiUrl,
977
919
  voiceId: providerConfig.runtype?.voiceId,
978
- createPlaybackEngine: providerConfig.runtype?.createPlaybackEngine
920
+ createPlaybackEngine: providerConfig.runtype?.createPlaybackEngine,
921
+ prewarmMode: providerConfig.runtype?.prewarmMode,
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
979
927
  }
980
928
  };
981
929
 
@@ -1017,9 +965,9 @@ export class AgentWidgetSession {
1017
965
  await this.historyInternals.historyBootstrapReady;
1018
966
  // Snapshot before init overwrites it: boot reconciliation compares the
1019
967
  // init revision against what the LAST page load persisted.
1020
- this.bootConversationRevision =
968
+ this.#bootConversationRevision =
1021
969
  this.historyInternals.getStoredConversationRevision?.() ?? null;
1022
- this.bootRevisionCaptured = true;
970
+ this.#bootRevisionCaptured = true;
1023
971
  }
1024
972
 
1025
973
  try {
@@ -1056,7 +1004,7 @@ export class AgentWidgetSession {
1056
1004
  */
1057
1005
  public setClientSession(session: ClientSession, pristine?: boolean): void {
1058
1006
  this.clientSession = session;
1059
- this.injectWelcomeMessage(session, pristine);
1007
+ this.#injectWelcomeMessage(session, pristine);
1060
1008
  }
1061
1009
 
1062
1010
  /**
@@ -1064,11 +1012,11 @@ export class AgentWidgetSession {
1064
1012
  * reopening a stored conversation is not a new thread, and the hydrated
1065
1013
  * transcript would otherwise race a welcome bubble.
1066
1014
  */
1067
- private injectWelcomeMessage(
1015
+ #injectWelcomeMessage(
1068
1016
  session: ClientSession,
1069
1017
  pristine = !this.messages.length
1070
1018
  ): void {
1071
- if (this.suppressWelcomeInjection) return;
1019
+ if (this.#suppressWelcomeInjection) return;
1072
1020
  if (!session.config.welcomeMessage || !pristine) return;
1073
1021
  // A late welcome (the first send raced the init) sorts ahead of that turn.
1074
1022
  const first = this.messages[0];
@@ -1077,9 +1025,9 @@ export class AgentWidgetSession {
1077
1025
  role: "assistant",
1078
1026
  content: session.config.welcomeMessage,
1079
1027
  createdAt: first?.createdAt ?? new Date().toISOString(),
1080
- sequence: first ? (first.sequence ?? 0) - 1 : this.nextSequence()
1028
+ sequence: first ? (first.sequence ?? 0) - 1 : this.#nextSequence()
1081
1029
  };
1082
- this.appendMessage(welcomeMessage);
1030
+ this.#appendMessage(welcomeMessage);
1083
1031
  }
1084
1032
 
1085
1033
  /**
@@ -1155,17 +1103,17 @@ export class AgentWidgetSession {
1155
1103
  * client and any rebuilt one share the same store.
1156
1104
  */
1157
1105
  public setHistoryInternals(internals: WidgetHistoryInternals): void {
1158
- this.historyInternals = this.composeHistoryInternals(internals);
1106
+ this.historyInternals = this.#composeHistoryInternals(internals);
1159
1107
  this.client.setHistoryInternals(this.historyInternals);
1160
- this.syncVisitorStoreSubscription();
1108
+ this.#syncVisitorStoreSubscription();
1161
1109
  }
1162
1110
 
1163
1111
  /** Widget teardown: release the visitor-store subscription. */
1164
1112
  public destroy(): void {
1165
1113
  this.cleanupVoice();
1166
- this.visitorStoreUnsubscribe?.();
1167
- this.visitorStoreUnsubscribe = null;
1168
- this.subscribedVisitorStore = null;
1114
+ this.#visitorStoreUnsubscribe?.();
1115
+ this.#visitorStoreUnsubscribe = null;
1116
+ this.#subscribedVisitorStore = null;
1169
1117
  }
1170
1118
 
1171
1119
  // ==========================================================================
@@ -1177,25 +1125,25 @@ export class AgentWidgetSession {
1177
1125
  * supplied its own observer: a credential/record break must wipe local state
1178
1126
  * before the replacement session becomes sendable.
1179
1127
  */
1180
- private composeHistoryInternals(
1128
+ #composeHistoryInternals(
1181
1129
  internals: WidgetHistoryInternals
1182
1130
  ): WidgetHistoryInternals {
1183
1131
  const hostContinuity = internals.onHistoryContinuityChanged;
1184
1132
  return {
1185
1133
  ...internals,
1186
1134
  onHistoryContinuityChanged: (info) => {
1187
- this.handleHistoryContinuityChanged(info);
1135
+ this.#handleHistoryContinuityChanged(info);
1188
1136
  hostContinuity?.(info);
1189
1137
  },
1190
1138
  };
1191
1139
  }
1192
1140
 
1193
- private get historyProvider(): HistoryProvider | null {
1141
+ get #historyProvider(): HistoryProvider | null {
1194
1142
  return this.historyInternals.historyProvider ?? null;
1195
1143
  }
1196
1144
 
1197
- private requireHistoryProvider(): HistoryProvider {
1198
- const provider = this.historyProvider;
1145
+ #requireHistoryProvider(): HistoryProvider {
1146
+ const provider = this.#historyProvider;
1199
1147
  if (!provider) {
1200
1148
  throw new SessionHistoryError(
1201
1149
  "history_unavailable",
@@ -1209,8 +1157,8 @@ export class AgentWidgetSession {
1209
1157
  * One resolved scope per logical action. An explicitly requested scope the
1210
1158
  * provider cannot serve fails locally rather than silently downgrading.
1211
1159
  */
1212
- private historyContext(scope?: HistoryScope): HistoryOperationContext {
1213
- const provider = this.requireHistoryProvider();
1160
+ #historyContext(scope?: HistoryScope): HistoryOperationContext {
1161
+ const provider = this.#requireHistoryProvider();
1214
1162
  const requested = scope ?? this.config.features?.history?.scope;
1215
1163
  const derived: HistoryScope = this.config.getIdentityProof
1216
1164
  ? "verified-user"
@@ -1233,23 +1181,23 @@ export class AgentWidgetSession {
1233
1181
 
1234
1182
  public getHistoryState(): SessionHistoryState {
1235
1183
  return {
1236
- activeConversationId: this.activeConversationId,
1237
- conversationRevision: this.activeConversationRevision,
1238
- nextMessageCursor: this.historyNextMessageCursor,
1239
- recovery: this.historyRecovery,
1240
- sendBlocked: this.historySendBlocked,
1184
+ activeConversationId: this.#activeConversationId,
1185
+ conversationRevision: this.#activeConversationRevision,
1186
+ nextMessageCursor: this.#historyNextMessageCursor,
1187
+ recovery: this.#historyRecovery,
1188
+ sendBlocked: this.#historySendBlocked,
1241
1189
  };
1242
1190
  }
1243
1191
 
1244
1192
  public getActiveConversationId(): string | null {
1245
- return this.activeConversationId;
1193
+ return this.#activeConversationId;
1246
1194
  }
1247
1195
 
1248
- private emitHistoryState(): void {
1196
+ #emitHistoryState(): void {
1249
1197
  this.callbacks.onHistoryStateChanged?.(this.getHistoryState());
1250
1198
  }
1251
1199
 
1252
- private notifyHistory(
1200
+ #notifyHistory(
1253
1201
  code: SessionHistoryNoticeCode,
1254
1202
  message: string
1255
1203
  ): void {
@@ -1258,13 +1206,13 @@ export class AgentWidgetSession {
1258
1206
 
1259
1207
  /** Pending destructive/reconciliation work every other operation waits on. */
1260
1208
  public async awaitHistorySettled(): Promise<void> {
1261
- const gate = this.historyGate;
1209
+ const gate = this.#historyGate;
1262
1210
  if (gate) await gate.catch(() => {});
1263
- const finalization = this.projectionFinalizationPromise;
1211
+ const finalization = this.#projectionFinalizationPromise;
1264
1212
  if (finalization) await finalization.catch(() => {});
1265
1213
  }
1266
1214
 
1267
- private assertHistoryIdle(): void {
1215
+ #assertHistoryIdle(): void {
1268
1216
  if (this.streaming || this.status === "paused" || this.status === "resuming") {
1269
1217
  throw new SessionHistoryError(
1270
1218
  "conversation_busy",
@@ -1279,12 +1227,12 @@ export class AgentWidgetSession {
1279
1227
  targetId?: string;
1280
1228
  scope?: HistoryScope;
1281
1229
  }) {
1282
- const provider = this.requireHistoryProvider();
1230
+ const provider = this.#requireHistoryProvider();
1283
1231
  return provider.list({
1284
1232
  ...(opts?.cursor ? { cursor: opts.cursor } : {}),
1285
1233
  ...(opts?.limit !== undefined ? { limit: opts.limit } : {}),
1286
1234
  ...(opts?.targetId !== undefined ? { targetId: opts.targetId } : {}),
1287
- context: this.historyContext(opts?.scope),
1235
+ context: this.#historyContext(opts?.scope),
1288
1236
  });
1289
1237
  }
1290
1238
 
@@ -1300,46 +1248,46 @@ export class AgentWidgetSession {
1300
1248
  summary: HistoryConversationSummary;
1301
1249
  nextMessageCursor: string | null;
1302
1250
  }> {
1303
- const provider = this.requireHistoryProvider();
1304
- this.assertHistoryIdle();
1305
- const context = this.historyContext(opts?.scope);
1251
+ const provider = this.#requireHistoryProvider();
1252
+ this.#assertHistoryIdle();
1253
+ const context = this.#historyContext(opts?.scope);
1306
1254
  await this.awaitHistorySettled();
1307
- const epoch = ++this.historyOpenEpoch;
1255
+ const epoch = ++this.#historyOpenEpoch;
1308
1256
 
1309
1257
  const page = await provider.getPage(id, { context });
1310
- if (epoch !== this.historyOpenEpoch) {
1258
+ if (epoch !== this.#historyOpenEpoch) {
1311
1259
  throw new SessionHistoryError("superseded", "A later selection won");
1312
1260
  }
1313
1261
 
1314
1262
  const prepared = await provider.prepareOpen(id, { context });
1315
- if (epoch !== this.historyOpenEpoch) {
1263
+ if (epoch !== this.#historyOpenEpoch) {
1316
1264
  prepared.discard();
1317
1265
  throw new SessionHistoryError("superseded", "A later selection won");
1318
1266
  }
1319
1267
 
1320
1268
  // The reopened transcript is not a new conversation: never inject welcome.
1321
- this.suppressWelcomeInjection = true;
1269
+ this.#suppressWelcomeInjection = true;
1322
1270
  try {
1323
- await this.commitActivation(prepared);
1271
+ await this.#commitActivation(prepared);
1324
1272
  } finally {
1325
- this.suppressWelcomeInjection = false;
1273
+ this.#suppressWelcomeInjection = false;
1326
1274
  }
1327
1275
 
1328
- this.activeConversationId = prepared.conversationId || id;
1329
- this.activeConversationRevision =
1276
+ this.#activeConversationId = prepared.conversationId || id;
1277
+ this.#activeConversationRevision =
1330
1278
  prepared.conversationRevision || page.conversationRevision || null;
1331
- this.historyNextMessageCursor = page.nextCursor;
1332
- this.historyRecovery = null;
1333
- this.historySendBlocked = false;
1334
- this.resetConversationScopedState();
1279
+ this.#historyNextMessageCursor = page.nextCursor;
1280
+ this.#historyRecovery = null;
1281
+ this.#historySendBlocked = false;
1282
+ this.#resetConversationScopedState();
1335
1283
  // Server projections are by definition acknowledged.
1336
1284
  for (const message of page.messages) {
1337
- this.acknowledgedProjections.set(message.id, message.content);
1285
+ this.#acknowledgedProjections.set(message.id, message.content);
1338
1286
  }
1339
1287
  // Metadata first, transcript last: the transcript emit is what persists.
1340
- this.persistConversationMetadata();
1288
+ this.#persistConversationMetadata();
1341
1289
  this.hydrateMessages(page.messages);
1342
- this.emitHistoryState();
1290
+ this.#emitHistoryState();
1343
1291
  return { summary: page.summary, nextMessageCursor: page.nextCursor };
1344
1292
  }
1345
1293
 
@@ -1352,25 +1300,25 @@ export class AgentWidgetSession {
1352
1300
  cursor?: string | null,
1353
1301
  opts?: { scope?: HistoryScope }
1354
1302
  ): Promise<HistoryOlderPage> {
1355
- const provider = this.requireHistoryProvider();
1356
- const context = this.historyContext(opts?.scope);
1357
- const resolvedCursor = cursor ?? this.historyNextMessageCursor;
1303
+ const provider = this.#requireHistoryProvider();
1304
+ const context = this.#historyContext(opts?.scope);
1305
+ const resolvedCursor = cursor ?? this.#historyNextMessageCursor;
1358
1306
  if (!resolvedCursor) {
1359
1307
  return Promise.resolve({ messages: [], nextMessageCursor: null });
1360
1308
  }
1361
1309
  const key = `${id} ${resolvedCursor}`;
1362
- const inflight = this.olderPageRequests.get(key);
1310
+ const inflight = this.#olderPageRequests.get(key);
1363
1311
  if (inflight) return inflight;
1364
1312
 
1365
- const request = this.fetchOlderPage(provider, id, resolvedCursor, context)
1313
+ const request = this.#fetchOlderPage(provider, id, resolvedCursor, context)
1366
1314
  .finally(() => {
1367
- this.olderPageRequests.delete(key);
1315
+ this.#olderPageRequests.delete(key);
1368
1316
  });
1369
- this.olderPageRequests.set(key, request);
1317
+ this.#olderPageRequests.set(key, request);
1370
1318
  return request;
1371
1319
  }
1372
1320
 
1373
- private async fetchOlderPage(
1321
+ async #fetchOlderPage(
1374
1322
  provider: HistoryProvider,
1375
1323
  id: string,
1376
1324
  cursor: string,
@@ -1383,20 +1331,20 @@ export class AgentWidgetSession {
1383
1331
  context,
1384
1332
  });
1385
1333
  // A conversation switch during the fetch retires this page.
1386
- if (this.activeConversationId && this.activeConversationId !== id) {
1334
+ if (this.#activeConversationId && this.#activeConversationId !== id) {
1387
1335
  return { messages: [], nextMessageCursor: page.nextCursor };
1388
1336
  }
1389
1337
  const merged = mergeWireMessagesById(this.messages, page.messages);
1390
- this.messages = this.sortMessages(
1391
- merged.map((message) => this.ensureSequence(message))
1338
+ this.messages = this.#sortMessages(
1339
+ merged.map((message) => this.#ensureSequence(message))
1392
1340
  );
1393
- this.historyNextMessageCursor = page.nextCursor;
1341
+ this.#historyNextMessageCursor = page.nextCursor;
1394
1342
  for (const message of page.messages) {
1395
- this.acknowledgedProjections.set(message.id, message.content);
1343
+ this.#acknowledgedProjections.set(message.id, message.content);
1396
1344
  }
1397
1345
  this.historyInternals.setStoredMessageCursor?.(page.nextCursor);
1398
1346
  this.callbacks.onMessagesChanged([...this.messages]);
1399
- this.emitHistoryState();
1347
+ this.#emitHistoryState();
1400
1348
  return { messages: page.messages, nextMessageCursor: page.nextCursor };
1401
1349
  }
1402
1350
 
@@ -1407,34 +1355,34 @@ export class AgentWidgetSession {
1407
1355
  public async startNewConversation(opts?: {
1408
1356
  scope?: HistoryScope;
1409
1357
  }): Promise<void> {
1410
- const provider = this.requireHistoryProvider();
1411
- const context = this.historyContext(opts?.scope);
1358
+ const provider = this.#requireHistoryProvider();
1359
+ const context = this.#historyContext(opts?.scope);
1412
1360
  await this.awaitHistorySettled();
1413
- const epoch = ++this.historyOpenEpoch;
1361
+ const epoch = ++this.#historyOpenEpoch;
1414
1362
  const prepared = await provider.prepareStartNew({ context });
1415
- if (epoch !== this.historyOpenEpoch) {
1363
+ if (epoch !== this.#historyOpenEpoch) {
1416
1364
  prepared.discard();
1417
1365
  throw new SessionHistoryError("superseded", "A later action won");
1418
1366
  }
1419
- await this.installFreshConversation(prepared);
1367
+ await this.#installFreshConversation(prepared);
1420
1368
  }
1421
1369
 
1422
1370
  /**
1423
1371
  * Clear local + persisted conversation state, then commit the replacement.
1424
1372
  * Welcome injection is deliberately allowed once the transcript is empty.
1425
1373
  */
1426
- private async installFreshConversation(
1374
+ async #installFreshConversation(
1427
1375
  prepared: PreparedHistoryActivation
1428
1376
  ): Promise<void> {
1429
- this.discardConversationState();
1430
- await this.commitActivation(prepared);
1431
- this.activeConversationId = prepared.conversationId || null;
1432
- this.activeConversationRevision = prepared.conversationRevision || null;
1433
- this.historyNextMessageCursor = null;
1434
- this.historyRecovery = null;
1435
- this.historySendBlocked = false;
1436
- this.persistConversationMetadata();
1437
- 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();
1438
1386
  }
1439
1387
 
1440
1388
  /**
@@ -1445,12 +1393,12 @@ export class AgentWidgetSession {
1445
1393
  id: string,
1446
1394
  opts?: { scope?: HistoryScope }
1447
1395
  ): Promise<void> {
1448
- const provider = this.requireHistoryProvider();
1449
- const context = this.historyContext(opts?.scope);
1396
+ const provider = this.#requireHistoryProvider();
1397
+ const context = this.#historyContext(opts?.scope);
1450
1398
  await this.awaitHistorySettled();
1451
1399
  await provider.delete(id, { context });
1452
- if (id !== this.activeConversationId) return;
1453
- await this.recoverFromDestroyedConversation(context);
1400
+ if (id !== this.#activeConversationId) return;
1401
+ await this.#recoverFromDestroyedConversation(context);
1454
1402
  }
1455
1403
 
1456
1404
  /** Visitor-scoped rename/star. Requires a provider with the update capability. */
@@ -1459,35 +1407,35 @@ export class AgentWidgetSession {
1459
1407
  patch: HistoryConversationPatch,
1460
1408
  opts?: { scope?: HistoryScope }
1461
1409
  ): Promise<HistoryConversationSummary> {
1462
- const provider = this.requireHistoryProvider();
1410
+ const provider = this.#requireHistoryProvider();
1463
1411
  if (!provider.update) {
1464
1412
  throw new SessionHistoryError(
1465
1413
  "history_unavailable",
1466
1414
  "This history provider cannot update conversations"
1467
1415
  );
1468
1416
  }
1469
- const context = this.historyContext(opts?.scope);
1417
+ const context = this.#historyContext(opts?.scope);
1470
1418
  await this.awaitHistorySettled();
1471
1419
  return provider.update(id, patch, { context });
1472
1420
  }
1473
1421
 
1474
1422
  /** True when the provider supports visitor-scoped rename/star. */
1475
1423
  public canUpdateConversations(): boolean {
1476
- return typeof this.historyProvider?.update === "function";
1424
+ return typeof this.#historyProvider?.update === "function";
1477
1425
  }
1478
1426
 
1479
1427
  public async clearConversationHistory(opts?: {
1480
1428
  targetId?: string;
1481
1429
  scope?: HistoryScope;
1482
1430
  }): Promise<{ deleted: number }> {
1483
- const provider = this.requireHistoryProvider();
1484
- const context = this.historyContext(opts?.scope);
1431
+ const provider = this.#requireHistoryProvider();
1432
+ const context = this.#historyContext(opts?.scope);
1485
1433
  await this.awaitHistorySettled();
1486
1434
  const result = await provider.deleteAll({
1487
1435
  ...(opts?.targetId !== undefined ? { targetId: opts.targetId } : {}),
1488
1436
  context,
1489
1437
  });
1490
- await this.recoverFromDestroyedConversation(context);
1438
+ await this.#recoverFromDestroyedConversation(context);
1491
1439
  return result;
1492
1440
  }
1493
1441
 
@@ -1498,65 +1446,65 @@ export class AgentWidgetSession {
1498
1446
  public async resetHistoryDevice(): Promise<{
1499
1447
  remoteRevocationConfirmed: boolean;
1500
1448
  }> {
1501
- const provider = this.requireHistoryProvider();
1449
+ const provider = this.#requireHistoryProvider();
1502
1450
  if (!provider.resetDevice) {
1503
1451
  throw new SessionHistoryError(
1504
1452
  "history_unavailable",
1505
1453
  "This history provider cannot reset the device"
1506
1454
  );
1507
1455
  }
1508
- this.historyOpenEpoch += 1;
1509
- this.projectionFinalizationPromise = null;
1456
+ this.#historyOpenEpoch += 1;
1457
+ this.#projectionFinalizationPromise = null;
1510
1458
  try {
1511
1459
  return await provider.resetDevice();
1512
1460
  } finally {
1513
- this.discardConversationState();
1514
- this.activeConversationId = null;
1515
- this.activeConversationRevision = null;
1516
- this.historyNextMessageCursor = null;
1517
- this.historyRecovery = null;
1518
- this.historySendBlocked = false;
1519
- 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();
1520
1468
  }
1521
1469
  }
1522
1470
 
1523
- private async recoverFromDestroyedConversation(
1471
+ async #recoverFromDestroyedConversation(
1524
1472
  context: HistoryOperationContext
1525
1473
  ): Promise<void> {
1526
1474
  // Supersede anything still in flight for the record being destroyed.
1527
- this.historyOpenEpoch += 1;
1528
- this.projectionFinalizationPromise = null;
1529
- this.discardConversationState();
1530
- this.activeConversationId = null;
1531
- this.activeConversationRevision = null;
1532
- this.historyNextMessageCursor = null;
1533
- this.historySendBlocked = true;
1534
- this.historyRecovery = null;
1535
- this.emitHistoryState();
1536
- 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);
1537
1485
  }
1538
1486
 
1539
1487
  /**
1540
1488
  * Never reinstalls the deleted session. A failed preparation leaves an
1541
1489
  * explicit recoverable state instead of the old transcript.
1542
1490
  */
1543
- private async prepareReplacementConversation(
1491
+ async #prepareReplacementConversation(
1544
1492
  context: HistoryOperationContext
1545
1493
  ): Promise<void> {
1546
- const provider = this.historyProvider;
1494
+ const provider = this.#historyProvider;
1547
1495
  if (!provider) {
1548
- this.historySendBlocked = false;
1549
- this.emitHistoryState();
1496
+ this.#historySendBlocked = false;
1497
+ this.#emitHistoryState();
1550
1498
  return;
1551
1499
  }
1552
1500
  try {
1553
1501
  const prepared = await provider.prepareStartNew({ context });
1554
- await this.installFreshConversation(prepared);
1502
+ await this.#installFreshConversation(prepared);
1555
1503
  } catch {
1556
- this.historySendBlocked = true;
1557
- this.historyRecovery = "new_conversation_required";
1558
- this.emitHistoryState();
1559
- this.notifyHistory(
1504
+ this.#historySendBlocked = true;
1505
+ this.#historyRecovery = "new_conversation_required";
1506
+ this.#emitHistoryState();
1507
+ this.#notifyHistory(
1560
1508
  "new_conversation_required",
1561
1509
  "That conversation was deleted. Start a new conversation to continue."
1562
1510
  );
@@ -1564,13 +1512,13 @@ export class AgentWidgetSession {
1564
1512
  }
1565
1513
 
1566
1514
  /** The next explicit action retries a failed replacement preparation. */
1567
- private async retryRequiredConversation(): Promise<void> {
1568
- if (this.historyRecovery !== "new_conversation_required") return;
1569
- const provider = this.historyProvider;
1515
+ async #retryRequiredConversation(): Promise<void> {
1516
+ if (this.#historyRecovery !== "new_conversation_required") return;
1517
+ const provider = this.#historyProvider;
1570
1518
  if (!provider) return;
1571
- this.historyRecovery = null;
1572
- await this.prepareReplacementConversation(this.historyContext());
1573
- if (this.historyRecovery === "new_conversation_required") {
1519
+ this.#historyRecovery = null;
1520
+ await this.#prepareReplacementConversation(this.#historyContext());
1521
+ if (this.#historyRecovery === "new_conversation_required") {
1574
1522
  throw new SessionHistoryError(
1575
1523
  "new_conversation_required",
1576
1524
  "A new conversation is required before sending"
@@ -1583,7 +1531,7 @@ export class AgentWidgetSession {
1583
1531
  * session binding (`bindActivatedSession`); the fallback covers a shell that
1584
1532
  * has not wired that callback yet.
1585
1533
  */
1586
- private async commitActivation(
1534
+ async #commitActivation(
1587
1535
  prepared: PreparedHistoryActivation
1588
1536
  ): Promise<void> {
1589
1537
  await prepared.commit();
@@ -1602,61 +1550,62 @@ export class AgentWidgetSession {
1602
1550
  this.clientSession = session;
1603
1551
  this.config.setStoredSessionId?.(session.sessionId);
1604
1552
  if (session.conversationId) {
1605
- this.activeConversationId = session.conversationId;
1606
- this.activeConversationRevision = session.conversationRevision ?? null;
1553
+ this.#activeConversationId = session.conversationId;
1554
+ this.#activeConversationRevision = session.conversationRevision ?? null;
1607
1555
  this.config.setStoredConversationId?.(session.conversationId);
1608
1556
  this.historyInternals.setStoredConversationRevision?.(
1609
1557
  session.conversationRevision ?? null
1610
1558
  );
1611
1559
  }
1612
- if (!this.suppressWelcomeInjection) this.injectWelcomeMessage(session);
1560
+ if (!this.#suppressWelcomeInjection) this.#injectWelcomeMessage(session);
1613
1561
  }
1614
1562
 
1615
- private persistConversationMetadata(): void {
1616
- if (this.activeConversationId) {
1617
- this.config.setStoredConversationId?.(this.activeConversationId);
1563
+ #persistConversationMetadata(): void {
1564
+ if (this.#activeConversationId) {
1565
+ this.config.setStoredConversationId?.(this.#activeConversationId);
1618
1566
  } else {
1619
1567
  this.config.clearStoredConversationId?.();
1620
1568
  }
1621
1569
  this.historyInternals.setStoredConversationRevision?.(
1622
- this.activeConversationRevision
1570
+ this.#activeConversationRevision
1623
1571
  );
1624
1572
  this.historyInternals.setStoredMessageCursor?.(
1625
- this.historyNextMessageCursor
1573
+ this.#historyNextMessageCursor
1626
1574
  );
1627
1575
  }
1628
1576
 
1629
1577
  /** Conversation-scoped caches that must not leak across records. */
1630
- private resetConversationScopedState(): void {
1578
+ #resetConversationScopedState(): void {
1631
1579
  this.historyInternals.setStoredResumableHandle?.(null);
1632
1580
  this.agentExecution = null;
1633
- this.clearArtifactState();
1581
+ this.#clearArtifactState();
1634
1582
  this.webMcpInflightKeys.clear();
1635
1583
  this.webMcpResolvedKeys.clear();
1636
1584
  this.client.resetClientToolsFingerprint();
1637
- this.acknowledgedProjections.clear();
1638
- this.setPendingProjections(null);
1585
+ this.#acknowledgedProjections.clear();
1586
+ this.#setPendingProjections(null);
1639
1587
  }
1640
1588
 
1641
1589
  /**
1642
1590
  * Irreversible local + persisted clear. Used by new-conversation, active
1643
1591
  * deletion, 410 recovery, reset, and the continuity wipe.
1644
1592
  */
1645
- private discardConversationState(): void {
1593
+ #discardConversationState(): void {
1646
1594
  this.stopSpeaking();
1647
1595
  this.abortController?.abort();
1648
1596
  this.abortController = null;
1649
- this.teardownReconnect();
1650
- this.abortWebMcpResolves();
1597
+ this.#teardownReconnect();
1598
+ this.#abortWebMcpResolves();
1599
+ this.#actionsEpoch++;
1651
1600
  this.messages = [];
1652
- this.resetConversationScopedState();
1653
- this.olderPageRequests.clear();
1601
+ this.#resetConversationScopedState();
1602
+ this.#olderPageRequests.clear();
1654
1603
  this.config.clearStoredSessionId?.();
1655
1604
  this.config.clearStoredConversationId?.();
1656
1605
  this.historyInternals.setStoredConversationRevision?.(null);
1657
1606
  this.historyInternals.setStoredMessageCursor?.(null);
1658
- this.setStreaming(false);
1659
- this.setStatus("idle");
1607
+ this.#setStreaming(false);
1608
+ this.#setStatus("idle");
1660
1609
  this.callbacks.onMessagesChanged([...this.messages]);
1661
1610
  }
1662
1611
 
@@ -1665,27 +1614,27 @@ export class AgentWidgetSession {
1665
1614
  * cross-tab convergence, or credential expiry selected a different record.
1666
1615
  * Invalidate synchronously; dispatch stays blocked until cleanup completes.
1667
1616
  */
1668
- private handleHistoryContinuityChanged(info: {
1617
+ #handleHistoryContinuityChanged(info: {
1669
1618
  previousConversationId: string | null;
1670
1619
  conversationId: string;
1671
1620
  }): void {
1672
- this.historyOpenEpoch += 1;
1673
- this.projectionFinalizationPromise = null;
1674
- this.historySendBlocked = true;
1675
- this.discardConversationState();
1676
- this.activeConversationId = info.conversationId;
1677
- this.activeConversationRevision = null;
1678
- this.historyNextMessageCursor = null;
1679
- this.historyRecovery = null;
1680
- this.emitHistoryState();
1681
- 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(
1682
1631
  "history_continuity_reset",
1683
1632
  "This browser was reconnected to a different conversation, so the previous messages were cleared."
1684
1633
  );
1685
- this.historyGate = Promise.resolve().then(() => {
1686
- this.historySendBlocked = false;
1687
- this.historyGate = null;
1688
- this.emitHistoryState();
1634
+ this.#historyGate = Promise.resolve().then(() => {
1635
+ this.#historySendBlocked = false;
1636
+ this.#historyGate = null;
1637
+ this.#emitHistoryState();
1689
1638
  });
1690
1639
  }
1691
1640
 
@@ -1694,15 +1643,15 @@ export class AgentWidgetSession {
1694
1643
  * the store. Owned here so the transition sits beside the continuity wipe it
1695
1644
  * reuses.
1696
1645
  */
1697
- private syncVisitorStoreSubscription(): void {
1646
+ #syncVisitorStoreSubscription(): void {
1698
1647
  const store = this.historyInternals.visitorStore ?? null;
1699
- if (store === this.subscribedVisitorStore) return;
1700
- this.visitorStoreUnsubscribe?.();
1701
- this.visitorStoreUnsubscribe = null;
1702
- this.subscribedVisitorStore = store;
1648
+ if (store === this.#subscribedVisitorStore) return;
1649
+ this.#visitorStoreUnsubscribe?.();
1650
+ this.#visitorStoreUnsubscribe = null;
1651
+ this.#subscribedVisitorStore = store;
1703
1652
  if (!store) return;
1704
- this.visitorStoreUnsubscribe = store.subscribe((change) =>
1705
- this.handleExternalCredentialChange(change)
1653
+ this.#visitorStoreUnsubscribe = store.subscribe((change) =>
1654
+ this.#handleExternalCredentialChange(change)
1706
1655
  );
1707
1656
  }
1708
1657
 
@@ -1712,40 +1661,40 @@ export class AgentWidgetSession {
1712
1661
  * A "local" change is this tab's own mint/write and owns its init path; a
1713
1662
  * change out of an empty store is first-init convergence, not a break.
1714
1663
  */
1715
- private handleExternalCredentialChange(change: VisitorStoreChange): void {
1664
+ #handleExternalCredentialChange(change: VisitorStoreChange): void {
1716
1665
  if (change.source !== "external" || change.previousToken === null) return;
1717
1666
 
1718
1667
  // Nothing may reuse the session minted under the revoked credential.
1719
1668
  this.clientSession = null;
1720
1669
  this.client.handleExternalCredentialChange();
1721
- this.historyOpenEpoch += 1;
1722
- this.projectionFinalizationPromise = null;
1723
- this.credentialReinitPending = true;
1724
- this.credentialReinitPromise = null;
1670
+ this.#historyOpenEpoch += 1;
1671
+ this.#projectionFinalizationPromise = null;
1672
+ this.#credentialReinitPending = true;
1673
+ this.#credentialReinitPromise = null;
1725
1674
 
1726
1675
  const hadVisibleTranscript = this.messages.length > 0;
1727
1676
  const hadConversationState =
1728
1677
  hadVisibleTranscript ||
1729
- this.activeConversationId !== null ||
1678
+ this.#activeConversationId !== null ||
1730
1679
  (this.config.getStoredConversationId?.() ?? null) !== null;
1731
1680
  if (hadConversationState) {
1732
- this.discardConversationState();
1681
+ this.#discardConversationState();
1733
1682
  } else {
1734
1683
  // No conversation to wipe, but the revoked visitor's ids must not survive.
1735
1684
  this.config.clearStoredSessionId?.();
1736
1685
  this.config.clearStoredConversationId?.();
1737
- this.olderPageRequests.clear();
1738
- this.setPendingProjections(null);
1686
+ this.#olderPageRequests.clear();
1687
+ this.#setPendingProjections(null);
1739
1688
  }
1740
- this.activeConversationId = null;
1741
- this.activeConversationRevision = null;
1742
- this.historyNextMessageCursor = null;
1743
- this.historyRecovery = null;
1744
- this.emitHistoryState();
1689
+ this.#activeConversationId = null;
1690
+ this.#activeConversationRevision = null;
1691
+ this.#historyNextMessageCursor = null;
1692
+ this.#historyRecovery = null;
1693
+ this.#emitHistoryState();
1745
1694
  // Only a visible wipe announces: a silent reconcile must not push the open
1746
1695
  // history view into a refetch that would immediately re-mint a credential.
1747
1696
  if (hadVisibleTranscript) {
1748
- this.notifyHistory(
1697
+ this.#notifyHistory(
1749
1698
  "history_continuity_reset",
1750
1699
  "This browser's conversation history was reset in another tab, so the previous messages were cleared."
1751
1700
  );
@@ -1757,26 +1706,26 @@ export class AgentWidgetSession {
1757
1706
  * Deliberately lazy: re-initializing on the storage event itself would hand
1758
1707
  * an idle tab a fresh credential the visitor just deleted elsewhere.
1759
1708
  */
1760
- private completeCredentialReinit(): Promise<void> {
1761
- if (!this.credentialReinitPending) return Promise.resolve();
1762
- if (!this.credentialReinitPromise) {
1763
- this.credentialReinitPromise = this.runCredentialReinit().finally(() => {
1764
- 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;
1765
1714
  });
1766
1715
  }
1767
- return this.credentialReinitPromise;
1716
+ return this.#credentialReinitPromise;
1768
1717
  }
1769
1718
 
1770
- private async runCredentialReinit(): Promise<void> {
1719
+ async #runCredentialReinit(): Promise<void> {
1771
1720
  if (!this.isClientTokenMode()) {
1772
- this.credentialReinitPending = false;
1721
+ this.#credentialReinitPending = false;
1773
1722
  return;
1774
1723
  }
1775
1724
  try {
1776
1725
  // Reads the store again: a clear mints fresh, a replacement adopts the
1777
1726
  // sibling's token.
1778
1727
  this.bindActivatedSession(await this.client.initSession());
1779
- this.credentialReinitPending = false;
1728
+ this.#credentialReinitPending = false;
1780
1729
  } catch {
1781
1730
  // Still pending: the dispatch's own init reports the failure normally.
1782
1731
  }
@@ -1791,53 +1740,53 @@ export class AgentWidgetSession {
1791
1740
  public reconcileBootConversation(opts?: {
1792
1741
  scope?: HistoryScope;
1793
1742
  }): Promise<void> {
1794
- const provider = this.historyProvider;
1743
+ const provider = this.#historyProvider;
1795
1744
  const conversationId =
1796
1745
  this.clientSession?.conversationId ??
1797
1746
  this.config.getStoredConversationId?.() ??
1798
- this.activeConversationId;
1747
+ this.#activeConversationId;
1799
1748
  if (!provider || !conversationId) return Promise.resolve();
1800
1749
 
1801
- const context = this.historyContext(opts?.scope);
1802
- this.historySendBlocked = true;
1803
- this.emitHistoryState();
1804
- const run = this.runBootReconciliation(
1750
+ const context = this.#historyContext(opts?.scope);
1751
+ this.#historySendBlocked = true;
1752
+ this.#emitHistoryState();
1753
+ const run = this.#runBootReconciliation(
1805
1754
  provider,
1806
1755
  conversationId,
1807
1756
  context
1808
1757
  ).finally(() => {
1809
- this.historySendBlocked = this.historyRecovery !== null;
1810
- if (this.historyGate === run) this.historyGate = null;
1811
- this.emitHistoryState();
1758
+ this.#historySendBlocked = this.#historyRecovery !== null;
1759
+ if (this.#historyGate === run) this.#historyGate = null;
1760
+ this.#emitHistoryState();
1812
1761
  });
1813
- this.historyGate = run;
1762
+ this.#historyGate = run;
1814
1763
  return run;
1815
1764
  }
1816
1765
 
1817
- private async runBootReconciliation(
1766
+ async #runBootReconciliation(
1818
1767
  provider: HistoryProvider,
1819
1768
  conversationId: string,
1820
1769
  context: HistoryOperationContext
1821
1770
  ): Promise<void> {
1822
- this.activeConversationId = conversationId;
1771
+ this.#activeConversationId = conversationId;
1823
1772
  const initRevision =
1824
1773
  this.clientSession?.conversationRevision ??
1825
- this.activeConversationRevision ??
1774
+ this.#activeConversationRevision ??
1826
1775
  null;
1827
- const persistedRevision = this.bootRevisionCaptured
1828
- ? this.bootConversationRevision
1776
+ const persistedRevision = this.#bootRevisionCaptured
1777
+ ? this.#bootConversationRevision
1829
1778
  : (this.historyInternals.getStoredConversationRevision?.() ?? null);
1830
- this.bootRevisionCaptured = false;
1831
- this.historyNextMessageCursor =
1779
+ this.#bootRevisionCaptured = false;
1780
+ this.#historyNextMessageCursor =
1832
1781
  this.historyInternals.getStoredMessageCursor?.() ??
1833
- this.historyNextMessageCursor;
1782
+ this.#historyNextMessageCursor;
1834
1783
 
1835
1784
  // Any locally finalized projection still owed to the server goes first, so
1836
1785
  // display reconciliation cannot complete against a stale server record.
1837
1786
  await this.replayPendingProjections();
1838
1787
 
1839
1788
  if (initRevision && persistedRevision && initRevision === persistedRevision) {
1840
- this.activeConversationRevision = initRevision;
1789
+ this.#activeConversationRevision = initRevision;
1841
1790
  return;
1842
1791
  }
1843
1792
 
@@ -1849,9 +1798,9 @@ export class AgentWidgetSession {
1849
1798
  let reconciled: AgentWidgetMessage[];
1850
1799
  if (!overlaps) {
1851
1800
  // No shared id: the local transcript is not this record's newest window.
1852
- reconciled = page.messages.map((message) => this.ensureSequence(message));
1801
+ reconciled = page.messages.map((message) => this.#ensureSequence(message));
1853
1802
  } else {
1854
- const durableId = this.resumable ? this.activeAssistantMessageId : null;
1803
+ const durableId = this.#resumable ? this.#activeAssistantMessageId : null;
1855
1804
  const survivors = this.messages.filter((message) => {
1856
1805
  if (serverIds.has(message.id)) return true;
1857
1806
  if (message.id === durableId) return true;
@@ -1859,53 +1808,53 @@ export class AgentWidgetSession {
1859
1808
  return pageOldest !== undefined && message.createdAt < pageOldest;
1860
1809
  });
1861
1810
  reconciled = mergeWireMessagesById(survivors, page.messages).map(
1862
- (message) => this.ensureSequence(message)
1811
+ (message) => this.#ensureSequence(message)
1863
1812
  );
1864
1813
  }
1865
1814
 
1866
- this.historyNextMessageCursor = page.nextCursor;
1867
- this.activeConversationRevision =
1815
+ this.#historyNextMessageCursor = page.nextCursor;
1816
+ this.#activeConversationRevision =
1868
1817
  page.conversationRevision || initRevision || null;
1869
- this.acknowledgedProjections.clear();
1818
+ this.#acknowledgedProjections.clear();
1870
1819
  for (const message of page.messages) {
1871
- this.acknowledgedProjections.set(message.id, message.content);
1820
+ this.#acknowledgedProjections.set(message.id, message.content);
1872
1821
  }
1873
1822
  // Metadata first, transcript last: one persist covers both.
1874
- this.persistConversationMetadata();
1875
- this.messages = this.sortMessages(reconciled);
1823
+ this.#persistConversationMetadata();
1824
+ this.messages = this.#sortMessages(reconciled);
1876
1825
  this.callbacks.onMessagesChanged([...this.messages]);
1877
1826
  }
1878
1827
 
1879
1828
  // ── Display-projection finalization ────────────────────────────
1880
1829
 
1881
1830
  /** Runtype transport op: active session + browser scope, never a proof. */
1882
- private projectionFinalizationEnabled(): boolean {
1831
+ #projectionFinalizationEnabled(): boolean {
1883
1832
  return (
1884
1833
  this.config.features?.history?.enabled === true &&
1885
1834
  this.isClientTokenMode()
1886
1835
  );
1887
1836
  }
1888
1837
 
1889
- private setPendingProjections(pending: PendingDisplayProjections | null): void {
1890
- this.pendingProjections = pending;
1838
+ #setPendingProjections(pending: PendingDisplayProjections | null): void {
1839
+ this.#pendingProjections = pending;
1891
1840
  // Memory-only when the shell supplies no persistence seam.
1892
1841
  this.historyInternals.setPendingDisplayProjections?.(pending);
1893
1842
  }
1894
1843
 
1895
- private readPendingProjections(): PendingDisplayProjections | null {
1844
+ #readPendingProjections(): PendingDisplayProjections | null {
1896
1845
  return (
1897
1846
  this.historyInternals.getPendingDisplayProjections?.() ??
1898
- this.pendingProjections
1847
+ this.#pendingProjections
1899
1848
  );
1900
1849
  }
1901
1850
 
1902
1851
  /** Record what the dispatch payload carried as each message's projection. */
1903
- private recordDispatchedProjections(messages: AgentWidgetMessage[]): void {
1904
- if (!this.projectionFinalizationEnabled()) return;
1852
+ #recordDispatchedProjections(messages: AgentWidgetMessage[]): void {
1853
+ if (!this.#projectionFinalizationEnabled()) return;
1905
1854
  for (const message of messages) {
1906
1855
  const projection = divergentDisplayProjection(message);
1907
1856
  if (projection !== undefined) {
1908
- this.acknowledgedProjections.set(message.id, projection);
1857
+ this.#acknowledgedProjections.set(message.id, projection);
1909
1858
  }
1910
1859
  }
1911
1860
  }
@@ -1914,57 +1863,57 @@ export class AgentWidgetSession {
1914
1863
  * After a terminal assistant message, finalize any browser-derived projection
1915
1864
  * the server cannot have. Painting is never delayed by this.
1916
1865
  */
1917
- private scheduleProjectionFinalization(): void {
1918
- if (!this.projectionFinalizationEnabled()) return;
1919
- const conversationId = this.activeConversationId;
1866
+ #scheduleProjectionFinalization(): void {
1867
+ if (!this.#projectionFinalizationEnabled()) return;
1868
+ const conversationId = this.#activeConversationId;
1920
1869
  if (!conversationId) return;
1921
1870
  const batch: HistoryDisplayProjection[] = [];
1922
1871
  for (const message of this.messages) {
1923
1872
  if (message.role !== "assistant" || message.streaming) continue;
1924
1873
  const projection = divergentDisplayProjection(message);
1925
1874
  if (projection === undefined) continue;
1926
- if (this.acknowledgedProjections.get(message.id) === projection) continue;
1875
+ if (this.#acknowledgedProjections.get(message.id) === projection) continue;
1927
1876
  batch.push({ id: message.id, displayContent: projection });
1928
1877
  }
1929
1878
  if (batch.length === 0) return;
1930
- this.setPendingProjections({
1879
+ this.#setPendingProjections({
1931
1880
  conversationId,
1932
1881
  messageIds: batch.map((item) => item.id),
1933
1882
  });
1934
- this.enqueueProjectionFinalization(conversationId, batch);
1883
+ this.#enqueueProjectionFinalization(conversationId, batch);
1935
1884
  }
1936
1885
 
1937
1886
  /** Serialized: operations await this chain instead of racing it. */
1938
- private enqueueProjectionFinalization(
1887
+ #enqueueProjectionFinalization(
1939
1888
  conversationId: string,
1940
1889
  batch: HistoryDisplayProjection[]
1941
1890
  ): void {
1942
- const previous = this.projectionFinalizationPromise ?? Promise.resolve();
1891
+ const previous = this.#projectionFinalizationPromise ?? Promise.resolve();
1943
1892
  const chain = previous
1944
1893
  .catch(() => {})
1945
- .then(() => this.finalizeProjections(conversationId, batch, false))
1894
+ .then(() => this.#finalizeProjections(conversationId, batch, false))
1946
1895
  .finally(() => {
1947
- if (this.projectionFinalizationPromise === chain) {
1948
- this.projectionFinalizationPromise = null;
1896
+ if (this.#projectionFinalizationPromise === chain) {
1897
+ this.#projectionFinalizationPromise = null;
1949
1898
  }
1950
1899
  });
1951
- this.projectionFinalizationPromise = chain;
1900
+ this.#projectionFinalizationPromise = chain;
1952
1901
  }
1953
1902
 
1954
- private async finalizeProjections(
1903
+ async #finalizeProjections(
1955
1904
  conversationId: string,
1956
1905
  batch: HistoryDisplayProjection[],
1957
1906
  retried: boolean
1958
1907
  ): Promise<void> {
1959
1908
  // A switch/reset/delete already invalidated this marker.
1960
- if (this.activeConversationId !== conversationId) return;
1909
+ if (this.#activeConversationId !== conversationId) return;
1961
1910
  // Two terminal scans can enqueue the same batch before the first PATCH
1962
1911
  // acknowledges; the chain serializes them, so re-filter at run time.
1963
1912
  const unacknowledged = batch.filter(
1964
- (item) => this.acknowledgedProjections.get(item.id) !== item.displayContent
1913
+ (item) => this.#acknowledgedProjections.get(item.id) !== item.displayContent
1965
1914
  );
1966
1915
  if (unacknowledged.length === 0) {
1967
- this.clearFinalizedMarker(conversationId, batch);
1916
+ this.#clearFinalizedMarker(conversationId, batch);
1968
1917
  return;
1969
1918
  }
1970
1919
  try {
@@ -1973,15 +1922,15 @@ export class AgentWidgetSession {
1973
1922
  unacknowledged
1974
1923
  );
1975
1924
  for (const item of unacknowledged) {
1976
- this.acknowledgedProjections.set(item.id, item.displayContent);
1925
+ this.#acknowledgedProjections.set(item.id, item.displayContent);
1977
1926
  }
1978
- if (this.activeConversationId === conversationId && conversationRevision) {
1979
- this.activeConversationRevision = conversationRevision;
1927
+ if (this.#activeConversationId === conversationId && conversationRevision) {
1928
+ this.#activeConversationRevision = conversationRevision;
1980
1929
  }
1981
- this.clearFinalizedMarker(conversationId, batch);
1930
+ this.#clearFinalizedMarker(conversationId, batch);
1982
1931
  } catch (error) {
1983
- if (!retried && this.isTransientProjectionError(error)) {
1984
- await this.finalizeProjections(conversationId, unacknowledged, true);
1932
+ if (!retried && this.#isTransientProjectionError(error)) {
1933
+ await this.#finalizeProjections(conversationId, unacknowledged, true);
1985
1934
  return;
1986
1935
  }
1987
1936
  if (
@@ -1989,11 +1938,11 @@ export class AgentWidgetSession {
1989
1938
  (error.code === "not_found" || error.code === "conversation_deleted")
1990
1939
  ) {
1991
1940
  // The record is gone: drop the projection instead of retrying forever.
1992
- this.clearFinalizedMarker(conversationId, batch);
1941
+ this.#clearFinalizedMarker(conversationId, batch);
1993
1942
  return;
1994
1943
  }
1995
1944
  // Marker retained for the next boot/dispatch; the turn itself succeeded.
1996
- this.notifyHistory(
1945
+ this.#notifyHistory(
1997
1946
  "projection_finalization_failed",
1998
1947
  "Some message formatting could not be saved for history; it will be retried."
1999
1948
  );
@@ -2001,20 +1950,20 @@ export class AgentWidgetSession {
2001
1950
  }
2002
1951
 
2003
1952
  /** Transport-shaped failures only; a contract failure is not retried. */
2004
- private isTransientProjectionError(error: unknown): boolean {
1953
+ #isTransientProjectionError(error: unknown): boolean {
2005
1954
  if (error instanceof HistoryClientError) return error.code === "request_failed";
2006
1955
  return error instanceof Error;
2007
1956
  }
2008
1957
 
2009
- private clearFinalizedMarker(
1958
+ #clearFinalizedMarker(
2010
1959
  conversationId: string,
2011
1960
  batch: HistoryDisplayProjection[]
2012
1961
  ): void {
2013
- const pending = this.readPendingProjections();
1962
+ const pending = this.#readPendingProjections();
2014
1963
  if (!pending || pending.conversationId !== conversationId) return;
2015
1964
  const done = new Set(batch.map((item) => item.id));
2016
1965
  const remaining = pending.messageIds.filter((id) => !done.has(id));
2017
- this.setPendingProjections(
1966
+ this.#setPendingProjections(
2018
1967
  remaining.length ? { conversationId, messageIds: remaining } : null
2019
1968
  );
2020
1969
  }
@@ -2024,15 +1973,15 @@ export class AgentWidgetSession {
2024
1973
  * message content, then finalize before display reconciliation completes.
2025
1974
  */
2026
1975
  public async replayPendingProjections(): Promise<void> {
2027
- if (!this.projectionFinalizationEnabled()) return;
2028
- const pending = this.readPendingProjections();
1976
+ if (!this.#projectionFinalizationEnabled()) return;
1977
+ const pending = this.#readPendingProjections();
2029
1978
  if (!pending) return;
2030
1979
  if (
2031
- this.activeConversationId &&
2032
- pending.conversationId !== this.activeConversationId
1980
+ this.#activeConversationId &&
1981
+ pending.conversationId !== this.#activeConversationId
2033
1982
  ) {
2034
1983
  // Stale marker from another record: never replay it into this one.
2035
- this.setPendingProjections(null);
1984
+ this.#setPendingProjections(null);
2036
1985
  return;
2037
1986
  }
2038
1987
  const batch: HistoryDisplayProjection[] = [];
@@ -2042,33 +1991,33 @@ export class AgentWidgetSession {
2042
1991
  batch.push({ id, displayContent: message.content });
2043
1992
  }
2044
1993
  if (batch.length === 0) {
2045
- this.setPendingProjections(null);
1994
+ this.#setPendingProjections(null);
2046
1995
  return;
2047
1996
  }
2048
- this.enqueueProjectionFinalization(pending.conversationId, batch);
2049
- await this.projectionFinalizationPromise?.catch(() => {});
1997
+ this.#enqueueProjectionFinalization(pending.conversationId, batch);
1998
+ await this.#projectionFinalizationPromise?.catch(() => {});
2050
1999
  }
2051
2000
 
2052
2001
  /**
2053
2002
  * A local transcript mutation whose response carries no new revision: clear
2054
2003
  * the persisted one rather than guessing, so the next boot refreshes safely.
2055
2004
  */
2056
- private invalidateConversationRevision(): void {
2057
- if (!this.activeConversationId) return;
2058
- this.activeConversationRevision = null;
2005
+ #invalidateConversationRevision(): void {
2006
+ if (!this.#activeConversationId) return;
2007
+ this.#activeConversationRevision = null;
2059
2008
  this.historyInternals.setStoredConversationRevision?.(null);
2060
2009
  }
2061
2010
 
2062
2011
  /** Blocks a send until the owning history transition finishes. */
2063
- private async awaitHistorySendable(): Promise<void> {
2012
+ async #awaitHistorySendable(): Promise<void> {
2064
2013
  await this.awaitHistorySettled();
2065
- await this.completeCredentialReinit();
2066
- if (this.historyRecovery === "new_conversation_required") {
2067
- await this.retryRequiredConversation();
2014
+ await this.#completeCredentialReinit();
2015
+ if (this.#historyRecovery === "new_conversation_required") {
2016
+ await this.#retryRequiredConversation();
2068
2017
  }
2069
2018
  }
2070
2019
 
2071
- private static isConversationDeleted(error: unknown): boolean {
2020
+ static #isConversationDeleted(error: unknown): boolean {
2072
2021
  return (
2073
2022
  error instanceof HistoryClientError &&
2074
2023
  error.code === "conversation_deleted"
@@ -2079,14 +2028,14 @@ export class AgentWidgetSession {
2079
2028
  * Safe 410 recovery (D5). The old payload would recreate the transcript that
2080
2029
  * was just deleted, so the retry carries ONLY the just-submitted user turn.
2081
2030
  */
2082
- private async dispatchWithDeletedRecovery(
2031
+ async #dispatchWithDeletedRecovery(
2083
2032
  snapshot: AgentWidgetMessage[],
2084
2033
  controller: AbortController,
2085
2034
  assistantMessageId: string,
2086
2035
  userMessageId: string,
2087
2036
  turnOptions?: { composerOptions?: ComposerOptionsPayload; interrupt?: boolean }
2088
2037
  ): Promise<void> {
2089
- this.recordDispatchedProjections(snapshot);
2038
+ this.#recordDispatchedProjections(snapshot);
2090
2039
  try {
2091
2040
  await this.client.dispatch(
2092
2041
  {
@@ -2100,12 +2049,12 @@ export class AgentWidgetSession {
2100
2049
  );
2101
2050
  return;
2102
2051
  } catch (error) {
2103
- if (!AgentWidgetSession.isConversationDeleted(error)) throw error;
2104
- const replacement = await this.recoverFromDeletedConversation(userMessageId);
2052
+ if (!AgentWidgetSession.#isConversationDeleted(error)) throw error;
2053
+ const replacement = await this.#recoverFromDeletedConversation(userMessageId);
2105
2054
  // No recoverable turn (or no fresh record): surface the original 410.
2106
2055
  if (!replacement) throw error;
2107
2056
  // A second 410 propagates: exactly one recovery attempt per turn.
2108
- this.recordDispatchedProjections([replacement]);
2057
+ this.#recordDispatchedProjections([replacement]);
2109
2058
  await this.client.dispatch(
2110
2059
  {
2111
2060
  messages: [replacement],
@@ -2122,7 +2071,7 @@ export class AgentWidgetSession {
2122
2071
  * Capture only the newly submitted user message, destroy the deleted record's
2123
2072
  * local state, and initialize a fresh owned conversation to retry into.
2124
2073
  */
2125
- private async recoverFromDeletedConversation(
2074
+ async #recoverFromDeletedConversation(
2126
2075
  userMessageId: string
2127
2076
  ): Promise<AgentWidgetMessage | null> {
2128
2077
  const submitted = this.messages.find(
@@ -2131,44 +2080,44 @@ export class AgentWidgetSession {
2131
2080
  if (!submitted) return null;
2132
2081
  const captured: AgentWidgetMessage = { ...submitted };
2133
2082
 
2134
- this.historyOpenEpoch += 1;
2135
- this.projectionFinalizationPromise = null;
2136
- this.discardConversationState();
2137
- this.activeConversationId = null;
2138
- this.activeConversationRevision = null;
2139
- 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;
2140
2089
 
2141
2090
  // The recovered turn is the whole transcript: no welcome bubble in front.
2142
- this.suppressWelcomeInjection = true;
2091
+ this.#suppressWelcomeInjection = true;
2143
2092
  try {
2144
- const provider = this.historyProvider;
2093
+ const provider = this.#historyProvider;
2145
2094
  if (provider) {
2146
2095
  const prepared = await provider.prepareStartNew({
2147
- context: this.historyContext(),
2096
+ context: this.#historyContext(),
2148
2097
  });
2149
- await this.installFreshConversation(prepared);
2098
+ await this.#installFreshConversation(prepared);
2150
2099
  } else {
2151
2100
  this.client.clearClientSession();
2152
2101
  const session = await this.client.initSession();
2153
2102
  this.bindActivatedSession(session);
2154
2103
  }
2155
2104
  } catch {
2156
- this.historySendBlocked = true;
2157
- this.historyRecovery = "new_conversation_required";
2158
- this.emitHistoryState();
2159
- this.notifyHistory(
2105
+ this.#historySendBlocked = true;
2106
+ this.#historyRecovery = "new_conversation_required";
2107
+ this.#emitHistoryState();
2108
+ this.#notifyHistory(
2160
2109
  "new_conversation_required",
2161
2110
  "That conversation was deleted. Start a new conversation to continue."
2162
2111
  );
2163
2112
  return null;
2164
2113
  } finally {
2165
- this.suppressWelcomeInjection = false;
2114
+ this.#suppressWelcomeInjection = false;
2166
2115
  }
2167
2116
 
2168
2117
  // Restore only the user's own turn: it is the entire new transcript.
2169
- this.appendMessage(captured);
2170
- this.setStreaming(true);
2171
- this.notifyHistory(
2118
+ this.#appendMessage(captured);
2119
+ this.#setStreaming(true);
2120
+ this.#notifyHistory(
2172
2121
  "conversation_deleted_recovered",
2173
2122
  "That conversation was deleted. Started a new conversation."
2174
2123
  );
@@ -2200,7 +2149,7 @@ export class AgentWidgetSession {
2200
2149
  // turn that's restyling the widget and strand the paused execution.
2201
2150
  if (!replaceClient) {
2202
2151
  this.client.updateConfig(merged);
2203
- if (artifactDisplayChanged) this.refreshArtifactReferenceBlocks();
2152
+ if (artifactDisplayChanged) this.#refreshArtifactReferenceBlocks();
2204
2153
  return;
2205
2154
  }
2206
2155
 
@@ -2211,13 +2160,13 @@ export class AgentWidgetSession {
2211
2160
  // fresh client: in client-token mode that would POST /resume without a
2212
2161
  // valid sessionId and strand the paused turn. Mirrors clearMessages' WebMCP
2213
2162
  // reset; the client swap already abandons any in-flight stream regardless.
2214
- this.abortWebMcpResolves();
2163
+ this.#abortWebMcpResolves();
2215
2164
  this.webMcpInflightKeys.clear();
2216
2165
  this.webMcpResolvedKeys.clear();
2217
2166
  const prevSSECallback = this.client.getSSEEventCallback();
2218
2167
  this.client = new AgentWidgetClient(this.config, this.historyInternals);
2219
- if (artifactDisplayChanged) this.refreshArtifactReferenceBlocks();
2220
- this.wireDefaultWebMcpConfirm();
2168
+ if (artifactDisplayChanged) this.#refreshArtifactReferenceBlocks();
2169
+ this.#wireDefaultWebMcpConfirm();
2221
2170
  if (prevSSECallback) {
2222
2171
  this.client.setSSEEventCallback(prevSSECallback);
2223
2172
  }
@@ -2278,6 +2227,7 @@ export class AgentWidgetSession {
2278
2227
  sequence,
2279
2228
  streaming = false,
2280
2229
  voiceProcessing,
2230
+ voiceCaption,
2281
2231
  rawContent
2282
2232
  } = options;
2283
2233
 
@@ -2295,12 +2245,13 @@ export class AgentWidgetSession {
2295
2245
  role,
2296
2246
  content,
2297
2247
  createdAt: createdAt ?? new Date().toISOString(),
2298
- sequence: sequence ?? this.nextSequence(),
2248
+ sequence: sequence ?? this.#nextSequence(),
2299
2249
  streaming,
2300
2250
  // Only include optional fields if provided
2301
2251
  ...(llmContent !== undefined && { llmContent }),
2302
2252
  ...(contentParts !== undefined && { contentParts }),
2303
2253
  ...(voiceProcessing !== undefined && { voiceProcessing }),
2254
+ ...(voiceCaption && { voiceCaption }),
2304
2255
  ...(rawContent !== undefined && { rawContent })
2305
2256
  };
2306
2257
 
@@ -2398,7 +2349,7 @@ export class AgentWidgetSession {
2398
2349
  role,
2399
2350
  content,
2400
2351
  createdAt: createdAt ?? new Date().toISOString(),
2401
- sequence: sequence ?? this.nextSequence(),
2352
+ sequence: sequence ?? this.#nextSequence(),
2402
2353
  streaming,
2403
2354
  ...(llmContent !== undefined && { llmContent }),
2404
2355
  ...(contentParts !== undefined && { contentParts }),
@@ -2410,7 +2361,7 @@ export class AgentWidgetSession {
2410
2361
  }
2411
2362
 
2412
2363
  // Add all messages, sort once, notify once
2413
- this.messages = this.sortMessages([...this.messages, ...results]);
2364
+ this.messages = this.#sortMessages([...this.messages, ...results]);
2414
2365
  this.callbacks.onMessagesChanged([...this.messages]);
2415
2366
 
2416
2367
  return results;
@@ -2471,7 +2422,7 @@ export class AgentWidgetSession {
2471
2422
  * client to namespace into the request `context`. Failures were already
2472
2423
  * dropped by `finalize()`.
2473
2424
  */
2474
- private async applyMentionBundle(
2425
+ async #applyMentionBundle(
2475
2426
  userMessage: AgentWidgetMessage,
2476
2427
  typedText: string,
2477
2428
  finalize: () => Promise<MentionSubmitBundle>
@@ -2569,6 +2520,13 @@ export class AgentWidgetSession {
2569
2520
  * (`composer.streamingSubmitBehavior: "interrupt"`).
2570
2521
  */
2571
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 };
2572
2530
  }
2573
2531
  ) {
2574
2532
  const input = rawInput.trim();
@@ -2588,16 +2546,23 @@ export class AgentWidgetSession {
2588
2546
  )
2589
2547
  return;
2590
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
+
2591
2556
  // History transitions (continuity wipe, boot reconciliation, projection
2592
2557
  // finalization, replacement init, external credential change) own the
2593
2558
  // record this turn would land in.
2594
2559
  if (
2595
- this.historyGate ||
2596
- this.projectionFinalizationPromise ||
2597
- this.historySendBlocked ||
2598
- this.credentialReinitPending
2560
+ this.#historyGate ||
2561
+ this.#projectionFinalizationPromise ||
2562
+ this.#historySendBlocked ||
2563
+ this.#credentialReinitPending
2599
2564
  ) {
2600
- await this.awaitHistorySendable();
2565
+ await this.#awaitHistorySendable();
2601
2566
  }
2602
2567
 
2603
2568
  this.stopSpeaking();
@@ -2606,18 +2571,23 @@ export class AgentWidgetSession {
2606
2571
  // turn. Tear them down here (they own controllers separate from the shared
2607
2572
  // one) so a lingering resolve can't race the new dispatch or post a stale
2608
2573
  // /resume against a superseded execution.
2609
- this.abortWebMcpResolves();
2574
+ this.#abortWebMcpResolves();
2575
+ this.#actionsEpoch++;
2610
2576
  // A new turn also supersedes any pending durable reconnect from the prior
2611
2577
  // turn (cancels backoff/listeners, clears the old resume handle).
2612
- this.teardownReconnect();
2578
+ this.#teardownReconnect();
2613
2579
 
2580
+ const voiceBubbleId = options?.voiceTurn?.userMessageId;
2581
+ const voiceBubble = voiceBubbleId
2582
+ ? this.messages.find((m) => m.id === voiceBubbleId && m.role === "user")
2583
+ : undefined;
2614
2584
  // Generate IDs for both user message and expected assistant response
2615
- const userMessageId = generateUserMessageId();
2585
+ const userMessageId = voiceBubble?.id ?? generateUserMessageId();
2616
2586
  const assistantMessageId = generateAssistantMessageId();
2617
2587
  // The active assistant bubble for a durable reconnect is captured from the
2618
2588
  // real streamed message events (see handleEvent), not pre-assigned here:
2619
2589
  // the proxy path auto-generates a different id than `assistantMessageId`.
2620
- this.activeAssistantMessageId = null;
2590
+ this.#activeAssistantMessageId = null;
2621
2591
 
2622
2592
  // Fallback display text ONLY when the sole content is image attachments.
2623
2593
  // A mention/command-only submit (empty text + a chip) must NOT read as
@@ -2632,7 +2602,7 @@ export class AgentWidgetSession {
2632
2602
  role: "user",
2633
2603
  content: input || imageOnlyFallback, // Display text (fallback if only images)
2634
2604
  createdAt: new Date().toISOString(),
2635
- sequence: this.nextSequence(),
2605
+ sequence: this.#nextSequence(),
2636
2606
  viaVoice: options?.viaVoice || false,
2637
2607
  // Include contentParts if provided (for multi-modal messages)
2638
2608
  ...(options?.contentParts && options.contentParts.length > 0 && {
@@ -2652,8 +2622,17 @@ export class AgentWidgetSession {
2652
2622
  ...(options?.replayFields ?? {})
2653
2623
  };
2654
2624
 
2655
- this.appendMessage(userMessage);
2656
- 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);
2657
2636
 
2658
2637
  // Assign the fresh controller BEFORE the mention await so cancel() (or a
2659
2638
  // superseding sendMessage) during finalize() aborts THIS turn, not a stale
@@ -2670,7 +2649,7 @@ export class AgentWidgetSession {
2670
2649
  if (options?.mentions) {
2671
2650
  const stored =
2672
2651
  this.messages.find((m) => m.id === userMessageId) ?? userMessage;
2673
- await this.applyMentionBundle(stored, input, options.mentions.finalize);
2652
+ await this.#applyMentionBundle(stored, input, options.mentions.finalize);
2674
2653
  // A cancel() or new sendMessage during finalize aborted this controller
2675
2654
  // (and replaced/nulled the shared ref). Bail without dispatching and
2676
2655
  // leave whatever idle/streaming state that caller already set.
@@ -2700,10 +2679,17 @@ export class AgentWidgetSession {
2700
2679
  }
2701
2680
  }
2702
2681
 
2703
- 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];
2704
2690
 
2705
2691
  try {
2706
- await this.dispatchWithDeletedRecovery(
2692
+ await this.#dispatchWithDeletedRecovery(
2707
2693
  snapshot,
2708
2694
  controller,
2709
2695
  assistantMessageId,
@@ -2718,7 +2704,8 @@ export class AgentWidgetSession {
2718
2704
  // first, which already flipped us into `resuming` and armed reconnect.
2719
2705
  // The subsequent dispatch rejection must NOT paint a dispatch-error
2720
2706
  // bubble: the turn is being resumed, not failed.
2721
- if (this.status === "resuming" || this.reconnecting) return;
2707
+ if (this.status === "resuming" || this.#reconnecting) return;
2708
+ if (this.#voiceDelegation) this.#voiceDelegation.failed = true;
2722
2709
  // Check if this is an abort error (user canceled, navigated away, etc.)
2723
2710
  // In these cases, don't show fallback - the request was intentionally interrupted
2724
2711
  const isAbortError =
@@ -2740,15 +2727,15 @@ export class AgentWidgetSession {
2740
2727
  role: "assistant",
2741
2728
  createdAt: new Date().toISOString(),
2742
2729
  content,
2743
- sequence: this.nextSequence()
2730
+ sequence: this.#nextSequence()
2744
2731
  };
2745
2732
 
2746
- this.appendMessage(fallback);
2733
+ this.#appendMessage(fallback);
2747
2734
  }
2748
2735
  }
2749
2736
 
2750
- this.setStatus("idle");
2751
- this.setStreaming(false);
2737
+ this.#setStatus("idle");
2738
+ this.#setStreaming(false);
2752
2739
  this.abortController = null;
2753
2740
 
2754
2741
  if (!isAbortError) {
@@ -2816,7 +2803,7 @@ export class AgentWidgetSession {
2816
2803
  };
2817
2804
 
2818
2805
  const composerOptions = replacement
2819
- ? this.toComposerOptionsPayload(replacement.options)
2806
+ ? this.#toComposerOptionsPayload(replacement.options)
2820
2807
  : stored.composerOptions;
2821
2808
  const contentSegments = replacement
2822
2809
  ? replacement.contentSegments
@@ -2843,7 +2830,7 @@ export class AgentWidgetSession {
2843
2830
  }
2844
2831
 
2845
2832
  /** Submission options → the wire payload shape; quote travels separately. */
2846
- private toComposerOptionsPayload(
2833
+ #toComposerOptionsPayload(
2847
2834
  options: { selectedModelId?: string; activeModeIds?: string[] } | undefined
2848
2835
  ): ComposerOptionsPayload | undefined {
2849
2836
  if (!options) return undefined;
@@ -2874,21 +2861,21 @@ export class AgentWidgetSession {
2874
2861
  // Same ownership rule as sendMessage: a pending history transition owns the
2875
2862
  // record this turn would land in.
2876
2863
  if (
2877
- this.historyGate ||
2878
- this.projectionFinalizationPromise ||
2879
- this.historySendBlocked ||
2880
- this.credentialReinitPending
2864
+ this.#historyGate ||
2865
+ this.#projectionFinalizationPromise ||
2866
+ this.#historySendBlocked ||
2867
+ this.#credentialReinitPending
2881
2868
  ) {
2882
- await this.awaitHistorySendable();
2869
+ await this.#awaitHistorySendable();
2883
2870
  }
2884
2871
 
2885
2872
  this.abortController?.abort();
2886
- this.teardownReconnect();
2873
+ this.#teardownReconnect();
2887
2874
 
2888
2875
  const assistantMessageId = generateAssistantMessageId();
2889
- this.activeAssistantMessageId = null;
2876
+ this.#activeAssistantMessageId = null;
2890
2877
 
2891
- this.setStreaming(true);
2878
+ this.#setStreaming(true);
2892
2879
 
2893
2880
  const controller = new AbortController();
2894
2881
  this.abortController = controller;
@@ -2905,7 +2892,7 @@ export class AgentWidgetSession {
2905
2892
  this.handleEvent
2906
2893
  );
2907
2894
  } catch (error) {
2908
- if (this.status === "resuming" || this.reconnecting) return;
2895
+ if (this.status === "resuming" || this.#reconnecting) return;
2909
2896
  // Check if this is an abort error (a prior in-flight stream was canceled,
2910
2897
  // the user navigated away, etc.). In these cases, don't show fallback or
2911
2898
  // fire onError - the request was intentionally interrupted.
@@ -2928,14 +2915,14 @@ export class AgentWidgetSession {
2928
2915
  role: "assistant",
2929
2916
  createdAt: new Date().toISOString(),
2930
2917
  content,
2931
- sequence: this.nextSequence()
2918
+ sequence: this.#nextSequence()
2932
2919
  };
2933
2920
 
2934
- this.appendMessage(fallback);
2921
+ this.#appendMessage(fallback);
2935
2922
  }
2936
2923
  }
2937
- this.setStatus("idle");
2938
- this.setStreaming(false);
2924
+ this.#setStatus("idle");
2925
+ this.#setStreaming(false);
2939
2926
  this.abortController = null;
2940
2927
  if (!isAbortError) {
2941
2928
  if (error instanceof Error) {
@@ -2979,7 +2966,7 @@ export class AgentWidgetSession {
2979
2966
  // Durable reconnect keeps filling the same bubble: track its id so the
2980
2967
  // cursor handler attaches to it.
2981
2968
  if (options?.preserveAssistantId && options.assistantMessageId) {
2982
- this.activeAssistantMessageId = options.assistantMessageId;
2969
+ this.#activeAssistantMessageId = options.assistantMessageId;
2983
2970
  }
2984
2971
 
2985
2972
  // Finalize any stale streaming messages from the previous stream
@@ -2999,7 +2986,7 @@ export class AgentWidgetSession {
2999
2986
  this.callbacks.onMessagesChanged([...this.messages]);
3000
2987
  }
3001
2988
 
3002
- this.setStreaming(true);
2989
+ this.#setStreaming(true);
3003
2990
 
3004
2991
  try {
3005
2992
  await this.client.processStream(
@@ -3012,14 +2999,14 @@ export class AgentWidgetSession {
3012
2999
  // During a durable reconnect a thrown resume stream is just another drop:
3013
3000
  // the reconnect loop owns the retry/backoff. Don't paint an error or tear
3014
3001
  // down, stay in `resuming`.
3015
- if (this.status === "resuming" || this.reconnecting) return;
3016
- this.setStatus("error");
3002
+ if (this.status === "resuming" || this.#reconnecting) return;
3003
+ this.#setStatus("error");
3017
3004
  // Mirror the idle/error handlers: a failed resume stream must not tear
3018
3005
  // down streaming/abortController while another WebMCP resolve is still
3019
3006
  // confirming or executing. The in-flight resolve's `finally` owns the
3020
3007
  // teardown once `webMcpResolveControllers` drains.
3021
3008
  if (this.webMcpResolveControllers.size === 0) {
3022
- this.setStreaming(false);
3009
+ this.#setStreaming(false);
3023
3010
  this.abortController = null;
3024
3011
  }
3025
3012
  this.callbacks.onError?.(
@@ -3034,7 +3021,7 @@ export class AgentWidgetSession {
3034
3021
  * this, the bridge falls back to a blunt `window.confirm`. Safe to call
3035
3022
  * repeatedly (e.g. after the client is re-created in `updateConfig`).
3036
3023
  */
3037
- private wireDefaultWebMcpConfirm(): void {
3024
+ #wireDefaultWebMcpConfirm(): void {
3038
3025
  const webmcp = this.config.webmcp;
3039
3026
  if (webmcp?.enabled === true && !webmcp.onConfirm) {
3040
3027
  this.client.setWebMcpConfirmHandler((info) =>
@@ -3063,7 +3050,7 @@ export class AgentWidgetSession {
3063
3050
  }
3064
3051
 
3065
3052
  const approval: AgentWidgetApproval = {
3066
- id: `webmcp-${++this.webMcpApprovalSeq}`,
3053
+ id: `webmcp-${++this.#webMcpApprovalSeq}`,
3067
3054
  status: "pending",
3068
3055
  agentId: "",
3069
3056
  executionId: "",
@@ -3119,133 +3106,58 @@ export class AgentWidgetSession {
3119
3106
  resolve(decision === "approved");
3120
3107
  }
3121
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
+
3122
3148
  /**
3123
3149
  * Resolve a tool approval request (approve or deny).
3124
3150
  * Updates the approval message status, calls the API (or custom onDecision),
3125
3151
  * and pipes the response stream through connectStream().
3126
3152
  */
3127
- public async resolveApproval(
3153
+ public resolveApproval(
3128
3154
  approval: AgentWidgetApproval,
3129
3155
  decision: 'approved' | 'denied',
3130
3156
  options?: AgentWidgetApprovalDecisionOptions
3131
3157
  ): Promise<void> {
3132
- // 1. Update approval message status immediately for responsive UI
3133
- const approvalMessageId = `approval-${approval.id}`;
3134
- const updatedApproval: AgentWidgetApproval = {
3135
- ...approval,
3136
- status: decision,
3137
- resolvedAt: Date.now(),
3138
- };
3139
- // Anchor the bubble where the agent paused for permission. An approval is a
3140
- // timeline checkpoint, not a "now" event, so resolving it must preserve the
3141
- // original message's createdAt/sequence: otherwise sortMessages (which
3142
- // orders by createdAt first) would re-stamp it to now and float it past any
3143
- // message created later (e.g. a long-pending approval resolved after more
3144
- // conversation, or restored/replayed transcripts).
3145
- const existing = this.messages.find((m) => m.id === approvalMessageId);
3146
- const updatedMessage: AgentWidgetMessage = {
3147
- id: approvalMessageId,
3148
- role: "assistant",
3149
- content: "",
3150
- createdAt: existing?.createdAt ?? new Date().toISOString(),
3151
- ...(existing?.sequence !== undefined ? { sequence: existing.sequence } : {}),
3152
- streaming: false,
3153
- variant: "approval",
3154
- approval: updatedApproval,
3155
- };
3156
- this.upsertMessage(updatedMessage);
3157
-
3158
- // Show the standalone typing indicator immediately while we wait for the
3159
- // approval round-trip. Install an abortController so cancel() works during
3160
- // the silent gap. See `resolveAskUserQuestion` for the same pattern.
3161
- this.abortController?.abort();
3162
- this.abortController = new AbortController();
3163
- this.setStreaming(true);
3164
-
3165
- // 2. Call onDecision callback if provided, otherwise use client.resolveApproval()
3166
- const approvalConfig = this.config.approval;
3167
- const onDecision = approvalConfig && typeof approvalConfig === 'object' ? approvalConfig.onDecision : undefined;
3168
-
3169
- try {
3170
- let response: Response | ReadableStream<Uint8Array> | void;
3171
-
3172
- if (onDecision) {
3173
- response = await onDecision(
3174
- {
3175
- approvalId: approval.id,
3176
- executionId: approval.executionId,
3177
- agentId: approval.agentId,
3178
- toolName: approval.toolName,
3179
- },
3180
- decision,
3181
- options
3182
- );
3183
- } else {
3184
- response = await this.client.resolveApproval(
3185
- {
3186
- agentId: approval.agentId,
3187
- executionId: approval.executionId,
3188
- approvalId: approval.id,
3189
- },
3190
- decision
3191
- );
3192
- }
3193
-
3194
- // 3. Pipe through connectStream if we got a response with a body
3195
- if (response) {
3196
- let stream: ReadableStream<Uint8Array> | null = null;
3197
- if (response instanceof Response) {
3198
- if (!response.ok) {
3199
- const errorData = await response.json().catch(() => null);
3200
- throw new Error(
3201
- errorData?.error ?? `Approval request failed: ${response.status}`
3202
- );
3203
- }
3204
- stream = response.body;
3205
- } else if (response instanceof ReadableStream) {
3206
- stream = response;
3207
- }
3208
-
3209
- if (stream) {
3210
- await this.connectStream(stream, { allowReentry: true });
3211
- } else {
3212
- if (decision === 'denied') {
3213
- // No stream body for denied: inject a denial message
3214
- this.appendMessage({
3215
- id: `denial-${approval.id}`,
3216
- role: "assistant",
3217
- content: "Tool execution was denied by user.",
3218
- createdAt: new Date().toISOString(),
3219
- streaming: false,
3220
- sequence: this.nextSequence(),
3221
- });
3222
- }
3223
- // No body to pipe: drop the pre-set streaming flag so the indicator
3224
- // doesn't linger forever.
3225
- this.setStreaming(false);
3226
- this.abortController = null;
3227
- }
3228
- } else {
3229
- // onDecision returned void / no response: drop the pre-set flag.
3230
- this.setStreaming(false);
3231
- this.abortController = null;
3232
- }
3233
- } catch (error) {
3234
- const isAbortError =
3235
- error instanceof Error &&
3236
- (error.name === 'AbortError' ||
3237
- error.message.includes('aborted') ||
3238
- error.message.includes('abort'));
3239
-
3240
- this.setStreaming(false);
3241
- this.abortController = null;
3242
-
3243
- if (!isAbortError) {
3244
- this.callbacks.onError?.(
3245
- error instanceof Error ? error : new Error(String(error))
3246
- );
3247
- }
3248
- }
3158
+ return this.#withActions((actions, host) =>
3159
+ actions.resolveApproval(host, approval, decision, options)
3160
+ );
3249
3161
  }
3250
3162
 
3251
3163
  /**
@@ -3260,6 +3172,27 @@ export class AgentWidgetSession {
3260
3172
  * 3. Append a user-visible bubble with the answer text so the
3261
3173
  * transcript reads naturally.
3262
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
+
3263
3196
  /**
3264
3197
  * Persist in-progress answers and the current page index for a multi-question
3265
3198
  * `ask_user_question` payload, so a refresh resumes on the same page with
@@ -3309,167 +3242,6 @@ export class AgentWidgetSession {
3309
3242
  });
3310
3243
  }
3311
3244
 
3312
- public async resolveAskUserQuestion(
3313
- toolMessage: AgentWidgetMessage,
3314
- answer: string | Record<string, string | string[]>
3315
- ): Promise<void> {
3316
- // Idempotent: guards against rapid double-clicks on answer pills before
3317
- // the re-render swaps the card to its collapsed/answered state.
3318
- const live = this.messages.find((m) => m.id === toolMessage.id);
3319
- if (live?.agentMetadata?.askUserQuestionAnswered === true) return;
3320
-
3321
- const executionId = toolMessage.agentMetadata?.executionId;
3322
- const toolName = toolMessage.toolCall?.name;
3323
- if (!executionId || !toolName) {
3324
- this.callbacks.onError?.(
3325
- new Error(
3326
- "resolveAskUserQuestion: message is missing executionId or toolCall.name"
3327
- )
3328
- );
3329
- return;
3330
- }
3331
-
3332
- // Flip answered flag first so the next render skips the sheet re-mount,
3333
- // avoiding the race between removeAskUserQuestionSheet's 180ms slide-out
3334
- // timer and the renders that fire as the resume stream lands. Pass the
3335
- // structured answer Record (when present) so it's atomically persisted
3336
- // alongside the flag: the answered-state review card depends on
3337
- // `agentMetadata.askUserQuestionAnswers` being populated at render time.
3338
- //
3339
- // For single-question payloads, callers (built-in pick handler, plugins)
3340
- // resolve with a plain string. Derive a `{ [questionText]: answer }` Record
3341
- // from the toolCall args so the answered-card render path is consistent
3342
- // with grouped flows.
3343
- let structuredAnswers: Record<string, string | string[]> | undefined =
3344
- typeof answer === "string" ? undefined : answer;
3345
- if (structuredAnswers === undefined && typeof answer === "string") {
3346
- const args = toolMessage.toolCall?.args as
3347
- | { questions?: Array<{ question?: unknown }> }
3348
- | undefined;
3349
- const questions = Array.isArray(args?.questions) ? args!.questions : [];
3350
- if (questions.length === 1) {
3351
- const qText = typeof questions[0]?.question === "string"
3352
- ? (questions[0].question as string)
3353
- : "";
3354
- if (qText) structuredAnswers = { [qText]: answer };
3355
- }
3356
- }
3357
- this.markAskUserQuestionResolved(toolMessage, structuredAnswers);
3358
-
3359
- // Show the standalone typing indicator immediately: the network round-trip
3360
- // to /resume is otherwise silent, which reads as broken. The render
3361
- // condition in ui.ts already shows the indicator once streaming flips true
3362
- // and the last message is a user bubble (the answer we inject below).
3363
- // Install an abortController so cancel() works during this silent gap.
3364
- this.abortController?.abort();
3365
- this.abortController = new AbortController();
3366
- this.setStreaming(true);
3367
-
3368
- // Inject Q→A pair messages: one assistant bubble per question, one user
3369
- // bubble per answer, so the transcript reads like a normal conversation.
3370
- // The original ask_user_question tool message is suppressed by the
3371
- // renderer once `askUserQuestionAnswered` is true. Skipped questions get
3372
- // a muted italic `*Skipped*` user bubble (rendered through the standard
3373
- // markdown pipeline).
3374
- const toolCallId = toolMessage.toolCall!.id;
3375
- const args = toolMessage.toolCall?.args as
3376
- | { questions?: Array<{ question?: unknown; header?: unknown }> }
3377
- | undefined;
3378
- const questions = Array.isArray(args?.questions) ? args!.questions : [];
3379
- if (questions.length === 0) {
3380
- const fallback =
3381
- typeof answer === "string"
3382
- ? answer
3383
- : Object.entries(answer)
3384
- .map(
3385
- ([q, v]) => `${q}: ${Array.isArray(v) ? v.join(", ") : v}`
3386
- )
3387
- .join(" | ");
3388
- this.appendMessage({
3389
- id: `ask-user-answer-${toolCallId}`,
3390
- role: "user",
3391
- content: fallback,
3392
- createdAt: new Date().toISOString(),
3393
- streaming: false,
3394
- sequence: this.nextSequence(),
3395
- });
3396
- } else {
3397
- const stored = structuredAnswers ?? {};
3398
- questions.forEach((p, i) => {
3399
- const qText = typeof p?.question === "string" ? p.question : "";
3400
- if (!qText) return;
3401
- const ans = stored[qText];
3402
- const answerStr = Array.isArray(ans)
3403
- ? ans.join(", ")
3404
- : typeof ans === "string"
3405
- ? ans
3406
- : "";
3407
- this.appendMessage({
3408
- id: `ask-user-q-${toolCallId}-${i}`,
3409
- role: "assistant",
3410
- content: qText,
3411
- createdAt: new Date().toISOString(),
3412
- streaming: false,
3413
- sequence: this.nextSequence(),
3414
- });
3415
- this.appendMessage({
3416
- id: `ask-user-a-${toolCallId}-${i}`,
3417
- role: "user",
3418
- content: answerStr || "*Skipped*",
3419
- createdAt: new Date().toISOString(),
3420
- streaming: false,
3421
- sequence: this.nextSequence(),
3422
- });
3423
- });
3424
- }
3425
-
3426
- try {
3427
- const response = await this.client.resumeFlow(
3428
- executionId,
3429
- { [toolName]: answer },
3430
- {
3431
- after:
3432
- this.resumable?.executionId === executionId
3433
- ? this.resumable.lastEventId
3434
- : undefined,
3435
- },
3436
- );
3437
-
3438
- if (!response.ok) {
3439
- const errorData = await response.json().catch(() => null);
3440
- throw new Error(
3441
- errorData?.error ?? `Resume failed: ${response.status}`
3442
- );
3443
- }
3444
-
3445
- if (response.body) {
3446
- await this.connectStream(response.body, { allowReentry: true });
3447
- } else {
3448
- // No body to pipe: drop the pre-set streaming flag so the indicator
3449
- // doesn't linger forever.
3450
- this.setStreaming(false);
3451
- this.abortController = null;
3452
- }
3453
- } catch (error) {
3454
- // Mirror sendMessage: a cancel() during the await aborts the controller
3455
- // and surfaces an AbortError: don't treat that as a real failure.
3456
- const isAbortError =
3457
- error instanceof Error &&
3458
- (error.name === 'AbortError' ||
3459
- error.message.includes('aborted') ||
3460
- error.message.includes('abort'));
3461
-
3462
- this.setStreaming(false);
3463
- this.abortController = null;
3464
-
3465
- if (!isAbortError) {
3466
- this.callbacks.onError?.(
3467
- error instanceof Error ? error : new Error(String(error))
3468
- );
3469
- }
3470
- }
3471
- }
3472
-
3473
3245
  /**
3474
3246
  * Collect an auto-resolving LOCAL-tool `await` (`webmcp:*` page tools
3475
3247
  * and the built-in `suggest_replies`) into a per-executionId batch
@@ -3488,13 +3260,13 @@ export class AgentWidgetSession {
3488
3260
  *: route them straight to the single-call path, which surfaces the malformed
3489
3261
  * wire shape via `onError` / an `isError` resume.
3490
3262
  */
3491
- private enqueueWebMcpAwait(toolMessage: AgentWidgetMessage): void {
3263
+ #enqueueWebMcpAwait(toolMessage: AgentWidgetMessage): void {
3492
3264
  const executionId = toolMessage.agentMetadata?.executionId;
3493
3265
  const callId = toolMessage.toolCall?.id;
3494
3266
  if (!executionId || !callId) {
3495
- const queuedEpoch = this.webMcpEpoch;
3267
+ const queuedEpoch = this.#webMcpEpoch;
3496
3268
  queueMicrotask(() => {
3497
- if (queuedEpoch !== this.webMcpEpoch) return;
3269
+ if (queuedEpoch !== this.#webMcpEpoch) return;
3498
3270
  void this.resolveWebMcpToolCall(toolMessage);
3499
3271
  });
3500
3272
  return;
@@ -3526,13 +3298,13 @@ export class AgentWidgetSession {
3526
3298
  * abortController) settles before a resolve grabs them: the same ordering the
3527
3299
  * single-call resolve always relied on.
3528
3300
  */
3529
- private scheduleWebMcpBatchFlush(): void {
3301
+ #scheduleWebMcpBatchFlush(): void {
3530
3302
  if (this.webMcpAwaitBatches.size === 0) return;
3531
- const queuedEpoch = this.webMcpEpoch;
3303
+ const queuedEpoch = this.#webMcpEpoch;
3532
3304
  queueMicrotask(() => {
3533
- if (queuedEpoch !== this.webMcpEpoch) return;
3305
+ if (queuedEpoch !== this.#webMcpEpoch) return;
3534
3306
  for (const executionId of [...this.webMcpAwaitBatches.keys()]) {
3535
- this.flushWebMcpAwaitBatch(executionId);
3307
+ this.#flushWebMcpAwaitBatch(executionId);
3536
3308
  }
3537
3309
  });
3538
3310
  }
@@ -3545,7 +3317,7 @@ export class AgentWidgetSession {
3545
3317
  * so any later sibling re-emit (e.g. from a re-pause) forms a fresh batch
3546
3318
  * rather than mutating one already in flight.
3547
3319
  */
3548
- private flushWebMcpAwaitBatch(executionId: string): void {
3320
+ #flushWebMcpAwaitBatch(executionId: string): void {
3549
3321
  const batch = this.webMcpAwaitBatches.get(executionId);
3550
3322
  if (!batch) return;
3551
3323
  this.webMcpAwaitBatches.delete(executionId);
@@ -3553,498 +3325,9 @@ export class AgentWidgetSession {
3553
3325
  if (snapshots.length === 1) {
3554
3326
  void this.resolveWebMcpToolCall(snapshots[0]);
3555
3327
  } else if (snapshots.length > 1) {
3556
- void this.resolveWebMcpToolCallBatch(executionId, snapshots);
3557
- }
3558
- }
3559
-
3560
- private resolveWebMcpToolStartedAt(
3561
- toolMessage: AgentWidgetMessage,
3562
- ): number {
3563
- const stored = this.messages.find((m) => m.id === toolMessage.id);
3564
- const candidates = [
3565
- stored?.toolCall?.startedAt,
3566
- toolMessage.toolCall?.startedAt,
3567
- ];
3568
- for (const candidate of candidates) {
3569
- if (typeof candidate === "number" && Number.isFinite(candidate)) {
3570
- return candidate;
3571
- }
3572
- }
3573
- return Date.now();
3574
- }
3575
-
3576
- /**
3577
- * Persisted-resolution guard for `suggest_replies`. The in-memory dedupe
3578
- * sets (`webMcpInflightKeys` / `webMcpResolvedKeys`) are cleared by
3579
- * hydrateMessages/clearMessages/cancel, but `suggestRepliesResolved`
3580
- * survives on the stored message, so a stale `await` re-emit after a
3581
- * hydration must not re-POST `/resume` for an already-resolved call (the
3582
- * historical double-resume failure mode the batching work exists to avoid).
3583
- * Checks the LIVE message first; the handleEvent snapshot is a fresh wire
3584
- * skeleton whose metadata never carries the flag.
3585
- */
3586
- private isSuggestRepliesAlreadyResolved(
3587
- toolMessage: AgentWidgetMessage,
3588
- ): boolean {
3589
- if (toolMessage.toolCall?.name !== SUGGEST_REPLIES_TOOL_NAME) return false;
3590
- const stored = this.messages.find((m) => m.id === toolMessage.id);
3591
- return (
3592
- (stored ?? toolMessage).agentMetadata?.suggestRepliesResolved === true
3593
- );
3594
- }
3595
-
3596
- private markWebMcpToolRunning(
3597
- toolMessage: AgentWidgetMessage,
3598
- ): number {
3599
- const startedAt = this.resolveWebMcpToolStartedAt(toolMessage);
3600
- this.upsertMessage({
3601
- ...toolMessage,
3602
- streaming: true,
3603
- agentMetadata: {
3604
- ...toolMessage.agentMetadata,
3605
- awaitingLocalTool: false,
3606
- },
3607
- toolCall: toolMessage.toolCall
3608
- ? {
3609
- ...toolMessage.toolCall,
3610
- status: "running",
3611
- startedAt,
3612
- completedAt: undefined,
3613
- duration: undefined,
3614
- durationMs: undefined,
3615
- }
3616
- : toolMessage.toolCall,
3617
- });
3618
- return startedAt;
3619
- }
3620
-
3621
- private markWebMcpToolComplete(
3622
- toolMessage: AgentWidgetMessage,
3623
- result: unknown,
3624
- startedAt: number,
3625
- completedAt = Date.now(),
3626
- extraMetadata?: Partial<
3627
- NonNullable<AgentWidgetMessage["agentMetadata"]>
3628
- >,
3629
- ): void {
3630
- // A teardown such as clearMessages()/hydrateMessages()/new send can remove
3631
- // the bubble while an aborted WebMCP promise is settling. Never resurrect a
3632
- // cleared message just to mark the old resolve complete.
3633
- if (!this.messages.some((message) => message.id === toolMessage.id)) return;
3634
- this.upsertMessage({
3635
- ...toolMessage,
3636
- streaming: false,
3637
- agentMetadata: {
3638
- ...toolMessage.agentMetadata,
3639
- awaitingLocalTool: false,
3640
- ...extraMetadata,
3641
- },
3642
- toolCall: toolMessage.toolCall
3643
- ? {
3644
- ...toolMessage.toolCall,
3645
- status: "complete",
3646
- result,
3647
- startedAt,
3648
- completedAt,
3649
- duration: undefined,
3650
- durationMs: Math.max(0, completedAt - startedAt),
3651
- }
3652
- : toolMessage.toolCall,
3653
- });
3654
- }
3655
-
3656
- /**
3657
- * Resolve one or more parallel local-tool awaits sharing one paused
3658
- * executionId with a SINGLE `/resume` (core#3878); `resolveWebMcpToolCall`
3659
- * delegates size-1 resolves here after its guards. By default each call is
3660
- * executed against the page registry concurrently: every gated call renders
3661
- * its own native approval bubble, and a sibling's confirm Promise never
3662
- * blocks another's execution. With `webmcp.execution: "sequential"` the
3663
- * calls run one at a time in emission order instead (see the config docs
3664
- * for when that matters). Outputs are keyed by per-call `webMcpToolCallId`
3665
- * (server prefers it over tool name; name-keying remains the fallback for
3666
- * legacy single/distinct-tool turns), so two calls to the SAME tool no longer
3667
- * collide. The server is tolerant: any call we omit (declined-after-abort,
3668
- * dedupe, exec failure) simply re-pauses and is retried on its re-emit.
3669
- *
3670
- * Owns the dedupe / abort / streaming machinery for both routes; resolved
3671
- * keys are marked on the shared resume POST's HTTP OK.
3672
- */
3673
- private async resolveWebMcpToolCallBatch(
3674
- executionId: string,
3675
- snapshots: AgentWidgetMessage[],
3676
- ): Promise<void> {
3677
- type ExecutedWebMcpTool = {
3678
- dedupeKey: string;
3679
- resumeKey: string;
3680
- output: unknown;
3681
- toolMessage: AgentWidgetMessage;
3682
- startedAt: number;
3683
- completedAt: number;
3684
- };
3685
- const claimedKeys: string[] = [];
3686
- // One controller per batch, shared by every execute and the resume fetch.
3687
- // Teardown (`abortWebMcpResolves`) only ever aborts the whole set, so
3688
- // finer granularity buys nothing; one registration per resolve op also
3689
- // keeps `webMcpResolveControllers.size` === in-flight resolve count.
3690
- const batchController = new AbortController();
3691
- this.webMcpResolveControllers.add(batchController);
3692
- this.setStreaming(true);
3693
-
3694
- // Phase 1: execute every pending call. A null result means the call was
3695
- // deduped, aborted, or threw; it's omitted from the resume and (per the
3696
- // tolerant server) re-pauses for retry.
3697
- const executeOne = async (
3698
- toolMessage: AgentWidgetMessage,
3699
- ): Promise<ExecutedWebMcpTool | null> => {
3700
- const wireToolName = toolMessage.toolCall?.name;
3701
- const callId = toolMessage.toolCall?.id;
3702
- if (!wireToolName || !callId) return null;
3703
-
3704
- const dedupeKey = `${executionId}:${callId}`;
3705
- if (
3706
- this.webMcpInflightKeys.has(dedupeKey) ||
3707
- this.webMcpResolvedKeys.has(dedupeKey) ||
3708
- this.isSuggestRepliesAlreadyResolved(toolMessage)
3709
- ) {
3710
- return null;
3711
- }
3712
- this.webMcpInflightKeys.add(dedupeKey);
3713
- claimedKeys.push(dedupeKey);
3714
-
3715
- // Clear the awaiting flag and keep the tool bubble running while the
3716
- // browser-side WebMCP promise is in flight. The initial `await`
3717
- // only means the server paused for a local tool; it is not completion.
3718
- const startedAt = this.markWebMcpToolRunning(toolMessage);
3719
-
3720
- // Per-call id wins for resume keying; fall back to the wire tool name
3721
- // for legacy servers that don't emit `webMcpToolCallId`.
3722
- const resumeKey =
3723
- toolMessage.agentMetadata?.webMcpToolCallId ?? wireToolName;
3724
-
3725
- // Built-in fire-and-forget tool: no bridge, no confirm gate, no
3726
- // browser-side execution: the chips render from the message list and
3727
- // the canned output joins the batch's single /resume.
3728
- if (wireToolName === SUGGEST_REPLIES_TOOL_NAME) {
3729
- return {
3730
- dedupeKey,
3731
- resumeKey,
3732
- output: suggestRepliesToolResult(),
3733
- toolMessage,
3734
- startedAt,
3735
- completedAt: Date.now(),
3736
- };
3737
- }
3738
-
3739
- const execPromise = this.client.executeWebMcpToolCall(
3740
- wireToolName,
3741
- toolMessage.toolCall?.args,
3742
- batchController.signal,
3328
+ void this.#withActions((actions, host) =>
3329
+ actions.resolveWebMcpToolCallBatch(host, executionId, snapshots)
3743
3330
  );
3744
-
3745
- let output: unknown;
3746
- if (!execPromise) {
3747
- output = {
3748
- isError: true,
3749
- content: [
3750
- { type: "text", text: "WebMCP not enabled on this widget." },
3751
- ],
3752
- };
3753
- } else {
3754
- try {
3755
- output = await execPromise;
3756
- } catch (error) {
3757
- const isAbortError =
3758
- error instanceof Error &&
3759
- (error.name === "AbortError" ||
3760
- error.message.includes("aborted") ||
3761
- error.message.includes("abort"));
3762
- if (!isAbortError) {
3763
- this.callbacks.onError?.(
3764
- error instanceof Error ? error : new Error(String(error)),
3765
- );
3766
- }
3767
- this.markWebMcpToolComplete(
3768
- toolMessage,
3769
- buildWebMcpErrorResult(
3770
- isAbortError
3771
- ? "Aborted by cancel()"
3772
- : getWebMcpErrorMessage(error),
3773
- ),
3774
- startedAt,
3775
- );
3776
- // Release the dedupe claim so a re-emit can retry this call.
3777
- this.webMcpInflightKeys.delete(dedupeKey);
3778
- return null;
3779
- }
3780
- }
3781
- if (batchController.signal.aborted) {
3782
- this.markWebMcpToolComplete(
3783
- toolMessage,
3784
- buildWebMcpErrorResult("Aborted by cancel()"),
3785
- startedAt,
3786
- );
3787
- this.webMcpInflightKeys.delete(dedupeKey);
3788
- return null;
3789
- }
3790
- return {
3791
- dedupeKey,
3792
- resumeKey,
3793
- output,
3794
- toolMessage,
3795
- startedAt,
3796
- completedAt: Date.now(),
3797
- };
3798
- };
3799
-
3800
- // Parallel (default): a gated sibling's approval never blocks another's
3801
- // execution. Sequential: one at a time in emission order, so tools that
3802
- // share page state (a canvas, a form) don't interleave; the next call
3803
- // starts only once the previous one has settled, approval included.
3804
- let executed: Array<ExecutedWebMcpTool | null>;
3805
- if (this.config.webmcp?.execution === "sequential") {
3806
- executed = [];
3807
- for (const toolMessage of snapshots) {
3808
- executed.push(await executeOne(toolMessage));
3809
- }
3810
- } else {
3811
- executed = await Promise.all(snapshots.map(executeOne));
3812
- }
3813
-
3814
- let ready: ExecutedWebMcpTool[] = [];
3815
- try {
3816
- ready = executed.filter((r): r is ExecutedWebMcpTool => r !== null);
3817
- // Everything deduped/aborted/failed: nothing to post.
3818
- if (ready.length === 0) return;
3819
-
3820
- const toolOutputs: Record<string, unknown> = {};
3821
- for (const r of ready) {
3822
- // Two omitted-on-collision safety: if two calls somehow resolve to the
3823
- // same key (only possible on a legacy name fallback), last write wins: // the server re-pauses the unrepresented call for retry.
3824
- toolOutputs[r.resumeKey] = r.output;
3825
- }
3826
-
3827
- const response = await this.client.resumeFlow(executionId, toolOutputs, {
3828
- signal: batchController.signal,
3829
- after:
3830
- this.resumable?.executionId === executionId
3831
- ? this.resumable.lastEventId
3832
- : undefined,
3833
- });
3834
- if (!response.ok) {
3835
- const errorData = await response.json().catch(() => null);
3836
- throw new Error(errorData?.error ?? `Resume failed: ${response.status}`);
3837
- }
3838
- // Server accepted the batch: mark every included call resolved so stale
3839
- // re-emits don't re-execute the page tool, then complete each bubble.
3840
- // Do this only after /resume HTTP success; if /resume fails, the server
3841
- // may still be paused and the retry path must not show a final result.
3842
- const batch = ready[0]!.dedupeKey;
3843
- for (const r of ready) {
3844
- this.webMcpResolvedKeys.add(r.dedupeKey);
3845
- const toolName = r.toolMessage.toolCall?.name;
3846
- const toolCallId = r.toolMessage.agentMetadata?.webMcpToolCallId;
3847
- this.markWebMcpToolComplete(
3848
- r.toolMessage,
3849
- r.output,
3850
- r.startedAt,
3851
- r.completedAt,
3852
- {
3853
- ...(toolName === SUGGEST_REPLIES_TOOL_NAME
3854
- ? { suggestRepliesResolved: true }
3855
- : {}),
3856
- // Only a provider call id can be replayed: a legacy name-keyed
3857
- // resume has nothing the model's transcript can reference.
3858
- ...(toolName && toolCallId
3859
- ? {
3860
- clientToolAnswer: {
3861
- toolCallId,
3862
- toolName,
3863
- args: r.toolMessage.toolCall?.args,
3864
- result: r.output,
3865
- batch,
3866
- },
3867
- }
3868
- : {}),
3869
- },
3870
- );
3871
- }
3872
- if (response.body) {
3873
- await this.connectStream(response.body, { allowReentry: true });
3874
- }
3875
- } catch (error) {
3876
- const isAbortError =
3877
- error instanceof Error &&
3878
- (error.name === "AbortError" ||
3879
- error.message.includes("aborted") ||
3880
- error.message.includes("abort"));
3881
- if (!isAbortError) {
3882
- this.callbacks.onError?.(
3883
- error instanceof Error ? error : new Error(String(error)),
3884
- );
3885
- } else {
3886
- for (const r of ready) {
3887
- this.markWebMcpToolComplete(
3888
- r.toolMessage,
3889
- buildWebMcpErrorResult("Aborted by cancel()"),
3890
- r.startedAt,
3891
- );
3892
- }
3893
- }
3894
- } finally {
3895
- for (const key of claimedKeys) {
3896
- this.webMcpInflightKeys.delete(key);
3897
- }
3898
- this.webMcpResolveControllers.delete(batchController);
3899
- if (this.webMcpResolveControllers.size === 0 && !this.abortController) {
3900
- this.setStreaming(false);
3901
- }
3902
- }
3903
- }
3904
-
3905
- /**
3906
- * Resolve a paused auto-resolving LOCAL tool call and post the result to
3907
- * `/resume`: `webmcp:*` calls execute against the host page's tool
3908
- * registry; the built-in `suggest_replies` skips execution entirely and
3909
- * resumes with a canned "shown" result (the chips render from the message
3910
- * list, not from this resolve).
3911
- *
3912
- * Triggered automatically from `handleEvent` when an `await`-derived
3913
- * message arrives for such a tool: the user does not click a pill; the
3914
- * bridge's confirm-bubble gate (WebMCP only) is the only interactive
3915
- * surface.
3916
- *
3917
- * Idempotent on the message's `toolCall.id`: re-emits of the same await
3918
- * (e.g. from message coalescing) won't double-fire `tool.execute`. Failure
3919
- * modes, declined, timed out, throw, unknown tool, all resolve into a
3920
- * `{ isError: true, content: [...] }` payload that resumes the dispatch
3921
- * cleanly so the agent can recover.
3922
- *
3923
- * After the malformed-wire guards this is a thin gate over
3924
- * `resolveWebMcpToolCallBatch` with a size-1 batch: the dedupe check runs
3925
- * synchronously here so a stale re-emit never toggles streaming.
3926
- */
3927
- public async resolveWebMcpToolCall(
3928
- toolMessage: AgentWidgetMessage,
3929
- ): Promise<void> {
3930
- const executionId = toolMessage.agentMetadata?.executionId;
3931
- const wireToolName = toolMessage.toolCall?.name;
3932
- const toolCallId = toolMessage.toolCall?.id;
3933
-
3934
- // Malformed await wire shapes shouldn't silently strand the
3935
- // server-side dispatch. Three failure modes:
3936
- // - no executionId: no /resume target exists; surface to the host
3937
- // via onError so an operator can react. This is a server-side
3938
- // wire-shape bug: Persona can't recover it from the client.
3939
- // - no wireToolName: defensive guard: handleEvent only calls us
3940
- // for an auto-resolving local tool name (`webmcp:*` or
3941
- // `suggest_replies`), so this path indicates a direct caller
3942
- // misuse. Silent return.
3943
- // - no toolCallId: dedupe key falls apart, but the server can still
3944
- // advance if we post an isError for the wireToolName. Do that
3945
- // and bail before the dedupe path.
3946
- if (!executionId) {
3947
- this.callbacks.onError?.(
3948
- new Error(
3949
- "WebMCP await missing executionId: dispatch left paused.",
3950
- ),
3951
- );
3952
- return;
3953
- }
3954
- if (!wireToolName) return;
3955
- if (!toolCallId) {
3956
- // No toolCall.id → no per-call dedupe key. Fall back to a synthetic
3957
- // `(executionId):(wireToolName)` so identical malformed re-emits don't
3958
- // re-POST /resume. Idempotent on duplicate bad payloads.
3959
- const malformedKey = `${executionId}:__no_tool_id__:${wireToolName}`;
3960
- if (
3961
- this.webMcpInflightKeys.has(malformedKey) ||
3962
- this.webMcpResolvedKeys.has(malformedKey)
3963
- ) {
3964
- return;
3965
- }
3966
- this.webMcpInflightKeys.add(malformedKey);
3967
- try {
3968
- await this.resumeWithToolOutput(executionId, wireToolName, {
3969
- isError: true,
3970
- content: [
3971
- {
3972
- type: "text",
3973
- text: "WebMCP await missing toolCall.id: cannot execute the page tool.",
3974
- },
3975
- ],
3976
- });
3977
- this.webMcpResolvedKeys.add(malformedKey);
3978
- } catch (error) {
3979
- this.callbacks.onError?.(
3980
- error instanceof Error ? error : new Error(String(error)),
3981
- );
3982
- } finally {
3983
- this.webMcpInflightKeys.delete(malformedKey);
3984
- }
3985
- return;
3986
- }
3987
-
3988
- // Dedupe key scoped by executionId: see `webMcpInflightKeys` doc comment
3989
- // for the failure-recovery + cross-dispatch rationale. The persisted
3990
- // `suggestRepliesResolved` guard backs the in-memory sets across
3991
- // hydrations. Checked synchronously HERE so a stale re-emit stays a pure
3992
- // no-op (no streaming toggle, no controller registration); the batch path
3993
- // re-checks under its own inflight claim.
3994
- const dedupeKey = `${executionId}:${toolCallId}`;
3995
- if (
3996
- this.webMcpInflightKeys.has(dedupeKey) ||
3997
- this.webMcpResolvedKeys.has(dedupeKey) ||
3998
- this.isSuggestRepliesAlreadyResolved(toolMessage)
3999
- ) {
4000
- return;
4001
- }
4002
- return this.resolveWebMcpToolCallBatch(executionId, [toolMessage]);
4003
- }
4004
-
4005
- /**
4006
- * POST `/resume` with a SINGLE tool's output and pipe the resulting SSE
4007
- * stream back through `connectStream`. Shared by every single-call local-tool
4008
- * resolve path (ask_user_question and single WebMCP calls). Parallel WebMCP
4009
- * calls use `resolveWebMcpToolCallBatch`, which posts one resume for many.
4010
- *
4011
- * `resumeKey` is the `toolOutputs` map key: the per-call `webMcpToolCallId`
4012
- * for WebMCP (core#3878), or the tool name for ask_user_question / legacy
4013
- * servers. `onHttpOk` runs synchronously between the HTTP-status check and the
4014
- * stream pipe; it lets the WebMCP resolve path commit the dedupe flag at
4015
- * "server accepted the answer" rather than "stream finished cleanly".
4016
- */
4017
- private async resumeWithToolOutput(
4018
- executionId: string,
4019
- resumeKey: string,
4020
- output: unknown,
4021
- options?: { onHttpOk?: () => void; signal?: AbortSignal },
4022
- ): Promise<void> {
4023
- const response = await this.client.resumeFlow(
4024
- executionId,
4025
- { [resumeKey]: output },
4026
- {
4027
- signal: options?.signal,
4028
- after:
4029
- this.resumable?.executionId === executionId
4030
- ? this.resumable.lastEventId
4031
- : undefined,
4032
- },
4033
- );
4034
- if (!response.ok) {
4035
- const errorData = await response.json().catch(() => null);
4036
- throw new Error(errorData?.error ?? `Resume failed: ${response.status}`);
4037
- }
4038
- options?.onHttpOk?.();
4039
- if (response.body) {
4040
- await this.connectStream(response.body, { allowReentry: true });
4041
- } else if (this.webMcpResolveControllers.size === 0) {
4042
- // No stream to pipe. Clear streaming only when no WebMCP resolve is in
4043
- // flight: for a WebMCP caller the current resolve's controller is still
4044
- // in the set, so its own `finally` (gated on the set draining) owns the
4045
- // teardown. Non-WebMCP callers (ask_user_question) keep the old behavior.
4046
- this.setStreaming(false);
4047
- this.abortController = null;
4048
3331
  }
4049
3332
  }
4050
3333
 
@@ -4057,7 +3340,7 @@ export class AgentWidgetSession {
4057
3340
  * controller, so it can't escape this teardown. Called from every stop /
4058
3341
  * new-turn boundary (cancel, clearMessages, hydrateMessages, sendMessage).
4059
3342
  */
4060
- private abortWebMcpResolves(): void {
3343
+ #abortWebMcpResolves(): void {
4061
3344
  for (const controller of this.webMcpResolveControllers) {
4062
3345
  controller.abort();
4063
3346
  }
@@ -4081,7 +3364,7 @@ export class AgentWidgetSession {
4081
3364
  // being torn down, and a microtask-deferred flush must not survive. The
4082
3365
  // epoch bump below also strands an already-scheduled flush.
4083
3366
  this.webMcpAwaitBatches.clear();
4084
- this.webMcpEpoch++;
3367
+ this.#webMcpEpoch++;
4085
3368
  }
4086
3369
 
4087
3370
  public cancel() {
@@ -4089,32 +3372,34 @@ export class AgentWidgetSession {
4089
3372
  this.abortController = null;
4090
3373
  // A user stop also cancels any pending/in-flight durable reconnect and
4091
3374
  // clears the resume handle (the abort above already killed its fetch).
4092
- this.teardownReconnect();
3375
+ this.#teardownReconnect();
4093
3376
  // Tear down every in-flight WebMCP resolve (each owns its own controller,
4094
3377
  // independent of the shared one above). Clear the inflight set so retries
4095
3378
  // are possible if the user re-issues the same await context.
4096
- this.abortWebMcpResolves();
3379
+ this.#abortWebMcpResolves();
3380
+ this.#actionsEpoch++;
4097
3381
  this.webMcpInflightKeys.clear();
4098
3382
  // Stop any in-progress audio too: when the user hits "stop", they want
4099
3383
  // the assistant to actually stop talking, not just stop generating tokens.
4100
3384
  // Both helpers are safe no-ops when audio isn't configured.
4101
3385
  this.stopSpeaking();
4102
3386
  this.stopVoicePlayback();
4103
- this.setStreaming(false);
4104
- this.setStatus("idle");
3387
+ this.#setStreaming(false);
3388
+ this.#setStatus("idle");
4105
3389
  }
4106
3390
 
4107
3391
  public clearMessages() {
4108
3392
  this.stopSpeaking();
4109
3393
  this.abortController?.abort();
4110
3394
  this.abortController = null;
4111
- this.teardownReconnect();
3395
+ this.#teardownReconnect();
4112
3396
  // Tear down every in-flight WebMCP resolve too: their messages are about
4113
3397
  // to be wiped, and a microtask-deferred resolve must not survive the clear.
4114
- this.abortWebMcpResolves();
3398
+ this.#abortWebMcpResolves();
3399
+ this.#actionsEpoch++;
4115
3400
  this.messages = [];
4116
3401
  this.agentExecution = null;
4117
- this.clearArtifactState();
3402
+ this.#clearArtifactState();
4118
3403
  // Clearing messages also wipes the WebMCP dedupe state: a fresh
4119
3404
  // conversation should not refuse to call a webmcp:* tool just because
4120
3405
  // a tool with the same key resolved in the prior conversation.
@@ -4124,8 +3409,8 @@ export class AgentWidgetSession {
4124
3409
  // turn: drop the diff-only fingerprint cache (server keys by recordId, so
4125
3410
  // a new conversation has no stored set to match).
4126
3411
  this.client.resetClientToolsFingerprint();
4127
- this.setStreaming(false);
4128
- this.setStatus("idle");
3412
+ this.#setStreaming(false);
3413
+ this.#setStatus("idle");
4129
3414
  this.callbacks.onMessagesChanged([...this.messages]);
4130
3415
  }
4131
3416
 
@@ -4138,16 +3423,16 @@ export class AgentWidgetSession {
4138
3423
  }
4139
3424
 
4140
3425
  public getSelectedArtifactId(): string | null {
4141
- return this.selectedArtifactId;
3426
+ return this.#selectedArtifactId;
4142
3427
  }
4143
3428
 
4144
3429
  public selectArtifact(id: string | null): void {
4145
- this.selectedArtifactId = id;
4146
- this.emitArtifactsState();
3430
+ this.#selectedArtifactId = id;
3431
+ this.#emitArtifactsState();
4147
3432
  }
4148
3433
 
4149
3434
  public clearArtifacts(): void {
4150
- this.clearArtifactState();
3435
+ this.#clearArtifactState();
4151
3436
  }
4152
3437
 
4153
3438
  public upsertArtifact(manual: PersonaArtifactManualUpsert): PersonaArtifactRecord {
@@ -4175,10 +3460,10 @@ export class AgentWidgetSession {
4175
3460
  ...(manual.presentation ? { presentation: manual.presentation } : {})
4176
3461
  };
4177
3462
  this.artifacts.set(id, rec);
4178
- this.selectedArtifactId = id;
4179
- this.emitArtifactsState();
3463
+ this.#selectedArtifactId = id;
3464
+ this.#emitArtifactsState();
4180
3465
  if (manual.transcript !== false) {
4181
- this.injectArtifactRefBlock(rec);
3466
+ this.#injectArtifactRefBlock(rec);
4182
3467
  }
4183
3468
  return rec;
4184
3469
  }
@@ -4199,7 +3484,7 @@ export class AgentWidgetSession {
4199
3484
  * these props); without this the block would keep the first version's
4200
3485
  * content after a refresh.
4201
3486
  */
4202
- private injectArtifactRefBlock(rec: PersonaArtifactRecord): void {
3487
+ #injectArtifactRefBlock(rec: PersonaArtifactRecord): void {
4203
3488
  const refId = `artifact-ref-${rec.id}`;
4204
3489
  const displayMode = resolveArtifactDisplayMode(
4205
3490
  this.config.features?.artifacts,
@@ -4237,7 +3522,7 @@ export class AgentWidgetSession {
4237
3522
  * Re-materialize existing transcript artifact blocks after a live display
4238
3523
  * preference update. One messages callback keeps conversion atomic.
4239
3524
  */
4240
- private refreshArtifactReferenceBlocks(): void {
3525
+ #refreshArtifactReferenceBlocks(): void {
4241
3526
  let changed = false;
4242
3527
  for (const rec of this.artifacts.values()) {
4243
3528
  const message = this.messages.find((candidate) => candidate.id === `artifact-ref-${rec.id}`);
@@ -4265,21 +3550,21 @@ export class AgentWidgetSession {
4265
3550
  if (changed) this.callbacks.onMessagesChanged([...this.messages]);
4266
3551
  }
4267
3552
 
4268
- private clearArtifactState(): void {
4269
- if (this.artifacts.size === 0 && this.selectedArtifactId === null) return;
3553
+ #clearArtifactState(): void {
3554
+ if (this.artifacts.size === 0 && this.#selectedArtifactId === null) return;
4270
3555
  this.artifacts.clear();
4271
- this.selectedArtifactId = null;
4272
- this.emitArtifactsState();
3556
+ this.#selectedArtifactId = null;
3557
+ this.#emitArtifactsState();
4273
3558
  }
4274
3559
 
4275
- private emitArtifactsState(): void {
3560
+ #emitArtifactsState(): void {
4276
3561
  this.callbacks.onArtifactsState?.({
4277
3562
  artifacts: [...this.artifacts.values()],
4278
- selectedId: this.selectedArtifactId
3563
+ selectedId: this.#selectedArtifactId
4279
3564
  });
4280
3565
  }
4281
3566
 
4282
- private applyArtifactStreamEvent(ev: AgentWidgetEvent): void {
3567
+ #applyArtifactStreamEvent(ev: AgentWidgetEvent): void {
4283
3568
  switch (ev.type) {
4284
3569
  case "artifact_start": {
4285
3570
  if (ev.artifactType === "markdown") {
@@ -4301,7 +3586,7 @@ export class AgentWidgetSession {
4301
3586
  props: {}
4302
3587
  });
4303
3588
  }
4304
- this.selectedArtifactId = ev.id;
3589
+ this.#selectedArtifactId = ev.id;
4305
3590
  break;
4306
3591
  }
4307
3592
  case "artifact_delta": {
@@ -4327,7 +3612,7 @@ export class AgentWidgetSession {
4327
3612
  default:
4328
3613
  return;
4329
3614
  }
4330
- this.emitArtifactsState();
3615
+ this.#emitArtifactsState();
4331
3616
  }
4332
3617
 
4333
3618
  public hydrateMessages(messages: AgentWidgetMessage[]) {
@@ -4335,23 +3620,24 @@ export class AgentWidgetSession {
4335
3620
  this.abortController = null;
4336
3621
  // Hydration replaces the conversation: also cancel any pending reconnect and
4337
3622
  // clear the resume handle (a boot resume re-arms via resumeFromHandle after).
4338
- this.teardownReconnect();
3623
+ this.#teardownReconnect();
4339
3624
  // Hydration replaces the conversation: abort and forget every in-flight
4340
3625
  // WebMCP resolve; their messages are about to be replaced.
4341
- this.abortWebMcpResolves();
3626
+ this.#abortWebMcpResolves();
3627
+ this.#actionsEpoch++;
4342
3628
  // Wipe the WebMCP dedupe state alongside the message restore: the
4343
3629
  // incoming snapshot is treated as a fresh conversation context.
4344
3630
  this.webMcpInflightKeys.clear();
4345
3631
  this.webMcpResolvedKeys.clear();
4346
- this.messages = this.sortMessages(
3632
+ this.messages = this.#sortMessages(
4347
3633
  messages.map((message) => ({
4348
3634
  ...message,
4349
3635
  streaming: false,
4350
- sequence: message.sequence ?? this.nextSequence()
3636
+ sequence: message.sequence ?? this.#nextSequence()
4351
3637
  }))
4352
3638
  );
4353
- this.setStreaming(false);
4354
- this.setStatus("idle");
3639
+ this.#setStreaming(false);
3640
+ this.#setStatus("idle");
4355
3641
  this.callbacks.onMessagesChanged([...this.messages]);
4356
3642
  }
4357
3643
 
@@ -4363,14 +3649,36 @@ export class AgentWidgetSession {
4363
3649
  for (const rec of artifacts) {
4364
3650
  this.artifacts.set(rec.id, { ...rec, status: "complete" });
4365
3651
  }
4366
- this.selectedArtifactId = selectedId;
4367
- this.emitArtifactsState();
3652
+ this.#selectedArtifactId = selectedId;
3653
+ this.#emitArtifactsState();
4368
3654
  }
4369
3655
 
4370
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
+ }
4371
3668
  if (event.type === "message") {
4372
3669
  this.upsertMessage(event.message);
4373
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
+
4374
3682
  // Track the open assistant text bubble's REAL streamed id so a durable
4375
3683
  // reconnect keeps filling the same message. The proxy path auto-generates
4376
3684
  // this id (it isn't the session's pre-generated one), so we must read it
@@ -4380,7 +3688,7 @@ export class AgentWidgetSession {
4380
3688
  !event.message.variant &&
4381
3689
  event.message.streaming
4382
3690
  ) {
4383
- this.activeAssistantMessageId = event.message.id;
3691
+ this.#activeAssistantMessageId = event.message.id;
4384
3692
  }
4385
3693
 
4386
3694
  // Local-tool auto-resolve: when a await emits a tool-variant
@@ -4407,6 +3715,16 @@ export class AgentWidgetSession {
4407
3715
  // the resolved `enabled` is false the widget neither renders chips nor
4408
3716
  // resumes: the same parked-execution posture as a server-declared
4409
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
+
4410
3728
  const tc = event.message.toolCall;
4411
3729
  const autoResolvable =
4412
3730
  !!tc?.name &&
@@ -4421,7 +3739,7 @@ export class AgentWidgetSession {
4421
3739
  // it on the spot. Parallel same-tool calls (core#3878) arrive as
4422
3740
  // separate `await`s in the same stream; batching lets us post ONE
4423
3741
  // `/resume` keyed by per-call id (see `enqueueWebMcpAwait`).
4424
- this.enqueueWebMcpAwait(event.message);
3742
+ this.#enqueueWebMcpAwait(event.message);
4425
3743
  }
4426
3744
 
4427
3745
  // Track agent execution state from message metadata
@@ -4444,35 +3762,35 @@ export class AgentWidgetSession {
4444
3762
  // cursor and (lazily) form the resume handle once the executionId is
4445
3763
  // known. Only durable, resumable executions emit these, so this is the
4446
3764
  // natural gate: no `id:` lines → no handle → reconnect never arms.
4447
- this.trackCursor(event.id);
3765
+ this.#trackCursor(event.id);
4448
3766
  } else if (event.type === "status") {
4449
3767
  // A plain `idle` (no `terminal`) on the durable lane, while the run is
4450
3768
  // genuinely mid-turn, is a dropped connection, not a finish and not an
4451
3769
  // intentional `await` pause. Reconnect instead of finalizing.
4452
- if (event.status === "idle" && !event.terminal && this.isDurableDrop()) {
4453
- this.beginReconnect();
3770
+ if (event.status === "idle" && !event.terminal && this.#isDurableDrop()) {
3771
+ this.#beginReconnect();
4454
3772
  return;
4455
3773
  }
4456
- this.setStatus(event.status);
3774
+ this.#setStatus(event.status);
4457
3775
  if (event.status === "connecting") {
4458
- this.setStreaming(true);
3776
+ this.#setStreaming(true);
4459
3777
  } else if (event.status === "idle" || event.status === "error") {
4460
3778
  // A client-tool pause is an intentional stream end but NOT a terminal:
4461
3779
  // retain its cursor so `/client/resume` can attach after the await
4462
3780
  // frame instead of replaying the whole turn. Real terminals and
4463
3781
  // ordinary non-durable idles still clear the handle.
4464
- if (!this.isAwaitPending()) this.clearResumable();
3782
+ if (!this.#isAwaitPending()) this.#clearResumable();
4465
3783
  // Keep the typing indicator up while a WebMCP resolve is still in
4466
3784
  // flight: in a chained turn the intermediate resume stream ends with an
4467
3785
  // idle status, but the successor tool is still executing. The resolve's
4468
3786
  // own `finally` flips streaming off once the resolve set drains.
4469
3787
  if (this.webMcpResolveControllers.size === 0) {
4470
- this.setStreaming(false);
3788
+ this.#setStreaming(false);
4471
3789
  this.abortController = null;
4472
3790
  // The turn mutated the record and the chat response carries no
4473
3791
  // revision; finalize any browser-only projection right after.
4474
- this.invalidateConversationRevision();
4475
- this.scheduleProjectionFinalization();
3792
+ this.#invalidateConversationRevision();
3793
+ this.#scheduleProjectionFinalization();
4476
3794
  }
4477
3795
  // Mark agent execution as complete when streaming ends: UNLESS local
4478
3796
  // tools are still outstanding. A batched WebMCP resume is deferred to
@@ -4496,19 +3814,19 @@ export class AgentWidgetSession {
4496
3814
  // batched `/resume` per executionId (deferred: see
4497
3815
  // scheduleWebMcpBatchFlush). Runs AFTER the teardown above so a resolve
4498
3816
  // doesn't fight the end-of-stream streaming/abortController reset.
4499
- this.scheduleWebMcpBatchFlush();
3817
+ this.#scheduleWebMcpBatchFlush();
4500
3818
  }
4501
3819
  } else if (event.type === "error") {
4502
- this.setStatus("error");
3820
+ this.#setStatus("error");
4503
3821
  // Terminal error: drop the resume handle so no reconnect arms.
4504
- this.clearResumable();
3822
+ this.#clearResumable();
4505
3823
  // Mirror the idle/status handler: don't tear down streaming while a
4506
3824
  // WebMCP resolve is still confirming/executing on another stream: an
4507
3825
  // error on one chained resume stream must not hide the typing indicator
4508
3826
  // (or null a controller) for a sibling/successor resolve still in flight.
4509
3827
  // The resolve's own `finally` flips streaming off once the set drains.
4510
3828
  if (this.webMcpResolveControllers.size === 0) {
4511
- this.setStreaming(false);
3829
+ this.#setStreaming(false);
4512
3830
  this.abortController = null;
4513
3831
  }
4514
3832
  if (this.agentExecution?.status === 'running') {
@@ -4521,7 +3839,7 @@ export class AgentWidgetSession {
4521
3839
  event.type === "artifact_update" ||
4522
3840
  event.type === "artifact_complete"
4523
3841
  ) {
4524
- this.applyArtifactStreamEvent(event);
3842
+ this.#applyArtifactStreamEvent(event);
4525
3843
  }
4526
3844
  };
4527
3845
 
@@ -4533,24 +3851,24 @@ export class AgentWidgetSession {
4533
3851
  * frame's metadata, handled before this trailing cursor event) and the active
4534
3852
  * assistant message id.
4535
3853
  */
4536
- private trackCursor(id: string): void {
3854
+ #trackCursor(id: string): void {
4537
3855
  const executionId = this.agentExecution?.executionId;
4538
- if (!executionId || !this.activeAssistantMessageId) return;
3856
+ if (!executionId || !this.#activeAssistantMessageId) return;
4539
3857
  // Only track while the run is live. A graceful terminal sets the execution
4540
3858
  // to 'complete'/'error' and clears the handle BEFORE its own frame's
4541
3859
  // trailing cursor fires; without this guard that cursor would re-arm the
4542
3860
  // handle and the next plain `idle` would look like a spurious drop.
4543
3861
  if (this.agentExecution?.status !== "running") return;
4544
- const isNew = this.resumable === null;
4545
- this.resumable = {
3862
+ const isNew = this.#resumable === null;
3863
+ this.#resumable = {
4546
3864
  executionId,
4547
3865
  lastEventId: id,
4548
- assistantMessageId: this.activeAssistantMessageId,
3866
+ assistantMessageId: this.#activeAssistantMessageId,
4549
3867
  status: "running",
4550
3868
  };
4551
3869
  // Fire immediately when the handle first appears (so the host can persist
4552
3870
  // the executionId promptly); throttle subsequent cursor advances.
4553
- this.notifyExecutionState(isNew);
3871
+ this.#notifyExecutionState(isNew);
4554
3872
  }
4555
3873
 
4556
3874
  /**
@@ -4558,14 +3876,14 @@ export class AgentWidgetSession {
4558
3876
  * rather than a graceful finish or an intentional `await` pause? ALL must
4559
3877
  * hold (see plan §Design overview keystone 3).
4560
3878
  */
4561
- private isDurableDrop(): boolean {
3879
+ #isDurableDrop(): boolean {
4562
3880
  return (
4563
- this.resumable !== null &&
3881
+ this.#resumable !== null &&
4564
3882
  typeof this.config.reconnectStream === "function" &&
4565
3883
  this.abortController?.signal.aborted !== true &&
4566
3884
  this.webMcpResolveControllers.size === 0 &&
4567
3885
  this.webMcpAwaitBatches.size === 0 &&
4568
- !this.isAwaitPending()
3886
+ !this.#isAwaitPending()
4569
3887
  );
4570
3888
  }
4571
3889
 
@@ -4574,7 +3892,7 @@ export class AgentWidgetSession {
4574
3892
  * `ask_user_question` / local-tool await, or a pending approval). Those end
4575
3893
  * the stream with `idle` and no terminal too, but are NOT drops.
4576
3894
  */
4577
- private isAwaitPending(): boolean {
3895
+ #isAwaitPending(): boolean {
4578
3896
  return this.messages.some((m) => {
4579
3897
  if (
4580
3898
  m.agentMetadata?.awaitingLocalTool === true &&
@@ -4594,22 +3912,22 @@ export class AgentWidgetSession {
4594
3912
  * true, flips to `resuming`, and kicks the bounded backoff loop. Re-entrant
4595
3913
  * calls (a second drop while reconnecting) are no-ops.
4596
3914
  */
4597
- private beginReconnect(): void {
4598
- if (this.reconnecting) return;
4599
- 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") {
4600
3918
  return;
4601
3919
  }
4602
3920
  // Flip the visible state synchronously — the bubble stays open and a
4603
3921
  // `resuming` status is observable immediately (e.g. right after
4604
3922
  // `resumeFromHandle`). The heavy backoff loop is loaded lazily below.
4605
- this.reconnecting = true;
4606
- this.callbacks.onReconnect?.({ phase: "paused", handle: this.resumable });
4607
- this.setStreaming(true);
4608
- this.setStatus("resuming");
4609
- 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) => {
4610
3928
  // The run may have been torn down (new turn / cancel / hydrate) while the
4611
3929
  // module was loading; only start the loop if we're still reconnecting.
4612
- if (this.reconnecting && this.resumable) controller.begin();
3930
+ if (this.#reconnecting && this.#resumable) controller.begin();
4613
3931
  });
4614
3932
  }
4615
3933
 
@@ -4619,43 +3937,43 @@ export class AgentWidgetSession {
4619
3937
  * every hot path (`sendMessage` / `cancel` / `teardownReconnect`) so a widget
4620
3938
  * that never reconnects never pulls the chunk.
4621
3939
  */
4622
- private loadReconnectController(): Promise<ReconnectController> {
4623
- if (this.reconnectController) {
4624
- return Promise.resolve(this.reconnectController);
3940
+ #loadReconnectController(): Promise<ReconnectController> {
3941
+ if (this.#reconnectController) {
3942
+ return Promise.resolve(this.#reconnectController);
4625
3943
  }
4626
- if (!this.reconnectControllerPromise) {
4627
- this.reconnectControllerPromise = loadSessionReconnect().then(
3944
+ if (!this.#reconnectControllerPromise) {
3945
+ this.#reconnectControllerPromise = loadSessionReconnect().then(
4628
3946
  ({ createReconnectController }) => {
4629
3947
  const controller = createReconnectController(
4630
- this.buildReconnectHost()
3948
+ this.#buildReconnectHost()
4631
3949
  );
4632
- this.reconnectController = controller;
3950
+ this.#reconnectController = controller;
4633
3951
  return controller;
4634
3952
  }
4635
3953
  );
4636
3954
  // A failed chunk fetch must not poison the memoized promise: clear it so
4637
3955
  // the next reconnect trigger retries the load.
4638
- this.reconnectControllerPromise.catch(() => {
4639
- this.reconnectControllerPromise = null;
3956
+ this.#reconnectControllerPromise.catch(() => {
3957
+ this.#reconnectControllerPromise = null;
4640
3958
  });
4641
3959
  }
4642
- return this.reconnectControllerPromise;
3960
+ return this.#reconnectControllerPromise;
4643
3961
  }
4644
3962
 
4645
3963
  /** The narrow surface the lazily-loaded reconnect loop drives. */
4646
- private buildReconnectHost(): ReconnectHost {
3964
+ #buildReconnectHost(): ReconnectHost {
4647
3965
  const session = this;
4648
3966
  return {
4649
3967
  get config() {
4650
3968
  return session.config;
4651
3969
  },
4652
- getResumable: () => session.resumable,
4653
- clearResumable: () => session.clearResumable(),
3970
+ getResumable: () => session.#resumable,
3971
+ clearResumable: () => session.#clearResumable(),
4654
3972
  getStatus: () => session.status,
4655
- setStatus: (status) => session.setStatus(status),
4656
- setStreaming: (streaming) => session.setStreaming(streaming),
3973
+ setStatus: (status) => session.#setStatus(status),
3974
+ setStreaming: (streaming) => session.#setStreaming(streaming),
4657
3975
  setReconnecting: (value) => {
4658
- session.reconnecting = value;
3976
+ session.#reconnecting = value;
4659
3977
  },
4660
3978
  setAbortController: (controller) => {
4661
3979
  session.abortController = controller;
@@ -4670,8 +3988,8 @@ export class AgentWidgetSession {
4670
3988
  preserveAssistantId: true,
4671
3989
  seedContent,
4672
3990
  }),
4673
- appendMessage: (message) => session.appendMessage(message),
4674
- nextSequence: () => session.nextSequence(),
3991
+ appendMessage: (message) => session.#appendMessage(message),
3992
+ nextSequence: () => session.#nextSequence(),
4675
3993
  emitReconnect: (event) => session.callbacks.onReconnect?.(event),
4676
3994
  buildErrorContent: (message) =>
4677
3995
  buildDispatchErrorContent(
@@ -4684,13 +4002,13 @@ export class AgentWidgetSession {
4684
4002
 
4685
4003
  /** Public manual retry (e.g. a "Reconnect" button). */
4686
4004
  public reconnectNow(): void {
4687
- if (this.reconnecting) {
4005
+ if (this.#reconnecting) {
4688
4006
  // Already trying, so just short-circuit the current backoff. (No-op if the
4689
4007
  // controller is still loading; the first attempt hasn't slept yet.)
4690
- this.reconnectController?.wake();
4008
+ this.#reconnectController?.wake();
4691
4009
  return;
4692
4010
  }
4693
- this.beginReconnect();
4011
+ this.#beginReconnect();
4694
4012
  }
4695
4013
 
4696
4014
  /**
@@ -4700,21 +4018,21 @@ export class AgentWidgetSession {
4700
4018
  */
4701
4019
  public resumeFromHandle(resume: { executionId: string; after: string }): void {
4702
4020
  if (typeof this.config.reconnectStream !== "function") return;
4703
- if (this.reconnecting) return;
4021
+ if (this.#reconnecting) return;
4704
4022
 
4705
- let assistantId = this.reopenTrailingAssistant();
4023
+ let assistantId = this.#reopenTrailingAssistant();
4706
4024
  if (!assistantId) {
4707
4025
  assistantId = generateAssistantMessageId();
4708
- this.appendMessage({
4026
+ this.#appendMessage({
4709
4027
  id: assistantId,
4710
4028
  role: "assistant",
4711
4029
  content: "",
4712
4030
  createdAt: new Date().toISOString(),
4713
4031
  streaming: true,
4714
- sequence: this.nextSequence(),
4032
+ sequence: this.#nextSequence(),
4715
4033
  });
4716
4034
  }
4717
- this.activeAssistantMessageId = assistantId;
4035
+ this.#activeAssistantMessageId = assistantId;
4718
4036
  if (!this.agentExecution) {
4719
4037
  this.agentExecution = {
4720
4038
  executionId: resume.executionId,
@@ -4725,13 +4043,13 @@ export class AgentWidgetSession {
4725
4043
  maxTurns: 0,
4726
4044
  };
4727
4045
  }
4728
- this.resumable = {
4046
+ this.#resumable = {
4729
4047
  executionId: resume.executionId,
4730
4048
  lastEventId: resume.after,
4731
4049
  assistantMessageId: assistantId,
4732
4050
  status: "running",
4733
4051
  };
4734
- this.beginReconnect();
4052
+ this.#beginReconnect();
4735
4053
  }
4736
4054
 
4737
4055
  /**
@@ -4739,7 +4057,7 @@ export class AgentWidgetSession {
4739
4057
  * deltas append to it. Returns its id, or null if the last turn doesn't end
4740
4058
  * in a plain assistant message.
4741
4059
  */
4742
- private reopenTrailingAssistant(): string | null {
4060
+ #reopenTrailingAssistant(): string | null {
4743
4061
  for (let i = this.messages.length - 1; i >= 0; i--) {
4744
4062
  const m = this.messages[i];
4745
4063
  if (m.role === "assistant" && !m.variant) {
@@ -4757,20 +4075,20 @@ export class AgentWidgetSession {
4757
4075
  * on every new turn / cancel / hydrate, so it must NOT pull the reconnect
4758
4076
  * chunk: it only delegates to the controller if one was already created.
4759
4077
  */
4760
- private teardownReconnect(): void {
4761
- this.reconnecting = false;
4762
- this.reconnectController?.teardown();
4763
- this.clearResumable();
4078
+ #teardownReconnect(): void {
4079
+ this.#reconnecting = false;
4080
+ this.#reconnectController?.teardown();
4081
+ this.#clearResumable();
4764
4082
  }
4765
4083
 
4766
4084
  /** Drop the resume handle and notify the host (`onExecutionState(null)`). */
4767
- private clearResumable(): void {
4768
- if (this.executionStateTimer) {
4769
- clearTimeout(this.executionStateTimer);
4770
- this.executionStateTimer = null;
4085
+ #clearResumable(): void {
4086
+ if (this.#executionStateTimer) {
4087
+ clearTimeout(this.#executionStateTimer);
4088
+ this.#executionStateTimer = null;
4771
4089
  }
4772
- const had = this.resumable !== null;
4773
- this.resumable = null;
4090
+ const had = this.#resumable !== null;
4091
+ this.#resumable = null;
4774
4092
  if (had) this.config.onExecutionState?.(null);
4775
4093
  }
4776
4094
 
@@ -4779,36 +4097,36 @@ export class AgentWidgetSession {
4779
4097
  * create/clear; trailing-edge throttled on cursor advances so it isn't called
4780
4098
  * per delta.
4781
4099
  */
4782
- private notifyExecutionState(immediate: boolean): void {
4100
+ #notifyExecutionState(immediate: boolean): void {
4783
4101
  const cb = this.config.onExecutionState;
4784
4102
  if (!cb) return;
4785
4103
  if (immediate) {
4786
- if (this.executionStateTimer) {
4787
- clearTimeout(this.executionStateTimer);
4788
- this.executionStateTimer = null;
4104
+ if (this.#executionStateTimer) {
4105
+ clearTimeout(this.#executionStateTimer);
4106
+ this.#executionStateTimer = null;
4789
4107
  }
4790
- cb(this.resumable);
4108
+ cb(this.#resumable);
4791
4109
  return;
4792
4110
  }
4793
- if (this.executionStateTimer) return; // a trailing call is already queued
4794
- this.executionStateTimer = setTimeout(() => {
4795
- this.executionStateTimer = null;
4796
- 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);
4797
4115
  }, 500);
4798
4116
  }
4799
4117
 
4800
4118
  /** The current durable resume handle, if any (read-only). */
4801
4119
  public getResumableHandle(): ResumableHandle | null {
4802
- return this.resumable;
4120
+ return this.#resumable;
4803
4121
  }
4804
4122
 
4805
- private setStatus(status: AgentWidgetSessionStatus) {
4123
+ #setStatus(status: AgentWidgetSessionStatus) {
4806
4124
  if (this.status === status) return;
4807
4125
  this.status = status;
4808
4126
  this.callbacks.onStatusChanged(status);
4809
4127
  }
4810
4128
 
4811
- private setStreaming(streaming: boolean) {
4129
+ #setStreaming(streaming: boolean) {
4812
4130
  if (this.streaming === streaming) return;
4813
4131
  const wasStreaming = this.streaming;
4814
4132
  this.streaming = streaming;
@@ -4824,7 +4142,8 @@ export class AgentWidgetSession {
4824
4142
  * Speak the latest assistant message using the Web Speech API
4825
4143
  * if text-to-speech is enabled in the config.
4826
4144
  */
4827
- private speakLatestAssistantMessage() {
4145
+ /** `only`: speak it only if the latest assistant message is one of these. */
4146
+ private speakLatestAssistantMessage(only?: string[]) {
4828
4147
  const ttsConfig = this.config.textToSpeech;
4829
4148
  if (!ttsConfig?.enabled) return;
4830
4149
 
@@ -4842,7 +4161,7 @@ export class AgentWidgetSession {
4842
4161
  .reverse()
4843
4162
  .find(m => m.role === 'assistant' && m.content && !m.voiceProcessing);
4844
4163
 
4845
- if (!lastAssistant) return;
4164
+ if (!lastAssistant || (only && !only.includes(lastAssistant.id))) return;
4846
4165
 
4847
4166
  // Skip if already spoken by Runtype provider's audio playback
4848
4167
  if (this.ttsSpokenMessageIds.has(lastAssistant.id)) {
@@ -4914,17 +4233,42 @@ export class AgentWidgetSession {
4914
4233
  }
4915
4234
  }
4916
4235
 
4917
- private appendMessage(message: AgentWidgetMessage) {
4918
- const withSequence = this.ensureSequence(message);
4919
- 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]);
4920
4264
  this.callbacks.onMessagesChanged([...this.messages]);
4921
4265
  }
4922
4266
 
4923
4267
  private upsertMessage(message: AgentWidgetMessage) {
4924
- const withSequence = this.ensureSequence(message);
4268
+ const withSequence = this.#ensureSequence(message);
4925
4269
  const index = this.messages.findIndex((m) => m.id === withSequence.id);
4926
4270
  if (index === -1) {
4927
- this.appendMessage(withSequence);
4271
+ this.#appendMessage(withSequence);
4928
4272
  return;
4929
4273
  }
4930
4274
 
@@ -5005,6 +4349,7 @@ export class AgentWidgetSession {
5005
4349
  toolType: incoming.toolType ?? prior.toolType,
5006
4350
  reason: incoming.reason ?? prior.reason,
5007
4351
  parameters: incoming.parameters ?? prior.parameters,
4352
+ toolCallId: incoming.toolCallId ?? prior.toolCallId,
5008
4353
  };
5009
4354
  }
5010
4355
  // Auto-resolved local-tool equivalent (`webmcp:*` and the built-in
@@ -5051,25 +4396,25 @@ export class AgentWidgetSession {
5051
4396
  }
5052
4397
  return merged;
5053
4398
  });
5054
- this.messages = this.sortMessages(this.messages);
4399
+ this.messages = this.#sortMessages(this.messages);
5055
4400
  this.callbacks.onMessagesChanged([...this.messages]);
5056
4401
  }
5057
4402
 
5058
- private ensureSequence(message: AgentWidgetMessage): AgentWidgetMessage {
4403
+ #ensureSequence(message: AgentWidgetMessage): AgentWidgetMessage {
5059
4404
  if (message.sequence !== undefined) {
5060
4405
  return { ...message };
5061
4406
  }
5062
4407
  return {
5063
4408
  ...message,
5064
- sequence: this.nextSequence()
4409
+ sequence: this.#nextSequence()
5065
4410
  };
5066
4411
  }
5067
4412
 
5068
- private nextSequence() {
5069
- return this.sequenceCounter++;
4413
+ #nextSequence() {
4414
+ return this.#sequenceCounter++;
5070
4415
  }
5071
4416
 
5072
- private sortMessages(messages: AgentWidgetMessage[]) {
4417
+ #sortMessages(messages: AgentWidgetMessage[]) {
5073
4418
  return [...messages].sort((a, b) => {
5074
4419
  // Sort by createdAt timestamp first (chronological order)
5075
4420
  const timeA = new Date(a.createdAt).getTime();