@vanira/sdk-react-native 0.0.2

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 (148) hide show
  1. package/README.md +239 -0
  2. package/package.json +53 -0
  3. package/src/__tests__/WebRTCClient.integration.test.ts +396 -0
  4. package/src/__tests__/adapters.test.ts +475 -0
  5. package/src/__tests__/httpResponse.test.ts +25 -0
  6. package/src/__tests__/mocks/react-native-incall-manager.ts +8 -0
  7. package/src/__tests__/mocks/react-native-permissions.ts +15 -0
  8. package/src/__tests__/mocks/react-native-webrtc.ts +6 -0
  9. package/src/__tests__/mocks/react-native.ts +28 -0
  10. package/src/__tests__/preset.test.ts +239 -0
  11. package/src/__tests__/resolveRuntimeConfig.test.ts +90 -0
  12. package/src/__tests__/storage.test.ts +211 -0
  13. package/src/__tests__/webrtcSignaling.test.ts +42 -0
  14. package/src/adapters/PeerConnectionAdapter.ts +101 -0
  15. package/src/adapters/browser/BrowserAudioAdapter.ts +43 -0
  16. package/src/adapters/browser/BrowserDataChannelAdapter.ts +69 -0
  17. package/src/adapters/browser/BrowserMediaAdapter.ts +15 -0
  18. package/src/adapters/browser/BrowserPeerAdapter.ts +14 -0
  19. package/src/adapters/browser/index.ts +4 -0
  20. package/src/adapters/interfaces.ts +84 -0
  21. package/src/adapters/react-native/RNAudioAdapter.ts +42 -0
  22. package/src/adapters/react-native/RNDataChannelAdapter.ts +79 -0
  23. package/src/adapters/react-native/RNMediaAdapter.ts +46 -0
  24. package/src/adapters/react-native/RNPeerAdapter.ts +28 -0
  25. package/src/adapters/react-native/callAudioRouting.ts +115 -0
  26. package/src/adapters/react-native/decodeUtf8.ts +72 -0
  27. package/src/adapters/react-native/index.ts +4 -0
  28. package/src/adapters/react-native/rnUploadFile.ts +76 -0
  29. package/src/adapters/storage/BrowserDualStorageAdapter.ts +71 -0
  30. package/src/adapters/storage/MemoryStorageAdapter.ts +50 -0
  31. package/src/adapters/storage/StorageAdapter.ts +21 -0
  32. package/src/adapters/storage/createSyncStorageAdapter.ts +40 -0
  33. package/src/adapters/storage/index.ts +7 -0
  34. package/src/api/services/ChatService.ts +304 -0
  35. package/src/api/services/ConfigService.ts +33 -0
  36. package/src/assets/icons.js +35 -0
  37. package/src/cdn.ts +68 -0
  38. package/src/core/CallSessionStore.ts +137 -0
  39. package/src/core/DraggableController.ts +83 -0
  40. package/src/core/SessionManager.ts +322 -0
  41. package/src/core/VaniraAI.ts +464 -0
  42. package/src/core/WebRTCClient.ts +1012 -0
  43. package/src/core/httpResponse.ts +22 -0
  44. package/src/core/iceServers.ts +18 -0
  45. package/src/core/toolCallNormalize.ts +80 -0
  46. package/src/core/voice-client.js +236 -0
  47. package/src/core/webrtcSignaling.ts +72 -0
  48. package/src/index.js +34 -0
  49. package/src/index.ts +6 -0
  50. package/src/platforms/browser.ts +67 -0
  51. package/src/platforms/react-native.ts +105 -0
  52. package/src/presets/BookingCalendarModal.tsx +457 -0
  53. package/src/presets/CameraModal.tsx +576 -0
  54. package/src/presets/DynamicFormModal.tsx +378 -0
  55. package/src/presets/NativePresetRenderer.tsx +350 -0
  56. package/src/presets/NavigateHandler.tsx +75 -0
  57. package/src/presets/PresetHost.tsx +155 -0
  58. package/src/presets/PresetShellModal.tsx +97 -0
  59. package/src/presets/UploadModal.tsx +321 -0
  60. package/src/presets/calendar/calendarUtils.ts +386 -0
  61. package/src/presets/call/CallSpeakerToggle.tsx +59 -0
  62. package/src/presets/call/callAudioRouting.ts +2 -0
  63. package/src/presets/call/useCallSpeaker.ts +31 -0
  64. package/src/presets/camera/cameraPermissions.ts +18 -0
  65. package/src/presets/camera/cameraStream.ts +19 -0
  66. package/src/presets/camera/cameraUtils.ts +21 -0
  67. package/src/presets/camera/useLivenessFlow.ts +95 -0
  68. package/src/presets/chalkboard/ChalkboardOverlay.tsx +156 -0
  69. package/src/presets/chalkboard/EraseTextHandler.tsx +95 -0
  70. package/src/presets/chalkboard/TypeTextHandler.tsx +107 -0
  71. package/src/presets/chalkboard/boardAbort.ts +36 -0
  72. package/src/presets/chalkboard/boardQueue.ts +620 -0
  73. package/src/presets/chalkboard/chalkboardSession.ts +75 -0
  74. package/src/presets/chalkboard/drawUtils.ts +123 -0
  75. package/src/presets/chalkboard/textUtils.ts +109 -0
  76. package/src/presets/clipRegion/ClipRegionModal.tsx +261 -0
  77. package/src/presets/clipRegion/clipRegionBridge.ts +19 -0
  78. package/src/presets/form/formValidation.ts +104 -0
  79. package/src/presets/form/parseFormFields.ts +171 -0
  80. package/src/presets/host/HostElementPresetHandler.tsx +155 -0
  81. package/src/presets/host/hostPresetBridge.ts +71 -0
  82. package/src/presets/index.ts +63 -0
  83. package/src/presets/liveScreen/CloseLiveScreenHandler.tsx +36 -0
  84. package/src/presets/liveScreen/LiveScreenCaptureHost.tsx +312 -0
  85. package/src/presets/liveScreen/LiveScreenHandler.tsx +25 -0
  86. package/src/presets/liveScreen/LiveScreenPipOverlay.tsx +6 -0
  87. package/src/presets/liveScreen/liveScreenSession.ts +73 -0
  88. package/src/presets/liveVision/CloseLiveVisionHandler.tsx +29 -0
  89. package/src/presets/liveVision/LiveVisionCameraHost.tsx +317 -0
  90. package/src/presets/liveVision/LiveVisionHandler.tsx +26 -0
  91. package/src/presets/liveVision/LiveVisionPipOverlay.tsx +7 -0
  92. package/src/presets/liveVision/liveVisionFrameLoop.ts +38 -0
  93. package/src/presets/liveVision/liveVisionSession.ts +75 -0
  94. package/src/presets/liveVision/liveVisionUpload.ts +62 -0
  95. package/src/presets/navigation/internalRouteRegistry.ts +25 -0
  96. package/src/presets/navigation/navigationBridge.ts +76 -0
  97. package/src/presets/navigation/navigationTypes.ts +12 -0
  98. package/src/presets/parseToolCall.ts +60 -0
  99. package/src/presets/presetClientAdapter.ts +29 -0
  100. package/src/presets/presetCompletion.ts +91 -0
  101. package/src/presets/presetEventHelpers.ts +45 -0
  102. package/src/presets/registry.ts +128 -0
  103. package/src/presets/streaming/mediaFrameUpload.ts +93 -0
  104. package/src/presets/types.ts +74 -0
  105. package/src/presets/upload/pickUploadFile.ts +256 -0
  106. package/src/presets/upload/uploadFormats.ts +163 -0
  107. package/src/presets/upload/uploadUtils.ts +68 -0
  108. package/src/react/PresetRenderer.tsx +144 -0
  109. package/src/react/index.ts +1 -0
  110. package/src/runtime/browserRuntime.ts +54 -0
  111. package/src/runtime/platform.ts +17 -0
  112. package/src/runtime/reactNativeRuntime.ts +68 -0
  113. package/src/runtime/resolveRuntimeConfig.ts +75 -0
  114. package/src/runtime/runtimeBundles.ts +74 -0
  115. package/src/runtime/types.ts +135 -0
  116. package/src/types/react-native-incall-manager.d.ts +17 -0
  117. package/src/types/react-native-webrtc.d.ts +47 -0
  118. package/src/types.ts +133 -0
  119. package/src/ui/VaniraWidget.ts +87 -0
  120. package/src/ui/abstraction/AbstractWidgetProvider.ts +18 -0
  121. package/src/ui/abstraction/interfaces.ts +12 -0
  122. package/src/ui/adapters/VaniraChatAdapter.ts +42 -0
  123. package/src/ui/components/AvatarView.ts +81 -0
  124. package/src/ui/components/ChatWindow.ts +263 -0
  125. package/src/ui/components/FloatingButton.ts +163 -0
  126. package/src/ui/components/FloatingWelcomeChips.ts +137 -0
  127. package/src/ui/components/Panel.ts +120 -0
  128. package/src/ui/components/VoiceOrb.ts +79 -0
  129. package/src/ui/components/VoiceOverlay.ts +497 -0
  130. package/src/ui/components/index.ts +7 -0
  131. package/src/ui/factory/WidgetFactory.ts +16 -0
  132. package/src/ui/icons_data.ts +2 -0
  133. package/src/ui/presets/WidgetPresetRenderer.ts +1802 -0
  134. package/src/ui/presets/types.ts +16 -0
  135. package/src/ui/providers/VaniraInternalProvider.ts +1066 -0
  136. package/src/ui/styles/index.ts +323 -0
  137. package/src/ui/styles/keyframes.ts +76 -0
  138. package/src/ui/styles/theme.ts +57 -0
  139. package/src/ui/styles/widget.css.ts +838 -0
  140. package/src/ui/utils.ts +37 -0
  141. package/src/ui/views/AbstractChatView.ts +93 -0
  142. package/src/ui/views/AbstractVoiceView.ts +57 -0
  143. package/src/ui/views/AvatarOnlyView.ts +78 -0
  144. package/src/ui/views/ChatAvatarView.ts +66 -0
  145. package/src/ui/views/ChatOnlyView.ts +28 -0
  146. package/src/ui/views/ChatVoiceView.ts +15 -0
  147. package/src/ui/views/VoiceOnlyView.ts +25 -0
  148. package/src/ui/views/index.ts +5 -0
package/README.md ADDED
@@ -0,0 +1,239 @@
1
+ # @vanira/sdk-react-native
2
+
3
+ React Native SDK for Vanira voice AI + interactive preset protocol (calendar, forms, upload, camera, navigation, live vision, etc.).
4
+
5
+ Separate from the web package [`@vanira/sdk`](https://www.npmjs.com/package/@vanira/sdk).
6
+
7
+ ---
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ npm install @vanira/sdk-react-native \
13
+ react-native-webrtc \
14
+ react-native-permissions \
15
+ react-native-incall-manager \
16
+ react-native-image-picker \
17
+ @react-native-documents/picker \
18
+ react-native-view-shot \
19
+ react-native-vision-camera
20
+ ```
21
+
22
+ **iOS** — add permission pods, then rebuild:
23
+
24
+ ```ruby
25
+ # ios/Podfile
26
+ require_relative '../node_modules/react-native-permissions/scripts/setup'
27
+
28
+ setup_permissions([
29
+ 'Camera',
30
+ 'Microphone',
31
+ 'PhotoLibrary',
32
+ ])
33
+ ```
34
+
35
+ ```bash
36
+ cd ios && pod install
37
+ ```
38
+
39
+ **Android** — add to `AndroidManifest.xml`:
40
+
41
+ ```xml
42
+ <uses-permission android:name="android.permission.RECORD_AUDIO" />
43
+ <uses-permission android:name="android.permission.CAMERA" />
44
+ <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
45
+ ```
46
+
47
+ **App entry** — call `registerGlobals()` once before any SDK import:
48
+
49
+ ```js
50
+ // index.js
51
+ import {AppRegistry} from 'react-native';
52
+ import {registerGlobals} from 'react-native-webrtc';
53
+
54
+ registerGlobals();
55
+
56
+ import App from './App';
57
+ import {name as appName} from './app.json';
58
+
59
+ AppRegistry.registerComponent(appName, () => App);
60
+ ```
61
+
62
+ ---
63
+
64
+ ## Quick start
65
+
66
+ ### 1. Wrap your app with `PresetHostProvider`
67
+
68
+ The SDK renders preset modals (upload, calendar, camera, etc.) at the root.
69
+
70
+ ```tsx
71
+ // App.tsx
72
+ import {PresetHostProvider} from '@vanira/sdk-react-native';
73
+
74
+ export default function App() {
75
+ return (
76
+ <PresetHostProvider>
77
+ {/* your navigation / screens */}
78
+ </PresetHostProvider>
79
+ );
80
+ }
81
+ ```
82
+
83
+ ### 2. Start a voice session with `createReactNativeAI`
84
+
85
+ ```tsx
86
+ import {
87
+ createReactNativeAI,
88
+ usePresetHost,
89
+ type ClientToolCall,
90
+ type VaniraAI,
91
+ } from '@vanira/sdk-react-native';
92
+
93
+ function VoiceScreen() {
94
+ const {setAiClient, setActiveToolCall} = usePresetHost();
95
+ const aiRef = useRef<VaniraAI | null>(null);
96
+
97
+ const startCall = async () => {
98
+ const ai = createReactNativeAI({
99
+ agentId: 'YOUR_AGENT_ID',
100
+ apiKey: 'pk_live_...', // or sk_live_...
101
+ backendUrl: 'https://api.vanira.io',
102
+ });
103
+
104
+ // Route preset + tool calls to the SDK renderer
105
+ ai.on('preset', ({toolCall}) => setActiveToolCall(toolCall));
106
+ ai.on('tool_call', (toolCall: ClientToolCall) => setActiveToolCall(toolCall));
107
+
108
+ ai.on('connected', () => console.log('connected'));
109
+ ai.on('disconnected', () => {
110
+ aiRef.current = null;
111
+ setAiClient(null);
112
+ });
113
+
114
+ aiRef.current = ai;
115
+ setAiClient(ai); // required — presets use ai.uploadMedia(), sendToolResult(), etc.
116
+ await ai.start();
117
+ };
118
+
119
+ const stopCall = () => {
120
+ aiRef.current?.stop();
121
+ aiRef.current = null;
122
+ setAiClient(null);
123
+ };
124
+
125
+ // render start/stop UI…
126
+ }
127
+ ```
128
+
129
+ ### 3. Handle blocking tool results (optional)
130
+
131
+ For tools with `execution_mode: 'blocking'`, send a result when your UI completes:
132
+
133
+ ```ts
134
+ ai.sendToolResult(toolCall.tool_call_id, {success: true, data: {...}});
135
+ ```
136
+
137
+ Preset modals in the SDK call this automatically when the user submits or cancels.
138
+
139
+ ---
140
+
141
+ ## Main exports
142
+
143
+ | Export | Purpose |
144
+ |--------|---------|
145
+ | `createReactNativeAI()` | **Recommended** — voice client with RN adapters wired in |
146
+ | `createReactNativeClient()` | Lower-level WebRTC client (same adapters) |
147
+ | `PresetHostProvider` | App root — mounts preset UI overlay |
148
+ | `usePresetHost()` | `{setAiClient, setActiveToolCall, clearPreset}` |
149
+ | `VaniraAI` / `WebRTCClient` | Core classes (usually via factories above) |
150
+ | `extractPresetId()` | Parse preset id from a tool call payload |
151
+ | `hasContinueSession()` / `loadContinueSession()` | Resume last call (needs storage adapter) |
152
+ | `createSyncStorageAdapter()` | Plug AsyncStorage (or similar) into the runtime |
153
+
154
+ Legacy aliases `createVaniraAI` / `createVaniraClient` still work but are deprecated.
155
+
156
+ ---
157
+
158
+ ## Continue / resume calls
159
+
160
+ Pass a storage backend on the runtime to persist `prospect_id` + `call_id` between sessions:
161
+
162
+ ```ts
163
+ import {
164
+ createReactNativeAI,
165
+ createSyncStorageAdapter,
166
+ reactNativeRuntime,
167
+ } from '@vanira/sdk-react-native';
168
+ import AsyncStorage from '@react-native-async-storage/async-storage';
169
+
170
+ const runtime = {
171
+ ...reactNativeRuntime,
172
+ storage: createSyncStorageAdapter(AsyncStorage),
173
+ };
174
+
175
+ const ai = createReactNativeAI({
176
+ agentId: 'YOUR_AGENT_ID',
177
+ apiKey: 'pk_live_...',
178
+ backendUrl: 'https://api.vanira.io',
179
+ sessionBehavior: 'continue', // or 'new'
180
+ runtime,
181
+ });
182
+ ```
183
+
184
+ ---
185
+
186
+ ## Metro (npm install)
187
+
188
+ When installed from npm (not a local `file:` path), Metro must transpile the SDK’s TypeScript source:
189
+
190
+ ```js
191
+ // metro.config.js
192
+ const path = require('path');
193
+ const {getDefaultConfig, mergeConfig} = require('@react-native/metro-config');
194
+
195
+ module.exports = mergeConfig(getDefaultConfig(__dirname), {
196
+ resolver: {unstable_enablePackageExports: true},
197
+ watchFolders: [
198
+ path.resolve(__dirname, 'node_modules/@vanira/sdk-react-native'),
199
+ ],
200
+ });
201
+ ```
202
+
203
+ For monorepo local dev with `"file:../vanira-sdk-rn"`, point Metro at the sibling folder (see `MyApp/metro.config.js` in this repo).
204
+
205
+ ---
206
+
207
+ ## Presets included
208
+
209
+ | Preset | Description |
210
+ |--------|-------------|
211
+ | `vanira_upload` | File picker — JPEG, PNG, PDF, TXT, CSV |
212
+ | `vanira_calendar` | Date / booking picker |
213
+ | `vanira_form` | Dynamic form modal |
214
+ | `vanira_camera` | Camera capture + upload |
215
+ | `vanira_navigate` | In-app or external navigation |
216
+ | `vanira_live_vision` | Live camera frames (requires `react-native-vision-camera`) |
217
+ | Chalkboard | Type / erase text overlays |
218
+
219
+ The app only needs to call `setActiveToolCall()` — the SDK renders the UI and sends results back to the agent.
220
+
221
+ ---
222
+
223
+ ## Platform notes
224
+
225
+ - Import from **`@vanira/sdk-react-native`** only — do not use `@vanira/sdk` (web) in RN apps.
226
+ - `registerGlobals()` from `react-native-webrtc` is **required** at app entry.
227
+ - Live vision uses Vision Camera (not `RTCView` + ViewShot — black frames on iOS).
228
+ - Upload uses gallery + document picker; iOS needs Photo Library permission in Podfile + Info.plist.
229
+
230
+ ---
231
+
232
+ ## Relationship to web SDK
233
+
234
+ | Package | Platform |
235
+ |---------|----------|
236
+ | `@vanira/sdk` | Browser / web widget |
237
+ | `@vanira/sdk-react-native` | React Native |
238
+
239
+ Same voice protocol and preset IDs; different adapters and UI layer.
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@vanira/sdk-react-native",
3
+ "version": "0.0.2",
4
+ "description": "Vanira Voice SDK for React Native — WebRTC, presets protocol, RN adapters",
5
+ "author": "Vanira <developers@vanira.io>",
6
+ "license": "MIT",
7
+ "type": "module",
8
+ "main": "./src/index.ts",
9
+ "types": "./src/index.ts",
10
+ "exports": {
11
+ ".": {
12
+ "import": "./src/index.ts",
13
+ "types": "./src/index.ts"
14
+ }
15
+ },
16
+ "files": [
17
+ "src"
18
+ ],
19
+ "scripts": {
20
+ "test": "vitest run --config vitest.config.ts",
21
+ "test:watch": "vitest --config vitest.config.ts",
22
+ "prepublishOnly": "npm test"
23
+ },
24
+ "react-native": "./src/index.ts",
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "https://github.com/vanira-ai/sdk"
28
+ },
29
+ "keywords": [
30
+ "react-native",
31
+ "voice-ai",
32
+ "webrtc",
33
+ "vanira",
34
+ "sdk"
35
+ ],
36
+ "peerDependencies": {
37
+ "react": ">=18",
38
+ "react-native": ">=0.74",
39
+ "react-native-webrtc": ">=124",
40
+ "react-native-image-picker": ">=8",
41
+ "@react-native-documents/picker": ">=10",
42
+ "react-native-view-shot": ">=4",
43
+ "react-native-incall-manager": ">=4",
44
+ "react-native-permissions": ">=5",
45
+ "react-native-vision-camera": ">=4.0.0"
46
+ },
47
+ "devDependencies": {
48
+ "@vitest/coverage-v8": "^4.1.6",
49
+ "typescript": "^5.0.0",
50
+ "vite": "^5.0.0",
51
+ "vitest": "^4.1.6"
52
+ }
53
+ }
@@ -0,0 +1,396 @@
1
+ /**
2
+ * WebRTCClient adapter wiring integration tests (Task 0.2)
3
+ */
4
+
5
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
6
+
7
+ const TEST_ICE_SERVERS = [{ urls: 'stun:stun.test.example:3478' }] as RTCIceServer[];
8
+ const TEST_WORKER_URL = 'https://worker.example.com/webrtc?token=abc';
9
+
10
+ function mockFetchResponse(body: unknown): Response {
11
+ return {
12
+ ok: true,
13
+ text: () => Promise.resolve(JSON.stringify(body)),
14
+ } as Response;
15
+ }
16
+
17
+ function makeMockRTCPeerConnection() {
18
+ return {
19
+ addTrack: vi.fn(),
20
+ createDataChannel: vi.fn(() => makeMockRTCDataChannel()),
21
+ createOffer: vi.fn(() => Promise.resolve({ type: 'offer', sdp: 'mock-sdp' })),
22
+ setLocalDescription: vi.fn(() => Promise.resolve()),
23
+ setRemoteDescription: vi.fn(() => Promise.resolve()),
24
+ getSenders: vi.fn(() => []),
25
+ close: vi.fn(),
26
+ iceGatheringState: 'complete',
27
+ connectionState: 'new',
28
+ iceConnectionState: 'new',
29
+ localDescription: { type: 'offer', sdp: 'mock-sdp' },
30
+ ontrack: null as ((event: RTCTrackEvent) => void) | null,
31
+ onconnectionstatechange: null as (() => void) | null,
32
+ oniceconnectionstatechange: null as (() => void) | null,
33
+ onicecandidate: null as ((event: RTCPeerConnectionIceEvent) => void) | null,
34
+ addEventListener: vi.fn(),
35
+ removeEventListener: vi.fn(),
36
+ };
37
+ }
38
+
39
+ function makeMockRTCDataChannel() {
40
+ return {
41
+ send: vi.fn(),
42
+ close: vi.fn(),
43
+ readyState: 'open' as RTCDataChannelState,
44
+ onopen: null as (() => void) | null,
45
+ onmessage: null as ((e: MessageEvent) => void) | null,
46
+ onerror: null as ((e: Event) => void) | null,
47
+ };
48
+ }
49
+
50
+ describe('WebRTCClient constructor storage', () => {
51
+ it('reads prospect and call id from storage when sessionBehavior is continue', async () => {
52
+ const { MemoryStorageAdapter } = await import('../adapters/storage/MemoryStorageAdapter');
53
+ const { WebRTCClient } = await import('../core/WebRTCClient');
54
+
55
+ const storage = new MemoryStorageAdapter();
56
+ storage.setItem('vanira_prospect_id', 'prospect_stored');
57
+ storage.setItem('vanira_latest_call_id', 'call_stored');
58
+
59
+ const client = new WebRTCClient({
60
+ agentId: 'agent_1',
61
+ serverUrl: 'https://worker.example.com',
62
+ sessionBehavior: 'continue',
63
+ storageAdapter: storage,
64
+ });
65
+
66
+ expect(client.prospectId).toBe('prospect_stored');
67
+ expect(client.callId).toBe('call_stored');
68
+ });
69
+
70
+ it('does not read call id from storage when sessionBehavior is not continue', async () => {
71
+ const { MemoryStorageAdapter } = await import('../adapters/storage/MemoryStorageAdapter');
72
+ const { WebRTCClient } = await import('../core/WebRTCClient');
73
+
74
+ const storage = new MemoryStorageAdapter();
75
+ storage.setItem('vanira_latest_call_id', 'call_stored');
76
+
77
+ const client = new WebRTCClient({
78
+ agentId: 'agent_1',
79
+ serverUrl: 'https://worker.example.com',
80
+ storageAdapter: storage,
81
+ });
82
+
83
+ expect(client.callId).toBeUndefined();
84
+ });
85
+ });
86
+
87
+ describe('WebRTCClient generateCallId', () => {
88
+ it('uses web_ prefix for bare web client', async () => {
89
+ const { WebRTCClient } = await import('../core/WebRTCClient');
90
+ const client = new WebRTCClient({
91
+ agentId: 'agent_1',
92
+ serverUrl: 'https://worker.example.com',
93
+ });
94
+ const id = client.generateCallId();
95
+ expect(id.startsWith('web_')).toBe(true);
96
+ });
97
+
98
+ it('uses rn_ prefix for react-native runtime', async () => {
99
+ const { WebRTCClient } = await import('../core/WebRTCClient');
100
+ const { reactNativeRuntime } = await import('../runtime/reactNativeRuntime');
101
+
102
+ const client = new WebRTCClient({
103
+ agentId: 'agent_1',
104
+ serverUrl: 'https://worker.example.com',
105
+ runtime: reactNativeRuntime,
106
+ });
107
+ const id = client.generateCallId();
108
+ expect(id.startsWith('rn_')).toBe(true);
109
+ });
110
+ });
111
+
112
+ describe('WebRTCClient createCall body', () => {
113
+ beforeEach(() => {
114
+ Object.defineProperty(globalThis, 'navigator', {
115
+ value: { product: 'Gecko', mediaDevices: { getUserMedia: vi.fn() } },
116
+ configurable: true,
117
+ });
118
+ });
119
+
120
+ afterEach(() => {
121
+ vi.restoreAllMocks();
122
+ });
123
+
124
+ it('includes runtime browser for bare web client', async () => {
125
+ const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
126
+ mockFetchResponse({
127
+ call_id: 'c1',
128
+ worker_url: TEST_WORKER_URL,
129
+ ice_servers: [{ urls: 'stun:stun.test.example:3478' }],
130
+ }),
131
+ );
132
+
133
+ const { WebRTCClient } = await import('../core/WebRTCClient');
134
+ const client = new WebRTCClient({
135
+ agentId: 'agent_1',
136
+ apiKey: 'sk_test',
137
+ });
138
+
139
+ try {
140
+ await client.createCall();
141
+ } catch {
142
+ // connect may fail in test env
143
+ }
144
+
145
+ const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
146
+ expect(body.type).toBe('web');
147
+ expect(body.runtime).toBe('browser');
148
+ expect(body.mode).toBeUndefined();
149
+ });
150
+
151
+ it('includes mode continue only when sessionBehavior is continue', async () => {
152
+ const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
153
+ mockFetchResponse({
154
+ call_id: 'c1',
155
+ worker_url: TEST_WORKER_URL,
156
+ ice_servers: [{ urls: 'stun:stun.test.example:3478' }],
157
+ }),
158
+ );
159
+
160
+ const { WebRTCClient } = await import('../core/WebRTCClient');
161
+ const client = new WebRTCClient({
162
+ agentId: 'agent_1',
163
+ apiKey: 'sk_test',
164
+ sessionBehavior: 'continue',
165
+ });
166
+
167
+ try {
168
+ await client.createCall();
169
+ } catch {
170
+ // connect may fail
171
+ }
172
+
173
+ const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
174
+ expect(body.mode).toBe('continue');
175
+ });
176
+
177
+ it('persists ids via storage after createCall', async () => {
178
+ const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
179
+ mockFetchResponse({
180
+ call_id: 'call_new',
181
+ prospect_id: 'prospect_new',
182
+ worker_url: TEST_WORKER_URL,
183
+ ice_servers: [{ urls: 'stun:stun.test.example:3478' }],
184
+ }),
185
+ );
186
+
187
+ const { MemoryStorageAdapter } = await import('../adapters/storage/MemoryStorageAdapter');
188
+ const { WebRTCClient } = await import('../core/WebRTCClient');
189
+
190
+ const storage = new MemoryStorageAdapter();
191
+ const client = new WebRTCClient({
192
+ agentId: 'agent_1',
193
+ apiKey: 'sk_test',
194
+ storageAdapter: storage,
195
+ });
196
+
197
+ try {
198
+ await client.createCall();
199
+ } catch {
200
+ // connect may fail
201
+ }
202
+
203
+ expect(storage.getItem('vanira_prospect_id')).toBe('prospect_new');
204
+ expect(storage.getItem('vanira_latest_call_id')).toBe('call_new');
205
+ expect(fetchSpy).toHaveBeenCalled();
206
+ });
207
+ });
208
+
209
+ describe('WebRTCClient adapter wiring in connect', () => {
210
+ let mockPc: ReturnType<typeof makeMockRTCPeerConnection>;
211
+ let mockChannel: ReturnType<typeof makeMockRTCDataChannel>;
212
+
213
+ beforeEach(() => {
214
+ mockPc = makeMockRTCPeerConnection();
215
+ mockChannel = makeMockRTCDataChannel();
216
+ mockPc.createDataChannel.mockReturnValue(mockChannel);
217
+
218
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
219
+ function MockRTC(this: any) {
220
+ Object.assign(this, mockPc);
221
+ }
222
+ (globalThis as unknown as Record<string, unknown>).RTCPeerConnection = MockRTC;
223
+
224
+ Object.defineProperty(globalThis, 'navigator', {
225
+ value: {
226
+ product: 'Gecko',
227
+ userAgent: 'Mozilla/5.0',
228
+ platform: 'MacIntel',
229
+ maxTouchPoints: 0,
230
+ mediaDevices: {
231
+ getUserMedia: vi.fn(() => Promise.resolve({
232
+ getTracks: () => [{ kind: 'audio', stop: vi.fn() }],
233
+ })),
234
+ },
235
+ },
236
+ configurable: true,
237
+ });
238
+
239
+ vi.spyOn(globalThis, 'fetch').mockImplementation((input: RequestInfo | URL) => {
240
+ const url = String(input);
241
+ if (url.includes('/webrtc/ice')) {
242
+ return Promise.resolve({
243
+ ok: true,
244
+ text: () => Promise.resolve(JSON.stringify({ candidates: [] })),
245
+ } as Response);
246
+ }
247
+ return Promise.resolve({
248
+ ok: true,
249
+ text: () =>
250
+ Promise.resolve(
251
+ JSON.stringify({ answer: { type: 'answer', sdp: 'answer-sdp' } }),
252
+ ),
253
+ } as Response);
254
+ });
255
+ });
256
+
257
+ afterEach(() => {
258
+ vi.restoreAllMocks();
259
+ });
260
+
261
+ it('calls peerAdapter.create with configured iceServers', async () => {
262
+ const { WebRTCClient } = await import('../core/WebRTCClient');
263
+ const { browserRuntime } = await import('../runtime/browserRuntime');
264
+
265
+ const createSpy = vi.spyOn(browserRuntime.peer, 'create');
266
+
267
+ const customIce = [{ urls: 'stun:custom.example.com:3478' }];
268
+ const client = new WebRTCClient({
269
+ agentId: 'agent_1',
270
+ serverUrl: TEST_WORKER_URL,
271
+ callId: 'call_1',
272
+ prospectId: 'p_1',
273
+ runtime: browserRuntime,
274
+ iceServers: customIce as RTCIceServer[],
275
+ });
276
+
277
+ await client.connect();
278
+
279
+ expect(createSpy).toHaveBeenCalledWith({
280
+ iceServers: customIce,
281
+ iceTransportPolicy: 'all',
282
+ });
283
+ });
284
+
285
+ it('calls mediaAdapter.getUserAudio with audio constraints', async () => {
286
+ const { WebRTCClient } = await import('../core/WebRTCClient');
287
+ const { browserRuntime } = await import('../runtime/browserRuntime');
288
+
289
+ const getUserSpy = vi.spyOn(browserRuntime.media, 'getUserAudio');
290
+
291
+ const client = new WebRTCClient({
292
+ agentId: 'agent_1',
293
+ serverUrl: TEST_WORKER_URL,
294
+ callId: 'call_1',
295
+ runtime: browserRuntime,
296
+ iceServers: TEST_ICE_SERVERS,
297
+ });
298
+
299
+ await client.connect();
300
+
301
+ expect(getUserSpy).toHaveBeenCalledWith({
302
+ echoCancellation: true,
303
+ noiseSuppression: true,
304
+ autoGainControl: true,
305
+ sampleRate: { ideal: 16000 },
306
+ channelCount: 1,
307
+ });
308
+ });
309
+
310
+ it('binds dataChannelAdapter and routes string messages to handleControlEvent', async () => {
311
+ const { WebRTCClient } = await import('../core/WebRTCClient');
312
+ const { browserRuntime } = await import('../runtime/browserRuntime');
313
+
314
+ const onTranscription = vi.fn();
315
+ const bindSpy = vi.spyOn(browserRuntime.dataChannel, 'bind');
316
+
317
+ const client = new WebRTCClient({
318
+ agentId: 'agent_1',
319
+ serverUrl: 'https://worker.example.com/webrtc?token=abc',
320
+ callId: 'call_1',
321
+ runtime: browserRuntime,
322
+ iceServers: TEST_ICE_SERVERS,
323
+ onTranscription,
324
+ });
325
+
326
+ await client.connect();
327
+
328
+ expect(bindSpy).toHaveBeenCalled();
329
+ const handlers = bindSpy.mock.calls[0][1];
330
+ handlers.onMessage({
331
+ text: JSON.stringify({ event: 'transcription', text: 'hello', isFinal: true }),
332
+ });
333
+ expect(onTranscription).toHaveBeenCalledWith('hello', true);
334
+ });
335
+
336
+ it('sendEvent uses dcController when channel is open', async () => {
337
+ const { WebRTCClient } = await import('../core/WebRTCClient');
338
+ const { browserRuntime } = await import('../runtime/browserRuntime');
339
+
340
+ const bindSpy = vi.spyOn(browserRuntime.dataChannel, 'bind');
341
+
342
+ const client = new WebRTCClient({
343
+ agentId: 'agent_1',
344
+ serverUrl: TEST_WORKER_URL,
345
+ callId: 'call_1',
346
+ runtime: browserRuntime,
347
+ iceServers: TEST_ICE_SERVERS,
348
+ });
349
+
350
+ await client.connect();
351
+
352
+ const handlers = bindSpy.mock.calls[0][1];
353
+ handlers.onOpen();
354
+
355
+ client.sendEvent('test_event', { foo: 'bar' });
356
+ expect(mockChannel.send).toHaveBeenCalledWith(
357
+ JSON.stringify({ event: 'test_event', foo: 'bar' })
358
+ );
359
+ });
360
+ });
361
+
362
+ describe('WebRTCClient handleControlEvent ack ordering', () => {
363
+ it('sendToolAck fires before onClientToolCall for preset tools', async () => {
364
+ const { WebRTCClient } = await import('../core/WebRTCClient');
365
+ const { browserRuntime } = await import('../runtime/browserRuntime');
366
+
367
+ const order: string[] = [];
368
+ const onClientToolCall = vi.fn(() => order.push('callback'));
369
+
370
+ const client = new WebRTCClient({
371
+ agentId: 'agent_1',
372
+ serverUrl: 'https://worker.example.com',
373
+ callId: 'call_1',
374
+ runtime: browserRuntime,
375
+ onPreset: () => order.push('onPreset'),
376
+ onClientToolCall,
377
+ });
378
+
379
+ const sendToolAckSpy = vi.spyOn(client, 'sendToolAck').mockImplementation(() => {
380
+ order.push('ack');
381
+ });
382
+
383
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
384
+ (client as any).handleControlEvent({
385
+ event: 'client_tool_call',
386
+ tool_call: {
387
+ tool_call_id: 'tc_1',
388
+ arguments: { preset_id: 'demo' },
389
+ },
390
+ });
391
+
392
+ expect(sendToolAckSpy).toHaveBeenCalledWith('tc_1');
393
+ expect(onClientToolCall).toHaveBeenCalled();
394
+ expect(order).toEqual(['ack', 'onPreset', 'callback']);
395
+ });
396
+ });