@vellumai/assistant 0.4.2 → 0.4.3
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/ARCHITECTURE.md +84 -7
- package/docs/trusted-contact-access.md +20 -0
- package/package.json +1 -1
- package/src/__tests__/access-request-decision.test.ts +0 -1
- package/src/__tests__/assistant-id-boundary-guard.test.ts +290 -0
- package/src/__tests__/call-routes-http.test.ts +0 -25
- package/src/__tests__/channel-guardian.test.ts +6 -5
- package/src/__tests__/config-schema.test.ts +2 -0
- package/src/__tests__/deterministic-verification-control-plane.test.ts +0 -1
- package/src/__tests__/guardian-actions-endpoint.test.ts +21 -0
- package/src/__tests__/guardian-outbound-http.test.ts +0 -1
- package/src/__tests__/inbound-invite-redemption.test.ts +0 -1
- package/src/__tests__/ingress-routes-http.test.ts +55 -0
- package/src/__tests__/non-member-access-request.test.ts +28 -1
- package/src/__tests__/notification-decision-strategy.test.ts +44 -0
- package/src/__tests__/relay-server.test.ts +644 -4
- package/src/__tests__/session-init.benchmark.test.ts +0 -1
- package/src/__tests__/session-runtime-assembly.test.ts +4 -1
- package/src/__tests__/session-surfaces-task-progress.test.ts +43 -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-routes.test.ts +4 -3
- package/src/__tests__/update-bulletin.test.ts +0 -1
- package/src/approvals/guardian-decision-primitive.ts +2 -1
- package/src/approvals/guardian-request-resolvers.ts +42 -3
- package/src/calls/call-constants.ts +8 -0
- package/src/calls/call-controller.ts +2 -1
- package/src/calls/call-domain.ts +5 -4
- package/src/calls/relay-server.ts +513 -116
- package/src/calls/twilio-routes.ts +3 -5
- package/src/calls/types.ts +1 -1
- package/src/calls/voice-session-bridge.ts +4 -3
- package/src/cli/core-commands.ts +7 -4
- package/src/config/bundled-skills/app-builder/SKILL.md +164 -1
- package/src/config/bundled-skills/vercel-token-setup/SKILL.md +214 -0
- package/src/config/calls-schema.ts +12 -0
- package/src/config/feature-flag-registry.json +0 -8
- package/src/config/vellum-skills/trusted-contacts/SKILL.md +8 -2
- package/src/daemon/handlers/config-channels.ts +5 -7
- package/src/daemon/handlers/config-inbox.ts +2 -0
- package/src/daemon/handlers/index.ts +2 -1
- package/src/daemon/handlers/publish.ts +11 -46
- package/src/daemon/handlers/sessions.ts +11 -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 +3 -1
- package/src/daemon/server.ts +2 -1
- package/src/daemon/session-agent-loop.ts +2 -1
- package/src/daemon/session-runtime-assembly.ts +3 -1
- package/src/daemon/session-surfaces.ts +29 -1
- package/src/memory/conversation-crud.ts +2 -1
- package/src/memory/conversation-title-service.ts +16 -2
- package/src/memory/db-init.ts +4 -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/124-voice-invite-display-metadata.ts +14 -0
- package/src/memory/migrations/index.ts +1 -0
- package/src/memory/schema.ts +10 -5
- package/src/notifications/copy-composer.ts +11 -1
- package/src/notifications/emit-signal.ts +2 -1
- package/src/runtime/access-request-helper.ts +11 -3
- package/src/runtime/actor-trust-resolver.ts +2 -2
- package/src/runtime/assistant-scope.ts +10 -0
- package/src/runtime/guardian-outbound-actions.ts +5 -4
- package/src/runtime/http-server.ts +11 -20
- package/src/runtime/ingress-service.ts +14 -0
- package/src/runtime/invite-redemption-service.ts +2 -1
- package/src/runtime/middleware/twilio-validation.ts +2 -4
- package/src/runtime/routes/call-routes.ts +2 -1
- 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 +2 -1
- package/src/runtime/routes/events-routes.ts +2 -3
- package/src/runtime/routes/inbound-conversation.ts +4 -3
- package/src/runtime/routes/inbound-message-handler.ts +4 -3
- package/src/runtime/routes/ingress-routes.ts +2 -0
- package/src/tools/calls/call-start.ts +2 -1
- package/src/tools/terminal/parser.ts +12 -0
- package/src/tools/tool-approval-handler.ts +2 -1
- package/src/workspace/git-service.ts +19 -0
|
@@ -56,6 +56,8 @@ const mockConfig = {
|
|
|
56
56
|
provider: 'twilio',
|
|
57
57
|
maxDurationSeconds: 3600,
|
|
58
58
|
userConsultTimeoutSeconds: 120,
|
|
59
|
+
ttsPlaybackDelayMs: 0,
|
|
60
|
+
accessRequestPollIntervalMs: 50,
|
|
59
61
|
disclosure: { enabled: false, text: '' },
|
|
60
62
|
safety: { denyCategories: [] },
|
|
61
63
|
callerIdentity: {
|
|
@@ -135,15 +137,18 @@ import {
|
|
|
135
137
|
import type { RelayWebSocketData } from '../calls/relay-server.js';
|
|
136
138
|
import { activeRelayConnections,RelayConnection } from '../calls/relay-server.js';
|
|
137
139
|
import { setVoiceBridgeDeps } from '../calls/voice-session-bridge.js';
|
|
140
|
+
import { listCanonicalGuardianRequests, resolveCanonicalGuardianRequest } from '../memory/canonical-guardian-store.js';
|
|
138
141
|
import { createBinding, createChallenge } from '../memory/channel-guardian-store.js';
|
|
139
142
|
import { addMessage, getMessages } from '../memory/conversation-store.js';
|
|
140
143
|
import { getDb, initializeDb, resetDb } from '../memory/db.js';
|
|
144
|
+
import { createInvite } from '../memory/ingress-invite-store.js';
|
|
141
145
|
import { upsertMember } from '../memory/ingress-member-store.js';
|
|
142
146
|
import { conversations } from '../memory/schema.js';
|
|
143
147
|
import {
|
|
144
148
|
createOutboundSession,
|
|
145
149
|
getGuardianBinding,
|
|
146
150
|
} from '../runtime/channel-guardian-service.js';
|
|
151
|
+
import { generateVoiceCode, hashVoiceCode } from '../util/voice-code.js';
|
|
147
152
|
|
|
148
153
|
initializeDb();
|
|
149
154
|
|
|
@@ -205,9 +210,12 @@ function resetTables() {
|
|
|
205
210
|
db.run('DELETE FROM messages');
|
|
206
211
|
db.run('DELETE FROM conversations');
|
|
207
212
|
db.run('DELETE FROM assistant_ingress_members');
|
|
213
|
+
db.run('DELETE FROM assistant_ingress_invites');
|
|
208
214
|
db.run('DELETE FROM channel_guardian_verification_challenges');
|
|
209
215
|
db.run('DELETE FROM channel_guardian_bindings');
|
|
210
216
|
db.run('DELETE FROM channel_guardian_rate_limits');
|
|
217
|
+
db.run('DELETE FROM canonical_guardian_requests');
|
|
218
|
+
db.run('DELETE FROM canonical_guardian_deliveries');
|
|
211
219
|
ensuredConvIds = new Set();
|
|
212
220
|
}
|
|
213
221
|
|
|
@@ -733,7 +741,7 @@ describe('relay-server', () => {
|
|
|
733
741
|
expect(getLatestAssistantText('conv-relay-verify-race')).toContain('**Call failed**');
|
|
734
742
|
|
|
735
743
|
// Let the delayed endSession callback flush to avoid timer bleed across tests.
|
|
736
|
-
await new Promise((resolve) => setTimeout(resolve,
|
|
744
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
737
745
|
|
|
738
746
|
const finalState = getCallSession(session.id);
|
|
739
747
|
expect(finalState).not.toBeNull();
|
|
@@ -1546,7 +1554,7 @@ describe('relay-server', () => {
|
|
|
1546
1554
|
expect(events.some((e) => e.eventType === 'guardian_voice_verification_failed')).toBe(true);
|
|
1547
1555
|
|
|
1548
1556
|
// Let the delayed endSession callback flush
|
|
1549
|
-
await new Promise((resolve) => setTimeout(resolve,
|
|
1557
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
1550
1558
|
|
|
1551
1559
|
// Verify end message was sent
|
|
1552
1560
|
const endMessages = ws.sentMessages
|
|
@@ -1687,7 +1695,7 @@ describe('relay-server', () => {
|
|
|
1687
1695
|
expect(originText).toContain('succeeded');
|
|
1688
1696
|
|
|
1689
1697
|
// Let the delayed endSession callback flush
|
|
1690
|
-
await new Promise((resolve) => setTimeout(resolve,
|
|
1698
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
1691
1699
|
|
|
1692
1700
|
relay.destroy();
|
|
1693
1701
|
});
|
|
@@ -1740,7 +1748,639 @@ describe('relay-server', () => {
|
|
|
1740
1748
|
expect(originText).toContain('failed');
|
|
1741
1749
|
|
|
1742
1750
|
// Let the delayed endSession callback flush
|
|
1743
|
-
await new Promise((resolve) => setTimeout(resolve,
|
|
1751
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
1752
|
+
|
|
1753
|
+
relay.destroy();
|
|
1754
|
+
});
|
|
1755
|
+
|
|
1756
|
+
// ── Inbound voice invite redemption ──────────────────────────────────
|
|
1757
|
+
|
|
1758
|
+
test('inbound voice invite redemption: personalized welcome prompt with friend/guardian names', async () => {
|
|
1759
|
+
ensureConversation('conv-invite-welcome');
|
|
1760
|
+
const session = createCallSession({
|
|
1761
|
+
conversationId: 'conv-invite-welcome',
|
|
1762
|
+
provider: 'twilio',
|
|
1763
|
+
fromNumber: '+15558887777',
|
|
1764
|
+
toNumber: '+15551111111',
|
|
1765
|
+
assistantId: 'self',
|
|
1766
|
+
});
|
|
1767
|
+
|
|
1768
|
+
// Create a voice invite with friend/guardian names
|
|
1769
|
+
const code = generateVoiceCode(6);
|
|
1770
|
+
const codeHash = hashVoiceCode(code);
|
|
1771
|
+
createInvite({
|
|
1772
|
+
assistantId: 'self',
|
|
1773
|
+
sourceChannel: 'voice',
|
|
1774
|
+
maxUses: 1,
|
|
1775
|
+
expectedExternalUserId: '+15558887777',
|
|
1776
|
+
voiceCodeHash: codeHash,
|
|
1777
|
+
voiceCodeDigits: 6,
|
|
1778
|
+
friendName: 'Alice',
|
|
1779
|
+
guardianName: 'Bob',
|
|
1780
|
+
});
|
|
1781
|
+
|
|
1782
|
+
mockSendMessage.mockImplementation(createMockProviderResponse(['Hello, how can I help?']));
|
|
1783
|
+
|
|
1784
|
+
const { ws, relay } = createMockWs(session.id);
|
|
1785
|
+
|
|
1786
|
+
await relay.handleMessage(JSON.stringify({
|
|
1787
|
+
type: 'setup',
|
|
1788
|
+
callSid: 'CA_invite_welcome',
|
|
1789
|
+
from: '+15558887777',
|
|
1790
|
+
to: '+15551111111',
|
|
1791
|
+
}));
|
|
1792
|
+
|
|
1793
|
+
// Should be in verification-pending state for invite redemption
|
|
1794
|
+
expect(relay.getConnectionState()).toBe('verification_pending');
|
|
1795
|
+
|
|
1796
|
+
// Check that the welcome prompt includes friend/guardian names
|
|
1797
|
+
const textMessages = ws.sentMessages
|
|
1798
|
+
.map((raw) => JSON.parse(raw) as { type: string; token?: string })
|
|
1799
|
+
.filter((m) => m.type === 'text');
|
|
1800
|
+
expect(textMessages.some((m) => (m.token ?? '').includes('Welcome Alice'))).toBe(true);
|
|
1801
|
+
expect(textMessages.some((m) => (m.token ?? '').includes('Bob provided you'))).toBe(true);
|
|
1802
|
+
|
|
1803
|
+
// Enter the correct code via DTMF
|
|
1804
|
+
for (const digit of code) {
|
|
1805
|
+
await relay.handleMessage(JSON.stringify({ type: 'dtmf', digit }));
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
1809
|
+
|
|
1810
|
+
// Should have transitioned to connected
|
|
1811
|
+
expect(relay.getConnectionState()).toBe('connected');
|
|
1812
|
+
|
|
1813
|
+
// Verify events
|
|
1814
|
+
const events = getCallEvents(session.id);
|
|
1815
|
+
expect(events.some((e) => e.eventType === 'invite_redemption_started')).toBe(true);
|
|
1816
|
+
expect(events.some((e) => e.eventType === 'invite_redemption_succeeded')).toBe(true);
|
|
1817
|
+
|
|
1818
|
+
relay.destroy();
|
|
1819
|
+
});
|
|
1820
|
+
|
|
1821
|
+
test('inbound voice invite redemption: invalid code gets exact failure copy with guardian name and call ends', async () => {
|
|
1822
|
+
ensureConversation('conv-invite-fail');
|
|
1823
|
+
const session = createCallSession({
|
|
1824
|
+
conversationId: 'conv-invite-fail',
|
|
1825
|
+
provider: 'twilio',
|
|
1826
|
+
fromNumber: '+15558886666',
|
|
1827
|
+
toNumber: '+15551111111',
|
|
1828
|
+
assistantId: 'self',
|
|
1829
|
+
});
|
|
1830
|
+
|
|
1831
|
+
// Create a voice invite with friend/guardian names
|
|
1832
|
+
const code = generateVoiceCode(6);
|
|
1833
|
+
const codeHash = hashVoiceCode(code);
|
|
1834
|
+
createInvite({
|
|
1835
|
+
assistantId: 'self',
|
|
1836
|
+
sourceChannel: 'voice',
|
|
1837
|
+
maxUses: 1,
|
|
1838
|
+
expectedExternalUserId: '+15558886666',
|
|
1839
|
+
voiceCodeHash: codeHash,
|
|
1840
|
+
voiceCodeDigits: 6,
|
|
1841
|
+
friendName: 'Carol',
|
|
1842
|
+
guardianName: 'Dave',
|
|
1843
|
+
});
|
|
1844
|
+
|
|
1845
|
+
const { ws, relay } = createMockWs(session.id);
|
|
1846
|
+
|
|
1847
|
+
await relay.handleMessage(JSON.stringify({
|
|
1848
|
+
type: 'setup',
|
|
1849
|
+
callSid: 'CA_invite_fail',
|
|
1850
|
+
from: '+15558886666',
|
|
1851
|
+
to: '+15551111111',
|
|
1852
|
+
}));
|
|
1853
|
+
|
|
1854
|
+
expect(relay.getConnectionState()).toBe('verification_pending');
|
|
1855
|
+
|
|
1856
|
+
// Enter a wrong code
|
|
1857
|
+
for (const digit of '000000') {
|
|
1858
|
+
await relay.handleMessage(JSON.stringify({ type: 'dtmf', digit }));
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
// Call should be marked as failed immediately
|
|
1862
|
+
const updated = getCallSession(session.id);
|
|
1863
|
+
expect(updated).not.toBeNull();
|
|
1864
|
+
expect(updated!.status).toBe('failed');
|
|
1865
|
+
|
|
1866
|
+
// Should have sent the exact deterministic failure copy with guardian name
|
|
1867
|
+
const textMessages = ws.sentMessages
|
|
1868
|
+
.map((raw) => JSON.parse(raw) as { type: string; token?: string })
|
|
1869
|
+
.filter((m) => m.type === 'text');
|
|
1870
|
+
expect(textMessages.some((m) => (m.token ?? '').includes('Sorry, the code you provided is incorrect or has since expired'))).toBe(true);
|
|
1871
|
+
expect(textMessages.some((m) => (m.token ?? '').includes('Please ask Dave for a new code'))).toBe(true);
|
|
1872
|
+
|
|
1873
|
+
// Verify events
|
|
1874
|
+
const events = getCallEvents(session.id);
|
|
1875
|
+
expect(events.some((e) => e.eventType === 'invite_redemption_failed')).toBe(true);
|
|
1876
|
+
|
|
1877
|
+
// Let the delayed endSession callback flush
|
|
1878
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
1879
|
+
|
|
1880
|
+
// Verify end message was sent
|
|
1881
|
+
const endMessages = ws.sentMessages
|
|
1882
|
+
.map((raw) => JSON.parse(raw) as { type: string })
|
|
1883
|
+
.filter((m) => m.type === 'end');
|
|
1884
|
+
expect(endMessages.length).toBe(1);
|
|
1885
|
+
|
|
1886
|
+
relay.destroy();
|
|
1887
|
+
});
|
|
1888
|
+
|
|
1889
|
+
test('inbound voice: unknown caller with no active invite enters name capture flow', async () => {
|
|
1890
|
+
ensureConversation('conv-invite-no-invite');
|
|
1891
|
+
const session = createCallSession({
|
|
1892
|
+
conversationId: 'conv-invite-no-invite',
|
|
1893
|
+
provider: 'twilio',
|
|
1894
|
+
fromNumber: '+15558885555',
|
|
1895
|
+
toNumber: '+15551111111',
|
|
1896
|
+
assistantId: 'self',
|
|
1897
|
+
});
|
|
1898
|
+
|
|
1899
|
+
// No voice invite created for this caller
|
|
1900
|
+
|
|
1901
|
+
const { ws, relay } = createMockWs(session.id);
|
|
1902
|
+
|
|
1903
|
+
await relay.handleMessage(JSON.stringify({
|
|
1904
|
+
type: 'setup',
|
|
1905
|
+
callSid: 'CA_invite_no_invite',
|
|
1906
|
+
from: '+15558885555',
|
|
1907
|
+
to: '+15551111111',
|
|
1908
|
+
}));
|
|
1909
|
+
|
|
1910
|
+
// Should be in the name capture state (not denied)
|
|
1911
|
+
expect(relay.getConnectionState()).toBe('awaiting_name');
|
|
1912
|
+
|
|
1913
|
+
// Should have sent the name capture prompt
|
|
1914
|
+
const textMessages = ws.sentMessages
|
|
1915
|
+
.map((raw) => JSON.parse(raw) as { type: string; token?: string })
|
|
1916
|
+
.filter((m) => m.type === 'text');
|
|
1917
|
+
expect(textMessages.some((m) => (m.token ?? '').includes("don't recognize this number"))).toBe(true);
|
|
1918
|
+
expect(textMessages.some((m) => (m.token ?? '').includes('Can I get your name'))).toBe(true);
|
|
1919
|
+
|
|
1920
|
+
// Verify event was recorded
|
|
1921
|
+
const events = getCallEvents(session.id);
|
|
1922
|
+
expect(events.some((e) => e.eventType === 'inbound_acl_name_capture_started')).toBe(true);
|
|
1923
|
+
|
|
1924
|
+
relay.destroy();
|
|
1925
|
+
});
|
|
1926
|
+
|
|
1927
|
+
// ── Friend-initiated in-call guardian approval flow ────────────────────
|
|
1928
|
+
|
|
1929
|
+
test('name capture flow: caller provides name and enters guardian decision wait', async () => {
|
|
1930
|
+
ensureConversation('conv-name-capture');
|
|
1931
|
+
const session = createCallSession({
|
|
1932
|
+
conversationId: 'conv-name-capture',
|
|
1933
|
+
provider: 'twilio',
|
|
1934
|
+
fromNumber: '+15558884444',
|
|
1935
|
+
toNumber: '+15551111111',
|
|
1936
|
+
assistantId: 'self',
|
|
1937
|
+
});
|
|
1938
|
+
|
|
1939
|
+
const { ws, relay } = createMockWs(session.id);
|
|
1940
|
+
|
|
1941
|
+
await relay.handleMessage(JSON.stringify({
|
|
1942
|
+
type: 'setup',
|
|
1943
|
+
callSid: 'CA_name_capture',
|
|
1944
|
+
from: '+15558884444',
|
|
1945
|
+
to: '+15551111111',
|
|
1946
|
+
}));
|
|
1947
|
+
|
|
1948
|
+
// Should be in name capture state
|
|
1949
|
+
expect(relay.getConnectionState()).toBe('awaiting_name');
|
|
1950
|
+
|
|
1951
|
+
// Caller speaks their name
|
|
1952
|
+
await relay.handleMessage(JSON.stringify({
|
|
1953
|
+
type: 'prompt',
|
|
1954
|
+
voicePrompt: 'My name is John',
|
|
1955
|
+
lang: 'en-US',
|
|
1956
|
+
last: true,
|
|
1957
|
+
}));
|
|
1958
|
+
|
|
1959
|
+
// Should have transitioned to awaiting guardian decision
|
|
1960
|
+
expect(relay.getConnectionState()).toBe('awaiting_guardian_decision');
|
|
1961
|
+
|
|
1962
|
+
// Should have sent the hold message
|
|
1963
|
+
const textMessages = ws.sentMessages
|
|
1964
|
+
.map((raw) => JSON.parse(raw) as { type: string; token?: string })
|
|
1965
|
+
.filter((m) => m.type === 'text');
|
|
1966
|
+
expect(textMessages.some((m) => (m.token ?? '').includes("I've let my guardian know"))).toBe(true);
|
|
1967
|
+
expect(textMessages.some((m) => (m.token ?? '').includes('Please hold'))).toBe(true);
|
|
1968
|
+
|
|
1969
|
+
// Verify events were recorded
|
|
1970
|
+
const events = getCallEvents(session.id);
|
|
1971
|
+
expect(events.some((e) => e.eventType === 'inbound_acl_name_captured')).toBe(true);
|
|
1972
|
+
|
|
1973
|
+
// Session should be in waiting_on_user status
|
|
1974
|
+
const updated = getCallSession(session.id);
|
|
1975
|
+
expect(updated).not.toBeNull();
|
|
1976
|
+
expect(updated!.status).toBe('waiting_on_user');
|
|
1977
|
+
|
|
1978
|
+
relay.destroy();
|
|
1979
|
+
});
|
|
1980
|
+
|
|
1981
|
+
test('name capture flow: DTMF input is ignored during awaiting_name state', async () => {
|
|
1982
|
+
ensureConversation('conv-name-dtmf-ignore');
|
|
1983
|
+
const session = createCallSession({
|
|
1984
|
+
conversationId: 'conv-name-dtmf-ignore',
|
|
1985
|
+
provider: 'twilio',
|
|
1986
|
+
fromNumber: '+15558883333',
|
|
1987
|
+
toNumber: '+15551111111',
|
|
1988
|
+
assistantId: 'self',
|
|
1989
|
+
});
|
|
1990
|
+
|
|
1991
|
+
const { ws, relay } = createMockWs(session.id);
|
|
1992
|
+
|
|
1993
|
+
await relay.handleMessage(JSON.stringify({
|
|
1994
|
+
type: 'setup',
|
|
1995
|
+
callSid: 'CA_name_dtmf_ignore',
|
|
1996
|
+
from: '+15558883333',
|
|
1997
|
+
to: '+15551111111',
|
|
1998
|
+
}));
|
|
1999
|
+
|
|
2000
|
+
expect(relay.getConnectionState()).toBe('awaiting_name');
|
|
2001
|
+
const msgCountBefore = ws.sentMessages.length;
|
|
2002
|
+
|
|
2003
|
+
// DTMF should be ignored during name capture
|
|
2004
|
+
await relay.handleMessage(JSON.stringify({ type: 'dtmf', digit: '5' }));
|
|
2005
|
+
|
|
2006
|
+
// No new messages should be sent (DTMF is ignored)
|
|
2007
|
+
expect(ws.sentMessages.length).toBe(msgCountBefore);
|
|
2008
|
+
expect(relay.getConnectionState()).toBe('awaiting_name');
|
|
2009
|
+
|
|
2010
|
+
relay.destroy();
|
|
2011
|
+
});
|
|
2012
|
+
|
|
2013
|
+
test('name capture flow: voice prompts ignored during guardian decision wait', async () => {
|
|
2014
|
+
ensureConversation('conv-wait-prompt-ignore');
|
|
2015
|
+
const session = createCallSession({
|
|
2016
|
+
conversationId: 'conv-wait-prompt-ignore',
|
|
2017
|
+
provider: 'twilio',
|
|
2018
|
+
fromNumber: '+15558882222',
|
|
2019
|
+
toNumber: '+15551111111',
|
|
2020
|
+
assistantId: 'self',
|
|
2021
|
+
});
|
|
2022
|
+
|
|
2023
|
+
const { ws, relay } = createMockWs(session.id);
|
|
2024
|
+
|
|
2025
|
+
await relay.handleMessage(JSON.stringify({
|
|
2026
|
+
type: 'setup',
|
|
2027
|
+
callSid: 'CA_wait_prompt_ignore',
|
|
2028
|
+
from: '+15558882222',
|
|
2029
|
+
to: '+15551111111',
|
|
2030
|
+
}));
|
|
2031
|
+
|
|
2032
|
+
// Provide name to enter guardian decision wait
|
|
2033
|
+
await relay.handleMessage(JSON.stringify({
|
|
2034
|
+
type: 'prompt',
|
|
2035
|
+
voicePrompt: 'Jane Doe',
|
|
2036
|
+
lang: 'en-US',
|
|
2037
|
+
last: true,
|
|
2038
|
+
}));
|
|
2039
|
+
|
|
2040
|
+
expect(relay.getConnectionState()).toBe('awaiting_guardian_decision');
|
|
2041
|
+
const msgCountBefore = ws.sentMessages.length;
|
|
2042
|
+
|
|
2043
|
+
// Voice prompts during guardian wait should be ignored
|
|
2044
|
+
await relay.handleMessage(JSON.stringify({
|
|
2045
|
+
type: 'prompt',
|
|
2046
|
+
voicePrompt: 'Are you still there?',
|
|
2047
|
+
lang: 'en-US',
|
|
2048
|
+
last: true,
|
|
2049
|
+
}));
|
|
2050
|
+
|
|
2051
|
+
// No new messages sent
|
|
2052
|
+
expect(ws.sentMessages.length).toBe(msgCountBefore);
|
|
2053
|
+
expect(relay.getConnectionState()).toBe('awaiting_guardian_decision');
|
|
2054
|
+
|
|
2055
|
+
relay.destroy();
|
|
2056
|
+
});
|
|
2057
|
+
|
|
2058
|
+
test('blocked caller gets immediate denial even with name capture flow', async () => {
|
|
2059
|
+
ensureConversation('conv-blocked-deny');
|
|
2060
|
+
const session = createCallSession({
|
|
2061
|
+
conversationId: 'conv-blocked-deny',
|
|
2062
|
+
provider: 'twilio',
|
|
2063
|
+
fromNumber: '+15558881111',
|
|
2064
|
+
toNumber: '+15551111111',
|
|
2065
|
+
assistantId: 'self',
|
|
2066
|
+
});
|
|
2067
|
+
|
|
2068
|
+
// Create a blocked member
|
|
2069
|
+
upsertMember({
|
|
2070
|
+
assistantId: 'self',
|
|
2071
|
+
sourceChannel: 'voice',
|
|
2072
|
+
externalUserId: '+15558881111',
|
|
2073
|
+
externalChatId: '+15558881111',
|
|
2074
|
+
status: 'blocked',
|
|
2075
|
+
policy: 'allow',
|
|
2076
|
+
});
|
|
2077
|
+
|
|
2078
|
+
const { ws, relay } = createMockWs(session.id);
|
|
2079
|
+
|
|
2080
|
+
await relay.handleMessage(JSON.stringify({
|
|
2081
|
+
type: 'setup',
|
|
2082
|
+
callSid: 'CA_blocked_deny',
|
|
2083
|
+
from: '+15558881111',
|
|
2084
|
+
to: '+15551111111',
|
|
2085
|
+
}));
|
|
2086
|
+
|
|
2087
|
+
// Blocked callers should NOT enter name capture — they get immediate denial
|
|
2088
|
+
expect(relay.getConnectionState()).toBe('disconnecting');
|
|
2089
|
+
|
|
2090
|
+
const updated = getCallSession(session.id);
|
|
2091
|
+
expect(updated).not.toBeNull();
|
|
2092
|
+
expect(updated!.status).toBe('failed');
|
|
2093
|
+
|
|
2094
|
+
const textMessages = ws.sentMessages
|
|
2095
|
+
.map((raw) => JSON.parse(raw) as { type: string; token?: string })
|
|
2096
|
+
.filter((m) => m.type === 'text');
|
|
2097
|
+
expect(textMessages.some((m) => (m.token ?? '').includes('not authorized'))).toBe(true);
|
|
2098
|
+
|
|
2099
|
+
// Let delayed endSession callback flush
|
|
2100
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
2101
|
+
|
|
2102
|
+
relay.destroy();
|
|
2103
|
+
});
|
|
2104
|
+
|
|
2105
|
+
test('name capture flow: access request creates canonical request for guardian', async () => {
|
|
2106
|
+
ensureConversation('conv-access-req-canonical');
|
|
2107
|
+
const session = createCallSession({
|
|
2108
|
+
conversationId: 'conv-access-req-canonical',
|
|
2109
|
+
provider: 'twilio',
|
|
2110
|
+
fromNumber: '+15557770001',
|
|
2111
|
+
toNumber: '+15551111111',
|
|
2112
|
+
assistantId: 'self',
|
|
2113
|
+
});
|
|
2114
|
+
|
|
2115
|
+
const { relay } = createMockWs(session.id);
|
|
2116
|
+
|
|
2117
|
+
await relay.handleMessage(JSON.stringify({
|
|
2118
|
+
type: 'setup',
|
|
2119
|
+
callSid: 'CA_access_req_canonical',
|
|
2120
|
+
from: '+15557770001',
|
|
2121
|
+
to: '+15551111111',
|
|
2122
|
+
}));
|
|
2123
|
+
|
|
2124
|
+
expect(relay.getConnectionState()).toBe('awaiting_name');
|
|
2125
|
+
|
|
2126
|
+
// Provide name
|
|
2127
|
+
await relay.handleMessage(JSON.stringify({
|
|
2128
|
+
type: 'prompt',
|
|
2129
|
+
voicePrompt: 'Sarah Connor',
|
|
2130
|
+
lang: 'en-US',
|
|
2131
|
+
last: true,
|
|
2132
|
+
}));
|
|
2133
|
+
|
|
2134
|
+
expect(relay.getConnectionState()).toBe('awaiting_guardian_decision');
|
|
2135
|
+
|
|
2136
|
+
// A canonical access request should have been created
|
|
2137
|
+
const pending = listCanonicalGuardianRequests({
|
|
2138
|
+
status: 'pending',
|
|
2139
|
+
requesterExternalUserId: '+15557770001',
|
|
2140
|
+
sourceChannel: 'voice',
|
|
2141
|
+
kind: 'access_request',
|
|
2142
|
+
});
|
|
2143
|
+
expect(pending.length).toBe(1);
|
|
2144
|
+
expect(pending[0].requesterExternalUserId).toBe('+15557770001');
|
|
2145
|
+
|
|
2146
|
+
relay.destroy();
|
|
2147
|
+
});
|
|
2148
|
+
|
|
2149
|
+
test('name capture flow: approved access request activates caller and continues call', async () => {
|
|
2150
|
+
ensureConversation('conv-access-approved');
|
|
2151
|
+
const session = createCallSession({
|
|
2152
|
+
conversationId: 'conv-access-approved',
|
|
2153
|
+
provider: 'twilio',
|
|
2154
|
+
fromNumber: '+15557770002',
|
|
2155
|
+
toNumber: '+15551111111',
|
|
2156
|
+
assistantId: 'self',
|
|
2157
|
+
});
|
|
2158
|
+
|
|
2159
|
+
mockSendMessage.mockImplementation(createMockProviderResponse(['I can help you with that.']));
|
|
2160
|
+
|
|
2161
|
+
const { relay } = createMockWs(session.id);
|
|
2162
|
+
|
|
2163
|
+
await relay.handleMessage(JSON.stringify({
|
|
2164
|
+
type: 'setup',
|
|
2165
|
+
callSid: 'CA_access_approved',
|
|
2166
|
+
from: '+15557770002',
|
|
2167
|
+
to: '+15551111111',
|
|
2168
|
+
}));
|
|
2169
|
+
|
|
2170
|
+
// Provide name to enter wait state
|
|
2171
|
+
await relay.handleMessage(JSON.stringify({
|
|
2172
|
+
type: 'prompt',
|
|
2173
|
+
voicePrompt: 'Bob Smith',
|
|
2174
|
+
lang: 'en-US',
|
|
2175
|
+
last: true,
|
|
2176
|
+
}));
|
|
2177
|
+
|
|
2178
|
+
expect(relay.getConnectionState()).toBe('awaiting_guardian_decision');
|
|
2179
|
+
|
|
2180
|
+
// Find the canonical request and simulate guardian approval
|
|
2181
|
+
const pending = listCanonicalGuardianRequests({
|
|
2182
|
+
status: 'pending',
|
|
2183
|
+
requesterExternalUserId: '+15557770002',
|
|
2184
|
+
sourceChannel: 'voice',
|
|
2185
|
+
kind: 'access_request',
|
|
2186
|
+
});
|
|
2187
|
+
expect(pending.length).toBe(1);
|
|
2188
|
+
|
|
2189
|
+
// Resolve the request to approved status
|
|
2190
|
+
resolveCanonicalGuardianRequest(pending[0].id, 'pending', {
|
|
2191
|
+
status: 'approved',
|
|
2192
|
+
answerText: undefined,
|
|
2193
|
+
decidedByExternalUserId: undefined,
|
|
2194
|
+
});
|
|
2195
|
+
|
|
2196
|
+
// Wait for the poll interval to detect the approval
|
|
2197
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
2198
|
+
|
|
2199
|
+
// Should have transitioned to connected state
|
|
2200
|
+
expect(relay.getConnectionState()).toBe('connected');
|
|
2201
|
+
|
|
2202
|
+
// Verify events
|
|
2203
|
+
const events = getCallEvents(session.id);
|
|
2204
|
+
expect(events.some((e) => e.eventType === 'inbound_acl_access_approved')).toBe(true);
|
|
2205
|
+
|
|
2206
|
+
// Session should be in_progress
|
|
2207
|
+
const updated = getCallSession(session.id);
|
|
2208
|
+
expect(updated).not.toBeNull();
|
|
2209
|
+
expect(updated!.status).toBe('in_progress');
|
|
2210
|
+
|
|
2211
|
+
relay.destroy();
|
|
2212
|
+
});
|
|
2213
|
+
|
|
2214
|
+
test('name capture flow: denied access request ends call with deterministic copy', async () => {
|
|
2215
|
+
ensureConversation('conv-access-denied');
|
|
2216
|
+
const session = createCallSession({
|
|
2217
|
+
conversationId: 'conv-access-denied',
|
|
2218
|
+
provider: 'twilio',
|
|
2219
|
+
fromNumber: '+15557770003',
|
|
2220
|
+
toNumber: '+15551111111',
|
|
2221
|
+
assistantId: 'self',
|
|
2222
|
+
});
|
|
2223
|
+
|
|
2224
|
+
const { ws, relay } = createMockWs(session.id);
|
|
2225
|
+
|
|
2226
|
+
await relay.handleMessage(JSON.stringify({
|
|
2227
|
+
type: 'setup',
|
|
2228
|
+
callSid: 'CA_access_denied',
|
|
2229
|
+
from: '+15557770003',
|
|
2230
|
+
to: '+15551111111',
|
|
2231
|
+
}));
|
|
2232
|
+
|
|
2233
|
+
// Provide name
|
|
2234
|
+
await relay.handleMessage(JSON.stringify({
|
|
2235
|
+
type: 'prompt',
|
|
2236
|
+
voicePrompt: 'Eve',
|
|
2237
|
+
lang: 'en-US',
|
|
2238
|
+
last: true,
|
|
2239
|
+
}));
|
|
2240
|
+
|
|
2241
|
+
expect(relay.getConnectionState()).toBe('awaiting_guardian_decision');
|
|
2242
|
+
|
|
2243
|
+
// Simulate guardian denial
|
|
2244
|
+
const pending = listCanonicalGuardianRequests({
|
|
2245
|
+
status: 'pending',
|
|
2246
|
+
requesterExternalUserId: '+15557770003',
|
|
2247
|
+
sourceChannel: 'voice',
|
|
2248
|
+
kind: 'access_request',
|
|
2249
|
+
});
|
|
2250
|
+
expect(pending.length).toBe(1);
|
|
2251
|
+
|
|
2252
|
+
resolveCanonicalGuardianRequest(pending[0].id, 'pending', {
|
|
2253
|
+
status: 'denied',
|
|
2254
|
+
answerText: undefined,
|
|
2255
|
+
decidedByExternalUserId: undefined,
|
|
2256
|
+
});
|
|
2257
|
+
|
|
2258
|
+
// Wait for poll to detect the denial
|
|
2259
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
2260
|
+
|
|
2261
|
+
// Should be disconnecting
|
|
2262
|
+
expect(relay.getConnectionState()).toBe('disconnecting');
|
|
2263
|
+
|
|
2264
|
+
// Should have sent the denial message
|
|
2265
|
+
const textMessages = ws.sentMessages
|
|
2266
|
+
.map((raw) => JSON.parse(raw) as { type: string; token?: string })
|
|
2267
|
+
.filter((m) => m.type === 'text');
|
|
2268
|
+
expect(textMessages.some((m) => (m.token ?? '').includes("my guardian says I'm not allowed"))).toBe(true);
|
|
2269
|
+
|
|
2270
|
+
// Session should be failed
|
|
2271
|
+
const updated = getCallSession(session.id);
|
|
2272
|
+
expect(updated).not.toBeNull();
|
|
2273
|
+
expect(updated!.status).toBe('failed');
|
|
2274
|
+
|
|
2275
|
+
// Verify event
|
|
2276
|
+
const events = getCallEvents(session.id);
|
|
2277
|
+
expect(events.some((e) => e.eventType === 'inbound_acl_access_denied')).toBe(true);
|
|
2278
|
+
|
|
2279
|
+
// Let the delayed endSession callback flush
|
|
2280
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
2281
|
+
|
|
2282
|
+
relay.destroy();
|
|
2283
|
+
});
|
|
2284
|
+
|
|
2285
|
+
test('name capture flow: timeout ends call with deterministic copy', async () => {
|
|
2286
|
+
// Override the consultation timeout to a very short value for testing
|
|
2287
|
+
mockConfig.calls.userConsultTimeoutSeconds = 2; // 2 seconds
|
|
2288
|
+
|
|
2289
|
+
ensureConversation('conv-access-timeout');
|
|
2290
|
+
const session = createCallSession({
|
|
2291
|
+
conversationId: 'conv-access-timeout',
|
|
2292
|
+
provider: 'twilio',
|
|
2293
|
+
fromNumber: '+15557770004',
|
|
2294
|
+
toNumber: '+15551111111',
|
|
2295
|
+
assistantId: 'self',
|
|
2296
|
+
});
|
|
2297
|
+
|
|
2298
|
+
const { ws, relay } = createMockWs(session.id);
|
|
2299
|
+
|
|
2300
|
+
await relay.handleMessage(JSON.stringify({
|
|
2301
|
+
type: 'setup',
|
|
2302
|
+
callSid: 'CA_access_timeout',
|
|
2303
|
+
from: '+15557770004',
|
|
2304
|
+
to: '+15551111111',
|
|
2305
|
+
}));
|
|
2306
|
+
|
|
2307
|
+
// Provide name
|
|
2308
|
+
await relay.handleMessage(JSON.stringify({
|
|
2309
|
+
type: 'prompt',
|
|
2310
|
+
voicePrompt: 'Timeout Tester',
|
|
2311
|
+
lang: 'en-US',
|
|
2312
|
+
last: true,
|
|
2313
|
+
}));
|
|
2314
|
+
|
|
2315
|
+
expect(relay.getConnectionState()).toBe('awaiting_guardian_decision');
|
|
2316
|
+
|
|
2317
|
+
// Wait for timeout (2 seconds + buffer)
|
|
2318
|
+
await new Promise((resolve) => setTimeout(resolve, 2500));
|
|
2319
|
+
|
|
2320
|
+
// Should be disconnecting after timeout
|
|
2321
|
+
expect(relay.getConnectionState()).toBe('disconnecting');
|
|
2322
|
+
|
|
2323
|
+
// Should have sent the timeout message
|
|
2324
|
+
const textMessages = ws.sentMessages
|
|
2325
|
+
.map((raw) => JSON.parse(raw) as { type: string; token?: string })
|
|
2326
|
+
.filter((m) => m.type === 'text');
|
|
2327
|
+
expect(textMessages.some((m) => (m.token ?? '').includes("can't get ahold of my guardian"))).toBe(true);
|
|
2328
|
+
expect(textMessages.some((m) => (m.token ?? '').includes("let them know you called"))).toBe(true);
|
|
2329
|
+
|
|
2330
|
+
// Session should be failed
|
|
2331
|
+
const updated = getCallSession(session.id);
|
|
2332
|
+
expect(updated).not.toBeNull();
|
|
2333
|
+
expect(updated!.status).toBe('failed');
|
|
2334
|
+
|
|
2335
|
+
// Verify event
|
|
2336
|
+
const events = getCallEvents(session.id);
|
|
2337
|
+
expect(events.some((e) => e.eventType === 'inbound_acl_access_timeout')).toBe(true);
|
|
2338
|
+
|
|
2339
|
+
// Let the delayed endSession callback flush
|
|
2340
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
2341
|
+
|
|
2342
|
+
// Restore default timeout
|
|
2343
|
+
mockConfig.calls.userConsultTimeoutSeconds = 120;
|
|
2344
|
+
|
|
2345
|
+
relay.destroy();
|
|
2346
|
+
});
|
|
2347
|
+
|
|
2348
|
+
test('name capture flow: transport close during guardian wait cleans up timers', async () => {
|
|
2349
|
+
ensureConversation('conv-access-transport-close');
|
|
2350
|
+
const session = createCallSession({
|
|
2351
|
+
conversationId: 'conv-access-transport-close',
|
|
2352
|
+
provider: 'twilio',
|
|
2353
|
+
fromNumber: '+15557770005',
|
|
2354
|
+
toNumber: '+15551111111',
|
|
2355
|
+
assistantId: 'self',
|
|
2356
|
+
});
|
|
2357
|
+
|
|
2358
|
+
const { relay } = createMockWs(session.id);
|
|
2359
|
+
|
|
2360
|
+
await relay.handleMessage(JSON.stringify({
|
|
2361
|
+
type: 'setup',
|
|
2362
|
+
callSid: 'CA_access_transport_close',
|
|
2363
|
+
from: '+15557770005',
|
|
2364
|
+
to: '+15551111111',
|
|
2365
|
+
}));
|
|
2366
|
+
|
|
2367
|
+
// Provide name
|
|
2368
|
+
await relay.handleMessage(JSON.stringify({
|
|
2369
|
+
type: 'prompt',
|
|
2370
|
+
voicePrompt: 'Disconnector',
|
|
2371
|
+
lang: 'en-US',
|
|
2372
|
+
last: true,
|
|
2373
|
+
}));
|
|
2374
|
+
|
|
2375
|
+
expect(relay.getConnectionState()).toBe('awaiting_guardian_decision');
|
|
2376
|
+
|
|
2377
|
+
// Simulate transport close while waiting for guardian
|
|
2378
|
+
relay.handleTransportClosed(1000, 'caller hung up');
|
|
2379
|
+
|
|
2380
|
+
// Session should be completed (normal close)
|
|
2381
|
+
const updated = getCallSession(session.id);
|
|
2382
|
+
expect(updated).not.toBeNull();
|
|
2383
|
+
expect(updated!.status).toBe('completed');
|
|
1744
2384
|
|
|
1745
2385
|
relay.destroy();
|
|
1746
2386
|
});
|