@vellumai/assistant 0.4.5 → 0.4.7

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 (112) hide show
  1. package/ARCHITECTURE.md +27 -10
  2. package/README.md +6 -6
  3. package/bun.lock +57 -2
  4. package/docs/architecture/memory.md +4 -4
  5. package/docs/trusted-contact-access.md +8 -0
  6. package/package.json +3 -2
  7. package/src/__tests__/actor-token-service.test.ts +9 -6
  8. package/src/__tests__/assistant-feature-flags-integration.test.ts +1 -0
  9. package/src/__tests__/call-controller.test.ts +115 -0
  10. package/src/__tests__/call-domain.test.ts +148 -10
  11. package/src/__tests__/call-pointer-message-composer.test.ts +39 -49
  12. package/src/__tests__/call-pointer-messages.test.ts +105 -43
  13. package/src/__tests__/canonical-guardian-store.test.ts +44 -10
  14. package/src/__tests__/channel-approval-routes.test.ts +67 -65
  15. package/src/__tests__/channel-delivery-store.test.ts +2 -2
  16. package/src/__tests__/confirmation-request-guardian-bridge.test.ts +1 -0
  17. package/src/__tests__/conversation-attention-telegram.test.ts +2 -2
  18. package/src/__tests__/deterministic-verification-control-plane.test.ts +6 -6
  19. package/src/__tests__/gateway-client-managed-outbound.test.ts +147 -0
  20. package/src/__tests__/guardian-actions-endpoint.test.ts +7 -6
  21. package/src/__tests__/guardian-decision-primitive-canonical.test.ts +57 -12
  22. package/src/__tests__/guardian-dispatch.test.ts +39 -1
  23. package/src/__tests__/guardian-grant-minting.test.ts +24 -24
  24. package/src/__tests__/guardian-principal-id-roundtrip.test.ts +205 -0
  25. package/src/__tests__/guardian-routing-invariants.test.ts +64 -25
  26. package/src/__tests__/guardian-routing-state.test.ts +10 -32
  27. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +2 -2
  28. package/src/__tests__/inbound-invite-redemption.test.ts +8 -8
  29. package/src/__tests__/memory-retrieval.benchmark.test.ts +22 -47
  30. package/src/__tests__/no-is-trusted-guard.test.ts +77 -0
  31. package/src/__tests__/non-member-access-request.test.ts +57 -47
  32. package/src/__tests__/notification-decision-fallback.test.ts +232 -0
  33. package/src/__tests__/notification-decision-strategy.test.ts +304 -8
  34. package/src/__tests__/notification-guardian-path.test.ts +38 -1
  35. package/src/__tests__/relay-server.test.ts +136 -5
  36. package/src/__tests__/send-endpoint-busy.test.ts +35 -1
  37. package/src/__tests__/session-tool-setup-tools-disabled.test.ts +155 -0
  38. package/src/__tests__/skill-feature-flags-integration.test.ts +1 -0
  39. package/src/__tests__/skill-projection.benchmark.test.ts +66 -2
  40. package/src/__tests__/system-prompt.test.ts +1 -0
  41. package/src/__tests__/tool-approval-handler.test.ts +1 -1
  42. package/src/__tests__/tool-grant-request-escalation.test.ts +10 -2
  43. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +14 -1
  44. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +24 -24
  45. package/src/__tests__/trusted-contact-multichannel.test.ts +5 -5
  46. package/src/__tests__/trusted-contact-verification.test.ts +10 -10
  47. package/src/approvals/guardian-decision-primitive.ts +29 -25
  48. package/src/approvals/guardian-request-resolvers.ts +9 -5
  49. package/src/calls/call-controller.ts +15 -0
  50. package/src/calls/call-pointer-message-composer.ts +27 -85
  51. package/src/calls/call-pointer-messages.ts +54 -21
  52. package/src/calls/guardian-dispatch.ts +30 -0
  53. package/src/calls/relay-server.ts +58 -24
  54. package/src/calls/types.ts +1 -0
  55. package/src/config/system-prompt.ts +10 -3
  56. package/src/config/templates/BOOTSTRAP.md +6 -5
  57. package/src/config/templates/USER.md +1 -0
  58. package/src/config/user-reference.ts +44 -0
  59. package/src/daemon/handlers/guardian-actions.ts +5 -2
  60. package/src/daemon/handlers/sessions.ts +8 -3
  61. package/src/daemon/lifecycle.ts +109 -3
  62. package/src/daemon/providers-setup.ts +0 -8
  63. package/src/daemon/server.ts +32 -24
  64. package/src/daemon/session-agent-loop.ts +4 -3
  65. package/src/daemon/session-lifecycle.ts +1 -9
  66. package/src/daemon/session-process.ts +2 -2
  67. package/src/daemon/session-runtime-assembly.ts +2 -0
  68. package/src/daemon/session-slash.ts +35 -2
  69. package/src/daemon/session-tool-setup.ts +10 -0
  70. package/src/daemon/session.ts +1 -0
  71. package/src/memory/canonical-guardian-store.ts +40 -0
  72. package/src/memory/conversation-crud.ts +26 -0
  73. package/src/memory/conversation-store.ts +1 -0
  74. package/src/memory/db-init.ts +12 -0
  75. package/src/memory/guardian-bindings.ts +4 -0
  76. package/src/memory/job-handlers/backfill.ts +2 -9
  77. package/src/memory/migrations/039-actor-refresh-token-records.ts +51 -0
  78. package/src/memory/migrations/125-guardian-principal-id-columns.ts +19 -0
  79. package/src/memory/migrations/126-backfill-guardian-principal-id.ts +210 -0
  80. package/src/memory/migrations/index.ts +3 -0
  81. package/src/memory/migrations/registry.ts +5 -0
  82. package/src/memory/schema.ts +22 -0
  83. package/src/notifications/README.md +8 -1
  84. package/src/notifications/copy-composer.ts +160 -30
  85. package/src/notifications/decision-engine.ts +98 -1
  86. package/src/runtime/access-request-helper.ts +43 -28
  87. package/src/runtime/actor-refresh-token-service.ts +309 -0
  88. package/src/runtime/actor-refresh-token-store.ts +157 -0
  89. package/src/runtime/actor-token-service.ts +3 -3
  90. package/src/runtime/actor-trust-resolver.ts +19 -14
  91. package/src/runtime/channel-guardian-service.ts +6 -0
  92. package/src/runtime/gateway-client.ts +239 -0
  93. package/src/runtime/guardian-context-resolver.ts +6 -2
  94. package/src/runtime/guardian-reply-router.ts +33 -16
  95. package/src/runtime/guardian-vellum-migration.ts +29 -5
  96. package/src/runtime/http-server.ts +2 -0
  97. package/src/runtime/http-types.ts +0 -13
  98. package/src/runtime/local-actor-identity.ts +19 -13
  99. package/src/runtime/middleware/actor-token.ts +2 -2
  100. package/src/runtime/routes/channel-delivery-routes.ts +5 -5
  101. package/src/runtime/routes/conversation-routes.ts +45 -35
  102. package/src/runtime/routes/guardian-action-routes.ts +7 -1
  103. package/src/runtime/routes/guardian-approval-interception.ts +52 -52
  104. package/src/runtime/routes/guardian-bootstrap-routes.ts +11 -24
  105. package/src/runtime/routes/guardian-refresh-routes.ts +53 -0
  106. package/src/runtime/routes/inbound-conversation.ts +7 -7
  107. package/src/runtime/routes/inbound-message-handler.ts +105 -94
  108. package/src/runtime/routes/pairing-routes.ts +60 -50
  109. package/src/runtime/tool-grant-request-helper.ts +1 -0
  110. package/src/types/qrcode.d.ts +10 -0
  111. package/src/util/logger.ts +10 -0
  112. package/src/daemon/call-pointer-generators.ts +0 -59
@@ -1,17 +1,16 @@
1
1
  /**
2
- * Unit tests for caller identity resolution in call-domain.ts.
2
+ * Unit tests for caller identity resolution and pointer message regression
3
+ * in call-domain.ts.
3
4
  *
4
- * Validates the strict implicit-default policy:
5
- * - Implicit calls (no explicit mode) always use assistant_number.
6
- * - Explicit user_number calls succeed when eligible.
7
- * - Explicit user_number calls fail clearly when missing/ineligible.
8
- * - Explicit override rejected when allowPerCallOverride=false.
5
+ * Validates:
6
+ * - Strict implicit-default policy for caller identity.
7
+ * - Pointer messages are written on successful call start and on failure.
9
8
  */
10
- import { mkdtempSync, realpathSync } from 'node:fs';
9
+ import { mkdtempSync, realpathSync, rmSync } from 'node:fs';
11
10
  import { tmpdir } from 'node:os';
12
11
  import { join } from 'node:path';
13
12
 
14
- import { describe, expect, mock,test } from 'bun:test';
13
+ import { afterAll, beforeEach, describe, expect, mock,test } from 'bun:test';
15
14
 
16
15
  const testDir = realpathSync(mkdtempSync(join(tmpdir(), 'call-domain-test-')));
17
16
 
@@ -34,6 +33,9 @@ mock.module('../util/logger.js', () => ({
34
33
  }),
35
34
  }));
36
35
 
36
+ // Track whether the Twilio provider's initiateCall should succeed or throw
37
+ let twilioInitiateCallBehavior: 'success' | 'error' = 'success';
38
+
37
39
  mock.module('../calls/twilio-config.js', () => ({
38
40
  getTwilioConfig: (assistantId?: string) => ({
39
41
  accountSid: 'AC_test',
@@ -47,10 +49,13 @@ mock.module('../calls/twilio-config.js', () => ({
47
49
  mock.module('../calls/twilio-provider.js', () => ({
48
50
  TwilioConversationRelayProvider: class {
49
51
  async checkCallerIdEligibility(number: string) {
50
- // Simulate: +15550002222 is eligible, others are not
51
52
  if (number === '+15550002222') return { eligible: true };
52
53
  return { eligible: false, reason: `${number} is not eligible as a caller ID` };
53
54
  }
55
+ async initiateCall() {
56
+ if (twilioInitiateCallBehavior === 'error') throw new Error('Twilio unavailable');
57
+ return { callSid: 'CA_test_123' };
58
+ }
54
59
  },
55
60
  }));
56
61
 
@@ -58,8 +63,91 @@ mock.module('../security/secure-keys.js', () => ({
58
63
  getSecureKey: () => null,
59
64
  }));
60
65
 
61
- import { resolveCallerIdentity } from '../calls/call-domain.js';
66
+ mock.module('../config/loader.js', () => ({
67
+ loadConfig: () => ({
68
+ calls: {
69
+ enabled: true,
70
+ provider: 'twilio',
71
+ callerIdentity: { allowPerCallOverride: true },
72
+ },
73
+ memory: { enabled: false },
74
+ }),
75
+ getConfig: () => ({
76
+ calls: {
77
+ enabled: true,
78
+ provider: 'twilio',
79
+ callerIdentity: { allowPerCallOverride: true },
80
+ },
81
+ memory: { enabled: false },
82
+ }),
83
+ }));
84
+
85
+ mock.module('../inbound/platform-callback-registration.js', () => ({
86
+ resolveCallbackUrl: async (fn: () => string) => fn(),
87
+ }));
88
+
89
+ mock.module('../inbound/public-ingress-urls.js', () => ({
90
+ getTwilioVoiceWebhookUrl: () => 'https://test.example.com/webhooks/twilio/voice/test',
91
+ getTwilioStatusCallbackUrl: () => 'https://test.example.com/webhooks/twilio/status',
92
+ }));
93
+
94
+ mock.module('../memory/conversation-title-service.js', () => ({
95
+ queueGenerateConversationTitle: () => {},
96
+ }));
97
+
98
+ import { resolveCallerIdentity, startCall } from '../calls/call-domain.js';
62
99
  import type { AssistantConfig } from '../config/types.js';
100
+ import { getMessages } from '../memory/conversation-store.js';
101
+ import { getDb, initializeDb, resetDb } from '../memory/db.js';
102
+ import { conversations } from '../memory/schema.js';
103
+
104
+ initializeDb();
105
+
106
+ let ensuredConvIds = new Set<string>();
107
+ function ensureConversation(id: string): void {
108
+ if (ensuredConvIds.has(id)) return;
109
+ const db = getDb();
110
+ const now = Date.now();
111
+ db.insert(conversations).values({
112
+ id,
113
+ title: `Test conversation ${id}`,
114
+ createdAt: now,
115
+ updatedAt: now,
116
+ }).run();
117
+ ensuredConvIds.add(id);
118
+ }
119
+
120
+ function resetTables(): void {
121
+ const db = getDb();
122
+ db.run('DELETE FROM external_conversation_bindings');
123
+ db.run('DELETE FROM call_sessions');
124
+ db.run('DELETE FROM messages');
125
+ db.run('DELETE FROM conversations');
126
+ ensuredConvIds = new Set();
127
+ }
128
+
129
+ function getLatestAssistantText(conversationId: string): string | null {
130
+ const msgs = getMessages(conversationId).filter((m) => m.role === 'assistant');
131
+ if (msgs.length === 0) return null;
132
+ const latest = msgs[msgs.length - 1];
133
+ try {
134
+ const parsed = JSON.parse(latest.content) as unknown;
135
+ if (Array.isArray(parsed)) {
136
+ return parsed
137
+ .filter((b): b is { type: string; text?: string } => typeof b === 'object' && b != null)
138
+ .filter((b) => b.type === 'text')
139
+ .map((b) => b.text ?? '')
140
+ .join('');
141
+ }
142
+ if (typeof parsed === 'string') return parsed;
143
+ } catch { /* fall through */ }
144
+ return latest.content;
145
+ }
146
+
147
+ afterAll(() => {
148
+ resetDb();
149
+ try { rmSync(testDir, { recursive: true }); } catch { /* best effort */ }
150
+ });
63
151
 
64
152
  function makeConfig(overrides: {
65
153
  allowPerCallOverride?: boolean;
@@ -172,3 +260,53 @@ describe('resolveCallerIdentity — strict implicit-default policy', () => {
172
260
  }
173
261
  });
174
262
  });
263
+
264
+ // ── Pointer message regression tests ──────────────────────────────
265
+
266
+ describe('startCall — pointer message regression', () => {
267
+ beforeEach(() => {
268
+ resetTables();
269
+ twilioInitiateCallBehavior = 'success';
270
+ });
271
+
272
+ test('successful call writes a started pointer to the initiating conversation', async () => {
273
+ const convId = 'conv-domain-ptr-start';
274
+ ensureConversation(convId);
275
+
276
+ const result = await startCall({
277
+ phoneNumber: '+15559876543',
278
+ task: 'Test call',
279
+ conversationId: convId,
280
+ });
281
+
282
+ expect(result.ok).toBe(true);
283
+ // Allow async pointer write to flush
284
+ await new Promise((r) => setTimeout(r, 50));
285
+
286
+ const text = getLatestAssistantText(convId);
287
+ expect(text).not.toBeNull();
288
+ expect(text!).toContain('+15559876543');
289
+ expect(text!).toContain('started');
290
+ });
291
+
292
+ test('failed call writes a failed pointer to the initiating conversation', async () => {
293
+ const convId = 'conv-domain-ptr-fail';
294
+ ensureConversation(convId);
295
+ twilioInitiateCallBehavior = 'error';
296
+
297
+ const result = await startCall({
298
+ phoneNumber: '+15559876543',
299
+ task: 'Test call',
300
+ conversationId: convId,
301
+ });
302
+
303
+ expect(result.ok).toBe(false);
304
+ // Allow async pointer write to flush
305
+ await new Promise((r) => setTimeout(r, 50));
306
+
307
+ const text = getLatestAssistantText(convId);
308
+ expect(text).not.toBeNull();
309
+ expect(text!).toContain('+15559876543');
310
+ expect(text!).toContain('failed');
311
+ });
312
+ });
@@ -9,11 +9,9 @@ mock.module('../util/logger.js', () => ({
9
9
  }));
10
10
 
11
11
  import {
12
- buildPointerGenerationPrompt,
12
+ buildPointerInstruction,
13
13
  type CallPointerMessageContext,
14
- composeCallPointerMessageGenerative,
15
14
  getPointerFallbackMessage,
16
- includesRequiredFacts,
17
15
  } from '../calls/call-pointer-message-composer.js';
18
16
 
19
17
  // ---------------------------------------------------------------------------
@@ -105,67 +103,59 @@ describe('getPointerFallbackMessage', () => {
105
103
  });
106
104
 
107
105
  // ---------------------------------------------------------------------------
108
- // Required facts validation
106
+ // Daemon instruction builder
109
107
  // ---------------------------------------------------------------------------
110
108
 
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);
109
+ describe('buildPointerInstruction', () => {
110
+ test('includes event tag, scenario, and phone number', () => {
111
+ const ctx: CallPointerMessageContext = { scenario: 'started', phoneNumber: '+15551234567' };
112
+ const instruction = buildPointerInstruction(ctx);
113
+ expect(instruction).toContain('[CALL_STATUS_EVENT]');
114
+ expect(instruction).toContain('Event: started');
115
+ expect(instruction).toContain('Phone number: +15551234567');
115
116
  });
116
117
 
117
- test('returns true when all facts present', () => {
118
- expect(includesRequiredFacts('Call to +15551234567 completed (2m).', ['+15551234567', '2m'])).toBe(true);
118
+ test('includes duration when provided', () => {
119
+ const ctx: CallPointerMessageContext = { scenario: 'completed', phoneNumber: '+15559876543', duration: '3m' };
120
+ const instruction = buildPointerInstruction(ctx);
121
+ expect(instruction).toContain('Duration: 3m');
119
122
  });
120
123
 
121
- test('returns false when a fact is missing', () => {
122
- expect(includesRequiredFacts('Call completed.', ['+15551234567'])).toBe(false);
124
+ test('includes reason when provided', () => {
125
+ const ctx: CallPointerMessageContext = { scenario: 'failed', phoneNumber: '+15559876543', reason: 'no answer' };
126
+ const instruction = buildPointerInstruction(ctx);
127
+ expect(instruction).toContain('Reason: no answer');
123
128
  });
124
- });
125
129
 
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');
130
+ test('includes verification code when provided', () => {
131
+ const ctx: CallPointerMessageContext = { scenario: 'started', phoneNumber: '+15551234567', verificationCode: '42' };
132
+ const instruction = buildPointerInstruction(ctx);
133
+ expect(instruction).toContain('Verification code: 42');
136
134
  });
137
135
 
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');
136
+ test('includes channel when provided', () => {
137
+ const ctx: CallPointerMessageContext = {
138
+ scenario: 'guardian_verification_succeeded',
139
+ phoneNumber: '+15559876543',
140
+ channel: 'sms',
141
+ };
142
+ const instruction = buildPointerInstruction(ctx);
143
+ expect(instruction).toContain('Channel: sms');
144
144
  });
145
- });
146
-
147
- // ---------------------------------------------------------------------------
148
- // Generative composition (test env falls back to deterministic)
149
- // ---------------------------------------------------------------------------
150
145
 
151
- describe('composeCallPointerMessageGenerative', () => {
152
- test('returns fallback in test environment regardless of generator', async () => {
153
- const generator = async () => 'LLM-generated copy';
146
+ test('omits optional fields when not provided', () => {
154
147
  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');
148
+ const instruction = buildPointerInstruction(ctx);
149
+ expect(instruction).not.toContain('Duration:');
150
+ expect(instruction).not.toContain('Reason:');
151
+ expect(instruction).not.toContain('Verification code:');
152
+ expect(instruction).not.toContain('Channel:');
164
153
  });
165
154
 
166
- test('uses custom fallbackText when provided', async () => {
155
+ test('ends with generation instructions', () => {
167
156
  const ctx: CallPointerMessageContext = { scenario: 'completed', phoneNumber: '+15559876543' };
168
- const result = await composeCallPointerMessageGenerative(ctx, { fallbackText: 'Custom fallback' });
169
- expect(result).toBe('Custom fallback');
157
+ const instruction = buildPointerInstruction(ctx);
158
+ expect(instruction).toContain('Write a brief');
159
+ expect(instruction).toContain('Preserve all factual details');
170
160
  });
171
161
  });
@@ -25,8 +25,8 @@ mock.module('../util/logger.js', () => ({
25
25
  }),
26
26
  }));
27
27
 
28
- import { addPointerMessage, formatDuration, resetPointerCopyGenerator, setPointerCopyGenerator } from '../calls/call-pointer-messages.js';
29
- import { getMessages } from '../memory/conversation-store.js';
28
+ import { addPointerMessage, formatDuration, resetPointerMessageProcessor, setPointerMessageProcessor } from '../calls/call-pointer-messages.js';
29
+ import { addMessage, 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
 
@@ -95,7 +95,7 @@ describe('addPointerMessage', () => {
95
95
  });
96
96
 
97
97
  afterEach(() => {
98
- resetPointerCopyGenerator();
98
+ resetPointerMessageProcessor();
99
99
  });
100
100
 
101
101
  afterAll(() => {
@@ -192,87 +192,149 @@ describe('addPointerMessage', () => {
192
192
  expect(text).toContain('failed: Max attempts exceeded');
193
193
  });
194
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.
195
+ // Trust-aware tests
198
196
 
199
- test('untrusted audience uses deterministic fallback even with generator set', () => {
197
+ test('untrusted audience uses deterministic fallback even with processor set', () => {
200
198
  const convId = 'conv-ptr-untrusted';
201
- // standard threadType + no origin channel = untrusted
202
199
  ensureConversation(convId, { threadType: 'standard' });
203
200
 
204
- const generatorCalled = { value: false };
205
- setPointerCopyGenerator(async () => {
206
- generatorCalled.value = true;
207
- return 'generated text';
201
+ const processorCalled = { value: false };
202
+ setPointerMessageProcessor(async () => {
203
+ processorCalled.value = true;
208
204
  });
209
205
 
210
206
  addPointerMessage(convId, 'started', '+15551234567');
211
207
  const text = getLatestAssistantText(convId);
212
- // In test env, deterministic fallback is always used regardless of trust
213
208
  expect(text).toContain('Call to +15551234567 started');
209
+ // processor not called because standard threadType + no origin = untrusted
210
+ expect(processorCalled.value).toBe(false);
214
211
  });
215
212
 
216
- test('explicit untrusted audience mode skips generator', () => {
213
+ test('explicit untrusted audience mode skips processor', () => {
217
214
  const convId = 'conv-ptr-explicit-untrusted';
218
215
  ensureConversation(convId, { threadType: 'private' });
219
216
 
220
- const generatorCalled = { value: false };
221
- setPointerCopyGenerator(async () => {
222
- generatorCalled.value = true;
223
- return 'generated text';
217
+ const processorCalled = { value: false };
218
+ setPointerMessageProcessor(async () => {
219
+ processorCalled.value = true;
224
220
  });
225
221
 
226
222
  addPointerMessage(convId, 'started', '+15551234567', undefined, 'untrusted');
227
223
  const text = getLatestAssistantText(convId);
228
224
  expect(text).toContain('Call to +15551234567 started');
229
- // generator is not called because audience is explicitly untrusted
230
- expect(generatorCalled.value).toBe(false);
225
+ expect(processorCalled.value).toBe(false);
231
226
  });
232
227
 
233
- test('private threadType is detected as trusted audience', async () => {
234
- const convId = 'conv-ptr-private';
228
+ test('trusted audience routes through daemon processor with required facts', async () => {
229
+ const convId = 'conv-ptr-trusted';
235
230
  ensureConversation(convId, { threadType: 'private' });
236
231
 
237
- setPointerCopyGenerator(async () => 'generated text');
232
+ let capturedInstruction = '';
233
+ let capturedFacts: string[] = [];
234
+ setPointerMessageProcessor(async (_convId, instruction, requiredFacts) => {
235
+ capturedInstruction = instruction;
236
+ capturedFacts = requiredFacts ?? [];
237
+ });
238
238
 
239
239
  await addPointerMessage(convId, 'completed', '+15559876543', { duration: '1m' });
240
+ // Processor was called with a structured instruction
241
+ expect(capturedInstruction).toContain('[CALL_STATUS_EVENT]');
242
+ expect(capturedInstruction).toContain('+15559876543');
243
+ expect(capturedInstruction).toContain('completed');
244
+ expect(capturedInstruction).toContain('1m');
245
+ // Required facts include phone number, duration, and outcome keyword
246
+ expect(capturedFacts).toContain('+15559876543');
247
+ expect(capturedFacts).toContain('1m');
248
+ expect(capturedFacts).toContain('completed');
249
+ });
250
+
251
+ test('trusted audience falls back to deterministic on processor failure', async () => {
252
+ const convId = 'conv-ptr-processor-fail';
253
+ ensureConversation(convId, { threadType: 'private' });
254
+
255
+ setPointerMessageProcessor(async () => {
256
+ throw new Error('Daemon unavailable');
257
+ });
258
+
259
+ await addPointerMessage(convId, 'failed', '+15559876543', { reason: 'busy' });
260
+ // Falls back to deterministic — written directly to conversation store
240
261
  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)');
262
+ expect(text).toContain('failed: busy');
243
263
  });
244
264
 
245
265
  test('vellum origin channel is detected as trusted audience', async () => {
246
266
  const convId = 'conv-ptr-vellum';
247
267
  ensureConversation(convId, { originChannel: 'vellum' });
248
268
 
249
- setPointerCopyGenerator(async () => 'generated text');
269
+ let processorCalled = false;
270
+ setPointerMessageProcessor(async () => {
271
+ processorCalled = true;
272
+ });
250
273
 
251
274
  await addPointerMessage(convId, 'failed', '+15559876543', { reason: 'busy' });
252
- const text = getLatestAssistantText(convId);
253
- expect(text).toContain('failed: busy');
275
+ expect(processorCalled).toBe(true);
254
276
  });
255
277
 
256
278
  test('missing conversation defaults to untrusted', () => {
257
- const _convId = 'conv-ptr-missing';
258
- // Don't create the conversation — trust resolution should default to untrusted
279
+ const convId = 'conv-ptr-no-signals';
280
+ ensureConversation(convId);
281
+
282
+ const processorCalled = { value: false };
283
+ setPointerMessageProcessor(async () => {
284
+ processorCalled.value = true;
285
+ });
286
+
287
+ addPointerMessage(convId, 'started', '+15551234567');
288
+ const text = getLatestAssistantText(convId);
289
+ expect(text).toContain('Call to +15551234567 started');
290
+ expect(processorCalled.value).toBe(false);
291
+ });
292
+
293
+ // Provenance trust class tests
294
+
295
+ test('guardian provenance trust class is detected as trusted audience', async () => {
296
+ const convId = 'conv-ptr-guardian-provenance';
297
+ ensureConversation(convId, { threadType: 'standard' });
298
+ // Add a user message with guardian provenance metadata
299
+ await addMessage(convId, 'user', 'hello', { provenanceTrustClass: 'guardian' });
300
+
301
+ let processorCalled = false;
302
+ setPointerMessageProcessor(async () => {
303
+ processorCalled = true;
304
+ });
305
+
306
+ await addPointerMessage(convId, 'completed', '+15559876543');
307
+ expect(processorCalled).toBe(true);
308
+ });
259
309
 
260
- const generatorCalled = { value: false };
261
- setPointerCopyGenerator(async () => {
262
- generatorCalled.value = true;
263
- return 'generated text';
310
+ test('trusted_contact provenance trust class is detected as trusted audience', async () => {
311
+ const convId = 'conv-ptr-tc-provenance';
312
+ ensureConversation(convId, { threadType: 'standard' });
313
+ // Add a user message with trusted_contact provenance metadata
314
+ await addMessage(convId, 'user', 'hello', { provenanceTrustClass: 'trusted_contact' });
315
+
316
+ let processorCalled = false;
317
+ setPointerMessageProcessor(async () => {
318
+ processorCalled = true;
264
319
  });
265
320
 
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);
321
+ await addPointerMessage(convId, 'completed', '+15559876543');
322
+ expect(processorCalled).toBe(true);
323
+ });
324
+
325
+ test('unknown provenance trust class does not grant trusted audience', () => {
326
+ const convId = 'conv-ptr-unknown-provenance';
327
+ ensureConversation(convId, { threadType: 'standard' });
328
+ addMessage(convId, 'user', 'hello', { provenanceTrustClass: 'unknown' });
271
329
 
272
- addPointerMessage(convId2, 'started', '+15551234567');
273
- const text = getLatestAssistantText(convId2);
330
+ const processorCalled = { value: false };
331
+ setPointerMessageProcessor(async () => {
332
+ processorCalled.value = true;
333
+ });
334
+
335
+ addPointerMessage(convId, 'started', '+15551234567');
336
+ const text = getLatestAssistantText(convId);
274
337
  expect(text).toContain('Call to +15551234567 started');
275
- // generator not called because standard threadType + no origin = untrusted
276
- expect(generatorCalled.value).toBe(false);
338
+ expect(processorCalled.value).toBe(false);
277
339
  });
278
340
  });