@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
@@ -56,6 +56,12 @@ const mockConfig = {
56
56
  provider: 'twilio',
57
57
  maxDurationSeconds: 3600,
58
58
  userConsultTimeoutSeconds: 120,
59
+ ttsPlaybackDelayMs: 0,
60
+ accessRequestPollIntervalMs: 50,
61
+ guardianWaitUpdateInitialIntervalMs: 100,
62
+ guardianWaitUpdateInitialWindowMs: 300,
63
+ guardianWaitUpdateSteadyMinIntervalMs: 150,
64
+ guardianWaitUpdateSteadyMaxIntervalMs: 200,
59
65
  disclosure: { enabled: false, text: '' },
60
66
  safety: { denyCategories: [] },
61
67
  callerIdentity: {
@@ -135,15 +141,18 @@ import {
135
141
  import type { RelayWebSocketData } from '../calls/relay-server.js';
136
142
  import { activeRelayConnections,RelayConnection } from '../calls/relay-server.js';
137
143
  import { setVoiceBridgeDeps } from '../calls/voice-session-bridge.js';
144
+ import { listCanonicalGuardianRequests, resolveCanonicalGuardianRequest } from '../memory/canonical-guardian-store.js';
138
145
  import { createBinding, createChallenge } from '../memory/channel-guardian-store.js';
139
146
  import { addMessage, getMessages } from '../memory/conversation-store.js';
140
147
  import { getDb, initializeDb, resetDb } from '../memory/db.js';
148
+ import { createInvite } from '../memory/ingress-invite-store.js';
141
149
  import { upsertMember } from '../memory/ingress-member-store.js';
142
150
  import { conversations } from '../memory/schema.js';
143
151
  import {
144
152
  createOutboundSession,
145
153
  getGuardianBinding,
146
154
  } from '../runtime/channel-guardian-service.js';
155
+ import { generateVoiceCode, hashVoiceCode } from '../util/voice-code.js';
147
156
 
148
157
  initializeDb();
149
158
 
@@ -205,9 +214,12 @@ function resetTables() {
205
214
  db.run('DELETE FROM messages');
206
215
  db.run('DELETE FROM conversations');
207
216
  db.run('DELETE FROM assistant_ingress_members');
217
+ db.run('DELETE FROM assistant_ingress_invites');
208
218
  db.run('DELETE FROM channel_guardian_verification_challenges');
209
219
  db.run('DELETE FROM channel_guardian_bindings');
210
220
  db.run('DELETE FROM channel_guardian_rate_limits');
221
+ db.run('DELETE FROM canonical_guardian_requests');
222
+ db.run('DELETE FROM canonical_guardian_deliveries');
211
223
  ensuredConvIds = new Set();
212
224
  }
213
225
 
@@ -733,7 +745,7 @@ describe('relay-server', () => {
733
745
  expect(getLatestAssistantText('conv-relay-verify-race')).toContain('**Call failed**');
734
746
 
735
747
  // Let the delayed endSession callback flush to avoid timer bleed across tests.
736
- await new Promise((resolve) => setTimeout(resolve, 2100));
748
+ await new Promise((resolve) => setTimeout(resolve, 100));
737
749
 
738
750
  const finalState = getCallSession(session.id);
739
751
  expect(finalState).not.toBeNull();
@@ -1132,12 +1144,12 @@ describe('relay-server', () => {
1132
1144
  provider: 'twilio',
1133
1145
  fromNumber: '+15559999999',
1134
1146
  toNumber: '+15551111111',
1135
- assistantId: 'test-assistant',
1147
+ assistantId: 'self',
1136
1148
  // no task — inbound call
1137
1149
  });
1138
1150
 
1139
1151
  // Create a pending voice guardian challenge
1140
- const secret = createPendingVoiceGuardianChallenge('test-assistant');
1152
+ const secret = createPendingVoiceGuardianChallenge('self');
1141
1153
 
1142
1154
  mockSendMessage.mockImplementation(createMockProviderResponse(['Hello, how can I help you?']));
1143
1155
 
@@ -1172,7 +1184,7 @@ describe('relay-server', () => {
1172
1184
  expect(relay.getConnectionState()).toBe('connected');
1173
1185
 
1174
1186
  // Guardian binding should have been created
1175
- const binding = getGuardianBinding('test-assistant', 'voice');
1187
+ const binding = getGuardianBinding('self', 'voice');
1176
1188
  expect(binding).not.toBeNull();
1177
1189
 
1178
1190
  // Orchestrator greeting should have fired
@@ -1196,10 +1208,10 @@ describe('relay-server', () => {
1196
1208
  provider: 'twilio',
1197
1209
  fromNumber: '+15559999999',
1198
1210
  toNumber: '+15551111111',
1199
- assistantId: 'test-assistant',
1211
+ assistantId: 'self',
1200
1212
  });
1201
1213
 
1202
- const secret = createPendingVoiceGuardianChallenge('test-assistant');
1214
+ const secret = createPendingVoiceGuardianChallenge('self');
1203
1215
 
1204
1216
  mockSendMessage.mockImplementation(createMockProviderResponse(['Hello, verified caller!']));
1205
1217
 
@@ -1230,7 +1242,7 @@ describe('relay-server', () => {
1230
1242
  expect(relay.getConnectionState()).toBe('connected');
1231
1243
 
1232
1244
  // Binding created
1233
- const binding = getGuardianBinding('test-assistant', 'voice');
1245
+ const binding = getGuardianBinding('self', 'voice');
1234
1246
  expect(binding).not.toBeNull();
1235
1247
 
1236
1248
  // Greeting should have started
@@ -1249,11 +1261,11 @@ describe('relay-server', () => {
1249
1261
  provider: 'twilio',
1250
1262
  fromNumber: '+15550001111',
1251
1263
  toNumber: '+15551111111',
1252
- assistantId: 'test-assistant',
1264
+ assistantId: 'self',
1253
1265
  });
1254
1266
 
1255
1267
  createBinding({
1256
- assistantId: 'test-assistant',
1268
+ assistantId: 'self',
1257
1269
  channel: 'voice',
1258
1270
  guardianExternalUserId: '+15550001111',
1259
1271
  guardianDeliveryChatId: '+15550001111',
@@ -1285,16 +1297,16 @@ describe('relay-server', () => {
1285
1297
  provider: 'twilio',
1286
1298
  fromNumber: '+15550002222',
1287
1299
  toNumber: '+15551111111',
1288
- assistantId: 'test-assistant',
1300
+ assistantId: 'self',
1289
1301
  });
1290
1302
 
1291
1303
  createBinding({
1292
- assistantId: 'test-assistant',
1304
+ assistantId: 'self',
1293
1305
  channel: 'voice',
1294
1306
  guardianExternalUserId: '+15550009999',
1295
1307
  guardianDeliveryChatId: '+15550009999',
1296
1308
  });
1297
- addTrustedVoiceContact('+15550002222', 'test-assistant');
1309
+ addTrustedVoiceContact('+15550002222', 'self');
1298
1310
 
1299
1311
  mockSendMessage.mockImplementation(createMockProviderResponse(['Hello there.']));
1300
1312
 
@@ -1331,12 +1343,12 @@ describe('relay-server', () => {
1331
1343
  provider: 'twilio',
1332
1344
  fromNumber: '+15551111111',
1333
1345
  toNumber: '+15550001111',
1334
- assistantId: 'test-assistant',
1346
+ assistantId: 'self',
1335
1347
  initiatedFromConversationId: 'conv-guardian-outbound-voice-origin',
1336
1348
  });
1337
1349
 
1338
1350
  createBinding({
1339
- assistantId: 'test-assistant',
1351
+ assistantId: 'self',
1340
1352
  channel: 'voice',
1341
1353
  guardianExternalUserId: '+15550001111',
1342
1354
  guardianDeliveryChatId: '+15550001111',
@@ -1375,12 +1387,12 @@ describe('relay-server', () => {
1375
1387
  provider: 'twilio',
1376
1388
  fromNumber: '+15551111111',
1377
1389
  toNumber: '+15550001111',
1378
- assistantId: 'test-assistant',
1390
+ assistantId: 'self',
1379
1391
  initiatedFromConversationId: 'conv-guardian-outbound-strict-origin',
1380
1392
  });
1381
1393
 
1382
1394
  createBinding({
1383
- assistantId: 'test-assistant',
1395
+ assistantId: 'self',
1384
1396
  channel: 'telegram',
1385
1397
  guardianExternalUserId: 'tg-guardian-user',
1386
1398
  guardianDeliveryChatId: 'tg-guardian-chat',
@@ -1419,10 +1431,10 @@ describe('relay-server', () => {
1419
1431
  provider: 'twilio',
1420
1432
  fromNumber: '+15550003333',
1421
1433
  toNumber: '+15551111111',
1422
- assistantId: 'test-assistant',
1434
+ assistantId: 'self',
1423
1435
  });
1424
1436
 
1425
- const secret = createPendingVoiceGuardianChallenge('test-assistant');
1437
+ const secret = createPendingVoiceGuardianChallenge('self');
1426
1438
  const spokenCode = secret.split('').join(' ');
1427
1439
 
1428
1440
  const { relay } = createMockWs(session.id);
@@ -1465,10 +1477,10 @@ describe('relay-server', () => {
1465
1477
  provider: 'twilio',
1466
1478
  fromNumber: '+15559999999',
1467
1479
  toNumber: '+15551111111',
1468
- assistantId: 'test-assistant',
1480
+ assistantId: 'self',
1469
1481
  });
1470
1482
 
1471
- createPendingVoiceGuardianChallenge('test-assistant');
1483
+ createPendingVoiceGuardianChallenge('self');
1472
1484
 
1473
1485
  const { ws, relay } = createMockWs(session.id);
1474
1486
 
@@ -1506,10 +1518,10 @@ describe('relay-server', () => {
1506
1518
  provider: 'twilio',
1507
1519
  fromNumber: '+15559999999',
1508
1520
  toNumber: '+15551111111',
1509
- assistantId: 'test-assistant',
1521
+ assistantId: 'self',
1510
1522
  });
1511
1523
 
1512
- createPendingVoiceGuardianChallenge('test-assistant');
1524
+ createPendingVoiceGuardianChallenge('self');
1513
1525
 
1514
1526
  const { ws, relay } = createMockWs(session.id);
1515
1527
 
@@ -1546,7 +1558,7 @@ describe('relay-server', () => {
1546
1558
  expect(events.some((e) => e.eventType === 'guardian_voice_verification_failed')).toBe(true);
1547
1559
 
1548
1560
  // Let the delayed endSession callback flush
1549
- await new Promise((resolve) => setTimeout(resolve, 2100));
1561
+ await new Promise((resolve) => setTimeout(resolve, 100));
1550
1562
 
1551
1563
  // Verify end message was sent
1552
1564
  const endMessages = ws.sentMessages
@@ -1564,14 +1576,14 @@ describe('relay-server', () => {
1564
1576
  provider: 'twilio',
1565
1577
  fromNumber: '+15559999999',
1566
1578
  toNumber: '+15551111111',
1567
- assistantId: 'test-assistant',
1579
+ assistantId: 'self',
1568
1580
  // no task — inbound call
1569
1581
  });
1570
1582
 
1571
1583
  // Do NOT create any pending challenge
1572
1584
 
1573
1585
  mockSendMessage.mockImplementation(createMockProviderResponse(['Welcome to the line.']));
1574
- addTrustedVoiceContact('+15559999999', 'test-assistant');
1586
+ addTrustedVoiceContact('+15559999999', 'self');
1575
1587
 
1576
1588
  const { ws, relay } = createMockWs(session.id);
1577
1589
 
@@ -1604,10 +1616,10 @@ describe('relay-server', () => {
1604
1616
  provider: 'twilio',
1605
1617
  fromNumber: '+15559999999',
1606
1618
  toNumber: '+15551111111',
1607
- assistantId: 'test-assistant',
1619
+ assistantId: 'self',
1608
1620
  });
1609
1621
 
1610
- createPendingVoiceGuardianChallenge('test-assistant');
1622
+ createPendingVoiceGuardianChallenge('self');
1611
1623
 
1612
1624
  const { ws, relay } = createMockWs(session.id);
1613
1625
 
@@ -1651,13 +1663,13 @@ describe('relay-server', () => {
1651
1663
  provider: 'twilio',
1652
1664
  fromNumber: '+15551111111',
1653
1665
  toNumber: '+15559999999',
1654
- assistantId: 'test-assistant',
1666
+ assistantId: 'self',
1655
1667
  callMode: 'guardian_verification',
1656
1668
  guardianVerificationSessionId: 'gv-session-ptr-success',
1657
1669
  initiatedFromConversationId: 'conv-gv-pointer-success-origin',
1658
1670
  });
1659
1671
 
1660
- const secret = createVoiceVerificationSession('test-assistant', '+15559999999', 'gv-session-ptr-success');
1672
+ const secret = createVoiceVerificationSession('self', '+15559999999', 'gv-session-ptr-success');
1661
1673
 
1662
1674
  const { relay } = createMockWs(session.id);
1663
1675
 
@@ -1687,7 +1699,7 @@ describe('relay-server', () => {
1687
1699
  expect(originText).toContain('succeeded');
1688
1700
 
1689
1701
  // Let the delayed endSession callback flush
1690
- await new Promise((resolve) => setTimeout(resolve, 3100));
1702
+ await new Promise((resolve) => setTimeout(resolve, 100));
1691
1703
 
1692
1704
  relay.destroy();
1693
1705
  });
@@ -1700,13 +1712,13 @@ describe('relay-server', () => {
1700
1712
  provider: 'twilio',
1701
1713
  fromNumber: '+15551111111',
1702
1714
  toNumber: '+15559999999',
1703
- assistantId: 'test-assistant',
1715
+ assistantId: 'self',
1704
1716
  callMode: 'guardian_verification',
1705
1717
  guardianVerificationSessionId: 'gv-session-ptr-fail',
1706
1718
  initiatedFromConversationId: 'conv-gv-pointer-fail-origin',
1707
1719
  });
1708
1720
 
1709
- createVoiceVerificationSession('test-assistant', '+15559999999', 'gv-session-ptr-fail');
1721
+ createVoiceVerificationSession('self', '+15559999999', 'gv-session-ptr-fail');
1710
1722
 
1711
1723
  const { relay } = createMockWs(session.id);
1712
1724
 
@@ -1740,8 +1752,1438 @@ describe('relay-server', () => {
1740
1752
  expect(originText).toContain('failed');
1741
1753
 
1742
1754
  // Let the delayed endSession callback flush
1743
- await new Promise((resolve) => setTimeout(resolve, 2100));
1755
+ await new Promise((resolve) => setTimeout(resolve, 100));
1756
+
1757
+ relay.destroy();
1758
+ });
1759
+
1760
+ // ── Inbound voice invite redemption ──────────────────────────────────
1761
+
1762
+ test('inbound voice invite redemption: personalized welcome prompt with friend/guardian names', async () => {
1763
+ ensureConversation('conv-invite-welcome');
1764
+ const session = createCallSession({
1765
+ conversationId: 'conv-invite-welcome',
1766
+ provider: 'twilio',
1767
+ fromNumber: '+15558887777',
1768
+ toNumber: '+15551111111',
1769
+ assistantId: 'self',
1770
+ });
1771
+
1772
+ // Create a voice invite with friend/guardian names
1773
+ const code = generateVoiceCode(6);
1774
+ const codeHash = hashVoiceCode(code);
1775
+ createInvite({
1776
+ assistantId: 'self',
1777
+ sourceChannel: 'voice',
1778
+ maxUses: 1,
1779
+ expectedExternalUserId: '+15558887777',
1780
+ voiceCodeHash: codeHash,
1781
+ voiceCodeDigits: 6,
1782
+ friendName: 'Alice',
1783
+ guardianName: 'Bob',
1784
+ });
1785
+
1786
+ mockSendMessage.mockImplementation(createMockProviderResponse(['Hello, how can I help?']));
1787
+
1788
+ const { ws, relay } = createMockWs(session.id);
1789
+
1790
+ await relay.handleMessage(JSON.stringify({
1791
+ type: 'setup',
1792
+ callSid: 'CA_invite_welcome',
1793
+ from: '+15558887777',
1794
+ to: '+15551111111',
1795
+ }));
1796
+
1797
+ // Should be in verification-pending state for invite redemption
1798
+ expect(relay.getConnectionState()).toBe('verification_pending');
1799
+
1800
+ // Check that the welcome prompt includes friend/guardian names
1801
+ const textMessages = ws.sentMessages
1802
+ .map((raw) => JSON.parse(raw) as { type: string; token?: string })
1803
+ .filter((m) => m.type === 'text');
1804
+ expect(textMessages.some((m) => (m.token ?? '').includes('Welcome Alice'))).toBe(true);
1805
+ expect(textMessages.some((m) => (m.token ?? '').includes('Bob provided you'))).toBe(true);
1806
+
1807
+ // Enter the correct code via DTMF
1808
+ for (const digit of code) {
1809
+ await relay.handleMessage(JSON.stringify({ type: 'dtmf', digit }));
1810
+ }
1811
+
1812
+ await new Promise((resolve) => setTimeout(resolve, 10));
1813
+
1814
+ // Should have transitioned to connected
1815
+ expect(relay.getConnectionState()).toBe('connected');
1816
+
1817
+ // Verify events
1818
+ const events = getCallEvents(session.id);
1819
+ expect(events.some((e) => e.eventType === 'invite_redemption_started')).toBe(true);
1820
+ expect(events.some((e) => e.eventType === 'invite_redemption_succeeded')).toBe(true);
1821
+
1822
+ relay.destroy();
1823
+ });
1824
+
1825
+ test('inbound voice invite redemption: invalid code gets exact failure copy with guardian name and call ends', async () => {
1826
+ ensureConversation('conv-invite-fail');
1827
+ const session = createCallSession({
1828
+ conversationId: 'conv-invite-fail',
1829
+ provider: 'twilio',
1830
+ fromNumber: '+15558886666',
1831
+ toNumber: '+15551111111',
1832
+ assistantId: 'self',
1833
+ });
1834
+
1835
+ // Create a voice invite with friend/guardian names
1836
+ const code = generateVoiceCode(6);
1837
+ const codeHash = hashVoiceCode(code);
1838
+ createInvite({
1839
+ assistantId: 'self',
1840
+ sourceChannel: 'voice',
1841
+ maxUses: 1,
1842
+ expectedExternalUserId: '+15558886666',
1843
+ voiceCodeHash: codeHash,
1844
+ voiceCodeDigits: 6,
1845
+ friendName: 'Carol',
1846
+ guardianName: 'Dave',
1847
+ });
1848
+
1849
+ const { ws, relay } = createMockWs(session.id);
1850
+
1851
+ await relay.handleMessage(JSON.stringify({
1852
+ type: 'setup',
1853
+ callSid: 'CA_invite_fail',
1854
+ from: '+15558886666',
1855
+ to: '+15551111111',
1856
+ }));
1857
+
1858
+ expect(relay.getConnectionState()).toBe('verification_pending');
1859
+
1860
+ // Enter a wrong code
1861
+ for (const digit of '000000') {
1862
+ await relay.handleMessage(JSON.stringify({ type: 'dtmf', digit }));
1863
+ }
1864
+
1865
+ // Call should be marked as failed immediately
1866
+ const updated = getCallSession(session.id);
1867
+ expect(updated).not.toBeNull();
1868
+ expect(updated!.status).toBe('failed');
1869
+
1870
+ // Should have sent the exact deterministic failure copy with guardian name
1871
+ const textMessages = ws.sentMessages
1872
+ .map((raw) => JSON.parse(raw) as { type: string; token?: string })
1873
+ .filter((m) => m.type === 'text');
1874
+ expect(textMessages.some((m) => (m.token ?? '').includes('Sorry, the code you provided is incorrect or has since expired'))).toBe(true);
1875
+ expect(textMessages.some((m) => (m.token ?? '').includes('Please ask Dave for a new code'))).toBe(true);
1876
+
1877
+ // Verify events
1878
+ const events = getCallEvents(session.id);
1879
+ expect(events.some((e) => e.eventType === 'invite_redemption_failed')).toBe(true);
1880
+
1881
+ // Let the delayed endSession callback flush
1882
+ await new Promise((resolve) => setTimeout(resolve, 100));
1883
+
1884
+ // Verify end message was sent
1885
+ const endMessages = ws.sentMessages
1886
+ .map((raw) => JSON.parse(raw) as { type: string })
1887
+ .filter((m) => m.type === 'end');
1888
+ expect(endMessages.length).toBe(1);
1889
+
1890
+ relay.destroy();
1891
+ });
1892
+
1893
+ test('inbound voice: unknown caller with no active invite enters name capture flow', async () => {
1894
+ ensureConversation('conv-invite-no-invite');
1895
+ const session = createCallSession({
1896
+ conversationId: 'conv-invite-no-invite',
1897
+ provider: 'twilio',
1898
+ fromNumber: '+15558885555',
1899
+ toNumber: '+15551111111',
1900
+ assistantId: 'self',
1901
+ });
1902
+
1903
+ // No voice invite created for this caller
1904
+
1905
+ const { ws, relay } = createMockWs(session.id);
1906
+
1907
+ await relay.handleMessage(JSON.stringify({
1908
+ type: 'setup',
1909
+ callSid: 'CA_invite_no_invite',
1910
+ from: '+15558885555',
1911
+ to: '+15551111111',
1912
+ }));
1913
+
1914
+ // Should be in the name capture state (not denied)
1915
+ expect(relay.getConnectionState()).toBe('awaiting_name');
1916
+
1917
+ // Should have sent the name capture prompt
1918
+ const textMessages = ws.sentMessages
1919
+ .map((raw) => JSON.parse(raw) as { type: string; token?: string })
1920
+ .filter((m) => m.type === 'text');
1921
+ expect(textMessages.some((m) => (m.token ?? '').includes("don't recognize this number"))).toBe(true);
1922
+ expect(textMessages.some((m) => (m.token ?? '').includes('Can I get your name'))).toBe(true);
1923
+
1924
+ // Verify event was recorded
1925
+ const events = getCallEvents(session.id);
1926
+ expect(events.some((e) => e.eventType === 'inbound_acl_name_capture_started')).toBe(true);
1927
+
1928
+ relay.destroy();
1929
+ });
1930
+
1931
+ // ── Friend-initiated in-call guardian approval flow ────────────────────
1932
+
1933
+ test('name capture flow: caller provides name and enters guardian decision wait', async () => {
1934
+ ensureConversation('conv-name-capture');
1935
+ const session = createCallSession({
1936
+ conversationId: 'conv-name-capture',
1937
+ provider: 'twilio',
1938
+ fromNumber: '+15558884444',
1939
+ toNumber: '+15551111111',
1940
+ assistantId: 'self',
1941
+ });
1942
+
1943
+ const { ws, relay } = createMockWs(session.id);
1944
+
1945
+ await relay.handleMessage(JSON.stringify({
1946
+ type: 'setup',
1947
+ callSid: 'CA_name_capture',
1948
+ from: '+15558884444',
1949
+ to: '+15551111111',
1950
+ }));
1951
+
1952
+ // Should be in name capture state
1953
+ expect(relay.getConnectionState()).toBe('awaiting_name');
1954
+
1955
+ // Caller speaks their name
1956
+ await relay.handleMessage(JSON.stringify({
1957
+ type: 'prompt',
1958
+ voicePrompt: 'My name is John',
1959
+ lang: 'en-US',
1960
+ last: true,
1961
+ }));
1962
+
1963
+ // Should have transitioned to awaiting guardian decision
1964
+ expect(relay.getConnectionState()).toBe('awaiting_guardian_decision');
1965
+
1966
+ // Should have sent the hold message (guardian label defaults to "my guardian")
1967
+ const textMessages = ws.sentMessages
1968
+ .map((raw) => JSON.parse(raw) as { type: string; token?: string })
1969
+ .filter((m) => m.type === 'text');
1970
+ expect(textMessages.some((m) => (m.token ?? '').includes("I've let my guardian know"))).toBe(true);
1971
+ expect(textMessages.some((m) => (m.token ?? '').includes('Please hold'))).toBe(true);
1972
+
1973
+ // Verify events were recorded
1974
+ const events = getCallEvents(session.id);
1975
+ expect(events.some((e) => e.eventType === 'inbound_acl_name_captured')).toBe(true);
1976
+
1977
+ // Session should be in waiting_on_user status
1978
+ const updated = getCallSession(session.id);
1979
+ expect(updated).not.toBeNull();
1980
+ expect(updated!.status).toBe('waiting_on_user');
1981
+
1982
+ relay.destroy();
1983
+ });
1984
+
1985
+ test('name capture flow: DTMF input is ignored during awaiting_name state', async () => {
1986
+ ensureConversation('conv-name-dtmf-ignore');
1987
+ const session = createCallSession({
1988
+ conversationId: 'conv-name-dtmf-ignore',
1989
+ provider: 'twilio',
1990
+ fromNumber: '+15558883333',
1991
+ toNumber: '+15551111111',
1992
+ assistantId: 'self',
1993
+ });
1994
+
1995
+ const { ws, relay } = createMockWs(session.id);
1996
+
1997
+ await relay.handleMessage(JSON.stringify({
1998
+ type: 'setup',
1999
+ callSid: 'CA_name_dtmf_ignore',
2000
+ from: '+15558883333',
2001
+ to: '+15551111111',
2002
+ }));
2003
+
2004
+ expect(relay.getConnectionState()).toBe('awaiting_name');
2005
+ const msgCountBefore = ws.sentMessages.length;
2006
+
2007
+ // DTMF should be ignored during name capture
2008
+ await relay.handleMessage(JSON.stringify({ type: 'dtmf', digit: '5' }));
2009
+
2010
+ // No new messages should be sent (DTMF is ignored)
2011
+ expect(ws.sentMessages.length).toBe(msgCountBefore);
2012
+ expect(relay.getConnectionState()).toBe('awaiting_name');
2013
+
2014
+ relay.destroy();
2015
+ });
2016
+
2017
+ test('name capture flow: voice prompts during guardian wait get reassurance response', async () => {
2018
+ ensureConversation('conv-wait-prompt-reassure');
2019
+ const session = createCallSession({
2020
+ conversationId: 'conv-wait-prompt-reassure',
2021
+ provider: 'twilio',
2022
+ fromNumber: '+15558882222',
2023
+ toNumber: '+15551111111',
2024
+ assistantId: 'self',
2025
+ });
2026
+
2027
+ const { ws, relay } = createMockWs(session.id);
2028
+
2029
+ await relay.handleMessage(JSON.stringify({
2030
+ type: 'setup',
2031
+ callSid: 'CA_wait_prompt_reassure',
2032
+ from: '+15558882222',
2033
+ to: '+15551111111',
2034
+ }));
2035
+
2036
+ // Provide name to enter guardian decision wait
2037
+ await relay.handleMessage(JSON.stringify({
2038
+ type: 'prompt',
2039
+ voicePrompt: 'Jane Doe',
2040
+ lang: 'en-US',
2041
+ last: true,
2042
+ }));
2043
+
2044
+ expect(relay.getConnectionState()).toBe('awaiting_guardian_decision');
2045
+ const msgCountBefore = ws.sentMessages.length;
2046
+
2047
+ // Voice prompts during guardian wait should get a reassurance reply
2048
+ await relay.handleMessage(JSON.stringify({
2049
+ type: 'prompt',
2050
+ voicePrompt: 'Are you still there?',
2051
+ lang: 'en-US',
2052
+ last: true,
2053
+ }));
2054
+
2055
+ // A reassurance message should have been sent
2056
+ const newMessages = ws.sentMessages.slice(msgCountBefore);
2057
+ const textMessages = newMessages
2058
+ .map((raw) => JSON.parse(raw) as { type: string; token?: string })
2059
+ .filter((m) => m.type === 'text');
2060
+ expect(textMessages.length).toBeGreaterThan(0);
2061
+ expect(textMessages.some((m) => (m.token ?? '').includes('still here'))).toBe(true);
2062
+ expect(relay.getConnectionState()).toBe('awaiting_guardian_decision');
2063
+
2064
+ relay.destroy();
2065
+ });
2066
+
2067
+ test('blocked caller gets immediate denial even with name capture flow', async () => {
2068
+ ensureConversation('conv-blocked-deny');
2069
+ const session = createCallSession({
2070
+ conversationId: 'conv-blocked-deny',
2071
+ provider: 'twilio',
2072
+ fromNumber: '+15558881111',
2073
+ toNumber: '+15551111111',
2074
+ assistantId: 'self',
2075
+ });
2076
+
2077
+ // Create a blocked member
2078
+ upsertMember({
2079
+ assistantId: 'self',
2080
+ sourceChannel: 'voice',
2081
+ externalUserId: '+15558881111',
2082
+ externalChatId: '+15558881111',
2083
+ status: 'blocked',
2084
+ policy: 'allow',
2085
+ });
2086
+
2087
+ const { ws, relay } = createMockWs(session.id);
2088
+
2089
+ await relay.handleMessage(JSON.stringify({
2090
+ type: 'setup',
2091
+ callSid: 'CA_blocked_deny',
2092
+ from: '+15558881111',
2093
+ to: '+15551111111',
2094
+ }));
2095
+
2096
+ // Blocked callers should NOT enter name capture — they get immediate denial
2097
+ expect(relay.getConnectionState()).toBe('disconnecting');
2098
+
2099
+ const updated = getCallSession(session.id);
2100
+ expect(updated).not.toBeNull();
2101
+ expect(updated!.status).toBe('failed');
2102
+
2103
+ const textMessages = ws.sentMessages
2104
+ .map((raw) => JSON.parse(raw) as { type: string; token?: string })
2105
+ .filter((m) => m.type === 'text');
2106
+ expect(textMessages.some((m) => (m.token ?? '').includes('not authorized'))).toBe(true);
2107
+
2108
+ // Let delayed endSession callback flush
2109
+ await new Promise((resolve) => setTimeout(resolve, 100));
2110
+
2111
+ relay.destroy();
2112
+ });
2113
+
2114
+ test('name capture flow: access request creates canonical request for guardian', async () => {
2115
+ ensureConversation('conv-access-req-canonical');
2116
+ const session = createCallSession({
2117
+ conversationId: 'conv-access-req-canonical',
2118
+ provider: 'twilio',
2119
+ fromNumber: '+15557770001',
2120
+ toNumber: '+15551111111',
2121
+ assistantId: 'self',
2122
+ });
2123
+
2124
+ const { relay } = createMockWs(session.id);
2125
+
2126
+ await relay.handleMessage(JSON.stringify({
2127
+ type: 'setup',
2128
+ callSid: 'CA_access_req_canonical',
2129
+ from: '+15557770001',
2130
+ to: '+15551111111',
2131
+ }));
2132
+
2133
+ expect(relay.getConnectionState()).toBe('awaiting_name');
2134
+
2135
+ // Provide name
2136
+ await relay.handleMessage(JSON.stringify({
2137
+ type: 'prompt',
2138
+ voicePrompt: 'Sarah Connor',
2139
+ lang: 'en-US',
2140
+ last: true,
2141
+ }));
2142
+
2143
+ expect(relay.getConnectionState()).toBe('awaiting_guardian_decision');
2144
+
2145
+ // A canonical access request should have been created
2146
+ const pending = listCanonicalGuardianRequests({
2147
+ status: 'pending',
2148
+ requesterExternalUserId: '+15557770001',
2149
+ sourceChannel: 'voice',
2150
+ kind: 'access_request',
2151
+ });
2152
+ expect(pending.length).toBe(1);
2153
+ expect(pending[0].requesterExternalUserId).toBe('+15557770001');
2154
+
2155
+ relay.destroy();
2156
+ });
2157
+
2158
+ test('name capture flow: approved access request activates caller and continues call', async () => {
2159
+ ensureConversation('conv-access-approved');
2160
+ const session = createCallSession({
2161
+ conversationId: 'conv-access-approved',
2162
+ provider: 'twilio',
2163
+ fromNumber: '+15557770002',
2164
+ toNumber: '+15551111111',
2165
+ assistantId: 'self',
2166
+ });
2167
+
2168
+ mockSendMessage.mockImplementation(createMockProviderResponse(['I can help you with that.']));
2169
+
2170
+ const { relay } = createMockWs(session.id);
2171
+
2172
+ await relay.handleMessage(JSON.stringify({
2173
+ type: 'setup',
2174
+ callSid: 'CA_access_approved',
2175
+ from: '+15557770002',
2176
+ to: '+15551111111',
2177
+ }));
2178
+
2179
+ // Provide name to enter wait state
2180
+ await relay.handleMessage(JSON.stringify({
2181
+ type: 'prompt',
2182
+ voicePrompt: 'Bob Smith',
2183
+ lang: 'en-US',
2184
+ last: true,
2185
+ }));
2186
+
2187
+ expect(relay.getConnectionState()).toBe('awaiting_guardian_decision');
2188
+
2189
+ // Find the canonical request and simulate guardian approval
2190
+ const pending = listCanonicalGuardianRequests({
2191
+ status: 'pending',
2192
+ requesterExternalUserId: '+15557770002',
2193
+ sourceChannel: 'voice',
2194
+ kind: 'access_request',
2195
+ });
2196
+ expect(pending.length).toBe(1);
2197
+
2198
+ // Resolve the request to approved status
2199
+ resolveCanonicalGuardianRequest(pending[0].id, 'pending', {
2200
+ status: 'approved',
2201
+ answerText: undefined,
2202
+ decidedByExternalUserId: undefined,
2203
+ });
2204
+
2205
+ // Wait for the poll interval to detect the approval
2206
+ await new Promise((resolve) => setTimeout(resolve, 200));
2207
+
2208
+ // Should have transitioned to connected state
2209
+ expect(relay.getConnectionState()).toBe('connected');
2210
+
2211
+ // Verify events
2212
+ const events = getCallEvents(session.id);
2213
+ expect(events.some((e) => e.eventType === 'inbound_acl_access_approved')).toBe(true);
2214
+
2215
+ // Session should be in_progress
2216
+ const updated = getCallSession(session.id);
2217
+ expect(updated).not.toBeNull();
2218
+ expect(updated!.status).toBe('in_progress');
2219
+
2220
+ relay.destroy();
2221
+ });
2222
+
2223
+ test('name capture flow: denied access request ends call with deterministic copy', async () => {
2224
+ ensureConversation('conv-access-denied');
2225
+ const session = createCallSession({
2226
+ conversationId: 'conv-access-denied',
2227
+ provider: 'twilio',
2228
+ fromNumber: '+15557770003',
2229
+ toNumber: '+15551111111',
2230
+ assistantId: 'self',
2231
+ });
2232
+
2233
+ const { ws, relay } = createMockWs(session.id);
2234
+
2235
+ await relay.handleMessage(JSON.stringify({
2236
+ type: 'setup',
2237
+ callSid: 'CA_access_denied',
2238
+ from: '+15557770003',
2239
+ to: '+15551111111',
2240
+ }));
2241
+
2242
+ // Provide name
2243
+ await relay.handleMessage(JSON.stringify({
2244
+ type: 'prompt',
2245
+ voicePrompt: 'Eve',
2246
+ lang: 'en-US',
2247
+ last: true,
2248
+ }));
2249
+
2250
+ expect(relay.getConnectionState()).toBe('awaiting_guardian_decision');
2251
+
2252
+ // Simulate guardian denial
2253
+ const pending = listCanonicalGuardianRequests({
2254
+ status: 'pending',
2255
+ requesterExternalUserId: '+15557770003',
2256
+ sourceChannel: 'voice',
2257
+ kind: 'access_request',
2258
+ });
2259
+ expect(pending.length).toBe(1);
2260
+
2261
+ resolveCanonicalGuardianRequest(pending[0].id, 'pending', {
2262
+ status: 'denied',
2263
+ answerText: undefined,
2264
+ decidedByExternalUserId: undefined,
2265
+ });
2266
+
2267
+ // Wait for poll to detect the denial
2268
+ await new Promise((resolve) => setTimeout(resolve, 200));
2269
+
2270
+ // Should be disconnecting
2271
+ expect(relay.getConnectionState()).toBe('disconnecting');
2272
+
2273
+ // Should have sent the denial message
2274
+ const textMessages = ws.sentMessages
2275
+ .map((raw) => JSON.parse(raw) as { type: string; token?: string })
2276
+ .filter((m) => m.type === 'text');
2277
+ expect(textMessages.some((m) => (m.token ?? '').includes("says I'm not allowed"))).toBe(true);
2278
+
2279
+ // Session should be failed
2280
+ const updated = getCallSession(session.id);
2281
+ expect(updated).not.toBeNull();
2282
+ expect(updated!.status).toBe('failed');
2283
+
2284
+ // Verify event
2285
+ const events = getCallEvents(session.id);
2286
+ expect(events.some((e) => e.eventType === 'inbound_acl_access_denied')).toBe(true);
2287
+
2288
+ // Let the delayed endSession callback flush
2289
+ await new Promise((resolve) => setTimeout(resolve, 100));
2290
+
2291
+ relay.destroy();
2292
+ });
2293
+
2294
+ test('name capture flow: timeout ends call with deterministic copy', async () => {
2295
+ // Override the consultation timeout to a very short value for testing
2296
+ mockConfig.calls.userConsultTimeoutSeconds = 2; // 2 seconds
2297
+
2298
+ ensureConversation('conv-access-timeout');
2299
+ const session = createCallSession({
2300
+ conversationId: 'conv-access-timeout',
2301
+ provider: 'twilio',
2302
+ fromNumber: '+15557770004',
2303
+ toNumber: '+15551111111',
2304
+ assistantId: 'self',
2305
+ });
2306
+
2307
+ const { ws, relay } = createMockWs(session.id);
2308
+
2309
+ await relay.handleMessage(JSON.stringify({
2310
+ type: 'setup',
2311
+ callSid: 'CA_access_timeout',
2312
+ from: '+15557770004',
2313
+ to: '+15551111111',
2314
+ }));
2315
+
2316
+ // Provide name
2317
+ await relay.handleMessage(JSON.stringify({
2318
+ type: 'prompt',
2319
+ voicePrompt: 'Timeout Tester',
2320
+ lang: 'en-US',
2321
+ last: true,
2322
+ }));
2323
+
2324
+ expect(relay.getConnectionState()).toBe('awaiting_guardian_decision');
2325
+
2326
+ // Wait for timeout (2 seconds + buffer)
2327
+ await new Promise((resolve) => setTimeout(resolve, 2500));
2328
+
2329
+ // Should be disconnecting after timeout
2330
+ expect(relay.getConnectionState()).toBe('disconnecting');
2331
+
2332
+ // Should have sent the timeout message
2333
+ const textMessages = ws.sentMessages
2334
+ .map((raw) => JSON.parse(raw) as { type: string; token?: string })
2335
+ .filter((m) => m.type === 'text');
2336
+ expect(textMessages.some((m) => (m.token ?? '').includes("can't get ahold of"))).toBe(true);
2337
+ expect(textMessages.some((m) => (m.token ?? '').includes("let them know you called"))).toBe(true);
2338
+
2339
+ // Session should be failed
2340
+ const updated = getCallSession(session.id);
2341
+ expect(updated).not.toBeNull();
2342
+ expect(updated!.status).toBe('failed');
2343
+
2344
+ // Verify event
2345
+ const events = getCallEvents(session.id);
2346
+ expect(events.some((e) => e.eventType === 'inbound_acl_access_timeout')).toBe(true);
2347
+
2348
+ // Let the delayed endSession callback flush
2349
+ await new Promise((resolve) => setTimeout(resolve, 100));
2350
+
2351
+ // Restore default timeout
2352
+ mockConfig.calls.userConsultTimeoutSeconds = 120;
2353
+
2354
+ relay.destroy();
2355
+ });
2356
+
2357
+ test('name capture flow: transport close during guardian wait cleans up timers', async () => {
2358
+ ensureConversation('conv-access-transport-close');
2359
+ const session = createCallSession({
2360
+ conversationId: 'conv-access-transport-close',
2361
+ provider: 'twilio',
2362
+ fromNumber: '+15557770005',
2363
+ toNumber: '+15551111111',
2364
+ assistantId: 'self',
2365
+ });
2366
+
2367
+ const { relay } = createMockWs(session.id);
2368
+
2369
+ await relay.handleMessage(JSON.stringify({
2370
+ type: 'setup',
2371
+ callSid: 'CA_access_transport_close',
2372
+ from: '+15557770005',
2373
+ to: '+15551111111',
2374
+ }));
2375
+
2376
+ // Provide name
2377
+ await relay.handleMessage(JSON.stringify({
2378
+ type: 'prompt',
2379
+ voicePrompt: 'Disconnector',
2380
+ lang: 'en-US',
2381
+ last: true,
2382
+ }));
2383
+
2384
+ expect(relay.getConnectionState()).toBe('awaiting_guardian_decision');
2385
+
2386
+ // Simulate transport close while waiting for guardian
2387
+ relay.handleTransportClosed(1000, 'caller hung up');
2388
+
2389
+ // Session should be completed (normal close)
2390
+ const updated = getCallSession(session.id);
2391
+ expect(updated).not.toBeNull();
2392
+ expect(updated!.status).toBe('completed');
2393
+
2394
+ relay.destroy();
2395
+ });
2396
+
2397
+ // ── Guardian wait heartbeat and impatience handling ──────────────────
2398
+
2399
+ test('guardian wait: heartbeat timer emits periodic updates', async () => {
2400
+ ensureConversation('conv-heartbeat-basic');
2401
+ const session = createCallSession({
2402
+ conversationId: 'conv-heartbeat-basic',
2403
+ provider: 'twilio',
2404
+ fromNumber: '+15557770010',
2405
+ toNumber: '+15551111111',
2406
+ assistantId: 'self',
2407
+ });
2408
+
2409
+ const { ws, relay } = createMockWs(session.id);
2410
+
2411
+ await relay.handleMessage(JSON.stringify({
2412
+ type: 'setup',
2413
+ callSid: 'CA_heartbeat_basic',
2414
+ from: '+15557770010',
2415
+ to: '+15551111111',
2416
+ }));
2417
+
2418
+ // Provide name to enter guardian wait
2419
+ await relay.handleMessage(JSON.stringify({
2420
+ type: 'prompt',
2421
+ voicePrompt: 'Heartbeat Tester',
2422
+ lang: 'en-US',
2423
+ last: true,
2424
+ }));
2425
+
2426
+ expect(relay.getConnectionState()).toBe('awaiting_guardian_decision');
2427
+ const msgCountAfterHold = ws.sentMessages.length;
2428
+
2429
+ // Wait for at least one heartbeat (initial interval is 100ms in test config)
2430
+ await new Promise((resolve) => setTimeout(resolve, 250));
2431
+
2432
+ const newMessages = ws.sentMessages.slice(msgCountAfterHold);
2433
+ const textMessages = newMessages
2434
+ .map((raw) => JSON.parse(raw) as { type: string; token?: string })
2435
+ .filter((m) => m.type === 'text');
2436
+ expect(textMessages.length).toBeGreaterThan(0);
2437
+ // Heartbeat messages mention "waiting" or "guardian"
2438
+ expect(textMessages.some((m) => (m.token ?? '').toLowerCase().includes('waiting'))).toBe(true);
2439
+
2440
+ // Verify heartbeat event was recorded
2441
+ const events = getCallEvents(session.id);
2442
+ expect(events.some((e) => e.eventType === 'voice_guardian_wait_heartbeat_sent')).toBe(true);
2443
+
2444
+ relay.destroy();
2445
+ });
2446
+
2447
+ test('guardian wait: heartbeat stops on approval', async () => {
2448
+ ensureConversation('conv-heartbeat-stop-approve');
2449
+ const session = createCallSession({
2450
+ conversationId: 'conv-heartbeat-stop-approve',
2451
+ provider: 'twilio',
2452
+ fromNumber: '+15557770011',
2453
+ toNumber: '+15551111111',
2454
+ assistantId: 'self',
2455
+ });
2456
+
2457
+ mockSendMessage.mockImplementation(createMockProviderResponse(['Welcome!']));
2458
+
2459
+ const { ws, relay } = createMockWs(session.id);
2460
+
2461
+ await relay.handleMessage(JSON.stringify({
2462
+ type: 'setup',
2463
+ callSid: 'CA_heartbeat_stop_approve',
2464
+ from: '+15557770011',
2465
+ to: '+15551111111',
2466
+ }));
2467
+
2468
+ await relay.handleMessage(JSON.stringify({
2469
+ type: 'prompt',
2470
+ voicePrompt: 'Approve Tester',
2471
+ lang: 'en-US',
2472
+ last: true,
2473
+ }));
2474
+
2475
+ expect(relay.getConnectionState()).toBe('awaiting_guardian_decision');
2476
+
2477
+ // Approve the request
2478
+ const pending = listCanonicalGuardianRequests({
2479
+ status: 'pending',
2480
+ requesterExternalUserId: '+15557770011',
2481
+ sourceChannel: 'voice',
2482
+ kind: 'access_request',
2483
+ });
2484
+ expect(pending.length).toBe(1);
2485
+
2486
+ resolveCanonicalGuardianRequest(pending[0].id, 'pending', {
2487
+ status: 'approved',
2488
+ answerText: undefined,
2489
+ decidedByExternalUserId: undefined,
2490
+ });
2491
+
2492
+ await new Promise((resolve) => setTimeout(resolve, 200));
2493
+
2494
+ // Connection should have transitioned
2495
+ expect(relay.getConnectionState()).toBe('connected');
2496
+
2497
+ // Record message count after approval
2498
+ const msgCountAfterApproval = ws.sentMessages.length;
2499
+
2500
+ // Wait and verify no more heartbeats
2501
+ await new Promise((resolve) => setTimeout(resolve, 300));
2502
+ expect(ws.sentMessages.length).toBe(msgCountAfterApproval);
2503
+
2504
+ relay.destroy();
2505
+ });
2506
+
2507
+ test('guardian wait: heartbeat stops on destroy', async () => {
2508
+ ensureConversation('conv-heartbeat-stop-destroy');
2509
+ const session = createCallSession({
2510
+ conversationId: 'conv-heartbeat-stop-destroy',
2511
+ provider: 'twilio',
2512
+ fromNumber: '+15557770012',
2513
+ toNumber: '+15551111111',
2514
+ assistantId: 'self',
2515
+ });
2516
+
2517
+ const { relay } = createMockWs(session.id);
2518
+
2519
+ await relay.handleMessage(JSON.stringify({
2520
+ type: 'setup',
2521
+ callSid: 'CA_heartbeat_stop_destroy',
2522
+ from: '+15557770012',
2523
+ to: '+15551111111',
2524
+ }));
2525
+
2526
+ await relay.handleMessage(JSON.stringify({
2527
+ type: 'prompt',
2528
+ voicePrompt: 'Destroy Tester',
2529
+ lang: 'en-US',
2530
+ last: true,
2531
+ }));
2532
+
2533
+ expect(relay.getConnectionState()).toBe('awaiting_guardian_decision');
2534
+
2535
+ // Destroy should not throw and should clean up timers
2536
+ expect(() => relay.destroy()).not.toThrow();
2537
+ });
2538
+
2539
+ test('guardian wait: impatience utterance triggers callback offer', async () => {
2540
+ ensureConversation('conv-impatience-offer');
2541
+ const session = createCallSession({
2542
+ conversationId: 'conv-impatience-offer',
2543
+ provider: 'twilio',
2544
+ fromNumber: '+15557770013',
2545
+ toNumber: '+15551111111',
2546
+ assistantId: 'self',
2547
+ });
2548
+
2549
+ const { ws, relay } = createMockWs(session.id);
2550
+
2551
+ await relay.handleMessage(JSON.stringify({
2552
+ type: 'setup',
2553
+ callSid: 'CA_impatience_offer',
2554
+ from: '+15557770013',
2555
+ to: '+15551111111',
2556
+ }));
2557
+
2558
+ await relay.handleMessage(JSON.stringify({
2559
+ type: 'prompt',
2560
+ voicePrompt: 'Impatient Tester',
2561
+ lang: 'en-US',
2562
+ last: true,
2563
+ }));
2564
+
2565
+ expect(relay.getConnectionState()).toBe('awaiting_guardian_decision');
2566
+ const msgCountBefore = ws.sentMessages.length;
2567
+
2568
+ // Send an impatient utterance
2569
+ await relay.handleMessage(JSON.stringify({
2570
+ type: 'prompt',
2571
+ voicePrompt: 'This is taking too long!',
2572
+ lang: 'en-US',
2573
+ last: true,
2574
+ }));
2575
+
2576
+ const newMessages = ws.sentMessages.slice(msgCountBefore);
2577
+ const textMessages = newMessages
2578
+ .map((raw) => JSON.parse(raw) as { type: string; token?: string })
2579
+ .filter((m) => m.type === 'text');
2580
+ expect(textMessages.length).toBeGreaterThan(0);
2581
+ // Should offer callback
2582
+ expect(textMessages.some((m) => (m.token ?? '').toLowerCase().includes('call you back'))).toBe(true);
2583
+
2584
+ // Verify event
2585
+ const events = getCallEvents(session.id);
2586
+ expect(events.some((e) => e.eventType === 'voice_guardian_wait_callback_offer_sent')).toBe(true);
2587
+ expect(events.some((e) => e.eventType === 'voice_guardian_wait_prompt_classified')).toBe(true);
2588
+
2589
+ relay.destroy();
2590
+ });
2591
+
2592
+ test('guardian wait: explicit callback opt-in after offer is acknowledged', async () => {
2593
+ ensureConversation('conv-callback-optin');
2594
+ const session = createCallSession({
2595
+ conversationId: 'conv-callback-optin',
2596
+ provider: 'twilio',
2597
+ fromNumber: '+15557770014',
2598
+ toNumber: '+15551111111',
2599
+ assistantId: 'self',
2600
+ });
2601
+
2602
+ const { ws, relay } = createMockWs(session.id);
2603
+
2604
+ await relay.handleMessage(JSON.stringify({
2605
+ type: 'setup',
2606
+ callSid: 'CA_callback_optin',
2607
+ from: '+15557770014',
2608
+ to: '+15551111111',
2609
+ }));
2610
+
2611
+ await relay.handleMessage(JSON.stringify({
2612
+ type: 'prompt',
2613
+ voicePrompt: 'OptIn Tester',
2614
+ lang: 'en-US',
2615
+ last: true,
2616
+ }));
2617
+
2618
+ expect(relay.getConnectionState()).toBe('awaiting_guardian_decision');
2619
+
2620
+ // Trigger impatience to get callback offer
2621
+ await relay.handleMessage(JSON.stringify({
2622
+ type: 'prompt',
2623
+ voicePrompt: 'Hurry up please',
2624
+ lang: 'en-US',
2625
+ last: true,
2626
+ }));
2627
+
2628
+ // Wait for cooldown
2629
+ await new Promise((resolve) => setTimeout(resolve, 3100));
2630
+
2631
+ const msgCountBeforeOptIn = ws.sentMessages.length;
2632
+
2633
+ // Accept the callback offer
2634
+ await relay.handleMessage(JSON.stringify({
2635
+ type: 'prompt',
2636
+ voicePrompt: 'Yes, please call me back',
2637
+ lang: 'en-US',
2638
+ last: true,
2639
+ }));
2640
+
2641
+ const newMessages = ws.sentMessages.slice(msgCountBeforeOptIn);
2642
+ const textMessages = newMessages
2643
+ .map((raw) => JSON.parse(raw) as { type: string; token?: string })
2644
+ .filter((m) => m.type === 'text');
2645
+ expect(textMessages.length).toBeGreaterThan(0);
2646
+ // Should acknowledge the callback opt-in
2647
+ expect(textMessages.some((m) => (m.token ?? '').toLowerCase().includes('noted'))).toBe(true);
2648
+
2649
+ // Verify events
2650
+ const events = getCallEvents(session.id);
2651
+ expect(events.some((e) => e.eventType === 'voice_guardian_wait_callback_opt_in_set')).toBe(true);
2652
+
2653
+ // Connection should still be in guardian wait (callback not auto-dispatched)
2654
+ expect(relay.getConnectionState()).toBe('awaiting_guardian_decision');
2655
+
2656
+ relay.destroy();
2657
+ });
2658
+
2659
+ test('guardian wait: neutral utterance gets acknowledgment', async () => {
2660
+ ensureConversation('conv-wait-neutral');
2661
+ const session = createCallSession({
2662
+ conversationId: 'conv-wait-neutral',
2663
+ provider: 'twilio',
2664
+ fromNumber: '+15557770015',
2665
+ toNumber: '+15551111111',
2666
+ assistantId: 'self',
2667
+ });
2668
+
2669
+ const { ws, relay } = createMockWs(session.id);
2670
+
2671
+ await relay.handleMessage(JSON.stringify({
2672
+ type: 'setup',
2673
+ callSid: 'CA_wait_neutral',
2674
+ from: '+15557770015',
2675
+ to: '+15551111111',
2676
+ }));
2677
+
2678
+ await relay.handleMessage(JSON.stringify({
2679
+ type: 'prompt',
2680
+ voicePrompt: 'Neutral Tester',
2681
+ lang: 'en-US',
2682
+ last: true,
2683
+ }));
2684
+
2685
+ expect(relay.getConnectionState()).toBe('awaiting_guardian_decision');
2686
+ const msgCountBefore = ws.sentMessages.length;
2687
+
2688
+ // Send a neutral utterance
2689
+ await relay.handleMessage(JSON.stringify({
2690
+ type: 'prompt',
2691
+ voicePrompt: 'I just wanted to say thanks',
2692
+ lang: 'en-US',
2693
+ last: true,
2694
+ }));
2695
+
2696
+ const newMessages = ws.sentMessages.slice(msgCountBefore);
2697
+ const textMessages = newMessages
2698
+ .map((raw) => JSON.parse(raw) as { type: string; token?: string })
2699
+ .filter((m) => m.type === 'text');
2700
+ expect(textMessages.length).toBeGreaterThan(0);
2701
+ // Should get an acknowledgment
2702
+ expect(textMessages.some((m) => (m.token ?? '').toLowerCase().includes('waiting'))).toBe(true);
2703
+
2704
+ relay.destroy();
2705
+ });
2706
+
2707
+ test('guardian wait: empty utterance is ignored without response', async () => {
2708
+ ensureConversation('conv-wait-empty');
2709
+ const session = createCallSession({
2710
+ conversationId: 'conv-wait-empty',
2711
+ provider: 'twilio',
2712
+ fromNumber: '+15557770016',
2713
+ toNumber: '+15551111111',
2714
+ assistantId: 'self',
2715
+ });
2716
+
2717
+ const { ws, relay } = createMockWs(session.id);
2718
+
2719
+ await relay.handleMessage(JSON.stringify({
2720
+ type: 'setup',
2721
+ callSid: 'CA_wait_empty',
2722
+ from: '+15557770016',
2723
+ to: '+15551111111',
2724
+ }));
2725
+
2726
+ await relay.handleMessage(JSON.stringify({
2727
+ type: 'prompt',
2728
+ voicePrompt: 'Empty Tester',
2729
+ lang: 'en-US',
2730
+ last: true,
2731
+ }));
2732
+
2733
+ expect(relay.getConnectionState()).toBe('awaiting_guardian_decision');
2734
+ const msgCountBefore = ws.sentMessages.length;
2735
+
2736
+ // Send an empty utterance
2737
+ await relay.handleMessage(JSON.stringify({
2738
+ type: 'prompt',
2739
+ voicePrompt: ' ',
2740
+ lang: 'en-US',
2741
+ last: true,
2742
+ }));
2743
+
2744
+ // No new messages should be sent
2745
+ expect(ws.sentMessages.length).toBe(msgCountBefore);
2746
+
2747
+ relay.destroy();
2748
+ });
2749
+
2750
+ test('guardian wait: cooldown prevents rapid-fire responses', async () => {
2751
+ ensureConversation('conv-wait-cooldown');
2752
+ const session = createCallSession({
2753
+ conversationId: 'conv-wait-cooldown',
2754
+ provider: 'twilio',
2755
+ fromNumber: '+15557770017',
2756
+ toNumber: '+15551111111',
2757
+ assistantId: 'self',
2758
+ });
2759
+
2760
+ const { ws, relay } = createMockWs(session.id);
2761
+
2762
+ await relay.handleMessage(JSON.stringify({
2763
+ type: 'setup',
2764
+ callSid: 'CA_wait_cooldown',
2765
+ from: '+15557770017',
2766
+ to: '+15551111111',
2767
+ }));
2768
+
2769
+ await relay.handleMessage(JSON.stringify({
2770
+ type: 'prompt',
2771
+ voicePrompt: 'Cooldown Tester',
2772
+ lang: 'en-US',
2773
+ last: true,
2774
+ }));
2775
+
2776
+ expect(relay.getConnectionState()).toBe('awaiting_guardian_decision');
2777
+
2778
+ // First utterance should get a response
2779
+ await relay.handleMessage(JSON.stringify({
2780
+ type: 'prompt',
2781
+ voicePrompt: 'Hello?',
2782
+ lang: 'en-US',
2783
+ last: true,
2784
+ }));
2785
+
2786
+ const msgCountAfterFirst = ws.sentMessages.length;
2787
+
2788
+ // Immediate second utterance should be suppressed by cooldown
2789
+ await relay.handleMessage(JSON.stringify({
2790
+ type: 'prompt',
2791
+ voicePrompt: 'Hello again?',
2792
+ lang: 'en-US',
2793
+ last: true,
2794
+ }));
2795
+
2796
+ // No new messages due to cooldown
2797
+ expect(ws.sentMessages.length).toBe(msgCountAfterFirst);
2798
+
2799
+ relay.destroy();
2800
+ });
2801
+
2802
+ // ── Callback handoff notification tests ────────────────────────────
2803
+
2804
+ test('callback opt-in + access timeout -> emits callback handoff notification exactly once', async () => {
2805
+ mockConfig.calls.userConsultTimeoutSeconds = 2;
2806
+
2807
+ ensureConversation('conv-cb-handoff-timeout');
2808
+ const session = createCallSession({
2809
+ conversationId: 'conv-cb-handoff-timeout',
2810
+ provider: 'twilio',
2811
+ fromNumber: '+15557770020',
2812
+ toNumber: '+15551111111',
2813
+ assistantId: 'self',
2814
+ });
2815
+
2816
+ const { ws, relay } = createMockWs(session.id);
2817
+
2818
+ await relay.handleMessage(JSON.stringify({
2819
+ type: 'setup',
2820
+ callSid: 'CA_cb_handoff_timeout',
2821
+ from: '+15557770020',
2822
+ to: '+15551111111',
2823
+ }));
2824
+
2825
+ // Provide name to enter guardian wait
2826
+ await relay.handleMessage(JSON.stringify({
2827
+ type: 'prompt',
2828
+ voicePrompt: 'Callback Tester',
2829
+ lang: 'en-US',
2830
+ last: true,
2831
+ }));
2832
+
2833
+ expect(relay.getConnectionState()).toBe('awaiting_guardian_decision');
2834
+
2835
+ // Trigger impatience to get callback offer
2836
+ await relay.handleMessage(JSON.stringify({
2837
+ type: 'prompt',
2838
+ voicePrompt: 'Hurry up please',
2839
+ lang: 'en-US',
2840
+ last: true,
2841
+ }));
2842
+
2843
+ // Accept callback offer (callback decisions bypass cooldown)
2844
+ await relay.handleMessage(JSON.stringify({
2845
+ type: 'prompt',
2846
+ voicePrompt: 'Yes, please call me back',
2847
+ lang: 'en-US',
2848
+ last: true,
2849
+ }));
2850
+
2851
+ // Verify callback opt-in was set
2852
+ const eventsBeforeTimeout = getCallEvents(session.id);
2853
+ expect(eventsBeforeTimeout.some((e) => e.eventType === 'voice_guardian_wait_callback_opt_in_set')).toBe(true);
2854
+
2855
+ // Wait for timeout (2s) plus settling time
2856
+ await new Promise((resolve) => setTimeout(resolve, 2500));
2857
+
2858
+ expect(relay.getConnectionState()).toBe('disconnecting');
2859
+
2860
+ // Allow async notification emission to complete
2861
+ await new Promise((resolve) => setTimeout(resolve, 200));
2862
+
2863
+ const events = getCallEvents(session.id);
2864
+ // Should have exactly one callback_handoff_notified event (or callback_handoff_failed
2865
+ // if the notification pipeline isn't fully wired in tests — either proves emission)
2866
+ const handoffEvents = events.filter(
2867
+ (e) => e.eventType === 'callback_handoff_notified' || e.eventType === 'callback_handoff_failed',
2868
+ );
2869
+ expect(handoffEvents.length).toBe(1);
2870
+
2871
+ // Verify the timeout event was also recorded
2872
+ expect(events.some((e) => e.eventType === 'inbound_acl_access_timeout')).toBe(true);
2873
+
2874
+ // Timeout copy should include callback note
2875
+ const textMessages = ws.sentMessages
2876
+ .map((raw) => JSON.parse(raw) as { type: string; token?: string })
2877
+ .filter((m) => m.type === 'text');
2878
+ expect(textMessages.some((m) => (m.token ?? '').includes('callback'))).toBe(true);
2879
+
2880
+ mockConfig.calls.userConsultTimeoutSeconds = 120;
2881
+ relay.destroy();
2882
+ });
2883
+
2884
+ test('no callback opt-in + access timeout -> no callback handoff notification', async () => {
2885
+ mockConfig.calls.userConsultTimeoutSeconds = 2;
2886
+
2887
+ ensureConversation('conv-no-cb-handoff');
2888
+ const session = createCallSession({
2889
+ conversationId: 'conv-no-cb-handoff',
2890
+ provider: 'twilio',
2891
+ fromNumber: '+15557770021',
2892
+ toNumber: '+15551111111',
2893
+ assistantId: 'self',
2894
+ });
2895
+
2896
+ const { relay } = createMockWs(session.id);
2897
+
2898
+ await relay.handleMessage(JSON.stringify({
2899
+ type: 'setup',
2900
+ callSid: 'CA_no_cb_handoff',
2901
+ from: '+15557770021',
2902
+ to: '+15551111111',
2903
+ }));
2904
+
2905
+ await relay.handleMessage(JSON.stringify({
2906
+ type: 'prompt',
2907
+ voicePrompt: 'No Callback Tester',
2908
+ lang: 'en-US',
2909
+ last: true,
2910
+ }));
2911
+
2912
+ expect(relay.getConnectionState()).toBe('awaiting_guardian_decision');
2913
+
2914
+ // Wait for timeout without opting into callback
2915
+ await new Promise((resolve) => setTimeout(resolve, 2500));
2916
+
2917
+ expect(relay.getConnectionState()).toBe('disconnecting');
2918
+
2919
+ await new Promise((resolve) => setTimeout(resolve, 200));
2920
+
2921
+ const events = getCallEvents(session.id);
2922
+ // Should NOT have callback handoff events
2923
+ const handoffEvents = events.filter(
2924
+ (e) => e.eventType === 'callback_handoff_notified' || e.eventType === 'callback_handoff_failed',
2925
+ );
2926
+ expect(handoffEvents.length).toBe(0);
2927
+
2928
+ mockConfig.calls.userConsultTimeoutSeconds = 120;
2929
+ relay.destroy();
2930
+ });
2931
+
2932
+ test('callback opt-in + transport close during guardian wait -> emits callback handoff notification', async () => {
2933
+ ensureConversation('conv-cb-handoff-transport');
2934
+ const session = createCallSession({
2935
+ conversationId: 'conv-cb-handoff-transport',
2936
+ provider: 'twilio',
2937
+ fromNumber: '+15557770022',
2938
+ toNumber: '+15551111111',
2939
+ assistantId: 'self',
2940
+ });
2941
+
2942
+ const { relay } = createMockWs(session.id);
2943
+
2944
+ await relay.handleMessage(JSON.stringify({
2945
+ type: 'setup',
2946
+ callSid: 'CA_cb_handoff_transport',
2947
+ from: '+15557770022',
2948
+ to: '+15551111111',
2949
+ }));
2950
+
2951
+ await relay.handleMessage(JSON.stringify({
2952
+ type: 'prompt',
2953
+ voicePrompt: 'Transport Close Tester',
2954
+ lang: 'en-US',
2955
+ last: true,
2956
+ }));
2957
+
2958
+ expect(relay.getConnectionState()).toBe('awaiting_guardian_decision');
2959
+
2960
+ // Trigger callback offer and opt-in (callback decisions bypass cooldown)
2961
+ await relay.handleMessage(JSON.stringify({
2962
+ type: 'prompt',
2963
+ voicePrompt: 'Hurry up please',
2964
+ lang: 'en-US',
2965
+ last: true,
2966
+ }));
2967
+
2968
+ await relay.handleMessage(JSON.stringify({
2969
+ type: 'prompt',
2970
+ voicePrompt: 'Yes, call me back please',
2971
+ lang: 'en-US',
2972
+ last: true,
2973
+ }));
2974
+
2975
+ // Simulate transport close while still in guardian wait
2976
+ relay.handleTransportClosed(1001, 'Going away');
2977
+
2978
+ await new Promise((resolve) => setTimeout(resolve, 200));
2979
+
2980
+ const events = getCallEvents(session.id);
2981
+ const handoffEvents = events.filter(
2982
+ (e) => e.eventType === 'callback_handoff_notified' || e.eventType === 'callback_handoff_failed',
2983
+ );
2984
+ expect(handoffEvents.length).toBe(1);
2985
+
2986
+ relay.destroy();
2987
+ });
2988
+
2989
+ test('timeout then transport-close race -> still emits only one handoff notification', async () => {
2990
+ mockConfig.calls.userConsultTimeoutSeconds = 2;
2991
+
2992
+ ensureConversation('conv-cb-handoff-race');
2993
+ const session = createCallSession({
2994
+ conversationId: 'conv-cb-handoff-race',
2995
+ provider: 'twilio',
2996
+ fromNumber: '+15557770023',
2997
+ toNumber: '+15551111111',
2998
+ assistantId: 'self',
2999
+ });
3000
+
3001
+ const { relay } = createMockWs(session.id);
3002
+
3003
+ await relay.handleMessage(JSON.stringify({
3004
+ type: 'setup',
3005
+ callSid: 'CA_cb_handoff_race',
3006
+ from: '+15557770023',
3007
+ to: '+15551111111',
3008
+ }));
3009
+
3010
+ await relay.handleMessage(JSON.stringify({
3011
+ type: 'prompt',
3012
+ voicePrompt: 'Race Tester',
3013
+ lang: 'en-US',
3014
+ last: true,
3015
+ }));
3016
+
3017
+ // Opt into callback (callback decisions bypass cooldown)
3018
+ await relay.handleMessage(JSON.stringify({
3019
+ type: 'prompt',
3020
+ voicePrompt: 'Hurry up please',
3021
+ lang: 'en-US',
3022
+ last: true,
3023
+ }));
3024
+
3025
+ await relay.handleMessage(JSON.stringify({
3026
+ type: 'prompt',
3027
+ voicePrompt: 'Yes call me back',
3028
+ lang: 'en-US',
3029
+ last: true,
3030
+ }));
3031
+
3032
+ // Wait for timeout to fire (2s) plus settling time
3033
+ await new Promise((resolve) => setTimeout(resolve, 2500));
3034
+
3035
+ // Now transport close too (simulating race)
3036
+ relay.handleTransportClosed(1000, 'Normal closure');
3037
+
3038
+ await new Promise((resolve) => setTimeout(resolve, 200));
3039
+
3040
+ const events = getCallEvents(session.id);
3041
+ // Guard should ensure only ONE handoff event
3042
+ const handoffEvents = events.filter(
3043
+ (e) => e.eventType === 'callback_handoff_notified' || e.eventType === 'callback_handoff_failed',
3044
+ );
3045
+ expect(handoffEvents.length).toBe(1);
3046
+
3047
+ mockConfig.calls.userConsultTimeoutSeconds = 120;
3048
+ relay.destroy();
3049
+ });
3050
+
3051
+ test('callback handoff payload includes requesterMemberId when voice caller maps to existing member', async () => {
3052
+ mockConfig.calls.userConsultTimeoutSeconds = 2;
3053
+
3054
+ ensureConversation('conv-cb-handoff-member');
3055
+ const session = createCallSession({
3056
+ conversationId: 'conv-cb-handoff-member',
3057
+ provider: 'twilio',
3058
+ fromNumber: '+15557770024',
3059
+ toNumber: '+15551111111',
3060
+ assistantId: 'self',
3061
+ });
3062
+
3063
+ const { relay } = createMockWs(session.id);
3064
+
3065
+ await relay.handleMessage(JSON.stringify({
3066
+ type: 'setup',
3067
+ callSid: 'CA_cb_handoff_member',
3068
+ from: '+15557770024',
3069
+ to: '+15551111111',
3070
+ }));
3071
+
3072
+ await relay.handleMessage(JSON.stringify({
3073
+ type: 'prompt',
3074
+ voicePrompt: 'Member Tester',
3075
+ lang: 'en-US',
3076
+ last: true,
3077
+ }));
3078
+
3079
+ expect(relay.getConnectionState()).toBe('awaiting_guardian_decision');
3080
+
3081
+ // Add the caller as a trusted contact AFTER the access request flow
3082
+ // is entered so resolveActorTrust doesn't skip the flow. The handoff
3083
+ // code uses findMember to resolve requesterMemberId at handoff time.
3084
+ addTrustedVoiceContact('+15557770024');
3085
+
3086
+ // Opt into callback (callback decisions bypass cooldown)
3087
+ await relay.handleMessage(JSON.stringify({
3088
+ type: 'prompt',
3089
+ voicePrompt: 'Hurry up',
3090
+ lang: 'en-US',
3091
+ last: true,
3092
+ }));
3093
+
3094
+ await relay.handleMessage(JSON.stringify({
3095
+ type: 'prompt',
3096
+ voicePrompt: 'Yes please call me back',
3097
+ lang: 'en-US',
3098
+ last: true,
3099
+ }));
3100
+
3101
+ // Wait for timeout (2s) plus settling time
3102
+ await new Promise((resolve) => setTimeout(resolve, 2500));
3103
+
3104
+ await new Promise((resolve) => setTimeout(resolve, 200));
3105
+
3106
+ const events = getCallEvents(session.id);
3107
+ const handoffEvents = events.filter(
3108
+ (e) => e.eventType === 'callback_handoff_notified' || e.eventType === 'callback_handoff_failed',
3109
+ );
3110
+ expect(handoffEvents.length).toBe(1);
3111
+
3112
+ // Parse the payload to verify requesterMemberId is present
3113
+ const handoffEvent = handoffEvents[0];
3114
+ const payload = JSON.parse(handoffEvent.payloadJson) as Record<string, unknown>;
3115
+ // The member was added, so requesterMemberId should be populated
3116
+ expect(payload.requesterMemberId).toBeDefined();
3117
+ expect(typeof payload.requesterMemberId).toBe('string');
3118
+
3119
+ mockConfig.calls.userConsultTimeoutSeconds = 120;
3120
+ relay.destroy();
3121
+ });
3122
+
3123
+ test('callback handoff payload omits member reference when no member record exists', async () => {
3124
+ mockConfig.calls.userConsultTimeoutSeconds = 2;
3125
+
3126
+ ensureConversation('conv-cb-handoff-no-member');
3127
+ const session = createCallSession({
3128
+ conversationId: 'conv-cb-handoff-no-member',
3129
+ provider: 'twilio',
3130
+ fromNumber: '+15557770025',
3131
+ toNumber: '+15551111111',
3132
+ assistantId: 'self',
3133
+ });
3134
+
3135
+ // Do NOT add caller as trusted contact
3136
+
3137
+ const { relay } = createMockWs(session.id);
3138
+
3139
+ await relay.handleMessage(JSON.stringify({
3140
+ type: 'setup',
3141
+ callSid: 'CA_cb_handoff_no_member',
3142
+ from: '+15557770025',
3143
+ to: '+15551111111',
3144
+ }));
3145
+
3146
+ await relay.handleMessage(JSON.stringify({
3147
+ type: 'prompt',
3148
+ voicePrompt: 'No Member Tester',
3149
+ lang: 'en-US',
3150
+ last: true,
3151
+ }));
3152
+
3153
+ expect(relay.getConnectionState()).toBe('awaiting_guardian_decision');
3154
+
3155
+ // Opt into callback (callback decisions bypass cooldown)
3156
+ await relay.handleMessage(JSON.stringify({
3157
+ type: 'prompt',
3158
+ voicePrompt: 'Come on hurry up',
3159
+ lang: 'en-US',
3160
+ last: true,
3161
+ }));
3162
+
3163
+ await relay.handleMessage(JSON.stringify({
3164
+ type: 'prompt',
3165
+ voicePrompt: 'Yes callback please',
3166
+ lang: 'en-US',
3167
+ last: true,
3168
+ }));
3169
+
3170
+ // Wait for timeout (2s) plus settling time
3171
+ await new Promise((resolve) => setTimeout(resolve, 2500));
3172
+
3173
+ await new Promise((resolve) => setTimeout(resolve, 200));
3174
+
3175
+ const events = getCallEvents(session.id);
3176
+ const handoffEvents = events.filter(
3177
+ (e) => e.eventType === 'callback_handoff_notified' || e.eventType === 'callback_handoff_failed',
3178
+ );
3179
+ expect(handoffEvents.length).toBe(1);
3180
+
3181
+ // Parse the payload to verify requesterMemberId is null
3182
+ const handoffEvent = handoffEvents[0];
3183
+ const payload = JSON.parse(handoffEvent.payloadJson) as Record<string, unknown>;
3184
+ expect(payload.requesterMemberId).toBeNull();
1744
3185
 
3186
+ mockConfig.calls.userConsultTimeoutSeconds = 120;
1745
3187
  relay.destroy();
1746
3188
  });
1747
3189
  });