vellum 0.2.12 → 0.2.13

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.
package/bun.lock CHANGED
@@ -11,7 +11,7 @@
11
11
  "@huggingface/transformers": "^3.8.1",
12
12
  "@qdrant/js-client-rest": "^1.16.2",
13
13
  "@sentry/node": "^10.38.0",
14
- "@vellumai/cli": "0.1.12",
14
+ "@vellumai/cli": "0.1.13",
15
15
  "@vellumai/vellum-gateway": "0.1.10",
16
16
  "agentmail": "^0.1.0",
17
17
  "archiver": "^7.0.1",
@@ -542,7 +542,7 @@
542
542
 
543
543
  "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.56.0", "", { "dependencies": { "@typescript-eslint/types": "8.56.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg=="],
544
544
 
545
- "@vellumai/cli": ["@vellumai/cli@0.1.12", "", { "dependencies": { "ink": "^6.7.0", "react": "^19.2.4", "react-devtools-core": "^6.1.2" }, "bin": { "vellum-cli": "src/index.ts" } }, "sha512-6httUd8UEnlhCpqSaROGITwp485jG6EB5ILC4VyWrs2tow6sYHxDFc6eaNBqjS54m33I+rbvIwcKOyiGRPTORA=="],
545
+ "@vellumai/cli": ["@vellumai/cli@0.1.13", "", { "dependencies": { "ink": "^6.7.0", "react": "^19.2.4", "react-devtools-core": "^6.1.2" }, "bin": { "vellum-cli": "src/index.ts" } }, "sha512-zoES1ddavpHZljC/uPkelJvIsen77aTtFwfLYAA4zdtCCf3zktS2+TaDVQxB1Eh+dbRi3Tgc+XCXbq7S86kFiQ=="],
546
546
 
547
547
  "@vellumai/vellum-gateway": ["@vellumai/vellum-gateway@0.1.10", "", { "dependencies": { "file-type": "^21.3.0", "pino": "^9.6.0", "pino-pretty": "^13.1.3", "zod": "^4.3.6" } }, "sha512-a41fGexW8RpWL4RTfZ3EM+XJMvz7t26D1axu2xAtZioXW3ZWMLGuogHnIJsgglzESl49E6VmmUsUGeD+dseV2w=="],
548
548
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vellum",
3
- "version": "0.2.12",
3
+ "version": "0.2.13",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "vellum": "./src/index.ts"
@@ -29,7 +29,7 @@
29
29
  "@huggingface/transformers": "^3.8.1",
30
30
  "@qdrant/js-client-rest": "^1.16.2",
31
31
  "@sentry/node": "^10.38.0",
32
- "@vellumai/cli": "0.1.12",
32
+ "@vellumai/cli": "0.1.13",
33
33
  "@vellumai/vellum-gateway": "0.1.10",
34
34
  "agentmail": "^0.1.0",
35
35
  "archiver": "^7.0.1",
@@ -29,6 +29,8 @@ mock.module('../util/logger.js', () => ({
29
29
 
30
30
  // ── Config mock ─────────────────────────────────────────────────────
31
31
 
32
+ let mockCallModel: string | undefined = undefined;
33
+
32
34
  mock.module('../config/loader.js', () => ({
33
35
  getConfig: () => ({
34
36
  apiKeys: { anthropic: 'test-key' },
@@ -41,6 +43,7 @@ mock.module('../config/loader.js', () => ({
41
43
  silenceTimeoutSeconds: 30,
42
44
  disclosure: { enabled: false, text: '' },
43
45
  safety: { denyCategories: [] },
46
+ model: mockCallModel,
44
47
  },
45
48
  }),
46
49
  }));
@@ -192,6 +195,7 @@ function setupOrchestrator(task?: string) {
192
195
  describe('call-orchestrator', () => {
193
196
  beforeEach(() => {
194
197
  resetTables();
198
+ mockCallModel = undefined;
195
199
  // Reset the stream mock to default behaviour
196
200
  mockStreamFn.mockImplementation(() => createMockStream(['Hello', ' there']));
197
201
  });
@@ -451,4 +455,58 @@ describe('call-orchestrator', () => {
451
455
  // Second destroy should not throw
452
456
  expect(() => orchestrator.destroy()).not.toThrow();
453
457
  });
458
+
459
+ // ── Model override from config ──────────────────────────────────────
460
+
461
+ test('uses default model when calls.model is not set', async () => {
462
+ mockCallModel = undefined;
463
+ mockStreamFn.mockImplementation((...args: unknown[]) => {
464
+ const firstArg = args[0] as { model: string };
465
+ expect(firstArg.model).toBe('claude-sonnet-4-20250514');
466
+ return createMockStream(['Default model response.']);
467
+ });
468
+
469
+ const { orchestrator } = setupOrchestrator();
470
+ await orchestrator.handleCallerUtterance('Hello');
471
+ orchestrator.destroy();
472
+ });
473
+
474
+ test('uses calls.model override from config when set', async () => {
475
+ mockCallModel = 'claude-haiku-4-5-20251001';
476
+ mockStreamFn.mockImplementation((...args: unknown[]) => {
477
+ const firstArg = args[0] as { model: string };
478
+ expect(firstArg.model).toBe('claude-haiku-4-5-20251001');
479
+ return createMockStream(['Override model response.']);
480
+ });
481
+
482
+ const { orchestrator } = setupOrchestrator();
483
+ await orchestrator.handleCallerUtterance('Hello');
484
+ orchestrator.destroy();
485
+ });
486
+
487
+ test('treats empty string calls.model as unset and falls back to default', async () => {
488
+ mockCallModel = '';
489
+ mockStreamFn.mockImplementation((...args: unknown[]) => {
490
+ const firstArg = args[0] as { model: string };
491
+ expect(firstArg.model).toBe('claude-sonnet-4-20250514');
492
+ return createMockStream(['Fallback model response.']);
493
+ });
494
+
495
+ const { orchestrator } = setupOrchestrator();
496
+ await orchestrator.handleCallerUtterance('Hello');
497
+ orchestrator.destroy();
498
+ });
499
+
500
+ test('treats whitespace-only calls.model as unset and falls back to default', async () => {
501
+ mockCallModel = ' ';
502
+ mockStreamFn.mockImplementation((...args: unknown[]) => {
503
+ const firstArg = args[0] as { model: string };
504
+ expect(firstArg.model).toBe('claude-sonnet-4-20250514');
505
+ return createMockStream(['Fallback model response.']);
506
+ });
507
+
508
+ const { orchestrator } = setupOrchestrator();
509
+ await orchestrator.handleCallerUtterance('Hello');
510
+ orchestrator.destroy();
511
+ });
454
512
  });
@@ -55,6 +55,7 @@ import { _setBackend } from '../security/secure-keys.js';
55
55
  import { loadConfig, invalidateConfigCache } from '../config/loader.js';
56
56
  import { AssistantConfigSchema } from '../config/schema.js';
57
57
  import { DEFAULT_CONFIG } from '../config/defaults.js';
58
+ import { buildElevenLabsVoiceSpec, resolveVoiceQualityProfile } from '../calls/voice-quality.js';
58
59
 
59
60
  // ---------------------------------------------------------------------------
60
61
  // Helpers
@@ -655,6 +656,23 @@ describe('AssistantConfigSchema', () => {
655
656
  safety: {
656
657
  denyCategories: [],
657
658
  },
659
+ voice: {
660
+ mode: 'twilio_standard',
661
+ language: 'en-US',
662
+ transcriptionProvider: 'Deepgram',
663
+ fallbackToStandardOnError: true,
664
+ elevenlabs: {
665
+ voiceId: '',
666
+ voiceModelId: 'turbo_v2_5',
667
+ stability: 0.5,
668
+ similarityBoost: 0.75,
669
+ style: 0.0,
670
+ useSpeakerBoost: true,
671
+ agentId: '',
672
+ apiBaseUrl: 'https://api.elevenlabs.io',
673
+ registerCallTimeoutMs: 5000,
674
+ },
675
+ },
658
676
  });
659
677
  });
660
678
 
@@ -745,6 +763,261 @@ describe('AssistantConfigSchema', () => {
745
763
  });
746
764
  expect(result.success).toBe(false);
747
765
  });
766
+
767
+ // ── Calls voice config ──────────────────────────────────────────────
768
+
769
+ test('config without calls.voice parses correctly and produces defaults', () => {
770
+ const result = AssistantConfigSchema.parse({});
771
+ expect(result.calls.voice.mode).toBe('twilio_standard');
772
+ expect(result.calls.voice.language).toBe('en-US');
773
+ expect(result.calls.voice.transcriptionProvider).toBe('Deepgram');
774
+ expect(result.calls.voice.fallbackToStandardOnError).toBe(true);
775
+ expect(result.calls.voice.elevenlabs.voiceId).toBe('');
776
+ expect(result.calls.voice.elevenlabs.voiceModelId).toBe('turbo_v2_5');
777
+ expect(result.calls.voice.elevenlabs.stability).toBe(0.5);
778
+ expect(result.calls.voice.elevenlabs.similarityBoost).toBe(0.75);
779
+ expect(result.calls.voice.elevenlabs.style).toBe(0.0);
780
+ expect(result.calls.voice.elevenlabs.useSpeakerBoost).toBe(true);
781
+ expect(result.calls.voice.elevenlabs.agentId).toBe('');
782
+ expect(result.calls.voice.elevenlabs.apiBaseUrl).toBe('https://api.elevenlabs.io');
783
+ expect(result.calls.voice.elevenlabs.registerCallTimeoutMs).toBe(5000);
784
+ });
785
+
786
+ test('accepts valid calls.voice overrides', () => {
787
+ const result = AssistantConfigSchema.parse({
788
+ calls: {
789
+ voice: {
790
+ mode: 'twilio_elevenlabs_tts',
791
+ language: 'es-ES',
792
+ transcriptionProvider: 'Google',
793
+ fallbackToStandardOnError: false,
794
+ elevenlabs: {
795
+ voiceId: 'abc123',
796
+ stability: 0.8,
797
+ },
798
+ },
799
+ },
800
+ });
801
+ expect(result.calls.voice.mode).toBe('twilio_elevenlabs_tts');
802
+ expect(result.calls.voice.language).toBe('es-ES');
803
+ expect(result.calls.voice.transcriptionProvider).toBe('Google');
804
+ expect(result.calls.voice.fallbackToStandardOnError).toBe(false);
805
+ expect(result.calls.voice.elevenlabs.voiceId).toBe('abc123');
806
+ expect(result.calls.voice.elevenlabs.stability).toBe(0.8);
807
+ // Defaults preserved for unset fields
808
+ expect(result.calls.voice.elevenlabs.voiceModelId).toBe('turbo_v2_5');
809
+ expect(result.calls.voice.elevenlabs.similarityBoost).toBe(0.75);
810
+ });
811
+
812
+ test('rejects invalid calls.voice.mode', () => {
813
+ const result = AssistantConfigSchema.safeParse({
814
+ calls: { voice: { mode: 'invalid_mode' } },
815
+ });
816
+ expect(result.success).toBe(false);
817
+ if (!result.success) {
818
+ const msgs = result.error.issues.map(i => i.message);
819
+ expect(msgs.some(m => m.includes('calls.voice.mode'))).toBe(true);
820
+ }
821
+ });
822
+
823
+ test('rejects invalid calls.voice.transcriptionProvider', () => {
824
+ const result = AssistantConfigSchema.safeParse({
825
+ calls: { voice: { transcriptionProvider: 'AWS' } },
826
+ });
827
+ expect(result.success).toBe(false);
828
+ if (!result.success) {
829
+ const msgs = result.error.issues.map(i => i.message);
830
+ expect(msgs.some(m => m.includes('calls.voice.transcriptionProvider'))).toBe(true);
831
+ }
832
+ });
833
+
834
+ test('rejects calls.voice.elevenlabs.stability out of range', () => {
835
+ const result = AssistantConfigSchema.safeParse({
836
+ calls: { voice: { elevenlabs: { stability: 1.5 } } },
837
+ });
838
+ expect(result.success).toBe(false);
839
+ });
840
+
841
+ test('rejects calls.voice.elevenlabs.registerCallTimeoutMs below 1000', () => {
842
+ const result = AssistantConfigSchema.safeParse({
843
+ calls: { voice: { elevenlabs: { registerCallTimeoutMs: 500 } } },
844
+ });
845
+ expect(result.success).toBe(false);
846
+ });
847
+
848
+ test('rejects calls.voice.elevenlabs.registerCallTimeoutMs above 15000', () => {
849
+ const result = AssistantConfigSchema.safeParse({
850
+ calls: { voice: { elevenlabs: { registerCallTimeoutMs: 20000 } } },
851
+ });
852
+ expect(result.success).toBe(false);
853
+ });
854
+
855
+ test('accepts optional calls.model', () => {
856
+ const result = AssistantConfigSchema.parse({
857
+ calls: { model: 'claude-haiku-4-5-20251001' },
858
+ });
859
+ expect(result.calls.model).toBe('claude-haiku-4-5-20251001');
860
+ });
861
+
862
+ test('calls.model is undefined by default', () => {
863
+ const result = AssistantConfigSchema.parse({});
864
+ expect(result.calls.model).toBeUndefined();
865
+ });
866
+ });
867
+
868
+ // ---------------------------------------------------------------------------
869
+ // Tests: Voice quality profile resolver
870
+ // ---------------------------------------------------------------------------
871
+
872
+ describe('resolveVoiceQualityProfile', () => {
873
+ test('returns correct profile for twilio_standard', () => {
874
+ const config = AssistantConfigSchema.parse({});
875
+ const profile = resolveVoiceQualityProfile(config);
876
+ expect(profile.mode).toBe('twilio_standard');
877
+ expect(profile.ttsProvider).toBe('Google');
878
+ expect(profile.voice).toBe('Google.en-US-Journey-O');
879
+ expect(profile.transcriptionProvider).toBe('Deepgram');
880
+ expect(profile.fallbackToStandardOnError).toBe(true);
881
+ expect(profile.validationErrors).toEqual([]);
882
+ });
883
+
884
+ test('returns correct profile for twilio_elevenlabs_tts with valid voiceId', () => {
885
+ const config = AssistantConfigSchema.parse({
886
+ calls: {
887
+ voice: {
888
+ mode: 'twilio_elevenlabs_tts',
889
+ elevenlabs: { voiceId: 'test-voice-id' },
890
+ },
891
+ },
892
+ });
893
+ const profile = resolveVoiceQualityProfile(config);
894
+ expect(profile.mode).toBe('twilio_elevenlabs_tts');
895
+ expect(profile.ttsProvider).toBe('ElevenLabs');
896
+ expect(profile.voice).toBe('test-voice-id-turbo_v2_5-0.5_0.75_0');
897
+ expect(profile.validationErrors).toEqual([]);
898
+ });
899
+
900
+ test('falls back for twilio_elevenlabs_tts with empty voiceId and fallback enabled', () => {
901
+ const config = AssistantConfigSchema.parse({
902
+ calls: {
903
+ voice: {
904
+ mode: 'twilio_elevenlabs_tts',
905
+ fallbackToStandardOnError: true,
906
+ elevenlabs: { voiceId: '' },
907
+ },
908
+ },
909
+ });
910
+ const profile = resolveVoiceQualityProfile(config);
911
+ expect(profile.mode).toBe('twilio_standard');
912
+ expect(profile.ttsProvider).toBe('Google');
913
+ expect(profile.voice).toBe('Google.en-US-Journey-O');
914
+ expect(profile.validationErrors.length).toBe(1);
915
+ expect(profile.validationErrors[0]).toContain('falling back');
916
+ });
917
+
918
+ test('returns errors for twilio_elevenlabs_tts with empty voiceId and fallback disabled', () => {
919
+ const config = AssistantConfigSchema.parse({
920
+ calls: {
921
+ voice: {
922
+ mode: 'twilio_elevenlabs_tts',
923
+ fallbackToStandardOnError: false,
924
+ elevenlabs: { voiceId: '' },
925
+ },
926
+ },
927
+ });
928
+ const profile = resolveVoiceQualityProfile(config);
929
+ expect(profile.mode).toBe('twilio_elevenlabs_tts');
930
+ expect(profile.validationErrors.length).toBe(1);
931
+ expect(profile.validationErrors[0]).toContain('voiceId is required');
932
+ });
933
+
934
+ test('returns correct profile for elevenlabs_agent with valid agentId', () => {
935
+ const config = AssistantConfigSchema.parse({
936
+ calls: {
937
+ voice: {
938
+ mode: 'elevenlabs_agent',
939
+ elevenlabs: { agentId: 'agent-123', voiceId: 'v1' },
940
+ },
941
+ },
942
+ });
943
+ const profile = resolveVoiceQualityProfile(config);
944
+ expect(profile.mode).toBe('elevenlabs_agent');
945
+ expect(profile.ttsProvider).toBe('ElevenLabs');
946
+ expect(profile.voice).toBe('v1-turbo_v2_5-0.5_0.75_0');
947
+ expect(profile.agentId).toBe('agent-123');
948
+ expect(profile.validationErrors).toEqual([]);
949
+ });
950
+
951
+ test('falls back for elevenlabs_agent with empty agentId and fallback enabled', () => {
952
+ const config = AssistantConfigSchema.parse({
953
+ calls: {
954
+ voice: {
955
+ mode: 'elevenlabs_agent',
956
+ fallbackToStandardOnError: true,
957
+ elevenlabs: { agentId: '' },
958
+ },
959
+ },
960
+ });
961
+ const profile = resolveVoiceQualityProfile(config);
962
+ expect(profile.mode).toBe('twilio_standard');
963
+ expect(profile.validationErrors.length).toBe(1);
964
+ expect(profile.validationErrors[0]).toContain('agentId is empty');
965
+ });
966
+
967
+ test('returns errors for elevenlabs_agent with empty agentId and fallback disabled', () => {
968
+ const config = AssistantConfigSchema.parse({
969
+ calls: {
970
+ voice: {
971
+ mode: 'elevenlabs_agent',
972
+ fallbackToStandardOnError: false,
973
+ elevenlabs: { agentId: '' },
974
+ },
975
+ },
976
+ });
977
+ const profile = resolveVoiceQualityProfile(config);
978
+ expect(profile.mode).toBe('elevenlabs_agent');
979
+ expect(profile.validationErrors.length).toBe(1);
980
+ expect(profile.validationErrors[0]).toContain('agentId is required');
981
+ });
982
+ });
983
+
984
+ // ---------------------------------------------------------------------------
985
+ // Tests: buildElevenLabsVoiceSpec
986
+ // ---------------------------------------------------------------------------
987
+
988
+ describe('buildElevenLabsVoiceSpec', () => {
989
+ test('produces correct voice string with all params', () => {
990
+ const spec = buildElevenLabsVoiceSpec({
991
+ voiceId: 'abc123',
992
+ voiceModelId: 'turbo_v2_5',
993
+ stability: 0.5,
994
+ similarityBoost: 0.75,
995
+ style: 0.0,
996
+ });
997
+ expect(spec).toBe('abc123-turbo_v2_5-0.5_0.75_0');
998
+ });
999
+
1000
+ test('returns empty string when voiceId is empty', () => {
1001
+ const spec = buildElevenLabsVoiceSpec({
1002
+ voiceId: '',
1003
+ voiceModelId: 'turbo_v2_5',
1004
+ stability: 0.5,
1005
+ similarityBoost: 0.75,
1006
+ style: 0.0,
1007
+ });
1008
+ expect(spec).toBe('');
1009
+ });
1010
+
1011
+ test('formats custom parameters correctly', () => {
1012
+ const spec = buildElevenLabsVoiceSpec({
1013
+ voiceId: 'myVoice',
1014
+ voiceModelId: 'eleven_multilingual_v2',
1015
+ stability: 0.8,
1016
+ similarityBoost: 0.9,
1017
+ style: 0.3,
1018
+ });
1019
+ expect(spec).toBe('myVoice-eleven_multilingual_v2-0.8_0.9_0.3');
1020
+ });
748
1021
  });
749
1022
 
750
1023
  // ---------------------------------------------------------------------------
@@ -994,6 +1267,11 @@ describe('loadConfig with schema validation', () => {
994
1267
  expect(config.calls.userConsultTimeoutSeconds).toBe(120);
995
1268
  expect(config.calls.disclosure.enabled).toBe(true);
996
1269
  expect(config.calls.safety.denyCategories).toEqual([]);
1270
+ expect(config.calls.voice.mode).toBe('twilio_standard');
1271
+ expect(config.calls.voice.language).toBe('en-US');
1272
+ expect(config.calls.voice.transcriptionProvider).toBe('Deepgram');
1273
+ expect(config.calls.voice.elevenlabs.voiceId).toBe('');
1274
+ expect(config.calls.model).toBeUndefined();
997
1275
  });
998
1276
  });
999
1277
 
@@ -0,0 +1,209 @@
1
+ import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test';
2
+
3
+ mock.module('../util/logger.js', () => ({
4
+ getLogger: () =>
5
+ new Proxy({} as Record<string, unknown>, {
6
+ get: () => () => {},
7
+ }),
8
+ }));
9
+
10
+ import {
11
+ ElevenLabsClient,
12
+ ElevenLabsError,
13
+ type ElevenLabsClientOptions,
14
+ type ElevenLabsRegisterCallRequest,
15
+ } from '../calls/elevenlabs-client.js';
16
+
17
+ // ── Helpers ────────────────────────────────────────────────────────────
18
+
19
+ const DEFAULT_OPTIONS: ElevenLabsClientOptions = {
20
+ apiBaseUrl: 'https://api.elevenlabs.io',
21
+ apiKey: 'test-api-key-secret',
22
+ timeoutMs: 5000,
23
+ };
24
+
25
+ const DEFAULT_REQUEST: ElevenLabsRegisterCallRequest = {
26
+ agent_id: 'agent-123',
27
+ from_number: '+15551111111',
28
+ to_number: '+15552222222',
29
+ direction: 'outbound',
30
+ };
31
+
32
+ let originalFetch: typeof globalThis.fetch;
33
+
34
+ beforeEach(() => {
35
+ originalFetch = globalThis.fetch;
36
+ });
37
+
38
+ afterEach(() => {
39
+ globalThis.fetch = originalFetch;
40
+ });
41
+
42
+ // ── Tests ──────────────────────────────────────────────────────────────
43
+
44
+ describe('ElevenLabsClient', () => {
45
+ describe('registerCall', () => {
46
+ test('successful register-call returns TwiML', async () => {
47
+ const twimlResponse = '<?xml version="1.0"?><Response><Connect><Stream url="wss://el.io/stream"/></Connect></Response>';
48
+
49
+ globalThis.fetch = mock(async () =>
50
+ new Response(twimlResponse, { status: 200 }),
51
+ ) as unknown as typeof globalThis.fetch;
52
+
53
+ const client = new ElevenLabsClient(DEFAULT_OPTIONS);
54
+ const result = await client.registerCall(DEFAULT_REQUEST);
55
+
56
+ expect(result.twiml).toBe(twimlResponse);
57
+ });
58
+
59
+ test('passes xi-api-key header in request', async () => {
60
+ let capturedHeaders: Headers | null = null;
61
+
62
+ globalThis.fetch = mock(async (url: string | URL | Request, init?: RequestInit) => {
63
+ capturedHeaders = new Headers(init?.headers);
64
+ return new Response('<Response/>', { status: 200 });
65
+ }) as unknown as typeof globalThis.fetch;
66
+
67
+ const client = new ElevenLabsClient(DEFAULT_OPTIONS);
68
+ await client.registerCall(DEFAULT_REQUEST);
69
+
70
+ expect(capturedHeaders).not.toBeNull();
71
+ expect(capturedHeaders!.get('xi-api-key')).toBe('test-api-key-secret');
72
+ expect(capturedHeaders!.get('Content-Type')).toBe('application/json');
73
+ });
74
+
75
+ test('sends correct URL and request body', async () => {
76
+ let capturedUrl = '';
77
+ let capturedBody = '';
78
+
79
+ globalThis.fetch = mock(async (url: string | URL | Request, init?: RequestInit) => {
80
+ capturedUrl = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
81
+ capturedBody = typeof init?.body === 'string' ? init.body : '';
82
+ return new Response('<Response/>', { status: 200 });
83
+ }) as unknown as typeof globalThis.fetch;
84
+
85
+ const client = new ElevenLabsClient(DEFAULT_OPTIONS);
86
+ await client.registerCall(DEFAULT_REQUEST);
87
+
88
+ expect(capturedUrl).toBe('https://api.elevenlabs.io/v1/convai/twilio/register-call');
89
+ const parsed = JSON.parse(capturedBody);
90
+ expect(parsed.agent_id).toBe('agent-123');
91
+ expect(parsed.from_number).toBe('+15551111111');
92
+ expect(parsed.to_number).toBe('+15552222222');
93
+ expect(parsed.direction).toBe('outbound');
94
+ });
95
+
96
+ test('non-2xx response throws ELEVENLABS_HTTP_ERROR', async () => {
97
+ globalThis.fetch = mock(async () =>
98
+ new Response('Internal Server Error', { status: 500 }),
99
+ ) as unknown as typeof globalThis.fetch;
100
+
101
+ const client = new ElevenLabsClient(DEFAULT_OPTIONS);
102
+
103
+ try {
104
+ await client.registerCall(DEFAULT_REQUEST);
105
+ expect(true).toBe(false); // Should not reach here
106
+ } catch (err) {
107
+ expect(err).toBeInstanceOf(ElevenLabsError);
108
+ const elErr = err as ElevenLabsError;
109
+ expect(elErr.code).toBe('ELEVENLABS_HTTP_ERROR');
110
+ expect(elErr.statusCode).toBe(500);
111
+ expect(elErr.message).toContain('500');
112
+ }
113
+ });
114
+
115
+ test('empty response throws ELEVENLABS_INVALID_RESPONSE', async () => {
116
+ globalThis.fetch = mock(async () =>
117
+ new Response('', { status: 200 }),
118
+ ) as unknown as typeof globalThis.fetch;
119
+
120
+ const client = new ElevenLabsClient(DEFAULT_OPTIONS);
121
+
122
+ try {
123
+ await client.registerCall(DEFAULT_REQUEST);
124
+ expect(true).toBe(false); // Should not reach here
125
+ } catch (err) {
126
+ expect(err).toBeInstanceOf(ElevenLabsError);
127
+ const elErr = err as ElevenLabsError;
128
+ expect(elErr.code).toBe('ELEVENLABS_INVALID_RESPONSE');
129
+ }
130
+ });
131
+
132
+ test('whitespace-only response throws ELEVENLABS_INVALID_RESPONSE', async () => {
133
+ globalThis.fetch = mock(async () =>
134
+ new Response(' \n ', { status: 200 }),
135
+ ) as unknown as typeof globalThis.fetch;
136
+
137
+ const client = new ElevenLabsClient(DEFAULT_OPTIONS);
138
+
139
+ try {
140
+ await client.registerCall(DEFAULT_REQUEST);
141
+ expect(true).toBe(false); // Should not reach here
142
+ } catch (err) {
143
+ expect(err).toBeInstanceOf(ElevenLabsError);
144
+ const elErr = err as ElevenLabsError;
145
+ expect(elErr.code).toBe('ELEVENLABS_INVALID_RESPONSE');
146
+ }
147
+ });
148
+
149
+ test('timeout throws ELEVENLABS_TIMEOUT', async () => {
150
+ globalThis.fetch = mock(async (_url: string | URL | Request, init?: RequestInit) => {
151
+ // Wait longer than the timeout
152
+ return new Promise<Response>((resolve, reject) => {
153
+ const timer = setTimeout(() => resolve(new Response('<Response/>', { status: 200 })), 10000);
154
+ init?.signal?.addEventListener('abort', () => {
155
+ clearTimeout(timer);
156
+ reject(new DOMException('The operation was aborted.', 'AbortError'));
157
+ });
158
+ });
159
+ }) as unknown as typeof globalThis.fetch;
160
+
161
+ const client = new ElevenLabsClient({ ...DEFAULT_OPTIONS, timeoutMs: 50 });
162
+
163
+ try {
164
+ await client.registerCall(DEFAULT_REQUEST);
165
+ expect(true).toBe(false); // Should not reach here
166
+ } catch (err) {
167
+ expect(err).toBeInstanceOf(ElevenLabsError);
168
+ const elErr = err as ElevenLabsError;
169
+ expect(elErr.code).toBe('ELEVENLABS_TIMEOUT');
170
+ expect(elErr.message).toContain('50ms');
171
+ }
172
+ });
173
+
174
+ test('network error throws ELEVENLABS_HTTP_ERROR', async () => {
175
+ globalThis.fetch = mock(async () => {
176
+ throw new TypeError('Failed to fetch');
177
+ }) as unknown as typeof globalThis.fetch;
178
+
179
+ const client = new ElevenLabsClient(DEFAULT_OPTIONS);
180
+
181
+ try {
182
+ await client.registerCall(DEFAULT_REQUEST);
183
+ expect(true).toBe(false); // Should not reach here
184
+ } catch (err) {
185
+ expect(err).toBeInstanceOf(ElevenLabsError);
186
+ const elErr = err as ElevenLabsError;
187
+ expect(elErr.code).toBe('ELEVENLABS_HTTP_ERROR');
188
+ expect(elErr.message).toContain('Failed to fetch');
189
+ }
190
+ });
191
+
192
+ test('API key is not included in logged data', async () => {
193
+ // The ElevenLabsClient logs agent_id and direction, but should never log the API key.
194
+ // We verify this by checking the request structure, not the log output.
195
+ let capturedBody = '';
196
+
197
+ globalThis.fetch = mock(async (_url: string | URL | Request, init?: RequestInit) => {
198
+ capturedBody = typeof init?.body === 'string' ? init.body : '';
199
+ return new Response('<Response/>', { status: 200 });
200
+ }) as unknown as typeof globalThis.fetch;
201
+
202
+ const client = new ElevenLabsClient(DEFAULT_OPTIONS);
203
+ await client.registerCall(DEFAULT_REQUEST);
204
+
205
+ // The request body should not contain the API key
206
+ expect(capturedBody).not.toContain('test-api-key-secret');
207
+ });
208
+ });
209
+ });
@@ -741,6 +741,7 @@ describe('Trust Store', () => {
741
741
  'host_file_edit',
742
742
  'host_file_read',
743
743
  'host_file_write',
744
+ 'memory_search',
744
745
  'scaffold_managed_skill',
745
746
  'skill_load',
746
747
  'ui_dismiss',