@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.
- package/.env.example +3 -0
- package/ARCHITECTURE.md +40 -3
- package/README.md +43 -35
- package/package.json +1 -1
- package/scripts/ipc/generate-swift.ts +1 -0
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +58 -120
- package/src/__tests__/actor-token-service.test.ts +1099 -0
- package/src/__tests__/agent-loop.test.ts +51 -0
- package/src/__tests__/approval-routes-http.test.ts +2 -0
- package/src/__tests__/assistant-events-sse-hardening.test.ts +7 -5
- package/src/__tests__/assistant-id-boundary-guard.test.ts +125 -0
- package/src/__tests__/call-controller.test.ts +49 -0
- package/src/__tests__/call-pointer-message-composer.test.ts +171 -0
- package/src/__tests__/call-pointer-messages.test.ts +93 -3
- package/src/__tests__/call-pointer-no-hardcoded-copy.guard.test.ts +42 -0
- package/src/__tests__/callback-handoff-copy.test.ts +186 -0
- package/src/__tests__/channel-approval-routes.test.ts +133 -12
- package/src/__tests__/channel-guardian.test.ts +0 -87
- package/src/__tests__/channel-readiness-service.test.ts +10 -16
- package/src/__tests__/checker.test.ts +33 -12
- package/src/__tests__/config-schema.test.ts +4 -0
- package/src/__tests__/confirmation-request-guardian-bridge.test.ts +410 -0
- package/src/__tests__/conversation-routes-guardian-reply.test.ts +256 -0
- package/src/__tests__/conversation-routes.test.ts +12 -3
- package/src/__tests__/credential-security-invariants.test.ts +1 -1
- package/src/__tests__/daemon-server-session-init.test.ts +4 -0
- package/src/__tests__/guardian-actions-endpoint.test.ts +19 -14
- package/src/__tests__/guardian-dispatch.test.ts +8 -0
- package/src/__tests__/guardian-outbound-http.test.ts +4 -4
- package/src/__tests__/guardian-question-mode.test.ts +200 -0
- package/src/__tests__/guardian-routing-invariants.test.ts +178 -0
- package/src/__tests__/guardian-routing-state.test.ts +525 -0
- package/src/__tests__/handle-user-message-secret-resume.test.ts +2 -0
- package/src/__tests__/handlers-telegram-config.test.ts +0 -83
- package/src/__tests__/handlers-user-message-approval-consumption.test.ts +55 -0
- package/src/__tests__/headless-browser-navigate.test.ts +2 -0
- package/src/__tests__/ipc-snapshot.test.ts +18 -51
- package/src/__tests__/non-member-access-request.test.ts +131 -8
- package/src/__tests__/notification-decision-fallback.test.ts +129 -4
- package/src/__tests__/notification-decision-strategy.test.ts +62 -2
- package/src/__tests__/notification-guardian-path.test.ts +3 -0
- package/src/__tests__/recording-intent-handler.test.ts +1 -0
- package/src/__tests__/relay-server.test.ts +841 -39
- package/src/__tests__/send-endpoint-busy.test.ts +5 -0
- package/src/__tests__/session-agent-loop.test.ts +1 -0
- package/src/__tests__/session-confirmation-signals.test.ts +523 -0
- package/src/__tests__/session-init.benchmark.test.ts +0 -1
- package/src/__tests__/session-surfaces-task-progress.test.ts +1 -1
- package/src/__tests__/session-tool-setup-app-refresh.test.ts +81 -2
- package/src/__tests__/session-tool-setup-memory-scope.test.ts +1 -1
- package/src/__tests__/session-tool-setup-side-effect-flag.test.ts +1 -1
- package/src/__tests__/tool-executor.test.ts +21 -2
- package/src/__tests__/tool-grant-request-escalation.test.ts +333 -27
- package/src/__tests__/trusted-contact-approval-notifier.test.ts +678 -0
- package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +1064 -0
- package/src/__tests__/twilio-config.test.ts +2 -13
- package/src/agent/loop.ts +1 -1
- package/src/approvals/guardian-decision-primitive.ts +10 -2
- package/src/approvals/guardian-request-resolvers.ts +128 -9
- package/src/calls/call-constants.ts +21 -0
- package/src/calls/call-controller.ts +9 -2
- package/src/calls/call-domain.ts +28 -7
- package/src/calls/call-pointer-message-composer.ts +154 -0
- package/src/calls/call-pointer-messages.ts +106 -27
- package/src/calls/guardian-dispatch.ts +4 -2
- package/src/calls/relay-server.ts +424 -12
- package/src/calls/twilio-config.ts +4 -11
- package/src/calls/twilio-routes.ts +1 -1
- package/src/calls/types.ts +3 -1
- package/src/cli.ts +5 -4
- package/src/config/bundled-skills/agentmail/SKILL.md +4 -0
- package/src/config/bundled-skills/app-builder/SKILL.md +146 -10
- package/src/config/bundled-skills/app-builder/TOOLS.json +1 -1
- package/src/config/bundled-skills/email-setup/SKILL.md +1 -1
- package/src/config/bundled-skills/google-oauth-setup/SKILL.md +105 -81
- package/src/config/bundled-skills/messaging/SKILL.md +61 -12
- package/src/config/bundled-skills/messaging/TOOLS.json +58 -0
- package/src/config/bundled-skills/messaging/tools/gmail-sender-digest.ts +6 -1
- package/src/config/bundled-skills/messaging/tools/messaging-archive-by-sender.ts +35 -0
- package/src/config/bundled-skills/messaging/tools/messaging-sender-digest.ts +52 -0
- package/src/config/bundled-skills/phone-calls/SKILL.md +30 -39
- package/src/config/bundled-skills/twitter/SKILL.md +3 -3
- package/src/config/bundled-skills/vercel-token-setup/SKILL.md +1 -0
- package/src/config/calls-schema.ts +24 -0
- package/src/config/env.ts +22 -0
- package/src/config/feature-flag-registry.json +8 -0
- package/src/config/schema.ts +2 -2
- package/src/config/skills.ts +11 -0
- package/src/config/system-prompt.ts +11 -1
- package/src/config/templates/SOUL.md +2 -0
- package/src/config/vellum-skills/sms-setup/SKILL.md +71 -82
- package/src/config/vellum-skills/trusted-contacts/SKILL.md +10 -9
- package/src/config/vellum-skills/twilio-setup/SKILL.md +88 -73
- package/src/daemon/call-pointer-generators.ts +59 -0
- package/src/daemon/computer-use-session.ts +2 -5
- package/src/daemon/handlers/apps.ts +76 -20
- package/src/daemon/handlers/config-channels.ts +5 -55
- package/src/daemon/handlers/config-inbox.ts +9 -3
- package/src/daemon/handlers/config-ingress.ts +28 -3
- package/src/daemon/handlers/config-telegram.ts +12 -0
- package/src/daemon/handlers/config.ts +2 -6
- package/src/daemon/handlers/pairing.ts +2 -0
- package/src/daemon/handlers/sessions.ts +48 -3
- package/src/daemon/handlers/shared.ts +17 -2
- package/src/daemon/ipc-contract/integrations.ts +1 -99
- package/src/daemon/ipc-contract/messages.ts +47 -1
- package/src/daemon/ipc-contract/notifications.ts +11 -0
- package/src/daemon/ipc-contract-inventory.json +2 -4
- package/src/daemon/lifecycle.ts +17 -0
- package/src/daemon/server.ts +14 -1
- package/src/daemon/session-agent-loop-handlers.ts +20 -0
- package/src/daemon/session-agent-loop.ts +22 -11
- package/src/daemon/session-lifecycle.ts +1 -1
- package/src/daemon/session-process.ts +11 -1
- package/src/daemon/session-runtime-assembly.ts +3 -0
- package/src/daemon/session-surfaces.ts +3 -2
- package/src/daemon/session.ts +88 -1
- package/src/daemon/tool-side-effects.ts +22 -0
- package/src/home-base/prebuilt/brain-graph.html +1483 -0
- package/src/home-base/prebuilt/index.html +40 -0
- package/src/inbound/platform-callback-registration.ts +157 -0
- package/src/memory/canonical-guardian-store.ts +1 -1
- package/src/memory/db-init.ts +4 -0
- package/src/memory/migrations/038-actor-token-records.ts +39 -0
- package/src/memory/migrations/index.ts +1 -0
- package/src/memory/schema.ts +16 -0
- package/src/messaging/provider-types.ts +24 -0
- package/src/messaging/provider.ts +7 -0
- package/src/messaging/providers/gmail/adapter.ts +127 -0
- package/src/messaging/providers/sms/adapter.ts +40 -37
- package/src/notifications/adapters/macos.ts +45 -2
- package/src/notifications/broadcaster.ts +16 -0
- package/src/notifications/copy-composer.ts +39 -1
- package/src/notifications/decision-engine.ts +22 -9
- package/src/notifications/destination-resolver.ts +16 -2
- package/src/notifications/emit-signal.ts +16 -8
- package/src/notifications/guardian-question-mode.ts +419 -0
- package/src/notifications/signal.ts +14 -3
- package/src/permissions/checker.ts +13 -1
- package/src/permissions/prompter.ts +14 -0
- package/src/providers/anthropic/client.ts +20 -0
- package/src/providers/provider-send-message.ts +15 -3
- package/src/runtime/access-request-helper.ts +71 -1
- package/src/runtime/actor-token-service.ts +234 -0
- package/src/runtime/actor-token-store.ts +236 -0
- package/src/runtime/channel-approvals.ts +5 -3
- package/src/runtime/channel-readiness-service.ts +23 -64
- package/src/runtime/channel-readiness-types.ts +3 -4
- package/src/runtime/channel-retry-sweep.ts +4 -1
- package/src/runtime/confirmation-request-guardian-bridge.ts +197 -0
- package/src/runtime/guardian-action-followup-executor.ts +1 -1
- package/src/runtime/guardian-context-resolver.ts +82 -0
- package/src/runtime/guardian-outbound-actions.ts +0 -3
- package/src/runtime/guardian-reply-router.ts +67 -30
- package/src/runtime/guardian-vellum-migration.ts +57 -0
- package/src/runtime/http-server.ts +65 -12
- package/src/runtime/http-types.ts +13 -0
- package/src/runtime/invite-redemption-service.ts +8 -0
- package/src/runtime/local-actor-identity.ts +76 -0
- package/src/runtime/middleware/actor-token.ts +271 -0
- package/src/runtime/routes/approval-routes.ts +82 -7
- package/src/runtime/routes/brain-graph-routes.ts +222 -0
- package/src/runtime/routes/channel-readiness-routes.ts +71 -0
- package/src/runtime/routes/conversation-routes.ts +140 -52
- package/src/runtime/routes/events-routes.ts +20 -5
- package/src/runtime/routes/guardian-action-routes.ts +45 -3
- package/src/runtime/routes/guardian-approval-interception.ts +29 -0
- package/src/runtime/routes/guardian-bootstrap-routes.ts +145 -0
- package/src/runtime/routes/inbound-message-handler.ts +143 -2
- package/src/runtime/routes/integration-routes.ts +7 -15
- package/src/runtime/routes/pairing-routes.ts +163 -0
- package/src/runtime/routes/twilio-routes.ts +934 -0
- package/src/runtime/tool-grant-request-helper.ts +3 -1
- package/src/security/oauth2.ts +27 -2
- package/src/security/token-manager.ts +46 -10
- package/src/tools/browser/browser-execution.ts +4 -3
- package/src/tools/browser/browser-handoff.ts +10 -18
- package/src/tools/browser/browser-manager.ts +80 -25
- package/src/tools/browser/browser-screencast.ts +35 -119
- package/src/tools/permission-checker.ts +15 -4
- package/src/tools/tool-approval-handler.ts +242 -18
- package/src/__tests__/handlers-twilio-config.test.ts +0 -1928
- 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: '
|
|
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('
|
|
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('
|
|
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: '
|
|
1211
|
+
assistantId: 'self',
|
|
1208
1212
|
});
|
|
1209
1213
|
|
|
1210
|
-
const secret = createPendingVoiceGuardianChallenge('
|
|
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('
|
|
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: '
|
|
1264
|
+
assistantId: 'self',
|
|
1261
1265
|
});
|
|
1262
1266
|
|
|
1263
1267
|
createBinding({
|
|
1264
|
-
assistantId: '
|
|
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: '
|
|
1300
|
+
assistantId: 'self',
|
|
1297
1301
|
});
|
|
1298
1302
|
|
|
1299
1303
|
createBinding({
|
|
1300
|
-
assistantId: '
|
|
1304
|
+
assistantId: 'self',
|
|
1301
1305
|
channel: 'voice',
|
|
1302
1306
|
guardianExternalUserId: '+15550009999',
|
|
1303
1307
|
guardianDeliveryChatId: '+15550009999',
|
|
1304
1308
|
});
|
|
1305
|
-
addTrustedVoiceContact('+15550002222', '
|
|
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: '
|
|
1346
|
+
assistantId: 'self',
|
|
1343
1347
|
initiatedFromConversationId: 'conv-guardian-outbound-voice-origin',
|
|
1344
1348
|
});
|
|
1345
1349
|
|
|
1346
1350
|
createBinding({
|
|
1347
|
-
assistantId: '
|
|
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: '
|
|
1390
|
+
assistantId: 'self',
|
|
1387
1391
|
initiatedFromConversationId: 'conv-guardian-outbound-strict-origin',
|
|
1388
1392
|
});
|
|
1389
1393
|
|
|
1390
1394
|
createBinding({
|
|
1391
|
-
assistantId: '
|
|
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: '
|
|
1434
|
+
assistantId: 'self',
|
|
1431
1435
|
});
|
|
1432
1436
|
|
|
1433
|
-
const secret = createPendingVoiceGuardianChallenge('
|
|
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: '
|
|
1480
|
+
assistantId: 'self',
|
|
1477
1481
|
});
|
|
1478
1482
|
|
|
1479
|
-
createPendingVoiceGuardianChallenge('
|
|
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: '
|
|
1521
|
+
assistantId: 'self',
|
|
1518
1522
|
});
|
|
1519
1523
|
|
|
1520
|
-
createPendingVoiceGuardianChallenge('
|
|
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: '
|
|
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', '
|
|
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: '
|
|
1619
|
+
assistantId: 'self',
|
|
1616
1620
|
});
|
|
1617
1621
|
|
|
1618
|
-
createPendingVoiceGuardianChallenge('
|
|
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: '
|
|
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('
|
|
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: '
|
|
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('
|
|
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
|
|
2014
|
-
ensureConversation('conv-wait-prompt-
|
|
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-
|
|
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: '
|
|
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
|
|
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
|
-
//
|
|
2052
|
-
|
|
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("
|
|
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
|
|
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
|
});
|