@vellumai/assistant 0.4.1 → 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.
Files changed (97) hide show
  1. package/ARCHITECTURE.md +84 -7
  2. package/bun.lock +0 -83
  3. package/docs/trusted-contact-access.md +20 -0
  4. package/package.json +2 -3
  5. package/src/__tests__/access-request-decision.test.ts +0 -1
  6. package/src/__tests__/assistant-id-boundary-guard.test.ts +290 -0
  7. package/src/__tests__/call-routes-http.test.ts +0 -25
  8. package/src/__tests__/channel-approval-routes.test.ts +55 -5
  9. package/src/__tests__/channel-guardian.test.ts +6 -5
  10. package/src/__tests__/config-schema.test.ts +2 -0
  11. package/src/__tests__/daemon-server-session-init.test.ts +54 -1
  12. package/src/__tests__/deterministic-verification-control-plane.test.ts +0 -1
  13. package/src/__tests__/guardian-actions-endpoint.test.ts +21 -0
  14. package/src/__tests__/guardian-decision-primitive-canonical.test.ts +4 -2
  15. package/src/__tests__/guardian-outbound-http.test.ts +0 -1
  16. package/src/__tests__/guardian-routing-invariants.test.ts +50 -9
  17. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +161 -2
  18. package/src/__tests__/inbound-invite-redemption.test.ts +0 -1
  19. package/src/__tests__/ingress-routes-http.test.ts +55 -0
  20. package/src/__tests__/non-member-access-request.test.ts +28 -1
  21. package/src/__tests__/notification-decision-strategy.test.ts +44 -0
  22. package/src/__tests__/relay-server.test.ts +644 -4
  23. package/src/__tests__/send-endpoint-busy.test.ts +129 -3
  24. package/src/__tests__/session-init.benchmark.test.ts +0 -1
  25. package/src/__tests__/session-runtime-assembly.test.ts +4 -1
  26. package/src/__tests__/session-surfaces-task-progress.test.ts +43 -0
  27. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +11 -1
  28. package/src/__tests__/trusted-contact-multichannel.test.ts +0 -1
  29. package/src/__tests__/trusted-contact-verification.test.ts +0 -1
  30. package/src/__tests__/twilio-routes.test.ts +4 -3
  31. package/src/__tests__/update-bulletin.test.ts +0 -1
  32. package/src/approvals/guardian-decision-primitive.ts +24 -2
  33. package/src/approvals/guardian-request-resolvers.ts +42 -3
  34. package/src/calls/call-constants.ts +8 -0
  35. package/src/calls/call-controller.ts +2 -1
  36. package/src/calls/call-domain.ts +5 -4
  37. package/src/calls/relay-server.ts +513 -116
  38. package/src/calls/twilio-routes.ts +3 -5
  39. package/src/calls/types.ts +1 -1
  40. package/src/calls/voice-session-bridge.ts +4 -3
  41. package/src/cli/core-commands.ts +7 -4
  42. package/src/config/bundled-skills/app-builder/SKILL.md +164 -1
  43. package/src/config/bundled-skills/vercel-token-setup/SKILL.md +214 -0
  44. package/src/config/calls-schema.ts +12 -0
  45. package/src/config/feature-flag-registry.json +0 -8
  46. package/src/config/vellum-skills/trusted-contacts/SKILL.md +8 -2
  47. package/src/daemon/handlers/config-channels.ts +5 -7
  48. package/src/daemon/handlers/config-inbox.ts +2 -0
  49. package/src/daemon/handlers/index.ts +2 -1
  50. package/src/daemon/handlers/publish.ts +11 -46
  51. package/src/daemon/handlers/sessions.ts +136 -13
  52. package/src/daemon/ipc-contract/apps.ts +1 -0
  53. package/src/daemon/ipc-contract/inbox.ts +4 -0
  54. package/src/daemon/ipc-contract/integrations.ts +3 -1
  55. package/src/daemon/server.ts +19 -3
  56. package/src/daemon/session-agent-loop.ts +35 -23
  57. package/src/daemon/session-runtime-assembly.ts +3 -1
  58. package/src/daemon/session-surfaces.ts +29 -1
  59. package/src/memory/app-store.ts +6 -0
  60. package/src/memory/conversation-crud.ts +2 -1
  61. package/src/memory/conversation-title-service.ts +16 -2
  62. package/src/memory/db-init.ts +4 -0
  63. package/src/memory/delivery-crud.ts +2 -1
  64. package/src/memory/embedding-local.ts +25 -13
  65. package/src/memory/embedding-runtime-manager.ts +24 -6
  66. package/src/memory/guardian-action-store.ts +2 -1
  67. package/src/memory/guardian-approvals.ts +3 -2
  68. package/src/memory/ingress-invite-store.ts +12 -2
  69. package/src/memory/ingress-member-store.ts +4 -3
  70. package/src/memory/migrations/124-voice-invite-display-metadata.ts +14 -0
  71. package/src/memory/migrations/index.ts +1 -0
  72. package/src/memory/schema.ts +10 -5
  73. package/src/notifications/copy-composer.ts +11 -1
  74. package/src/notifications/emit-signal.ts +2 -1
  75. package/src/runtime/access-request-helper.ts +11 -3
  76. package/src/runtime/actor-trust-resolver.ts +2 -2
  77. package/src/runtime/assistant-scope.ts +10 -0
  78. package/src/runtime/guardian-context-resolver.ts +5 -1
  79. package/src/runtime/guardian-outbound-actions.ts +5 -4
  80. package/src/runtime/guardian-reply-router.ts +12 -0
  81. package/src/runtime/http-server.ts +12 -20
  82. package/src/runtime/ingress-service.ts +14 -0
  83. package/src/runtime/invite-redemption-service.ts +2 -1
  84. package/src/runtime/middleware/twilio-validation.ts +2 -4
  85. package/src/runtime/routes/call-routes.ts +2 -1
  86. package/src/runtime/routes/channel-route-shared.ts +3 -3
  87. package/src/runtime/routes/conversation-attention-routes.ts +2 -1
  88. package/src/runtime/routes/conversation-routes.ts +33 -11
  89. package/src/runtime/routes/events-routes.ts +2 -3
  90. package/src/runtime/routes/inbound-conversation.ts +4 -3
  91. package/src/runtime/routes/inbound-message-handler.ts +16 -4
  92. package/src/runtime/routes/ingress-routes.ts +2 -0
  93. package/src/tools/apps/executors.ts +15 -0
  94. package/src/tools/calls/call-start.ts +2 -1
  95. package/src/tools/terminal/parser.ts +12 -0
  96. package/src/tools/tool-approval-handler.ts +2 -1
  97. 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, 2100));
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, 2100));
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, 3100));
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, 2100));
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
  });