@runtypelabs/persona 4.28.2 → 4.29.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 (103) 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.d.cts +1 -1
  6. package/dist/approval-ui.d.ts +1 -1
  7. package/dist/artifacts-ui.d.cts +2 -2
  8. package/dist/artifacts-ui.d.ts +2 -2
  9. package/dist/chunk-BkW8s3fj.js +2 -0
  10. package/dist/chunk-CIVsNcrk.js +1 -0
  11. package/dist/chunk-CKdlNyzM.js +9 -0
  12. package/dist/{chunk-DrYAfpvk.js → chunk-DjOvgFsn.js} +1 -1
  13. package/dist/chunk-lA5CJCgK.js +1 -0
  14. package/dist/client-history.js +1 -1
  15. package/dist/codegen.cjs +1 -1
  16. package/dist/codegen.js +1 -1
  17. package/dist/{composer-input-C39npJaz.d.cts → composer-input-D3sB8_P_.d.cts} +1 -1
  18. package/dist/{composer-input-C39npJaz.d.ts → composer-input-D3sB8_P_.d.ts} +1 -1
  19. package/dist/{context-mention-manager-DLCB1l9E.d.cts → context-mention-manager-D2qxE5op.d.cts} +1 -1
  20. package/dist/{context-mention-manager-DLCB1l9E.d.ts → context-mention-manager-D2qxE5op.d.ts} +1 -1
  21. package/dist/context-mentions-inline.d.cts +2 -2
  22. package/dist/context-mentions-inline.d.ts +2 -2
  23. package/dist/context-mentions.d.cts +3 -3
  24. package/dist/context-mentions.d.ts +3 -3
  25. package/dist/{defaults-DiAgWHXy.d.cts → defaults-Dsw66XqE.d.cts} +1 -1
  26. package/dist/{defaults-DiAgWHXy.d.ts → defaults-Dsw66XqE.d.ts} +1 -1
  27. package/dist/event-stream-view.d.cts +1 -1
  28. package/dist/event-stream-view.d.ts +1 -1
  29. package/dist/{fallback-speech-engine-C5_PUZjH.d.cts → fallback-speech-engine-DJpJcWAk.d.cts} +1 -1
  30. package/dist/{fallback-speech-engine-C5_PUZjH.d.ts → fallback-speech-engine-DJpJcWAk.d.ts} +1 -1
  31. package/dist/forms-ui.d.cts +2 -2
  32. package/dist/forms-ui.d.ts +2 -2
  33. package/dist/history-view.d.cts +1 -1
  34. package/dist/history-view.d.ts +1 -1
  35. package/dist/index.cjs +45 -44
  36. package/dist/index.cjs.map +1 -1
  37. package/dist/index.d.cts +7 -7
  38. package/dist/index.d.ts +7 -7
  39. package/dist/index.global.js +1 -1
  40. package/dist/index.global.js.map +1 -1
  41. package/dist/index.js +34 -33
  42. package/dist/index.js.map +1 -1
  43. package/dist/{markdown-parsers-loader-BBdyIEU7.d.cts → markdown-parsers-loader-CCY7T4Up.d.cts} +1 -1
  44. package/dist/{markdown-parsers-loader-BBdyIEU7.d.ts → markdown-parsers-loader-CCY7T4Up.d.ts} +1 -1
  45. package/dist/{preview-Chjb0bmI.d.cts → preview-DKKpfTo7.d.cts} +2 -2
  46. package/dist/{preview-Chjb0bmI.d.ts → preview-DKKpfTo7.d.ts} +2 -2
  47. package/dist/{session-a_qIp4xp.d.ts → session-DleMr9dZ.d.cts} +13 -7
  48. package/dist/{session-a_qIp4xp.d.cts → session-DleMr9dZ.d.ts} +13 -7
  49. package/dist/session-reconnect.d.cts +2 -2
  50. package/dist/session-reconnect.d.ts +2 -2
  51. package/dist/smart-dom-reader.d.cts +1 -1
  52. package/dist/smart-dom-reader.d.ts +1 -1
  53. package/dist/theme-editor-preview.cjs +41 -40
  54. package/dist/theme-editor-preview.d.cts +1 -1
  55. package/dist/theme-editor-preview.d.ts +1 -1
  56. package/dist/theme-editor-preview.js +20 -20
  57. package/dist/theme-editor.d.cts +2 -2
  58. package/dist/theme-editor.d.ts +2 -2
  59. package/dist/theme-reference.d.cts +2 -2
  60. package/dist/theme-reference.d.ts +2 -2
  61. package/dist/{types-BdO1jNRm.d.cts → types-oAZ1VReS.d.cts} +166 -21
  62. package/dist/{types-BdO1jNRm.d.ts → types-oAZ1VReS.d.ts} +166 -21
  63. package/dist/voice-client-tools.cjs +1 -0
  64. package/dist/voice-client-tools.d.cts +13 -0
  65. package/dist/voice-client-tools.d.ts +13 -0
  66. package/dist/voice-client-tools.js +1 -0
  67. package/dist/voice-runtime.cjs +3 -3
  68. package/dist/voice-runtime.d.cts +7 -3
  69. package/dist/voice-runtime.d.ts +7 -3
  70. package/dist/voice-runtime.js +4 -4
  71. package/dist/voice-worklet-player.d.cts +2 -2
  72. package/dist/voice-worklet-player.d.ts +2 -2
  73. package/dist/{webmcp-runtime-entry-B6bpVWSa.d.ts → webmcp-runtime-entry-Deg0NfmB.d.cts} +26 -5
  74. package/dist/{webmcp-runtime-entry-B6bpVWSa.d.cts → webmcp-runtime-entry-Deg0NfmB.d.ts} +26 -5
  75. package/dist/webmcp-runtime.cjs +2 -1
  76. package/dist/webmcp-runtime.d.cts +1 -1
  77. package/dist/webmcp-runtime.d.ts +1 -1
  78. package/dist/webmcp-runtime.js +2 -1
  79. package/package.json +6 -1
  80. package/src/client.ts +37 -0
  81. package/src/client.voice-tools.test.ts +65 -0
  82. package/src/generated/runtype-openapi-contract.ts +38 -0
  83. package/src/index-core.ts +11 -0
  84. package/src/index-global.ts +6 -0
  85. package/src/session.test.ts +26 -0
  86. package/src/session.ts +8 -1
  87. package/src/session.voice.test.ts +43 -0
  88. package/src/types.ts +141 -20
  89. package/src/voice/runtype-voice-provider.ts +85 -3
  90. package/src/voice/session-voice-wiring.ts +26 -1
  91. package/src/voice/voice-client-tools-session.ts +116 -0
  92. package/src/voice/voice-delegation.ts +9 -0
  93. package/src/voice/voice.test.ts +360 -0
  94. package/src/voice-client-tools-bundle.test.ts +37 -0
  95. package/src/voice-client-tools-loader.ts +24 -0
  96. package/src/voice-client-tools.ts +9 -0
  97. package/src/webmcp-bridge.test.ts +152 -1
  98. package/src/webmcp-bridge.ts +5 -6
  99. package/src/webmcp-polyfill.integration.test.ts +5 -1
  100. package/src/webmcp-runtime-entry.ts +151 -26
  101. package/dist/chunk-CztSjKHK.js +0 -9
  102. package/dist/chunk-HUOUif-P.js +0 -1
  103. package/dist/chunk-UlcxdHef.js +0 -1
@@ -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
 
@@ -51,6 +53,10 @@ export interface VoiceDelegationHost {
51
53
  decide(approvalMessageId: string): void;
52
54
  /** Let browser TTS read these messages after all (their spoken result was dropped). */
53
55
  unspoken(messageIds: string[]): void;
56
+ /** WebMCP access for the voice fast tier; `null` when WebMCP is off. */
57
+ clientTools?(): VoiceClientTools | null;
58
+ /** Write a server-answered (`route: 'direct'`) turn into the chat. */
59
+ recordDirect?(turn: VoiceDirectTurn): void;
54
60
  }
55
61
 
56
62
  const SETTLE_POLL_MS = 50;
@@ -185,6 +191,9 @@ export function createVoiceSessionBridge(host: VoiceDelegationHost): VoiceSessio
185
191
  };
186
192
 
187
193
  return {
194
+ clientTools: host.clientTools,
195
+ recordDirectTurn: host.recordDirect,
196
+
188
197
  // Read once at call start, before this call has any captions, so the
189
198
  // captions here are earlier calls' (e.g. on the previous page of a
190
199
  // multi-page site): spoken conversation the new voice session never heard.
@@ -1,6 +1,10 @@
1
1
  // Voice SDK Tests
2
2
  import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest';
3
3
  import type {
4
+ ClientToolDefinition,
5
+ VoiceClientToolOutcome,
6
+ VoiceClientTools,
7
+ VoiceDirectTurn,
4
8
  VoiceConfig,
5
9
  VoiceDelegationFollowUp,
6
10
  VoiceDelegationRequest,
@@ -9,6 +13,7 @@ import type {
9
13
  } from '../types';
10
14
 
11
15
  import { RuntypeVoiceProvider, buildCallContext } from './runtype-voice-provider';
16
+ import { createVoiceClientToolsSession } from './voice-client-tools-session';
12
17
  import { VERSION } from '../version';
13
18
  import { BrowserVoiceProvider } from './browser-voice-provider';
14
19
  import { createVoiceProvider, createBestAvailableVoiceProvider, isVoiceSupported } from './voice-factory';
@@ -1658,6 +1663,361 @@ describe('RuntypeVoiceProvider (realtime streaming)', () => {
1658
1663
  });
1659
1664
  });
1660
1665
 
1666
+ describe('fast tier (client_tools)', () => {
1667
+ const FAST_CAPS = [...ALL_CAPS, 'client_tools'];
1668
+ const nextSlide: ClientToolDefinition = {
1669
+ name: 'next_slide',
1670
+ description: 'Advance the presentation to the next slide.',
1671
+ parametersSchema: { type: 'object', properties: {} },
1672
+ origin: 'webmcp',
1673
+ pageOrigin: 'https://example.com',
1674
+ annotations: { readOnlyHint: true },
1675
+ };
1676
+ const jumpToSlide: ClientToolDefinition = {
1677
+ name: 'jump_to_slide',
1678
+ description: 'Jump to a 1-based slide position.',
1679
+ parametersSchema: { type: 'object', required: ['position'], properties: { position: { type: 'integer' } } },
1680
+ origin: 'webmcp',
1681
+ annotations: { readOnlyHint: true },
1682
+ };
1683
+
1684
+ function makeFastBridge(tools: ClientToolDefinition[] = [nextSlide, jumpToSlide]) {
1685
+ const base = makeBridge();
1686
+ const state = { tools, fingerprint: 'fp1' };
1687
+ const listeners = new Set<() => void>();
1688
+ const executed: Array<{ name: string; args: Record<string, unknown>; options: { signal: AbortSignal; autoConfirmReadOnly: boolean } }> = [];
1689
+ let respond: (name: string, signal: AbortSignal) => Promise<VoiceClientToolOutcome> = async () => ({
1690
+ status: 'completed',
1691
+ result: { slidePosition: 5, slideTitle: 'Pricing' },
1692
+ });
1693
+ const surface: VoiceClientTools = {
1694
+ snapshot: async () => ({ tools: state.tools, fingerprint: state.fingerprint }),
1695
+ onChange: (listener) => {
1696
+ listeners.add(listener);
1697
+ return () => listeners.delete(listener);
1698
+ },
1699
+ execute: (name, args, options) => {
1700
+ executed.push({ name, args, options });
1701
+ return respond(name, options.signal);
1702
+ },
1703
+ open: async (channel) => createVoiceClientToolsSession(surface, channel),
1704
+ };
1705
+ const recorded: VoiceDirectTurn[] = [];
1706
+ const bridge: VoiceSessionBridge = {
1707
+ ...base.bridge,
1708
+ clientTools: () => surface,
1709
+ recordDirectTurn: (turn) => recorded.push(turn),
1710
+ };
1711
+ return {
1712
+ ...base,
1713
+ bridge,
1714
+ state,
1715
+ executed,
1716
+ recorded,
1717
+ change: () => listeners.forEach((listener) => listener()),
1718
+ setRespond: (fn: typeof respond) => (respond = fn),
1719
+ };
1720
+ }
1721
+
1722
+ async function startFastCall(capabilities = FAST_CAPS, tools?: ClientToolDefinition[]) {
1723
+ const fast = makeFastBridge(tools);
1724
+ const provider = new RuntypeVoiceProvider({ ...baseConfig(), createPlaybackEngine: () => makeFakeEngine() });
1725
+ provider.setSessionBridge(fast.bridge);
1726
+ await provider.startListening();
1727
+ const ws = lastWs();
1728
+ ws.triggerOpen();
1729
+ ws.triggerMessage(
1730
+ JSON.stringify({ type: 'session_config', interruptionMode: 'barge-in', speechMode: 'speech_to_speech', capabilities }),
1731
+ );
1732
+ await flush();
1733
+ return { ...fast, provider, ws };
1734
+ }
1735
+
1736
+ const toolCall = (ws: MockWebSocket, fields: Record<string, unknown> = {}) =>
1737
+ ws.triggerMessage(
1738
+ JSON.stringify({
1739
+ type: 'client_tool_call',
1740
+ delegationId: 'd1',
1741
+ toolCallId: 'vtc_01',
1742
+ name: 'webmcp:jump_to_slide',
1743
+ arguments: { position: 5 },
1744
+ fingerprint: 'fp1',
1745
+ timeoutMs: 5000,
1746
+ ...fields,
1747
+ }),
1748
+ );
1749
+ const started = (ws: MockWebSocket, delegationId: string, text = 'Jump to the pricing slide') =>
1750
+ ws.triggerMessage(
1751
+ JSON.stringify({
1752
+ type: 'delegation_started',
1753
+ delegationId,
1754
+ input: { text, userUtteranceIds: ['u1'], messages: [] },
1755
+ }),
1756
+ );
1757
+ const heard = (ws: MockWebSocket, text = 'Jump to the pricing slide', utteranceId = 'u1') =>
1758
+ ws.triggerMessage(update('user', text, utteranceId));
1759
+ const directCompleted = (ws: MockWebSocket, delegationId = 'd1', text = 'Slide 5: Pricing.') =>
1760
+ ws.triggerMessage(
1761
+ JSON.stringify({ type: 'delegation_completed', delegationId, route: 'direct', speak: true, text, final: true }),
1762
+ );
1763
+ const results = (ws: MockWebSocket) => sentJson(ws).filter((f) => f.type === 'client_tool_result');
1764
+
1765
+ it('declares client_tools only when the bridge has WebMCP tools', async () => {
1766
+ const { ws } = await startFastCall();
1767
+ expect(ws.url).toBe(
1768
+ voiceUrl('wss://api.example.com', 'a1', [
1769
+ ...BASE_CAPS,
1770
+ 'client_delegation',
1771
+ 'delegation_update',
1772
+ 'delegation_read_back',
1773
+ 'delegation_stream',
1774
+ 'client_tools',
1775
+ ]),
1776
+ );
1777
+ const provider = new RuntypeVoiceProvider(baseConfig());
1778
+ provider.setSessionBridge({ ...makeBridge().bridge, clientTools: () => null });
1779
+ await provider.startListening();
1780
+ expect(new URL(lastWs().url).searchParams.get('clientCapabilities')).not.toContain('client_tools');
1781
+ });
1782
+
1783
+ it('sends client_tools after session_config with full webmcp: names and the dispatch fields', async () => {
1784
+ const { ws } = await startFastCall();
1785
+ const frames = sentJson(ws).filter((f) => f.type === 'client_tools');
1786
+ expect(frames).toHaveLength(1);
1787
+ expect(frames[0].fingerprint).toBe('fp1');
1788
+ expect(frames[0].tools).toEqual([
1789
+ { ...nextSlide, name: 'webmcp:next_slide' },
1790
+ { ...jumpToSlide, name: 'webmcp:jump_to_slide' },
1791
+ ]);
1792
+ });
1793
+
1794
+ it('keeps sdk-origin names bare and does not double-prefix', async () => {
1795
+ const sdk: ClientToolDefinition = { ...nextSlide, name: 'ask_user_question', origin: 'sdk' };
1796
+ const prefixed: ClientToolDefinition = { ...nextSlide, name: 'webmcp:already' };
1797
+ const { ws } = await startFastCall(FAST_CAPS, [sdk, prefixed]);
1798
+ const [frame] = sentJson(ws).filter((f) => f.type === 'client_tools');
1799
+ expect(frame.tools.map((t: { name: string }) => t.name)).toEqual(['ask_user_question', 'webmcp:already']);
1800
+ });
1801
+
1802
+ it('resends on a fingerprint change with its new fingerprint, and not on an unchanged one', async () => {
1803
+ const { ws, state, change } = await startFastCall();
1804
+ change(); // same fingerprint
1805
+ await flush();
1806
+ expect(sentJson(ws).filter((f) => f.type === 'client_tools')).toHaveLength(1);
1807
+ state.tools = [nextSlide];
1808
+ state.fingerprint = 'fp2';
1809
+ change();
1810
+ await flush();
1811
+ const frames = sentJson(ws).filter((f) => f.type === 'client_tools');
1812
+ expect(frames).toHaveLength(2);
1813
+ expect(frames[1].fingerprint).toBe('fp2');
1814
+ expect(frames[1].tools).toEqual([{ ...nextSlide, name: 'webmcp:next_slide' }]);
1815
+ });
1816
+
1817
+ it('sends no client_tools when the server did not negotiate it', async () => {
1818
+ const { ws } = await startFastCall(ALL_CAPS);
1819
+ expect(sentJson(ws).filter((f) => f.type === 'client_tools')).toEqual([]);
1820
+ });
1821
+
1822
+ it('runs a client_tool_call with the read-only auto-confirm and answers completed', async () => {
1823
+ const { ws, executed } = await startFastCall();
1824
+ toolCall(ws);
1825
+ await flush();
1826
+ expect(executed).toHaveLength(1);
1827
+ expect(executed[0].name).toBe('webmcp:jump_to_slide');
1828
+ expect(executed[0].args).toEqual({ position: 5 });
1829
+ expect(executed[0].options.autoConfirmReadOnly).toBe(true);
1830
+ expect(results(ws)).toEqual([
1831
+ {
1832
+ type: 'client_tool_result',
1833
+ delegationId: 'd1',
1834
+ toolCallId: 'vtc_01',
1835
+ status: 'completed',
1836
+ result: { slidePosition: 5, slideTitle: 'Pricing' },
1837
+ },
1838
+ ]);
1839
+ });
1840
+
1841
+ it('accepts the bare name too', async () => {
1842
+ const { ws, executed } = await startFastCall();
1843
+ toolCall(ws, { name: 'jump_to_slide' });
1844
+ await flush();
1845
+ expect(executed[0].name).toBe('webmcp:jump_to_slide');
1846
+ expect(results(ws)[0].status).toBe('completed');
1847
+ });
1848
+
1849
+ it('answers stale for an unknown tool or a fingerprint other than the last sent, without running anything', async () => {
1850
+ const { ws, executed, state, change } = await startFastCall();
1851
+ toolCall(ws, { name: 'webmcp:delete_deck', toolCallId: 't1' });
1852
+ state.fingerprint = 'fp2';
1853
+ change();
1854
+ await flush();
1855
+ toolCall(ws, { toolCallId: 't2' });
1856
+ await flush();
1857
+ expect(executed).toEqual([]);
1858
+ expect(results(ws).map((r) => [r.toolCallId, r.status])).toEqual([
1859
+ ['t1', 'stale'],
1860
+ ['t2', 'stale'],
1861
+ ]);
1862
+ });
1863
+
1864
+ it('passes denied through and maps a thrown error to failed with its message', async () => {
1865
+ const { ws, setRespond } = await startFastCall();
1866
+ setRespond(async () => ({ status: 'denied', error: { message: 'User declined the tool call.' } }));
1867
+ toolCall(ws, { toolCallId: 't1' });
1868
+ await flush();
1869
+ setRespond(async () => {
1870
+ throw new Error('page exploded');
1871
+ });
1872
+ toolCall(ws, { toolCallId: 't2' });
1873
+ await flush();
1874
+ expect(results(ws)).toEqual([
1875
+ expect.objectContaining({ toolCallId: 't1', status: 'denied' }),
1876
+ expect.objectContaining({ toolCallId: 't2', status: 'failed', error: { message: 'page exploded' } }),
1877
+ ]);
1878
+ });
1879
+
1880
+ it('answers timeout once timeoutMs passes and aborts the page tool', async () => {
1881
+ vi.useFakeTimers();
1882
+ try {
1883
+ const { ws, setRespond } = await startFastCall();
1884
+ let signal: AbortSignal | undefined;
1885
+ setRespond((_name, s) => {
1886
+ signal = s;
1887
+ return new Promise(() => {}); // a page tool that never settles
1888
+ });
1889
+ toolCall(ws, { timeoutMs: 50 });
1890
+ await flush();
1891
+ expect(results(ws)).toEqual([]);
1892
+ await vi.advanceTimersByTimeAsync(60);
1893
+ expect(results(ws)).toEqual([expect.objectContaining({ status: 'timeout' })]);
1894
+ expect(signal?.aborted).toBe(true);
1895
+ } finally {
1896
+ vi.useRealTimers();
1897
+ }
1898
+ });
1899
+
1900
+ it('a call reusing a pending toolCallId replaces it: only the replacement answers', async () => {
1901
+ const { ws, setRespond } = await startFastCall();
1902
+ setRespond((_name, signal) => new Promise((resolve) => signal.addEventListener('abort', () => resolve({ status: 'failed' }))));
1903
+ toolCall(ws);
1904
+ await flush();
1905
+ setRespond(async () => ({ status: 'completed', result: { slidePosition: 2 } }));
1906
+ toolCall(ws);
1907
+ await flush();
1908
+ expect(results(ws)).toEqual([expect.objectContaining({ toolCallId: 'vtc_01', status: 'completed' })]);
1909
+ });
1910
+
1911
+ it('sends nothing for a tool call whose delegation the server cancelled', async () => {
1912
+ const { ws, setRespond } = await startFastCall();
1913
+ let finish!: (o: VoiceClientToolOutcome) => void;
1914
+ setRespond(() => new Promise((resolve) => (finish = resolve)));
1915
+ toolCall(ws);
1916
+ await flush();
1917
+ ws.triggerMessage(JSON.stringify({ type: 'delegation_cancelled', delegationId: 'd1', reason: 'superseded' }));
1918
+ finish({ status: 'completed' });
1919
+ await flush();
1920
+ expect(results(ws)).toEqual([]);
1921
+ });
1922
+
1923
+ it('a direct completion with no started delegation records the heard request and the answer', async () => {
1924
+ const { ws, calls, recorded } = await startFastCall();
1925
+ heard(ws);
1926
+ toolCall(ws);
1927
+ await flush();
1928
+ directCompleted(ws);
1929
+ await flush();
1930
+ expect(calls).toEqual([]);
1931
+ expect(recorded).toEqual([
1932
+ {
1933
+ delegationId: 'd1',
1934
+ userText: 'Jump to the pricing slide',
1935
+ userUtteranceIds: ['u1'],
1936
+ assistantText: 'Slide 5: Pricing.',
1937
+ },
1938
+ ]);
1939
+ directCompleted(ws); // a repeat never records twice
1940
+ expect(recorded).toHaveLength(1);
1941
+ expect(sentJson(ws).filter((f) => f.type === 'delegation_result')).toEqual([]);
1942
+ });
1943
+
1944
+ it('a direct turn keeps the request it answered when the visitor speaks again before it completes', async () => {
1945
+ const { ws, recorded } = await startFastCall();
1946
+ heard(ws);
1947
+ toolCall(ws);
1948
+ await flush();
1949
+ heard(ws, 'Next slide', 'u2');
1950
+ directCompleted(ws);
1951
+ expect(recorded).toEqual([
1952
+ expect.objectContaining({ userText: 'Jump to the pricing slide', userUtteranceIds: ['u1'] }),
1953
+ ]);
1954
+ });
1955
+
1956
+ it('a hand-back aborts the direct tool still running, which then sends nothing', async () => {
1957
+ const { ws, setRespond } = await startFastCall();
1958
+ let signal: AbortSignal | undefined;
1959
+ let finish!: (o: VoiceClientToolOutcome) => void;
1960
+ setRespond((_name, s) => {
1961
+ signal = s;
1962
+ return new Promise((resolve) => (finish = resolve));
1963
+ });
1964
+ heard(ws);
1965
+ toolCall(ws);
1966
+ await flush();
1967
+ started(ws, 'd1');
1968
+ await flush();
1969
+ expect(signal?.aborted).toBe(true);
1970
+ finish({ status: 'completed' });
1971
+ await flush();
1972
+ expect(results(ws)).toEqual([]);
1973
+ });
1974
+
1975
+ it('folds the tagged read-back of a direct turn, which the chat now shows', async () => {
1976
+ const { ws, provider } = await startFastCall([...FAST_CAPS, 'delegation_read_back']);
1977
+ const transcripts: unknown[][] = [];
1978
+ provider.onTranscript((...args) => transcripts.push(args));
1979
+ toolCall(ws);
1980
+ ws.triggerMessage(
1981
+ JSON.stringify({ type: 'transcript_update', role: 'assistant', text: 'Slide 5: Pricing.', utteranceId: 'a9', final: true, delegationId: 'd1' }),
1982
+ );
1983
+ expect(transcripts).toEqual([]);
1984
+ });
1985
+
1986
+ it('a fall-through arrives as an ordinary delegation, and its completion is not a direct turn', async () => {
1987
+ const { ws, calls, recorded, pending, setRespond } = await startFastCall();
1988
+ setRespond(async () => ({ status: 'failed', error: { message: 'nope' } }));
1989
+ heard(ws);
1990
+ toolCall(ws);
1991
+ await flush();
1992
+ started(ws, 'd1');
1993
+ await flush();
1994
+ expect(calls.map((c) => [c.delegationId, c.userText])).toEqual([['d1', 'Jump to the pricing slide']]);
1995
+ pending[0]({ status: 'completed', text: 'Here is pricing.' });
1996
+ await flush();
1997
+ ws.triggerMessage(JSON.stringify({ type: 'delegation_completed', delegationId: 'd1', speak: true, text: 'x' }));
1998
+ directCompleted(ws); // even a mislabeled completion of a started delegation
1999
+ expect(recorded).toEqual([]);
2000
+ expect(sentJson(ws).filter((f) => f.type === 'delegation_result')).toEqual([
2001
+ expect.objectContaining({ delegationId: 'd1', status: 'completed', text: 'Here is pricing.' }),
2002
+ ]);
2003
+ });
2004
+
2005
+ it('ignores a direct completion when client_tools was not negotiated', async () => {
2006
+ const { ws, recorded } = await startFastCall(ALL_CAPS);
2007
+ heard(ws);
2008
+ directCompleted(ws);
2009
+ expect(recorded).toEqual([]);
2010
+ });
2011
+
2012
+ it('against an old core (no new tokens) sends no new frames and ignores client_tool_call', async () => {
2013
+ const { ws, executed } = await startFastCall(ALL_CAPS);
2014
+ toolCall(ws);
2015
+ await flush();
2016
+ expect(executed).toEqual([]);
2017
+ expect(sentJson(ws)).toEqual([]);
2018
+ });
2019
+ });
2020
+
1661
2021
  describe('capabilities and tolerance', () => {
1662
2022
  it('uses no client delegation, context or updates when session_config has no capabilities (old server)', async () => {
1663
2023
  const { ws, calls, transcripts } = await startDelegatedCall(
@@ -0,0 +1,37 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { resolve } from "node:path";
4
+
5
+ /**
6
+ * Bundle guard for the lazy voice-client-tools chunk (the voice fast tier).
7
+ * Its session code must stay out of both the core IIFE and the voice-runtime
8
+ * chunk: only calls that negotiate `client_tools` download it.
9
+ * Skips when `dist/` hasn't been built.
10
+ */
11
+ const dist = (f: string) => resolve(__dirname, "..", "dist", f);
12
+
13
+ // Session-only literals, emitted nowhere else.
14
+ const MARKERS = ["Page tool exceeded", "Unknown tool: "];
15
+
16
+ describe("voice-client-tools bundle split", () => {
17
+ // Skip only when nothing was built; a build missing the chunk must fail.
18
+ const built = existsSync(dist("index.global.js"));
19
+
20
+ it.runIf(built)("keeps the fast-tier session out of the core IIFE and the voice-runtime chunk", () => {
21
+ for (const file of ["index.global.js", "voice-runtime.js"]) {
22
+ const source = readFileSync(dist(file), "utf8");
23
+ for (const marker of MARKERS) {
24
+ expect(source.includes(marker), `${file} unexpectedly contains "${marker}"`).toBe(false);
25
+ }
26
+ }
27
+ expect(readFileSync(dist("index.global.js"), "utf8")).toContain("voice-client-tools.js");
28
+ });
29
+
30
+ it.runIf(built)("ships the session in its own chunk, with CJS and declarations", () => {
31
+ expect(existsSync(dist("voice-client-tools.js")), "dist/voice-client-tools.js is missing").toBe(true);
32
+ const chunk = readFileSync(dist("voice-client-tools.js"), "utf8");
33
+ for (const marker of MARKERS) expect(chunk.includes(marker), `chunk is missing "${marker}"`).toBe(true);
34
+ expect(existsSync(dist("voice-client-tools.cjs"))).toBe(true);
35
+ expect(existsSync(dist("voice-client-tools.d.ts"))).toBe(true);
36
+ });
37
+ });
@@ -0,0 +1,24 @@
1
+ import { createChunkLoader } from "./utils/chunk-loader";
2
+ import type { VoiceClientTools, VoiceClientToolsChannel, VoiceClientToolsSession } from "./types";
3
+
4
+ /**
5
+ * Loader indirection for the lazy voice fast-tier chunk. Stays in the core
6
+ * bundle; the fallback import must be the literal package subpath (see
7
+ * `webmcp-runtime-loader.ts`).
8
+ */
9
+ export type VoiceClientToolsModule = {
10
+ createVoiceClientToolsSession: (
11
+ surface: VoiceClientTools,
12
+ channel: VoiceClientToolsChannel
13
+ ) => VoiceClientToolsSession;
14
+ };
15
+
16
+ const { setLoader, load } = createChunkLoader<VoiceClientToolsModule>({
17
+ fallbackImport: () => import("@runtypelabs/persona/voice-client-tools"),
18
+ });
19
+
20
+ /** Override how the chunk is fetched (the IIFE build registers a sibling-URL loader). */
21
+ export const setVoiceClientToolsLoader = setLoader;
22
+
23
+ /** Load the fast-tier chunk. Memoized; retries after rejection. */
24
+ export const loadVoiceClientTools = load;
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Subpath/chunk module for the voice fast tier
3
+ * (`@runtypelabs/persona/voice-client-tools` → `dist/voice-client-tools.{js,cjs}`).
4
+ *
5
+ * `client.ts` loads this on demand via `voice-client-tools-loader.ts` when a
6
+ * Runtype voice call negotiates `client_tools` (the IIFE from a sibling URL,
7
+ * ESM/CJS via this external subpath), so calls without it never download it.
8
+ */
9
+ export { createVoiceClientToolsSession, voiceClientToolName } from "./voice/voice-client-tools-session";