@vellumai/assistant 0.4.2 → 0.4.4

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 (221) hide show
  1. package/.env.example +3 -0
  2. package/ARCHITECTURE.md +124 -10
  3. package/README.md +43 -35
  4. package/docs/trusted-contact-access.md +20 -0
  5. package/package.json +1 -1
  6. package/scripts/ipc/generate-swift.ts +1 -0
  7. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +58 -120
  8. package/src/__tests__/access-request-decision.test.ts +0 -1
  9. package/src/__tests__/actor-token-service.test.ts +1099 -0
  10. package/src/__tests__/agent-loop.test.ts +51 -0
  11. package/src/__tests__/approval-routes-http.test.ts +2 -0
  12. package/src/__tests__/assistant-events-sse-hardening.test.ts +7 -5
  13. package/src/__tests__/assistant-id-boundary-guard.test.ts +415 -0
  14. package/src/__tests__/call-controller.test.ts +49 -0
  15. package/src/__tests__/call-pointer-message-composer.test.ts +171 -0
  16. package/src/__tests__/call-pointer-messages.test.ts +93 -3
  17. package/src/__tests__/call-pointer-no-hardcoded-copy.guard.test.ts +42 -0
  18. package/src/__tests__/call-routes-http.test.ts +0 -25
  19. package/src/__tests__/callback-handoff-copy.test.ts +186 -0
  20. package/src/__tests__/channel-approval-routes.test.ts +133 -12
  21. package/src/__tests__/channel-guardian.test.ts +0 -86
  22. package/src/__tests__/channel-readiness-service.test.ts +10 -16
  23. package/src/__tests__/checker.test.ts +33 -12
  24. package/src/__tests__/config-schema.test.ts +6 -0
  25. package/src/__tests__/confirmation-request-guardian-bridge.test.ts +410 -0
  26. package/src/__tests__/conversation-routes-guardian-reply.test.ts +256 -0
  27. package/src/__tests__/conversation-routes.test.ts +12 -3
  28. package/src/__tests__/credential-security-invariants.test.ts +1 -1
  29. package/src/__tests__/daemon-server-session-init.test.ts +4 -0
  30. package/src/__tests__/deterministic-verification-control-plane.test.ts +0 -1
  31. package/src/__tests__/guardian-actions-endpoint.test.ts +39 -13
  32. package/src/__tests__/guardian-dispatch.test.ts +8 -0
  33. package/src/__tests__/guardian-outbound-http.test.ts +4 -5
  34. package/src/__tests__/guardian-question-mode.test.ts +200 -0
  35. package/src/__tests__/guardian-routing-invariants.test.ts +178 -0
  36. package/src/__tests__/guardian-routing-state.test.ts +525 -0
  37. package/src/__tests__/handle-user-message-secret-resume.test.ts +2 -0
  38. package/src/__tests__/handlers-telegram-config.test.ts +0 -83
  39. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +55 -0
  40. package/src/__tests__/headless-browser-navigate.test.ts +2 -0
  41. package/src/__tests__/inbound-invite-redemption.test.ts +0 -1
  42. package/src/__tests__/ingress-routes-http.test.ts +55 -0
  43. package/src/__tests__/ipc-snapshot.test.ts +18 -51
  44. package/src/__tests__/non-member-access-request.test.ts +159 -9
  45. package/src/__tests__/notification-decision-fallback.test.ts +129 -4
  46. package/src/__tests__/notification-decision-strategy.test.ts +106 -2
  47. package/src/__tests__/notification-guardian-path.test.ts +3 -0
  48. package/src/__tests__/recording-intent-handler.test.ts +1 -0
  49. package/src/__tests__/relay-server.test.ts +1475 -33
  50. package/src/__tests__/send-endpoint-busy.test.ts +5 -0
  51. package/src/__tests__/session-agent-loop.test.ts +1 -0
  52. package/src/__tests__/session-confirmation-signals.test.ts +523 -0
  53. package/src/__tests__/session-init.benchmark.test.ts +0 -2
  54. package/src/__tests__/session-runtime-assembly.test.ts +4 -1
  55. package/src/__tests__/session-surfaces-task-progress.test.ts +44 -1
  56. package/src/__tests__/session-tool-setup-app-refresh.test.ts +81 -2
  57. package/src/__tests__/session-tool-setup-memory-scope.test.ts +1 -1
  58. package/src/__tests__/session-tool-setup-side-effect-flag.test.ts +1 -1
  59. package/src/__tests__/tool-executor.test.ts +21 -2
  60. package/src/__tests__/tool-grant-request-escalation.test.ts +333 -27
  61. package/src/__tests__/trusted-contact-approval-notifier.test.ts +678 -0
  62. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +1064 -0
  63. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +11 -1
  64. package/src/__tests__/trusted-contact-multichannel.test.ts +0 -1
  65. package/src/__tests__/trusted-contact-verification.test.ts +0 -1
  66. package/src/__tests__/twilio-config.test.ts +2 -13
  67. package/src/__tests__/twilio-routes.test.ts +4 -3
  68. package/src/__tests__/update-bulletin.test.ts +0 -1
  69. package/src/agent/loop.ts +1 -1
  70. package/src/approvals/guardian-decision-primitive.ts +12 -3
  71. package/src/approvals/guardian-request-resolvers.ts +169 -11
  72. package/src/calls/call-constants.ts +29 -0
  73. package/src/calls/call-controller.ts +11 -3
  74. package/src/calls/call-domain.ts +33 -11
  75. package/src/calls/call-pointer-message-composer.ts +154 -0
  76. package/src/calls/call-pointer-messages.ts +106 -27
  77. package/src/calls/guardian-dispatch.ts +4 -2
  78. package/src/calls/relay-server.ts +921 -112
  79. package/src/calls/twilio-config.ts +4 -11
  80. package/src/calls/twilio-routes.ts +4 -6
  81. package/src/calls/types.ts +3 -1
  82. package/src/calls/voice-session-bridge.ts +4 -3
  83. package/src/cli/core-commands.ts +7 -4
  84. package/src/cli.ts +5 -4
  85. package/src/config/bundled-skills/agentmail/SKILL.md +4 -0
  86. package/src/config/bundled-skills/app-builder/SKILL.md +309 -10
  87. package/src/config/bundled-skills/app-builder/TOOLS.json +1 -1
  88. package/src/config/bundled-skills/email-setup/SKILL.md +1 -1
  89. package/src/config/bundled-skills/google-oauth-setup/SKILL.md +105 -81
  90. package/src/config/bundled-skills/messaging/SKILL.md +61 -12
  91. package/src/config/bundled-skills/messaging/TOOLS.json +58 -0
  92. package/src/config/bundled-skills/messaging/tools/gmail-sender-digest.ts +6 -1
  93. package/src/config/bundled-skills/messaging/tools/messaging-archive-by-sender.ts +35 -0
  94. package/src/config/bundled-skills/messaging/tools/messaging-sender-digest.ts +52 -0
  95. package/src/config/bundled-skills/phone-calls/SKILL.md +30 -39
  96. package/src/config/bundled-skills/twitter/SKILL.md +3 -3
  97. package/src/config/bundled-skills/vercel-token-setup/SKILL.md +215 -0
  98. package/src/config/calls-schema.ts +36 -0
  99. package/src/config/env.ts +22 -0
  100. package/src/config/feature-flag-registry.json +8 -8
  101. package/src/config/schema.ts +2 -2
  102. package/src/config/skills.ts +11 -0
  103. package/src/config/system-prompt.ts +11 -1
  104. package/src/config/templates/SOUL.md +2 -0
  105. package/src/config/vellum-skills/sms-setup/SKILL.md +71 -82
  106. package/src/config/vellum-skills/trusted-contacts/SKILL.md +8 -1
  107. package/src/config/vellum-skills/twilio-setup/SKILL.md +88 -73
  108. package/src/daemon/call-pointer-generators.ts +59 -0
  109. package/src/daemon/computer-use-session.ts +2 -5
  110. package/src/daemon/handlers/apps.ts +76 -20
  111. package/src/daemon/handlers/config-channels.ts +9 -61
  112. package/src/daemon/handlers/config-inbox.ts +11 -3
  113. package/src/daemon/handlers/config-ingress.ts +28 -3
  114. package/src/daemon/handlers/config-telegram.ts +12 -0
  115. package/src/daemon/handlers/config.ts +2 -6
  116. package/src/daemon/handlers/index.ts +2 -1
  117. package/src/daemon/handlers/pairing.ts +2 -0
  118. package/src/daemon/handlers/publish.ts +11 -46
  119. package/src/daemon/handlers/sessions.ts +59 -5
  120. package/src/daemon/handlers/shared.ts +17 -2
  121. package/src/daemon/ipc-contract/apps.ts +1 -0
  122. package/src/daemon/ipc-contract/inbox.ts +4 -0
  123. package/src/daemon/ipc-contract/integrations.ts +1 -97
  124. package/src/daemon/ipc-contract/messages.ts +47 -1
  125. package/src/daemon/ipc-contract/notifications.ts +11 -0
  126. package/src/daemon/ipc-contract-inventory.json +2 -4
  127. package/src/daemon/lifecycle.ts +17 -0
  128. package/src/daemon/server.ts +16 -2
  129. package/src/daemon/session-agent-loop-handlers.ts +20 -0
  130. package/src/daemon/session-agent-loop.ts +24 -12
  131. package/src/daemon/session-lifecycle.ts +1 -1
  132. package/src/daemon/session-process.ts +11 -1
  133. package/src/daemon/session-runtime-assembly.ts +6 -1
  134. package/src/daemon/session-surfaces.ts +32 -3
  135. package/src/daemon/session.ts +88 -1
  136. package/src/daemon/tool-side-effects.ts +22 -0
  137. package/src/home-base/prebuilt/brain-graph.html +1483 -0
  138. package/src/home-base/prebuilt/index.html +40 -0
  139. package/src/inbound/platform-callback-registration.ts +157 -0
  140. package/src/memory/canonical-guardian-store.ts +1 -1
  141. package/src/memory/conversation-crud.ts +2 -1
  142. package/src/memory/conversation-title-service.ts +16 -2
  143. package/src/memory/db-init.ts +8 -0
  144. package/src/memory/delivery-crud.ts +2 -1
  145. package/src/memory/guardian-action-store.ts +2 -1
  146. package/src/memory/guardian-approvals.ts +3 -2
  147. package/src/memory/ingress-invite-store.ts +12 -2
  148. package/src/memory/ingress-member-store.ts +4 -3
  149. package/src/memory/migrations/038-actor-token-records.ts +39 -0
  150. package/src/memory/migrations/124-voice-invite-display-metadata.ts +14 -0
  151. package/src/memory/migrations/index.ts +2 -0
  152. package/src/memory/schema.ts +26 -5
  153. package/src/messaging/provider-types.ts +24 -0
  154. package/src/messaging/provider.ts +7 -0
  155. package/src/messaging/providers/gmail/adapter.ts +127 -0
  156. package/src/messaging/providers/sms/adapter.ts +40 -37
  157. package/src/notifications/adapters/macos.ts +45 -2
  158. package/src/notifications/broadcaster.ts +16 -0
  159. package/src/notifications/copy-composer.ts +50 -2
  160. package/src/notifications/decision-engine.ts +22 -9
  161. package/src/notifications/destination-resolver.ts +16 -2
  162. package/src/notifications/emit-signal.ts +18 -9
  163. package/src/notifications/guardian-question-mode.ts +419 -0
  164. package/src/notifications/signal.ts +14 -3
  165. package/src/permissions/checker.ts +13 -1
  166. package/src/permissions/prompter.ts +14 -0
  167. package/src/providers/anthropic/client.ts +20 -0
  168. package/src/providers/provider-send-message.ts +15 -3
  169. package/src/runtime/access-request-helper.ts +82 -4
  170. package/src/runtime/actor-token-service.ts +234 -0
  171. package/src/runtime/actor-token-store.ts +236 -0
  172. package/src/runtime/actor-trust-resolver.ts +2 -2
  173. package/src/runtime/assistant-scope.ts +10 -0
  174. package/src/runtime/channel-approvals.ts +5 -3
  175. package/src/runtime/channel-readiness-service.ts +23 -64
  176. package/src/runtime/channel-readiness-types.ts +3 -4
  177. package/src/runtime/channel-retry-sweep.ts +4 -1
  178. package/src/runtime/confirmation-request-guardian-bridge.ts +197 -0
  179. package/src/runtime/guardian-action-followup-executor.ts +1 -1
  180. package/src/runtime/guardian-context-resolver.ts +82 -0
  181. package/src/runtime/guardian-outbound-actions.ts +5 -7
  182. package/src/runtime/guardian-reply-router.ts +67 -30
  183. package/src/runtime/guardian-vellum-migration.ts +57 -0
  184. package/src/runtime/http-server.ts +75 -31
  185. package/src/runtime/http-types.ts +13 -0
  186. package/src/runtime/ingress-service.ts +14 -0
  187. package/src/runtime/invite-redemption-service.ts +10 -1
  188. package/src/runtime/local-actor-identity.ts +76 -0
  189. package/src/runtime/middleware/actor-token.ts +271 -0
  190. package/src/runtime/middleware/twilio-validation.ts +2 -4
  191. package/src/runtime/routes/approval-routes.ts +82 -7
  192. package/src/runtime/routes/brain-graph-routes.ts +222 -0
  193. package/src/runtime/routes/call-routes.ts +2 -1
  194. package/src/runtime/routes/channel-readiness-routes.ts +71 -0
  195. package/src/runtime/routes/channel-route-shared.ts +3 -3
  196. package/src/runtime/routes/conversation-attention-routes.ts +2 -1
  197. package/src/runtime/routes/conversation-routes.ts +142 -53
  198. package/src/runtime/routes/events-routes.ts +22 -8
  199. package/src/runtime/routes/guardian-action-routes.ts +45 -3
  200. package/src/runtime/routes/guardian-approval-interception.ts +29 -0
  201. package/src/runtime/routes/guardian-bootstrap-routes.ts +145 -0
  202. package/src/runtime/routes/inbound-conversation.ts +4 -3
  203. package/src/runtime/routes/inbound-message-handler.ts +147 -5
  204. package/src/runtime/routes/ingress-routes.ts +2 -0
  205. package/src/runtime/routes/integration-routes.ts +7 -15
  206. package/src/runtime/routes/pairing-routes.ts +163 -0
  207. package/src/runtime/routes/twilio-routes.ts +934 -0
  208. package/src/runtime/tool-grant-request-helper.ts +3 -1
  209. package/src/security/oauth2.ts +27 -2
  210. package/src/security/token-manager.ts +46 -10
  211. package/src/tools/browser/browser-execution.ts +4 -3
  212. package/src/tools/browser/browser-handoff.ts +10 -18
  213. package/src/tools/browser/browser-manager.ts +80 -25
  214. package/src/tools/browser/browser-screencast.ts +35 -119
  215. package/src/tools/calls/call-start.ts +2 -1
  216. package/src/tools/permission-checker.ts +15 -4
  217. package/src/tools/terminal/parser.ts +12 -0
  218. package/src/tools/tool-approval-handler.ts +244 -19
  219. package/src/workspace/git-service.ts +19 -0
  220. package/src/__tests__/handlers-twilio-config.test.ts +0 -1928
  221. package/src/daemon/handlers/config-twilio.ts +0 -1082
@@ -100,6 +100,9 @@ interface TestSession {
100
100
  setAssistantId: (assistantId: string) => void;
101
101
  setGuardianContext: (ctx: unknown) => void;
102
102
  setCommandIntent: (intent: unknown) => void;
103
+ updateClient: (sendToClient: (msg: ServerMessage) => void, hasNoClient?: boolean) => void;
104
+ emitActivityState: (...args: unknown[]) => void;
105
+ emitConfirmationStateChanged: (...args: unknown[]) => void;
103
106
  processMessage: (...args: unknown[]) => Promise<string>;
104
107
  }
105
108
 
@@ -153,6 +156,9 @@ function makeSession(overrides: Partial<TestSession> = {}): TestSession {
153
156
  setAssistantId: () => {},
154
157
  setGuardianContext: () => {},
155
158
  setCommandIntent: () => {},
159
+ updateClient: () => {},
160
+ emitActivityState: () => {},
161
+ emitConfirmationStateChanged: () => {},
156
162
  processMessage: async () => 'msg-id',
157
163
  ...overrides,
158
164
  };
@@ -411,6 +417,53 @@ describe('handleUserMessage pending-confirmation reply interception', () => {
411
417
  expect(sent.some((event) => event.type === 'confirmation_request')).toBe(true);
412
418
  });
413
419
 
420
+ test('registers IPC confirmation events emitted via session sender (prompter path)', async () => {
421
+ let currentSender: (msg: ServerMessage) => void = () => {};
422
+ const session = makeSession({
423
+ hasAnyPendingConfirmation: () => false,
424
+ enqueueMessage: mock(() => ({ queued: false, requestId: 'direct-id' })),
425
+ updateClient: (sendToClient: (msg: ServerMessage) => void) => {
426
+ currentSender = sendToClient;
427
+ },
428
+ processMessage: async () => {
429
+ currentSender({
430
+ type: 'confirmation_request',
431
+ requestId: 'req-prompter-1',
432
+ toolName: 'call_start',
433
+ input: { phone_number: '+18084436762' },
434
+ riskLevel: 'high',
435
+ executionTarget: 'host',
436
+ allowlistOptions: [],
437
+ scopeOptions: [],
438
+ persistentDecisionsAllowed: false,
439
+ } as ServerMessage);
440
+ return 'msg-id';
441
+ },
442
+ });
443
+ const { ctx, sent } = createContext(session);
444
+
445
+ await handleUserMessage(makeMessage('please call now'), {} as net.Socket, ctx);
446
+
447
+ expect(registerMock).toHaveBeenCalledWith(
448
+ 'req-prompter-1',
449
+ expect.objectContaining({
450
+ conversationId: 'conv-1',
451
+ kind: 'confirmation',
452
+ session,
453
+ }),
454
+ );
455
+ expect(createCanonicalGuardianRequestMock).toHaveBeenCalledWith(
456
+ expect.objectContaining({
457
+ id: 'req-prompter-1',
458
+ kind: 'tool_approval',
459
+ sourceType: 'desktop',
460
+ sourceChannel: 'vellum',
461
+ conversationId: 'conv-1',
462
+ }),
463
+ );
464
+ expect(sent.some((event) => event.type === 'confirmation_request')).toBe(true);
465
+ });
466
+
414
467
  test('syncs canonical status to approved for IPC allow decisions', () => {
415
468
  const session = {
416
469
  hasPendingConfirmation: (requestId: string) => requestId === 'req-confirm-allow',
@@ -433,6 +486,8 @@ describe('handleUserMessage pending-confirmation reply interception', () => {
433
486
  'always_allow',
434
487
  undefined,
435
488
  undefined,
489
+ undefined,
490
+ { source: 'button' },
436
491
  ]);
437
492
  expect(resolveCanonicalGuardianRequestMock).toHaveBeenCalledWith(
438
493
  'req-confirm-allow',
@@ -32,6 +32,8 @@ mock.module('../tools/browser/browser-manager.js', () => {
32
32
  getOrCreateSessionPage: getOrCreateSessionPageMock,
33
33
  clearSnapshotMap: mock(() => {}),
34
34
  supportsRouteInterception: true,
35
+ isInteractive: () => false,
36
+ positionWindowSidebar: () => {},
35
37
  },
36
38
  };
37
39
  });
@@ -28,7 +28,6 @@ mock.module('../util/platform.js', () => ({
28
28
  getDbPath: () => join(testDir, 'test.db'),
29
29
  getLogPath: () => join(testDir, 'test.log'),
30
30
  ensureDataDir: () => {},
31
- normalizeAssistantId: (id: string) => id === 'self' ? 'self' : id,
32
31
  readHttpToken: () => 'test-bearer-token',
33
32
  }));
34
33
 
@@ -492,6 +492,8 @@ describe('voice invite HTTP routes', () => {
492
492
  body: JSON.stringify({
493
493
  sourceChannel: 'voice',
494
494
  expectedExternalUserId: '+15551234567',
495
+ friendName: 'Alice',
496
+ guardianName: 'Bob',
495
497
  maxUses: 3,
496
498
  }),
497
499
  });
@@ -514,6 +516,9 @@ describe('voice invite HTTP routes', () => {
514
516
  expect(invite.voiceCodeDigits).toBe(6);
515
517
  // expectedExternalUserId should be recorded
516
518
  expect(invite.expectedExternalUserId).toBe('+15551234567');
519
+ // friendName and guardianName should be recorded
520
+ expect(invite.friendName).toBe('Alice');
521
+ expect(invite.guardianName).toBe('Bob');
517
522
  });
518
523
 
519
524
  test('voice invite creation requires expectedExternalUserId', async () => {
@@ -522,6 +527,8 @@ describe('voice invite HTTP routes', () => {
522
527
  headers: { 'Content-Type': 'application/json' },
523
528
  body: JSON.stringify({
524
529
  sourceChannel: 'voice',
530
+ friendName: 'Alice',
531
+ guardianName: 'Bob',
525
532
  }),
526
533
  });
527
534
 
@@ -540,6 +547,8 @@ describe('voice invite HTTP routes', () => {
540
547
  body: JSON.stringify({
541
548
  sourceChannel: 'voice',
542
549
  expectedExternalUserId: 'not-a-phone-number',
550
+ friendName: 'Alice',
551
+ guardianName: 'Bob',
543
552
  }),
544
553
  });
545
554
 
@@ -551,6 +560,44 @@ describe('voice invite HTTP routes', () => {
551
560
  expect(body.error).toContain('E.164');
552
561
  });
553
562
 
563
+ test('voice invite creation requires friendName', async () => {
564
+ const req = new Request('http://localhost/v1/ingress/invites', {
565
+ method: 'POST',
566
+ headers: { 'Content-Type': 'application/json' },
567
+ body: JSON.stringify({
568
+ sourceChannel: 'voice',
569
+ expectedExternalUserId: '+15551234567',
570
+ guardianName: 'Bob',
571
+ }),
572
+ });
573
+
574
+ const res = await handleCreateInvite(req);
575
+ const body = await res.json() as Record<string, unknown>;
576
+
577
+ expect(res.status).toBe(400);
578
+ expect(body.ok).toBe(false);
579
+ expect(body.error).toContain('friendName');
580
+ });
581
+
582
+ test('voice invite creation requires guardianName', async () => {
583
+ const req = new Request('http://localhost/v1/ingress/invites', {
584
+ method: 'POST',
585
+ headers: { 'Content-Type': 'application/json' },
586
+ body: JSON.stringify({
587
+ sourceChannel: 'voice',
588
+ expectedExternalUserId: '+15551234567',
589
+ friendName: 'Alice',
590
+ }),
591
+ });
592
+
593
+ const res = await handleCreateInvite(req);
594
+ const body = await res.json() as Record<string, unknown>;
595
+
596
+ expect(res.status).toBe(400);
597
+ expect(body.ok).toBe(false);
598
+ expect(body.error).toContain('guardianName');
599
+ });
600
+
554
601
  test('voiceCodeDigits is always 6 — custom values are ignored', async () => {
555
602
  const req = new Request('http://localhost/v1/ingress/invites', {
556
603
  method: 'POST',
@@ -558,6 +605,8 @@ describe('voice invite HTTP routes', () => {
558
605
  body: JSON.stringify({
559
606
  sourceChannel: 'voice',
560
607
  expectedExternalUserId: '+15551234567',
608
+ friendName: 'Alice',
609
+ guardianName: 'Bob',
561
610
  voiceCodeDigits: 8,
562
611
  }),
563
612
  });
@@ -579,6 +628,8 @@ describe('voice invite HTTP routes', () => {
579
628
  body: JSON.stringify({
580
629
  sourceChannel: 'voice',
581
630
  expectedExternalUserId: '+15551234567',
631
+ friendName: 'Alice',
632
+ guardianName: 'Bob',
582
633
  }),
583
634
  });
584
635
 
@@ -600,6 +651,8 @@ describe('voice invite HTTP routes', () => {
600
651
  body: JSON.stringify({
601
652
  sourceChannel: 'voice',
602
653
  expectedExternalUserId: '+15551234567',
654
+ friendName: 'Alice',
655
+ guardianName: 'Bob',
603
656
  maxUses: 1,
604
657
  }),
605
658
  }));
@@ -648,6 +701,8 @@ describe('voice invite HTTP routes', () => {
648
701
  body: JSON.stringify({
649
702
  sourceChannel: 'voice',
650
703
  expectedExternalUserId: '+15551234567',
704
+ friendName: 'Alice',
705
+ guardianName: 'Bob',
651
706
  maxUses: 1,
652
707
  }),
653
708
  }));
@@ -422,16 +422,6 @@ const clientMessages: Record<ClientMessageType, ClientMessage> = {
422
422
  type: 'telegram_config',
423
423
  action: 'get',
424
424
  },
425
- twilio_config: {
426
- type: 'twilio_config',
427
- action: 'get',
428
- },
429
- channel_readiness: {
430
- type: 'channel_readiness',
431
- action: 'get',
432
- channel: 'sms',
433
- includeRemote: true,
434
- },
435
425
  guardian_verification: {
436
426
  type: 'guardian_verification',
437
427
  action: 'create_challenge',
@@ -823,6 +813,24 @@ const serverMessages: Record<ServerMessageType, ServerMessage> = {
823
813
  sandboxed: false,
824
814
  sessionId: 'sess-001',
825
815
  },
816
+ confirmation_state_changed: {
817
+ type: 'confirmation_state_changed',
818
+ sessionId: 'sess-001',
819
+ requestId: 'req-002',
820
+ state: 'approved',
821
+ source: 'inline_nl',
822
+ causedByRequestId: 'req-003',
823
+ decisionText: 'approve',
824
+ },
825
+ assistant_activity_state: {
826
+ type: 'assistant_activity_state',
827
+ sessionId: 'sess-001',
828
+ activityVersion: 1,
829
+ phase: 'thinking',
830
+ anchor: 'assistant_turn',
831
+ requestId: 'req-003',
832
+ reason: 'message_dequeued',
833
+ },
826
834
  message_complete: {
827
835
  type: 'message_complete',
828
836
  sessionId: 'sess-001',
@@ -1446,47 +1454,6 @@ const serverMessages: Record<ServerMessageType, ServerMessage> = {
1446
1454
  connected: true,
1447
1455
  hasWebhookSecret: true,
1448
1456
  },
1449
- twilio_config_response: {
1450
- type: 'twilio_config_response',
1451
- success: true,
1452
- hasCredentials: true,
1453
- phoneNumber: '+15551234567',
1454
- compliance: {
1455
- numberType: 'toll_free',
1456
- verificationSid: 'TF_VER_001',
1457
- verificationStatus: 'TWILIO_APPROVED',
1458
- },
1459
- testResult: {
1460
- messageSid: 'SM-test-001',
1461
- to: '+15559876543',
1462
- initialStatus: 'queued',
1463
- finalStatus: 'delivered',
1464
- },
1465
- diagnostics: {
1466
- readiness: { ready: true, issues: [] },
1467
- compliance: { status: 'TWILIO_APPROVED', detail: 'Toll-free verification: TWILIO_APPROVED' },
1468
- overallStatus: 'healthy',
1469
- actionItems: [],
1470
- },
1471
- },
1472
- channel_readiness_response: {
1473
- type: 'channel_readiness_response',
1474
- success: true,
1475
- snapshots: [
1476
- {
1477
- channel: 'sms',
1478
- ready: false,
1479
- checkedAt: 1700000000000,
1480
- stale: false,
1481
- reasons: [{ code: 'twilio_credentials', text: 'Twilio credentials are not configured' }],
1482
- localChecks: [
1483
- { name: 'twilio_credentials', passed: false, message: 'Twilio credentials are not configured' },
1484
- { name: 'phone_number', passed: true, message: 'Phone number is assigned' },
1485
- { name: 'ingress', passed: true, message: 'Public ingress URL is configured' },
1486
- ],
1487
- },
1488
- ],
1489
- },
1490
1457
  guardian_verification_response: {
1491
1458
  type: 'guardian_verification_response',
1492
1459
  success: true,
@@ -30,7 +30,6 @@ mock.module('../util/platform.js', () => ({
30
30
  getDbPath: () => join(testDir, 'test.db'),
31
31
  getLogPath: () => join(testDir, 'test.log'),
32
32
  ensureDataDir: () => {},
33
- normalizeAssistantId: (id: string) => id === 'self' ? 'self' : id,
34
33
  readHttpToken: () => 'test-bearer-token',
35
34
  }));
36
35
 
@@ -59,16 +58,23 @@ mock.module('../config/env.js', () => ({
59
58
 
60
59
  // Track emitNotificationSignal calls
61
60
  const emitSignalCalls: Array<Record<string, unknown>> = [];
61
+ let mockEmitResult: {
62
+ signalId: string;
63
+ deduplicated: boolean;
64
+ dispatched: boolean;
65
+ reason: string;
66
+ deliveryResults: Array<Record<string, unknown>>;
67
+ } = {
68
+ signalId: 'mock-signal-id',
69
+ deduplicated: false,
70
+ dispatched: true,
71
+ reason: 'mock',
72
+ deliveryResults: [],
73
+ };
62
74
  mock.module('../notifications/emit-signal.js', () => ({
63
75
  emitNotificationSignal: async (params: Record<string, unknown>) => {
64
76
  emitSignalCalls.push(params);
65
- return {
66
- signalId: 'mock-signal-id',
67
- deduplicated: false,
68
- dispatched: true,
69
- reason: 'mock',
70
- deliveryResults: [],
71
- };
77
+ return mockEmitResult;
72
78
  },
73
79
  }));
74
80
 
@@ -80,7 +86,10 @@ mock.module('../runtime/gateway-client.js', () => ({
80
86
  },
81
87
  }));
82
88
 
83
- import { listCanonicalGuardianRequests } from '../memory/canonical-guardian-store.js';
89
+ import {
90
+ listCanonicalGuardianDeliveries,
91
+ listCanonicalGuardianRequests,
92
+ } from '../memory/canonical-guardian-store.js';
84
93
  import {
85
94
  createBinding,
86
95
  } from '../memory/channel-guardian-store.js';
@@ -112,6 +121,17 @@ function resetState(): void {
112
121
  db.run('DELETE FROM canonical_guardian_deliveries');
113
122
  emitSignalCalls.length = 0;
114
123
  deliverReplyCalls.length = 0;
124
+ mockEmitResult = {
125
+ signalId: 'mock-signal-id',
126
+ deduplicated: false,
127
+ dispatched: true,
128
+ reason: 'mock',
129
+ deliveryResults: [],
130
+ };
131
+ }
132
+
133
+ async function flushAsyncAccessRequestBookkeeping(): Promise<void> {
134
+ await new Promise((resolve) => setTimeout(resolve, 0));
115
135
  }
116
136
 
117
137
  function buildInboundRequest(overrides: Record<string, unknown> = {}): Request {
@@ -437,6 +457,34 @@ describe('access-request-helper unit tests', () => {
437
457
  expect(payload.guardianBindingChannel).toBe('telegram');
438
458
  });
439
459
 
460
+ test('notifyGuardianOfAccessRequest for voice channel includes senderName in contextPayload', () => {
461
+ const result = notifyGuardianOfAccessRequest({
462
+ canonicalAssistantId: 'self',
463
+ sourceChannel: 'voice',
464
+ externalChatId: '+15559998888',
465
+ senderExternalUserId: '+15559998888',
466
+ senderName: 'Alice Caller',
467
+ });
468
+
469
+ expect(result.notified).toBe(true);
470
+ expect(emitSignalCalls.length).toBe(1);
471
+
472
+ const payload = emitSignalCalls[0].contextPayload as Record<string, unknown>;
473
+ expect(payload.sourceChannel).toBe('voice');
474
+ expect(payload.senderName).toBe('Alice Caller');
475
+ expect(payload.senderExternalUserId).toBe('+15559998888');
476
+ expect(payload.senderIdentifier).toBe('Alice Caller');
477
+
478
+ // Canonical request should exist
479
+ const pending = listCanonicalGuardianRequests({
480
+ status: 'pending',
481
+ requesterExternalUserId: '+15559998888',
482
+ sourceChannel: 'voice',
483
+ kind: 'access_request',
484
+ });
485
+ expect(pending.length).toBe(1);
486
+ });
487
+
440
488
  test('notifyGuardianOfAccessRequest includes requestCode in contextPayload', () => {
441
489
  const result = notifyGuardianOfAccessRequest({
442
490
  canonicalAssistantId: 'self',
@@ -454,4 +502,106 @@ describe('access-request-helper unit tests', () => {
454
502
  expect(typeof payload.requestCode).toBe('string');
455
503
  expect((payload.requestCode as string).length).toBe(6);
456
504
  });
505
+
506
+ test('notifyGuardianOfAccessRequest includes previousMemberStatus in contextPayload', () => {
507
+ const result = notifyGuardianOfAccessRequest({
508
+ canonicalAssistantId: 'self',
509
+ sourceChannel: 'telegram',
510
+ externalChatId: 'chat-123',
511
+ senderExternalUserId: 'revoked-user',
512
+ senderName: 'Revoked User',
513
+ previousMemberStatus: 'revoked',
514
+ });
515
+
516
+ expect(result.notified).toBe(true);
517
+ expect(emitSignalCalls.length).toBe(1);
518
+
519
+ const payload = emitSignalCalls[0].contextPayload as Record<string, unknown>;
520
+ expect(payload.previousMemberStatus).toBe('revoked');
521
+ });
522
+
523
+ test('notifyGuardianOfAccessRequest persists canonical delivery rows from notification results', async () => {
524
+ mockEmitResult = {
525
+ signalId: 'sig-deliveries',
526
+ deduplicated: false,
527
+ dispatched: true,
528
+ reason: 'ok',
529
+ deliveryResults: [
530
+ {
531
+ channel: 'vellum',
532
+ destination: 'vellum',
533
+ status: 'sent',
534
+ conversationId: 'conv-guardian-access-request',
535
+ },
536
+ {
537
+ channel: 'telegram',
538
+ destination: 'guardian-chat-123',
539
+ status: 'sent',
540
+ },
541
+ ],
542
+ };
543
+
544
+ const result = notifyGuardianOfAccessRequest({
545
+ canonicalAssistantId: 'self',
546
+ sourceChannel: 'voice',
547
+ externalChatId: '+15556667777',
548
+ senderExternalUserId: '+15556667777',
549
+ senderName: 'Noah',
550
+ });
551
+
552
+ expect(result.notified).toBe(true);
553
+ if (!result.notified) return;
554
+
555
+ await flushAsyncAccessRequestBookkeeping();
556
+
557
+ const deliveries = listCanonicalGuardianDeliveries(result.requestId);
558
+ const vellum = deliveries.find((d) => d.destinationChannel === 'vellum');
559
+ const telegram = deliveries.find((d) => d.destinationChannel === 'telegram');
560
+
561
+ expect(vellum).toBeDefined();
562
+ expect(vellum!.destinationConversationId).toBe('conv-guardian-access-request');
563
+ expect(vellum!.status).toBe('sent');
564
+ expect(telegram).toBeDefined();
565
+ expect(telegram!.destinationChatId).toBe('guardian-chat-123');
566
+ expect(telegram!.status).toBe('sent');
567
+ });
568
+
569
+ test('notifyGuardianOfAccessRequest records failed vellum fallback when pipeline has no vellum delivery', async () => {
570
+ mockEmitResult = {
571
+ signalId: 'sig-no-vellum',
572
+ deduplicated: false,
573
+ dispatched: true,
574
+ reason: 'telegram-only',
575
+ deliveryResults: [
576
+ {
577
+ channel: 'telegram',
578
+ destination: 'guardian-chat-456',
579
+ status: 'sent',
580
+ },
581
+ ],
582
+ };
583
+
584
+ const result = notifyGuardianOfAccessRequest({
585
+ canonicalAssistantId: 'self',
586
+ sourceChannel: 'telegram',
587
+ externalChatId: 'chat-123',
588
+ senderExternalUserId: 'unknown-user',
589
+ senderName: 'Alice',
590
+ });
591
+
592
+ expect(result.notified).toBe(true);
593
+ if (!result.notified) return;
594
+
595
+ await flushAsyncAccessRequestBookkeeping();
596
+
597
+ const deliveries = listCanonicalGuardianDeliveries(result.requestId);
598
+ const vellum = deliveries.find((d) => d.destinationChannel === 'vellum');
599
+ const telegram = deliveries.find((d) => d.destinationChannel === 'telegram');
600
+
601
+ expect(vellum).toBeDefined();
602
+ expect(vellum!.status).toBe('failed');
603
+ expect(telegram).toBeDefined();
604
+ expect(telegram!.destinationChatId).toBe('guardian-chat-456');
605
+ expect(telegram!.status).toBe('sent');
606
+ });
457
607
  });
@@ -96,22 +96,27 @@ describe('notification decision fallback copy', () => {
96
96
  expect(decision.renderedCopy.vellum?.body).not.toContain('Action required: guardian.question');
97
97
  });
98
98
 
99
- test('enforces request-code instructions for guardian.question when requestCode exists', async () => {
99
+ test('enforces free-text answer instructions for guardian.question when requestCode exists', async () => {
100
100
  const signal = makeSignal({
101
101
  contextPayload: {
102
+ requestId: 'req-pending-1',
102
103
  questionText: 'What is the gate code?',
103
104
  requestCode: 'A1B2C3',
105
+ requestKind: 'pending_question',
106
+ callSessionId: 'call-1',
107
+ activeGuardianRequestCount: 1,
104
108
  },
105
109
  });
106
110
  const decision = await evaluateSignal(signal, ['vellum'] as NotificationChannel[]);
107
111
 
108
112
  expect(decision.fallbackUsed).toBe(true);
109
113
  expect(decision.renderedCopy.vellum?.body).toContain('A1B2C3');
110
- expect(decision.renderedCopy.vellum?.body).toContain('approve');
111
- expect(decision.renderedCopy.vellum?.body).toContain('reject');
114
+ expect(decision.renderedCopy.vellum?.body).toContain('<your answer>');
115
+ expect(decision.renderedCopy.vellum?.body).not.toContain('approve');
116
+ expect(decision.renderedCopy.vellum?.body).not.toContain('reject');
112
117
  });
113
118
 
114
- test('enforcement appends explicit approve/reject instructions when LLM copy only mentions request code', async () => {
119
+ test('enforcement appends free-text answer instructions when LLM copy only mentions request code', async () => {
115
120
  configuredProvider = {
116
121
  sendMessage: async () => ({ content: [] }),
117
122
  };
@@ -134,8 +139,127 @@ describe('notification decision fallback copy', () => {
134
139
 
135
140
  const signal = makeSignal({
136
141
  contextPayload: {
142
+ requestId: 'req-pending-1',
137
143
  questionText: 'What is the gate code?',
138
144
  requestCode: 'A1B2C3',
145
+ requestKind: 'pending_question',
146
+ callSessionId: 'call-1',
147
+ activeGuardianRequestCount: 1,
148
+ },
149
+ });
150
+
151
+ const decision = await evaluateSignal(signal, ['vellum'] as NotificationChannel[]);
152
+
153
+ expect(decision.fallbackUsed).toBe(false);
154
+ expect(decision.renderedCopy.vellum?.body).toContain('"A1B2C3 <your answer>"');
155
+ expect(decision.renderedCopy.vellum?.body).not.toContain('"A1B2C3 approve"');
156
+ expect(decision.renderedCopy.vellum?.body).not.toContain('"A1B2C3 reject"');
157
+ });
158
+
159
+ test('enforcement appends answer instructions when LLM copy incorrectly uses approve/reject wording', async () => {
160
+ configuredProvider = {
161
+ sendMessage: async () => ({ content: [] }),
162
+ };
163
+ extractedToolUse = {
164
+ name: 'record_notification_decision',
165
+ input: {
166
+ shouldNotify: true,
167
+ selectedChannels: ['vellum'],
168
+ reasoningSummary: 'LLM decision',
169
+ renderedCopy: {
170
+ vellum: {
171
+ title: 'Guardian Question',
172
+ body: 'Reference code: A1B2C3. Reply "A1B2C3 approve" or "A1B2C3 reject".',
173
+ },
174
+ },
175
+ dedupeKey: 'guardian-question-wrong-instructions-test',
176
+ confidence: 0.9,
177
+ },
178
+ };
179
+
180
+ const signal = makeSignal({
181
+ contextPayload: {
182
+ requestId: 'req-pending-approve-phrasing',
183
+ questionText: 'What is the gate code?',
184
+ requestCode: 'A1B2C3',
185
+ requestKind: 'pending_question',
186
+ callSessionId: 'call-1',
187
+ activeGuardianRequestCount: 1,
188
+ },
189
+ });
190
+
191
+ const decision = await evaluateSignal(signal, ['vellum'] as NotificationChannel[]);
192
+
193
+ expect(decision.fallbackUsed).toBe(false);
194
+ expect(decision.renderedCopy.vellum?.body).toContain('"A1B2C3 <your answer>"');
195
+ });
196
+
197
+ test('enforcement appends explicit approve/reject instructions for tool-approval guardian questions', async () => {
198
+ configuredProvider = {
199
+ sendMessage: async () => ({ content: [] }),
200
+ };
201
+ extractedToolUse = {
202
+ name: 'record_notification_decision',
203
+ input: {
204
+ shouldNotify: true,
205
+ selectedChannels: ['vellum'],
206
+ reasoningSummary: 'LLM decision',
207
+ renderedCopy: {
208
+ vellum: {
209
+ title: 'Guardian Question',
210
+ body: 'Use reference code A1B2C3 for this request.',
211
+ },
212
+ },
213
+ dedupeKey: 'guardian-question-tool-approval-test',
214
+ confidence: 0.9,
215
+ },
216
+ };
217
+
218
+ const signal = makeSignal({
219
+ contextPayload: {
220
+ requestId: 'req-grant-1',
221
+ questionText: 'Allow running host_bash?',
222
+ requestCode: 'A1B2C3',
223
+ requestKind: 'tool_grant_request',
224
+ toolName: 'host_bash',
225
+ },
226
+ });
227
+
228
+ const decision = await evaluateSignal(signal, ['vellum'] as NotificationChannel[]);
229
+
230
+ expect(decision.fallbackUsed).toBe(false);
231
+ expect(decision.renderedCopy.vellum?.body).toContain('"A1B2C3 approve"');
232
+ expect(decision.renderedCopy.vellum?.body).toContain('"A1B2C3 reject"');
233
+ });
234
+
235
+ test('approval-mode enforcement removes conflicting answer-mode phrasing', async () => {
236
+ configuredProvider = {
237
+ sendMessage: async () => ({ content: [] }),
238
+ };
239
+ extractedToolUse = {
240
+ name: 'record_notification_decision',
241
+ input: {
242
+ shouldNotify: true,
243
+ selectedChannels: ['vellum'],
244
+ reasoningSummary: 'LLM decision',
245
+ renderedCopy: {
246
+ vellum: {
247
+ title: 'Guardian Question',
248
+ body: 'Reference code: A1B2C3. Reply "A1B2C3 <your answer>".',
249
+ },
250
+ },
251
+ dedupeKey: 'guardian-question-approval-removes-answer-test',
252
+ confidence: 0.9,
253
+ },
254
+ };
255
+
256
+ const signal = makeSignal({
257
+ contextPayload: {
258
+ requestId: 'req-grant-2',
259
+ questionText: 'Allow running host_bash?',
260
+ requestCode: 'A1B2C3',
261
+ requestKind: 'tool_grant_request',
262
+ toolName: 'host_bash',
139
263
  },
140
264
  });
141
265
 
@@ -144,5 +268,6 @@ describe('notification decision fallback copy', () => {
144
268
  expect(decision.fallbackUsed).toBe(false);
145
269
  expect(decision.renderedCopy.vellum?.body).toContain('"A1B2C3 approve"');
146
270
  expect(decision.renderedCopy.vellum?.body).toContain('"A1B2C3 reject"');
271
+ expect(decision.renderedCopy.vellum?.body).not.toContain('<your answer>');
147
272
  });
148
273
  });