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