@runtypelabs/persona 4.28.2 → 4.29.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) 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/wipe.d.cts +1 -1
  4. package/dist/animations/wipe.d.ts +1 -1
  5. package/dist/approval-ui.cjs +3 -3
  6. package/dist/approval-ui.d.cts +1 -1
  7. package/dist/approval-ui.d.ts +1 -1
  8. package/dist/approval-ui.js +3 -3
  9. package/dist/artifacts-ui.d.cts +2 -2
  10. package/dist/artifacts-ui.d.ts +2 -2
  11. package/dist/chunk-B1-_pp-Y.js +4 -0
  12. package/dist/{chunk-DrYAfpvk.js → chunk-BcLIgi1h.js} +1 -1
  13. package/dist/chunk-BkW8s3fj.js +2 -0
  14. package/dist/chunk-Bm2-bemv.js +1 -0
  15. package/dist/chunk-BvhTpG6k.js +1 -0
  16. package/dist/chunk-Csks3Qw1.js +1 -0
  17. package/dist/chunk-D5B629Mj.js +1 -0
  18. package/dist/chunk-DGlFKA0r.js +9 -0
  19. package/dist/chunk-DO9_WSpy.js +1 -0
  20. package/dist/chunk-DTDHeUsG.js +1 -0
  21. package/dist/chunk-DjFpGFGA.js +1 -0
  22. package/dist/{chunk-dRBlrLd7.js → chunk-DtmtfIkd.js} +1 -1
  23. package/dist/chunk-lA5CJCgK.js +1 -0
  24. package/dist/client-history.js +1 -1
  25. package/dist/client-stream.js +1 -1
  26. package/dist/codegen.cjs +1 -1
  27. package/dist/codegen.js +1 -1
  28. package/dist/{composer-input-C39npJaz.d.cts → composer-input-B0P4OtNi.d.cts} +1 -1
  29. package/dist/{composer-input-C39npJaz.d.ts → composer-input-B0P4OtNi.d.ts} +1 -1
  30. package/dist/{context-mention-manager-DLCB1l9E.d.cts → context-mention-manager-DmLI_RM6.d.cts} +1 -1
  31. package/dist/{context-mention-manager-DLCB1l9E.d.ts → context-mention-manager-DmLI_RM6.d.ts} +1 -1
  32. package/dist/context-mentions-inline.d.cts +2 -2
  33. package/dist/context-mentions-inline.d.ts +2 -2
  34. package/dist/context-mentions.d.cts +3 -3
  35. package/dist/context-mentions.d.ts +3 -3
  36. package/dist/{defaults-DiAgWHXy.d.cts → defaults-DVo1wbZs.d.cts} +1 -1
  37. package/dist/{defaults-DiAgWHXy.d.ts → defaults-DVo1wbZs.d.ts} +1 -1
  38. package/dist/event-stream-view.d.cts +1 -1
  39. package/dist/event-stream-view.d.ts +1 -1
  40. package/dist/{fallback-speech-engine-C5_PUZjH.d.cts → fallback-speech-engine-BMnlmKnY.d.cts} +1 -1
  41. package/dist/{fallback-speech-engine-C5_PUZjH.d.ts → fallback-speech-engine-BMnlmKnY.d.ts} +1 -1
  42. package/dist/forms-ui.d.cts +2 -2
  43. package/dist/forms-ui.d.ts +2 -2
  44. package/dist/history-view.d.cts +1 -1
  45. package/dist/history-view.d.ts +1 -1
  46. package/dist/index.cjs +50 -49
  47. package/dist/index.cjs.map +1 -1
  48. package/dist/index.d.cts +7 -7
  49. package/dist/index.d.ts +7 -7
  50. package/dist/index.global.js +2 -3
  51. package/dist/index.global.js.map +1 -1
  52. package/dist/index.js +38 -37
  53. package/dist/index.js.map +1 -1
  54. package/dist/{markdown-parsers-loader-BBdyIEU7.d.cts → markdown-parsers-loader-CeovcyYf.d.cts} +1 -1
  55. package/dist/{markdown-parsers-loader-BBdyIEU7.d.ts → markdown-parsers-loader-CeovcyYf.d.ts} +1 -1
  56. package/dist/{preview-Chjb0bmI.d.cts → preview-Bkb4UrQC.d.cts} +2 -2
  57. package/dist/{preview-Chjb0bmI.d.ts → preview-Bkb4UrQC.d.ts} +2 -2
  58. package/dist/session-actions.js +1 -1
  59. package/dist/{session-a_qIp4xp.d.ts → session-iLyKipoM.d.cts} +28 -10
  60. package/dist/{session-a_qIp4xp.d.cts → session-iLyKipoM.d.ts} +28 -10
  61. package/dist/session-reconnect.cjs +1 -1
  62. package/dist/session-reconnect.d.cts +8 -2
  63. package/dist/session-reconnect.d.ts +8 -2
  64. package/dist/session-reconnect.js +1 -1
  65. package/dist/smart-dom-reader.d.cts +1 -1
  66. package/dist/smart-dom-reader.d.ts +1 -1
  67. package/dist/theme-editor-preview.cjs +61 -60
  68. package/dist/theme-editor-preview.d.cts +1 -1
  69. package/dist/theme-editor-preview.d.ts +1 -1
  70. package/dist/theme-editor-preview.js +40 -40
  71. package/dist/theme-editor.d.cts +2 -2
  72. package/dist/theme-editor.d.ts +2 -2
  73. package/dist/theme-reference.d.cts +2 -2
  74. package/dist/theme-reference.d.ts +2 -2
  75. package/dist/{types-BdO1jNRm.d.cts → types-BuDirJkQ.d.cts} +200 -23
  76. package/dist/{types-BdO1jNRm.d.ts → types-BuDirJkQ.d.ts} +200 -23
  77. package/dist/voice-client-tools.cjs +1 -0
  78. package/dist/voice-client-tools.d.cts +13 -0
  79. package/dist/voice-client-tools.d.ts +13 -0
  80. package/dist/voice-client-tools.js +1 -0
  81. package/dist/voice-runtime.cjs +3 -3
  82. package/dist/voice-runtime.d.cts +15 -5
  83. package/dist/voice-runtime.d.ts +15 -5
  84. package/dist/voice-runtime.js +5 -5
  85. package/dist/voice-worklet-player.d.cts +2 -2
  86. package/dist/voice-worklet-player.d.ts +2 -2
  87. package/dist/{webmcp-runtime-entry-B6bpVWSa.d.ts → webmcp-runtime-entry-CXLjRt1k.d.cts} +26 -5
  88. package/dist/{webmcp-runtime-entry-B6bpVWSa.d.cts → webmcp-runtime-entry-CXLjRt1k.d.ts} +26 -5
  89. package/dist/webmcp-runtime.cjs +2 -1
  90. package/dist/webmcp-runtime.d.cts +1 -1
  91. package/dist/webmcp-runtime.d.ts +1 -1
  92. package/dist/webmcp-runtime.js +2 -1
  93. package/dist/widget.css +1 -1
  94. package/package.json +6 -1
  95. package/src/client-approve.test.ts +17 -1
  96. package/src/client-resume.ts +15 -3
  97. package/src/client.interrupt.test.ts +21 -0
  98. package/src/client.ts +61 -4
  99. package/src/client.voice-tools.test.ts +65 -0
  100. package/src/components/activity-row.ts +3 -2
  101. package/src/components/approval-actions.test.ts +8 -0
  102. package/src/components/approval-actions.ts +6 -2
  103. package/src/components/approval-bubble.test.ts +14 -0
  104. package/src/components/approval-bubble.ts +8 -1
  105. package/src/components/tool-bubble.ts +8 -5
  106. package/src/generated/runtype-openapi-contract.ts +39 -1
  107. package/src/index-core.ts +11 -0
  108. package/src/index-global.ts +6 -0
  109. package/src/resume-dedupe-local-only.test.ts +273 -0
  110. package/src/runtime/persist-state.test.ts +86 -0
  111. package/src/session-actions.ts +20 -6
  112. package/src/session-reconnect.ts +18 -1
  113. package/src/session.restored-browser-tools.test.ts +272 -0
  114. package/src/session.test.ts +26 -0
  115. package/src/session.ts +185 -31
  116. package/src/session.voice.test.ts +212 -0
  117. package/src/styles/widget.css +4 -2
  118. package/src/types.ts +178 -22
  119. package/src/ui.restored-browser-tools.test.ts +128 -0
  120. package/src/ui.ts +32 -18
  121. package/src/utils/formatting.test.ts +9 -1
  122. package/src/utils/formatting.ts +4 -0
  123. package/src/utils/local-only-message.ts +15 -0
  124. package/src/utils/restored-browser-state.ts +50 -0
  125. package/src/utils/storage.ts +12 -1
  126. package/src/utils/voice-prior-tool-results.ts +37 -0
  127. package/src/voice/runtype-voice-provider.ts +94 -7
  128. package/src/voice/session-voice-wiring.ts +27 -3
  129. package/src/voice/voice-client-tools-session.ts +116 -0
  130. package/src/voice/voice-delegation.ts +19 -3
  131. package/src/voice/voice.test.ts +396 -0
  132. package/src/voice-client-tools-bundle.test.ts +37 -0
  133. package/src/voice-client-tools-loader.ts +24 -0
  134. package/src/voice-client-tools.ts +9 -0
  135. package/src/webmcp-bridge.test.ts +152 -1
  136. package/src/webmcp-bridge.ts +5 -6
  137. package/src/webmcp-polyfill.integration.test.ts +5 -1
  138. package/src/webmcp-runtime-entry.ts +151 -26
  139. package/dist/chunk-B6zZsT6F.js +0 -1
  140. package/dist/chunk-CztSjKHK.js +0 -9
  141. package/dist/chunk-DbhxjyOB.js +0 -4
  142. package/dist/chunk-DsdqLStH.js +0 -1
  143. package/dist/chunk-GIqbL2Eg.js +0 -1
  144. package/dist/chunk-HUOUif-P.js +0 -1
  145. package/dist/chunk-UlcxdHef.js +0 -1
@@ -30,8 +30,19 @@ const sanitizeArtifacts = (artifacts: PersonaArtifactRecord[]) =>
30
30
  status: "complete" as const
31
31
  }));
32
32
 
33
+ const DEFAULT_PERSIST_KEY_PREFIX = "persona-";
34
+
35
+ /**
36
+ * The widget's default transcript key, scoped by `persistState.keyPrefix` so
37
+ * two widgets on one origin with distinct prefixes keep separate transcripts.
38
+ * The default prefix yields the legacy `"persona-state"` key, so unprefixed
39
+ * widgets keep their stored conversation; prefixed widgets never adopt it.
40
+ */
41
+ export const transcriptStorageKey = (keyPrefix?: string): string =>
42
+ `${keyPrefix ?? DEFAULT_PERSIST_KEY_PREFIX}state`;
43
+
33
44
  export const createLocalStorageAdapter = (
34
- key = "persona-state"
45
+ key = transcriptStorageKey()
35
46
  ): AgentWidgetStorageAdapter => {
36
47
  const getStorage = () => {
37
48
  if (typeof window === "undefined" || !window.localStorage) {
@@ -0,0 +1,37 @@
1
+ import type { AgentWidgetMessage } from "../types";
2
+
3
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
4
+ typeof value === "object" && value !== null && !Array.isArray(value);
5
+
6
+ /**
7
+ * Tool rows for the page tools a voice server already ran before handing the
8
+ * turn to the chat (`delegation_started.input.priorToolResults`). Each row is
9
+ * a completed call carrying `clientToolAnswer`, so proxy/agent payloads replay
10
+ * it to the model as an assistant `toolCalls` + `tool` `toolResults` pair under
11
+ * the same `toolCallId`. Nothing marks it awaiting, so it never runs again.
12
+ * Malformed entries and repeats of a `toolCallId` are skipped.
13
+ */
14
+ export const voicePriorToolRows = (value: unknown, createdAt: string): AgentWidgetMessage[] => {
15
+ const seen = new Set<string>();
16
+ return (Array.isArray(value) ? value : []).flatMap((entry): AgentWidgetMessage[] => {
17
+ if (!isRecord(entry)) return [];
18
+ const { toolCallId, name } = entry;
19
+ if (typeof toolCallId !== "string" || !toolCallId || typeof name !== "string" || !name) return [];
20
+ if (seen.has(toolCallId)) return [];
21
+ seen.add(toolCallId);
22
+ const args = isRecord(entry.arguments) ? entry.arguments : {};
23
+ const result = entry.result ?? null;
24
+ return [
25
+ {
26
+ id: `voice-tool-${toolCallId}`,
27
+ role: "assistant",
28
+ variant: "tool",
29
+ content: "",
30
+ createdAt,
31
+ streaming: false,
32
+ toolCall: { id: toolCallId, name, status: "complete", args, result },
33
+ agentMetadata: { clientToolAnswer: { toolCallId, toolName: name, args, result } },
34
+ },
35
+ ];
36
+ });
37
+ };
@@ -83,8 +83,29 @@
83
83
  // completed), and that phase's completion doesn't fold another utterance.
84
84
  // Delegated sends carry `voice: { spoken: true }` on the chat request, so the
85
85
  // agent answers in speakable sentences.
86
+ //
87
+ // Fast tier (`client_tools`, declared only with client delegation and WebMCP
88
+ // enabled; the session ships in the lazy voice-client-tools chunk): after
89
+ // `session_config` the provider sends `client_tools{fingerprint, tools}`, the
90
+ // same WebMCP snapshot and fingerprint the chat path sends as `clientTools[]`
91
+ // but with each page tool's FULL name (`webmcp:<name>`; sdk tools stay bare),
92
+ // and again whenever the fingerprint changes. The server may then answer a
93
+ // delegation itself, with no `delegation_started`: it sends
94
+ // `client_tool_call{delegationId, toolCallId, name, arguments, fingerprint,
95
+ // timeoutMs}`, which runs through the WebMCP execute path (the confirm gate is
96
+ // skipped only for a tool whose registry entry says `readOnlyHint: true`) and
97
+ // is answered by one `client_tool_result{status}`: `completed`, `denied` (gate
98
+ // declined), `stale` (unknown tool, or a fingerprint other than the last one
99
+ // sent), `failed`, or `timeout` (past `timeoutMs`, which bounds the gate too).
100
+ // Then `delegation_completed{route:'direct', text}` for a delegation that never
101
+ // started here writes the heard user turn and `text` into the chat. When the
102
+ // server falls through, the delegation arrives as today's `delegation_started`;
103
+ // its `input.priorToolResults` (page tools the server already ran; sent only
104
+ // with `prior_tool_results`, declared beside `client_tools`) ride along to the
105
+ // chat turn as completed calls, never run again here.
86
106
 
87
107
  import type {
108
+ VoiceClientToolsSession,
88
109
  VoiceProvider,
89
110
  VoiceResult,
90
111
  VoiceStatus,
@@ -327,6 +348,13 @@ export class RuntypeVoiceProvider implements VoiceProvider {
327
348
  #foldReadback = false;
328
349
  #assistantTurns = new Set<string>();
329
350
  #foldedTurns = new Set<string>();
351
+ // Fast tier (see header): the call's session, from the lazy voice-client-tools chunk.
352
+ #fastTier: Promise<VoiceClientToolsSession | null> | null = null;
353
+ // Delegations this call saw start, and the last final user utterance (a direct turn's request).
354
+ #started = new Set<string>();
355
+ #heard: { id: string; text: string } | null = null;
356
+ // Direct turns in flight: the utterance each answers, captured at its first client_tool_call.
357
+ #direct = new Map<string, { id: string; text: string } | null>();
330
358
 
331
359
  #resultCallbacks: ((result: VoiceResult) => void)[] = [];
332
360
  #errorCallbacks: ((error: Error) => void)[] = [];
@@ -494,15 +522,17 @@ export class RuntypeVoiceProvider implements VoiceProvider {
494
522
  * the URL, and these params are not secrets.
495
523
  */
496
524
  #voiceSocketUrl(host: string, agentId: string, attach?: URLSearchParams): string {
497
- const capabilities = ["partial_transcript", "context"];
498
- if (this.bridge && this.config?.clientDelegation !== false)
499
- capabilities.push("client_delegation", "delegation_update", "delegation_read_back", "delegation_stream");
500
- if (attach) capabilities.unshift("attach");
525
+ let capabilities = "partial_transcript,context";
526
+ if (this.bridge && this.config?.clientDelegation !== false) {
527
+ capabilities += ",client_delegation,delegation_update,delegation_read_back,delegation_stream";
528
+ if (this.bridge.clientTools?.()) capabilities += ",client_tools,prior_tool_results";
529
+ }
530
+ if (attach) capabilities = `attach,${capabilities}`;
501
531
  const params = new URLSearchParams({
502
532
  voiceProtocol: VOICE_PROTOCOL,
503
533
  clientVersion: `persona/${VERSION}`,
504
534
  voiceCapabilities: FULL_DUPLEX_CAPABILITY,
505
- clientCapabilities: capabilities.join(","),
535
+ clientCapabilities: capabilities,
506
536
  });
507
537
  attach?.forEach((value, key) => params.set(key, value));
508
538
  return `${toWsBase(host)}/ws/agents/${encodeURIComponent(agentId)}/voice?${params}`;
@@ -849,8 +879,35 @@ export class RuntypeVoiceProvider implements VoiceProvider {
849
879
  this.#contextSent = true;
850
880
  this.#pendingContext = this.#buildContextText();
851
881
  }
882
+ if (this.#clientDelegation && this.#capabilities.has("client_tools") && !this.#fastTier) {
883
+ // INVARIANT: #clientDelegation implies a bridge.
884
+ this.#fastTier =
885
+ this.bridge!
886
+ .clientTools?.()
887
+ ?.open({
888
+ send: (frame) => {
889
+ const ws = this.#ws;
890
+ if (generation !== this.#callGeneration || ws?.readyState !== WebSocket.OPEN) return false;
891
+ ws.send(JSON.stringify(frame));
892
+ return true;
893
+ },
894
+ cancelled: (id) => this.#cancelledDelegations.has(id),
895
+ })
896
+ .catch(() => null) ?? null;
897
+ }
852
898
  break;
853
899
 
900
+ case "client_tool_call": {
901
+ // A direct turn: keep the request it answers (speech may follow) and fold its read-back.
902
+ const id = String(msg.delegationId);
903
+ if (this.#fastTier && !this.#direct.has(id)) {
904
+ this.#direct.set(id, this.#heard);
905
+ this.#answered.add(id);
906
+ }
907
+ void this.#fastTier?.then((session) => session?.call(msg));
908
+ break;
909
+ }
910
+
854
911
  case "transcript_update": {
855
912
  const role = msg.role === "assistant" ? "assistant" : "user";
856
913
  // A reply the client cancelled keeps streaming until the server clears it.
@@ -860,7 +917,10 @@ export class RuntypeVoiceProvider implements VoiceProvider {
860
917
  if (role === "user") {
861
918
  // Release on a final user transcript, not a partial: a mid-utterance
862
919
  // context append makes the voice model answer early (or not delegate).
863
- if (msg.final === true) this.#flushCallContext(generation);
920
+ if (msg.final === true) {
921
+ this.#flushCallContext(generation);
922
+ this.#heard = { id: turnId, text: String(msg.text ?? "") };
923
+ }
864
924
  this.#foldReadback = false;
865
925
  } else {
866
926
  // Read-back of a delegated result the chat already renders. A server
@@ -902,6 +962,10 @@ export class RuntypeVoiceProvider implements VoiceProvider {
902
962
  const delegationId = String(msg.delegationId ?? msg.turnId);
903
963
  if (msg.type === "delegation_started") {
904
964
  this.#flushCallContext(generation);
965
+ this.#started.add(delegationId);
966
+ // A direct attempt that fell through: its answer is the chat turn's now, and its tool must not act late.
967
+ this.#answered.delete(delegationId);
968
+ if (this.#direct.delete(delegationId)) void this.#fastTier?.then((session) => session?.abort(delegationId));
905
969
  // `input`: run this turn through the chat pipeline (client delegation).
906
970
  const input = msg.input;
907
971
  if (this.#clientDelegation && input) {
@@ -914,11 +978,26 @@ export class RuntypeVoiceProvider implements VoiceProvider {
914
978
  ? input.userUtteranceIds.filter((id: unknown): id is string => typeof id === "string")
915
979
  : [],
916
980
  messages: Array.isArray(input.messages) ? input.messages : [],
981
+ priorToolResults: input.priorToolResults,
917
982
  },
918
983
  generation,
919
984
  );
920
985
  }
921
- } else if (msg.speak !== false && this.#answered.has(delegationId)) {
986
+ } else if (msg.route === "direct" && this.#fastTier && !this.#started.has(delegationId)) {
987
+ // The server answered it itself: write the request it answered and its
988
+ // answer into the chat, as the chat pipeline would have.
989
+ const heard = this.#direct.get(delegationId) ?? this.#heard;
990
+ this.#direct.delete(delegationId);
991
+ this.#started.add(delegationId);
992
+ this.#answered.add(delegationId);
993
+ this.bridge?.recordDirectTurn?.({
994
+ delegationId,
995
+ userText: heard?.text ?? "",
996
+ userUtteranceIds: heard ? [heard.id] : [],
997
+ assistantText: typeof msg.text === "string" ? msg.text : "",
998
+ });
999
+ }
1000
+ if (msg.type === "delegation_completed" && msg.speak !== false && this.#answered.has(delegationId)) {
922
1001
  // Every spoken phase (the approval ask, the late result) of an answer
923
1002
  // the chat shows is read back. A refusal of one that never started
924
1003
  // here has no chat answer: it renders. `final` may never come. A
@@ -1086,6 +1165,9 @@ export class RuntypeVoiceProvider implements VoiceProvider {
1086
1165
  // unrelated utterance: disarm it.
1087
1166
  if (this.#foldArmedBy === delegationId) this.#foldReadback = false;
1088
1167
  this.#progressed.delete(delegationId);
1168
+ // A direct answer that never completed has no chat message to fold onto.
1169
+ if (!this.#started.has(delegationId)) this.#answered.delete(delegationId);
1170
+ void this.#fastTier?.then((session) => session?.abort(delegationId));
1089
1171
  this.bridge?.dropDelegation?.(delegationId, expired);
1090
1172
  this.#followUps.get(delegationId)?.abort();
1091
1173
  }
@@ -1255,6 +1337,11 @@ export class RuntypeVoiceProvider implements VoiceProvider {
1255
1337
  this.#foldReadback = false;
1256
1338
  this.#assistantTurns.clear();
1257
1339
  this.#foldedTurns.clear();
1340
+ void this.#fastTier?.then((session) => session?.close());
1341
+ this.#fastTier = null;
1342
+ this.#started.clear();
1343
+ this.#direct.clear();
1344
+ this.#heard = null;
1258
1345
  this.#finishedArmed = false;
1259
1346
  this.#cancelledUntil = 0;
1260
1347
  this.#playbackEndsAt = 0;
@@ -16,7 +16,7 @@ import type { VoiceTurnTracker } from "./voice-turn-tracker";
16
16
  /** The session members the wiring touches (some are TS-private on the class). */
17
17
  export type VoiceWiringSession = Pick<
18
18
  AgentWidgetSession,
19
- "injectMessage" | "sendMessage" | "resolveWebMcpApproval" | "resolveApproval"
19
+ "injectMessage" | "sendMessage" | "resolveWebMcpApproval" | "resolveApproval" | "getClient"
20
20
  > & {
21
21
  messages: AgentWidgetMessage[];
22
22
  callbacks: AgentWidgetSession["callbacks"];
@@ -81,8 +81,7 @@ export const wireSessionVoice = (
81
81
  busy: () => p.busy(),
82
82
  parked: () => s.webMcpApprovalResolvers.size > 0,
83
83
  claim: (text, userUtteranceIds) => p.keyed?.claimUserTurn(text, userUtteranceIds) ?? null,
84
- send: (text, userMessageId) =>
85
- s.sendMessage(text, { viaVoice: true, voiceTurn: { userMessageId } }),
84
+ send: (text, voiceTurn) => s.sendMessage(text, { viaVoice: true, voiceTurn }),
86
85
  track: (capture) => {
87
86
  p.setDelegation(capture);
88
87
  },
@@ -92,6 +91,31 @@ export const wireSessionVoice = (
92
91
  // now. A stream still running reads it when it ends, once.
93
92
  if (!s.streaming) s.speakLatestAssistantMessage(ids);
94
93
  },
94
+ clientTools: () => s.getClient().getVoiceClientTools(),
95
+ recordDirect: ({ delegationId, userText, userUtteranceIds, assistantText }) => {
96
+ // The heard transcript bubble becomes the conversation turn, as a
97
+ // delegated send would make it; without one, the request text is.
98
+ const bubbleId = p.keyed?.claimUserTurn(userText, userUtteranceIds);
99
+ let claimed = false;
100
+ // upsertMessage merges, so it cannot drop the caption flag: replace the bubble.
101
+ s.messages = s.messages.map((m) => {
102
+ if (m.id !== bubbleId || m.role !== 'user') return m;
103
+ claimed = true;
104
+ const { voiceCaption: _caption, ...turn } = m;
105
+ return { ...turn, viaVoice: true };
106
+ });
107
+ if (claimed) {
108
+ s.callbacks.onMessagesChanged([...s.messages]);
109
+ } else if (userText.trim()) {
110
+ s.injectMessage({ role: 'user', content: userText.trim() });
111
+ }
112
+ const answer = assistantText.trim();
113
+ if (!answer) return;
114
+ // The voice model speaks this answer: browser TTS must not read it again.
115
+ const id = `voice-direct-${delegationId}`;
116
+ s.ttsSpokenMessageIds.add(id);
117
+ s.injectMessage({ id, role: 'assistant', content: answer });
118
+ },
95
119
  decide: (id) => {
96
120
  if (s.webMcpApprovalResolvers.has(id)) return s.resolveWebMcpApproval(id, 'denied');
97
121
  const approval = s.messages.find((m) => m.id === id)?.approval;
@@ -0,0 +1,116 @@
1
+ import type {
2
+ ClientToolCallFrame,
3
+ ClientToolDefinition,
4
+ ClientToolsFrame,
5
+ VoiceClientToolOutcome,
6
+ VoiceClientTools,
7
+ VoiceClientToolsChannel,
8
+ VoiceClientToolsSession,
9
+ } from "../types";
10
+
11
+ const WEBMCP_PREFIX = "webmcp:";
12
+
13
+ /** The name the server calls a client tool back with: `webmcp:` prefixed for page tools. */
14
+ export function voiceClientToolName(tool: ClientToolDefinition): string {
15
+ return tool.origin === "webmcp" && !tool.name.startsWith(WEBMCP_PREFIX) ? WEBMCP_PREFIX + tool.name : tool.name;
16
+ }
17
+
18
+ /**
19
+ * The fast-tier half of a voice call (see runtype-voice-provider.ts): keeps the
20
+ * server's `client_tools` snapshot current and answers each `client_tool_call`
21
+ * with exactly one `client_tool_result`. Ships in the lazy
22
+ * `voice-client-tools` chunk, loaded only when the server negotiates it.
23
+ */
24
+ export function createVoiceClientToolsSession(
25
+ surface: VoiceClientTools,
26
+ channel: VoiceClientToolsChannel,
27
+ ): VoiceClientToolsSession {
28
+ let closed = false;
29
+ let queue = Promise.resolve();
30
+ let sentFingerprint: string | null = null;
31
+ let sent = new Map<string, ClientToolDefinition>();
32
+ const calls = new Map<string, { delegationId: string; abort: AbortController }>();
33
+
34
+ // Serialized, so snapshots go out in order; only a changed fingerprint is resent.
35
+ const sync = () => {
36
+ queue = queue.then(async () => {
37
+ const snapshot = closed ? null : await surface.snapshot().catch(() => null);
38
+ if (!snapshot || closed || snapshot.fingerprint === sentFingerprint) return;
39
+ const tools = snapshot.tools.map((tool) => ({ ...tool, name: voiceClientToolName(tool) }));
40
+ const frame: ClientToolsFrame = { type: "client_tools", fingerprint: snapshot.fingerprint, tools };
41
+ if (!channel.send(frame)) return;
42
+ sentFingerprint = snapshot.fingerprint;
43
+ sent = new Map(tools.map((tool) => [tool.name, tool]));
44
+ });
45
+ };
46
+
47
+ const abort = (delegationId?: string) => {
48
+ for (const [id, call] of calls) {
49
+ if (delegationId !== undefined && call.delegationId !== delegationId) continue;
50
+ calls.delete(id);
51
+ call.abort.abort();
52
+ }
53
+ };
54
+
55
+ const stopWatch = surface.onChange(sync);
56
+ sync();
57
+
58
+ return {
59
+ abort,
60
+ close() {
61
+ closed = true;
62
+ stopWatch();
63
+ abort();
64
+ },
65
+ call(msg: Partial<ClientToolCallFrame>) {
66
+ const { delegationId, toolCallId } = msg;
67
+ if (closed || typeof delegationId !== "string" || !delegationId || typeof toolCallId !== "string" || !toolCallId)
68
+ return;
69
+ const controller = new AbortController();
70
+ calls.get(toolCallId)?.abort.abort();
71
+ calls.set(toolCallId, { delegationId, abort: controller });
72
+ // Exactly one answer, unless the call was aborted (cancelled, handed back, hang-up).
73
+ const reply = ({ status, result, error }: VoiceClientToolOutcome) => {
74
+ // A replacement call with the same id owns the slot now: leave it alone.
75
+ if (calls.get(toolCallId)?.abort !== controller) return;
76
+ calls.delete(toolCallId);
77
+ if (closed || channel.cancelled(delegationId)) return;
78
+ channel.send({
79
+ type: "client_tool_result",
80
+ delegationId,
81
+ toolCallId,
82
+ status,
83
+ ...(result !== undefined && { result }),
84
+ ...(error && { error: { message: error.message } }),
85
+ });
86
+ };
87
+
88
+ const name = typeof msg.name === "string" ? msg.name : "";
89
+ const tool = sent.get(name) ?? sent.get(WEBMCP_PREFIX + name);
90
+ if (!tool || msg.fingerprint !== sentFingerprint) {
91
+ return reply({ status: "stale", error: { message: tool ? "The page's tools changed." : `Unknown tool: ${name}` } });
92
+ }
93
+ const args = msg.arguments && typeof msg.arguments === "object" && !Array.isArray(msg.arguments) ? msg.arguments : {};
94
+ const timeoutMs = typeof msg.timeoutMs === "number" && msg.timeoutMs > 0 ? msg.timeoutMs : 0;
95
+ let timer: ReturnType<typeof setTimeout> | undefined;
96
+ const run = surface
97
+ .execute(tool.name, args, { signal: controller.signal, autoConfirmReadOnly: true })
98
+ .catch((error: unknown): VoiceClientToolOutcome => ({
99
+ status: "failed",
100
+ error: { message: error instanceof Error ? error.message : String(error) },
101
+ }));
102
+ // The deadline also stops a gate still waiting: a late approval must not run the tool.
103
+ const deadline = new Promise<VoiceClientToolOutcome>((resolve) => {
104
+ if (timeoutMs)
105
+ timer = setTimeout(() => {
106
+ controller.abort();
107
+ resolve({ status: "timeout", error: { message: `Page tool exceeded ${timeoutMs}ms.` } });
108
+ }, timeoutMs);
109
+ });
110
+ void Promise.race([run, deadline]).then((outcome) => {
111
+ clearTimeout(timer);
112
+ reply(outcome);
113
+ });
114
+ },
115
+ };
116
+ }
@@ -4,6 +4,8 @@ import type {
4
4
  VoiceDelegationFollowUp,
5
5
  VoiceDelegationRequest,
6
6
  VoiceDelegationResult,
7
+ VoiceClientTools,
8
+ VoiceDirectTurn,
7
9
  VoiceSessionBridge,
8
10
  } from "../types";
9
11
 
@@ -43,14 +45,21 @@ export interface VoiceDelegationHost {
43
45
  parked(): boolean;
44
46
  /** The transcript bubble the request came from (see KeyedVoiceTranscript.claimUserTurn). */
45
47
  claim(userText: string, userUtteranceIds: string[]): string | null;
46
- /** sendMessage as a voice turn, submitting `userMessageId`'s bubble when given. */
47
- send(userText: string, userMessageId: string | undefined): Promise<void>;
48
+ /**
49
+ * sendMessage as a voice turn, submitting `userMessageId`'s bubble when
50
+ * given and recording `priorToolResults` as already-run tool calls.
51
+ */
52
+ send(userText: string, voiceTurn: { userMessageId?: string | null; priorToolResults?: unknown }): Promise<void>;
48
53
  /** Route the chat stream's assistant messages and failures into `capture` (or stop). */
49
54
  track(capture: VoiceDelegationCapture | null): void;
50
55
  /** Deny a pending approval (server gate or WebMCP) by its message id. */
51
56
  decide(approvalMessageId: string): void;
52
57
  /** Let browser TTS read these messages after all (their spoken result was dropped). */
53
58
  unspoken(messageIds: string[]): void;
59
+ /** WebMCP access for the voice fast tier; `null` when WebMCP is off. */
60
+ clientTools?(): VoiceClientTools | null;
61
+ /** Write a server-answered (`route: 'direct'`) turn into the chat. */
62
+ recordDirect?(turn: VoiceDirectTurn): void;
54
63
  }
55
64
 
56
65
  const SETTLE_POLL_MS = 50;
@@ -185,6 +194,9 @@ export function createVoiceSessionBridge(host: VoiceDelegationHost): VoiceSessio
185
194
  };
186
195
 
187
196
  return {
197
+ clientTools: host.clientTools,
198
+ recordDirectTurn: host.recordDirect,
199
+
188
200
  // Read once at call start, before this call has any captions, so the
189
201
  // captions here are earlier calls' (e.g. on the previous page of a
190
202
  // multi-page site): spoken conversation the new voice session never heard.
@@ -231,6 +243,7 @@ export function createVoiceSessionBridge(host: VoiceDelegationHost): VoiceSessio
231
243
  userText,
232
244
  userUtteranceIds,
233
245
  stream,
246
+ priorToolResults,
234
247
  }: VoiceDelegationRequest): Promise<VoiceDelegationResult> {
235
248
  const live = parks.filter((park) => pendingOf(park).length);
236
249
  const pendingIds = live.flatMap(pendingOf);
@@ -297,7 +310,10 @@ export function createVoiceSessionBridge(host: VoiceDelegationHost): VoiceSessio
297
310
  active = capture;
298
311
  retrack();
299
312
  try {
300
- await host.send(userText, host.claim(userText, userUtteranceIds) ?? undefined);
313
+ await host.send(userText, {
314
+ userMessageId: host.claim(userText, userUtteranceIds),
315
+ priorToolResults,
316
+ });
301
317
  // Local tools and approvals may continue the turn past the first stream.
302
318
  await settled(capture);
303
319
  } catch {