@runtypelabs/persona 4.24.0 → 4.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (237) hide show
  1. package/dist/animations/glyph-cycle.d.cts +1 -1
  2. package/dist/animations/glyph-cycle.d.ts +1 -1
  3. package/dist/animations/{types-Db61aI9J.d.cts → types-BwKTZhg9.d.cts} +14 -0
  4. package/dist/animations/{types-Db61aI9J.d.ts → types-BwKTZhg9.d.ts} +14 -0
  5. package/dist/animations/wipe.d.cts +1 -1
  6. package/dist/animations/wipe.d.ts +1 -1
  7. package/dist/animations-extra.d.cts +14 -0
  8. package/dist/animations-extra.d.ts +14 -0
  9. package/dist/approval-ui-QAKWNFNM.js +1 -0
  10. package/dist/approval-ui.cjs +2 -2
  11. package/dist/approval-ui.d.cts +243 -8
  12. package/dist/approval-ui.d.ts +243 -8
  13. package/dist/approval-ui.js +2 -2
  14. package/dist/{artifacts-ui-IGB5YKLO.js → artifacts-ui-NJGMM27I.js} +1 -1
  15. package/dist/artifacts-ui.cjs +4 -4
  16. package/dist/artifacts-ui.d.cts +243 -8
  17. package/dist/artifacts-ui.d.ts +243 -8
  18. package/dist/artifacts-ui.js +5 -5
  19. package/dist/chunk-2ANTRKTV.js +1 -0
  20. package/dist/chunk-4HPTPUC6.js +1 -0
  21. package/dist/chunk-6V3MNEKB.js +1 -0
  22. package/dist/chunk-HGUCGM3K.js +9 -0
  23. package/dist/chunk-IO5VVUKP.js +3 -0
  24. package/dist/chunk-J4J3Y2P2.js +1 -0
  25. package/dist/chunk-K6HKIP5K.js +1 -0
  26. package/dist/chunk-K7NA7G7J.js +1 -0
  27. package/dist/chunk-MXUTS3LB.js +1 -0
  28. package/dist/chunk-PDPVORUF.js +1 -0
  29. package/dist/chunk-QPPJUSZH.js +1 -0
  30. package/dist/chunk-QRKGQPU7.js +1 -0
  31. package/dist/chunk-S6PBWE75.js +1 -0
  32. package/dist/chunk-T7QM7YMF.js +1 -0
  33. package/dist/chunk-TR2H475E.js +1 -0
  34. package/dist/chunk-UPO4GUFC.js +1 -0
  35. package/dist/chunk-ZC66NAIR.js +1 -0
  36. package/dist/client-history-YPEG45Q4.js +1 -0
  37. package/dist/client-history.js +1 -0
  38. package/dist/client-stream-42FOI3EC.js +4 -0
  39. package/dist/client-stream.js +5 -0
  40. package/dist/codegen.cjs +1 -1
  41. package/dist/codegen.js +1 -1
  42. package/dist/context-mentions-E7HJYWO6.js +161 -0
  43. package/dist/{context-mentions-inline-I2RDEBAK.js → context-mentions-inline-QVESLW55.js} +1 -1
  44. package/dist/context-mentions.cjs +7 -7
  45. package/dist/context-mentions.d.cts +243 -8
  46. package/dist/context-mentions.d.ts +243 -8
  47. package/dist/context-mentions.js +8 -8
  48. package/dist/event-stream-view-FOIJHFQO.js +1 -0
  49. package/dist/event-stream-view.cjs +1 -1
  50. package/dist/event-stream-view.d.cts +246 -31
  51. package/dist/event-stream-view.d.ts +246 -31
  52. package/dist/event-stream-view.js +1 -1
  53. package/dist/forms-ui.d.cts +295 -667
  54. package/dist/forms-ui.d.ts +295 -667
  55. package/dist/history-shell-QDQ44PSE.js +3 -0
  56. package/dist/history-shell.js +3 -0
  57. package/dist/{history-view-BD6OXLNA.js → history-view-NCFBI5GR.js} +2 -2
  58. package/dist/history-view.cjs +2 -2
  59. package/dist/history-view.d.cts +35 -1
  60. package/dist/history-view.d.ts +35 -1
  61. package/dist/history-view.js +2 -2
  62. package/dist/index.cjs +80 -74
  63. package/dist/index.cjs.map +1 -1
  64. package/dist/index.d.cts +301 -738
  65. package/dist/index.d.ts +301 -738
  66. package/dist/index.global.js +3 -215
  67. package/dist/index.global.js.map +1 -1
  68. package/dist/index.js +81 -75
  69. package/dist/index.js.map +1 -1
  70. package/dist/install.global.js +1 -1
  71. package/dist/install.global.js.map +1 -1
  72. package/dist/launcher.global.js +3 -67
  73. package/dist/launcher.global.js.map +1 -1
  74. package/dist/markdown-parsers.js +22 -22
  75. package/dist/runtype-tts-entry-NFBVWR7N.js +1 -0
  76. package/dist/runtype-tts.js +1 -1
  77. package/dist/session-actions-ISSKSI66.js +1 -0
  78. package/dist/session-actions.js +1 -0
  79. package/dist/session-reconnect.d.cts +243 -8
  80. package/dist/session-reconnect.d.ts +243 -8
  81. package/dist/smart-dom-reader.cjs +13 -13
  82. package/dist/smart-dom-reader.d.cts +243 -8
  83. package/dist/smart-dom-reader.d.ts +243 -8
  84. package/dist/smart-dom-reader.js +11 -11
  85. package/dist/theme-editor-preview.cjs +116 -98
  86. package/dist/theme-editor-preview.d.cts +245 -8
  87. package/dist/theme-editor-preview.d.ts +245 -8
  88. package/dist/theme-editor-preview.js +49 -54
  89. package/dist/theme-editor.cjs +2 -2
  90. package/dist/theme-editor.d.cts +244 -23
  91. package/dist/theme-editor.d.ts +244 -23
  92. package/dist/theme-editor.js +2 -2
  93. package/dist/theme-reference.d.cts +14 -0
  94. package/dist/theme-reference.d.ts +14 -0
  95. package/dist/ui-extras-entry-WAYFKVNW.js +3 -0
  96. package/dist/ui-extras.js +72 -0
  97. package/dist/voice-runtime-UOQOFAFY.js +17 -0
  98. package/dist/voice-runtime.cjs +17 -1
  99. package/dist/voice-runtime.d.cts +11643 -192
  100. package/dist/voice-runtime.d.ts +11643 -192
  101. package/dist/voice-runtime.js +17 -1
  102. package/dist/voice-worklet-player.cjs +19 -4
  103. package/dist/voice-worklet-player.d.cts +9 -10
  104. package/dist/voice-worklet-player.d.ts +9 -10
  105. package/dist/voice-worklet-player.js +19 -4
  106. package/dist/webmcp-polyfill.js +1 -1
  107. package/dist/webmcp-runtime-V6ZWW3PI.js +6 -0
  108. package/dist/webmcp-runtime.cjs +2 -2
  109. package/dist/webmcp-runtime.d.cts +1 -33
  110. package/dist/webmcp-runtime.d.ts +1 -33
  111. package/dist/webmcp-runtime.js +2 -2
  112. package/dist/widget.css +1 -1
  113. package/package.json +11 -5
  114. package/src/approval-paused-tool-settle.test.ts +221 -0
  115. package/src/client-approve.test.ts +311 -0
  116. package/src/client-history-eager.ts +9 -0
  117. package/src/client-history-loader.ts +23 -0
  118. package/src/client-history.ts +579 -0
  119. package/src/client-resume.ts +177 -0
  120. package/src/client-stream-eager.ts +12 -0
  121. package/src/client-stream-loader.ts +25 -0
  122. package/src/client-stream.ts +1880 -0
  123. package/src/client.test.ts +31 -1
  124. package/src/client.ts +293 -2607
  125. package/src/components/approval-actions.test.ts +27 -0
  126. package/src/components/approval-actions.ts +27 -3
  127. package/src/components/message-bubble.test.ts +19 -0
  128. package/src/components/message-bubble.ts +2 -0
  129. package/src/components/registry.ts +6 -6
  130. package/src/components/tool-bubble.test.ts +9 -0
  131. package/src/components/tool-bubble.ts +1 -0
  132. package/src/history-shell-bundle.test.ts +54 -0
  133. package/src/history-shell-eager.ts +12 -0
  134. package/src/history-shell-loader.ts +27 -0
  135. package/src/history-shell.ts +2362 -0
  136. package/src/history-view-loader.ts +2 -0
  137. package/src/history-view.ts +3 -0
  138. package/src/index-core.ts +8 -0
  139. package/src/index-global.ts +90 -248
  140. package/src/index.ts +13 -0
  141. package/src/install.ts +6 -4
  142. package/src/internal/runtype-history-provider.test.ts +1 -0
  143. package/src/internal/runtype-history-provider.ts +28 -13
  144. package/src/plugins/registry.ts +8 -8
  145. package/src/session-actions-chunk.test.ts +55 -0
  146. package/src/session-actions-eager.ts +11 -0
  147. package/src/session-actions-loader.ts +37 -0
  148. package/src/session-actions.ts +909 -0
  149. package/src/session-history.test.ts +3 -1
  150. package/src/session.ts +875 -1530
  151. package/src/session.voice.test.ts +1241 -5
  152. package/src/styles/widget.css +58 -0
  153. package/src/theme-editor/state.ts +91 -91
  154. package/src/types.ts +269 -5
  155. package/src/ui-ask-user-sheet.ts +382 -0
  156. package/src/ui-extras-eager.ts +10 -0
  157. package/src/ui-extras-entry.ts +9 -0
  158. package/src/ui-extras-loader.ts +26 -0
  159. package/src/ui.artifact-rail-resize.test.ts +2 -1
  160. package/src/ui.ask-user-chunk.test.ts +93 -0
  161. package/src/ui.history-chunk.test.ts +2 -1
  162. package/src/ui.history-config-provider.test.ts +2 -1
  163. package/src/ui.history-confirm-fallback.test.ts +142 -0
  164. package/src/ui.history-controller.test.ts +2 -1
  165. package/src/ui.history-lifecycle.test.ts +2 -1
  166. package/src/ui.history-render-hooks.test.ts +2 -1
  167. package/src/ui.history-shell.test.ts +2 -1
  168. package/src/ui.rail-overlay.test.ts +2 -1
  169. package/src/ui.rail-resize.test.ts +2 -1
  170. package/src/ui.rail-trigger-pending.test.ts +2 -1
  171. package/src/ui.ts +399 -2770
  172. package/src/ui.voice-prewarm.test.ts +222 -0
  173. package/src/utils/attachment-manager.ts +67 -67
  174. package/src/utils/constants.ts +3 -0
  175. package/src/utils/context-mention-controller.ts +323 -323
  176. package/src/utils/context-mention-manager.ts +40 -40
  177. package/src/utils/context-mention-orchestrator.ts +28 -0
  178. package/src/utils/event-stream-buffer.ts +50 -50
  179. package/src/utils/event-stream-store.ts +43 -43
  180. package/src/utils/throughput-tracker.ts +34 -34
  181. package/src/utils/virtual-scroller.ts +67 -67
  182. package/src/utils/visitor-store.test.ts +50 -0
  183. package/src/utils/visitor-store.ts +23 -5
  184. package/src/voice/__fixtures__/voice-wire/README.md +1 -0
  185. package/src/voice/__fixtures__/voice-wire/client/cancel.json +3 -0
  186. package/src/voice/__fixtures__/voice-wire/client/context.json +4 -0
  187. package/src/voice/__fixtures__/voice-wire/client/delegation_result.json +6 -0
  188. package/src/voice/__fixtures__/voice-wire/client/delegation_update.json +6 -0
  189. package/src/voice/__fixtures__/voice-wire/client/ping.json +3 -0
  190. package/src/voice/__fixtures__/voice-wire/client/playback_progress.json +5 -0
  191. package/src/voice/__fixtures__/voice-wire/client/start.json +3 -0
  192. package/src/voice/__fixtures__/voice-wire/server/artifact.json +11 -0
  193. package/src/voice/__fixtures__/voice-wire/server/attached.json +4 -0
  194. package/src/voice/__fixtures__/voice-wire/server/audio_clear.json +4 -0
  195. package/src/voice/__fixtures__/voice-wire/server/audio_end.json +3 -0
  196. package/src/voice/__fixtures__/voice-wire/server/delegation_cancelled.json +5 -0
  197. package/src/voice/__fixtures__/voice-wire/server/delegation_completed.json +8 -0
  198. package/src/voice/__fixtures__/voice-wire/server/delegation_started.json +15 -0
  199. package/src/voice/__fixtures__/voice-wire/server/error.json +6 -0
  200. package/src/voice/__fixtures__/voice-wire/server/metrics.json +10 -0
  201. package/src/voice/__fixtures__/voice-wire/server/pong.json +3 -0
  202. package/src/voice/__fixtures__/voice-wire/server/session_config.json +16 -0
  203. package/src/voice/__fixtures__/voice-wire/server/transcript_final.json +7 -0
  204. package/src/voice/__fixtures__/voice-wire/server/transcript_interim.json +4 -0
  205. package/src/voice/__fixtures__/voice-wire/server/transcript_partial.json +6 -0
  206. package/src/voice/__fixtures__/voice-wire/server/transcript_update.json +10 -0
  207. package/src/voice/__fixtures__/voice-wire/server/warning.json +5 -0
  208. package/src/voice/audio-playback-manager.test.ts +57 -0
  209. package/src/voice/audio-playback-manager.ts +120 -98
  210. package/src/voice/browser-voice-provider.ts +37 -37
  211. package/src/voice/fallback-speech-engine.ts +22 -16
  212. package/src/voice/keyed-voice-transcript.ts +314 -0
  213. package/src/voice/mic-state-styles.ts +182 -0
  214. package/src/voice/read-aloud-controller.ts +46 -44
  215. package/src/voice/runtype-speech-engine.ts +35 -33
  216. package/src/voice/runtype-voice-provider.ts +856 -164
  217. package/src/voice/session-voice-wiring.ts +266 -0
  218. package/src/voice/voice-delegation.test.ts +71 -0
  219. package/src/voice/voice-delegation.ts +364 -0
  220. package/src/voice/voice-turn-tracker.ts +6 -6
  221. package/src/voice/voice.test.ts +1250 -7
  222. package/src/voice/worklet-playback-engine.test.ts +81 -0
  223. package/src/voice/worklet-playback-engine.ts +21 -3
  224. package/src/voice-runtime-loader.ts +4 -1
  225. package/src/voice-runtime.ts +7 -0
  226. package/src/webmcp-runtime-entry.ts +41 -39
  227. package/dist/approval-ui-3NMYNXBD.js +0 -1
  228. package/dist/chunk-5EIIHQLQ.js +0 -1
  229. package/dist/chunk-7CO4T5KB.js +0 -1
  230. package/dist/chunk-EFJBUH5F.js +0 -1
  231. package/dist/chunk-R2TMIMDI.js +0 -3
  232. package/dist/chunk-UTVLOTWB.js +0 -9
  233. package/dist/context-mentions-45MODWOY.js +0 -161
  234. package/dist/event-stream-view-GL3ADRY2.js +0 -1
  235. package/dist/runtype-tts-entry-UJAEF7NZ.js +0 -1
  236. package/dist/voice-runtime-EFR3R5ES.js +0 -1
  237. package/dist/webmcp-runtime-JI7T4QYF.js +0 -6
@@ -0,0 +1,364 @@
1
+ import type {
2
+ AgentWidgetApproval,
3
+ AgentWidgetMessage,
4
+ VoiceDelegationFollowUp,
5
+ VoiceDelegationRequest,
6
+ VoiceDelegationResult,
7
+ VoiceSessionBridge,
8
+ } from "../types";
9
+
10
+ /**
11
+ * What a delegated turn streamed: its assistant messages, whether it failed,
12
+ * and whether the server dropped its result (then browser TTS may read it).
13
+ */
14
+ export type VoiceDelegationCapture = { ids: string[]; failed: boolean; dropped?: boolean };
15
+
16
+ /** The session internals the delegation bridge drives. */
17
+ export interface VoiceDelegationHost {
18
+ messages(): AgentWidgetMessage[];
19
+ /** A chat turn is streaming, resuming, or running local (WebMCP) tools. */
20
+ busy(): boolean;
21
+ /** A local (WebMCP) tool is waiting on the visitor's approval. */
22
+ parked(): boolean;
23
+ /** The transcript bubble the request came from (see KeyedVoiceTranscript.claimUserTurn). */
24
+ claim(userText: string, userUtteranceIds: string[]): string | null;
25
+ /** sendMessage as a voice turn, submitting `userMessageId`'s bubble when given. */
26
+ send(userText: string, userMessageId: string | undefined): Promise<void>;
27
+ /** Route the chat stream's assistant messages and failures into `capture` (or stop). */
28
+ track(capture: VoiceDelegationCapture | null): void;
29
+ /** Deny a pending approval (server gate or WebMCP) by its message id. */
30
+ decide(approvalMessageId: string): void;
31
+ /** Let browser TTS read these messages after all (their spoken result was dropped). */
32
+ unspoken(messageIds: string[]): void;
33
+ }
34
+
35
+ const SETTLE_POLL_MS = 50;
36
+ const WAITING_FOR_INPUT = "I need your answer in the chat before I can continue.";
37
+ const APPROVAL_SCRIPT_MAX_CHARS = 1000;
38
+ /** How long a voice-originated approval waits for the visitor (Amendment 4). */
39
+ const APPROVAL_TTL_MS = 5 * 60_000;
40
+ // Unambiguous spoken declines only (on normalized text); anything else runs as
41
+ // a normal turn. Nothing is ever approved by voice.
42
+ const DECLINE_PHRASE =
43
+ "no|nope|never ?mind|decline(?: it| that)?|cancel(?: it| that| this)?(?: (?:the|my|that|this) (?:[a-z]+ )?(?:order|request))?|don'?t (?:do|place) (?:it|that|the (?:[a-z]+ )?order)";
44
+ const VOICE_DECLINE = new RegExp(`^(?:${DECLINE_PHRASE})(?: (?:${DECLINE_PHRASE}))?(?: thanks| thank you)?$`);
45
+ /** Whether `userText` is an unambiguous spoken decline ("cancel that", "never mind"). */
46
+ export const isVoiceDecline = (userText: string) =>
47
+ VOICE_DECLINE.test(
48
+ userText
49
+ .toLowerCase()
50
+ .replace(/[^a-z' ]+/g, " ")
51
+ .replace(/\s+/g, " ")
52
+ .trim(),
53
+ );
54
+
55
+ const APPROVAL_GUIDANCE =
56
+ "Briefly tell the user what you're about to do and ask them to approve or decline it in the chat. Don't claim it's done.";
57
+
58
+ const sleep = () => new Promise((resolve) => setTimeout(resolve, SETTLE_POLL_MS));
59
+ const clip = (text: string, max: number) => (text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text);
60
+ /** `place_pickup_order` / `webmcp:addToCart` → "place pickup order" / "add to cart". */
61
+ const humanize = (name: string) =>
62
+ name
63
+ .replace(/^webmcp:/, "")
64
+ .replace(/([a-z\d])([A-Z])/g, "$1 $2")
65
+ .replace(/[_\-.]+/g, " ")
66
+ .trim()
67
+ .toLowerCase();
68
+
69
+ /** A compact spoken summary of a tool argument ("2 almond croissants, 1 sourdough loaf"). */
70
+ function brief(value: unknown, depth = 0): string {
71
+ if (value == null || value === "") return "";
72
+ if (typeof value !== "object") return String(value);
73
+ if (depth > 2) return "";
74
+ if (Array.isArray(value)) return value.map((item) => brief(item, depth + 1)).filter(Boolean).join(", ");
75
+ const record = value as Record<string, unknown>;
76
+ const quantity = record.quantity ?? record.qty ?? record.count;
77
+ const name = record.name ?? record.item ?? record.product ?? record.title;
78
+ if (quantity != null && name != null && typeof name !== "object") return `${quantity} ${name}`;
79
+ return Object.entries(record)
80
+ // `_approvalReason` and other reserved keys are not arguments.
81
+ .filter(([key]) => !key.startsWith("_"))
82
+ .map(([key, item]) => {
83
+ const text = brief(item, depth + 1);
84
+ return text && (depth ? `${humanize(key)} ${text}` : `${humanize(key)}: ${text}`);
85
+ })
86
+ .filter(Boolean)
87
+ .join(depth ? ", " : "; ");
88
+ }
89
+
90
+ /**
91
+ * The `delegation_result` text for a turn parked on approvals: what is about
92
+ * to happen, so the voice model can ask for the decision in its own words.
93
+ */
94
+ export function buildApprovalScript(approvals: AgentWidgetApproval[]): string {
95
+ const header =
96
+ approvals.length > 1
97
+ ? "These actions need the user's approval in the chat before they happen:"
98
+ : "This action needs the user's approval in the chat before it happens:";
99
+ // Header, lines and guidance, with a newline after each line and one blank line.
100
+ const budget = APPROVAL_SCRIPT_MAX_CHARS - header.length - APPROVAL_GUIDANCE.length - 2;
101
+ const lines = approvals.map((approval) => {
102
+ const parts = [humanize(approval.toolName)];
103
+ if (approval.description) parts.push(`(${clip(approval.description.trim(), 160)})`);
104
+ const args = brief(approval.parameters);
105
+ if (args) parts.push(`with ${args}`);
106
+ if (approval.reason) parts.push(`because: ${clip(approval.reason.trim(), 160)}`);
107
+ return clip(`- ${parts.join(" ")}`, Math.floor(budget / approvals.length) - 1);
108
+ });
109
+ return `${header}\n${lines.join("\n")}\n\n${APPROVAL_GUIDANCE}`;
110
+ }
111
+
112
+ const isPendingApproval = (m: AgentWidgetMessage) => m.variant === "approval" && m.approval?.status === "pending";
113
+
114
+ /**
115
+ * Builds the {@link VoiceSessionBridge} a full-duplex provider uses to run
116
+ * delegated turns through the widget's chat pipeline. Ships in the lazy
117
+ * voice-runtime chunk.
118
+ */
119
+ export function createVoiceSessionBridge(host: VoiceDelegationHost): VoiceSessionBridge {
120
+ // One tracking slot: a running delegated turn, else a parked turn's follow-up.
121
+ let active: VoiceDelegationCapture | null = null;
122
+ let following: VoiceDelegationCapture | null = null;
123
+ const retrack = () => host.track(active ?? following);
124
+ // Voice-originated approval parks still awaiting a decision or follow-up.
125
+ // `replaced`: approvals this bridge declined because a newer request for the same tool came in.
126
+ type Parked = {
127
+ delegationId: string;
128
+ approvals: string[];
129
+ at: number;
130
+ replaced: Set<string>;
131
+ outcome?: VoiceDelegationFollowUp;
132
+ };
133
+ const parks: Parked[] = [];
134
+ // Per delegation: the captures of its turn and follow-up, so a result the
135
+ // server dropped can be handed back to browser TTS. Bounded: recent calls only.
136
+ const captures = new Map<string, VoiceDelegationCapture[]>();
137
+ // Delegations the server cancelled, and whether that expired their approvals.
138
+ const cancelled = new Map<string, boolean>();
139
+ const remember = (delegationId: string, capture: VoiceDelegationCapture) => {
140
+ captures.set(delegationId, [...(captures.get(delegationId) ?? []), capture]);
141
+ if (captures.size > 32) captures.delete(captures.keys().next().value!);
142
+ };
143
+ const message = (id: string) => host.messages().find((m) => m.id === id);
144
+ const pendingOf = (park: Parked) => park.approvals.filter((id) => message(id)?.approval?.status === "pending");
145
+ const toolOf = (id: string) => humanize(message(id)?.approval?.toolName ?? "");
146
+ /** Decide a park's outcome now, denying whatever of it is still pending. */
147
+ const settle = (park: Parked, status: VoiceDelegationFollowUp["status"], text: string) => {
148
+ park.outcome = { status, text };
149
+ for (const id of pendingOf(park)) host.decide(id);
150
+ };
151
+ const answerOf = (ids: string[]) =>
152
+ ids
153
+ .flatMap((id) => host.messages().find((m) => m.id === id) ?? [])
154
+ .filter((m) => !m.variant && m.content.trim())
155
+ .map((m) => m.content.trim())
156
+ .join("\n\n");
157
+
158
+ // Turns end on many paths (stream end, WebMCP resumes, reconnects): poll.
159
+ // A turn parked on the visitor stays busy, and a replaced one is done.
160
+ const settled = async (capture?: VoiceDelegationCapture) => {
161
+ do {
162
+ await sleep();
163
+ } while (host.busy() && !(capture && (host.parked() || capture.failed)));
164
+ };
165
+
166
+ return {
167
+ getHistory: () =>
168
+ host.messages().flatMap((m) =>
169
+ (m.role === "user" || m.role === "assistant") &&
170
+ !m.variant &&
171
+ !m.voiceProcessing &&
172
+ !m.voiceCaption &&
173
+ m.content
174
+ ? [{ role: m.role, content: m.content }]
175
+ : [],
176
+ ),
177
+
178
+ dropDelegation(delegationId, expired) {
179
+ // Remembered for a turn still running: when it parks, the same rule applies.
180
+ cancelled.set(delegationId, !!expired);
181
+ if (cancelled.size > 32) cancelled.delete(cancelled.keys().next().value!);
182
+ // Past the server's deadline: decline the card, as the approval TTL would.
183
+ const park = expired && parks.find((p) => p.delegationId === delegationId);
184
+ if (park && !park.outcome) settle(park, "timeout", "");
185
+ for (const capture of captures.get(delegationId) ?? []) {
186
+ capture.dropped = true;
187
+ if (capture.ids.length) host.unspoken(capture.ids);
188
+ }
189
+ },
190
+
191
+ async runDelegatedTurn({
192
+ delegationId,
193
+ userText,
194
+ userUtteranceIds,
195
+ }: VoiceDelegationRequest): Promise<VoiceDelegationResult> {
196
+ const live = parks.filter((park) => pendingOf(park).length);
197
+ const pendingIds = live.flatMap(pendingOf);
198
+ // "Cancel that" while exactly one voice approval waits: deny it here; its
199
+ // own follow-up stays silent, since this answer says so.
200
+ if (pendingIds.length === 1 && isVoiceDecline(userText)) {
201
+ const tool = toolOf(pendingIds[0]);
202
+ // The parked delegation still gets its one terminal result, silently.
203
+ settle(live[0], "denied", "");
204
+ return { status: "denied", text: `Okay, I cancelled the ${tool} request. Nothing was done.` };
205
+ }
206
+ // A voice-originated WebMCP approval holds the chat turn open: this turn
207
+ // replaces it (a new send declines it), so don't wait on it. Anything
208
+ // else in flight finishes first.
209
+ const replacing =
210
+ host.parked() && host.messages().every((m) => !isPendingApproval(m) || pendingIds.includes(m.id));
211
+ if (host.busy() && !replacing) await settled();
212
+ const before = new Set(host.messages().map((m) => m.id));
213
+ const raised = () => host.messages().filter((m) => !before.has(m.id) && m.variant === "approval");
214
+ const capture: VoiceDelegationCapture = { ids: [], failed: false };
215
+ remember(delegationId, capture);
216
+ active = capture;
217
+ retrack();
218
+ try {
219
+ await host.send(userText, host.claim(userText, userUtteranceIds) ?? undefined);
220
+ // Local tools and approvals may continue the turn past the first stream.
221
+ await settled(capture);
222
+ } catch {
223
+ capture.failed = true;
224
+ } finally {
225
+ active = null;
226
+ retrack();
227
+ }
228
+ const replies = capture.ids.flatMap((id) => host.messages().find((m) => m.id === id) ?? []);
229
+ let text = answerOf(capture.ids);
230
+ const pending = capture.failed ? [] : raised().filter(isPendingApproval);
231
+
232
+ // An earlier voice approval for the same tool is replaced by this one; a
233
+ // WebMCP one this turn displaced (and so declined) is reported as such.
234
+ const tools = new Set(pending.map((m) => humanize(m.approval!.toolName)));
235
+ for (const park of live) {
236
+ if (park.outcome) continue;
237
+ // Only the approvals for the same tool are replaced; the park's others stand.
238
+ for (const id of pendingOf(park).filter((id) => tools.has(toolOf(id)))) {
239
+ park.replaced.add(id);
240
+ host.decide(id);
241
+ }
242
+ const tool = toolOf(park.approvals[0]);
243
+ if (park.approvals.every((id) => park.replaced.has(id))) {
244
+ park.outcome = { status: "cancelled", text: `The earlier ${tool} request was replaced by the new one; it was not done.` };
245
+ } else if (replacing && park.approvals.every((id) => message(id)?.approval?.status === "denied")) {
246
+ park.outcome = { status: "cancelled", text: `The earlier ${tool} request was replaced by the new one; it was not done.` };
247
+ }
248
+ }
249
+
250
+ if (pending.length) {
251
+ const own = new Set(pending.map((m) => m.id));
252
+ const park: Parked = { delegationId, approvals: [...own], at: Date.now(), replaced: new Set() };
253
+ parks.push(park);
254
+ // Cancelled while it ran: no ask will go out and no TTL will run, so
255
+ // expire the card now (deadline), or leave it to the chat.
256
+ if (cancelled.get(delegationId)) settle(park, "timeout", "");
257
+ // Parked on approvals: answer now, so the voice model asks for the
258
+ // decision, then read the outcome back once the visitor decides.
259
+ text = `${text}\n\n${buildApprovalScript(pending.map((m) => m.approval!))}`.trim();
260
+ return {
261
+ status: "pending_approval",
262
+ text,
263
+ followUp: async ({ signal, approvalTimeoutMs = APPROVAL_TTL_MS, readBack = true, onUpdate }) => {
264
+ const follow: VoiceDelegationCapture = { ids: [], failed: false };
265
+ // Called once the ask went out: it carried this turn's reply to the
266
+ // voice model, so a later drop releases only the follow-up's answer.
267
+ captures.delete(delegationId);
268
+ remember(delegationId, follow);
269
+ // This turn's approvals, and any its resumed stream chains into
270
+ // (not another turn's).
271
+ const mine = () => raised().filter((m) => own.has(m.id) || follow.ids.includes(m.id));
272
+ // A gated tool the resumed turn chains into: a new approval of this
273
+ // park (server gate: captured; WebMCP: the one pending while this
274
+ // follow-up owns the stream), asked for with another update.
275
+ const chained = () =>
276
+ raised().filter(
277
+ (m) =>
278
+ isPendingApproval(m) &&
279
+ !own.has(m.id) &&
280
+ !parks.some((other) => other.approvals.includes(m.id)) &&
281
+ (follow.ids.includes(m.id) || (m.approval?.toolType === "webmcp" && following === follow)),
282
+ );
283
+ const claim = () => {
284
+ following = follow;
285
+ retrack();
286
+ };
287
+ if (readBack) claim();
288
+ try {
289
+ // Every approval of this turn decided (or replaced, cancelled,
290
+ // expired), and the resumed turn finished.
291
+ while (!signal.aborted && !park.outcome && (active || host.busy() || mine().some(isPendingApproval))) {
292
+ // With several turns parked, the one whose approvals were just
293
+ // decided takes the capture slot back: its resumed stream is next.
294
+ if (readBack && following !== follow && !pendingOf(park).length) claim();
295
+ const next = chained();
296
+ if (next.length) {
297
+ for (const m of next) {
298
+ own.add(m.id);
299
+ park.approvals.push(m.id);
300
+ }
301
+ park.at = Date.now();
302
+ onUpdate?.(buildApprovalScript(next.map((m) => m.approval!)));
303
+ }
304
+ // Unanswered too long: decline it, but never by aborting a
305
+ // turn in flight (a parked WebMCP turn is the one waiting).
306
+ if (Date.now() - park.at >= approvalTimeoutMs && pendingOf(park).length && (!host.busy() || host.parked())) {
307
+ settle(park, "timeout", `That ${toolOf(park.approvals[0])} request expired, so nothing was done.`);
308
+ break;
309
+ }
310
+ await sleep();
311
+ }
312
+ } finally {
313
+ if (following === follow) {
314
+ following = null;
315
+ retrack();
316
+ }
317
+ // Hang-up stops voice follow-ups; the approval card stays usable.
318
+ parks.splice(parks.indexOf(park), 1);
319
+ }
320
+ if (signal.aborted) return null;
321
+ if (park.outcome) return park.outcome;
322
+ if (follow.failed) return { status: "failed", text: `The ${toolOf(park.approvals[0])} request didn't complete.` };
323
+ const decided = mine();
324
+ const names = (pick: (m: AgentWidgetMessage) => boolean) =>
325
+ decided.filter(pick).map((m) => humanize(m.approval!.toolName)).join(", ");
326
+ const approved = decided.some((m) => m.approval?.status === "approved");
327
+ const declined = names((m) => m.approval?.status === "denied" && !park.replaced.has(m.id));
328
+ const replaced = names((m) => park.replaced.has(m.id));
329
+ const timedOut = names((m) => m.approval?.status === "timeout");
330
+ // An approved action's result is read back; a decline is stated
331
+ // plainly, since the agent's own reply to it often re-asks for
332
+ // confirmation, which the voice model would repeat.
333
+ const text = [
334
+ approved ? answerOf(follow.ids) : "",
335
+ declined && `The user declined the ${declined} request in the chat, so ${approved ? "that part was not done" : "nothing was done"}.`,
336
+ replaced && `The earlier ${replaced} request was replaced by the new one; it was not done.`,
337
+ timedOut && `The ${timedOut} request timed out, so it was not done.`,
338
+ ]
339
+ .filter(Boolean)
340
+ .join("\n\n");
341
+ return {
342
+ status: approved ? "completed" : declined ? "denied" : replaced ? "cancelled" : timedOut ? "timeout" : "completed",
343
+ text,
344
+ };
345
+ },
346
+ };
347
+ }
348
+ // A turn parked on the visitor's answer still answers now, so the voice
349
+ // model can tell them to finish it in the chat.
350
+ if (
351
+ (!capture.failed && host.parked()) ||
352
+ replies.some(
353
+ (m) =>
354
+ m.agentMetadata?.awaitingLocalTool &&
355
+ !m.agentMetadata.askUserQuestionAnswered &&
356
+ !m.toolCall?.name?.startsWith("webmcp:"),
357
+ )
358
+ ) {
359
+ return { status: capture.failed ? "failed" : "completed", text: `${text}\n\n${WAITING_FOR_INPUT}`.trim() };
360
+ }
361
+ return { status: !capture.failed && text ? "completed" : "failed", text };
362
+ },
363
+ };
364
+ }
@@ -1,18 +1,18 @@
1
1
  /** Correlate provider transcripts across cancellation and overlapping turns. */
2
2
  export class VoiceTurnTracker {
3
- private turnId?: string;
4
- private cancelled = false;
3
+ #turnId?: string;
4
+ #cancelled = false;
5
5
 
6
6
  start(turnId?: string): void {
7
- this.turnId = turnId;
8
- this.cancelled = false;
7
+ this.#turnId = turnId;
8
+ this.#cancelled = false;
9
9
  }
10
10
 
11
11
  cancel(): void {
12
- this.cancelled = true;
12
+ this.#cancelled = true;
13
13
  }
14
14
 
15
15
  accepts(turnId?: string): boolean {
16
- return !this.cancelled && (!turnId || !this.turnId || turnId === this.turnId);
16
+ return !this.#cancelled && (!turnId || !this.#turnId || turnId === this.#turnId);
17
17
  }
18
18
  }