@vellumai/assistant 0.3.3 → 0.3.5

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 (163) hide show
  1. package/Dockerfile +2 -0
  2. package/README.md +45 -18
  3. package/package.json +1 -1
  4. package/scripts/ipc/generate-swift.ts +13 -0
  5. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +100 -0
  6. package/src/__tests__/approval-hardcoded-copy-guard.test.ts +41 -0
  7. package/src/__tests__/approval-message-composer.test.ts +253 -0
  8. package/src/__tests__/call-domain.test.ts +12 -2
  9. package/src/__tests__/call-orchestrator.test.ts +391 -1
  10. package/src/__tests__/call-routes-http.test.ts +27 -2
  11. package/src/__tests__/channel-approval-routes.test.ts +397 -135
  12. package/src/__tests__/channel-approvals.test.ts +99 -3
  13. package/src/__tests__/channel-delivery-store.test.ts +30 -4
  14. package/src/__tests__/channel-guardian.test.ts +261 -22
  15. package/src/__tests__/channel-readiness-service.test.ts +257 -0
  16. package/src/__tests__/config-schema.test.ts +2 -1
  17. package/src/__tests__/credential-security-invariants.test.ts +1 -0
  18. package/src/__tests__/daemon-lifecycle.test.ts +636 -0
  19. package/src/__tests__/dictation-mode-detection.test.ts +63 -0
  20. package/src/__tests__/entity-search.test.ts +615 -0
  21. package/src/__tests__/gateway-only-enforcement.test.ts +19 -13
  22. package/src/__tests__/handlers-twilio-config.test.ts +480 -0
  23. package/src/__tests__/ipc-snapshot.test.ts +63 -0
  24. package/src/__tests__/messaging-send-tool.test.ts +65 -0
  25. package/src/__tests__/run-orchestrator-assistant-events.test.ts +4 -0
  26. package/src/__tests__/run-orchestrator.test.ts +22 -0
  27. package/src/__tests__/secret-scanner.test.ts +223 -0
  28. package/src/__tests__/session-runtime-assembly.test.ts +85 -1
  29. package/src/__tests__/shell-parser-property.test.ts +357 -2
  30. package/src/__tests__/sms-messaging-provider.test.ts +125 -0
  31. package/src/__tests__/system-prompt.test.ts +25 -1
  32. package/src/__tests__/tool-executor-lifecycle-events.test.ts +34 -1
  33. package/src/__tests__/twilio-routes.test.ts +39 -3
  34. package/src/__tests__/twitter-cli-error-shaping.test.ts +2 -2
  35. package/src/__tests__/user-reference.test.ts +68 -0
  36. package/src/__tests__/web-search.test.ts +1 -1
  37. package/src/__tests__/work-item-output.test.ts +110 -0
  38. package/src/calls/call-domain.ts +8 -5
  39. package/src/calls/call-orchestrator.ts +85 -22
  40. package/src/calls/twilio-config.ts +17 -11
  41. package/src/calls/twilio-rest.ts +276 -0
  42. package/src/calls/twilio-routes.ts +39 -1
  43. package/src/cli/map.ts +6 -0
  44. package/src/commands/__tests__/cc-command-registry.test.ts +67 -0
  45. package/src/commands/cc-command-registry.ts +14 -1
  46. package/src/config/bundled-skills/claude-code/TOOLS.json +10 -3
  47. package/src/config/bundled-skills/knowledge-graph/SKILL.md +15 -0
  48. package/src/config/bundled-skills/knowledge-graph/TOOLS.json +56 -0
  49. package/src/config/bundled-skills/knowledge-graph/tools/graph-query.ts +185 -0
  50. package/src/config/bundled-skills/media-processing/SKILL.md +199 -0
  51. package/src/config/bundled-skills/media-processing/TOOLS.json +320 -0
  52. package/src/config/bundled-skills/media-processing/services/capability-registry.ts +137 -0
  53. package/src/config/bundled-skills/media-processing/services/event-detection-service.ts +280 -0
  54. package/src/config/bundled-skills/media-processing/services/feedback-aggregation.ts +144 -0
  55. package/src/config/bundled-skills/media-processing/services/feedback-store.ts +136 -0
  56. package/src/config/bundled-skills/media-processing/services/processing-pipeline.ts +261 -0
  57. package/src/config/bundled-skills/media-processing/services/retrieval-service.ts +95 -0
  58. package/src/config/bundled-skills/media-processing/services/timeline-service.ts +267 -0
  59. package/src/config/bundled-skills/media-processing/tools/analyze-keyframes.ts +301 -0
  60. package/src/config/bundled-skills/media-processing/tools/detect-events.ts +110 -0
  61. package/src/config/bundled-skills/media-processing/tools/extract-keyframes.ts +190 -0
  62. package/src/config/bundled-skills/media-processing/tools/generate-clip.ts +195 -0
  63. package/src/config/bundled-skills/media-processing/tools/ingest-media.ts +197 -0
  64. package/src/config/bundled-skills/media-processing/tools/media-diagnostics.ts +166 -0
  65. package/src/config/bundled-skills/media-processing/tools/media-status.ts +75 -0
  66. package/src/config/bundled-skills/media-processing/tools/query-media-events.ts +300 -0
  67. package/src/config/bundled-skills/media-processing/tools/recalibrate.ts +235 -0
  68. package/src/config/bundled-skills/media-processing/tools/select-tracking-profile.ts +142 -0
  69. package/src/config/bundled-skills/media-processing/tools/submit-feedback.ts +150 -0
  70. package/src/config/bundled-skills/messaging/SKILL.md +24 -5
  71. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -1
  72. package/src/config/bundled-skills/phone-calls/SKILL.md +2 -2
  73. package/src/config/bundled-skills/twitter/SKILL.md +19 -3
  74. package/src/config/defaults.ts +2 -1
  75. package/src/config/schema.ts +9 -3
  76. package/src/config/skills.ts +5 -32
  77. package/src/config/system-prompt.ts +40 -0
  78. package/src/config/templates/IDENTITY.md +2 -2
  79. package/src/config/user-reference.ts +29 -0
  80. package/src/config/vellum-skills/catalog.json +58 -0
  81. package/src/config/vellum-skills/google-oauth-setup/SKILL.md +3 -3
  82. package/src/config/vellum-skills/slack-oauth-setup/SKILL.md +3 -3
  83. package/src/config/vellum-skills/sms-setup/SKILL.md +118 -0
  84. package/src/config/vellum-skills/telegram-setup/SKILL.md +6 -1
  85. package/src/config/vellum-skills/twilio-setup/SKILL.md +76 -6
  86. package/src/daemon/auth-manager.ts +103 -0
  87. package/src/daemon/computer-use-session.ts +8 -1
  88. package/src/daemon/config-watcher.ts +253 -0
  89. package/src/daemon/handlers/config.ts +819 -22
  90. package/src/daemon/handlers/dictation.ts +182 -0
  91. package/src/daemon/handlers/identity.ts +14 -23
  92. package/src/daemon/handlers/index.ts +2 -0
  93. package/src/daemon/handlers/sessions.ts +2 -0
  94. package/src/daemon/handlers/shared.ts +3 -0
  95. package/src/daemon/handlers/skills.ts +6 -7
  96. package/src/daemon/handlers/work-items.ts +15 -7
  97. package/src/daemon/ipc-contract-inventory.json +10 -0
  98. package/src/daemon/ipc-contract.ts +114 -4
  99. package/src/daemon/ipc-handler.ts +87 -0
  100. package/src/daemon/lifecycle.ts +18 -4
  101. package/src/daemon/ride-shotgun-handler.ts +11 -1
  102. package/src/daemon/server.ts +111 -504
  103. package/src/daemon/session-agent-loop.ts +10 -15
  104. package/src/daemon/session-runtime-assembly.ts +115 -44
  105. package/src/daemon/session-tool-setup.ts +2 -0
  106. package/src/daemon/session.ts +19 -2
  107. package/src/inbound/public-ingress-urls.ts +3 -3
  108. package/src/memory/channel-guardian-store.ts +2 -1
  109. package/src/memory/db-connection.ts +28 -0
  110. package/src/memory/db-init.ts +1163 -0
  111. package/src/memory/db.ts +2 -2007
  112. package/src/memory/embedding-backend.ts +79 -11
  113. package/src/memory/indexer.ts +2 -0
  114. package/src/memory/job-handlers/media-processing.ts +100 -0
  115. package/src/memory/job-utils.ts +64 -4
  116. package/src/memory/jobs-store.ts +2 -1
  117. package/src/memory/jobs-worker.ts +11 -1
  118. package/src/memory/media-store.ts +759 -0
  119. package/src/memory/recall-cache.ts +107 -0
  120. package/src/memory/retriever.ts +36 -2
  121. package/src/memory/schema-migration.ts +984 -0
  122. package/src/memory/schema.ts +99 -0
  123. package/src/memory/search/entity.ts +208 -25
  124. package/src/memory/search/ranking.ts +6 -1
  125. package/src/memory/search/types.ts +26 -0
  126. package/src/messaging/provider-types.ts +2 -0
  127. package/src/messaging/providers/sms/adapter.ts +204 -0
  128. package/src/messaging/providers/sms/client.ts +93 -0
  129. package/src/messaging/providers/sms/types.ts +7 -0
  130. package/src/permissions/checker.ts +16 -2
  131. package/src/permissions/prompter.ts +14 -3
  132. package/src/permissions/trust-store.ts +7 -0
  133. package/src/runtime/approval-message-composer.ts +143 -0
  134. package/src/runtime/channel-approvals.ts +29 -7
  135. package/src/runtime/channel-guardian-service.ts +44 -18
  136. package/src/runtime/channel-readiness-service.ts +292 -0
  137. package/src/runtime/channel-readiness-types.ts +29 -0
  138. package/src/runtime/gateway-client.ts +2 -1
  139. package/src/runtime/http-server.ts +65 -28
  140. package/src/runtime/http-types.ts +3 -0
  141. package/src/runtime/routes/call-routes.ts +2 -1
  142. package/src/runtime/routes/channel-routes.ts +237 -103
  143. package/src/runtime/routes/run-routes.ts +7 -1
  144. package/src/runtime/run-orchestrator.ts +43 -3
  145. package/src/security/secret-scanner.ts +218 -0
  146. package/src/skills/frontmatter.ts +63 -0
  147. package/src/skills/slash-commands.ts +23 -0
  148. package/src/skills/vellum-catalog-remote.ts +107 -0
  149. package/src/tools/assets/materialize.ts +2 -2
  150. package/src/tools/browser/auto-navigate.ts +132 -24
  151. package/src/tools/browser/browser-manager.ts +67 -61
  152. package/src/tools/calls/call-start.ts +1 -0
  153. package/src/tools/claude-code/claude-code.ts +55 -3
  154. package/src/tools/credentials/vault.ts +1 -1
  155. package/src/tools/execution-target.ts +11 -1
  156. package/src/tools/executor.ts +10 -2
  157. package/src/tools/network/web-search.ts +1 -1
  158. package/src/tools/skills/vellum-catalog.ts +61 -156
  159. package/src/tools/terminal/parser.ts +21 -5
  160. package/src/tools/types.ts +2 -0
  161. package/src/twitter/router.ts +1 -1
  162. package/src/util/platform.ts +43 -1
  163. package/src/util/retry.ts +4 -4
@@ -27,9 +27,18 @@ mock.module('../util/logger.js', () => ({
27
27
  }),
28
28
  }));
29
29
 
30
+ // ── User reference mock ──────────────────────────────────────────────
31
+
32
+ let mockUserReference = 'my human';
33
+
34
+ mock.module('../config/user-reference.js', () => ({
35
+ resolveUserReference: () => mockUserReference,
36
+ }));
37
+
30
38
  // ── Config mock ─────────────────────────────────────────────────────
31
39
 
32
40
  let mockCallModel: string | undefined = undefined;
41
+ let mockDisclosure: { enabled: boolean; text: string } = { enabled: false, text: '' };
33
42
 
34
43
  mock.module('../config/loader.js', () => ({
35
44
  getConfig: () => ({
@@ -41,7 +50,7 @@ mock.module('../config/loader.js', () => ({
41
50
  userConsultTimeoutSeconds: 90,
42
51
  userConsultationTimeoutSeconds: 90,
43
52
  silenceTimeoutSeconds: 30,
44
- disclosure: { enabled: false, text: '' },
53
+ disclosure: mockDisclosure,
45
54
  safety: { denyCategories: [] },
46
55
  model: mockCallModel,
47
56
  },
@@ -197,6 +206,8 @@ describe('call-orchestrator', () => {
197
206
  beforeEach(() => {
198
207
  resetTables();
199
208
  mockCallModel = undefined;
209
+ mockUserReference = 'my human';
210
+ mockDisclosure = { enabled: false, text: '' };
200
211
  // Reset the stream mock to default behaviour
201
212
  mockStreamFn.mockImplementation(() => createMockStream(['Hello', ' there']));
202
213
  });
@@ -280,6 +291,30 @@ describe('call-orchestrator', () => {
280
291
  orchestrator.destroy();
281
292
  });
282
293
 
294
+ test('strips USER_ANSWERED and USER_INSTRUCTION markers from spoken output', async () => {
295
+ mockStreamFn.mockImplementation(() =>
296
+ createMockStream([
297
+ 'Thanks for waiting. ',
298
+ '[USER_ANSWERED: The guardian said 3 PM works.] ',
299
+ '[USER_INSTRUCTION: Keep this short.] ',
300
+ 'I can confirm 3 PM works.',
301
+ ]),
302
+ );
303
+ const { relay, orchestrator } = setupOrchestrator();
304
+
305
+ await orchestrator.handleCallerUtterance('Any update?');
306
+
307
+ const allText = relay.sentTokens.map((t) => t.token).join('');
308
+ expect(allText).toContain('Thanks for waiting.');
309
+ expect(allText).toContain('I can confirm 3 PM works.');
310
+ expect(allText).not.toContain('[USER_ANSWERED:');
311
+ expect(allText).not.toContain('[USER_INSTRUCTION:');
312
+ expect(allText).not.toContain('USER_ANSWERED');
313
+ expect(allText).not.toContain('USER_INSTRUCTION');
314
+
315
+ orchestrator.destroy();
316
+ });
317
+
283
318
  // ── END_CALL pattern ──────────────────────────────────────────────
284
319
 
285
320
  test('END_CALL pattern: detects marker, calls endSession, updates status to completed', async () => {
@@ -414,6 +449,166 @@ describe('call-orchestrator', () => {
414
449
  orchestrator.destroy();
415
450
  });
416
451
 
452
+ test('LLM APIUserAbortError: treats as expected abort without technical-issue fallback', async () => {
453
+ mockStreamFn.mockImplementation(() => {
454
+ const emitter = new EventEmitter();
455
+ return {
456
+ on: (event: string, handler: (...args: unknown[]) => void) => {
457
+ emitter.on(event, handler);
458
+ return { on: () => ({ on: () => ({}) }) };
459
+ },
460
+ finalMessage: () => {
461
+ const err = new Error('user abort');
462
+ err.name = 'APIUserAbortError';
463
+ return Promise.reject(err);
464
+ },
465
+ };
466
+ });
467
+
468
+ const { relay, orchestrator } = setupOrchestrator();
469
+ await orchestrator.handleCallerUtterance('Hello');
470
+
471
+ const errorTokens = relay.sentTokens.filter((t) => t.token.includes('technical issue'));
472
+ expect(errorTokens.length).toBe(0);
473
+ expect(orchestrator.getState()).toBe('idle');
474
+
475
+ orchestrator.destroy();
476
+ });
477
+
478
+ test('stale superseded turn errors do not emit technical-issue fallback', async () => {
479
+ let callCount = 0;
480
+ mockStreamFn.mockImplementation(() => {
481
+ callCount++;
482
+ if (callCount === 1) {
483
+ const emitter = new EventEmitter();
484
+ return {
485
+ on: (event: string, handler: (...args: unknown[]) => void) => {
486
+ emitter.on(event, handler);
487
+ return { on: () => ({ on: () => ({}) }) };
488
+ },
489
+ finalMessage: () =>
490
+ new Promise((_, reject) => {
491
+ setTimeout(() => reject(new Error('stale stream failure')), 20);
492
+ }),
493
+ };
494
+ }
495
+ return createMockStream(['Second turn response.']);
496
+ });
497
+
498
+ const { relay, orchestrator } = setupOrchestrator();
499
+
500
+ const firstTurnPromise = orchestrator.handleCallerUtterance('First utterance');
501
+ // Allow the first turn to enter runLlm before the second utterance interrupts it.
502
+ await new Promise((r) => setTimeout(r, 5));
503
+ const secondTurnPromise = orchestrator.handleCallerUtterance('Second utterance');
504
+
505
+ await Promise.all([firstTurnPromise, secondTurnPromise]);
506
+
507
+ const allTokens = relay.sentTokens.map((t) => t.token).join('');
508
+ expect(allTokens).toContain('Second turn response.');
509
+ expect(allTokens).not.toContain('technical issue');
510
+
511
+ orchestrator.destroy();
512
+ });
513
+
514
+ test('rapid caller barge-in coalesces contiguous user turns for role alternation', async () => {
515
+ let callCount = 0;
516
+ mockStreamFn.mockImplementation((...args: unknown[]) => {
517
+ callCount++;
518
+ if (callCount === 1) {
519
+ const emitter = new EventEmitter();
520
+ const options = args[1] as { signal?: AbortSignal } | undefined;
521
+ return {
522
+ on: (event: string, handler: (...evtArgs: unknown[]) => void) => {
523
+ emitter.on(event, handler);
524
+ return { on: () => ({ on: () => ({}) }) };
525
+ },
526
+ finalMessage: () =>
527
+ new Promise((_, reject) => {
528
+ options?.signal?.addEventListener('abort', () => {
529
+ const err = new Error('aborted');
530
+ err.name = 'AbortError';
531
+ reject(err);
532
+ }, { once: true });
533
+ }),
534
+ };
535
+ }
536
+
537
+ const firstArg = args[0] as { messages: Array<{ role: string; content: string }> };
538
+ const roles = firstArg.messages.map((m) => m.role);
539
+ for (let i = 1; i < roles.length; i++) {
540
+ expect(!(roles[i - 1] === 'user' && roles[i] === 'user')).toBe(true);
541
+ }
542
+ const userMessages = firstArg.messages.filter((m) => m.role === 'user');
543
+ const lastUser = userMessages[userMessages.length - 1];
544
+ expect(lastUser?.content).toContain('First caller utterance');
545
+ expect(lastUser?.content).toContain('Second caller utterance');
546
+ return createMockStream(['Merged turn handled.']);
547
+ });
548
+
549
+ const { relay, orchestrator } = setupOrchestrator();
550
+ const firstTurnPromise = orchestrator.handleCallerUtterance('First caller utterance');
551
+ await new Promise((r) => setTimeout(r, 5));
552
+ const secondTurnPromise = orchestrator.handleCallerUtterance('Second caller utterance');
553
+
554
+ await Promise.all([firstTurnPromise, secondTurnPromise]);
555
+
556
+ const allTokens = relay.sentTokens.map((t) => t.token).join('');
557
+ expect(allTokens).toContain('Merged turn handled.');
558
+
559
+ orchestrator.destroy();
560
+ });
561
+
562
+ test('interrupt then next caller prompt still preserves role alternation', async () => {
563
+ let callCount = 0;
564
+ mockStreamFn.mockImplementation((...args: unknown[]) => {
565
+ callCount++;
566
+ if (callCount === 1) {
567
+ const emitter = new EventEmitter();
568
+ const options = args[1] as { signal?: AbortSignal } | undefined;
569
+ return {
570
+ on: (event: string, handler: (...evtArgs: unknown[]) => void) => {
571
+ emitter.on(event, handler);
572
+ return { on: () => ({ on: () => ({}) }) };
573
+ },
574
+ finalMessage: () =>
575
+ new Promise((_, reject) => {
576
+ options?.signal?.addEventListener('abort', () => {
577
+ const err = new Error('aborted');
578
+ err.name = 'AbortError';
579
+ reject(err);
580
+ }, { once: true });
581
+ }),
582
+ };
583
+ }
584
+
585
+ const firstArg = args[0] as { messages: Array<{ role: string; content: string }> };
586
+ const roles = firstArg.messages.map((m) => m.role);
587
+ for (let i = 1; i < roles.length; i++) {
588
+ expect(!(roles[i - 1] === 'user' && roles[i] === 'user')).toBe(true);
589
+ }
590
+ const userMessages = firstArg.messages.filter((m) => m.role === 'user');
591
+ const lastUser = userMessages[userMessages.length - 1];
592
+ expect(lastUser?.content).toContain('First caller utterance');
593
+ expect(lastUser?.content).toContain('Second caller utterance');
594
+ return createMockStream(['Post-interrupt response.']);
595
+ });
596
+
597
+ const { relay, orchestrator } = setupOrchestrator();
598
+ const firstTurnPromise = orchestrator.handleCallerUtterance('First caller utterance');
599
+ await new Promise((r) => setTimeout(r, 5));
600
+ orchestrator.handleInterrupt();
601
+ const secondTurnPromise = orchestrator.handleCallerUtterance('Second caller utterance');
602
+
603
+ await Promise.all([firstTurnPromise, secondTurnPromise]);
604
+
605
+ const allTokens = relay.sentTokens.map((t) => t.token).join('');
606
+ expect(allTokens).toContain('Post-interrupt response.');
607
+ expect(allTokens).not.toContain('technical issue');
608
+
609
+ orchestrator.destroy();
610
+ });
611
+
417
612
  test('handleUserAnswer: returns false when not in waiting_on_user state', async () => {
418
613
  const { orchestrator } = setupOrchestrator();
419
614
 
@@ -435,6 +630,87 @@ describe('call-orchestrator', () => {
435
630
  orchestrator.destroy();
436
631
  });
437
632
 
633
+ test('handleInterrupt: increments llmRunVersion to suppress stale turn side effects', async () => {
634
+ // Use a stream whose finalMessage resolves immediately but whose
635
+ // continuation (the code after `await stream.finalMessage()`) will
636
+ // run asynchronously. This simulates the race where the promise
637
+ // microtask is queued right as handleInterrupt fires.
638
+ mockStreamFn.mockImplementation(() => {
639
+ const emitter = new EventEmitter();
640
+ return {
641
+ on: (event: string, handler: (...args: unknown[]) => void) => {
642
+ emitter.on(event, handler);
643
+ return { on: () => ({ on: () => ({}) }) };
644
+ },
645
+ finalMessage: () => {
646
+ // Emit some tokens synchronously
647
+ emitter.emit('text', 'Stale response that should be suppressed.');
648
+ return Promise.resolve({
649
+ content: [{ type: 'text', text: 'Stale response that should be suppressed.' }],
650
+ });
651
+ },
652
+ };
653
+ });
654
+
655
+ const { relay, orchestrator } = setupOrchestrator();
656
+
657
+ // Start an LLM turn (don't await — we want to interrupt mid-flight)
658
+ const turnPromise = orchestrator.handleCallerUtterance('Hello');
659
+
660
+ // Interrupt immediately. Because finalMessage resolves as a microtask,
661
+ // its continuation hasn't run yet. handleInterrupt increments
662
+ // llmRunVersion so the continuation's isCurrentRun check will fail.
663
+ orchestrator.handleInterrupt();
664
+
665
+ // Let the stale turn's microtask continuation execute
666
+ await turnPromise;
667
+
668
+ // The orchestrator should remain idle — the stale turn must not
669
+ // have pushed state to waiting_on_user or any other post-turn state.
670
+ expect(orchestrator.getState()).toBe('idle');
671
+
672
+ // No technical-issue fallback should have been sent
673
+ const errorTokens = relay.sentTokens.filter((t) => t.token.includes('technical issue'));
674
+ expect(errorTokens.length).toBe(0);
675
+
676
+ // endSession should NOT have been called by the stale turn
677
+ expect(relay.endCalled).toBe(false);
678
+
679
+ orchestrator.destroy();
680
+ });
681
+
682
+ test('handleInterrupt: sends turn terminator when interrupting active speech', async () => {
683
+ mockStreamFn.mockImplementation((...args: unknown[]) => {
684
+ const emitter = new EventEmitter();
685
+ const options = args[1] as { signal?: AbortSignal } | undefined;
686
+ return {
687
+ on: (event: string, handler: (...evtArgs: unknown[]) => void) => {
688
+ emitter.on(event, handler);
689
+ return { on: () => ({ on: () => ({}) }) };
690
+ },
691
+ finalMessage: () =>
692
+ new Promise((_, reject) => {
693
+ options?.signal?.addEventListener('abort', () => {
694
+ const err = new Error('aborted');
695
+ err.name = 'AbortError';
696
+ reject(err);
697
+ }, { once: true });
698
+ }),
699
+ };
700
+ });
701
+
702
+ const { relay, orchestrator } = setupOrchestrator();
703
+ const turnPromise = orchestrator.handleCallerUtterance('Start speaking');
704
+ await new Promise((r) => setTimeout(r, 5));
705
+ orchestrator.handleInterrupt();
706
+ await turnPromise;
707
+
708
+ const endTurnMarkers = relay.sentTokens.filter((t) => t.token === '' && t.last === true);
709
+ expect(endTurnMarkers.length).toBeGreaterThan(0);
710
+
711
+ orchestrator.destroy();
712
+ });
713
+
438
714
  // ── destroy ───────────────────────────────────────────────────────
439
715
 
440
716
  test('destroy: unregisters orchestrator', () => {
@@ -622,4 +898,118 @@ describe('call-orchestrator', () => {
622
898
 
623
899
  orchestrator.destroy();
624
900
  });
901
+
902
+ // ── System prompt: identity phrasing ────────────────────────────────
903
+
904
+ test('system prompt contains resolved user reference (default)', async () => {
905
+ mockStreamFn.mockImplementation((...args: unknown[]) => {
906
+ const firstArg = args[0] as { system: string };
907
+ expect(firstArg.system).toContain('on behalf of my human');
908
+ return createMockStream(['Hello.']);
909
+ });
910
+
911
+ const { orchestrator } = setupOrchestrator();
912
+ await orchestrator.handleCallerUtterance('Hi');
913
+ orchestrator.destroy();
914
+ });
915
+
916
+ test('system prompt contains resolved user reference when set to a name', async () => {
917
+ mockUserReference = 'John';
918
+ mockStreamFn.mockImplementation((...args: unknown[]) => {
919
+ const firstArg = args[0] as { system: string };
920
+ expect(firstArg.system).toContain('on behalf of John');
921
+ return createMockStream(['Hello John\'s contact.']);
922
+ });
923
+
924
+ const { orchestrator } = setupOrchestrator();
925
+ await orchestrator.handleCallerUtterance('Hi');
926
+ orchestrator.destroy();
927
+ });
928
+
929
+ test('system prompt does not hardcode "your user" in the opening line', async () => {
930
+ mockUserReference = 'Alice';
931
+ mockStreamFn.mockImplementation((...args: unknown[]) => {
932
+ const firstArg = args[0] as { system: string };
933
+ expect(firstArg.system).not.toContain('on behalf of your user');
934
+ expect(firstArg.system).toContain('on behalf of Alice');
935
+ return createMockStream(['Hi there.']);
936
+ });
937
+
938
+ const { orchestrator } = setupOrchestrator();
939
+ await orchestrator.handleCallerUtterance('Hello');
940
+ orchestrator.destroy();
941
+ });
942
+
943
+ test('system prompt includes assistant identity bias rule', async () => {
944
+ mockStreamFn.mockImplementation((...args: unknown[]) => {
945
+ const firstArg = args[0] as { system: string };
946
+ expect(firstArg.system).toContain('refer to yourself as an assistant');
947
+ expect(firstArg.system).toContain('Avoid the phrase "AI assistant" unless directly asked');
948
+ return createMockStream(['Sure thing.']);
949
+ });
950
+
951
+ const { orchestrator } = setupOrchestrator();
952
+ await orchestrator.handleCallerUtterance('Hi');
953
+ orchestrator.destroy();
954
+ });
955
+
956
+ test('assistant identity rule appears before disclosure rule in prompt', async () => {
957
+ mockStreamFn.mockImplementation((...args: unknown[]) => {
958
+ const firstArg = args[0] as { system: string };
959
+ const prompt = firstArg.system;
960
+ const identityIdx = prompt.indexOf('refer to yourself as an assistant');
961
+ const disclosureIdx = prompt.indexOf('Be concise');
962
+ expect(identityIdx).toBeGreaterThan(-1);
963
+ expect(disclosureIdx).toBeGreaterThan(-1);
964
+ expect(identityIdx).toBeLessThan(disclosureIdx);
965
+ return createMockStream(['OK.']);
966
+ });
967
+
968
+ const { orchestrator } = setupOrchestrator();
969
+ await orchestrator.handleCallerUtterance('Test');
970
+ orchestrator.destroy();
971
+ });
972
+
973
+ test('system prompt uses disclosure text when disclosure is enabled', async () => {
974
+ mockDisclosure = {
975
+ enabled: true,
976
+ text: 'At the very beginning of the call, introduce yourself as an assistant calling on behalf of the person you represent. Do not say "AI assistant".',
977
+ };
978
+ mockStreamFn.mockImplementation((...args: unknown[]) => {
979
+ const firstArg = args[0] as { system: string };
980
+ expect(firstArg.system).toContain('introduce yourself as an assistant calling on behalf of the person you represent');
981
+ expect(firstArg.system).toContain('Do not say "AI assistant"');
982
+ return createMockStream(['Hello, I am calling on behalf of my human.']);
983
+ });
984
+
985
+ const { orchestrator } = setupOrchestrator();
986
+ await orchestrator.handleCallerUtterance('Who is this?');
987
+ orchestrator.destroy();
988
+ });
989
+
990
+ test('system prompt falls back to "Begin the conversation naturally" when disclosure is disabled', async () => {
991
+ mockDisclosure = { enabled: false, text: '' };
992
+ mockStreamFn.mockImplementation((...args: unknown[]) => {
993
+ const firstArg = args[0] as { system: string };
994
+ expect(firstArg.system).toContain('Begin the conversation naturally');
995
+ expect(firstArg.system).not.toContain('introduce yourself as an assistant calling on behalf of the person');
996
+ return createMockStream(['Hello there.']);
997
+ });
998
+
999
+ const { orchestrator } = setupOrchestrator();
1000
+ await orchestrator.handleCallerUtterance('Hi');
1001
+ orchestrator.destroy();
1002
+ });
1003
+
1004
+ test('system prompt does not use "AI assistant" as a self-identity label', async () => {
1005
+ mockStreamFn.mockImplementation((...args: unknown[]) => {
1006
+ const firstArg = args[0] as { system: string };
1007
+ expect(firstArg.system).not.toMatch(/(?:you are|call yourself|introduce yourself as).*AI assistant/i);
1008
+ return createMockStream(['Got it.']);
1009
+ });
1010
+
1011
+ const { orchestrator } = setupOrchestrator();
1012
+ await orchestrator.handleCallerUtterance('Hello');
1013
+ orchestrator.destroy();
1014
+ });
625
1015
  });
@@ -80,10 +80,10 @@ mock.module('../calls/twilio-provider.js', () => ({
80
80
 
81
81
  // Mock Twilio config
82
82
  mock.module('../calls/twilio-config.js', () => ({
83
- getTwilioConfig: () => ({
83
+ getTwilioConfig: (assistantId?: string) => ({
84
84
  accountSid: 'AC_test',
85
85
  authToken: 'test_token',
86
- phoneNumber: '+15550001111',
86
+ phoneNumber: assistantId === 'asst-alpha' ? '+15550009999' : '+15550001111',
87
87
  webhookBaseUrl: 'https://test.example.com',
88
88
  wssBaseUrl: 'wss://test.example.com',
89
89
  }),
@@ -168,6 +168,10 @@ describe('runtime call routes — HTTP layer', () => {
168
168
  return `http://127.0.0.1:${port}/v1/calls${path}`;
169
169
  }
170
170
 
171
+ function assistantCallsUrl(assistantId: string, path = ''): string {
172
+ return `http://127.0.0.1:${port}/v1/assistants/${assistantId}/calls${path}`;
173
+ }
174
+
171
175
  // ── POST /v1/calls/start ────────────────────────────────────────────
172
176
 
173
177
  test('POST /v1/calls/start returns 201 with call session', async () => {
@@ -222,6 +226,27 @@ describe('runtime call routes — HTTP layer', () => {
222
226
  await stopServer();
223
227
  });
224
228
 
229
+ test('POST /v1/assistants/:assistantId/calls/start uses assistant-scoped caller number', async () => {
230
+ await startServer();
231
+ ensureConversation('conv-start-scoped-1');
232
+
233
+ const res = await fetch(assistantCallsUrl('asst-alpha', '/start'), {
234
+ method: 'POST',
235
+ headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
236
+ body: JSON.stringify({
237
+ phoneNumber: '+15559997777',
238
+ task: 'Check order status',
239
+ conversationId: 'conv-start-scoped-1',
240
+ }),
241
+ });
242
+
243
+ expect(res.status).toBe(201);
244
+ const body = await res.json() as { fromNumber: string };
245
+ expect(body.fromNumber).toBe('+15550009999');
246
+
247
+ await stopServer();
248
+ });
249
+
225
250
  test('POST /v1/calls/start returns 400 for invalid phone number', async () => {
226
251
  await startServer();
227
252
  ensureConversation('conv-start-2');