@vellumai/assistant 0.4.2 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. package/.env.example +3 -0
  2. package/ARCHITECTURE.md +124 -10
  3. package/README.md +43 -35
  4. package/docs/trusted-contact-access.md +20 -0
  5. package/package.json +1 -1
  6. package/scripts/ipc/generate-swift.ts +1 -0
  7. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +58 -120
  8. package/src/__tests__/access-request-decision.test.ts +0 -1
  9. package/src/__tests__/actor-token-service.test.ts +1099 -0
  10. package/src/__tests__/agent-loop.test.ts +51 -0
  11. package/src/__tests__/approval-routes-http.test.ts +2 -0
  12. package/src/__tests__/assistant-events-sse-hardening.test.ts +7 -5
  13. package/src/__tests__/assistant-id-boundary-guard.test.ts +415 -0
  14. package/src/__tests__/call-controller.test.ts +49 -0
  15. package/src/__tests__/call-pointer-message-composer.test.ts +171 -0
  16. package/src/__tests__/call-pointer-messages.test.ts +93 -3
  17. package/src/__tests__/call-pointer-no-hardcoded-copy.guard.test.ts +42 -0
  18. package/src/__tests__/call-routes-http.test.ts +0 -25
  19. package/src/__tests__/callback-handoff-copy.test.ts +186 -0
  20. package/src/__tests__/channel-approval-routes.test.ts +133 -12
  21. package/src/__tests__/channel-guardian.test.ts +0 -86
  22. package/src/__tests__/channel-readiness-service.test.ts +10 -16
  23. package/src/__tests__/checker.test.ts +33 -12
  24. package/src/__tests__/config-schema.test.ts +6 -0
  25. package/src/__tests__/confirmation-request-guardian-bridge.test.ts +410 -0
  26. package/src/__tests__/conversation-routes-guardian-reply.test.ts +256 -0
  27. package/src/__tests__/conversation-routes.test.ts +12 -3
  28. package/src/__tests__/credential-security-invariants.test.ts +1 -1
  29. package/src/__tests__/daemon-server-session-init.test.ts +4 -0
  30. package/src/__tests__/deterministic-verification-control-plane.test.ts +0 -1
  31. package/src/__tests__/guardian-actions-endpoint.test.ts +39 -13
  32. package/src/__tests__/guardian-dispatch.test.ts +8 -0
  33. package/src/__tests__/guardian-outbound-http.test.ts +4 -5
  34. package/src/__tests__/guardian-question-mode.test.ts +200 -0
  35. package/src/__tests__/guardian-routing-invariants.test.ts +178 -0
  36. package/src/__tests__/guardian-routing-state.test.ts +525 -0
  37. package/src/__tests__/handle-user-message-secret-resume.test.ts +2 -0
  38. package/src/__tests__/handlers-telegram-config.test.ts +0 -83
  39. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +55 -0
  40. package/src/__tests__/headless-browser-navigate.test.ts +2 -0
  41. package/src/__tests__/inbound-invite-redemption.test.ts +0 -1
  42. package/src/__tests__/ingress-routes-http.test.ts +55 -0
  43. package/src/__tests__/ipc-snapshot.test.ts +18 -51
  44. package/src/__tests__/non-member-access-request.test.ts +159 -9
  45. package/src/__tests__/notification-decision-fallback.test.ts +129 -4
  46. package/src/__tests__/notification-decision-strategy.test.ts +106 -2
  47. package/src/__tests__/notification-guardian-path.test.ts +3 -0
  48. package/src/__tests__/recording-intent-handler.test.ts +1 -0
  49. package/src/__tests__/relay-server.test.ts +1475 -33
  50. package/src/__tests__/send-endpoint-busy.test.ts +5 -0
  51. package/src/__tests__/session-agent-loop.test.ts +1 -0
  52. package/src/__tests__/session-confirmation-signals.test.ts +523 -0
  53. package/src/__tests__/session-init.benchmark.test.ts +0 -2
  54. package/src/__tests__/session-runtime-assembly.test.ts +4 -1
  55. package/src/__tests__/session-surfaces-task-progress.test.ts +44 -1
  56. package/src/__tests__/session-tool-setup-app-refresh.test.ts +81 -2
  57. package/src/__tests__/session-tool-setup-memory-scope.test.ts +1 -1
  58. package/src/__tests__/session-tool-setup-side-effect-flag.test.ts +1 -1
  59. package/src/__tests__/tool-executor.test.ts +21 -2
  60. package/src/__tests__/tool-grant-request-escalation.test.ts +333 -27
  61. package/src/__tests__/trusted-contact-approval-notifier.test.ts +678 -0
  62. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +1064 -0
  63. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +11 -1
  64. package/src/__tests__/trusted-contact-multichannel.test.ts +0 -1
  65. package/src/__tests__/trusted-contact-verification.test.ts +0 -1
  66. package/src/__tests__/twilio-config.test.ts +2 -13
  67. package/src/__tests__/twilio-routes.test.ts +4 -3
  68. package/src/__tests__/update-bulletin.test.ts +0 -1
  69. package/src/agent/loop.ts +1 -1
  70. package/src/approvals/guardian-decision-primitive.ts +12 -3
  71. package/src/approvals/guardian-request-resolvers.ts +169 -11
  72. package/src/calls/call-constants.ts +29 -0
  73. package/src/calls/call-controller.ts +11 -3
  74. package/src/calls/call-domain.ts +33 -11
  75. package/src/calls/call-pointer-message-composer.ts +154 -0
  76. package/src/calls/call-pointer-messages.ts +106 -27
  77. package/src/calls/guardian-dispatch.ts +4 -2
  78. package/src/calls/relay-server.ts +921 -112
  79. package/src/calls/twilio-config.ts +4 -11
  80. package/src/calls/twilio-routes.ts +4 -6
  81. package/src/calls/types.ts +3 -1
  82. package/src/calls/voice-session-bridge.ts +4 -3
  83. package/src/cli/core-commands.ts +7 -4
  84. package/src/cli.ts +5 -4
  85. package/src/config/bundled-skills/agentmail/SKILL.md +4 -0
  86. package/src/config/bundled-skills/app-builder/SKILL.md +309 -10
  87. package/src/config/bundled-skills/app-builder/TOOLS.json +1 -1
  88. package/src/config/bundled-skills/email-setup/SKILL.md +1 -1
  89. package/src/config/bundled-skills/google-oauth-setup/SKILL.md +105 -81
  90. package/src/config/bundled-skills/messaging/SKILL.md +61 -12
  91. package/src/config/bundled-skills/messaging/TOOLS.json +58 -0
  92. package/src/config/bundled-skills/messaging/tools/gmail-sender-digest.ts +6 -1
  93. package/src/config/bundled-skills/messaging/tools/messaging-archive-by-sender.ts +35 -0
  94. package/src/config/bundled-skills/messaging/tools/messaging-sender-digest.ts +52 -0
  95. package/src/config/bundled-skills/phone-calls/SKILL.md +30 -39
  96. package/src/config/bundled-skills/twitter/SKILL.md +3 -3
  97. package/src/config/bundled-skills/vercel-token-setup/SKILL.md +215 -0
  98. package/src/config/calls-schema.ts +36 -0
  99. package/src/config/env.ts +22 -0
  100. package/src/config/feature-flag-registry.json +8 -8
  101. package/src/config/schema.ts +2 -2
  102. package/src/config/skills.ts +11 -0
  103. package/src/config/system-prompt.ts +11 -1
  104. package/src/config/templates/SOUL.md +2 -0
  105. package/src/config/vellum-skills/sms-setup/SKILL.md +71 -82
  106. package/src/config/vellum-skills/trusted-contacts/SKILL.md +8 -1
  107. package/src/config/vellum-skills/twilio-setup/SKILL.md +88 -73
  108. package/src/daemon/call-pointer-generators.ts +59 -0
  109. package/src/daemon/computer-use-session.ts +2 -5
  110. package/src/daemon/handlers/apps.ts +76 -20
  111. package/src/daemon/handlers/config-channels.ts +9 -61
  112. package/src/daemon/handlers/config-inbox.ts +11 -3
  113. package/src/daemon/handlers/config-ingress.ts +28 -3
  114. package/src/daemon/handlers/config-telegram.ts +12 -0
  115. package/src/daemon/handlers/config.ts +2 -6
  116. package/src/daemon/handlers/index.ts +2 -1
  117. package/src/daemon/handlers/pairing.ts +2 -0
  118. package/src/daemon/handlers/publish.ts +11 -46
  119. package/src/daemon/handlers/sessions.ts +59 -5
  120. package/src/daemon/handlers/shared.ts +17 -2
  121. package/src/daemon/ipc-contract/apps.ts +1 -0
  122. package/src/daemon/ipc-contract/inbox.ts +4 -0
  123. package/src/daemon/ipc-contract/integrations.ts +1 -97
  124. package/src/daemon/ipc-contract/messages.ts +47 -1
  125. package/src/daemon/ipc-contract/notifications.ts +11 -0
  126. package/src/daemon/ipc-contract-inventory.json +2 -4
  127. package/src/daemon/lifecycle.ts +17 -0
  128. package/src/daemon/server.ts +16 -2
  129. package/src/daemon/session-agent-loop-handlers.ts +20 -0
  130. package/src/daemon/session-agent-loop.ts +24 -12
  131. package/src/daemon/session-lifecycle.ts +1 -1
  132. package/src/daemon/session-process.ts +11 -1
  133. package/src/daemon/session-runtime-assembly.ts +6 -1
  134. package/src/daemon/session-surfaces.ts +32 -3
  135. package/src/daemon/session.ts +88 -1
  136. package/src/daemon/tool-side-effects.ts +22 -0
  137. package/src/home-base/prebuilt/brain-graph.html +1483 -0
  138. package/src/home-base/prebuilt/index.html +40 -0
  139. package/src/inbound/platform-callback-registration.ts +157 -0
  140. package/src/memory/canonical-guardian-store.ts +1 -1
  141. package/src/memory/conversation-crud.ts +2 -1
  142. package/src/memory/conversation-title-service.ts +16 -2
  143. package/src/memory/db-init.ts +8 -0
  144. package/src/memory/delivery-crud.ts +2 -1
  145. package/src/memory/guardian-action-store.ts +2 -1
  146. package/src/memory/guardian-approvals.ts +3 -2
  147. package/src/memory/ingress-invite-store.ts +12 -2
  148. package/src/memory/ingress-member-store.ts +4 -3
  149. package/src/memory/migrations/038-actor-token-records.ts +39 -0
  150. package/src/memory/migrations/124-voice-invite-display-metadata.ts +14 -0
  151. package/src/memory/migrations/index.ts +2 -0
  152. package/src/memory/schema.ts +26 -5
  153. package/src/messaging/provider-types.ts +24 -0
  154. package/src/messaging/provider.ts +7 -0
  155. package/src/messaging/providers/gmail/adapter.ts +127 -0
  156. package/src/messaging/providers/sms/adapter.ts +40 -37
  157. package/src/notifications/adapters/macos.ts +45 -2
  158. package/src/notifications/broadcaster.ts +16 -0
  159. package/src/notifications/copy-composer.ts +50 -2
  160. package/src/notifications/decision-engine.ts +22 -9
  161. package/src/notifications/destination-resolver.ts +16 -2
  162. package/src/notifications/emit-signal.ts +18 -9
  163. package/src/notifications/guardian-question-mode.ts +419 -0
  164. package/src/notifications/signal.ts +14 -3
  165. package/src/permissions/checker.ts +13 -1
  166. package/src/permissions/prompter.ts +14 -0
  167. package/src/providers/anthropic/client.ts +20 -0
  168. package/src/providers/provider-send-message.ts +15 -3
  169. package/src/runtime/access-request-helper.ts +82 -4
  170. package/src/runtime/actor-token-service.ts +234 -0
  171. package/src/runtime/actor-token-store.ts +236 -0
  172. package/src/runtime/actor-trust-resolver.ts +2 -2
  173. package/src/runtime/assistant-scope.ts +10 -0
  174. package/src/runtime/channel-approvals.ts +5 -3
  175. package/src/runtime/channel-readiness-service.ts +23 -64
  176. package/src/runtime/channel-readiness-types.ts +3 -4
  177. package/src/runtime/channel-retry-sweep.ts +4 -1
  178. package/src/runtime/confirmation-request-guardian-bridge.ts +197 -0
  179. package/src/runtime/guardian-action-followup-executor.ts +1 -1
  180. package/src/runtime/guardian-context-resolver.ts +82 -0
  181. package/src/runtime/guardian-outbound-actions.ts +5 -7
  182. package/src/runtime/guardian-reply-router.ts +67 -30
  183. package/src/runtime/guardian-vellum-migration.ts +57 -0
  184. package/src/runtime/http-server.ts +75 -31
  185. package/src/runtime/http-types.ts +13 -0
  186. package/src/runtime/ingress-service.ts +14 -0
  187. package/src/runtime/invite-redemption-service.ts +10 -1
  188. package/src/runtime/local-actor-identity.ts +76 -0
  189. package/src/runtime/middleware/actor-token.ts +271 -0
  190. package/src/runtime/middleware/twilio-validation.ts +2 -4
  191. package/src/runtime/routes/approval-routes.ts +82 -7
  192. package/src/runtime/routes/brain-graph-routes.ts +222 -0
  193. package/src/runtime/routes/call-routes.ts +2 -1
  194. package/src/runtime/routes/channel-readiness-routes.ts +71 -0
  195. package/src/runtime/routes/channel-route-shared.ts +3 -3
  196. package/src/runtime/routes/conversation-attention-routes.ts +2 -1
  197. package/src/runtime/routes/conversation-routes.ts +142 -53
  198. package/src/runtime/routes/events-routes.ts +22 -8
  199. package/src/runtime/routes/guardian-action-routes.ts +45 -3
  200. package/src/runtime/routes/guardian-approval-interception.ts +29 -0
  201. package/src/runtime/routes/guardian-bootstrap-routes.ts +145 -0
  202. package/src/runtime/routes/inbound-conversation.ts +4 -3
  203. package/src/runtime/routes/inbound-message-handler.ts +147 -5
  204. package/src/runtime/routes/ingress-routes.ts +2 -0
  205. package/src/runtime/routes/integration-routes.ts +7 -15
  206. package/src/runtime/routes/pairing-routes.ts +163 -0
  207. package/src/runtime/routes/twilio-routes.ts +934 -0
  208. package/src/runtime/tool-grant-request-helper.ts +3 -1
  209. package/src/security/oauth2.ts +27 -2
  210. package/src/security/token-manager.ts +46 -10
  211. package/src/tools/browser/browser-execution.ts +4 -3
  212. package/src/tools/browser/browser-handoff.ts +10 -18
  213. package/src/tools/browser/browser-manager.ts +80 -25
  214. package/src/tools/browser/browser-screencast.ts +35 -119
  215. package/src/tools/calls/call-start.ts +2 -1
  216. package/src/tools/permission-checker.ts +15 -4
  217. package/src/tools/terminal/parser.ts +12 -0
  218. package/src/tools/tool-approval-handler.ts +244 -19
  219. package/src/workspace/git-service.ts +19 -0
  220. package/src/__tests__/handlers-twilio-config.test.ts +0 -1928
  221. package/src/daemon/handlers/config-twilio.ts +0 -1082
@@ -302,24 +302,18 @@ describe('ChannelReadinessService', () => {
302
302
  expect(snapshot.reasons).toEqual([]);
303
303
  });
304
304
 
305
- test('remote cache is scoped per assistantId', async () => {
306
- const remoteCalls: Record<string, number> = {};
307
- const probe: ChannelProbe = {
308
- channel: 'sms',
309
- runLocalChecks: () => [{ name: 'local', passed: true, message: 'ok' }],
310
- async runRemoteChecks(context) {
311
- const key = context?.assistantId ?? '__default__';
312
- remoteCalls[key] = (remoteCalls[key] ?? 0) + 1;
313
- return [{ name: 'remote', passed: true, message: `ok-${key}` }];
314
- },
315
- };
305
+ test('remote cache uses fixed internal scope (no per-assistantId scoping)', async () => {
306
+ const probe = makeProbe(
307
+ 'sms',
308
+ [{ name: 'local', passed: true, message: 'ok' }],
309
+ [{ name: 'remote', passed: true, message: 'ok' }],
310
+ );
316
311
  service.registerProbe(probe);
317
312
 
318
- await service.getReadiness('sms', true, 'ast-alpha');
319
- await service.getReadiness('sms', true, 'ast-beta');
320
- await service.getReadiness('sms', true, 'ast-alpha');
313
+ // All calls share the same cache key since there is no assistantId dimension
314
+ await service.getReadiness('sms', true);
315
+ await service.getReadiness('sms', true);
321
316
 
322
- expect(remoteCalls['ast-alpha']).toBe(1);
323
- expect(remoteCalls['ast-beta']).toBe(1);
317
+ expect(probe.remoteCallCount).toBe(1);
324
318
  });
325
319
  });
@@ -63,7 +63,7 @@ mock.module('../config/loader.js', () => ({
63
63
  setNestedValue: () => {},
64
64
  }));
65
65
 
66
- import { _resetLegacyDeprecationWarning,check, classifyRisk, generateAllowlistOptions, generateScopeOptions } from '../permissions/checker.js';
66
+ import { _resetLegacyDeprecationWarning,check, classifyRisk, generateAllowlistOptions, generateScopeOptions, SCOPE_AWARE_TOOLS } from '../permissions/checker.js';
67
67
  import { getDefaultRuleTemplates } from '../permissions/defaults.js';
68
68
  import { addRule, clearCache, findHighestPriorityRule } from '../permissions/trust-store.js';
69
69
  import type { TrustRule } from '../permissions/types.js';
@@ -1341,21 +1341,42 @@ describe('Permission Checker', () => {
1341
1341
  expect(options[2]).toEqual({ label: 'everywhere', scope: 'everywhere' });
1342
1342
  });
1343
1343
 
1344
- test('scope options are always project parent → everywhere regardless of tool', () => {
1344
+ test('scope-aware tools all produce the same directory-based ordering', () => {
1345
1345
  const workingDir = join(homedir(), 'projects', 'myapp');
1346
1346
 
1347
- // Non-host tool
1348
- const nonHostOpts = generateScopeOptions(workingDir, 'bash');
1349
- expect(nonHostOpts[0].scope).toBe(workingDir);
1350
- expect(nonHostOpts[nonHostOpts.length - 1].scope).toBe('everywhere');
1347
+ const bashOpts = generateScopeOptions(workingDir, 'bash');
1348
+ expect(bashOpts[0].scope).toBe(workingDir);
1349
+ expect(bashOpts[bashOpts.length - 1].scope).toBe('everywhere');
1351
1350
 
1352
- // Host tool same order now
1353
- const hostOpts = generateScopeOptions(workingDir, 'host_bash');
1354
- expect(hostOpts[0].scope).toBe(workingDir);
1355
- expect(hostOpts[hostOpts.length - 1].scope).toBe('everywhere');
1351
+ const hostBashOpts = generateScopeOptions(workingDir, 'host_bash');
1352
+ expect(bashOpts.map(o => o.scope)).toEqual(hostBashOpts.map(o => o.scope));
1356
1353
 
1357
- // Same ordering for both
1358
- expect(nonHostOpts.map(o => o.scope)).toEqual(hostOpts.map(o => o.scope));
1354
+ const fileOpts = generateScopeOptions(workingDir, 'file_write');
1355
+ expect(bashOpts.map(o => o.scope)).toEqual(fileOpts.map(o => o.scope));
1356
+ });
1357
+
1358
+ test('returns empty for non-scoped tools', () => {
1359
+ const workingDir = join(homedir(), 'projects', 'myapp');
1360
+ expect(generateScopeOptions(workingDir, 'web_fetch')).toHaveLength(0);
1361
+ expect(generateScopeOptions(workingDir, 'browser_navigate')).toHaveLength(0);
1362
+ expect(generateScopeOptions(workingDir, 'skill_load')).toHaveLength(0);
1363
+ expect(generateScopeOptions(workingDir, 'credential_store')).toHaveLength(0);
1364
+ expect(generateScopeOptions(workingDir, 'computer_use_click')).toHaveLength(0);
1365
+ expect(generateScopeOptions(workingDir, 'my_custom_mcp_tool')).toHaveLength(0);
1366
+ });
1367
+
1368
+ test('returns directory options when toolName is omitted (backward compat)', () => {
1369
+ const options = generateScopeOptions('/home/user/project');
1370
+ expect(options).toHaveLength(3);
1371
+ expect(options[0].scope).toBe('/home/user/project');
1372
+ });
1373
+
1374
+ test('SCOPE_AWARE_TOOLS contains only filesystem and shell tools', () => {
1375
+ expect(SCOPE_AWARE_TOOLS).toEqual(new Set([
1376
+ 'bash', 'host_bash',
1377
+ 'file_read', 'file_write', 'file_edit',
1378
+ 'host_file_read', 'host_file_write', 'host_file_edit',
1379
+ ]));
1359
1380
  });
1360
1381
  });
1361
1382
 
@@ -581,6 +581,12 @@ describe('AssistantConfigSchema', () => {
581
581
  provider: 'twilio',
582
582
  maxDurationSeconds: 3600,
583
583
  userConsultTimeoutSeconds: 120,
584
+ ttsPlaybackDelayMs: 3000,
585
+ accessRequestPollIntervalMs: 500,
586
+ guardianWaitUpdateInitialIntervalMs: 5000,
587
+ guardianWaitUpdateInitialWindowMs: 30000,
588
+ guardianWaitUpdateSteadyMinIntervalMs: 7000,
589
+ guardianWaitUpdateSteadyMaxIntervalMs: 10000,
584
590
  disclosure: {
585
591
  enabled: true,
586
592
  text: 'At the very beginning of the call, introduce yourself as an assistant calling on behalf of the person you represent. Do not say "AI assistant".',
@@ -0,0 +1,410 @@
1
+ /**
2
+ * Tests for the confirmation-request -> guardian.question notification bridge.
3
+ *
4
+ * Verifies that:
5
+ * 1. Trusted-contact confirmation_requests emit guardian.question notifications
6
+ * 2. Canonical delivery rows are persisted for guardian destinations
7
+ * 3. Guardian and unknown actor sessions are correctly skipped
8
+ * 4. Missing guardian binding causes a skip
9
+ */
10
+
11
+ import { mkdtempSync, rmSync } from 'node:fs';
12
+ import { tmpdir } from 'node:os';
13
+ import { join } from 'node:path';
14
+
15
+ import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test';
16
+
17
+ const testDir = mkdtempSync(join(tmpdir(), 'confirmation-bridge-test-'));
18
+
19
+ mock.module('../util/platform.js', () => ({
20
+ getDataDir: () => testDir,
21
+ isMacOS: () => process.platform === 'darwin',
22
+ isLinux: () => process.platform === 'linux',
23
+ isWindows: () => process.platform === 'win32',
24
+ getSocketPath: () => join(testDir, 'test.sock'),
25
+ getPidPath: () => join(testDir, 'test.pid'),
26
+ getDbPath: () => join(testDir, 'test.db'),
27
+ getLogPath: () => join(testDir, 'test.log'),
28
+ ensureDataDir: () => {},
29
+ migrateToDataLayout: () => {},
30
+ migrateToWorkspaceLayout: () => {},
31
+ }));
32
+
33
+ mock.module('../util/logger.js', () => ({
34
+ getLogger: () =>
35
+ new Proxy({} as Record<string, unknown>, {
36
+ get: () => () => {},
37
+ }),
38
+ isDebug: () => false,
39
+ truncateForLog: (value: string) => value,
40
+ }));
41
+
42
+ // Mock notification emission — capture calls without running the full pipeline
43
+ const emittedSignals: Array<Record<string, unknown>> = [];
44
+ const mockOnThreadCreatedCallbacks: Array<(info: { conversationId: string; title: string; sourceEventName: string }) => void> = [];
45
+ mock.module('../notifications/emit-signal.js', () => ({
46
+ emitNotificationSignal: async (params: Record<string, unknown>) => {
47
+ emittedSignals.push(params);
48
+ // Capture onThreadCreated callback so tests can invoke it
49
+ if (typeof params.onThreadCreated === 'function') {
50
+ mockOnThreadCreatedCallbacks.push(params.onThreadCreated as (info: { conversationId: string; title: string; sourceEventName: string }) => void);
51
+ }
52
+ return {
53
+ signalId: 'test-signal',
54
+ deduplicated: false,
55
+ dispatched: true,
56
+ reason: 'ok',
57
+ deliveryResults: [
58
+ { channel: 'telegram', destination: 'guardian-chat-1', success: true },
59
+ ],
60
+ };
61
+ },
62
+ registerBroadcastFn: () => {},
63
+ }));
64
+
65
+ // Mock channel guardian service — provide a guardian binding for 'self' + 'telegram'
66
+ mock.module('../runtime/channel-guardian-service.js', () => ({
67
+ getGuardianBinding: (assistantId: string, channel: string) => {
68
+ if (assistantId === 'self' && channel === 'telegram') {
69
+ return {
70
+ id: 'binding-1',
71
+ assistantId: 'self',
72
+ channel: 'telegram',
73
+ guardianExternalUserId: 'guardian-1',
74
+ guardianDeliveryChatId: 'guardian-chat-1',
75
+ status: 'active',
76
+ };
77
+ }
78
+ return null;
79
+ },
80
+ }));
81
+
82
+ import type { GuardianRuntimeContext } from '../daemon/session-runtime-assembly.js';
83
+ import {
84
+ createCanonicalGuardianRequest,
85
+ generateCanonicalRequestCode,
86
+ listCanonicalGuardianDeliveries,
87
+ } from '../memory/canonical-guardian-store.js';
88
+ import { getDb, initializeDb, resetDb } from '../memory/db.js';
89
+ import { bridgeConfirmationRequestToGuardian } from '../runtime/confirmation-request-guardian-bridge.js';
90
+
91
+ initializeDb();
92
+
93
+ function resetTables(): void {
94
+ const db = getDb();
95
+ db.run('DELETE FROM canonical_guardian_deliveries');
96
+ db.run('DELETE FROM canonical_guardian_requests');
97
+ }
98
+
99
+ afterAll(() => {
100
+ resetDb();
101
+ try {
102
+ rmSync(testDir, { recursive: true });
103
+ } catch {
104
+ /* best effort */
105
+ }
106
+ });
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // Helpers
110
+ // ---------------------------------------------------------------------------
111
+
112
+ function makeCanonicalRequest(overrides: Record<string, unknown> = {}) {
113
+ return createCanonicalGuardianRequest({
114
+ id: `req-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
115
+ kind: 'tool_approval',
116
+ sourceType: 'channel',
117
+ sourceChannel: 'telegram',
118
+ conversationId: 'conv-1',
119
+ requesterExternalUserId: 'requester-1',
120
+ guardianExternalUserId: 'guardian-1',
121
+ toolName: 'bash',
122
+ status: 'pending',
123
+ requestCode: generateCanonicalRequestCode(),
124
+ expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(),
125
+ ...overrides,
126
+ });
127
+ }
128
+
129
+ function makeTrustedContactContext(overrides: Partial<GuardianRuntimeContext> = {}): GuardianRuntimeContext {
130
+ return {
131
+ sourceChannel: 'telegram',
132
+ trustClass: 'trusted_contact',
133
+ guardianExternalUserId: 'guardian-1',
134
+ guardianChatId: 'guardian-chat-1',
135
+ requesterExternalUserId: 'requester-1',
136
+ requesterChatId: 'requester-chat-1',
137
+ requesterIdentifier: '@requester',
138
+ ...overrides,
139
+ };
140
+ }
141
+
142
+ // ===========================================================================
143
+ // TESTS
144
+ // ===========================================================================
145
+
146
+ describe('bridgeConfirmationRequestToGuardian', () => {
147
+ beforeEach(() => {
148
+ resetTables();
149
+ emittedSignals.length = 0;
150
+ mockOnThreadCreatedCallbacks.length = 0;
151
+ });
152
+
153
+ test('emits guardian.question for trusted-contact sessions', () => {
154
+ const canonicalRequest = makeCanonicalRequest();
155
+ const guardianContext = makeTrustedContactContext();
156
+
157
+ const result = bridgeConfirmationRequestToGuardian({
158
+ canonicalRequest,
159
+ guardianContext,
160
+ conversationId: 'conv-1',
161
+ toolName: 'bash',
162
+ });
163
+
164
+ expect('bridged' in result && result.bridged).toBe(true);
165
+ expect(emittedSignals).toHaveLength(1);
166
+ expect(emittedSignals[0].sourceEventName).toBe('guardian.question');
167
+ expect(emittedSignals[0].sourceChannel).toBe('telegram');
168
+ expect(emittedSignals[0].sourceSessionId).toBe('conv-1');
169
+
170
+ const payload = emittedSignals[0].contextPayload as Record<string, unknown>;
171
+ expect(payload.requestId).toBe(canonicalRequest.id);
172
+ expect(payload.requestCode).toBe(canonicalRequest.requestCode);
173
+ expect(payload.toolName).toBe('bash');
174
+ expect(payload.requesterExternalUserId).toBe('requester-1');
175
+ expect(payload.requesterIdentifier).toBe('@requester');
176
+ });
177
+
178
+ test('skips guardian actor sessions (self-approve)', () => {
179
+ const canonicalRequest = makeCanonicalRequest();
180
+ const guardianContext: GuardianRuntimeContext = {
181
+ sourceChannel: 'telegram',
182
+ trustClass: 'guardian',
183
+ guardianExternalUserId: 'guardian-1',
184
+ };
185
+
186
+ const result = bridgeConfirmationRequestToGuardian({
187
+ canonicalRequest,
188
+ guardianContext,
189
+ conversationId: 'conv-1',
190
+ toolName: 'bash',
191
+ });
192
+
193
+ expect('skipped' in result && result.skipped).toBe(true);
194
+ if ('skipped' in result) {
195
+ expect(result.reason).toBe('not_trusted_contact');
196
+ }
197
+ expect(emittedSignals).toHaveLength(0);
198
+ });
199
+
200
+ test('skips unknown actor sessions', () => {
201
+ const canonicalRequest = makeCanonicalRequest();
202
+ const guardianContext: GuardianRuntimeContext = {
203
+ sourceChannel: 'telegram',
204
+ trustClass: 'unknown',
205
+ };
206
+
207
+ const result = bridgeConfirmationRequestToGuardian({
208
+ canonicalRequest,
209
+ guardianContext,
210
+ conversationId: 'conv-1',
211
+ toolName: 'bash',
212
+ });
213
+
214
+ expect('skipped' in result && result.skipped).toBe(true);
215
+ if ('skipped' in result) {
216
+ expect(result.reason).toBe('not_trusted_contact');
217
+ }
218
+ expect(emittedSignals).toHaveLength(0);
219
+ });
220
+
221
+ test('skips when guardian identity is missing', () => {
222
+ const canonicalRequest = makeCanonicalRequest();
223
+ const guardianContext = makeTrustedContactContext({
224
+ guardianExternalUserId: undefined,
225
+ });
226
+
227
+ const result = bridgeConfirmationRequestToGuardian({
228
+ canonicalRequest,
229
+ guardianContext,
230
+ conversationId: 'conv-1',
231
+ toolName: 'bash',
232
+ });
233
+
234
+ expect('skipped' in result && result.skipped).toBe(true);
235
+ if ('skipped' in result) {
236
+ expect(result.reason).toBe('missing_guardian_identity');
237
+ }
238
+ expect(emittedSignals).toHaveLength(0);
239
+ });
240
+
241
+ test('skips when no guardian binding exists for channel', () => {
242
+ const canonicalRequest = makeCanonicalRequest({ sourceChannel: 'sms' });
243
+ const guardianContext = makeTrustedContactContext({
244
+ sourceChannel: 'sms',
245
+ });
246
+
247
+ const result = bridgeConfirmationRequestToGuardian({
248
+ canonicalRequest,
249
+ guardianContext,
250
+ conversationId: 'conv-1',
251
+ toolName: 'bash',
252
+ });
253
+
254
+ expect('skipped' in result && result.skipped).toBe(true);
255
+ if ('skipped' in result) {
256
+ expect(result.reason).toBe('no_guardian_binding');
257
+ }
258
+ expect(emittedSignals).toHaveLength(0);
259
+ });
260
+
261
+ test('sets correct attention hints for urgency', () => {
262
+ const canonicalRequest = makeCanonicalRequest();
263
+ const guardianContext = makeTrustedContactContext();
264
+
265
+ bridgeConfirmationRequestToGuardian({
266
+ canonicalRequest,
267
+ guardianContext,
268
+ conversationId: 'conv-1',
269
+ toolName: 'bash',
270
+ });
271
+
272
+ const hints = emittedSignals[0].attentionHints as Record<string, unknown>;
273
+ expect(hints.requiresAction).toBe(true);
274
+ expect(hints.urgency).toBe('high');
275
+ expect(hints.isAsyncBackground).toBe(false);
276
+ expect(hints.visibleInSourceNow).toBe(false);
277
+ });
278
+
279
+ test('uses dedupe key scoped to canonical request ID', () => {
280
+ const canonicalRequest = makeCanonicalRequest();
281
+ const guardianContext = makeTrustedContactContext();
282
+
283
+ bridgeConfirmationRequestToGuardian({
284
+ canonicalRequest,
285
+ guardianContext,
286
+ conversationId: 'conv-1',
287
+ toolName: 'bash',
288
+ });
289
+
290
+ expect(emittedSignals[0].dedupeKey).toBe(`tc-confirmation-request:${canonicalRequest.id}`);
291
+ });
292
+
293
+ test('creates vellum delivery row via onThreadCreated callback', () => {
294
+ const canonicalRequest = makeCanonicalRequest();
295
+ const guardianContext = makeTrustedContactContext();
296
+
297
+ bridgeConfirmationRequestToGuardian({
298
+ canonicalRequest,
299
+ guardianContext,
300
+ conversationId: 'conv-1',
301
+ toolName: 'bash',
302
+ });
303
+
304
+ expect(mockOnThreadCreatedCallbacks).toHaveLength(1);
305
+
306
+ // Simulate the broadcaster invoking onThreadCreated
307
+ mockOnThreadCreatedCallbacks[0]({
308
+ conversationId: 'guardian-thread-1',
309
+ title: 'Guardian question',
310
+ sourceEventName: 'guardian.question',
311
+ });
312
+
313
+ const deliveries = listCanonicalGuardianDeliveries(canonicalRequest.id);
314
+ expect(deliveries).toHaveLength(1);
315
+ expect(deliveries[0].destinationChannel).toBe('vellum');
316
+ expect(deliveries[0].destinationConversationId).toBe('guardian-thread-1');
317
+ });
318
+
319
+ test('uses custom assistantId when provided', () => {
320
+ const canonicalRequest = makeCanonicalRequest();
321
+ const guardianContext = makeTrustedContactContext();
322
+
323
+ bridgeConfirmationRequestToGuardian({
324
+ canonicalRequest,
325
+ guardianContext,
326
+ conversationId: 'conv-1',
327
+ toolName: 'bash',
328
+ assistantId: 'custom-assistant',
329
+ });
330
+
331
+ // The mock only returns a binding for 'self', so 'custom-assistant'
332
+ // should fail with no_guardian_binding.
333
+ // Actually let's verify the signal uses the right assistantId.
334
+ // Since mock only has binding for 'self', this will skip.
335
+ expect(emittedSignals).toHaveLength(0);
336
+ });
337
+
338
+ test('passes assistantId to notification signal', () => {
339
+ const canonicalRequest = makeCanonicalRequest();
340
+ const guardianContext = makeTrustedContactContext();
341
+
342
+ // Use default assistantId 'self' which has a binding
343
+ bridgeConfirmationRequestToGuardian({
344
+ canonicalRequest,
345
+ guardianContext,
346
+ conversationId: 'conv-1',
347
+ toolName: 'bash',
348
+ });
349
+
350
+ expect(emittedSignals[0].assistantId).toBe('self');
351
+ });
352
+
353
+ test('includes requesterChatId as null when not provided', () => {
354
+ const canonicalRequest = makeCanonicalRequest();
355
+ const guardianContext = makeTrustedContactContext({
356
+ requesterChatId: undefined,
357
+ });
358
+
359
+ bridgeConfirmationRequestToGuardian({
360
+ canonicalRequest,
361
+ guardianContext,
362
+ conversationId: 'conv-1',
363
+ toolName: 'bash',
364
+ });
365
+
366
+ const payload = emittedSignals[0].contextPayload as Record<string, unknown>;
367
+ expect(payload.requesterChatId).toBeNull();
368
+ });
369
+
370
+ test('skips when binding guardian identity does not match canonical request guardian', () => {
371
+ // Create a canonical request where guardianExternalUserId differs from the
372
+ // binding's guardianExternalUserId ('guardian-1' in the mock).
373
+ const canonicalRequest = makeCanonicalRequest({
374
+ guardianExternalUserId: 'old-guardian-who-was-rebound',
375
+ });
376
+ const guardianContext = makeTrustedContactContext();
377
+
378
+ const result = bridgeConfirmationRequestToGuardian({
379
+ canonicalRequest,
380
+ guardianContext,
381
+ conversationId: 'conv-1',
382
+ toolName: 'bash',
383
+ });
384
+
385
+ expect('skipped' in result && result.skipped).toBe(true);
386
+ if ('skipped' in result) {
387
+ expect(result.reason).toBe('binding_identity_mismatch');
388
+ }
389
+ expect(emittedSignals).toHaveLength(0);
390
+ });
391
+
392
+ test('does not skip when canonical request guardian identity is null', () => {
393
+ // When guardianExternalUserId is null on the canonical request (e.g. desktop
394
+ // flow), the identity check should be skipped and the bridge should proceed.
395
+ const canonicalRequest = makeCanonicalRequest({
396
+ guardianExternalUserId: null,
397
+ });
398
+ const guardianContext = makeTrustedContactContext();
399
+
400
+ const result = bridgeConfirmationRequestToGuardian({
401
+ canonicalRequest,
402
+ guardianContext,
403
+ conversationId: 'conv-1',
404
+ toolName: 'bash',
405
+ });
406
+
407
+ expect('bridged' in result && result.bridged).toBe(true);
408
+ expect(emittedSignals).toHaveLength(1);
409
+ });
410
+ });