@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.
Files changed (221) hide show
  1. package/.env.example +3 -0
  2. package/ARCHITECTURE.md +124 -10
  3. package/README.md +43 -35
  4. package/docs/trusted-contact-access.md +20 -0
  5. package/package.json +1 -1
  6. package/scripts/ipc/generate-swift.ts +1 -0
  7. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +58 -120
  8. package/src/__tests__/access-request-decision.test.ts +0 -1
  9. package/src/__tests__/actor-token-service.test.ts +1099 -0
  10. package/src/__tests__/agent-loop.test.ts +51 -0
  11. package/src/__tests__/approval-routes-http.test.ts +2 -0
  12. package/src/__tests__/assistant-events-sse-hardening.test.ts +7 -5
  13. package/src/__tests__/assistant-id-boundary-guard.test.ts +415 -0
  14. package/src/__tests__/call-controller.test.ts +49 -0
  15. package/src/__tests__/call-pointer-message-composer.test.ts +171 -0
  16. package/src/__tests__/call-pointer-messages.test.ts +93 -3
  17. package/src/__tests__/call-pointer-no-hardcoded-copy.guard.test.ts +42 -0
  18. package/src/__tests__/call-routes-http.test.ts +0 -25
  19. package/src/__tests__/callback-handoff-copy.test.ts +186 -0
  20. package/src/__tests__/channel-approval-routes.test.ts +133 -12
  21. package/src/__tests__/channel-guardian.test.ts +0 -86
  22. package/src/__tests__/channel-readiness-service.test.ts +10 -16
  23. package/src/__tests__/checker.test.ts +33 -12
  24. package/src/__tests__/config-schema.test.ts +6 -0
  25. package/src/__tests__/confirmation-request-guardian-bridge.test.ts +410 -0
  26. package/src/__tests__/conversation-routes-guardian-reply.test.ts +256 -0
  27. package/src/__tests__/conversation-routes.test.ts +12 -3
  28. package/src/__tests__/credential-security-invariants.test.ts +1 -1
  29. package/src/__tests__/daemon-server-session-init.test.ts +4 -0
  30. package/src/__tests__/deterministic-verification-control-plane.test.ts +0 -1
  31. package/src/__tests__/guardian-actions-endpoint.test.ts +39 -13
  32. package/src/__tests__/guardian-dispatch.test.ts +8 -0
  33. package/src/__tests__/guardian-outbound-http.test.ts +4 -5
  34. package/src/__tests__/guardian-question-mode.test.ts +200 -0
  35. package/src/__tests__/guardian-routing-invariants.test.ts +178 -0
  36. package/src/__tests__/guardian-routing-state.test.ts +525 -0
  37. package/src/__tests__/handle-user-message-secret-resume.test.ts +2 -0
  38. package/src/__tests__/handlers-telegram-config.test.ts +0 -83
  39. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +55 -0
  40. package/src/__tests__/headless-browser-navigate.test.ts +2 -0
  41. package/src/__tests__/inbound-invite-redemption.test.ts +0 -1
  42. package/src/__tests__/ingress-routes-http.test.ts +55 -0
  43. package/src/__tests__/ipc-snapshot.test.ts +18 -51
  44. package/src/__tests__/non-member-access-request.test.ts +159 -9
  45. package/src/__tests__/notification-decision-fallback.test.ts +129 -4
  46. package/src/__tests__/notification-decision-strategy.test.ts +106 -2
  47. package/src/__tests__/notification-guardian-path.test.ts +3 -0
  48. package/src/__tests__/recording-intent-handler.test.ts +1 -0
  49. package/src/__tests__/relay-server.test.ts +1475 -33
  50. package/src/__tests__/send-endpoint-busy.test.ts +5 -0
  51. package/src/__tests__/session-agent-loop.test.ts +1 -0
  52. package/src/__tests__/session-confirmation-signals.test.ts +523 -0
  53. package/src/__tests__/session-init.benchmark.test.ts +0 -2
  54. package/src/__tests__/session-runtime-assembly.test.ts +4 -1
  55. package/src/__tests__/session-surfaces-task-progress.test.ts +44 -1
  56. package/src/__tests__/session-tool-setup-app-refresh.test.ts +81 -2
  57. package/src/__tests__/session-tool-setup-memory-scope.test.ts +1 -1
  58. package/src/__tests__/session-tool-setup-side-effect-flag.test.ts +1 -1
  59. package/src/__tests__/tool-executor.test.ts +21 -2
  60. package/src/__tests__/tool-grant-request-escalation.test.ts +333 -27
  61. package/src/__tests__/trusted-contact-approval-notifier.test.ts +678 -0
  62. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +1064 -0
  63. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +11 -1
  64. package/src/__tests__/trusted-contact-multichannel.test.ts +0 -1
  65. package/src/__tests__/trusted-contact-verification.test.ts +0 -1
  66. package/src/__tests__/twilio-config.test.ts +2 -13
  67. package/src/__tests__/twilio-routes.test.ts +4 -3
  68. package/src/__tests__/update-bulletin.test.ts +0 -1
  69. package/src/agent/loop.ts +1 -1
  70. package/src/approvals/guardian-decision-primitive.ts +12 -3
  71. package/src/approvals/guardian-request-resolvers.ts +169 -11
  72. package/src/calls/call-constants.ts +29 -0
  73. package/src/calls/call-controller.ts +11 -3
  74. package/src/calls/call-domain.ts +33 -11
  75. package/src/calls/call-pointer-message-composer.ts +154 -0
  76. package/src/calls/call-pointer-messages.ts +106 -27
  77. package/src/calls/guardian-dispatch.ts +4 -2
  78. package/src/calls/relay-server.ts +921 -112
  79. package/src/calls/twilio-config.ts +4 -11
  80. package/src/calls/twilio-routes.ts +4 -6
  81. package/src/calls/types.ts +3 -1
  82. package/src/calls/voice-session-bridge.ts +4 -3
  83. package/src/cli/core-commands.ts +7 -4
  84. package/src/cli.ts +5 -4
  85. package/src/config/bundled-skills/agentmail/SKILL.md +4 -0
  86. package/src/config/bundled-skills/app-builder/SKILL.md +309 -10
  87. package/src/config/bundled-skills/app-builder/TOOLS.json +1 -1
  88. package/src/config/bundled-skills/email-setup/SKILL.md +1 -1
  89. package/src/config/bundled-skills/google-oauth-setup/SKILL.md +105 -81
  90. package/src/config/bundled-skills/messaging/SKILL.md +61 -12
  91. package/src/config/bundled-skills/messaging/TOOLS.json +58 -0
  92. package/src/config/bundled-skills/messaging/tools/gmail-sender-digest.ts +6 -1
  93. package/src/config/bundled-skills/messaging/tools/messaging-archive-by-sender.ts +35 -0
  94. package/src/config/bundled-skills/messaging/tools/messaging-sender-digest.ts +52 -0
  95. package/src/config/bundled-skills/phone-calls/SKILL.md +30 -39
  96. package/src/config/bundled-skills/twitter/SKILL.md +3 -3
  97. package/src/config/bundled-skills/vercel-token-setup/SKILL.md +215 -0
  98. package/src/config/calls-schema.ts +36 -0
  99. package/src/config/env.ts +22 -0
  100. package/src/config/feature-flag-registry.json +8 -8
  101. package/src/config/schema.ts +2 -2
  102. package/src/config/skills.ts +11 -0
  103. package/src/config/system-prompt.ts +11 -1
  104. package/src/config/templates/SOUL.md +2 -0
  105. package/src/config/vellum-skills/sms-setup/SKILL.md +71 -82
  106. package/src/config/vellum-skills/trusted-contacts/SKILL.md +8 -1
  107. package/src/config/vellum-skills/twilio-setup/SKILL.md +88 -73
  108. package/src/daemon/call-pointer-generators.ts +59 -0
  109. package/src/daemon/computer-use-session.ts +2 -5
  110. package/src/daemon/handlers/apps.ts +76 -20
  111. package/src/daemon/handlers/config-channels.ts +9 -61
  112. package/src/daemon/handlers/config-inbox.ts +11 -3
  113. package/src/daemon/handlers/config-ingress.ts +28 -3
  114. package/src/daemon/handlers/config-telegram.ts +12 -0
  115. package/src/daemon/handlers/config.ts +2 -6
  116. package/src/daemon/handlers/index.ts +2 -1
  117. package/src/daemon/handlers/pairing.ts +2 -0
  118. package/src/daemon/handlers/publish.ts +11 -46
  119. package/src/daemon/handlers/sessions.ts +59 -5
  120. package/src/daemon/handlers/shared.ts +17 -2
  121. package/src/daemon/ipc-contract/apps.ts +1 -0
  122. package/src/daemon/ipc-contract/inbox.ts +4 -0
  123. package/src/daemon/ipc-contract/integrations.ts +1 -97
  124. package/src/daemon/ipc-contract/messages.ts +47 -1
  125. package/src/daemon/ipc-contract/notifications.ts +11 -0
  126. package/src/daemon/ipc-contract-inventory.json +2 -4
  127. package/src/daemon/lifecycle.ts +17 -0
  128. package/src/daemon/server.ts +16 -2
  129. package/src/daemon/session-agent-loop-handlers.ts +20 -0
  130. package/src/daemon/session-agent-loop.ts +24 -12
  131. package/src/daemon/session-lifecycle.ts +1 -1
  132. package/src/daemon/session-process.ts +11 -1
  133. package/src/daemon/session-runtime-assembly.ts +6 -1
  134. package/src/daemon/session-surfaces.ts +32 -3
  135. package/src/daemon/session.ts +88 -1
  136. package/src/daemon/tool-side-effects.ts +22 -0
  137. package/src/home-base/prebuilt/brain-graph.html +1483 -0
  138. package/src/home-base/prebuilt/index.html +40 -0
  139. package/src/inbound/platform-callback-registration.ts +157 -0
  140. package/src/memory/canonical-guardian-store.ts +1 -1
  141. package/src/memory/conversation-crud.ts +2 -1
  142. package/src/memory/conversation-title-service.ts +16 -2
  143. package/src/memory/db-init.ts +8 -0
  144. package/src/memory/delivery-crud.ts +2 -1
  145. package/src/memory/guardian-action-store.ts +2 -1
  146. package/src/memory/guardian-approvals.ts +3 -2
  147. package/src/memory/ingress-invite-store.ts +12 -2
  148. package/src/memory/ingress-member-store.ts +4 -3
  149. package/src/memory/migrations/038-actor-token-records.ts +39 -0
  150. package/src/memory/migrations/124-voice-invite-display-metadata.ts +14 -0
  151. package/src/memory/migrations/index.ts +2 -0
  152. package/src/memory/schema.ts +26 -5
  153. package/src/messaging/provider-types.ts +24 -0
  154. package/src/messaging/provider.ts +7 -0
  155. package/src/messaging/providers/gmail/adapter.ts +127 -0
  156. package/src/messaging/providers/sms/adapter.ts +40 -37
  157. package/src/notifications/adapters/macos.ts +45 -2
  158. package/src/notifications/broadcaster.ts +16 -0
  159. package/src/notifications/copy-composer.ts +50 -2
  160. package/src/notifications/decision-engine.ts +22 -9
  161. package/src/notifications/destination-resolver.ts +16 -2
  162. package/src/notifications/emit-signal.ts +18 -9
  163. package/src/notifications/guardian-question-mode.ts +419 -0
  164. package/src/notifications/signal.ts +14 -3
  165. package/src/permissions/checker.ts +13 -1
  166. package/src/permissions/prompter.ts +14 -0
  167. package/src/providers/anthropic/client.ts +20 -0
  168. package/src/providers/provider-send-message.ts +15 -3
  169. package/src/runtime/access-request-helper.ts +82 -4
  170. package/src/runtime/actor-token-service.ts +234 -0
  171. package/src/runtime/actor-token-store.ts +236 -0
  172. package/src/runtime/actor-trust-resolver.ts +2 -2
  173. package/src/runtime/assistant-scope.ts +10 -0
  174. package/src/runtime/channel-approvals.ts +5 -3
  175. package/src/runtime/channel-readiness-service.ts +23 -64
  176. package/src/runtime/channel-readiness-types.ts +3 -4
  177. package/src/runtime/channel-retry-sweep.ts +4 -1
  178. package/src/runtime/confirmation-request-guardian-bridge.ts +197 -0
  179. package/src/runtime/guardian-action-followup-executor.ts +1 -1
  180. package/src/runtime/guardian-context-resolver.ts +82 -0
  181. package/src/runtime/guardian-outbound-actions.ts +5 -7
  182. package/src/runtime/guardian-reply-router.ts +67 -30
  183. package/src/runtime/guardian-vellum-migration.ts +57 -0
  184. package/src/runtime/http-server.ts +75 -31
  185. package/src/runtime/http-types.ts +13 -0
  186. package/src/runtime/ingress-service.ts +14 -0
  187. package/src/runtime/invite-redemption-service.ts +10 -1
  188. package/src/runtime/local-actor-identity.ts +76 -0
  189. package/src/runtime/middleware/actor-token.ts +271 -0
  190. package/src/runtime/middleware/twilio-validation.ts +2 -4
  191. package/src/runtime/routes/approval-routes.ts +82 -7
  192. package/src/runtime/routes/brain-graph-routes.ts +222 -0
  193. package/src/runtime/routes/call-routes.ts +2 -1
  194. package/src/runtime/routes/channel-readiness-routes.ts +71 -0
  195. package/src/runtime/routes/channel-route-shared.ts +3 -3
  196. package/src/runtime/routes/conversation-attention-routes.ts +2 -1
  197. package/src/runtime/routes/conversation-routes.ts +142 -53
  198. package/src/runtime/routes/events-routes.ts +22 -8
  199. package/src/runtime/routes/guardian-action-routes.ts +45 -3
  200. package/src/runtime/routes/guardian-approval-interception.ts +29 -0
  201. package/src/runtime/routes/guardian-bootstrap-routes.ts +145 -0
  202. package/src/runtime/routes/inbound-conversation.ts +4 -3
  203. package/src/runtime/routes/inbound-message-handler.ts +147 -5
  204. package/src/runtime/routes/ingress-routes.ts +2 -0
  205. package/src/runtime/routes/integration-routes.ts +7 -15
  206. package/src/runtime/routes/pairing-routes.ts +163 -0
  207. package/src/runtime/routes/twilio-routes.ts +934 -0
  208. package/src/runtime/tool-grant-request-helper.ts +3 -1
  209. package/src/security/oauth2.ts +27 -2
  210. package/src/security/token-manager.ts +46 -10
  211. package/src/tools/browser/browser-execution.ts +4 -3
  212. package/src/tools/browser/browser-handoff.ts +10 -18
  213. package/src/tools/browser/browser-manager.ts +80 -25
  214. package/src/tools/browser/browser-screencast.ts +35 -119
  215. package/src/tools/calls/call-start.ts +2 -1
  216. package/src/tools/permission-checker.ts +15 -4
  217. package/src/tools/terminal/parser.ts +12 -0
  218. package/src/tools/tool-approval-handler.ts +244 -19
  219. package/src/workspace/git-service.ts +19 -0
  220. package/src/__tests__/handlers-twilio-config.test.ts +0 -1928
  221. package/src/daemon/handlers/config-twilio.ts +0 -1082
@@ -0,0 +1,171 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { mock } from 'bun:test';
3
+
4
+ mock.module('../util/logger.js', () => ({
5
+ getLogger: () =>
6
+ new Proxy({} as Record<string, unknown>, {
7
+ get: () => () => {},
8
+ }),
9
+ }));
10
+
11
+ import {
12
+ buildPointerGenerationPrompt,
13
+ type CallPointerMessageContext,
14
+ composeCallPointerMessageGenerative,
15
+ getPointerFallbackMessage,
16
+ includesRequiredFacts,
17
+ } from '../calls/call-pointer-message-composer.js';
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Deterministic fallback templates
21
+ // ---------------------------------------------------------------------------
22
+
23
+ describe('getPointerFallbackMessage', () => {
24
+ test('started without verification code', () => {
25
+ const msg = getPointerFallbackMessage({ scenario: 'started', phoneNumber: '+15551234567' });
26
+ expect(msg).toContain('Call to +15551234567 started');
27
+ expect(msg).not.toContain('Verification code');
28
+ });
29
+
30
+ test('started with verification code', () => {
31
+ const msg = getPointerFallbackMessage({
32
+ scenario: 'started',
33
+ phoneNumber: '+15551234567',
34
+ verificationCode: '1234',
35
+ });
36
+ expect(msg).toContain('Verification code: 1234');
37
+ expect(msg).toContain('+15551234567');
38
+ });
39
+
40
+ test('completed without duration', () => {
41
+ const msg = getPointerFallbackMessage({ scenario: 'completed', phoneNumber: '+15559876543' });
42
+ expect(msg).toContain('completed');
43
+ expect(msg).toContain('+15559876543');
44
+ });
45
+
46
+ test('completed with duration', () => {
47
+ const msg = getPointerFallbackMessage({
48
+ scenario: 'completed',
49
+ phoneNumber: '+15559876543',
50
+ duration: '5m 30s',
51
+ });
52
+ expect(msg).toContain('completed (5m 30s)');
53
+ });
54
+
55
+ test('failed without reason', () => {
56
+ const msg = getPointerFallbackMessage({ scenario: 'failed', phoneNumber: '+15559876543' });
57
+ expect(msg).toContain('failed');
58
+ expect(msg).toContain('+15559876543');
59
+ });
60
+
61
+ test('failed with reason', () => {
62
+ const msg = getPointerFallbackMessage({
63
+ scenario: 'failed',
64
+ phoneNumber: '+15559876543',
65
+ reason: 'no answer',
66
+ });
67
+ expect(msg).toContain('failed: no answer');
68
+ });
69
+
70
+ test('guardian_verification_succeeded defaults to voice channel', () => {
71
+ const msg = getPointerFallbackMessage({
72
+ scenario: 'guardian_verification_succeeded',
73
+ phoneNumber: '+15559876543',
74
+ });
75
+ expect(msg).toContain('Guardian verification (voice)');
76
+ expect(msg).toContain('succeeded');
77
+ });
78
+
79
+ test('guardian_verification_succeeded with custom channel', () => {
80
+ const msg = getPointerFallbackMessage({
81
+ scenario: 'guardian_verification_succeeded',
82
+ phoneNumber: '+15559876543',
83
+ channel: 'sms',
84
+ });
85
+ expect(msg).toContain('Guardian verification (sms)');
86
+ });
87
+
88
+ test('guardian_verification_failed without reason', () => {
89
+ const msg = getPointerFallbackMessage({
90
+ scenario: 'guardian_verification_failed',
91
+ phoneNumber: '+15559876543',
92
+ });
93
+ expect(msg).toContain('Guardian verification');
94
+ expect(msg).toContain('failed');
95
+ });
96
+
97
+ test('guardian_verification_failed with reason', () => {
98
+ const msg = getPointerFallbackMessage({
99
+ scenario: 'guardian_verification_failed',
100
+ phoneNumber: '+15559876543',
101
+ reason: 'Max attempts exceeded',
102
+ });
103
+ expect(msg).toContain('failed: Max attempts exceeded');
104
+ });
105
+ });
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // Required facts validation
109
+ // ---------------------------------------------------------------------------
110
+
111
+ describe('includesRequiredFacts', () => {
112
+ test('returns true when no required facts', () => {
113
+ expect(includesRequiredFacts('any text', undefined)).toBe(true);
114
+ expect(includesRequiredFacts('any text', [])).toBe(true);
115
+ });
116
+
117
+ test('returns true when all facts present', () => {
118
+ expect(includesRequiredFacts('Call to +15551234567 completed (2m).', ['+15551234567', '2m'])).toBe(true);
119
+ });
120
+
121
+ test('returns false when a fact is missing', () => {
122
+ expect(includesRequiredFacts('Call completed.', ['+15551234567'])).toBe(false);
123
+ });
124
+ });
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // Prompt builder
128
+ // ---------------------------------------------------------------------------
129
+
130
+ describe('buildPointerGenerationPrompt', () => {
131
+ test('includes context JSON and fallback message', () => {
132
+ const ctx: CallPointerMessageContext = { scenario: 'started', phoneNumber: '+15551234567' };
133
+ const prompt = buildPointerGenerationPrompt(ctx, 'Fallback text', undefined);
134
+ expect(prompt).toContain(JSON.stringify(ctx));
135
+ expect(prompt).toContain('Fallback text');
136
+ });
137
+
138
+ test('includes required facts clause when provided', () => {
139
+ const ctx: CallPointerMessageContext = { scenario: 'completed', phoneNumber: '+15559876543', duration: '3m' };
140
+ const prompt = buildPointerGenerationPrompt(ctx, 'Fallback', ['+15559876543', '3m']);
141
+ expect(prompt).toContain('Required facts to include');
142
+ expect(prompt).toContain('+15559876543');
143
+ expect(prompt).toContain('3m');
144
+ });
145
+ });
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // Generative composition (test env falls back to deterministic)
149
+ // ---------------------------------------------------------------------------
150
+
151
+ describe('composeCallPointerMessageGenerative', () => {
152
+ test('returns fallback in test environment regardless of generator', async () => {
153
+ const generator = async () => 'LLM-generated copy';
154
+ const ctx: CallPointerMessageContext = { scenario: 'started', phoneNumber: '+15551234567' };
155
+ const result = await composeCallPointerMessageGenerative(ctx, {}, generator);
156
+ // NODE_ENV is 'test' during bun test
157
+ expect(result).toContain('Call to +15551234567 started');
158
+ });
159
+
160
+ test('returns fallback when no generator provided', async () => {
161
+ const ctx: CallPointerMessageContext = { scenario: 'failed', phoneNumber: '+15559876543', reason: 'busy' };
162
+ const result = await composeCallPointerMessageGenerative(ctx);
163
+ expect(result).toContain('failed: busy');
164
+ });
165
+
166
+ test('uses custom fallbackText when provided', async () => {
167
+ const ctx: CallPointerMessageContext = { scenario: 'completed', phoneNumber: '+15559876543' };
168
+ const result = await composeCallPointerMessageGenerative(ctx, { fallbackText: 'Custom fallback' });
169
+ expect(result).toBe('Custom fallback');
170
+ });
171
+ });
@@ -2,7 +2,7 @@ import { mkdtempSync, rmSync } from 'node:fs';
2
2
  import { tmpdir } from 'node:os';
3
3
  import { join } from 'node:path';
4
4
 
5
- import { afterAll, beforeEach, describe, expect, mock,test } from 'bun:test';
5
+ import { afterAll, afterEach, beforeEach, describe, expect, mock,test } from 'bun:test';
6
6
 
7
7
  const testDir = mkdtempSync(join(tmpdir(), 'call-pointer-messages-test-'));
8
8
 
@@ -25,14 +25,14 @@ mock.module('../util/logger.js', () => ({
25
25
  }),
26
26
  }));
27
27
 
28
- import { addPointerMessage, formatDuration } from '../calls/call-pointer-messages.js';
28
+ import { addPointerMessage, formatDuration, resetPointerCopyGenerator, setPointerCopyGenerator } from '../calls/call-pointer-messages.js';
29
29
  import { getMessages } from '../memory/conversation-store.js';
30
30
  import { getDb, initializeDb, resetDb } from '../memory/db.js';
31
31
  import { conversations } from '../memory/schema.js';
32
32
 
33
33
  initializeDb();
34
34
 
35
- function ensureConversation(id: string): void {
35
+ function ensureConversation(id: string, options?: { threadType?: string; originChannel?: string }): void {
36
36
  const db = getDb();
37
37
  const now = Date.now();
38
38
  db.insert(conversations).values({
@@ -40,6 +40,8 @@ function ensureConversation(id: string): void {
40
40
  title: `Conversation ${id}`,
41
41
  createdAt: now,
42
42
  updatedAt: now,
43
+ ...(options?.threadType ? { threadType: options.threadType } : {}),
44
+ ...(options?.originChannel ? { originChannel: options.originChannel } : {}),
43
45
  }).run();
44
46
  }
45
47
 
@@ -92,6 +94,10 @@ describe('addPointerMessage', () => {
92
94
  resetTables();
93
95
  });
94
96
 
97
+ afterEach(() => {
98
+ resetPointerCopyGenerator();
99
+ });
100
+
95
101
  afterAll(() => {
96
102
  resetDb();
97
103
  try { rmSync(testDir, { recursive: true }); } catch { /* best effort */ }
@@ -185,4 +191,88 @@ describe('addPointerMessage', () => {
185
191
  const text = getLatestAssistantText(convId);
186
192
  expect(text).toContain('failed: Max attempts exceeded');
187
193
  });
194
+
195
+ // Trust-aware tests: in test env, generator is not called (NODE_ENV=test
196
+ // short-circuits to fallback), so these validate the trust gating path
197
+ // while still receiving deterministic text.
198
+
199
+ test('untrusted audience uses deterministic fallback even with generator set', () => {
200
+ const convId = 'conv-ptr-untrusted';
201
+ // standard threadType + no origin channel = untrusted
202
+ ensureConversation(convId, { threadType: 'standard' });
203
+
204
+ const generatorCalled = { value: false };
205
+ setPointerCopyGenerator(async () => {
206
+ generatorCalled.value = true;
207
+ return 'generated text';
208
+ });
209
+
210
+ addPointerMessage(convId, 'started', '+15551234567');
211
+ const text = getLatestAssistantText(convId);
212
+ // In test env, deterministic fallback is always used regardless of trust
213
+ expect(text).toContain('Call to +15551234567 started');
214
+ });
215
+
216
+ test('explicit untrusted audience mode skips generator', () => {
217
+ const convId = 'conv-ptr-explicit-untrusted';
218
+ ensureConversation(convId, { threadType: 'private' });
219
+
220
+ const generatorCalled = { value: false };
221
+ setPointerCopyGenerator(async () => {
222
+ generatorCalled.value = true;
223
+ return 'generated text';
224
+ });
225
+
226
+ addPointerMessage(convId, 'started', '+15551234567', undefined, 'untrusted');
227
+ const text = getLatestAssistantText(convId);
228
+ expect(text).toContain('Call to +15551234567 started');
229
+ // generator is not called because audience is explicitly untrusted
230
+ expect(generatorCalled.value).toBe(false);
231
+ });
232
+
233
+ test('private threadType is detected as trusted audience', async () => {
234
+ const convId = 'conv-ptr-private';
235
+ ensureConversation(convId, { threadType: 'private' });
236
+
237
+ setPointerCopyGenerator(async () => 'generated text');
238
+
239
+ await addPointerMessage(convId, 'completed', '+15559876543', { duration: '1m' });
240
+ const text = getLatestAssistantText(convId);
241
+ // In test env, falls back to deterministic even on trusted path
242
+ expect(text).toContain('Call to +15559876543 completed (1m)');
243
+ });
244
+
245
+ test('vellum origin channel is detected as trusted audience', async () => {
246
+ const convId = 'conv-ptr-vellum';
247
+ ensureConversation(convId, { originChannel: 'vellum' });
248
+
249
+ setPointerCopyGenerator(async () => 'generated text');
250
+
251
+ await addPointerMessage(convId, 'failed', '+15559876543', { reason: 'busy' });
252
+ const text = getLatestAssistantText(convId);
253
+ expect(text).toContain('failed: busy');
254
+ });
255
+
256
+ test('missing conversation defaults to untrusted', () => {
257
+ const _convId = 'conv-ptr-missing';
258
+ // Don't create the conversation — trust resolution should default to untrusted
259
+
260
+ const generatorCalled = { value: false };
261
+ setPointerCopyGenerator(async () => {
262
+ generatorCalled.value = true;
263
+ return 'generated text';
264
+ });
265
+
266
+ // This will fail at addMessage because conversation doesn't exist,
267
+ // but the trust check itself should not throw. Test just the trust
268
+ // gating by using a conversation that exists but has no trust signals.
269
+ const convId2 = 'conv-ptr-no-signals';
270
+ ensureConversation(convId2);
271
+
272
+ addPointerMessage(convId2, 'started', '+15551234567');
273
+ const text = getLatestAssistantText(convId2);
274
+ expect(text).toContain('Call to +15551234567 started');
275
+ // generator not called because standard threadType + no origin = untrusted
276
+ expect(generatorCalled.value).toBe(false);
277
+ });
188
278
  });
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Guard test: prevent reintroduction of hard-coded pointer copy in
3
+ * relay-server.ts, call-controller.ts, and call-domain.ts.
4
+ *
5
+ * Deterministic fallback literals should only exist in the pointer
6
+ * composer file (call-pointer-message-composer.ts). The call-site
7
+ * files should route through addPointerMessage() exclusively.
8
+ */
9
+ import { readFileSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+
12
+ import { describe, expect, test } from 'bun:test';
13
+
14
+ const srcDir = join(import.meta.dir, '..');
15
+
16
+ // These files must NOT contain inline pointer copy strings.
17
+ const guardedFiles = [
18
+ 'calls/relay-server.ts',
19
+ 'calls/call-controller.ts',
20
+ 'calls/call-domain.ts',
21
+ ];
22
+
23
+ // Patterns that indicate inline pointer copy rather than routing through
24
+ // addPointerMessage. We check for the distinctive emoji + "Call to" prefix
25
+ // that the old hard-coded templates used.
26
+ const forbiddenPatterns = [
27
+ /["\u{1F4DE}].*Call to.*(?:started|completed|failed)/u,
28
+ /["\u{2705}].*Guardian verification.*succeeded/u,
29
+ /["\u{274C}].*Guardian verification.*failed/u,
30
+ ];
31
+
32
+ describe('no hardcoded pointer copy in call-site files', () => {
33
+ for (const file of guardedFiles) {
34
+ test(`${file} does not contain inline pointer copy`, () => {
35
+ const content = readFileSync(join(srcDir, file), 'utf-8');
36
+ for (const pattern of forbiddenPatterns) {
37
+ const match = pattern.exec(content);
38
+ expect(match).toBeNull();
39
+ }
40
+ });
41
+ }
42
+ });
@@ -177,10 +177,6 @@ describe('runtime call routes — HTTP layer', () => {
177
177
  return `http://127.0.0.1:${port}/v1/calls${path}`;
178
178
  }
179
179
 
180
- function assistantCallsUrl(assistantId: string, path = ''): string {
181
- return `http://127.0.0.1:${port}/v1/assistants/${assistantId}/calls${path}`;
182
- }
183
-
184
180
  // ── POST /v1/calls/start ────────────────────────────────────────────
185
181
 
186
182
  test('POST /v1/calls/start returns 201 with call session', async () => {
@@ -235,27 +231,6 @@ describe('runtime call routes — HTTP layer', () => {
235
231
  await stopServer();
236
232
  });
237
233
 
238
- test('POST /v1/assistants/:assistantId/calls/start uses assistant-scoped caller number', async () => {
239
- await startServer();
240
- ensureConversation('conv-start-scoped-1');
241
-
242
- const res = await fetch(assistantCallsUrl('asst-alpha', '/start'), {
243
- method: 'POST',
244
- headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
245
- body: JSON.stringify({
246
- phoneNumber: '+15559997777',
247
- task: 'Check order status',
248
- conversationId: 'conv-start-scoped-1',
249
- }),
250
- });
251
-
252
- expect(res.status).toBe(201);
253
- const body = await res.json() as { fromNumber: string };
254
- expect(body.fromNumber).toBe('+15550009999');
255
-
256
- await stopServer();
257
- });
258
-
259
234
  test('POST /v1/calls/start returns 400 for invalid phone number', async () => {
260
235
  await startServer();
261
236
  ensureConversation('conv-start-2');
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Tests for the callback handoff notification fallback copy template.
3
+ *
4
+ * Verifies that the `ingress.access_request.callback_handoff` template in
5
+ * copy-composer.ts renders caller identity, request code, and trusted-contact
6
+ * member reference correctly — including graceful fallback when fields are missing.
7
+ */
8
+ import { describe, expect, test } from 'bun:test';
9
+
10
+ import { composeFallbackCopy } from '../notifications/copy-composer.js';
11
+ import type { NotificationSignal } from '../notifications/signal.js';
12
+
13
+ function buildSignal(payloadOverrides: Record<string, unknown> = {}): NotificationSignal {
14
+ return {
15
+ signalId: 'test-signal-1',
16
+ assistantId: 'self',
17
+ createdAt: Date.now(),
18
+ sourceChannel: 'voice',
19
+ sourceSessionId: 'test-session-1',
20
+ sourceEventName: 'ingress.access_request.callback_handoff',
21
+ contextPayload: {
22
+ requestId: 'req-123',
23
+ requestCode: null,
24
+ callSessionId: 'call-456',
25
+ sourceChannel: 'voice',
26
+ reason: 'timeout',
27
+ callbackOptIn: true,
28
+ callerPhoneNumber: '+15551234567',
29
+ callerName: null,
30
+ requesterExternalUserId: '+15551234567',
31
+ requesterChatId: '+15551234567',
32
+ requesterMemberId: null,
33
+ requesterMemberSourceChannel: null,
34
+ ...payloadOverrides,
35
+ },
36
+ attentionHints: {
37
+ requiresAction: false,
38
+ urgency: 'medium',
39
+ isAsyncBackground: true,
40
+ visibleInSourceNow: false,
41
+ },
42
+ };
43
+ }
44
+
45
+ describe('callback handoff copy template', () => {
46
+ test('renders caller name and phone when both are present', () => {
47
+ const signal = buildSignal({
48
+ callerName: 'Alice',
49
+ callerPhoneNumber: '+15551234567',
50
+ });
51
+ const result = composeFallbackCopy(signal, ['vellum']);
52
+ const copy = result.vellum!;
53
+
54
+ expect(copy.title).toBe('Callback Requested');
55
+ expect(copy.body).toContain('Alice (+15551234567)');
56
+ expect(copy.body).toContain('callback');
57
+ expect(copy.body).toContain('unreachable');
58
+ });
59
+
60
+ test('renders phone number only when caller name is missing', () => {
61
+ const signal = buildSignal({
62
+ callerName: null,
63
+ callerPhoneNumber: '+15559876543',
64
+ });
65
+ const result = composeFallbackCopy(signal, ['vellum']);
66
+ const copy = result.vellum!;
67
+
68
+ expect(copy.body).toContain('+15559876543');
69
+ expect(copy.body).not.toContain('null');
70
+ });
71
+
72
+ test('renders caller name only when phone is missing', () => {
73
+ const signal = buildSignal({
74
+ callerName: 'Bob',
75
+ callerPhoneNumber: null,
76
+ });
77
+ const result = composeFallbackCopy(signal, ['vellum']);
78
+ const copy = result.vellum!;
79
+
80
+ expect(copy.body).toContain('Bob');
81
+ });
82
+
83
+ test('falls back to "An unknown caller" when both name and phone are missing', () => {
84
+ const signal = buildSignal({
85
+ callerName: null,
86
+ callerPhoneNumber: null,
87
+ });
88
+ const result = composeFallbackCopy(signal, ['vellum']);
89
+ const copy = result.vellum!;
90
+
91
+ expect(copy.body).toContain('An unknown caller');
92
+ });
93
+
94
+ test('includes request code when present', () => {
95
+ const signal = buildSignal({
96
+ callerName: 'Charlie',
97
+ callerPhoneNumber: '+15551111111',
98
+ requestCode: 'a1b2c3',
99
+ });
100
+ const result = composeFallbackCopy(signal, ['vellum']);
101
+ const copy = result.vellum!;
102
+
103
+ expect(copy.body).toContain('A1B2C3');
104
+ });
105
+
106
+ test('omits request code line when not present', () => {
107
+ const signal = buildSignal({
108
+ callerName: 'Charlie',
109
+ callerPhoneNumber: '+15551111111',
110
+ requestCode: null,
111
+ });
112
+ const result = composeFallbackCopy(signal, ['vellum']);
113
+ const copy = result.vellum!;
114
+
115
+ expect(copy.body).not.toContain('Request code');
116
+ });
117
+
118
+ test('includes trusted-contact member reference when requesterMemberId is present', () => {
119
+ const signal = buildSignal({
120
+ callerName: 'Diana',
121
+ callerPhoneNumber: '+15552222222',
122
+ requesterMemberId: 'member-789',
123
+ });
124
+ const result = composeFallbackCopy(signal, ['vellum']);
125
+ const copy = result.vellum!;
126
+
127
+ expect(copy.body).toContain('trusted contact');
128
+ expect(copy.body).toContain('member-789');
129
+ });
130
+
131
+ test('omits member reference line when requesterMemberId is null', () => {
132
+ const signal = buildSignal({
133
+ callerName: 'Eve',
134
+ callerPhoneNumber: '+15553333333',
135
+ requesterMemberId: null,
136
+ });
137
+ const result = composeFallbackCopy(signal, ['vellum']);
138
+ const copy = result.vellum!;
139
+
140
+ expect(copy.body).not.toContain('trusted contact');
141
+ expect(copy.body).not.toContain('member');
142
+ });
143
+
144
+ test('telegram channel gets deliveryText fallback', () => {
145
+ const signal = buildSignal({
146
+ callerName: 'Frank',
147
+ callerPhoneNumber: '+15554444444',
148
+ });
149
+ const result = composeFallbackCopy(signal, ['telegram']);
150
+ const copy = result.telegram!;
151
+
152
+ expect(copy.deliveryText).toBeDefined();
153
+ expect(copy.deliveryText!.length).toBeGreaterThan(0);
154
+ expect(copy.deliveryText).toContain('Frank');
155
+ });
156
+
157
+ test('sms channel gets deliveryText fallback', () => {
158
+ const signal = buildSignal({
159
+ callerName: 'Grace',
160
+ callerPhoneNumber: '+15555555555',
161
+ });
162
+ const result = composeFallbackCopy(signal, ['sms']);
163
+ const copy = result.sms!;
164
+
165
+ expect(copy.deliveryText).toBeDefined();
166
+ expect(copy.deliveryText!.length).toBeGreaterThan(0);
167
+ });
168
+
169
+ test('full payload renders all fields correctly', () => {
170
+ const signal = buildSignal({
171
+ callerName: 'Hank',
172
+ callerPhoneNumber: '+15556666666',
173
+ requestCode: 'ff00aa',
174
+ requesterMemberId: 'member-full-test',
175
+ });
176
+ const result = composeFallbackCopy(signal, ['vellum']);
177
+ const copy = result.vellum!;
178
+
179
+ expect(copy.title).toBe('Callback Requested');
180
+ expect(copy.body).toContain('Hank (+15556666666)');
181
+ expect(copy.body).toContain('FF00AA');
182
+ expect(copy.body).toContain('member-full-test');
183
+ expect(copy.body).toContain('callback');
184
+ expect(copy.body).toContain('unreachable');
185
+ });
186
+ });