@vellumai/assistant 0.4.3 → 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 (183) hide show
  1. package/.env.example +3 -0
  2. package/ARCHITECTURE.md +40 -3
  3. package/README.md +43 -35
  4. package/package.json +1 -1
  5. package/scripts/ipc/generate-swift.ts +1 -0
  6. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +58 -120
  7. package/src/__tests__/actor-token-service.test.ts +1099 -0
  8. package/src/__tests__/agent-loop.test.ts +51 -0
  9. package/src/__tests__/approval-routes-http.test.ts +2 -0
  10. package/src/__tests__/assistant-events-sse-hardening.test.ts +7 -5
  11. package/src/__tests__/assistant-id-boundary-guard.test.ts +125 -0
  12. package/src/__tests__/call-controller.test.ts +49 -0
  13. package/src/__tests__/call-pointer-message-composer.test.ts +171 -0
  14. package/src/__tests__/call-pointer-messages.test.ts +93 -3
  15. package/src/__tests__/call-pointer-no-hardcoded-copy.guard.test.ts +42 -0
  16. package/src/__tests__/callback-handoff-copy.test.ts +186 -0
  17. package/src/__tests__/channel-approval-routes.test.ts +133 -12
  18. package/src/__tests__/channel-guardian.test.ts +0 -87
  19. package/src/__tests__/channel-readiness-service.test.ts +10 -16
  20. package/src/__tests__/checker.test.ts +33 -12
  21. package/src/__tests__/config-schema.test.ts +4 -0
  22. package/src/__tests__/confirmation-request-guardian-bridge.test.ts +410 -0
  23. package/src/__tests__/conversation-routes-guardian-reply.test.ts +256 -0
  24. package/src/__tests__/conversation-routes.test.ts +12 -3
  25. package/src/__tests__/credential-security-invariants.test.ts +1 -1
  26. package/src/__tests__/daemon-server-session-init.test.ts +4 -0
  27. package/src/__tests__/guardian-actions-endpoint.test.ts +19 -14
  28. package/src/__tests__/guardian-dispatch.test.ts +8 -0
  29. package/src/__tests__/guardian-outbound-http.test.ts +4 -4
  30. package/src/__tests__/guardian-question-mode.test.ts +200 -0
  31. package/src/__tests__/guardian-routing-invariants.test.ts +178 -0
  32. package/src/__tests__/guardian-routing-state.test.ts +525 -0
  33. package/src/__tests__/handle-user-message-secret-resume.test.ts +2 -0
  34. package/src/__tests__/handlers-telegram-config.test.ts +0 -83
  35. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +55 -0
  36. package/src/__tests__/headless-browser-navigate.test.ts +2 -0
  37. package/src/__tests__/ipc-snapshot.test.ts +18 -51
  38. package/src/__tests__/non-member-access-request.test.ts +131 -8
  39. package/src/__tests__/notification-decision-fallback.test.ts +129 -4
  40. package/src/__tests__/notification-decision-strategy.test.ts +62 -2
  41. package/src/__tests__/notification-guardian-path.test.ts +3 -0
  42. package/src/__tests__/recording-intent-handler.test.ts +1 -0
  43. package/src/__tests__/relay-server.test.ts +841 -39
  44. package/src/__tests__/send-endpoint-busy.test.ts +5 -0
  45. package/src/__tests__/session-agent-loop.test.ts +1 -0
  46. package/src/__tests__/session-confirmation-signals.test.ts +523 -0
  47. package/src/__tests__/session-init.benchmark.test.ts +0 -1
  48. package/src/__tests__/session-surfaces-task-progress.test.ts +1 -1
  49. package/src/__tests__/session-tool-setup-app-refresh.test.ts +81 -2
  50. package/src/__tests__/session-tool-setup-memory-scope.test.ts +1 -1
  51. package/src/__tests__/session-tool-setup-side-effect-flag.test.ts +1 -1
  52. package/src/__tests__/tool-executor.test.ts +21 -2
  53. package/src/__tests__/tool-grant-request-escalation.test.ts +333 -27
  54. package/src/__tests__/trusted-contact-approval-notifier.test.ts +678 -0
  55. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +1064 -0
  56. package/src/__tests__/twilio-config.test.ts +2 -13
  57. package/src/agent/loop.ts +1 -1
  58. package/src/approvals/guardian-decision-primitive.ts +10 -2
  59. package/src/approvals/guardian-request-resolvers.ts +128 -9
  60. package/src/calls/call-constants.ts +21 -0
  61. package/src/calls/call-controller.ts +9 -2
  62. package/src/calls/call-domain.ts +28 -7
  63. package/src/calls/call-pointer-message-composer.ts +154 -0
  64. package/src/calls/call-pointer-messages.ts +106 -27
  65. package/src/calls/guardian-dispatch.ts +4 -2
  66. package/src/calls/relay-server.ts +424 -12
  67. package/src/calls/twilio-config.ts +4 -11
  68. package/src/calls/twilio-routes.ts +1 -1
  69. package/src/calls/types.ts +3 -1
  70. package/src/cli.ts +5 -4
  71. package/src/config/bundled-skills/agentmail/SKILL.md +4 -0
  72. package/src/config/bundled-skills/app-builder/SKILL.md +146 -10
  73. package/src/config/bundled-skills/app-builder/TOOLS.json +1 -1
  74. package/src/config/bundled-skills/email-setup/SKILL.md +1 -1
  75. package/src/config/bundled-skills/google-oauth-setup/SKILL.md +105 -81
  76. package/src/config/bundled-skills/messaging/SKILL.md +61 -12
  77. package/src/config/bundled-skills/messaging/TOOLS.json +58 -0
  78. package/src/config/bundled-skills/messaging/tools/gmail-sender-digest.ts +6 -1
  79. package/src/config/bundled-skills/messaging/tools/messaging-archive-by-sender.ts +35 -0
  80. package/src/config/bundled-skills/messaging/tools/messaging-sender-digest.ts +52 -0
  81. package/src/config/bundled-skills/phone-calls/SKILL.md +30 -39
  82. package/src/config/bundled-skills/twitter/SKILL.md +3 -3
  83. package/src/config/bundled-skills/vercel-token-setup/SKILL.md +1 -0
  84. package/src/config/calls-schema.ts +24 -0
  85. package/src/config/env.ts +22 -0
  86. package/src/config/feature-flag-registry.json +8 -0
  87. package/src/config/schema.ts +2 -2
  88. package/src/config/skills.ts +11 -0
  89. package/src/config/system-prompt.ts +11 -1
  90. package/src/config/templates/SOUL.md +2 -0
  91. package/src/config/vellum-skills/sms-setup/SKILL.md +71 -82
  92. package/src/config/vellum-skills/trusted-contacts/SKILL.md +10 -9
  93. package/src/config/vellum-skills/twilio-setup/SKILL.md +88 -73
  94. package/src/daemon/call-pointer-generators.ts +59 -0
  95. package/src/daemon/computer-use-session.ts +2 -5
  96. package/src/daemon/handlers/apps.ts +76 -20
  97. package/src/daemon/handlers/config-channels.ts +5 -55
  98. package/src/daemon/handlers/config-inbox.ts +9 -3
  99. package/src/daemon/handlers/config-ingress.ts +28 -3
  100. package/src/daemon/handlers/config-telegram.ts +12 -0
  101. package/src/daemon/handlers/config.ts +2 -6
  102. package/src/daemon/handlers/pairing.ts +2 -0
  103. package/src/daemon/handlers/sessions.ts +48 -3
  104. package/src/daemon/handlers/shared.ts +17 -2
  105. package/src/daemon/ipc-contract/integrations.ts +1 -99
  106. package/src/daemon/ipc-contract/messages.ts +47 -1
  107. package/src/daemon/ipc-contract/notifications.ts +11 -0
  108. package/src/daemon/ipc-contract-inventory.json +2 -4
  109. package/src/daemon/lifecycle.ts +17 -0
  110. package/src/daemon/server.ts +14 -1
  111. package/src/daemon/session-agent-loop-handlers.ts +20 -0
  112. package/src/daemon/session-agent-loop.ts +22 -11
  113. package/src/daemon/session-lifecycle.ts +1 -1
  114. package/src/daemon/session-process.ts +11 -1
  115. package/src/daemon/session-runtime-assembly.ts +3 -0
  116. package/src/daemon/session-surfaces.ts +3 -2
  117. package/src/daemon/session.ts +88 -1
  118. package/src/daemon/tool-side-effects.ts +22 -0
  119. package/src/home-base/prebuilt/brain-graph.html +1483 -0
  120. package/src/home-base/prebuilt/index.html +40 -0
  121. package/src/inbound/platform-callback-registration.ts +157 -0
  122. package/src/memory/canonical-guardian-store.ts +1 -1
  123. package/src/memory/db-init.ts +4 -0
  124. package/src/memory/migrations/038-actor-token-records.ts +39 -0
  125. package/src/memory/migrations/index.ts +1 -0
  126. package/src/memory/schema.ts +16 -0
  127. package/src/messaging/provider-types.ts +24 -0
  128. package/src/messaging/provider.ts +7 -0
  129. package/src/messaging/providers/gmail/adapter.ts +127 -0
  130. package/src/messaging/providers/sms/adapter.ts +40 -37
  131. package/src/notifications/adapters/macos.ts +45 -2
  132. package/src/notifications/broadcaster.ts +16 -0
  133. package/src/notifications/copy-composer.ts +39 -1
  134. package/src/notifications/decision-engine.ts +22 -9
  135. package/src/notifications/destination-resolver.ts +16 -2
  136. package/src/notifications/emit-signal.ts +16 -8
  137. package/src/notifications/guardian-question-mode.ts +419 -0
  138. package/src/notifications/signal.ts +14 -3
  139. package/src/permissions/checker.ts +13 -1
  140. package/src/permissions/prompter.ts +14 -0
  141. package/src/providers/anthropic/client.ts +20 -0
  142. package/src/providers/provider-send-message.ts +15 -3
  143. package/src/runtime/access-request-helper.ts +71 -1
  144. package/src/runtime/actor-token-service.ts +234 -0
  145. package/src/runtime/actor-token-store.ts +236 -0
  146. package/src/runtime/channel-approvals.ts +5 -3
  147. package/src/runtime/channel-readiness-service.ts +23 -64
  148. package/src/runtime/channel-readiness-types.ts +3 -4
  149. package/src/runtime/channel-retry-sweep.ts +4 -1
  150. package/src/runtime/confirmation-request-guardian-bridge.ts +197 -0
  151. package/src/runtime/guardian-action-followup-executor.ts +1 -1
  152. package/src/runtime/guardian-context-resolver.ts +82 -0
  153. package/src/runtime/guardian-outbound-actions.ts +0 -3
  154. package/src/runtime/guardian-reply-router.ts +67 -30
  155. package/src/runtime/guardian-vellum-migration.ts +57 -0
  156. package/src/runtime/http-server.ts +65 -12
  157. package/src/runtime/http-types.ts +13 -0
  158. package/src/runtime/invite-redemption-service.ts +8 -0
  159. package/src/runtime/local-actor-identity.ts +76 -0
  160. package/src/runtime/middleware/actor-token.ts +271 -0
  161. package/src/runtime/routes/approval-routes.ts +82 -7
  162. package/src/runtime/routes/brain-graph-routes.ts +222 -0
  163. package/src/runtime/routes/channel-readiness-routes.ts +71 -0
  164. package/src/runtime/routes/conversation-routes.ts +140 -52
  165. package/src/runtime/routes/events-routes.ts +20 -5
  166. package/src/runtime/routes/guardian-action-routes.ts +45 -3
  167. package/src/runtime/routes/guardian-approval-interception.ts +29 -0
  168. package/src/runtime/routes/guardian-bootstrap-routes.ts +145 -0
  169. package/src/runtime/routes/inbound-message-handler.ts +143 -2
  170. package/src/runtime/routes/integration-routes.ts +7 -15
  171. package/src/runtime/routes/pairing-routes.ts +163 -0
  172. package/src/runtime/routes/twilio-routes.ts +934 -0
  173. package/src/runtime/tool-grant-request-helper.ts +3 -1
  174. package/src/security/oauth2.ts +27 -2
  175. package/src/security/token-manager.ts +46 -10
  176. package/src/tools/browser/browser-execution.ts +4 -3
  177. package/src/tools/browser/browser-handoff.ts +10 -18
  178. package/src/tools/browser/browser-manager.ts +80 -25
  179. package/src/tools/browser/browser-screencast.ts +35 -119
  180. package/src/tools/permission-checker.ts +15 -4
  181. package/src/tools/tool-approval-handler.ts +242 -18
  182. package/src/__tests__/handlers-twilio-config.test.ts +0 -1928
  183. 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
  });
@@ -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,
@@ -58,16 +58,23 @@ mock.module('../config/env.js', () => ({
58
58
 
59
59
  // Track emitNotificationSignal calls
60
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
+ };
61
74
  mock.module('../notifications/emit-signal.js', () => ({
62
75
  emitNotificationSignal: async (params: Record<string, unknown>) => {
63
76
  emitSignalCalls.push(params);
64
- return {
65
- signalId: 'mock-signal-id',
66
- deduplicated: false,
67
- dispatched: true,
68
- reason: 'mock',
69
- deliveryResults: [],
70
- };
77
+ return mockEmitResult;
71
78
  },
72
79
  }));
73
80
 
@@ -79,7 +86,10 @@ mock.module('../runtime/gateway-client.js', () => ({
79
86
  },
80
87
  }));
81
88
 
82
- import { listCanonicalGuardianRequests } from '../memory/canonical-guardian-store.js';
89
+ import {
90
+ listCanonicalGuardianDeliveries,
91
+ listCanonicalGuardianRequests,
92
+ } from '../memory/canonical-guardian-store.js';
83
93
  import {
84
94
  createBinding,
85
95
  } from '../memory/channel-guardian-store.js';
@@ -111,6 +121,17 @@ function resetState(): void {
111
121
  db.run('DELETE FROM canonical_guardian_deliveries');
112
122
  emitSignalCalls.length = 0;
113
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));
114
135
  }
115
136
 
116
137
  function buildInboundRequest(overrides: Record<string, unknown> = {}): Request {
@@ -481,4 +502,106 @@ describe('access-request-helper unit tests', () => {
481
502
  expect(typeof payload.requestCode).toBe('string');
482
503
  expect((payload.requestCode as string).length).toBe(6);
483
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
+ });
484
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
  });
@@ -55,21 +55,67 @@ describe('notification decision strategy', () => {
55
55
  expect(copy.vellum!.body).toContain('What is the gate code?');
56
56
  });
57
57
 
58
- test('guardian.question template includes request-code instructions when present', () => {
58
+ test('guardian.question template includes free-text answer instructions when requestCode is present', () => {
59
59
  const signal = makeSignal({
60
60
  sourceEventName: 'guardian.question',
61
61
  contextPayload: {
62
+ requestId: 'req-pending-1',
62
63
  questionText: 'What is the gate code?',
63
64
  requestCode: 'A1B2C3',
65
+ requestKind: 'pending_question',
66
+ callSessionId: 'call-1',
67
+ activeGuardianRequestCount: 1,
64
68
  },
65
69
  });
66
70
 
67
71
  const copy = composeFallbackCopy(signal, channels);
68
72
  expect(copy.vellum).toBeDefined();
69
73
  expect(copy.vellum!.body).toContain('A1B2C3');
74
+ expect(copy.vellum!.body).toContain('<your answer>');
75
+ expect(copy.vellum!.body).not.toContain('approve');
76
+ expect(copy.vellum!.body).not.toContain('reject');
77
+ expect(copy.telegram!.deliveryText).toContain('A1B2C3');
78
+ });
79
+
80
+ test('guardian.question template uses approve/reject instructions for approval-kind request', () => {
81
+ const signal = makeSignal({
82
+ sourceEventName: 'guardian.question',
83
+ contextPayload: {
84
+ requestId: 'req-grant-1',
85
+ questionText: 'Allow running host_bash?',
86
+ requestCode: 'D4E5F6',
87
+ requestKind: 'tool_grant_request',
88
+ toolName: 'host_bash',
89
+ },
90
+ });
91
+
92
+ const copy = composeFallbackCopy(signal, channels);
93
+ expect(copy.vellum).toBeDefined();
94
+ expect(copy.vellum!.body).toContain('D4E5F6');
70
95
  expect(copy.vellum!.body).toContain('approve');
71
96
  expect(copy.vellum!.body).toContain('reject');
72
- expect(copy.telegram!.deliveryText).toContain('A1B2C3');
97
+ });
98
+
99
+ test('guardian.question template uses approve/reject for tool-backed pending_question payloads', () => {
100
+ const signal = makeSignal({
101
+ sourceEventName: 'guardian.question',
102
+ contextPayload: {
103
+ requestId: 'req-voice-tool-1',
104
+ questionText: 'Allow send_email to bob@example.com?',
105
+ requestCode: 'A1B2C3',
106
+ requestKind: 'pending_question',
107
+ callSessionId: 'call-1',
108
+ activeGuardianRequestCount: 1,
109
+ toolName: 'send_email',
110
+ },
111
+ });
112
+
113
+ const copy = composeFallbackCopy(signal, channels);
114
+ expect(copy.vellum).toBeDefined();
115
+ expect(copy.vellum!.body).toContain('A1B2C3');
116
+ expect(copy.vellum!.body).toContain('approve');
117
+ expect(copy.vellum!.body).toContain('reject');
118
+ expect(copy.vellum!.body).not.toContain('<your answer>');
73
119
  });
74
120
 
75
121
  test('reminder.fired template uses message from payload', () => {
@@ -196,6 +242,20 @@ describe('notification decision strategy', () => {
196
242
  expect(copy.vellum!.body).toContain('open invite flow');
197
243
  });
198
244
 
245
+ test('ingress.access_request template includes revoked-member context when provided', () => {
246
+ const signal = makeSignal({
247
+ sourceEventName: 'ingress.access_request',
248
+ contextPayload: {
249
+ senderIdentifier: 'Charlie',
250
+ previousMemberStatus: 'revoked',
251
+ },
252
+ });
253
+
254
+ const copy = composeFallbackCopy(signal, channels);
255
+ expect(copy.vellum).toBeDefined();
256
+ expect(copy.vellum!.body).toContain('previously revoked');
257
+ });
258
+
199
259
  test('ingress.access_request template includes caller name for voice-originated requests', () => {
200
260
  // In production, senderIdentifier resolves to senderName for voice
201
261
  // calls (senderName || senderUsername || senderExternalUserId), so
@@ -181,6 +181,9 @@ describe('ASK_GUARDIAN canonical notification path', () => {
181
181
  const payload = signalParams.contextPayload as Record<string, unknown>;
182
182
  expect(payload.questionText).toBe('What is the gate code?');
183
183
  expect(payload.callSessionId).toBe(session.id);
184
+ expect(payload.requestKind).toBe('pending_question');
185
+ expect(payload.toolName).toBeUndefined();
186
+ expect(payload.pendingQuestionId).toBeUndefined();
184
187
  expect(payload.requestId).toBeDefined();
185
188
  expect(payload.requestCode).toBeDefined();
186
189
  });
@@ -391,6 +391,7 @@ function createCtx(overrides?: Partial<HandlerContext>): {
391
391
  setChannelCapabilities: noop,
392
392
  setGuardianContext: noop,
393
393
  setCommandIntent: noop,
394
+ updateClient: noop,
394
395
  processMessage: async () => {},
395
396
  getQueueDepth: () => 0,
396
397
  setPreactivatedSkillIds: noop,