@vellumai/assistant 0.4.4 → 0.4.6

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 (90) hide show
  1. package/ARCHITECTURE.md +4 -4
  2. package/README.md +6 -6
  3. package/bun.lock +6 -2
  4. package/docs/architecture/memory.md +4 -4
  5. package/package.json +2 -2
  6. package/src/__tests__/actor-token-service.test.ts +5 -2
  7. package/src/__tests__/assistant-feature-flags-integration.test.ts +1 -0
  8. package/src/__tests__/call-controller.test.ts +78 -0
  9. package/src/__tests__/call-domain.test.ts +148 -10
  10. package/src/__tests__/call-pointer-message-composer.test.ts +39 -49
  11. package/src/__tests__/call-pointer-messages.test.ts +105 -43
  12. package/src/__tests__/canonical-guardian-store.test.ts +44 -10
  13. package/src/__tests__/channel-approval-routes.test.ts +67 -65
  14. package/src/__tests__/confirmation-request-guardian-bridge.test.ts +1 -0
  15. package/src/__tests__/conversation-attention-telegram.test.ts +2 -2
  16. package/src/__tests__/deterministic-verification-control-plane.test.ts +6 -6
  17. package/src/__tests__/guardian-actions-endpoint.test.ts +7 -6
  18. package/src/__tests__/guardian-decision-primitive-canonical.test.ts +57 -12
  19. package/src/__tests__/guardian-grant-minting.test.ts +24 -24
  20. package/src/__tests__/guardian-principal-id-roundtrip.test.ts +205 -0
  21. package/src/__tests__/guardian-routing-invariants.test.ts +64 -25
  22. package/src/__tests__/guardian-routing-state.test.ts +4 -4
  23. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +2 -2
  24. package/src/__tests__/inbound-invite-redemption.test.ts +8 -8
  25. package/src/__tests__/memory-retrieval.benchmark.test.ts +22 -47
  26. package/src/__tests__/no-is-trusted-guard.test.ts +77 -0
  27. package/src/__tests__/non-member-access-request.test.ts +50 -47
  28. package/src/__tests__/relay-server.test.ts +71 -0
  29. package/src/__tests__/send-endpoint-busy.test.ts +6 -0
  30. package/src/__tests__/session-tool-setup-tools-disabled.test.ts +155 -0
  31. package/src/__tests__/skill-feature-flags-integration.test.ts +1 -0
  32. package/src/__tests__/skill-projection.benchmark.test.ts +66 -2
  33. package/src/__tests__/system-prompt.test.ts +1 -0
  34. package/src/__tests__/tool-approval-handler.test.ts +1 -1
  35. package/src/__tests__/tool-grant-request-escalation.test.ts +9 -2
  36. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +8 -1
  37. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +22 -22
  38. package/src/__tests__/trusted-contact-multichannel.test.ts +4 -4
  39. package/src/__tests__/trusted-contact-verification.test.ts +10 -10
  40. package/src/approvals/guardian-decision-primitive.ts +29 -25
  41. package/src/approvals/guardian-request-resolvers.ts +9 -5
  42. package/src/calls/call-pointer-message-composer.ts +27 -85
  43. package/src/calls/call-pointer-messages.ts +54 -21
  44. package/src/calls/guardian-dispatch.ts +30 -0
  45. package/src/calls/relay-server.ts +13 -13
  46. package/src/config/system-prompt.ts +10 -3
  47. package/src/config/templates/BOOTSTRAP.md +6 -5
  48. package/src/config/templates/USER.md +1 -0
  49. package/src/config/user-reference.ts +44 -0
  50. package/src/daemon/handlers/guardian-actions.ts +5 -2
  51. package/src/daemon/handlers/sessions.ts +8 -3
  52. package/src/daemon/lifecycle.ts +109 -3
  53. package/src/daemon/server.ts +32 -24
  54. package/src/daemon/session-agent-loop.ts +4 -3
  55. package/src/daemon/session-lifecycle.ts +1 -9
  56. package/src/daemon/session-process.ts +2 -2
  57. package/src/daemon/session-runtime-assembly.ts +2 -0
  58. package/src/daemon/session-tool-setup.ts +10 -0
  59. package/src/daemon/session.ts +1 -0
  60. package/src/memory/canonical-guardian-store.ts +40 -0
  61. package/src/memory/conversation-crud.ts +26 -0
  62. package/src/memory/conversation-store.ts +1 -0
  63. package/src/memory/db-init.ts +8 -0
  64. package/src/memory/guardian-bindings.ts +4 -0
  65. package/src/memory/job-handlers/backfill.ts +2 -9
  66. package/src/memory/migrations/125-guardian-principal-id-columns.ts +19 -0
  67. package/src/memory/migrations/126-backfill-guardian-principal-id.ts +210 -0
  68. package/src/memory/migrations/index.ts +2 -0
  69. package/src/memory/migrations/registry.ts +5 -0
  70. package/src/memory/schema.ts +3 -0
  71. package/src/notifications/copy-composer.ts +2 -2
  72. package/src/runtime/access-request-helper.ts +43 -28
  73. package/src/runtime/actor-trust-resolver.ts +19 -14
  74. package/src/runtime/channel-guardian-service.ts +6 -0
  75. package/src/runtime/guardian-context-resolver.ts +6 -2
  76. package/src/runtime/guardian-reply-router.ts +33 -16
  77. package/src/runtime/guardian-vellum-migration.ts +29 -5
  78. package/src/runtime/http-types.ts +0 -13
  79. package/src/runtime/local-actor-identity.ts +19 -13
  80. package/src/runtime/middleware/actor-token.ts +2 -2
  81. package/src/runtime/routes/channel-delivery-routes.ts +5 -5
  82. package/src/runtime/routes/conversation-routes.ts +45 -35
  83. package/src/runtime/routes/guardian-action-routes.ts +7 -1
  84. package/src/runtime/routes/guardian-approval-interception.ts +52 -52
  85. package/src/runtime/routes/guardian-bootstrap-routes.ts +1 -0
  86. package/src/runtime/routes/inbound-conversation.ts +7 -7
  87. package/src/runtime/routes/inbound-message-handler.ts +105 -94
  88. package/src/runtime/tool-grant-request-helper.ts +1 -0
  89. package/src/util/logger.ts +10 -0
  90. package/src/daemon/call-pointer-generators.ts +0 -59
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Guard test: `isTrusted` must not appear in production code.
3
+ *
4
+ * The authorization model was migrated from a boolean `isTrusted` flag to
5
+ * principal-based authorization (`guardianPrincipalId` matching). This guard
6
+ * ensures the legacy pattern is never reintroduced in production source files.
7
+ *
8
+ * The invariant: `actor.guardianPrincipalId === request.guardianPrincipalId`
9
+ * (with cross-channel fallback via the vellum canonical principal).
10
+ *
11
+ * Allowed exceptions:
12
+ * - Variable names like `isTrustedActor` or `isTrustedContact` that refer
13
+ * to trust-class checks (e.g. `trustClass === 'guardian'`), NOT to a
14
+ * boolean `isTrusted` property on ActorContext.
15
+ * - Test files (__tests__/) — may reference `isTrusted` in test descriptions
16
+ * or comments about the migration.
17
+ */
18
+
19
+ import { execSync } from 'node:child_process';
20
+ import { resolve } from 'node:path';
21
+
22
+ import { describe, expect, test } from 'bun:test';
23
+
24
+ const repoRoot = resolve(__dirname, '..', '..', '..');
25
+
26
+ describe('isTrusted guard', () => {
27
+ test('isTrusted property must not exist in production ActorContext usage', () => {
28
+ // Search for `isTrusted` used as a property (e.g., `.isTrusted`, `isTrusted:`,
29
+ // `isTrusted =`) in production TypeScript files, excluding tests, node_modules,
30
+ // and the allowed trust-class variable pattern.
31
+ const raw = execSync(
32
+ [
33
+ 'grep -rn "isTrusted" assistant/src/ --include="*.ts"',
34
+ 'grep -v "__tests__"',
35
+ 'grep -v "node_modules"',
36
+ ].join(' | ') + ' || true',
37
+ { encoding: 'utf-8', cwd: repoRoot },
38
+ );
39
+
40
+ // Filter in JS: strip allowed token names from each line, then check if
41
+ // `isTrusted` still appears. This avoids the grep -v approach which could
42
+ // mask forbidden usage on lines that also contain allowed tokens.
43
+ const ALLOWED_TOKENS = ['isTrustedActor', 'isTrustedContact', 'isTrustedTrustClass'];
44
+ const offending = raw
45
+ .trim()
46
+ .split('\n')
47
+ .filter((line) => {
48
+ if (!line) return false;
49
+ let stripped = line;
50
+ for (const token of ALLOWED_TOKENS) {
51
+ stripped = stripped.replaceAll(token, '');
52
+ }
53
+ return stripped.includes('isTrusted');
54
+ });
55
+
56
+ if (offending.length > 0) {
57
+ throw new Error(
58
+ 'Found `isTrusted` references in production code. Authorization must use ' +
59
+ '`guardianPrincipalId` matching instead. Offending lines:\n' +
60
+ offending.join('\n'),
61
+ );
62
+ }
63
+ });
64
+
65
+ test('ActorContext interface must not declare isTrusted field', () => {
66
+ // Verify the ActorContext type definition does not include isTrusted
67
+ const result = execSync(
68
+ [
69
+ 'grep -n "isTrusted" assistant/src/approvals/guardian-request-resolvers.ts',
70
+ 'true',
71
+ ].join(' || '),
72
+ { encoding: 'utf-8', cwd: repoRoot },
73
+ );
74
+
75
+ expect(result.trim()).toBe('');
76
+ });
77
+ });
@@ -138,12 +138,12 @@ function buildInboundRequest(overrides: Record<string, unknown> = {}): Request {
138
138
  const body: Record<string, unknown> = {
139
139
  sourceChannel: 'telegram',
140
140
  interface: 'telegram',
141
- externalChatId: 'chat-123',
141
+ conversationExternalId: 'chat-123',
142
142
  externalMessageId: `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
143
143
  content: 'Hello, can I use this assistant?',
144
- senderExternalUserId: 'user-unknown-456',
145
- senderName: 'Alice Unknown',
146
- senderUsername: 'alice_unknown',
144
+ actorExternalId: 'user-unknown-456',
145
+ actorDisplayName: 'Alice Unknown',
146
+ actorUsername: 'alice_unknown',
147
147
  replyCallbackUrl: 'http://localhost:7830/deliver/telegram',
148
148
  ...overrides,
149
149
  };
@@ -206,8 +206,8 @@ describe('non-member access request notification', () => {
206
206
  expect(emitSignalCalls[0].sourceEventName).toBe('ingress.access_request');
207
207
  expect(emitSignalCalls[0].sourceChannel).toBe('telegram');
208
208
  const payload = emitSignalCalls[0].contextPayload as Record<string, unknown>;
209
- expect(payload.senderExternalUserId).toBe('user-unknown-456');
210
- expect(payload.senderName).toBe('Alice Unknown');
209
+ expect(payload.actorExternalId).toBe('user-unknown-456');
210
+ expect(payload.actorDisplayName).toBe('Alice Unknown');
211
211
 
212
212
  // A canonical access request was created
213
213
  const pending = listCanonicalGuardianRequests({
@@ -258,9 +258,9 @@ describe('non-member access request notification', () => {
258
258
  expect(pending.length).toBe(1);
259
259
  });
260
260
 
261
- test('access request is created and signal emitted even without same-channel guardian binding', async () => {
262
- // No guardian binding on any channel — access request should still be
263
- // created and notification signal emitted (null guardianExternalUserId).
261
+ test('access request is created with self-healed principal even without same-channel guardian binding', async () => {
262
+ // No guardian binding on any channel — self-heal creates a vellum binding
263
+ // so the access_request (now decisionable) has a guardianPrincipalId.
264
264
  const req = buildInboundRequest();
265
265
  const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
266
266
  const json = await resp.json() as Record<string, unknown>;
@@ -276,7 +276,7 @@ describe('non-member access request notification', () => {
276
276
  expect(emitSignalCalls.length).toBe(1);
277
277
  expect(emitSignalCalls[0].sourceEventName).toBe('ingress.access_request');
278
278
 
279
- // Canonical request was created with null guardianExternalUserId
279
+ // Canonical request was created with a self-healed principal
280
280
  const pending = listCanonicalGuardianRequests({
281
281
  status: 'pending',
282
282
  requesterExternalUserId: 'user-unknown-456',
@@ -284,7 +284,9 @@ describe('non-member access request notification', () => {
284
284
  kind: 'access_request',
285
285
  });
286
286
  expect(pending.length).toBe(1);
287
- expect(pending[0].guardianExternalUserId).toBeNull();
287
+ // Self-heal bootstraps a vellum binding — guardianExternalUserId is now set
288
+ expect(pending[0].guardianExternalUserId).toBeDefined();
289
+ expect(pending[0].guardianPrincipalId).toBeDefined();
288
290
  });
289
291
 
290
292
  test('cross-channel fallback: SMS guardian binding resolves for Telegram access request', async () => {
@@ -319,7 +321,7 @@ describe('non-member access request notification', () => {
319
321
  expect(pending[0].guardianExternalUserId).toBe('guardian-sms-user');
320
322
  });
321
323
 
322
- test('no notification when senderExternalUserId is absent', async () => {
324
+ test('no notification when actorExternalId is absent', async () => {
323
325
  createBinding({
324
326
  assistantId: 'self',
325
327
  channel: 'telegram',
@@ -327,13 +329,12 @@ describe('non-member access request notification', () => {
327
329
  guardianDeliveryChatId: 'guardian-chat-789',
328
330
  });
329
331
 
330
- // Message without senderExternalUserIdcan't identify the requester.
331
- // The ACL check requires senderExternalUserId to look up members,
332
- // so without it the non-member gate is bypassed entirely.
332
+ // Message without actorExternalIdthe handler returns BAD_REQUEST.
333
333
  const req = buildInboundRequest({
334
- senderExternalUserId: undefined,
334
+ actorExternalId: undefined,
335
335
  });
336
- await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
336
+ const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
337
+ expect(resp.status).toBe(400);
337
338
 
338
339
  // No access request notification should fire (no identity to notify about)
339
340
  expect(emitSignalCalls.length).toBe(0);
@@ -345,12 +346,12 @@ describe('access-request-helper unit tests', () => {
345
346
  resetState();
346
347
  });
347
348
 
348
- test('notifyGuardianOfAccessRequest returns no_sender_id when senderExternalUserId is absent', () => {
349
+ test('notifyGuardianOfAccessRequest returns no_sender_id when actorExternalId is absent', () => {
349
350
  const result = notifyGuardianOfAccessRequest({
350
351
  canonicalAssistantId: 'self',
351
352
  sourceChannel: 'telegram',
352
- externalChatId: 'chat-123',
353
- senderExternalUserId: undefined,
353
+ conversationExternalId: 'chat-123',
354
+ actorExternalId: undefined,
354
355
  });
355
356
 
356
357
  expect(result.notified).toBe(false);
@@ -363,13 +364,13 @@ describe('access-request-helper unit tests', () => {
363
364
  expect(pending.length).toBe(0);
364
365
  });
365
366
 
366
- test('notifyGuardianOfAccessRequest creates request with null guardianExternalUserId when no binding exists', () => {
367
+ test('notifyGuardianOfAccessRequest creates request with self-healed principal when no binding exists', () => {
367
368
  const result = notifyGuardianOfAccessRequest({
368
369
  canonicalAssistantId: 'self',
369
370
  sourceChannel: 'telegram',
370
- externalChatId: 'chat-123',
371
- senderExternalUserId: 'unknown-user',
372
- senderName: 'Bob',
371
+ conversationExternalId: 'chat-123',
372
+ actorExternalId: 'unknown-user',
373
+ actorDisplayName: 'Bob',
373
374
  });
374
375
 
375
376
  expect(result.notified).toBe(true);
@@ -383,7 +384,9 @@ describe('access-request-helper unit tests', () => {
383
384
  kind: 'access_request',
384
385
  });
385
386
  expect(pending.length).toBe(1);
386
- expect(pending[0].guardianExternalUserId).toBeNull();
387
+ // Self-heal bootstraps a vellum binding
388
+ expect(pending[0].guardianExternalUserId).toBeDefined();
389
+ expect(pending[0].guardianPrincipalId).toBeDefined();
387
390
 
388
391
  // Signal was emitted
389
392
  expect(emitSignalCalls.length).toBe(1);
@@ -401,8 +404,8 @@ describe('access-request-helper unit tests', () => {
401
404
  const result = notifyGuardianOfAccessRequest({
402
405
  canonicalAssistantId: 'self',
403
406
  sourceChannel: 'telegram',
404
- externalChatId: 'tg-chat',
405
- senderExternalUserId: 'unknown-tg-user',
407
+ conversationExternalId: 'tg-chat',
408
+ actorExternalId: 'unknown-tg-user',
406
409
  });
407
410
 
408
411
  expect(result.notified).toBe(true);
@@ -438,8 +441,8 @@ describe('access-request-helper unit tests', () => {
438
441
  const result = notifyGuardianOfAccessRequest({
439
442
  canonicalAssistantId: 'self',
440
443
  sourceChannel: 'telegram',
441
- externalChatId: 'chat-123',
442
- senderExternalUserId: 'unknown-user',
444
+ conversationExternalId: 'chat-123',
445
+ actorExternalId: 'unknown-user',
443
446
  });
444
447
 
445
448
  expect(result.notified).toBe(true);
@@ -457,13 +460,13 @@ describe('access-request-helper unit tests', () => {
457
460
  expect(payload.guardianBindingChannel).toBe('telegram');
458
461
  });
459
462
 
460
- test('notifyGuardianOfAccessRequest for voice channel includes senderName in contextPayload', () => {
463
+ test('notifyGuardianOfAccessRequest for voice channel includes actorDisplayName in contextPayload', () => {
461
464
  const result = notifyGuardianOfAccessRequest({
462
465
  canonicalAssistantId: 'self',
463
466
  sourceChannel: 'voice',
464
- externalChatId: '+15559998888',
465
- senderExternalUserId: '+15559998888',
466
- senderName: 'Alice Caller',
467
+ conversationExternalId: '+15559998888',
468
+ actorExternalId: '+15559998888',
469
+ actorDisplayName: 'Alice Caller',
467
470
  });
468
471
 
469
472
  expect(result.notified).toBe(true);
@@ -471,8 +474,8 @@ describe('access-request-helper unit tests', () => {
471
474
 
472
475
  const payload = emitSignalCalls[0].contextPayload as Record<string, unknown>;
473
476
  expect(payload.sourceChannel).toBe('voice');
474
- expect(payload.senderName).toBe('Alice Caller');
475
- expect(payload.senderExternalUserId).toBe('+15559998888');
477
+ expect(payload.actorDisplayName).toBe('Alice Caller');
478
+ expect(payload.actorExternalId).toBe('+15559998888');
476
479
  expect(payload.senderIdentifier).toBe('Alice Caller');
477
480
 
478
481
  // Canonical request should exist
@@ -489,9 +492,9 @@ describe('access-request-helper unit tests', () => {
489
492
  const result = notifyGuardianOfAccessRequest({
490
493
  canonicalAssistantId: 'self',
491
494
  sourceChannel: 'telegram',
492
- externalChatId: 'chat-123',
493
- senderExternalUserId: 'unknown-user',
494
- senderName: 'Test User',
495
+ conversationExternalId: 'chat-123',
496
+ actorExternalId: 'unknown-user',
497
+ actorDisplayName: 'Test User',
495
498
  });
496
499
 
497
500
  expect(result.notified).toBe(true);
@@ -507,9 +510,9 @@ describe('access-request-helper unit tests', () => {
507
510
  const result = notifyGuardianOfAccessRequest({
508
511
  canonicalAssistantId: 'self',
509
512
  sourceChannel: 'telegram',
510
- externalChatId: 'chat-123',
511
- senderExternalUserId: 'revoked-user',
512
- senderName: 'Revoked User',
513
+ conversationExternalId: 'chat-123',
514
+ actorExternalId: 'revoked-user',
515
+ actorDisplayName: 'Revoked User',
513
516
  previousMemberStatus: 'revoked',
514
517
  });
515
518
 
@@ -544,9 +547,9 @@ describe('access-request-helper unit tests', () => {
544
547
  const result = notifyGuardianOfAccessRequest({
545
548
  canonicalAssistantId: 'self',
546
549
  sourceChannel: 'voice',
547
- externalChatId: '+15556667777',
548
- senderExternalUserId: '+15556667777',
549
- senderName: 'Noah',
550
+ conversationExternalId: '+15556667777',
551
+ actorExternalId: '+15556667777',
552
+ actorDisplayName: 'Noah',
550
553
  });
551
554
 
552
555
  expect(result.notified).toBe(true);
@@ -584,9 +587,9 @@ describe('access-request-helper unit tests', () => {
584
587
  const result = notifyGuardianOfAccessRequest({
585
588
  canonicalAssistantId: 'self',
586
589
  sourceChannel: 'telegram',
587
- externalChatId: 'chat-123',
588
- senderExternalUserId: 'unknown-user',
589
- senderName: 'Alice',
590
+ conversationExternalId: 'chat-123',
591
+ actorExternalId: 'unknown-user',
592
+ actorDisplayName: 'Alice',
590
593
  });
591
594
 
592
595
  expect(result.notified).toBe(true);
@@ -137,6 +137,7 @@ import {
137
137
  createCallSession,
138
138
  getCallEvents,
139
139
  getCallSession,
140
+ updateCallSession,
140
141
  } from '../calls/call-store.js';
141
142
  import type { RelayWebSocketData } from '../calls/relay-server.js';
142
143
  import { activeRelayConnections,RelayConnection } from '../calls/relay-server.js';
@@ -3186,4 +3187,74 @@ describe('relay-server', () => {
3186
3187
  mockConfig.calls.userConsultTimeoutSeconds = 120;
3187
3188
  relay.destroy();
3188
3189
  });
3190
+
3191
+ // ── Pointer message regression tests for non-guardian paths ───────
3192
+
3193
+ test('normal relay close (1000) writes completed pointer to origin conversation', async () => {
3194
+ ensureConversation('conv-relay-ptr-complete');
3195
+ ensureConversation('conv-relay-ptr-complete-origin');
3196
+ const session = createCallSession({
3197
+ conversationId: 'conv-relay-ptr-complete',
3198
+ provider: 'twilio',
3199
+ fromNumber: '+15551111111',
3200
+ toNumber: '+15559876543',
3201
+ assistantId: 'self',
3202
+ initiatedFromConversationId: 'conv-relay-ptr-complete-origin',
3203
+ });
3204
+ updateCallSession(session.id, { status: 'in_progress', startedAt: Date.now() - 30_000 });
3205
+
3206
+ const { relay } = createMockWs(session.id);
3207
+
3208
+ await relay.handleMessage(JSON.stringify({
3209
+ type: 'setup',
3210
+ callSid: 'CA_relay_ptr_complete',
3211
+ from: '+15551111111',
3212
+ to: '+15559876543',
3213
+ customParameters: {},
3214
+ }));
3215
+
3216
+ relay.handleTransportClosed(1000, 'normal');
3217
+ await new Promise((r) => setTimeout(r, 100));
3218
+
3219
+ const text = getLatestAssistantText('conv-relay-ptr-complete-origin');
3220
+ expect(text).not.toBeNull();
3221
+ expect(text!).toContain('+15559876543');
3222
+ expect(text!).toContain('completed');
3223
+
3224
+ relay.destroy();
3225
+ });
3226
+
3227
+ test('abnormal relay close writes failed pointer to origin conversation', async () => {
3228
+ ensureConversation('conv-relay-ptr-fail');
3229
+ ensureConversation('conv-relay-ptr-fail-origin');
3230
+ const session = createCallSession({
3231
+ conversationId: 'conv-relay-ptr-fail',
3232
+ provider: 'twilio',
3233
+ fromNumber: '+15551111111',
3234
+ toNumber: '+15559876543',
3235
+ assistantId: 'self',
3236
+ initiatedFromConversationId: 'conv-relay-ptr-fail-origin',
3237
+ });
3238
+ updateCallSession(session.id, { status: 'in_progress' });
3239
+
3240
+ const { relay } = createMockWs(session.id);
3241
+
3242
+ await relay.handleMessage(JSON.stringify({
3243
+ type: 'setup',
3244
+ callSid: 'CA_relay_ptr_fail',
3245
+ from: '+15551111111',
3246
+ to: '+15559876543',
3247
+ customParameters: {},
3248
+ }));
3249
+
3250
+ relay.handleTransportClosed(1006, 'abnormal');
3251
+ await new Promise((r) => setTimeout(r, 100));
3252
+
3253
+ const text = getLatestAssistantText('conv-relay-ptr-fail-origin');
3254
+ expect(text).not.toBeNull();
3255
+ expect(text!).toContain('+15559876543');
3256
+ expect(text!).toContain('failed');
3257
+
3258
+ relay.destroy();
3259
+ });
3189
3260
  });
@@ -333,6 +333,7 @@ describe('POST /v1/messages — queue-if-busy and hub publishing', () => {
333
333
  sourceChannel: 'vellum',
334
334
  conversationId,
335
335
  toolName: 'call_start',
336
+ guardianPrincipalId: 'test-principal-id',
336
337
  status: 'pending',
337
338
  requestCode: 'ABC123',
338
339
  expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(),
@@ -389,6 +390,7 @@ describe('POST /v1/messages — queue-if-busy and hub publishing', () => {
389
390
  conversationId,
390
391
  toolName: 'call_start',
391
392
  status: 'pending',
393
+ guardianPrincipalId: 'test-principal-id',
392
394
  requestCode: 'C0FFEE',
393
395
  expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(),
394
396
  });
@@ -450,6 +452,7 @@ describe('POST /v1/messages — queue-if-busy and hub publishing', () => {
450
452
  conversationId,
451
453
  toolName: 'call_start',
452
454
  status: 'pending',
455
+ guardianPrincipalId: 'test-principal-id',
453
456
  requestCode: 'DEF456',
454
457
  expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(),
455
458
  });
@@ -505,6 +508,7 @@ describe('POST /v1/messages — queue-if-busy and hub publishing', () => {
505
508
  conversationId,
506
509
  toolName: 'call_start',
507
510
  status: 'pending',
511
+ guardianPrincipalId: 'test-principal-id',
508
512
  requestCode: 'Q2D456',
509
513
  expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(),
510
514
  });
@@ -560,6 +564,7 @@ describe('POST /v1/messages — queue-if-busy and hub publishing', () => {
560
564
  conversationId,
561
565
  toolName: 'call_start',
562
566
  status: 'pending',
567
+ guardianPrincipalId: 'test-principal-id',
563
568
  requestCode: 'GHI789',
564
569
  expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(),
565
570
  });
@@ -613,6 +618,7 @@ describe('POST /v1/messages — queue-if-busy and hub publishing', () => {
613
618
  conversationId,
614
619
  toolName: 'call_start',
615
620
  status: 'pending',
621
+ guardianPrincipalId: 'test-principal-id',
616
622
  requestCode: 'JKL012',
617
623
  expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(),
618
624
  });
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Tests for the toolsDisabledDepth mechanism in createResolveToolsCallback.
3
+ *
4
+ * Covers:
5
+ * - Resolver returns empty tools when toolsDisabledDepth > 0
6
+ * - Resolver returns normal tools when toolsDisabledDepth is back to 0
7
+ * - allowedToolNames is cleared while disabled and restored on next normal call
8
+ * - Depth counter survives overlapping increments/decrements
9
+ */
10
+
11
+ import { describe, expect, mock, test } from 'bun:test';
12
+
13
+ import type { SkillProjectionCache } from '../daemon/session-skill-tools.js';
14
+ import type { Message, ToolDefinition } from '../providers/types.js';
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Mocks — must be set up before importing the module under test
18
+ // ---------------------------------------------------------------------------
19
+
20
+ mock.module('../daemon/session-skill-tools.js', () => ({
21
+ projectSkillTools: mock((_history: Message[], _opts: unknown) => ({
22
+ allowedToolNames: new Set<string>(),
23
+ toolDefinitions: [],
24
+ })),
25
+ }));
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Import after mocks
29
+ // ---------------------------------------------------------------------------
30
+
31
+ import {
32
+ createResolveToolsCallback,
33
+ type SkillProjectionContext,
34
+ } from '../daemon/session-tool-setup.js';
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Helpers
38
+ // ---------------------------------------------------------------------------
39
+
40
+ function makeToolDef(name: string): ToolDefinition {
41
+ return { name, description: `${name} tool`, input_schema: {} };
42
+ }
43
+
44
+ function makeCtx(overrides: Partial<SkillProjectionContext> = {}): SkillProjectionContext {
45
+ return {
46
+ skillProjectionState: new Map(),
47
+ skillProjectionCache: {} as SkillProjectionCache,
48
+ coreToolNames: new Set(['tool_a', 'tool_b']),
49
+ toolsDisabledDepth: 0,
50
+ ...overrides,
51
+ };
52
+ }
53
+
54
+ const EMPTY_HISTORY: Message[] = [];
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Tests
58
+ // ---------------------------------------------------------------------------
59
+
60
+ describe('createResolveToolsCallback — toolsDisabledDepth', () => {
61
+ test('returns undefined when no tool definitions provided', () => {
62
+ const ctx = makeCtx();
63
+ const resolve = createResolveToolsCallback([], ctx);
64
+ expect(resolve).toBeUndefined();
65
+ });
66
+
67
+ test('returns normal tools when toolsDisabledDepth is 0', () => {
68
+ const toolDefs = [makeToolDef('tool_a'), makeToolDef('tool_b')];
69
+ const ctx = makeCtx();
70
+ const resolve = createResolveToolsCallback(toolDefs, ctx)!;
71
+
72
+ const tools = resolve(EMPTY_HISTORY);
73
+ expect(tools.length).toBeGreaterThanOrEqual(2);
74
+ expect(tools.map((t) => t.name)).toContain('tool_a');
75
+ expect(tools.map((t) => t.name)).toContain('tool_b');
76
+ expect(ctx.allowedToolNames?.size).toBeGreaterThan(0);
77
+ });
78
+
79
+ test('returns empty tools when toolsDisabledDepth > 0', () => {
80
+ const toolDefs = [makeToolDef('tool_a'), makeToolDef('tool_b')];
81
+ const ctx = makeCtx({ toolsDisabledDepth: 1 });
82
+ const resolve = createResolveToolsCallback(toolDefs, ctx)!;
83
+
84
+ const tools = resolve(EMPTY_HISTORY);
85
+ expect(tools).toEqual([]);
86
+ expect(ctx.allowedToolNames).toEqual(new Set());
87
+ });
88
+
89
+ test('returns empty tools when toolsDisabledDepth is > 1 (overlapping callers)', () => {
90
+ const toolDefs = [makeToolDef('tool_a')];
91
+ const ctx = makeCtx({ toolsDisabledDepth: 3 });
92
+ const resolve = createResolveToolsCallback(toolDefs, ctx)!;
93
+
94
+ const tools = resolve(EMPTY_HISTORY);
95
+ expect(tools).toEqual([]);
96
+ expect(ctx.allowedToolNames).toEqual(new Set());
97
+ });
98
+
99
+ test('restores normal tools after depth returns to 0', () => {
100
+ const toolDefs = [makeToolDef('tool_a'), makeToolDef('tool_b')];
101
+ const ctx = makeCtx({ toolsDisabledDepth: 0 });
102
+ const resolve = createResolveToolsCallback(toolDefs, ctx)!;
103
+
104
+ // First call: normal
105
+ let tools = resolve(EMPTY_HISTORY);
106
+ expect(tools.length).toBeGreaterThanOrEqual(2);
107
+
108
+ // Simulate pointer processor incrementing depth
109
+ ctx.toolsDisabledDepth++;
110
+ tools = resolve(EMPTY_HISTORY);
111
+ expect(tools).toEqual([]);
112
+ expect(ctx.allowedToolNames).toEqual(new Set());
113
+
114
+ // Simulate pointer processor decrementing depth (back to 0)
115
+ ctx.toolsDisabledDepth--;
116
+ tools = resolve(EMPTY_HISTORY);
117
+ expect(tools.length).toBeGreaterThanOrEqual(2);
118
+ expect(ctx.allowedToolNames!.has('tool_a')).toBe(true);
119
+ expect(ctx.allowedToolNames!.has('tool_b')).toBe(true);
120
+ });
121
+
122
+ test('overlapping increments keep tools disabled until all decremented', () => {
123
+ const toolDefs = [makeToolDef('tool_a')];
124
+ const ctx = makeCtx();
125
+ const resolve = createResolveToolsCallback(toolDefs, ctx)!;
126
+
127
+ // Two overlapping pointer requests
128
+ ctx.toolsDisabledDepth++;
129
+ ctx.toolsDisabledDepth++;
130
+ expect(resolve(EMPTY_HISTORY)).toEqual([]);
131
+
132
+ // First one finishes
133
+ ctx.toolsDisabledDepth--;
134
+ expect(ctx.toolsDisabledDepth).toBe(1);
135
+ expect(resolve(EMPTY_HISTORY)).toEqual([]);
136
+
137
+ // Second one finishes
138
+ ctx.toolsDisabledDepth--;
139
+ expect(ctx.toolsDisabledDepth).toBe(0);
140
+ const tools = resolve(EMPTY_HISTORY);
141
+ expect(tools.length).toBeGreaterThanOrEqual(1);
142
+ });
143
+
144
+ test('clears allowedToolNames on every disabled call', () => {
145
+ const toolDefs = [makeToolDef('tool_a')];
146
+ const ctx = makeCtx({ toolsDisabledDepth: 1 });
147
+ const resolve = createResolveToolsCallback(toolDefs, ctx)!;
148
+
149
+ // Pre-populate allowedToolNames as if a previous normal turn set them
150
+ ctx.allowedToolNames = new Set(['tool_a', 'skill_x']);
151
+
152
+ resolve(EMPTY_HISTORY);
153
+ expect(ctx.allowedToolNames).toEqual(new Set());
154
+ });
155
+ });
@@ -66,6 +66,7 @@ mock.module('../config/loader.js', () => ({
66
66
 
67
67
  mock.module('../config/user-reference.js', () => ({
68
68
  resolveUserReference: () => 'TestUser',
69
+ resolveUserPronouns: () => null,
69
70
  }));
70
71
 
71
72
  mock.module('../tools/credentials/metadata-store.js', () => ({