@vellumai/assistant 0.4.3 → 0.4.5

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 (183) hide show
  1. package/.env.example +3 -0
  2. package/ARCHITECTURE.md +40 -3
  3. package/README.md +43 -35
  4. package/package.json +1 -1
  5. package/scripts/ipc/generate-swift.ts +1 -0
  6. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +58 -120
  7. package/src/__tests__/actor-token-service.test.ts +1099 -0
  8. package/src/__tests__/agent-loop.test.ts +51 -0
  9. package/src/__tests__/approval-routes-http.test.ts +2 -0
  10. package/src/__tests__/assistant-events-sse-hardening.test.ts +7 -5
  11. package/src/__tests__/assistant-id-boundary-guard.test.ts +125 -0
  12. package/src/__tests__/call-controller.test.ts +49 -0
  13. package/src/__tests__/call-pointer-message-composer.test.ts +171 -0
  14. package/src/__tests__/call-pointer-messages.test.ts +93 -3
  15. package/src/__tests__/call-pointer-no-hardcoded-copy.guard.test.ts +42 -0
  16. package/src/__tests__/callback-handoff-copy.test.ts +186 -0
  17. package/src/__tests__/channel-approval-routes.test.ts +133 -12
  18. package/src/__tests__/channel-guardian.test.ts +0 -87
  19. package/src/__tests__/channel-readiness-service.test.ts +10 -16
  20. package/src/__tests__/checker.test.ts +33 -12
  21. package/src/__tests__/config-schema.test.ts +4 -0
  22. package/src/__tests__/confirmation-request-guardian-bridge.test.ts +410 -0
  23. package/src/__tests__/conversation-routes-guardian-reply.test.ts +256 -0
  24. package/src/__tests__/conversation-routes.test.ts +12 -3
  25. package/src/__tests__/credential-security-invariants.test.ts +1 -1
  26. package/src/__tests__/daemon-server-session-init.test.ts +4 -0
  27. package/src/__tests__/guardian-actions-endpoint.test.ts +19 -14
  28. package/src/__tests__/guardian-dispatch.test.ts +8 -0
  29. package/src/__tests__/guardian-outbound-http.test.ts +4 -4
  30. package/src/__tests__/guardian-question-mode.test.ts +200 -0
  31. package/src/__tests__/guardian-routing-invariants.test.ts +178 -0
  32. package/src/__tests__/guardian-routing-state.test.ts +525 -0
  33. package/src/__tests__/handle-user-message-secret-resume.test.ts +2 -0
  34. package/src/__tests__/handlers-telegram-config.test.ts +0 -83
  35. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +55 -0
  36. package/src/__tests__/headless-browser-navigate.test.ts +2 -0
  37. package/src/__tests__/ipc-snapshot.test.ts +18 -51
  38. package/src/__tests__/non-member-access-request.test.ts +131 -8
  39. package/src/__tests__/notification-decision-fallback.test.ts +129 -4
  40. package/src/__tests__/notification-decision-strategy.test.ts +62 -2
  41. package/src/__tests__/notification-guardian-path.test.ts +3 -0
  42. package/src/__tests__/recording-intent-handler.test.ts +1 -0
  43. package/src/__tests__/relay-server.test.ts +841 -39
  44. package/src/__tests__/send-endpoint-busy.test.ts +5 -0
  45. package/src/__tests__/session-agent-loop.test.ts +1 -0
  46. package/src/__tests__/session-confirmation-signals.test.ts +523 -0
  47. package/src/__tests__/session-init.benchmark.test.ts +0 -1
  48. package/src/__tests__/session-surfaces-task-progress.test.ts +1 -1
  49. package/src/__tests__/session-tool-setup-app-refresh.test.ts +81 -2
  50. package/src/__tests__/session-tool-setup-memory-scope.test.ts +1 -1
  51. package/src/__tests__/session-tool-setup-side-effect-flag.test.ts +1 -1
  52. package/src/__tests__/tool-executor.test.ts +21 -2
  53. package/src/__tests__/tool-grant-request-escalation.test.ts +333 -27
  54. package/src/__tests__/trusted-contact-approval-notifier.test.ts +678 -0
  55. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +1064 -0
  56. package/src/__tests__/twilio-config.test.ts +2 -13
  57. package/src/agent/loop.ts +1 -1
  58. package/src/approvals/guardian-decision-primitive.ts +10 -2
  59. package/src/approvals/guardian-request-resolvers.ts +128 -9
  60. package/src/calls/call-constants.ts +21 -0
  61. package/src/calls/call-controller.ts +9 -2
  62. package/src/calls/call-domain.ts +28 -7
  63. package/src/calls/call-pointer-message-composer.ts +154 -0
  64. package/src/calls/call-pointer-messages.ts +106 -27
  65. package/src/calls/guardian-dispatch.ts +4 -2
  66. package/src/calls/relay-server.ts +424 -12
  67. package/src/calls/twilio-config.ts +4 -11
  68. package/src/calls/twilio-routes.ts +1 -1
  69. package/src/calls/types.ts +3 -1
  70. package/src/cli.ts +5 -4
  71. package/src/config/bundled-skills/agentmail/SKILL.md +4 -0
  72. package/src/config/bundled-skills/app-builder/SKILL.md +146 -10
  73. package/src/config/bundled-skills/app-builder/TOOLS.json +1 -1
  74. package/src/config/bundled-skills/email-setup/SKILL.md +1 -1
  75. package/src/config/bundled-skills/google-oauth-setup/SKILL.md +105 -81
  76. package/src/config/bundled-skills/messaging/SKILL.md +61 -12
  77. package/src/config/bundled-skills/messaging/TOOLS.json +58 -0
  78. package/src/config/bundled-skills/messaging/tools/gmail-sender-digest.ts +6 -1
  79. package/src/config/bundled-skills/messaging/tools/messaging-archive-by-sender.ts +35 -0
  80. package/src/config/bundled-skills/messaging/tools/messaging-sender-digest.ts +52 -0
  81. package/src/config/bundled-skills/phone-calls/SKILL.md +30 -39
  82. package/src/config/bundled-skills/twitter/SKILL.md +3 -3
  83. package/src/config/bundled-skills/vercel-token-setup/SKILL.md +1 -0
  84. package/src/config/calls-schema.ts +24 -0
  85. package/src/config/env.ts +22 -0
  86. package/src/config/feature-flag-registry.json +8 -0
  87. package/src/config/schema.ts +2 -2
  88. package/src/config/skills.ts +11 -0
  89. package/src/config/system-prompt.ts +11 -1
  90. package/src/config/templates/SOUL.md +2 -0
  91. package/src/config/vellum-skills/sms-setup/SKILL.md +71 -82
  92. package/src/config/vellum-skills/trusted-contacts/SKILL.md +10 -9
  93. package/src/config/vellum-skills/twilio-setup/SKILL.md +88 -73
  94. package/src/daemon/call-pointer-generators.ts +59 -0
  95. package/src/daemon/computer-use-session.ts +2 -5
  96. package/src/daemon/handlers/apps.ts +76 -20
  97. package/src/daemon/handlers/config-channels.ts +5 -55
  98. package/src/daemon/handlers/config-inbox.ts +9 -3
  99. package/src/daemon/handlers/config-ingress.ts +28 -3
  100. package/src/daemon/handlers/config-telegram.ts +12 -0
  101. package/src/daemon/handlers/config.ts +2 -6
  102. package/src/daemon/handlers/pairing.ts +2 -0
  103. package/src/daemon/handlers/sessions.ts +48 -3
  104. package/src/daemon/handlers/shared.ts +17 -2
  105. package/src/daemon/ipc-contract/integrations.ts +1 -99
  106. package/src/daemon/ipc-contract/messages.ts +47 -1
  107. package/src/daemon/ipc-contract/notifications.ts +11 -0
  108. package/src/daemon/ipc-contract-inventory.json +2 -4
  109. package/src/daemon/lifecycle.ts +17 -0
  110. package/src/daemon/server.ts +14 -1
  111. package/src/daemon/session-agent-loop-handlers.ts +20 -0
  112. package/src/daemon/session-agent-loop.ts +22 -11
  113. package/src/daemon/session-lifecycle.ts +1 -1
  114. package/src/daemon/session-process.ts +11 -1
  115. package/src/daemon/session-runtime-assembly.ts +3 -0
  116. package/src/daemon/session-surfaces.ts +3 -2
  117. package/src/daemon/session.ts +88 -1
  118. package/src/daemon/tool-side-effects.ts +22 -0
  119. package/src/home-base/prebuilt/brain-graph.html +1483 -0
  120. package/src/home-base/prebuilt/index.html +40 -0
  121. package/src/inbound/platform-callback-registration.ts +157 -0
  122. package/src/memory/canonical-guardian-store.ts +1 -1
  123. package/src/memory/db-init.ts +4 -0
  124. package/src/memory/migrations/038-actor-token-records.ts +39 -0
  125. package/src/memory/migrations/index.ts +1 -0
  126. package/src/memory/schema.ts +16 -0
  127. package/src/messaging/provider-types.ts +24 -0
  128. package/src/messaging/provider.ts +7 -0
  129. package/src/messaging/providers/gmail/adapter.ts +127 -0
  130. package/src/messaging/providers/sms/adapter.ts +40 -37
  131. package/src/notifications/adapters/macos.ts +45 -2
  132. package/src/notifications/broadcaster.ts +16 -0
  133. package/src/notifications/copy-composer.ts +39 -1
  134. package/src/notifications/decision-engine.ts +22 -9
  135. package/src/notifications/destination-resolver.ts +16 -2
  136. package/src/notifications/emit-signal.ts +16 -8
  137. package/src/notifications/guardian-question-mode.ts +419 -0
  138. package/src/notifications/signal.ts +14 -3
  139. package/src/permissions/checker.ts +13 -1
  140. package/src/permissions/prompter.ts +14 -0
  141. package/src/providers/anthropic/client.ts +20 -0
  142. package/src/providers/provider-send-message.ts +15 -3
  143. package/src/runtime/access-request-helper.ts +71 -1
  144. package/src/runtime/actor-token-service.ts +234 -0
  145. package/src/runtime/actor-token-store.ts +236 -0
  146. package/src/runtime/channel-approvals.ts +5 -3
  147. package/src/runtime/channel-readiness-service.ts +23 -64
  148. package/src/runtime/channel-readiness-types.ts +3 -4
  149. package/src/runtime/channel-retry-sweep.ts +4 -1
  150. package/src/runtime/confirmation-request-guardian-bridge.ts +197 -0
  151. package/src/runtime/guardian-action-followup-executor.ts +1 -1
  152. package/src/runtime/guardian-context-resolver.ts +82 -0
  153. package/src/runtime/guardian-outbound-actions.ts +0 -3
  154. package/src/runtime/guardian-reply-router.ts +67 -30
  155. package/src/runtime/guardian-vellum-migration.ts +57 -0
  156. package/src/runtime/http-server.ts +65 -12
  157. package/src/runtime/http-types.ts +13 -0
  158. package/src/runtime/invite-redemption-service.ts +8 -0
  159. package/src/runtime/local-actor-identity.ts +76 -0
  160. package/src/runtime/middleware/actor-token.ts +271 -0
  161. package/src/runtime/routes/approval-routes.ts +82 -7
  162. package/src/runtime/routes/brain-graph-routes.ts +222 -0
  163. package/src/runtime/routes/channel-readiness-routes.ts +71 -0
  164. package/src/runtime/routes/conversation-routes.ts +140 -52
  165. package/src/runtime/routes/events-routes.ts +20 -5
  166. package/src/runtime/routes/guardian-action-routes.ts +45 -3
  167. package/src/runtime/routes/guardian-approval-interception.ts +29 -0
  168. package/src/runtime/routes/guardian-bootstrap-routes.ts +145 -0
  169. package/src/runtime/routes/inbound-message-handler.ts +143 -2
  170. package/src/runtime/routes/integration-routes.ts +7 -15
  171. package/src/runtime/routes/pairing-routes.ts +163 -0
  172. package/src/runtime/routes/twilio-routes.ts +934 -0
  173. package/src/runtime/tool-grant-request-helper.ts +3 -1
  174. package/src/security/oauth2.ts +27 -2
  175. package/src/security/token-manager.ts +46 -10
  176. package/src/tools/browser/browser-execution.ts +4 -3
  177. package/src/tools/browser/browser-handoff.ts +10 -18
  178. package/src/tools/browser/browser-manager.ts +80 -25
  179. package/src/tools/browser/browser-screencast.ts +35 -119
  180. package/src/tools/permission-checker.ts +15 -4
  181. package/src/tools/tool-approval-handler.ts +242 -18
  182. package/src/__tests__/handlers-twilio-config.test.ts +0 -1928
  183. package/src/daemon/handlers/config-twilio.ts +0 -1082
@@ -461,6 +461,57 @@ describe('AgentLoop', () => {
461
461
  expect(warningBlock).toBeDefined();
462
462
  });
463
463
 
464
+ test('runs without limit when maxToolUseTurns is 0', async () => {
465
+ // Use 20 turns (beyond old default of 8 used in other tests) to verify no cap
466
+ const turnCount = 20;
467
+ const responses: ProviderResponse[] = [];
468
+ for (let i = 0; i < turnCount; i++) {
469
+ responses.push(toolUseResponse(`t${i}`, 'read_file', { path: `/${i}.txt` }));
470
+ }
471
+ responses.push(textResponse('done'));
472
+ const { provider, calls } = createMockProvider(responses);
473
+ const toolExecutor = async () => ({ content: 'data', isError: false });
474
+ const loop = new AgentLoop(
475
+ provider,
476
+ 'system',
477
+ { maxToolUseTurns: 0, minTurnIntervalMs: 0 },
478
+ dummyTools,
479
+ toolExecutor,
480
+ );
481
+
482
+ const events: AgentEvent[] = [];
483
+ await loop.run([userMessage], collectEvents(events));
484
+
485
+ // All 20 tool turns + 1 final text response = 21 provider calls
486
+ expect(calls).toHaveLength(turnCount + 1);
487
+
488
+ // No hard-limit error events should have been emitted
489
+ const errorEvents = events.filter(
490
+ (e): e is Extract<AgentEvent, { type: 'error' }> => e.type === 'error',
491
+ );
492
+ expect(errorEvents).toHaveLength(0);
493
+
494
+ // Progress check reminders should still fire every 5 turns
495
+ const progressChecks = calls.filter((call) => {
496
+ const lastMsg = call.messages[call.messages.length - 1];
497
+ return lastMsg.content.some(
498
+ (b): b is Extract<ContentBlock, { type: 'text' }> =>
499
+ b.type === 'text' && b.text.includes('making meaningful progress'),
500
+ );
501
+ });
502
+ expect(progressChecks.length).toBeGreaterThanOrEqual(3);
503
+
504
+ // No approaching-limit warnings should appear
505
+ const limitWarnings = calls.filter((call) => {
506
+ const lastMsg = call.messages[call.messages.length - 1];
507
+ return lastMsg.content.some(
508
+ (b): b is Extract<ContentBlock, { type: 'text' }> =>
509
+ b.type === 'text' && b.text.includes('approaching the tool-use turn limit'),
510
+ );
511
+ });
512
+ expect(limitWarnings).toHaveLength(0);
513
+ });
514
+
464
515
  // 9. Tool executor error results are forwarded correctly
465
516
  test('forwards tool error results to provider', async () => {
466
517
  const { provider, calls } = createMockProvider([
@@ -83,6 +83,7 @@ function makeIdleSession(opts?: {
83
83
  setCommandIntent: () => {},
84
84
  setTurnChannelContext: () => {},
85
85
  setTurnInterfaceContext: () => {},
86
+ setStateSignalListener: () => {},
86
87
  updateClient: () => {},
87
88
  enqueueMessage: () => ({ queued: false, requestId: 'noop' }),
88
89
  hasAnyPendingConfirmation: () => false,
@@ -125,6 +126,7 @@ function makeConfirmationEmittingSession(opts?: {
125
126
  setCommandIntent: () => {},
126
127
  setTurnChannelContext: () => {},
127
128
  setTurnInterfaceContext: () => {},
129
+ setStateSignalListener: () => {},
128
130
  updateClient: () => {},
129
131
  enqueueMessage: () => ({ queued: false, requestId: 'noop' }),
130
132
  hasAnyPendingConfirmation: () => false,
@@ -150,7 +150,7 @@ describe('SSE route — capacity limit', () => {
150
150
 
151
151
  test('new connection evicts oldest and returns 200', async () => {
152
152
  const hub = new AssistantEventHub({ maxSubscribers: 1 });
153
- const opts = { hub, heartbeatIntervalMs: 60_000 };
153
+ const opts = { hub, heartbeatIntervalMs: 60_000, skipActorVerification: true as const };
154
154
 
155
155
  const ac1 = new AbortController();
156
156
  const req1 = new Request('http://localhost/v1/events?conversationKey=evict-a', { signal: ac1.signal });
@@ -181,7 +181,7 @@ describe('SSE route — capacity limit', () => {
181
181
  { signal: new AbortController().signal },
182
182
  );
183
183
 
184
- const response = handleSubscribeAssistantEvents(req, new URL(req.url), { hub });
184
+ const response = handleSubscribeAssistantEvents(req, new URL(req.url), { hub, skipActorVerification: true });
185
185
  expect(response.status).toBe(503);
186
186
  const body = await response.json() as { error: { message: string; code?: string } };
187
187
  expect(body.error.message).toMatch(/Too many concurrent connections/);
@@ -195,7 +195,7 @@ describe('SSE route — capacity limit', () => {
195
195
  { signal: ac.signal },
196
196
  );
197
197
 
198
- const response = handleSubscribeAssistantEvents(req, new URL(req.url), { hub });
198
+ const response = handleSubscribeAssistantEvents(req, new URL(req.url), { hub, skipActorVerification: true });
199
199
 
200
200
  expect(response.status).toBe(200);
201
201
  ac.abort(); // clean up the subscription
@@ -218,6 +218,7 @@ describe('SSE route — heartbeat', () => {
218
218
  const response = handleSubscribeAssistantEvents(req, new URL(req.url), {
219
219
  hub,
220
220
  heartbeatIntervalMs: 10,
221
+ skipActorVerification: true,
221
222
  });
222
223
 
223
224
  // Wait for at least one heartbeat interval to fire.
@@ -243,6 +244,7 @@ describe('SSE route — heartbeat', () => {
243
244
  const response = handleSubscribeAssistantEvents(req, new URL(req.url), {
244
245
  hub,
245
246
  heartbeatIntervalMs: 10,
247
+ skipActorVerification: true,
246
248
  });
247
249
 
248
250
  // Wait for several intervals.
@@ -283,7 +285,7 @@ describe('SSE route — disconnect cleanup', () => {
283
285
  { signal: ac.signal },
284
286
  );
285
287
 
286
- handleSubscribeAssistantEvents(req, new URL(req.url), { hub });
288
+ handleSubscribeAssistantEvents(req, new URL(req.url), { hub, skipActorVerification: true });
287
289
 
288
290
  expect(hub.subscriberCount()).toBe(1);
289
291
 
@@ -303,7 +305,7 @@ describe('SSE route — disconnect cleanup', () => {
303
305
  { signal: ac.signal },
304
306
  );
305
307
 
306
- const response = handleSubscribeAssistantEvents(req, new URL(req.url), { hub });
308
+ const response = handleSubscribeAssistantEvents(req, new URL(req.url), { hub, skipActorVerification: true });
307
309
 
308
310
  expect(hub.subscriberCount()).toBe(1);
309
311
 
@@ -287,4 +287,129 @@ describe('assistant ID boundary', () => {
287
287
  // all daemon storage keyed by DAEMON_INTERNAL_ASSISTANT_ID uses the fixed
288
288
  // internal value rather than externally-provided IDs).
289
289
  // -------------------------------------------------------------------------
290
+
291
+ // -------------------------------------------------------------------------
292
+ // Rule (e): No assistantId on daemon control-plane request/param types
293
+ //
294
+ // Daemon IPC contracts and guardian outbound param interfaces must not
295
+ // accept an assistantId field -- the daemon always uses
296
+ // DAEMON_INTERNAL_ASSISTANT_ID internally. Accepting assistantId on these
297
+ // surfaces invites callers to pass external IDs into daemon scoping.
298
+ // -------------------------------------------------------------------------
299
+
300
+ test('IPC contract types do not contain assistantId for guardian requests', () => {
301
+ const ipcContractPath = join(import.meta.dir, '..', 'daemon', 'ipc-contract', 'integrations.ts');
302
+ const content = readFileSync(ipcContractPath, 'utf-8');
303
+
304
+ // Extract the interface blocks for the request types and verify
305
+ // none of them declare an assistantId property.
306
+ const requestTypeNames = [
307
+ 'GuardianVerificationRequest',
308
+ ];
309
+
310
+ for (const typeName of requestTypeNames) {
311
+ // Find the interface/type block — match from the type name to the next
312
+ // closing brace at the same indentation level. We use a simple heuristic:
313
+ // find the line declaring the type, then scan forward to the closing '}'.
314
+ const typeIndex = content.indexOf(typeName);
315
+ expect(typeIndex, `Expected to find ${typeName} in IPC contract`).toBeGreaterThan(-1);
316
+
317
+ // Extract from the type declaration to the next '}' line
318
+ const blockStart = content.indexOf('{', typeIndex);
319
+ if (blockStart === -1) continue;
320
+ let braceDepth = 0;
321
+ let blockEnd = blockStart;
322
+ for (let i = blockStart; i < content.length; i++) {
323
+ if (content[i] === '{') braceDepth++;
324
+ if (content[i] === '}') braceDepth--;
325
+ if (braceDepth === 0) {
326
+ blockEnd = i + 1;
327
+ break;
328
+ }
329
+ }
330
+ const block = content.slice(blockStart, blockEnd);
331
+
332
+ // The block should not contain an assistantId property declaration
333
+ // (matches "assistantId?" or "assistantId:" on a non-comment line)
334
+ const lines = block.split('\n');
335
+ for (const line of lines) {
336
+ const trimmed = line.trim();
337
+ if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) continue;
338
+ expect(
339
+ /\bassistantId\s*[?:]/.test(trimmed),
340
+ `${typeName} must not declare an assistantId property. Found: "${trimmed}"`,
341
+ ).toBe(false);
342
+ }
343
+ }
344
+ });
345
+
346
+ test('guardian outbound param interfaces do not contain assistantId', () => {
347
+ const actionsPath = join(import.meta.dir, '..', 'runtime', 'guardian-outbound-actions.ts');
348
+ const content = readFileSync(actionsPath, 'utf-8');
349
+
350
+ const interfaceNames = [
351
+ 'StartOutboundParams',
352
+ 'ResendOutboundParams',
353
+ 'CancelOutboundParams',
354
+ ];
355
+
356
+ for (const name of interfaceNames) {
357
+ const idx = content.indexOf(name);
358
+ expect(idx, `Expected to find ${name} in guardian-outbound-actions.ts`).toBeGreaterThan(-1);
359
+
360
+ const blockStart = content.indexOf('{', idx);
361
+ if (blockStart === -1) continue;
362
+ let braceDepth = 0;
363
+ let blockEnd = blockStart;
364
+ for (let i = blockStart; i < content.length; i++) {
365
+ if (content[i] === '{') braceDepth++;
366
+ if (content[i] === '}') braceDepth--;
367
+ if (braceDepth === 0) {
368
+ blockEnd = i + 1;
369
+ break;
370
+ }
371
+ }
372
+ const block = content.slice(blockStart, blockEnd);
373
+
374
+ const lines = block.split('\n');
375
+ for (const line of lines) {
376
+ const trimmed = line.trim();
377
+ if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) continue;
378
+ expect(
379
+ /\bassistantId\s*[?:]/.test(trimmed),
380
+ `${name} must not declare an assistantId property. Found: "${trimmed}"`,
381
+ ).toBe(false);
382
+ }
383
+ }
384
+ });
385
+
386
+ test('channel readiness service does not accept assistantId parameter', () => {
387
+ const servicePath = join(import.meta.dir, '..', 'runtime', 'channel-readiness-service.ts');
388
+ const content = readFileSync(servicePath, 'utf-8');
389
+
390
+ // getReadiness and invalidateChannel signatures must not include assistantId
391
+ const signaturePatterns = [
392
+ /getReadiness\([^)]*assistantId/,
393
+ /invalidateChannel\([^)]*assistantId/,
394
+ ];
395
+ for (const pattern of signaturePatterns) {
396
+ expect(
397
+ pattern.test(content),
398
+ `Channel readiness service must not accept assistantId parameter (matched: ${pattern})`,
399
+ ).toBe(false);
400
+ }
401
+
402
+ // ChannelProbeContext must not have assistantId.
403
+ // The interface is declared in channel-readiness-types.ts, not the service file.
404
+ const typesPath = join(import.meta.dir, '..', 'runtime', 'channel-readiness-types.ts');
405
+ const typesContent = readFileSync(typesPath, 'utf-8');
406
+ const probeContextMatch = typesContent.match(/interface\s+ChannelProbeContext\s*\{([^}]*)\}/);
407
+ expect(probeContextMatch, 'Expected to find ChannelProbeContext interface in channel-readiness-types.ts').not.toBeNull();
408
+ if (probeContextMatch) {
409
+ expect(
410
+ probeContextMatch[1],
411
+ 'ChannelProbeContext must not contain assistantId',
412
+ ).not.toContain('assistantId');
413
+ }
414
+ });
290
415
  });
@@ -56,10 +56,12 @@ mock.module('../config/loader.js', () => ({
56
56
  // ── Call constants mock ──────────────────────────────────────────────
57
57
 
58
58
  let mockConsultationTimeoutMs = 90_000;
59
+ let mockSilenceTimeoutMs = 30_000;
59
60
 
60
61
  mock.module('../calls/call-constants.js', () => ({
61
62
  getMaxCallDurationMs: () => 12 * 60 * 1000,
62
63
  getUserConsultationTimeoutMs: () => mockConsultationTimeoutMs,
64
+ getSilenceTimeoutMs: () => mockSilenceTimeoutMs,
63
65
  SILENCE_TIMEOUT_MS: 30_000,
64
66
  MAX_CALL_DURATION_MS: 3600 * 1000,
65
67
  USER_CONSULTATION_TIMEOUT_MS: 120 * 1000,
@@ -154,6 +156,7 @@ interface MockRelay extends RelayConnection {
154
156
  sentTokens: Array<{ token: string; last: boolean }>;
155
157
  endCalled: boolean;
156
158
  endReason: string | undefined;
159
+ mockConnectionState: string;
157
160
  }
158
161
 
159
162
  function createMockRelay(): MockRelay {
@@ -161,12 +164,15 @@ function createMockRelay(): MockRelay {
161
164
  sentTokens: [] as Array<{ token: string; last: boolean }>,
162
165
  _endCalled: false,
163
166
  _endReason: undefined as string | undefined,
167
+ _connectionState: 'connected',
164
168
  };
165
169
 
166
170
  return {
167
171
  get sentTokens() { return state.sentTokens; },
168
172
  get endCalled() { return state._endCalled; },
169
173
  get endReason() { return state._endReason; },
174
+ get mockConnectionState() { return state._connectionState; },
175
+ set mockConnectionState(v: string) { state._connectionState = v; },
170
176
  sendTextToken(token: string, last: boolean) {
171
177
  state.sentTokens.push({ token, last });
172
178
  },
@@ -174,6 +180,9 @@ function createMockRelay(): MockRelay {
174
180
  state._endCalled = true;
175
181
  state._endReason = reason;
176
182
  },
183
+ getConnectionState() {
184
+ return state._connectionState;
185
+ },
177
186
  } as unknown as MockRelay;
178
187
  }
179
188
 
@@ -236,6 +245,7 @@ describe('call-controller', () => {
236
245
  mockStartVoiceTurn.mockImplementation(createMockVoiceTurn(['Hello', ' there']));
237
246
  // Reset consultation timeout to the default (long) value
238
247
  mockConsultationTimeoutMs = 90_000;
248
+ mockSilenceTimeoutMs = 30_000;
239
249
  });
240
250
 
241
251
  // ── handleCallerUtterance ─────────────────────────────────────────
@@ -1697,4 +1707,43 @@ describe('call-controller', () => {
1697
1707
 
1698
1708
  controller.destroy();
1699
1709
  });
1710
+
1711
+ // ── Silence suppression during guardian wait ──────────────────────
1712
+
1713
+ test('silence timeout suppressed during guardian wait: does not say "Are you still there?"', async () => {
1714
+ mockSilenceTimeoutMs = 50; // Short timeout for testing
1715
+ const { relay, controller } = setupController();
1716
+
1717
+ // Simulate guardian wait state on the relay
1718
+ relay.mockConnectionState = 'awaiting_guardian_decision';
1719
+
1720
+ // Wait for the silence timeout to fire
1721
+ await new Promise((r) => setTimeout(r, 200));
1722
+
1723
+ // "Are you still there?" should NOT have been sent
1724
+ const silenceTokens = relay.sentTokens.filter((t) =>
1725
+ t.token.includes('Are you still there?'),
1726
+ );
1727
+ expect(silenceTokens.length).toBe(0);
1728
+
1729
+ controller.destroy();
1730
+ });
1731
+
1732
+ test('silence timeout fires normally when not in guardian wait', async () => {
1733
+ mockSilenceTimeoutMs = 50; // Short timeout for testing
1734
+ const { relay, controller } = setupController();
1735
+
1736
+ // Default connection state is 'connected' (not guardian wait)
1737
+
1738
+ // Wait for the silence timeout to fire
1739
+ await new Promise((r) => setTimeout(r, 200));
1740
+
1741
+ // "Are you still there?" SHOULD have been sent
1742
+ const silenceTokens = relay.sentTokens.filter((t) =>
1743
+ t.token.includes('Are you still there?'),
1744
+ );
1745
+ expect(silenceTokens.length).toBe(1);
1746
+
1747
+ controller.destroy();
1748
+ });
1700
1749
  });
@@ -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
  });