@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
@@ -58,6 +58,10 @@ const mockConfig = {
58
58
  userConsultTimeoutSeconds: 120,
59
59
  ttsPlaybackDelayMs: 0,
60
60
  accessRequestPollIntervalMs: 50,
61
+ guardianWaitUpdateInitialIntervalMs: 100,
62
+ guardianWaitUpdateInitialWindowMs: 300,
63
+ guardianWaitUpdateSteadyMinIntervalMs: 150,
64
+ guardianWaitUpdateSteadyMaxIntervalMs: 200,
61
65
  disclosure: { enabled: false, text: '' },
62
66
  safety: { denyCategories: [] },
63
67
  callerIdentity: {
@@ -1140,12 +1144,12 @@ describe('relay-server', () => {
1140
1144
  provider: 'twilio',
1141
1145
  fromNumber: '+15559999999',
1142
1146
  toNumber: '+15551111111',
1143
- assistantId: 'test-assistant',
1147
+ assistantId: 'self',
1144
1148
  // no task — inbound call
1145
1149
  });
1146
1150
 
1147
1151
  // Create a pending voice guardian challenge
1148
- const secret = createPendingVoiceGuardianChallenge('test-assistant');
1152
+ const secret = createPendingVoiceGuardianChallenge('self');
1149
1153
 
1150
1154
  mockSendMessage.mockImplementation(createMockProviderResponse(['Hello, how can I help you?']));
1151
1155
 
@@ -1180,7 +1184,7 @@ describe('relay-server', () => {
1180
1184
  expect(relay.getConnectionState()).toBe('connected');
1181
1185
 
1182
1186
  // Guardian binding should have been created
1183
- const binding = getGuardianBinding('test-assistant', 'voice');
1187
+ const binding = getGuardianBinding('self', 'voice');
1184
1188
  expect(binding).not.toBeNull();
1185
1189
 
1186
1190
  // Orchestrator greeting should have fired
@@ -1204,10 +1208,10 @@ describe('relay-server', () => {
1204
1208
  provider: 'twilio',
1205
1209
  fromNumber: '+15559999999',
1206
1210
  toNumber: '+15551111111',
1207
- assistantId: 'test-assistant',
1211
+ assistantId: 'self',
1208
1212
  });
1209
1213
 
1210
- const secret = createPendingVoiceGuardianChallenge('test-assistant');
1214
+ const secret = createPendingVoiceGuardianChallenge('self');
1211
1215
 
1212
1216
  mockSendMessage.mockImplementation(createMockProviderResponse(['Hello, verified caller!']));
1213
1217
 
@@ -1238,7 +1242,7 @@ describe('relay-server', () => {
1238
1242
  expect(relay.getConnectionState()).toBe('connected');
1239
1243
 
1240
1244
  // Binding created
1241
- const binding = getGuardianBinding('test-assistant', 'voice');
1245
+ const binding = getGuardianBinding('self', 'voice');
1242
1246
  expect(binding).not.toBeNull();
1243
1247
 
1244
1248
  // Greeting should have started
@@ -1257,11 +1261,11 @@ describe('relay-server', () => {
1257
1261
  provider: 'twilio',
1258
1262
  fromNumber: '+15550001111',
1259
1263
  toNumber: '+15551111111',
1260
- assistantId: 'test-assistant',
1264
+ assistantId: 'self',
1261
1265
  });
1262
1266
 
1263
1267
  createBinding({
1264
- assistantId: 'test-assistant',
1268
+ assistantId: 'self',
1265
1269
  channel: 'voice',
1266
1270
  guardianExternalUserId: '+15550001111',
1267
1271
  guardianDeliveryChatId: '+15550001111',
@@ -1293,16 +1297,16 @@ describe('relay-server', () => {
1293
1297
  provider: 'twilio',
1294
1298
  fromNumber: '+15550002222',
1295
1299
  toNumber: '+15551111111',
1296
- assistantId: 'test-assistant',
1300
+ assistantId: 'self',
1297
1301
  });
1298
1302
 
1299
1303
  createBinding({
1300
- assistantId: 'test-assistant',
1304
+ assistantId: 'self',
1301
1305
  channel: 'voice',
1302
1306
  guardianExternalUserId: '+15550009999',
1303
1307
  guardianDeliveryChatId: '+15550009999',
1304
1308
  });
1305
- addTrustedVoiceContact('+15550002222', 'test-assistant');
1309
+ addTrustedVoiceContact('+15550002222', 'self');
1306
1310
 
1307
1311
  mockSendMessage.mockImplementation(createMockProviderResponse(['Hello there.']));
1308
1312
 
@@ -1339,12 +1343,12 @@ describe('relay-server', () => {
1339
1343
  provider: 'twilio',
1340
1344
  fromNumber: '+15551111111',
1341
1345
  toNumber: '+15550001111',
1342
- assistantId: 'test-assistant',
1346
+ assistantId: 'self',
1343
1347
  initiatedFromConversationId: 'conv-guardian-outbound-voice-origin',
1344
1348
  });
1345
1349
 
1346
1350
  createBinding({
1347
- assistantId: 'test-assistant',
1351
+ assistantId: 'self',
1348
1352
  channel: 'voice',
1349
1353
  guardianExternalUserId: '+15550001111',
1350
1354
  guardianDeliveryChatId: '+15550001111',
@@ -1383,12 +1387,12 @@ describe('relay-server', () => {
1383
1387
  provider: 'twilio',
1384
1388
  fromNumber: '+15551111111',
1385
1389
  toNumber: '+15550001111',
1386
- assistantId: 'test-assistant',
1390
+ assistantId: 'self',
1387
1391
  initiatedFromConversationId: 'conv-guardian-outbound-strict-origin',
1388
1392
  });
1389
1393
 
1390
1394
  createBinding({
1391
- assistantId: 'test-assistant',
1395
+ assistantId: 'self',
1392
1396
  channel: 'telegram',
1393
1397
  guardianExternalUserId: 'tg-guardian-user',
1394
1398
  guardianDeliveryChatId: 'tg-guardian-chat',
@@ -1427,10 +1431,10 @@ describe('relay-server', () => {
1427
1431
  provider: 'twilio',
1428
1432
  fromNumber: '+15550003333',
1429
1433
  toNumber: '+15551111111',
1430
- assistantId: 'test-assistant',
1434
+ assistantId: 'self',
1431
1435
  });
1432
1436
 
1433
- const secret = createPendingVoiceGuardianChallenge('test-assistant');
1437
+ const secret = createPendingVoiceGuardianChallenge('self');
1434
1438
  const spokenCode = secret.split('').join(' ');
1435
1439
 
1436
1440
  const { relay } = createMockWs(session.id);
@@ -1473,10 +1477,10 @@ describe('relay-server', () => {
1473
1477
  provider: 'twilio',
1474
1478
  fromNumber: '+15559999999',
1475
1479
  toNumber: '+15551111111',
1476
- assistantId: 'test-assistant',
1480
+ assistantId: 'self',
1477
1481
  });
1478
1482
 
1479
- createPendingVoiceGuardianChallenge('test-assistant');
1483
+ createPendingVoiceGuardianChallenge('self');
1480
1484
 
1481
1485
  const { ws, relay } = createMockWs(session.id);
1482
1486
 
@@ -1514,10 +1518,10 @@ describe('relay-server', () => {
1514
1518
  provider: 'twilio',
1515
1519
  fromNumber: '+15559999999',
1516
1520
  toNumber: '+15551111111',
1517
- assistantId: 'test-assistant',
1521
+ assistantId: 'self',
1518
1522
  });
1519
1523
 
1520
- createPendingVoiceGuardianChallenge('test-assistant');
1524
+ createPendingVoiceGuardianChallenge('self');
1521
1525
 
1522
1526
  const { ws, relay } = createMockWs(session.id);
1523
1527
 
@@ -1572,14 +1576,14 @@ describe('relay-server', () => {
1572
1576
  provider: 'twilio',
1573
1577
  fromNumber: '+15559999999',
1574
1578
  toNumber: '+15551111111',
1575
- assistantId: 'test-assistant',
1579
+ assistantId: 'self',
1576
1580
  // no task — inbound call
1577
1581
  });
1578
1582
 
1579
1583
  // Do NOT create any pending challenge
1580
1584
 
1581
1585
  mockSendMessage.mockImplementation(createMockProviderResponse(['Welcome to the line.']));
1582
- addTrustedVoiceContact('+15559999999', 'test-assistant');
1586
+ addTrustedVoiceContact('+15559999999', 'self');
1583
1587
 
1584
1588
  const { ws, relay } = createMockWs(session.id);
1585
1589
 
@@ -1612,10 +1616,10 @@ describe('relay-server', () => {
1612
1616
  provider: 'twilio',
1613
1617
  fromNumber: '+15559999999',
1614
1618
  toNumber: '+15551111111',
1615
- assistantId: 'test-assistant',
1619
+ assistantId: 'self',
1616
1620
  });
1617
1621
 
1618
- createPendingVoiceGuardianChallenge('test-assistant');
1622
+ createPendingVoiceGuardianChallenge('self');
1619
1623
 
1620
1624
  const { ws, relay } = createMockWs(session.id);
1621
1625
 
@@ -1659,13 +1663,13 @@ describe('relay-server', () => {
1659
1663
  provider: 'twilio',
1660
1664
  fromNumber: '+15551111111',
1661
1665
  toNumber: '+15559999999',
1662
- assistantId: 'test-assistant',
1666
+ assistantId: 'self',
1663
1667
  callMode: 'guardian_verification',
1664
1668
  guardianVerificationSessionId: 'gv-session-ptr-success',
1665
1669
  initiatedFromConversationId: 'conv-gv-pointer-success-origin',
1666
1670
  });
1667
1671
 
1668
- const secret = createVoiceVerificationSession('test-assistant', '+15559999999', 'gv-session-ptr-success');
1672
+ const secret = createVoiceVerificationSession('self', '+15559999999', 'gv-session-ptr-success');
1669
1673
 
1670
1674
  const { relay } = createMockWs(session.id);
1671
1675
 
@@ -1708,13 +1712,13 @@ describe('relay-server', () => {
1708
1712
  provider: 'twilio',
1709
1713
  fromNumber: '+15551111111',
1710
1714
  toNumber: '+15559999999',
1711
- assistantId: 'test-assistant',
1715
+ assistantId: 'self',
1712
1716
  callMode: 'guardian_verification',
1713
1717
  guardianVerificationSessionId: 'gv-session-ptr-fail',
1714
1718
  initiatedFromConversationId: 'conv-gv-pointer-fail-origin',
1715
1719
  });
1716
1720
 
1717
- createVoiceVerificationSession('test-assistant', '+15559999999', 'gv-session-ptr-fail');
1721
+ createVoiceVerificationSession('self', '+15559999999', 'gv-session-ptr-fail');
1718
1722
 
1719
1723
  const { relay } = createMockWs(session.id);
1720
1724
 
@@ -1959,7 +1963,7 @@ describe('relay-server', () => {
1959
1963
  // Should have transitioned to awaiting guardian decision
1960
1964
  expect(relay.getConnectionState()).toBe('awaiting_guardian_decision');
1961
1965
 
1962
- // Should have sent the hold message
1966
+ // Should have sent the hold message (guardian label defaults to "my guardian")
1963
1967
  const textMessages = ws.sentMessages
1964
1968
  .map((raw) => JSON.parse(raw) as { type: string; token?: string })
1965
1969
  .filter((m) => m.type === 'text');
@@ -2010,10 +2014,10 @@ describe('relay-server', () => {
2010
2014
  relay.destroy();
2011
2015
  });
2012
2016
 
2013
- test('name capture flow: voice prompts ignored during guardian decision wait', async () => {
2014
- ensureConversation('conv-wait-prompt-ignore');
2017
+ test('name capture flow: voice prompts during guardian wait get reassurance response', async () => {
2018
+ ensureConversation('conv-wait-prompt-reassure');
2015
2019
  const session = createCallSession({
2016
- conversationId: 'conv-wait-prompt-ignore',
2020
+ conversationId: 'conv-wait-prompt-reassure',
2017
2021
  provider: 'twilio',
2018
2022
  fromNumber: '+15558882222',
2019
2023
  toNumber: '+15551111111',
@@ -2024,7 +2028,7 @@ describe('relay-server', () => {
2024
2028
 
2025
2029
  await relay.handleMessage(JSON.stringify({
2026
2030
  type: 'setup',
2027
- callSid: 'CA_wait_prompt_ignore',
2031
+ callSid: 'CA_wait_prompt_reassure',
2028
2032
  from: '+15558882222',
2029
2033
  to: '+15551111111',
2030
2034
  }));
@@ -2040,7 +2044,7 @@ describe('relay-server', () => {
2040
2044
  expect(relay.getConnectionState()).toBe('awaiting_guardian_decision');
2041
2045
  const msgCountBefore = ws.sentMessages.length;
2042
2046
 
2043
- // Voice prompts during guardian wait should be ignored
2047
+ // Voice prompts during guardian wait should get a reassurance reply
2044
2048
  await relay.handleMessage(JSON.stringify({
2045
2049
  type: 'prompt',
2046
2050
  voicePrompt: 'Are you still there?',
@@ -2048,8 +2052,13 @@ describe('relay-server', () => {
2048
2052
  last: true,
2049
2053
  }));
2050
2054
 
2051
- // No new messages sent
2052
- expect(ws.sentMessages.length).toBe(msgCountBefore);
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);
2053
2062
  expect(relay.getConnectionState()).toBe('awaiting_guardian_decision');
2054
2063
 
2055
2064
  relay.destroy();
@@ -2265,7 +2274,7 @@ describe('relay-server', () => {
2265
2274
  const textMessages = ws.sentMessages
2266
2275
  .map((raw) => JSON.parse(raw) as { type: string; token?: string })
2267
2276
  .filter((m) => m.type === 'text');
2268
- expect(textMessages.some((m) => (m.token ?? '').includes("my guardian says I'm not allowed"))).toBe(true);
2277
+ expect(textMessages.some((m) => (m.token ?? '').includes("says I'm not allowed"))).toBe(true);
2269
2278
 
2270
2279
  // Session should be failed
2271
2280
  const updated = getCallSession(session.id);
@@ -2324,7 +2333,7 @@ describe('relay-server', () => {
2324
2333
  const textMessages = ws.sentMessages
2325
2334
  .map((raw) => JSON.parse(raw) as { type: string; token?: string })
2326
2335
  .filter((m) => m.type === 'text');
2327
- expect(textMessages.some((m) => (m.token ?? '').includes("can't get ahold of my guardian"))).toBe(true);
2336
+ expect(textMessages.some((m) => (m.token ?? '').includes("can't get ahold of"))).toBe(true);
2328
2337
  expect(textMessages.some((m) => (m.token ?? '').includes("let them know you called"))).toBe(true);
2329
2338
 
2330
2339
  // Session should be failed
@@ -2384,4 +2393,797 @@ describe('relay-server', () => {
2384
2393
 
2385
2394
  relay.destroy();
2386
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();
3185
+
3186
+ mockConfig.calls.userConsultTimeoutSeconds = 120;
3187
+ relay.destroy();
3188
+ });
2387
3189
  });