@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
@@ -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,397 @@ 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 and prior_tool_results 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
+ 'prior_tool_results',
1776
+ ]),
1777
+ );
1778
+ const provider = new RuntypeVoiceProvider(baseConfig());
1779
+ provider.setSessionBridge({ ...makeBridge().bridge, clientTools: () => null });
1780
+ await provider.startListening();
1781
+ const declared = new URL(lastWs().url).searchParams.get('clientCapabilities');
1782
+ expect(declared).not.toContain('client_tools');
1783
+ expect(declared).not.toContain('prior_tool_results');
1784
+ });
1785
+
1786
+ it('sends client_tools after session_config with full webmcp: names and the dispatch fields', async () => {
1787
+ const { ws } = await startFastCall();
1788
+ const frames = sentJson(ws).filter((f) => f.type === 'client_tools');
1789
+ expect(frames).toHaveLength(1);
1790
+ expect(frames[0].fingerprint).toBe('fp1');
1791
+ expect(frames[0].tools).toEqual([
1792
+ { ...nextSlide, name: 'webmcp:next_slide' },
1793
+ { ...jumpToSlide, name: 'webmcp:jump_to_slide' },
1794
+ ]);
1795
+ });
1796
+
1797
+ it('keeps sdk-origin names bare and does not double-prefix', async () => {
1798
+ const sdk: ClientToolDefinition = { ...nextSlide, name: 'ask_user_question', origin: 'sdk' };
1799
+ const prefixed: ClientToolDefinition = { ...nextSlide, name: 'webmcp:already' };
1800
+ const { ws } = await startFastCall(FAST_CAPS, [sdk, prefixed]);
1801
+ const [frame] = sentJson(ws).filter((f) => f.type === 'client_tools');
1802
+ expect(frame.tools.map((t: { name: string }) => t.name)).toEqual(['ask_user_question', 'webmcp:already']);
1803
+ });
1804
+
1805
+ it('resends on a fingerprint change with its new fingerprint, and not on an unchanged one', async () => {
1806
+ const { ws, state, change } = await startFastCall();
1807
+ change(); // same fingerprint
1808
+ await flush();
1809
+ expect(sentJson(ws).filter((f) => f.type === 'client_tools')).toHaveLength(1);
1810
+ state.tools = [nextSlide];
1811
+ state.fingerprint = 'fp2';
1812
+ change();
1813
+ await flush();
1814
+ const frames = sentJson(ws).filter((f) => f.type === 'client_tools');
1815
+ expect(frames).toHaveLength(2);
1816
+ expect(frames[1].fingerprint).toBe('fp2');
1817
+ expect(frames[1].tools).toEqual([{ ...nextSlide, name: 'webmcp:next_slide' }]);
1818
+ });
1819
+
1820
+ it('sends no client_tools when the server did not negotiate it', async () => {
1821
+ const { ws } = await startFastCall(ALL_CAPS);
1822
+ expect(sentJson(ws).filter((f) => f.type === 'client_tools')).toEqual([]);
1823
+ });
1824
+
1825
+ it('runs a client_tool_call with the read-only auto-confirm and answers completed', async () => {
1826
+ const { ws, executed } = await startFastCall();
1827
+ toolCall(ws);
1828
+ await flush();
1829
+ expect(executed).toHaveLength(1);
1830
+ expect(executed[0].name).toBe('webmcp:jump_to_slide');
1831
+ expect(executed[0].args).toEqual({ position: 5 });
1832
+ expect(executed[0].options.autoConfirmReadOnly).toBe(true);
1833
+ expect(results(ws)).toEqual([
1834
+ {
1835
+ type: 'client_tool_result',
1836
+ delegationId: 'd1',
1837
+ toolCallId: 'vtc_01',
1838
+ status: 'completed',
1839
+ result: { slidePosition: 5, slideTitle: 'Pricing' },
1840
+ },
1841
+ ]);
1842
+ });
1843
+
1844
+ it('accepts the bare name too', async () => {
1845
+ const { ws, executed } = await startFastCall();
1846
+ toolCall(ws, { name: 'jump_to_slide' });
1847
+ await flush();
1848
+ expect(executed[0].name).toBe('webmcp:jump_to_slide');
1849
+ expect(results(ws)[0].status).toBe('completed');
1850
+ });
1851
+
1852
+ it('answers stale for an unknown tool or a fingerprint other than the last sent, without running anything', async () => {
1853
+ const { ws, executed, state, change } = await startFastCall();
1854
+ toolCall(ws, { name: 'webmcp:delete_deck', toolCallId: 't1' });
1855
+ state.fingerprint = 'fp2';
1856
+ change();
1857
+ await flush();
1858
+ toolCall(ws, { toolCallId: 't2' });
1859
+ await flush();
1860
+ expect(executed).toEqual([]);
1861
+ expect(results(ws).map((r) => [r.toolCallId, r.status])).toEqual([
1862
+ ['t1', 'stale'],
1863
+ ['t2', 'stale'],
1864
+ ]);
1865
+ });
1866
+
1867
+ it('passes denied through and maps a thrown error to failed with its message', async () => {
1868
+ const { ws, setRespond } = await startFastCall();
1869
+ setRespond(async () => ({ status: 'denied', error: { message: 'User declined the tool call.' } }));
1870
+ toolCall(ws, { toolCallId: 't1' });
1871
+ await flush();
1872
+ setRespond(async () => {
1873
+ throw new Error('page exploded');
1874
+ });
1875
+ toolCall(ws, { toolCallId: 't2' });
1876
+ await flush();
1877
+ expect(results(ws)).toEqual([
1878
+ expect.objectContaining({ toolCallId: 't1', status: 'denied' }),
1879
+ expect.objectContaining({ toolCallId: 't2', status: 'failed', error: { message: 'page exploded' } }),
1880
+ ]);
1881
+ });
1882
+
1883
+ it('answers timeout once timeoutMs passes and aborts the page tool', async () => {
1884
+ vi.useFakeTimers();
1885
+ try {
1886
+ const { ws, setRespond } = await startFastCall();
1887
+ let signal: AbortSignal | undefined;
1888
+ setRespond((_name, s) => {
1889
+ signal = s;
1890
+ return new Promise(() => {}); // a page tool that never settles
1891
+ });
1892
+ toolCall(ws, { timeoutMs: 50 });
1893
+ await flush();
1894
+ expect(results(ws)).toEqual([]);
1895
+ await vi.advanceTimersByTimeAsync(60);
1896
+ expect(results(ws)).toEqual([expect.objectContaining({ status: 'timeout' })]);
1897
+ expect(signal?.aborted).toBe(true);
1898
+ } finally {
1899
+ vi.useRealTimers();
1900
+ }
1901
+ });
1902
+
1903
+ it('a call reusing a pending toolCallId replaces it: only the replacement answers', async () => {
1904
+ const { ws, setRespond } = await startFastCall();
1905
+ setRespond((_name, signal) => new Promise((resolve) => signal.addEventListener('abort', () => resolve({ status: 'failed' }))));
1906
+ toolCall(ws);
1907
+ await flush();
1908
+ setRespond(async () => ({ status: 'completed', result: { slidePosition: 2 } }));
1909
+ toolCall(ws);
1910
+ await flush();
1911
+ expect(results(ws)).toEqual([expect.objectContaining({ toolCallId: 'vtc_01', status: 'completed' })]);
1912
+ });
1913
+
1914
+ it('sends nothing for a tool call whose delegation the server cancelled', async () => {
1915
+ const { ws, setRespond } = await startFastCall();
1916
+ let finish!: (o: VoiceClientToolOutcome) => void;
1917
+ setRespond(() => new Promise((resolve) => (finish = resolve)));
1918
+ toolCall(ws);
1919
+ await flush();
1920
+ ws.triggerMessage(JSON.stringify({ type: 'delegation_cancelled', delegationId: 'd1', reason: 'superseded' }));
1921
+ finish({ status: 'completed' });
1922
+ await flush();
1923
+ expect(results(ws)).toEqual([]);
1924
+ });
1925
+
1926
+ it('a direct completion with no started delegation records the heard request and the answer', async () => {
1927
+ const { ws, calls, recorded } = await startFastCall();
1928
+ heard(ws);
1929
+ toolCall(ws);
1930
+ await flush();
1931
+ directCompleted(ws);
1932
+ await flush();
1933
+ expect(calls).toEqual([]);
1934
+ expect(recorded).toEqual([
1935
+ {
1936
+ delegationId: 'd1',
1937
+ userText: 'Jump to the pricing slide',
1938
+ userUtteranceIds: ['u1'],
1939
+ assistantText: 'Slide 5: Pricing.',
1940
+ },
1941
+ ]);
1942
+ directCompleted(ws); // a repeat never records twice
1943
+ expect(recorded).toHaveLength(1);
1944
+ expect(sentJson(ws).filter((f) => f.type === 'delegation_result')).toEqual([]);
1945
+ });
1946
+
1947
+ it('a direct turn keeps the request it answered when the visitor speaks again before it completes', async () => {
1948
+ const { ws, recorded } = await startFastCall();
1949
+ heard(ws);
1950
+ toolCall(ws);
1951
+ await flush();
1952
+ heard(ws, 'Next slide', 'u2');
1953
+ directCompleted(ws);
1954
+ expect(recorded).toEqual([
1955
+ expect.objectContaining({ userText: 'Jump to the pricing slide', userUtteranceIds: ['u1'] }),
1956
+ ]);
1957
+ });
1958
+
1959
+ it('a hand-back aborts the direct tool still running, which then sends nothing', async () => {
1960
+ const { ws, setRespond } = await startFastCall();
1961
+ let signal: AbortSignal | undefined;
1962
+ let finish!: (o: VoiceClientToolOutcome) => void;
1963
+ setRespond((_name, s) => {
1964
+ signal = s;
1965
+ return new Promise((resolve) => (finish = resolve));
1966
+ });
1967
+ heard(ws);
1968
+ toolCall(ws);
1969
+ await flush();
1970
+ started(ws, 'd1');
1971
+ await flush();
1972
+ expect(signal?.aborted).toBe(true);
1973
+ finish({ status: 'completed' });
1974
+ await flush();
1975
+ expect(results(ws)).toEqual([]);
1976
+ });
1977
+
1978
+ it('folds the tagged read-back of a direct turn, which the chat now shows', async () => {
1979
+ const { ws, provider } = await startFastCall([...FAST_CAPS, 'delegation_read_back']);
1980
+ const transcripts: unknown[][] = [];
1981
+ provider.onTranscript((...args) => transcripts.push(args));
1982
+ toolCall(ws);
1983
+ ws.triggerMessage(
1984
+ JSON.stringify({ type: 'transcript_update', role: 'assistant', text: 'Slide 5: Pricing.', utteranceId: 'a9', final: true, delegationId: 'd1' }),
1985
+ );
1986
+ expect(transcripts).toEqual([]);
1987
+ });
1988
+
1989
+ it('a fall-through arrives as an ordinary delegation, and its completion is not a direct turn', async () => {
1990
+ const { ws, calls, recorded, pending, setRespond } = await startFastCall();
1991
+ setRespond(async () => ({ status: 'failed', error: { message: 'nope' } }));
1992
+ heard(ws);
1993
+ toolCall(ws);
1994
+ await flush();
1995
+ started(ws, 'd1');
1996
+ await flush();
1997
+ expect(calls.map((c) => [c.delegationId, c.userText])).toEqual([['d1', 'Jump to the pricing slide']]);
1998
+ pending[0]({ status: 'completed', text: 'Here is pricing.' });
1999
+ await flush();
2000
+ ws.triggerMessage(JSON.stringify({ type: 'delegation_completed', delegationId: 'd1', speak: true, text: 'x' }));
2001
+ directCompleted(ws); // even a mislabeled completion of a started delegation
2002
+ expect(recorded).toEqual([]);
2003
+ expect(sentJson(ws).filter((f) => f.type === 'delegation_result')).toEqual([
2004
+ expect.objectContaining({ delegationId: 'd1', status: 'completed', text: 'Here is pricing.' }),
2005
+ ]);
2006
+ });
2007
+
2008
+ it('hands a fall-through its priorToolResults without running them again on the page', async () => {
2009
+ const { ws, calls, executed, recorded, pending } = await startFastCall([...FAST_CAPS, 'prior_tool_results']);
2010
+ const prior = [
2011
+ { toolCallId: 'vtc_01', name: 'webmcp:jump_to_slide', arguments: { position: 5 }, result: { slideTitle: 'Pricing' } },
2012
+ ];
2013
+ heard(ws);
2014
+ toolCall(ws);
2015
+ await flush();
2016
+ expect(executed).toHaveLength(1);
2017
+ ws.triggerMessage(
2018
+ JSON.stringify({
2019
+ type: 'delegation_started',
2020
+ delegationId: 'd1',
2021
+ input: { text: 'Jump to the pricing slide', userUtteranceIds: ['u1'], messages: [], priorToolResults: prior },
2022
+ }),
2023
+ );
2024
+ await flush();
2025
+ expect(calls).toHaveLength(1);
2026
+ expect(calls[0].priorToolResults).toEqual(prior);
2027
+ pending[0]({ status: 'completed', text: 'You are on Pricing.' });
2028
+ await flush();
2029
+ directCompleted(ws);
2030
+ expect(executed).toHaveLength(1);
2031
+ expect(recorded).toEqual([]);
2032
+ });
2033
+
2034
+ it('leaves priorToolResults unset on a fall-through without them', async () => {
2035
+ const { ws, calls } = await startFastCall();
2036
+ started(ws, 'd1');
2037
+ await flush();
2038
+ expect(calls[0].priorToolResults).toBeUndefined();
2039
+ });
2040
+
2041
+ it('ignores a direct completion when client_tools was not negotiated', async () => {
2042
+ const { ws, recorded } = await startFastCall(ALL_CAPS);
2043
+ heard(ws);
2044
+ directCompleted(ws);
2045
+ expect(recorded).toEqual([]);
2046
+ });
2047
+
2048
+ it('against an old core (no new tokens) sends no new frames and ignores client_tool_call', async () => {
2049
+ const { ws, executed } = await startFastCall(ALL_CAPS);
2050
+ toolCall(ws);
2051
+ await flush();
2052
+ expect(executed).toEqual([]);
2053
+ expect(sentJson(ws)).toEqual([]);
2054
+ });
2055
+ });
2056
+
1661
2057
  describe('capabilities and tolerance', () => {
1662
2058
  it('uses no client delegation, context or updates when session_config has no capabilities (old server)', async () => {
1663
2059
  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";
@@ -268,7 +268,11 @@ describe("WebMcpBridge.snapshotForDispatch", () => {
268
268
  annotations: {
269
269
  readOnlyHint: false,
270
270
  untrustedContentHint: false,
271
- destructiveHint: true, // MCP-only today; not in the WebMCP dict
271
+ destructiveHint: true,
272
+ consequentialHint: true,
273
+ idempotentHint: false,
274
+ openWorldHint: false,
275
+ debugging: true,
272
276
  evil: () => "not JSON",
273
277
  },
274
278
  }),
@@ -277,6 +281,11 @@ describe("WebMcpBridge.snapshotForDispatch", () => {
277
281
  expect(snap[0]!.annotations).toEqual({
278
282
  readOnlyHint: false,
279
283
  untrustedContentHint: false,
284
+ destructiveHint: true,
285
+ consequentialHint: true,
286
+ idempotentHint: false,
287
+ openWorldHint: false,
288
+ debugging: true,
280
289
  });
281
290
  expect(snap[0]!).not.toHaveProperty("untrustedContentHint");
282
291
  });
@@ -380,6 +389,20 @@ describe("setWebMcpPolyfillLoader", () => {
380
389
  });
381
390
 
382
391
  describe("WebMCP display titles", () => {
392
+ it("passes sanitized annotations to confirmation without skipping the gate", async () => {
393
+ registry.tools = [fakeTool({ name: "search", annotations: {
394
+ readOnlyHint: true, destructiveHint: false, consequentialHint: false,
395
+ debugging: "yes", extra: () => "ignored",
396
+ } })];
397
+ const onConfirm = vi.fn(async () => false);
398
+ const bridge = new WebMcpBridge({ enabled: true, onConfirm });
399
+ const result = await bridge.executeToolCall("webmcp:search", {});
400
+ expect(onConfirm).toHaveBeenCalledWith(expect.objectContaining({ annotations: {
401
+ readOnlyHint: true, destructiveHint: false, consequentialHint: false,
402
+ } }));
403
+ expect(result.isError).toBe(true);
404
+ });
405
+
383
406
  it("records declared titles on snapshot and exposes them via getWebMcpToolDisplayTitle", async () => {
384
407
  registry.tools = [
385
408
  fakeTool({ name: "add_to_cart", title: "Add to Cart" }),
@@ -811,3 +834,131 @@ describe("native preview argument conversion", () => {
811
834
  expect(nativeExecute).toHaveBeenCalledTimes(1);
812
835
  });
813
836
  });
837
+
838
+ describe("WebMcpBridge.executeVoiceToolCall", () => {
839
+ it("skips the confirm gate for a tool the registry marks readOnlyHint: true", async () => {
840
+ const onConfirm = vi.fn(async () => false);
841
+ registry.tools = [
842
+ fakeTool({ name: "next_slide", annotations: { readOnlyHint: true }, execute: () => ({ slidePosition: 2 }) }),
843
+ ];
844
+ const bridge = new WebMcpBridge({ enabled: true, onConfirm });
845
+ const r = await bridge.executeVoiceToolCall("webmcp:next_slide", {}, { autoConfirmReadOnly: true });
846
+ expect(onConfirm).not.toHaveBeenCalled();
847
+ expect(r).toEqual({ status: "completed", result: { slidePosition: 2 } });
848
+ });
849
+
850
+ it.each([
851
+ ["no annotations", undefined],
852
+ ["readOnlyHint false", { readOnlyHint: false }],
853
+ ["only other hints", { idempotentHint: true }],
854
+ ])("still shows the gate for a tool with %s, and maps a decline to denied", async (_label, annotations) => {
855
+ const onConfirm = vi.fn(async () => false);
856
+ const execute = vi.fn(() => "done");
857
+ registry.tools = [fakeTool({ name: "edit_slide", annotations, execute })];
858
+ const bridge = new WebMcpBridge({ enabled: true, onConfirm });
859
+ const r = await bridge.executeVoiceToolCall("webmcp:edit_slide", {}, { autoConfirmReadOnly: true });
860
+ expect(onConfirm).toHaveBeenCalledTimes(1);
861
+ expect(execute).not.toHaveBeenCalled();
862
+ expect(r).toEqual({ status: "denied", error: { message: "User declined the tool call." } });
863
+ });
864
+
865
+ it("shows the gate even for a read-only tool when autoConfirmReadOnly is off", async () => {
866
+ const onConfirm = vi.fn(async () => true);
867
+ registry.tools = [fakeTool({ name: "next_slide", annotations: { readOnlyHint: true } })];
868
+ const bridge = new WebMcpBridge({ enabled: true, onConfirm });
869
+ await bridge.executeVoiceToolCall("webmcp:next_slide", {});
870
+ expect(onConfirm).toHaveBeenCalledTimes(1);
871
+ });
872
+
873
+ it("answers stale for a tool missing from the registry or excluded by the allowlist", async () => {
874
+ registry.tools = [fakeTool({ name: "secret", annotations: { readOnlyHint: true } })];
875
+ const bridge = new WebMcpBridge({ enabled: true, onConfirm: allowAll, allowlist: ["public_*"] });
876
+ expect((await bridge.executeVoiceToolCall("webmcp:gone", {})).status).toBe("stale");
877
+ expect((await bridge.executeVoiceToolCall("webmcp:secret", {})).status).toBe("stale");
878
+ });
879
+
880
+ it("answers failed with the message when the page tool throws or returns an MCP error", async () => {
881
+ registry.tools = [
882
+ fakeTool({
883
+ name: "boom",
884
+ annotations: { readOnlyHint: true },
885
+ execute: () => {
886
+ throw new Error("kaboom");
887
+ },
888
+ }),
889
+ fakeTool({
890
+ name: "mcp_err",
891
+ annotations: { readOnlyHint: true },
892
+ execute: () => ({ isError: true, content: [{ type: "text", text: "no such slide" }] }),
893
+ }),
894
+ ];
895
+ const bridge = new WebMcpBridge({ enabled: true, onConfirm: allowAll });
896
+ const thrown = await bridge.executeVoiceToolCall("webmcp:boom", {}, { autoConfirmReadOnly: true });
897
+ expect(thrown).toEqual({ status: "failed", error: { message: "kaboom" } });
898
+ expect(await bridge.executeVoiceToolCall("webmcp:mcp_err", {}, { autoConfirmReadOnly: true })).toEqual({
899
+ status: "failed",
900
+ error: { message: "no such slide" },
901
+ });
902
+ });
903
+
904
+ it("hands the call's signal to the confirm handler so the gate can close on abort", async () => {
905
+ const abort = new AbortController();
906
+ const onConfirm = vi.fn(async (info: { signal?: AbortSignal }) => {
907
+ expect(info.signal).toBe(abort.signal);
908
+ return false;
909
+ });
910
+ registry.tools = [fakeTool({ name: "edit_slide" })];
911
+ const bridge = new WebMcpBridge({ enabled: true, onConfirm });
912
+ await bridge.executeVoiceToolCall("webmcp:edit_slide", {}, { signal: abort.signal, autoConfirmReadOnly: true });
913
+ expect(onConfirm).toHaveBeenCalledTimes(1);
914
+ });
915
+
916
+ it("does not run the tool when the signal aborts while the gate is open", async () => {
917
+ const abort = new AbortController();
918
+ const execute = vi.fn(() => "done");
919
+ const onConfirm = vi.fn(async () => {
920
+ abort.abort();
921
+ return true;
922
+ });
923
+ registry.tools = [fakeTool({ name: "edit_slide", execute })];
924
+ const bridge = new WebMcpBridge({ enabled: true, onConfirm });
925
+ const r = await bridge.executeVoiceToolCall("webmcp:edit_slide", {}, {
926
+ signal: abort.signal,
927
+ autoConfirmReadOnly: true,
928
+ });
929
+ expect(execute).not.toHaveBeenCalled();
930
+ expect(r.status).toBe("failed");
931
+ });
932
+
933
+ it("keeps executeToolCall's /resume shape and gate unchanged", async () => {
934
+ const onConfirm = vi.fn(async () => false);
935
+ registry.tools = [fakeTool({ name: "next_slide", annotations: { readOnlyHint: true } })];
936
+ const bridge = new WebMcpBridge({ enabled: true, onConfirm });
937
+ const r = await bridge.executeToolCall("webmcp:next_slide", {});
938
+ expect(onConfirm).toHaveBeenCalledTimes(1);
939
+ expect(r).toEqual({ isError: true, content: [{ type: "text", text: "User declined the tool call." }] });
940
+ });
941
+ });
942
+
943
+ describe("WebMcpBridge.watchToolChanges", () => {
944
+ it("calls the listener on the registry's toolchange event until unsubscribed", async () => {
945
+ const mc = Object.assign(new EventTarget(), makeModelContext());
946
+ vi.stubGlobal("document", { modelContext: mc });
947
+ const bridge = new WebMcpBridge({ enabled: true });
948
+ const listener = vi.fn();
949
+ const stop = bridge.watchToolChanges(listener);
950
+ await new Promise((resolve) => setTimeout(resolve, 0));
951
+ mc.dispatchEvent(new Event("toolchange"));
952
+ expect(listener).toHaveBeenCalledTimes(1);
953
+ stop();
954
+ mc.dispatchEvent(new Event("toolchange"));
955
+ expect(listener).toHaveBeenCalledTimes(1);
956
+ });
957
+
958
+ it("is inert on a registry without events", async () => {
959
+ const bridge = new WebMcpBridge({ enabled: true });
960
+ const stop = bridge.watchToolChanges(vi.fn());
961
+ await new Promise((resolve) => setTimeout(resolve, 0));
962
+ expect(() => stop()).not.toThrow();
963
+ });
964
+ });