vellum 0.2.12 → 0.2.14

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 (209) hide show
  1. package/README.md +32 -0
  2. package/bun.lock +2 -2
  3. package/docs/skills.md +4 -4
  4. package/package.json +2 -2
  5. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +213 -3
  6. package/src/__tests__/app-git-history.test.ts +176 -0
  7. package/src/__tests__/app-git-service.test.ts +169 -0
  8. package/src/__tests__/assistant-events-sse-hardening.test.ts +315 -0
  9. package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +8 -8
  10. package/src/__tests__/browser-skill-endstate.test.ts +6 -6
  11. package/src/__tests__/call-bridge.test.ts +105 -13
  12. package/src/__tests__/call-domain.test.ts +163 -0
  13. package/src/__tests__/call-orchestrator.test.ts +171 -0
  14. package/src/__tests__/call-routes-http.test.ts +246 -6
  15. package/src/__tests__/channel-approval-routes.test.ts +438 -0
  16. package/src/__tests__/channel-approval.test.ts +266 -0
  17. package/src/__tests__/channel-approvals.test.ts +393 -0
  18. package/src/__tests__/channel-delivery-store.test.ts +447 -0
  19. package/src/__tests__/checker.test.ts +607 -1048
  20. package/src/__tests__/cli.test.ts +1 -56
  21. package/src/__tests__/config-schema.test.ts +402 -5
  22. package/src/__tests__/conflict-intent-tokenization.test.ts +141 -0
  23. package/src/__tests__/conflict-policy.test.ts +121 -0
  24. package/src/__tests__/conflict-store.test.ts +2 -0
  25. package/src/__tests__/contacts-tools.test.ts +3 -3
  26. package/src/__tests__/contradiction-checker.test.ts +99 -1
  27. package/src/__tests__/credential-security-invariants.test.ts +22 -6
  28. package/src/__tests__/credential-vault-unit.test.ts +780 -0
  29. package/src/__tests__/elevenlabs-client.test.ts +271 -0
  30. package/src/__tests__/ephemeral-permissions.test.ts +73 -23
  31. package/src/__tests__/filesystem-tools.test.ts +579 -0
  32. package/src/__tests__/gateway-only-enforcement.test.ts +114 -4
  33. package/src/__tests__/handlers-add-trust-rule-metadata.test.ts +202 -0
  34. package/src/__tests__/handlers-cu-observation-blob.test.ts +2 -1
  35. package/src/__tests__/handlers-ipc-blob-probe.test.ts +2 -1
  36. package/src/__tests__/handlers-slack-config.test.ts +2 -1
  37. package/src/__tests__/handlers-telegram-config.test.ts +855 -0
  38. package/src/__tests__/handlers-twitter-config.test.ts +141 -1
  39. package/src/__tests__/hooks-runner.test.ts +6 -2
  40. package/src/__tests__/host-file-edit-tool.test.ts +124 -0
  41. package/src/__tests__/host-file-read-tool.test.ts +62 -0
  42. package/src/__tests__/host-file-write-tool.test.ts +59 -0
  43. package/src/__tests__/host-shell-tool.test.ts +251 -0
  44. package/src/__tests__/ingress-reconcile.test.ts +581 -0
  45. package/src/__tests__/ipc-snapshot.test.ts +100 -41
  46. package/src/__tests__/ipc-validate.test.ts +50 -0
  47. package/src/__tests__/key-migration.test.ts +23 -0
  48. package/src/__tests__/memory-regressions.test.ts +99 -0
  49. package/src/__tests__/memory-retrieval.benchmark.test.ts +1 -1
  50. package/src/__tests__/oauth-callback-registry.test.ts +11 -4
  51. package/src/__tests__/playbook-execution.test.ts +502 -0
  52. package/src/__tests__/playbook-tools.test.ts +4 -6
  53. package/src/__tests__/public-ingress-urls.test.ts +34 -0
  54. package/src/__tests__/qdrant-manager.test.ts +267 -0
  55. package/src/__tests__/recurrence-engine-rruleset.test.ts +97 -0
  56. package/src/__tests__/recurrence-engine.test.ts +9 -0
  57. package/src/__tests__/recurrence-types.test.ts +8 -0
  58. package/src/__tests__/registry.test.ts +1 -1
  59. package/src/__tests__/runtime-runs.test.ts +1 -25
  60. package/src/__tests__/schedule-store.test.ts +16 -14
  61. package/src/__tests__/schedule-tools.test.ts +83 -0
  62. package/src/__tests__/scheduler-recurrence.test.ts +111 -10
  63. package/src/__tests__/secret-allowlist.test.ts +18 -17
  64. package/src/__tests__/secret-ingress-handler.test.ts +11 -0
  65. package/src/__tests__/secret-scanner.test.ts +43 -0
  66. package/src/__tests__/session-conflict-gate.test.ts +442 -6
  67. package/src/__tests__/session-init.benchmark.test.ts +3 -0
  68. package/src/__tests__/session-process-bridge.test.ts +242 -0
  69. package/src/__tests__/session-skill-tools.test.ts +1 -1
  70. package/src/__tests__/shell-identity.test.ts +256 -0
  71. package/src/__tests__/skill-projection.benchmark.test.ts +11 -1
  72. package/src/__tests__/subagent-tools.test.ts +637 -54
  73. package/src/__tests__/task-management-tools.test.ts +936 -0
  74. package/src/__tests__/task-runner.test.ts +2 -2
  75. package/src/__tests__/terminal-tools.test.ts +840 -0
  76. package/src/__tests__/tool-executor-shell-integration.test.ts +301 -0
  77. package/src/__tests__/tool-executor.test.ts +85 -151
  78. package/src/__tests__/tool-permission-simulate-handler.test.ts +336 -0
  79. package/src/__tests__/trust-store.test.ts +28 -453
  80. package/src/__tests__/twilio-provider.test.ts +153 -3
  81. package/src/__tests__/twilio-routes-elevenlabs.test.ts +375 -0
  82. package/src/__tests__/twilio-routes-twiml.test.ts +127 -0
  83. package/src/__tests__/twilio-routes.test.ts +17 -262
  84. package/src/__tests__/twitter-auth-handler.test.ts +2 -1
  85. package/src/__tests__/twitter-cli-error-shaping.test.ts +208 -0
  86. package/src/__tests__/twitter-cli-routing.test.ts +252 -0
  87. package/src/__tests__/twitter-oauth-client.test.ts +209 -0
  88. package/src/__tests__/workspace-policy.test.ts +213 -0
  89. package/src/calls/call-bridge.ts +92 -19
  90. package/src/calls/call-domain.ts +157 -5
  91. package/src/calls/call-orchestrator.ts +96 -8
  92. package/src/calls/call-store.ts +6 -0
  93. package/src/calls/elevenlabs-client.ts +97 -0
  94. package/src/calls/elevenlabs-config.ts +31 -0
  95. package/src/calls/twilio-provider.ts +91 -0
  96. package/src/calls/twilio-routes.ts +50 -6
  97. package/src/calls/types.ts +3 -1
  98. package/src/calls/voice-quality.ts +114 -0
  99. package/src/cli/twitter.ts +200 -21
  100. package/src/cli.ts +1 -20
  101. package/src/config/bundled-skills/contacts/tools/contact-merge.ts +52 -4
  102. package/src/config/bundled-skills/contacts/tools/contact-search.ts +55 -4
  103. package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +61 -4
  104. package/src/config/bundled-skills/messaging/SKILL.md +17 -2
  105. package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +4 -1
  106. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -1
  107. package/src/config/bundled-skills/messaging/tools/shared.ts +5 -0
  108. package/src/config/bundled-skills/phone-calls/SKILL.md +207 -19
  109. package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +95 -6
  110. package/src/config/bundled-skills/playbooks/tools/playbook-delete.ts +51 -6
  111. package/src/config/bundled-skills/playbooks/tools/playbook-list.ts +73 -6
  112. package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +110 -6
  113. package/src/config/bundled-skills/public-ingress/SKILL.md +22 -5
  114. package/src/config/bundled-skills/twitter/SKILL.md +103 -17
  115. package/src/config/defaults.ts +26 -2
  116. package/src/config/schema.ts +178 -9
  117. package/src/config/types.ts +3 -0
  118. package/src/config/vellum-skills/telegram-setup/SKILL.md +56 -61
  119. package/src/daemon/assistant-attachments.ts +4 -2
  120. package/src/daemon/handlers/apps.ts +69 -0
  121. package/src/daemon/handlers/config.ts +543 -24
  122. package/src/daemon/handlers/index.ts +1 -0
  123. package/src/daemon/handlers/sessions.ts +22 -6
  124. package/src/daemon/handlers/shared.ts +2 -1
  125. package/src/daemon/handlers/skills.ts +5 -20
  126. package/src/daemon/ipc-contract-inventory.json +28 -0
  127. package/src/daemon/ipc-contract.ts +168 -10
  128. package/src/daemon/ipc-validate.ts +17 -0
  129. package/src/daemon/lifecycle.ts +2 -0
  130. package/src/daemon/server.ts +78 -72
  131. package/src/daemon/session-attachments.ts +1 -1
  132. package/src/daemon/session-conflict-gate.ts +62 -6
  133. package/src/daemon/session-notifiers.ts +1 -1
  134. package/src/daemon/session-process.ts +62 -3
  135. package/src/daemon/session-tool-setup.ts +1 -2
  136. package/src/daemon/tls-certs.ts +189 -0
  137. package/src/daemon/video-thumbnail.ts +5 -3
  138. package/src/hooks/manager.ts +5 -9
  139. package/src/memory/app-git-service.ts +295 -0
  140. package/src/memory/app-store.ts +21 -0
  141. package/src/memory/conflict-intent.ts +47 -4
  142. package/src/memory/conflict-policy.ts +73 -0
  143. package/src/memory/conflict-store.ts +9 -1
  144. package/src/memory/contradiction-checker.ts +28 -0
  145. package/src/memory/conversation-key-store.ts +15 -0
  146. package/src/memory/db.ts +81 -0
  147. package/src/memory/embedding-local.ts +3 -13
  148. package/src/memory/external-conversation-store.ts +234 -0
  149. package/src/memory/job-handlers/conflict.ts +22 -2
  150. package/src/memory/jobs-worker.ts +67 -28
  151. package/src/memory/runs-store.ts +54 -7
  152. package/src/memory/schema.ts +20 -0
  153. package/src/messaging/provider.ts +9 -0
  154. package/src/messaging/providers/telegram-bot/adapter.ts +162 -0
  155. package/src/messaging/providers/telegram-bot/client.ts +104 -0
  156. package/src/messaging/providers/telegram-bot/types.ts +15 -0
  157. package/src/messaging/registry.ts +1 -0
  158. package/src/permissions/checker.ts +48 -44
  159. package/src/permissions/defaults.ts +11 -0
  160. package/src/permissions/prompter.ts +0 -4
  161. package/src/permissions/shell-identity.ts +227 -0
  162. package/src/permissions/trust-store.ts +76 -53
  163. package/src/permissions/types.ts +0 -19
  164. package/src/permissions/workspace-policy.ts +114 -0
  165. package/src/providers/retry.ts +12 -37
  166. package/src/runtime/assistant-event-hub.ts +41 -4
  167. package/src/runtime/channel-approval-parser.ts +60 -0
  168. package/src/runtime/channel-approval-types.ts +71 -0
  169. package/src/runtime/channel-approvals.ts +145 -0
  170. package/src/runtime/gateway-client.ts +16 -0
  171. package/src/runtime/http-server.ts +29 -9
  172. package/src/runtime/routes/call-routes.ts +52 -2
  173. package/src/runtime/routes/channel-routes.ts +296 -16
  174. package/src/runtime/routes/conversation-routes.ts +12 -5
  175. package/src/runtime/routes/events-routes.ts +97 -28
  176. package/src/runtime/routes/run-routes.ts +2 -7
  177. package/src/runtime/run-orchestrator.ts +0 -3
  178. package/src/schedule/recurrence-engine.ts +26 -2
  179. package/src/schedule/recurrence-types.ts +1 -1
  180. package/src/schedule/schedule-store.ts +12 -3
  181. package/src/security/secret-scanner.ts +7 -0
  182. package/src/tasks/ephemeral-permissions.ts +0 -2
  183. package/src/tasks/task-scheduler.ts +2 -1
  184. package/src/tools/calls/call-start.ts +8 -0
  185. package/src/tools/execution-target.ts +21 -0
  186. package/src/tools/execution-timeout.ts +49 -0
  187. package/src/tools/executor.ts +6 -135
  188. package/src/tools/network/web-search.ts +9 -32
  189. package/src/tools/policy-context.ts +29 -0
  190. package/src/tools/schedule/update.ts +8 -1
  191. package/src/tools/terminal/parser.ts +16 -18
  192. package/src/tools/types.ts +4 -11
  193. package/src/twitter/oauth-client.ts +102 -0
  194. package/src/twitter/router.ts +101 -0
  195. package/src/util/debounce.ts +88 -0
  196. package/src/util/network-info.ts +47 -0
  197. package/src/util/platform.ts +29 -4
  198. package/src/util/promise-guard.ts +37 -0
  199. package/src/util/retry.ts +98 -0
  200. package/src/util/truncate.ts +1 -1
  201. package/src/workspace/git-service.ts +129 -112
  202. package/src/tools/contacts/contact-merge.ts +0 -55
  203. package/src/tools/contacts/contact-search.ts +0 -58
  204. package/src/tools/contacts/contact-upsert.ts +0 -64
  205. package/src/tools/playbooks/index.ts +0 -4
  206. package/src/tools/playbooks/playbook-create.ts +0 -96
  207. package/src/tools/playbooks/playbook-delete.ts +0 -52
  208. package/src/tools/playbooks/playbook-list.ts +0 -74
  209. package/src/tools/playbooks/playbook-update.ts +0 -111
@@ -101,7 +101,7 @@ import {
101
101
  fireCallCompletionNotifier,
102
102
  } from '../calls/call-state.js';
103
103
  import { CallOrchestrator } from '../calls/call-orchestrator.js';
104
- import { tryHandlePendingCallAnswer } from '../calls/call-bridge.js';
104
+ import { tryRouteCallMessage } from '../calls/call-bridge.js';
105
105
  import * as conversationStore from '../memory/conversation-store.js';
106
106
  import type { RelayConnection } from '../calls/relay-server.js';
107
107
 
@@ -177,26 +177,27 @@ describe('call-bridge', () => {
177
177
  mockStreamFn.mockImplementation(() => createMockStream(['Hello']));
178
178
  });
179
179
 
180
- // ── tryHandlePendingCallAnswer ──────────────────────────────────
180
+ // ── tryRouteCallMessage — answer path ───────────────────────
181
181
 
182
182
  test('returns handled:false when no active call exists', async () => {
183
183
  ensureConversation('conv-no-call');
184
- const result = await tryHandlePendingCallAnswer('conv-no-call', 'some answer');
184
+ const result = await tryRouteCallMessage('conv-no-call', 'some answer');
185
185
  expect(result.handled).toBe(false);
186
186
  expect(result.reason).toBe('no_active_call');
187
187
  });
188
188
 
189
- test('returns handled:false when call exists but no pending question', async () => {
190
- ensureConversation('conv-no-question');
189
+ test('returns instruction_relay_failed (consumed) when call exists but no orchestrator and no pending question', async () => {
190
+ ensureConversation('conv-no-orch');
191
191
  createCallSession({
192
- conversationId: 'conv-no-question',
192
+ conversationId: 'conv-no-orch',
193
193
  provider: 'twilio',
194
194
  fromNumber: '+15551111111',
195
195
  toNumber: '+15552222222',
196
196
  });
197
- const result = await tryHandlePendingCallAnswer('conv-no-question', 'some answer');
198
- expect(result.handled).toBe(false);
199
- expect(result.reason).toBe('no_pending_question');
197
+ const result = await tryRouteCallMessage('conv-no-orch', 'some instruction');
198
+ expect(result.handled).toBe(true);
199
+ expect(result.reason).toBe('instruction_relay_failed');
200
+ expect(result.userFacingText).toBe('Failed to relay instruction to the active call.');
200
201
  });
201
202
 
202
203
  test('returns handled:false when orchestrator is not found (call still active but no orchestrator)', async () => {
@@ -215,7 +216,7 @@ describe('call-bridge', () => {
215
216
  // Create a pending question without an orchestrator
216
217
  createPendingQuestion(callSession.id, 'What time?');
217
218
 
218
- const result = await tryHandlePendingCallAnswer('conv-ended', 'Too late');
219
+ const result = await tryRouteCallMessage('conv-ended', 'Too late');
219
220
  expect(result.handled).toBe(false);
220
221
  expect(result.reason).toBe('orchestrator_not_found');
221
222
  });
@@ -231,7 +232,7 @@ describe('call-bridge', () => {
231
232
  // Mark the call as completed — getActiveCallSessionForConversation will return null
232
233
  updateCallSession(callSession.id, { status: 'completed', endedAt: Date.now() });
233
234
 
234
- const result = await tryHandlePendingCallAnswer('conv-completed', 'Too late');
235
+ const result = await tryRouteCallMessage('conv-completed', 'Too late');
235
236
  expect(result.handled).toBe(false);
236
237
  expect(result.reason).toBe('no_active_call');
237
238
  });
@@ -252,7 +253,7 @@ describe('call-bridge', () => {
252
253
  // Create a pending question in the DB but orchestrator is idle, not waiting_on_user
253
254
  createPendingQuestion(callSession.id, 'What time?');
254
255
 
255
- const result = await tryHandlePendingCallAnswer('conv-not-waiting', 'answer');
256
+ const result = await tryRouteCallMessage('conv-not-waiting', 'answer');
256
257
  expect(result.handled).toBe(false);
257
258
  expect(result.reason).toBe('orchestrator_not_waiting');
258
259
 
@@ -284,7 +285,7 @@ describe('call-bridge', () => {
284
285
  // Now provide the answer — set up mock for the LLM call after answer
285
286
  mockStreamFn.mockImplementation(() => createMockStream(['Great, booking for tomorrow.']));
286
287
 
287
- const result = await tryHandlePendingCallAnswer('conv-bridge', 'Tomorrow at noon');
288
+ const result = await tryRouteCallMessage('conv-bridge', 'Tomorrow at noon');
288
289
  expect(result.handled).toBe(true);
289
290
 
290
291
  // Wait for the fire-and-forget LLM call
@@ -298,6 +299,97 @@ describe('call-bridge', () => {
298
299
  orchestrator.destroy();
299
300
  });
300
301
 
302
+ // ── tryRouteCallMessage — instruction path ────────────────────
303
+
304
+ test('routes instruction to orchestrator when active call exists with no pending question', async () => {
305
+ ensureConversation('conv-instruct');
306
+ const callSession = createCallSession({
307
+ conversationId: 'conv-instruct',
308
+ provider: 'twilio',
309
+ fromNumber: '+15551111111',
310
+ toNumber: '+15552222222',
311
+ });
312
+
313
+ const relay = createMockRelay();
314
+ const orchestrator = new CallOrchestrator(callSession.id, relay as unknown as RelayConnection, 'test task');
315
+
316
+ const result = await tryRouteCallMessage('conv-instruct', 'Please ask about pricing');
317
+ expect(result.handled).toBe(true);
318
+ expect(result.userFacingText).toBe('Instruction relayed to active call.');
319
+
320
+ // Verify acknowledgement was persisted
321
+ const msgs = getMessagesForConversation('conv-instruct');
322
+ const ackMsg = msgs.find((m) => m.content.includes('Instruction relayed'));
323
+ expect(ackMsg).toBeDefined();
324
+ expect(ackMsg!.role).toBe('assistant');
325
+
326
+ orchestrator.destroy();
327
+ });
328
+
329
+ test('prefers answer path over instruction path when pending question exists', async () => {
330
+ // Setup: trigger ASK_USER to put orchestrator in waiting_on_user state
331
+ mockStreamFn.mockImplementation(() =>
332
+ createMockStream(['Hold on. [ASK_USER: Budget range?]']),
333
+ );
334
+
335
+ ensureConversation('conv-prefer-answer');
336
+ const callSession = createCallSession({
337
+ conversationId: 'conv-prefer-answer',
338
+ provider: 'twilio',
339
+ fromNumber: '+15551111111',
340
+ toNumber: '+15552222222',
341
+ });
342
+
343
+ const relay = createMockRelay();
344
+ const orchestrator = new CallOrchestrator(callSession.id, relay as unknown as RelayConnection, 'test task');
345
+
346
+ await orchestrator.handleCallerUtterance('What is your budget?');
347
+ expect(orchestrator.getState()).toBe('waiting_on_user');
348
+
349
+ // Mock the next LLM call
350
+ mockStreamFn.mockImplementation(() => createMockStream(['Got it, thanks.']));
351
+
352
+ // This should route as answer, not instruction
353
+ const result = await tryRouteCallMessage('conv-prefer-answer', '$500');
354
+ expect(result.handled).toBe(true);
355
+
356
+ // Wait for fire-and-forget LLM call
357
+ await new Promise((r) => setTimeout(r, 50));
358
+
359
+ // Should have answered the pending question, not relayed as instruction
360
+ const question = getPendingQuestion(callSession.id);
361
+ expect(question).toBeNull();
362
+
363
+ // No instruction acknowledgement should be persisted
364
+ const msgs = getMessagesForConversation('conv-prefer-answer');
365
+ const ackMsg = msgs.find((m) => m.content.includes('Instruction relayed'));
366
+ expect(ackMsg).toBeUndefined();
367
+
368
+ orchestrator.destroy();
369
+ });
370
+
371
+ test('instruction relay failure persists notice and is consumed (handled:true)', async () => {
372
+ ensureConversation('conv-no-orch-instruct');
373
+ createCallSession({
374
+ conversationId: 'conv-no-orch-instruct',
375
+ provider: 'twilio',
376
+ fromNumber: '+15551111111',
377
+ toNumber: '+15552222222',
378
+ });
379
+
380
+ // No orchestrator registered — relay should fail but still be consumed
381
+ const result = await tryRouteCallMessage('conv-no-orch-instruct', 'Change the topic');
382
+ expect(result.handled).toBe(true);
383
+ expect(result.reason).toBe('instruction_relay_failed');
384
+ expect(result.userFacingText).toBe('Failed to relay instruction to the active call.');
385
+
386
+ // Verify failure notice was persisted in-thread
387
+ const msgs = getMessagesForConversation('conv-no-orch-instruct');
388
+ const failMsg = msgs.find((m) => m.content.includes('Failed to relay'));
389
+ expect(failMsg).toBeDefined();
390
+ expect(failMsg!.role).toBe('assistant');
391
+ });
392
+
301
393
  // ── Call question notifier ──────────────────────────────────────
302
394
 
303
395
  test('call question notifier persists assistant message and emits events', () => {
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Unit tests for caller identity resolution in call-domain.ts.
3
+ *
4
+ * Validates the strict implicit-default policy:
5
+ * - Implicit calls (no explicit mode) always use assistant_number.
6
+ * - Explicit user_number calls succeed when eligible.
7
+ * - Explicit user_number calls fail clearly when missing/ineligible.
8
+ * - Explicit override rejected when allowPerCallOverride=false.
9
+ */
10
+ import { describe, test, expect, mock } from 'bun:test';
11
+ import { mkdtempSync, realpathSync } from 'node:fs';
12
+ import { tmpdir } from 'node:os';
13
+ import { join } from 'node:path';
14
+
15
+ const testDir = realpathSync(mkdtempSync(join(tmpdir(), 'call-domain-test-')));
16
+
17
+ mock.module('../util/platform.js', () => ({
18
+ getRootDir: () => testDir,
19
+ getDataDir: () => testDir,
20
+ isMacOS: () => process.platform === 'darwin',
21
+ isLinux: () => process.platform === 'linux',
22
+ isWindows: () => process.platform === 'win32',
23
+ getSocketPath: () => join(testDir, 'test.sock'),
24
+ getPidPath: () => join(testDir, 'test.pid'),
25
+ getDbPath: () => join(testDir, 'test.db'),
26
+ getLogPath: () => join(testDir, 'test.log'),
27
+ ensureDataDir: () => {},
28
+ }));
29
+
30
+ mock.module('../util/logger.js', () => ({
31
+ getLogger: () => new Proxy({} as Record<string, unknown>, {
32
+ get: () => () => {},
33
+ }),
34
+ }));
35
+
36
+ mock.module('../calls/twilio-config.js', () => ({
37
+ getTwilioConfig: () => ({
38
+ accountSid: 'AC_test',
39
+ authToken: 'test_token',
40
+ phoneNumber: '+15550001111',
41
+ webhookBaseUrl: 'https://test.example.com',
42
+ wssBaseUrl: 'wss://test.example.com',
43
+ }),
44
+ }));
45
+
46
+ mock.module('../calls/twilio-provider.js', () => ({
47
+ TwilioConversationRelayProvider: class {
48
+ async checkCallerIdEligibility(number: string) {
49
+ // Simulate: +15550002222 is eligible, others are not
50
+ if (number === '+15550002222') return { eligible: true };
51
+ return { eligible: false, reason: `${number} is not eligible as a caller ID` };
52
+ }
53
+ },
54
+ }));
55
+
56
+ mock.module('../security/secure-keys.js', () => ({
57
+ getSecureKey: () => null,
58
+ }));
59
+
60
+ import { resolveCallerIdentity } from '../calls/call-domain.js';
61
+ import type { AssistantConfig } from '../config/types.js';
62
+
63
+ function makeConfig(overrides: {
64
+ allowPerCallOverride?: boolean;
65
+ userNumber?: string;
66
+ } = {}): AssistantConfig {
67
+ return {
68
+ calls: {
69
+ callerIdentity: {
70
+ allowPerCallOverride: overrides.allowPerCallOverride ?? true,
71
+ userNumber: overrides.userNumber,
72
+ },
73
+ },
74
+ } as unknown as AssistantConfig;
75
+ }
76
+
77
+ describe('resolveCallerIdentity — strict implicit-default policy', () => {
78
+ test('implicit call defaults to assistant_number', async () => {
79
+ const result = await resolveCallerIdentity(makeConfig());
80
+ expect(result.ok).toBe(true);
81
+ if (result.ok) {
82
+ expect(result.mode).toBe('assistant_number');
83
+ expect(result.fromNumber).toBe('+15550001111');
84
+ expect(result.source).toBe('implicit_default');
85
+ }
86
+ });
87
+
88
+ test('implicit call uses assistant_number even when userNumber is configured', async () => {
89
+ const result = await resolveCallerIdentity(
90
+ makeConfig({ userNumber: '+15550002222' }),
91
+ );
92
+ expect(result.ok).toBe(true);
93
+ if (result.ok) {
94
+ expect(result.mode).toBe('assistant_number');
95
+ expect(result.fromNumber).toBe('+15550001111');
96
+ expect(result.source).toBe('implicit_default');
97
+ }
98
+ });
99
+
100
+ test('explicit user_number succeeds when eligible', async () => {
101
+ const result = await resolveCallerIdentity(
102
+ makeConfig({ userNumber: '+15550002222' }),
103
+ 'user_number',
104
+ );
105
+ expect(result.ok).toBe(true);
106
+ if (result.ok) {
107
+ expect(result.mode).toBe('user_number');
108
+ expect(result.fromNumber).toBe('+15550002222');
109
+ expect(result.source).toBe('user_config');
110
+ }
111
+ });
112
+
113
+ test('explicit user_number fails when no user phone configured', async () => {
114
+ const result = await resolveCallerIdentity(makeConfig(), 'user_number');
115
+ expect(result.ok).toBe(false);
116
+ if (!result.ok) {
117
+ expect(result.error).toContain('user_number');
118
+ expect(result.error).toContain('user phone number');
119
+ }
120
+ });
121
+
122
+ test('explicit user_number fails when number is ineligible', async () => {
123
+ const result = await resolveCallerIdentity(
124
+ makeConfig({ userNumber: '+15559999999' }),
125
+ 'user_number',
126
+ );
127
+ expect(result.ok).toBe(false);
128
+ if (!result.ok) {
129
+ expect(result.error).toContain('not eligible');
130
+ }
131
+ });
132
+
133
+ test('explicit override rejected when allowPerCallOverride=false', async () => {
134
+ const result = await resolveCallerIdentity(
135
+ makeConfig({ allowPerCallOverride: false, userNumber: '+15550002222' }),
136
+ 'user_number',
137
+ );
138
+ expect(result.ok).toBe(false);
139
+ if (!result.ok) {
140
+ expect(result.error).toContain('override is disabled');
141
+ }
142
+ });
143
+
144
+ test('explicit assistant_number override succeeds when allowed', async () => {
145
+ const result = await resolveCallerIdentity(makeConfig(), 'assistant_number');
146
+ expect(result.ok).toBe(true);
147
+ if (result.ok) {
148
+ expect(result.mode).toBe('assistant_number');
149
+ expect(result.source).toBe('per_call_override');
150
+ }
151
+ });
152
+
153
+ test('invalid mode returns error', async () => {
154
+ const result = await resolveCallerIdentity(
155
+ makeConfig(),
156
+ 'custom_number' as 'assistant_number',
157
+ );
158
+ expect(result.ok).toBe(false);
159
+ if (!result.ok) {
160
+ expect(result.error).toContain('Invalid callerIdentityMode');
161
+ }
162
+ });
163
+ });
@@ -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
  }));
@@ -97,6 +100,7 @@ import { conversations } from '../memory/schema.js';
97
100
  import {
98
101
  createCallSession,
99
102
  getCallSession,
103
+ getCallEvents,
100
104
  getPendingQuestion,
101
105
  updateCallSession,
102
106
  } from '../calls/call-store.js';
@@ -192,6 +196,7 @@ function setupOrchestrator(task?: string) {
192
196
  describe('call-orchestrator', () => {
193
197
  beforeEach(() => {
194
198
  resetTables();
199
+ mockCallModel = undefined;
195
200
  // Reset the stream mock to default behaviour
196
201
  mockStreamFn.mockImplementation(() => createMockStream(['Hello', ' there']));
197
202
  });
@@ -451,4 +456,170 @@ describe('call-orchestrator', () => {
451
456
  // Second destroy should not throw
452
457
  expect(() => orchestrator.destroy()).not.toThrow();
453
458
  });
459
+
460
+ // ── Model override from config ──────────────────────────────────────
461
+
462
+ test('uses default model when calls.model is not set', async () => {
463
+ mockCallModel = undefined;
464
+ mockStreamFn.mockImplementation((...args: unknown[]) => {
465
+ const firstArg = args[0] as { model: string };
466
+ expect(firstArg.model).toBe('claude-sonnet-4-20250514');
467
+ return createMockStream(['Default model response.']);
468
+ });
469
+
470
+ const { orchestrator } = setupOrchestrator();
471
+ await orchestrator.handleCallerUtterance('Hello');
472
+ orchestrator.destroy();
473
+ });
474
+
475
+ test('uses calls.model override from config when set', async () => {
476
+ mockCallModel = 'claude-haiku-4-5-20251001';
477
+ mockStreamFn.mockImplementation((...args: unknown[]) => {
478
+ const firstArg = args[0] as { model: string };
479
+ expect(firstArg.model).toBe('claude-haiku-4-5-20251001');
480
+ return createMockStream(['Override model response.']);
481
+ });
482
+
483
+ const { orchestrator } = setupOrchestrator();
484
+ await orchestrator.handleCallerUtterance('Hello');
485
+ orchestrator.destroy();
486
+ });
487
+
488
+ test('treats empty string calls.model as unset and falls back to default', async () => {
489
+ mockCallModel = '';
490
+ mockStreamFn.mockImplementation((...args: unknown[]) => {
491
+ const firstArg = args[0] as { model: string };
492
+ expect(firstArg.model).toBe('claude-sonnet-4-20250514');
493
+ return createMockStream(['Fallback model response.']);
494
+ });
495
+
496
+ const { orchestrator } = setupOrchestrator();
497
+ await orchestrator.handleCallerUtterance('Hello');
498
+ orchestrator.destroy();
499
+ });
500
+
501
+ test('treats whitespace-only calls.model as unset and falls back to default', async () => {
502
+ mockCallModel = ' ';
503
+ mockStreamFn.mockImplementation((...args: unknown[]) => {
504
+ const firstArg = args[0] as { model: string };
505
+ expect(firstArg.model).toBe('claude-sonnet-4-20250514');
506
+ return createMockStream(['Fallback model response.']);
507
+ });
508
+
509
+ const { orchestrator } = setupOrchestrator();
510
+ await orchestrator.handleCallerUtterance('Hello');
511
+ orchestrator.destroy();
512
+ });
513
+
514
+ // ── handleUserInstruction ─────────────────────────────────────────
515
+
516
+ test('handleUserInstruction: injects instruction marker into conversation history and triggers LLM when idle', async () => {
517
+ mockStreamFn.mockImplementation((...args: unknown[]) => {
518
+ const firstArg = args[0] as { messages: Array<{ role: string; content: string }> };
519
+ const instructionMsg = firstArg.messages.find((m) =>
520
+ m.role === 'user' && m.content.includes('[USER_INSTRUCTION:'),
521
+ );
522
+ expect(instructionMsg).toBeDefined();
523
+ expect(instructionMsg!.content).toContain('[USER_INSTRUCTION: Ask about their weekend plans]');
524
+ return createMockStream(['Sure, do you have any weekend plans?']);
525
+ });
526
+
527
+ const { relay, orchestrator } = setupOrchestrator();
528
+
529
+ await orchestrator.handleUserInstruction('Ask about their weekend plans');
530
+
531
+ // Should have streamed a response since orchestrator was idle
532
+ const nonEmptyTokens = relay.sentTokens.filter((t) => t.token.length > 0);
533
+ expect(nonEmptyTokens.length).toBeGreaterThan(0);
534
+
535
+ orchestrator.destroy();
536
+ });
537
+
538
+ test('handleUserInstruction: does not break existing answer flow', async () => {
539
+ // Step 1: Caller says something, LLM responds normally
540
+ mockStreamFn.mockImplementation(() => createMockStream(['Hello! How can I help you today?']));
541
+ const { session: _session, relay, orchestrator } = setupOrchestrator('Book appointment');
542
+
543
+ await orchestrator.handleCallerUtterance('Hi there');
544
+
545
+ // Step 2: Inject an instruction while idle
546
+ mockStreamFn.mockImplementation((...args: unknown[]) => {
547
+ const firstArg = args[0] as { messages: Array<{ role: string; content: string }> };
548
+ // Verify the history contains both the original exchange and the instruction
549
+ const messages = firstArg.messages;
550
+ expect(messages.length).toBeGreaterThanOrEqual(3); // user utterance + assistant response + instruction
551
+ const instructionMsg = messages.find((m) =>
552
+ m.role === 'user' && m.content.includes('[USER_INSTRUCTION:'),
553
+ );
554
+ expect(instructionMsg).toBeDefined();
555
+ return createMockStream(['Of course, let me mention the weekend special.']);
556
+ });
557
+
558
+ await orchestrator.handleUserInstruction('Mention the weekend special');
559
+
560
+ // Step 3: Caller speaks again — the flow should continue normally
561
+ mockStreamFn.mockImplementation(() =>
562
+ createMockStream(['Great choice! The weekend special is 20% off.']),
563
+ );
564
+
565
+ await orchestrator.handleCallerUtterance('Tell me more about that');
566
+
567
+ // Verify state is idle after the normal flow
568
+ expect(orchestrator.getState()).toBe('idle');
569
+
570
+ // Verify relay received tokens from all exchanges
571
+ const allText = relay.sentTokens.map((t) => t.token).join('');
572
+ expect(allText).toContain('Hello');
573
+ expect(allText).toContain('weekend special');
574
+
575
+ orchestrator.destroy();
576
+ });
577
+
578
+ test('handleUserInstruction: emits user_instruction_relayed event', async () => {
579
+ mockStreamFn.mockImplementation(() => createMockStream(['Understood, adjusting approach.']));
580
+
581
+ const { session, orchestrator } = setupOrchestrator();
582
+
583
+ await orchestrator.handleUserInstruction('Be more formal in your tone');
584
+
585
+ const events = getCallEvents(session.id);
586
+ const instructionEvents = events.filter((e) => e.eventType === 'user_instruction_relayed');
587
+ expect(instructionEvents.length).toBe(1);
588
+
589
+ const payload = JSON.parse(instructionEvents[0].payloadJson);
590
+ expect(payload.instruction).toBe('Be more formal in your tone');
591
+
592
+ orchestrator.destroy();
593
+ });
594
+
595
+ test('handleUserInstruction: does not trigger LLM when orchestrator is not idle', async () => {
596
+ // First, trigger ASK_USER so orchestrator enters waiting_on_user
597
+ mockStreamFn.mockImplementation(() =>
598
+ createMockStream(['Hold on. [ASK_USER: What time?]']),
599
+ );
600
+
601
+ const { session, orchestrator } = setupOrchestrator();
602
+ await orchestrator.handleCallerUtterance('I need an appointment');
603
+ expect(orchestrator.getState()).toBe('waiting_on_user');
604
+
605
+ // Track how many times the stream mock is called
606
+ let streamCallCount = 0;
607
+ mockStreamFn.mockImplementation(() => {
608
+ streamCallCount++;
609
+ return createMockStream(['Response after instruction.']);
610
+ });
611
+
612
+ // Inject instruction while in waiting_on_user state
613
+ await orchestrator.handleUserInstruction('Suggest morning slots');
614
+
615
+ // The LLM should NOT have been triggered since we're not idle
616
+ expect(streamCallCount).toBe(0);
617
+
618
+ // But the event should still be recorded
619
+ const events = getCallEvents(session.id);
620
+ const instructionEvents = events.filter((e) => e.eventType === 'user_instruction_relayed');
621
+ expect(instructionEvents.length).toBe(1);
622
+
623
+ orchestrator.destroy();
624
+ });
454
625
  });