@vellumai/assistant 0.3.15 → 0.3.16

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 (290) hide show
  1. package/ARCHITECTURE.md +142 -0
  2. package/Dockerfile +1 -1
  3. package/README.md +5 -5
  4. package/docs/architecture/http-token-refresh.md +252 -0
  5. package/docs/architecture/memory.md +5 -4
  6. package/docs/architecture/scheduling.md +4 -88
  7. package/docs/runbook-trusted-contacts.md +283 -0
  8. package/docs/trusted-contact-access.md +247 -0
  9. package/package.json +1 -1
  10. package/scripts/ipc/check-swift-decoder-drift.ts +2 -0
  11. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +2 -6
  12. package/src/__tests__/access-request-decision.test.ts +331 -0
  13. package/src/__tests__/asset-materialize-tool.test.ts +7 -7
  14. package/src/__tests__/asset-search-tool.test.ts +15 -15
  15. package/src/__tests__/attachments-store.test.ts +13 -13
  16. package/src/__tests__/call-controller.test.ts +150 -4
  17. package/src/__tests__/call-conversation-messages.test.ts +2 -2
  18. package/src/__tests__/call-pointer-messages.test.ts +28 -0
  19. package/src/__tests__/call-start-guardian-guard.test.ts +93 -0
  20. package/src/__tests__/channel-approval-routes.test.ts +108 -12
  21. package/src/__tests__/channel-guardian.test.ts +16 -14
  22. package/src/__tests__/checker.test.ts +24 -0
  23. package/src/__tests__/computer-use-skill-manifest-regression.test.ts +2 -2
  24. package/src/__tests__/config-watcher.test.ts +358 -0
  25. package/src/__tests__/conversation-pairing.test.ts +24 -24
  26. package/src/__tests__/conversation-store.test.ts +36 -36
  27. package/src/__tests__/date-context.test.ts +179 -1
  28. package/src/__tests__/db-migration-rollback.test.ts +4 -7
  29. package/src/__tests__/deterministic-verification-control-plane.test.ts +5 -5
  30. package/src/__tests__/emit-signal-routing-intent.test.ts +179 -0
  31. package/src/__tests__/gateway-only-guard.test.ts +188 -0
  32. package/src/__tests__/guardian-action-conversation-turn.test.ts +451 -0
  33. package/src/__tests__/guardian-action-copy-generator.test.ts +197 -0
  34. package/src/__tests__/guardian-action-followup-executor.test.ts +379 -0
  35. package/src/__tests__/guardian-action-followup-store.test.ts +376 -0
  36. package/src/__tests__/guardian-action-late-reply.test.ts +294 -0
  37. package/src/__tests__/guardian-action-no-hardcoded-copy.test.ts +71 -0
  38. package/src/__tests__/guardian-action-sweep.test.ts +9 -9
  39. package/src/__tests__/guardian-outbound-http.test.ts +194 -2
  40. package/src/__tests__/guardian-verification-intent-routing.test.ts +179 -0
  41. package/src/__tests__/guardian-verify-setup-skill-regression.test.ts +141 -0
  42. package/src/__tests__/handlers-telegram-config.test.ts +6 -6
  43. package/src/__tests__/hooks-runner.test.ts +13 -4
  44. package/src/__tests__/ingress-routes-http.test.ts +443 -0
  45. package/src/__tests__/intent-routing.test.ts +14 -0
  46. package/src/__tests__/ipc-snapshot.test.ts +2 -5
  47. package/src/__tests__/media-reuse-story.e2e.test.ts +7 -7
  48. package/src/__tests__/memory-regressions.test.ts +16 -12
  49. package/src/__tests__/non-member-access-request.test.ts +282 -0
  50. package/src/__tests__/notification-decision-strategy.test.ts +136 -0
  51. package/src/__tests__/notification-routing-intent.test.ts +11 -1
  52. package/src/__tests__/notification-thread-candidates.test.ts +166 -0
  53. package/src/__tests__/recording-intent.test.ts +1 -0
  54. package/src/__tests__/recording-state-machine.test.ts +328 -17
  55. package/src/__tests__/registry.test.ts +17 -8
  56. package/src/__tests__/relay-server.test.ts +105 -0
  57. package/src/__tests__/reminder.test.ts +13 -0
  58. package/src/__tests__/runtime-attachment-metadata.test.ts +4 -4
  59. package/src/__tests__/scheduler-recurrence.test.ts +50 -0
  60. package/src/__tests__/server-history-render.test.ts +8 -8
  61. package/src/__tests__/session-agent-loop.test.ts +1 -0
  62. package/src/__tests__/session-runtime-assembly.test.ts +49 -0
  63. package/src/__tests__/session-skill-tools.test.ts +1 -0
  64. package/src/__tests__/skill-projection.benchmark.test.ts +11 -3
  65. package/src/__tests__/slack-channel-config.test.ts +230 -0
  66. package/src/__tests__/subagent-manager-notify.test.ts +4 -4
  67. package/src/__tests__/swarm-session-integration.test.ts +2 -2
  68. package/src/__tests__/system-prompt.test.ts +43 -0
  69. package/src/__tests__/task-management-tools.test.ts +3 -3
  70. package/src/__tests__/task-tools.test.ts +3 -3
  71. package/src/__tests__/trust-store.test.ts +17 -1
  72. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +491 -0
  73. package/src/__tests__/trusted-contact-multichannel.test.ts +409 -0
  74. package/src/__tests__/trusted-contact-verification.test.ts +360 -0
  75. package/src/__tests__/update-bulletin-format.test.ts +119 -0
  76. package/src/__tests__/update-bulletin-state.test.ts +129 -0
  77. package/src/__tests__/update-bulletin.test.ts +260 -0
  78. package/src/__tests__/update-template-contract.test.ts +29 -0
  79. package/src/agent/loop.ts +2 -2
  80. package/src/amazon/client.ts +2 -3
  81. package/src/calls/call-controller.ts +115 -34
  82. package/src/calls/call-conversation-messages.ts +2 -2
  83. package/src/calls/call-domain.ts +10 -3
  84. package/src/calls/call-pointer-messages.ts +17 -5
  85. package/src/calls/guardian-action-sweep.ts +77 -36
  86. package/src/calls/relay-server.ts +51 -12
  87. package/src/calls/twilio-routes.ts +3 -1
  88. package/src/calls/types.ts +1 -1
  89. package/src/calls/voice-session-bridge.ts +4 -4
  90. package/src/cli/core-commands.ts +3 -3
  91. package/src/cli/map.ts +8 -5
  92. package/src/config/bundled-skills/phone-calls/SKILL.md +16 -1
  93. package/src/config/bundled-skills/tasks/SKILL.md +1 -1
  94. package/src/config/bundled-skills/tasks/TOOLS.json +4 -4
  95. package/src/config/bundled-skills/time-based-actions/SKILL.md +11 -1
  96. package/src/config/computer-use-prompt.ts +1 -0
  97. package/src/config/core-schema.ts +16 -0
  98. package/src/config/env-registry.ts +1 -0
  99. package/src/config/env.ts +16 -1
  100. package/src/config/memory-schema.ts +5 -0
  101. package/src/config/schema.ts +4 -0
  102. package/src/config/system-prompt.ts +69 -2
  103. package/src/config/templates/BOOTSTRAP.md +1 -1
  104. package/src/config/templates/IDENTITY.md +8 -4
  105. package/src/config/templates/SOUL.md +14 -0
  106. package/src/config/templates/UPDATES.md +16 -0
  107. package/src/config/templates/USER.md +5 -1
  108. package/src/config/types.ts +1 -0
  109. package/src/config/update-bulletin-format.ts +52 -0
  110. package/src/config/update-bulletin-state.ts +49 -0
  111. package/src/config/update-bulletin.ts +82 -0
  112. package/src/config/vellum-skills/catalog.json +6 -0
  113. package/src/config/vellum-skills/chatgpt-import/tools/chatgpt-import.ts +1 -1
  114. package/src/config/vellum-skills/guardian-verify-setup/SKILL.md +44 -10
  115. package/src/config/vellum-skills/telegram-setup/SKILL.md +4 -4
  116. package/src/config/vellum-skills/trusted-contacts/SKILL.md +147 -0
  117. package/src/config/vellum-skills/twilio-setup/SKILL.md +2 -2
  118. package/src/context/window-manager.ts +43 -3
  119. package/src/daemon/config-watcher.ts +1 -0
  120. package/src/daemon/connection-policy.ts +21 -1
  121. package/src/daemon/daemon-control.ts +164 -7
  122. package/src/daemon/date-context.ts +174 -1
  123. package/src/daemon/guardian-action-generators.ts +175 -0
  124. package/src/daemon/guardian-verification-intent.ts +120 -0
  125. package/src/daemon/handlers/apps.ts +1 -3
  126. package/src/daemon/handlers/config-channels.ts +2 -2
  127. package/src/daemon/handlers/config-heartbeat.ts +1 -1
  128. package/src/daemon/handlers/config-inbox.ts +55 -159
  129. package/src/daemon/handlers/config-ingress.ts +1 -1
  130. package/src/daemon/handlers/config-integrations.ts +1 -1
  131. package/src/daemon/handlers/config-platform.ts +1 -1
  132. package/src/daemon/handlers/config-scheduling.ts +2 -2
  133. package/src/daemon/handlers/config-slack-channel.ts +190 -0
  134. package/src/daemon/handlers/config-telegram.ts +1 -1
  135. package/src/daemon/handlers/config-twilio.ts +1 -1
  136. package/src/daemon/handlers/config-voice.ts +100 -0
  137. package/src/daemon/handlers/config.ts +3 -0
  138. package/src/daemon/handlers/misc.ts +83 -5
  139. package/src/daemon/handlers/navigate-settings.ts +27 -0
  140. package/src/daemon/handlers/recording.ts +270 -144
  141. package/src/daemon/handlers/sessions.ts +100 -17
  142. package/src/daemon/handlers/subagents.ts +3 -3
  143. package/src/daemon/handlers/work-items.ts +10 -7
  144. package/src/daemon/ipc-contract/integrations.ts +9 -1
  145. package/src/daemon/ipc-contract/messages.ts +4 -0
  146. package/src/daemon/ipc-contract/sessions.ts +1 -1
  147. package/src/daemon/ipc-contract/settings.ts +26 -0
  148. package/src/daemon/ipc-contract/shared.ts +2 -0
  149. package/src/daemon/ipc-contract/work-items.ts +1 -7
  150. package/src/daemon/ipc-contract-inventory.json +5 -1
  151. package/src/daemon/ipc-contract.ts +5 -1
  152. package/src/daemon/lifecycle.ts +306 -266
  153. package/src/daemon/recording-intent.ts +0 -41
  154. package/src/daemon/response-tier.ts +2 -2
  155. package/src/daemon/server.ts +6 -6
  156. package/src/daemon/session-agent-loop-handlers.ts +34 -9
  157. package/src/daemon/session-agent-loop.ts +15 -8
  158. package/src/daemon/session-history.ts +3 -2
  159. package/src/daemon/session-media-retry.ts +3 -0
  160. package/src/daemon/session-messaging.ts +38 -4
  161. package/src/daemon/session-notifiers.ts +2 -2
  162. package/src/daemon/session-process.ts +256 -23
  163. package/src/daemon/session-queue-manager.ts +2 -0
  164. package/src/daemon/session-runtime-assembly.ts +39 -0
  165. package/src/daemon/session-skill-tools.ts +13 -4
  166. package/src/daemon/session-tool-setup.ts +5 -6
  167. package/src/daemon/session.ts +19 -8
  168. package/src/daemon/tls-certs.ts +55 -13
  169. package/src/daemon/tool-side-effects.ts +13 -5
  170. package/src/gallery/default-gallery.ts +32 -9
  171. package/src/influencer/client.ts +2 -1
  172. package/src/memory/channel-delivery-store.ts +37 -567
  173. package/src/memory/channel-guardian-store.ts +66 -1317
  174. package/src/memory/conflict-store.ts +4 -4
  175. package/src/memory/conversation-attention-store.ts +0 -3
  176. package/src/memory/conversation-crud.ts +668 -0
  177. package/src/memory/conversation-queries.ts +361 -0
  178. package/src/memory/conversation-store.ts +45 -983
  179. package/src/memory/db-connection.ts +3 -0
  180. package/src/memory/db-init.ts +25 -0
  181. package/src/memory/delivery-channels.ts +175 -0
  182. package/src/memory/delivery-crud.ts +211 -0
  183. package/src/memory/delivery-status.ts +199 -0
  184. package/src/memory/embedding-backend.ts +70 -4
  185. package/src/memory/embedding-local.ts +12 -2
  186. package/src/memory/entity-extractor.ts +3 -8
  187. package/src/memory/fts-reconciler.ts +121 -0
  188. package/src/memory/guardian-action-store.ts +366 -3
  189. package/src/memory/guardian-approvals.ts +569 -0
  190. package/src/memory/guardian-bindings.ts +130 -0
  191. package/src/memory/guardian-rate-limits.ts +196 -0
  192. package/src/memory/guardian-verification.ts +520 -0
  193. package/src/memory/job-handlers/index-maintenance.ts +2 -1
  194. package/src/memory/job-utils.ts +8 -5
  195. package/src/memory/jobs-store.ts +66 -6
  196. package/src/memory/jobs-worker.ts +23 -1
  197. package/src/memory/migrations/030-guardian-action-followup.ts +21 -0
  198. package/src/memory/migrations/030-guardian-verification-purpose.ts +17 -0
  199. package/src/memory/migrations/031-conversations-thread-type-index.ts +5 -0
  200. package/src/memory/migrations/100-core-tables.ts +1 -1
  201. package/src/memory/migrations/101-watchers-and-logs.ts +4 -0
  202. package/src/memory/migrations/108-tasks-and-work-items.ts +1 -1
  203. package/src/memory/migrations/112-assistant-inbox.ts +1 -1
  204. package/src/memory/migrations/113-late-migrations.ts +1 -1
  205. package/src/memory/migrations/116-messages-fts.ts +13 -0
  206. package/src/memory/migrations/119-schema-indexes-and-columns.ts +37 -0
  207. package/src/memory/migrations/120-fk-cascade-rebuilds.ts +161 -0
  208. package/src/memory/migrations/index.ts +8 -3
  209. package/src/memory/migrations/validate-migration-state.ts +114 -15
  210. package/src/memory/qdrant-circuit-breaker.ts +105 -0
  211. package/src/memory/retriever.ts +46 -13
  212. package/src/memory/schema-migration.ts +3 -0
  213. package/src/memory/schema.ts +25 -7
  214. package/src/memory/search/semantic.ts +8 -90
  215. package/src/notifications/README.md +1 -1
  216. package/src/notifications/broadcaster.ts +20 -2
  217. package/src/notifications/conversation-pairing.ts +3 -3
  218. package/src/notifications/decision-engine.ts +173 -8
  219. package/src/notifications/deliveries-store.ts +27 -8
  220. package/src/notifications/preferences-store.ts +7 -7
  221. package/src/notifications/thread-candidates.ts +234 -0
  222. package/src/notifications/types.ts +18 -0
  223. package/src/permissions/defaults.ts +11 -1
  224. package/src/permissions/prompter.ts +17 -0
  225. package/src/permissions/trust-store.ts +2 -0
  226. package/src/providers/failover.ts +19 -0
  227. package/src/providers/registry.ts +46 -1
  228. package/src/runtime/approval-message-composer.ts +1 -1
  229. package/src/runtime/channel-guardian-service.ts +15 -3
  230. package/src/runtime/channel-retry-sweep.ts +7 -2
  231. package/src/runtime/guardian-action-conversation-turn.ts +85 -0
  232. package/src/runtime/guardian-action-followup-executor.ts +301 -0
  233. package/src/runtime/guardian-action-message-composer.ts +245 -0
  234. package/src/runtime/guardian-outbound-actions.ts +26 -6
  235. package/src/runtime/guardian-verification-templates.ts +15 -9
  236. package/src/runtime/http-errors.ts +93 -0
  237. package/src/runtime/http-server.ts +133 -44
  238. package/src/runtime/http-types.ts +53 -0
  239. package/src/runtime/ingress-service.ts +237 -0
  240. package/src/runtime/middleware/error-handler.ts +4 -3
  241. package/src/runtime/middleware/rate-limiter.ts +160 -0
  242. package/src/runtime/middleware/request-logger.ts +71 -0
  243. package/src/runtime/middleware/twilio-validation.ts +7 -6
  244. package/src/runtime/pending-interactions.ts +12 -0
  245. package/src/runtime/routes/access-request-decision.ts +215 -0
  246. package/src/runtime/routes/app-routes.ts +25 -18
  247. package/src/runtime/routes/approval-routes.ts +18 -47
  248. package/src/runtime/routes/attachment-routes.ts +15 -41
  249. package/src/runtime/routes/call-routes.ts +20 -20
  250. package/src/runtime/routes/channel-delivery-routes.ts +6 -5
  251. package/src/runtime/routes/contact-routes.ts +4 -9
  252. package/src/runtime/routes/conversation-attention-routes.ts +2 -1
  253. package/src/runtime/routes/conversation-routes.ts +26 -57
  254. package/src/runtime/routes/debug-routes.ts +71 -0
  255. package/src/runtime/routes/events-routes.ts +3 -2
  256. package/src/runtime/routes/guardian-approval-interception.ts +221 -0
  257. package/src/runtime/routes/identity-routes.ts +14 -10
  258. package/src/runtime/routes/inbound-conversation.ts +3 -2
  259. package/src/runtime/routes/inbound-message-handler.ts +527 -62
  260. package/src/runtime/routes/ingress-routes.ts +174 -0
  261. package/src/runtime/routes/integration-routes.ts +78 -16
  262. package/src/runtime/routes/pairing-routes.ts +11 -10
  263. package/src/runtime/routes/secret-routes.ts +10 -18
  264. package/src/runtime/verification-rate-limiter.ts +83 -0
  265. package/src/schedule/schedule-store.ts +13 -1
  266. package/src/schedule/scheduler.ts +1 -1
  267. package/src/security/secret-ingress.ts +5 -2
  268. package/src/security/secret-scanner.ts +72 -6
  269. package/src/subagent/manager.ts +6 -4
  270. package/src/swarm/plan-validator.ts +4 -1
  271. package/src/tasks/task-runner.ts +3 -1
  272. package/src/tools/browser/api-map.ts +9 -6
  273. package/src/tools/calls/call-start.ts +20 -0
  274. package/src/tools/executor.ts +50 -568
  275. package/src/tools/permission-checker.ts +272 -0
  276. package/src/tools/registry.ts +14 -6
  277. package/src/tools/reminder/reminder-store.ts +7 -7
  278. package/src/tools/reminder/reminder.ts +6 -3
  279. package/src/tools/secret-detection-handler.ts +301 -0
  280. package/src/tools/subagent/message.ts +1 -1
  281. package/src/tools/system/voice-config.ts +62 -0
  282. package/src/tools/tasks/index.ts +3 -3
  283. package/src/tools/tasks/work-item-list.ts +3 -3
  284. package/src/tools/tasks/work-item-update.ts +4 -5
  285. package/src/tools/tool-approval-handler.ts +192 -0
  286. package/src/tools/tool-manifest.ts +2 -0
  287. package/src/watcher/watcher-store.ts +9 -9
  288. package/src/work-items/work-item-runner.ts +9 -6
  289. /package/src/memory/migrations/{026-embeddings-nullable-vector-json.ts → 026a-embeddings-nullable-vector-json.ts} +0 -0
  290. /package/src/memory/migrations/{027-guardian-bootstrap-token.ts → 027a-guardian-bootstrap-token.ts} +0 -0
@@ -0,0 +1,331 @@
1
+ /**
2
+ * Tests for the access request decision flow.
3
+ *
4
+ * When a guardian approves or denies an `ingress_access_request`:
5
+ * - Approve: creates a verification session, delivers code to guardian,
6
+ * notifies requester to expect a code.
7
+ * - Deny: sends refusal reply to requester.
8
+ * - Stale: handles already-resolved requests gracefully.
9
+ * - Idempotent: approving same request twice does not create duplicate sessions.
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
+ // ---------------------------------------------------------------------------
18
+ // Test isolation: in-memory SQLite via temp directory
19
+ // ---------------------------------------------------------------------------
20
+
21
+ const testDir = mkdtempSync(join(tmpdir(), 'access-request-decision-test-'));
22
+
23
+ mock.module('../util/platform.js', () => ({
24
+ getRootDir: () => testDir,
25
+ getDataDir: () => testDir,
26
+ isMacOS: () => process.platform === 'darwin',
27
+ isLinux: () => process.platform === 'linux',
28
+ isWindows: () => process.platform === 'win32',
29
+ getSocketPath: () => join(testDir, 'test.sock'),
30
+ getPidPath: () => join(testDir, 'test.pid'),
31
+ getDbPath: () => join(testDir, 'test.db'),
32
+ getLogPath: () => join(testDir, 'test.log'),
33
+ ensureDataDir: () => {},
34
+ normalizeAssistantId: (id: string) => id === 'self' ? 'self' : id,
35
+ readHttpToken: () => 'test-bearer-token',
36
+ }));
37
+
38
+ mock.module('../util/logger.js', () => ({
39
+ getLogger: () => new Proxy({} as Record<string, unknown>, {
40
+ get: () => () => {},
41
+ }),
42
+ }));
43
+
44
+ // Track deliverChannelReply calls and allow injecting failures
45
+ const deliverReplyCalls: Array<{ url: string; payload: Record<string, unknown> }> = [];
46
+ let deliverReplyError: Error | null = null;
47
+ mock.module('../runtime/gateway-client.js', () => ({
48
+ deliverChannelReply: async (url: string, payload: Record<string, unknown>) => {
49
+ if (deliverReplyError) {
50
+ throw deliverReplyError;
51
+ }
52
+ deliverReplyCalls.push({ url, payload });
53
+ },
54
+ }));
55
+
56
+ import {
57
+ createApprovalRequest,
58
+ createBinding,
59
+ getApprovalRequestById,
60
+ findPendingAccessRequestForRequester,
61
+ } from '../memory/channel-guardian-store.js';
62
+ import {
63
+ findActiveSession,
64
+ } from '../runtime/channel-guardian-service.js';
65
+ import { initializeDb, resetDb } from '../memory/db.js';
66
+ import {
67
+ handleAccessRequestDecision,
68
+ deliverVerificationCodeToGuardian,
69
+ notifyRequesterOfApproval,
70
+ notifyRequesterOfDenial,
71
+ notifyRequesterOfDeliveryFailure,
72
+ } from '../runtime/routes/access-request-decision.js';
73
+
74
+ initializeDb();
75
+
76
+ afterAll(() => {
77
+ resetDb();
78
+ try { rmSync(testDir, { recursive: true }); } catch { /* best effort */ }
79
+ });
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // Helpers
83
+ // ---------------------------------------------------------------------------
84
+
85
+ const GUARDIAN_APPROVAL_TTL_MS = 5 * 60 * 1000;
86
+
87
+ function resetState(): void {
88
+ const { getDb } = require('../memory/db.js');
89
+ const db = getDb();
90
+ db.run('DELETE FROM channel_guardian_approval_requests');
91
+ db.run('DELETE FROM channel_guardian_bindings');
92
+ db.run('DELETE FROM channel_guardian_verification_challenges');
93
+ deliverReplyCalls.length = 0;
94
+ }
95
+
96
+ function createTestApproval(overrides: Record<string, unknown> = {}) {
97
+ return createApprovalRequest({
98
+ runId: `ingress-access-request-${Date.now()}`,
99
+ conversationId: `access-req-telegram-user-unknown-456`,
100
+ assistantId: 'self',
101
+ channel: 'telegram',
102
+ requesterExternalUserId: 'user-unknown-456',
103
+ requesterChatId: 'chat-123',
104
+ guardianExternalUserId: 'guardian-user-789',
105
+ guardianChatId: 'guardian-chat-789',
106
+ toolName: 'ingress_access_request',
107
+ riskLevel: 'access_request',
108
+ reason: 'Alice Unknown is requesting access to the assistant',
109
+ expiresAt: Date.now() + GUARDIAN_APPROVAL_TTL_MS,
110
+ ...overrides,
111
+ });
112
+ }
113
+
114
+ // ---------------------------------------------------------------------------
115
+ // Tests
116
+ // ---------------------------------------------------------------------------
117
+
118
+ describe('access request decision handler', () => {
119
+ beforeEach(() => {
120
+ resetState();
121
+ });
122
+
123
+ test('guardian approve creates a verification session', () => {
124
+ const approval = createTestApproval();
125
+
126
+ const result = handleAccessRequestDecision(
127
+ approval,
128
+ 'approve',
129
+ 'guardian-user-789',
130
+ );
131
+
132
+ expect(result.handled).toBe(true);
133
+ expect(result.type).toBe('approved');
134
+ expect(result.verificationSessionId).toBeDefined();
135
+ expect(result.verificationCode).toBeDefined();
136
+ // Verification code should be a 6-digit numeric string
137
+ expect(result.verificationCode).toMatch(/^\d{6}$/);
138
+
139
+ // Approval record should be updated to 'approved'
140
+ const updated = getApprovalRequestById(approval.id);
141
+ expect(updated).not.toBeNull();
142
+ expect(updated!.status).toBe('approved');
143
+ expect(updated!.decidedByExternalUserId).toBe('guardian-user-789');
144
+ });
145
+
146
+ test('verification session is identity-bound to the requester', () => {
147
+ const approval = createTestApproval();
148
+
149
+ const result = handleAccessRequestDecision(
150
+ approval,
151
+ 'approve',
152
+ 'guardian-user-789',
153
+ );
154
+
155
+ expect(result.type).toBe('approved');
156
+
157
+ // There should be an active session for this channel
158
+ const session = findActiveSession('self', 'telegram');
159
+ expect(session).not.toBeNull();
160
+ expect(session!.expectedExternalUserId).toBe('user-unknown-456');
161
+ expect(session!.expectedChatId).toBe('chat-123');
162
+ expect(session!.identityBindingStatus).toBe('bound');
163
+ expect(session!.status).toBe('awaiting_response');
164
+ });
165
+
166
+ test('guardian deny marks approval as denied', () => {
167
+ const approval = createTestApproval();
168
+
169
+ const result = handleAccessRequestDecision(
170
+ approval,
171
+ 'deny',
172
+ 'guardian-user-789',
173
+ );
174
+
175
+ expect(result.handled).toBe(true);
176
+ expect(result.type).toBe('denied');
177
+ expect(result.verificationSessionId).toBeUndefined();
178
+ expect(result.verificationCode).toBeUndefined();
179
+
180
+ // Approval record should be updated to 'denied'
181
+ const updated = getApprovalRequestById(approval.id);
182
+ expect(updated).not.toBeNull();
183
+ expect(updated!.status).toBe('denied');
184
+ expect(updated!.decidedByExternalUserId).toBe('guardian-user-789');
185
+
186
+ // No verification session should be created
187
+ const session = findActiveSession('self', 'telegram');
188
+ expect(session).toBeNull();
189
+ });
190
+
191
+ test('stale decision (already resolved) returns stale', () => {
192
+ const approval = createTestApproval();
193
+
194
+ // Approve first
195
+ handleAccessRequestDecision(approval, 'approve', 'guardian-user-789');
196
+
197
+ // Try to deny the same approval — should be stale
198
+ const result = handleAccessRequestDecision(
199
+ approval,
200
+ 'deny',
201
+ 'guardian-user-789',
202
+ );
203
+
204
+ expect(result.handled).toBe(true);
205
+ expect(result.type).toBe('stale');
206
+ });
207
+
208
+ test('idempotent approval does not create duplicate verification sessions', () => {
209
+ const approval = createTestApproval();
210
+
211
+ // Approve first
212
+ const result1 = handleAccessRequestDecision(
213
+ approval,
214
+ 'approve',
215
+ 'guardian-user-789',
216
+ );
217
+ expect(result1.type).toBe('approved');
218
+ const sessionId1 = result1.verificationSessionId;
219
+
220
+ // Approve again — should be idempotent (already resolved with same decision)
221
+ const result2 = handleAccessRequestDecision(
222
+ approval,
223
+ 'approve',
224
+ 'guardian-user-789',
225
+ );
226
+
227
+ // resolveApprovalRequest returns the existing record for same-decision idempotency,
228
+ // but since the approval is no longer 'pending', a second createOutboundSession
229
+ // will still be called. However, createOutboundSession auto-revokes prior sessions,
230
+ // so there will be exactly one active session at the end.
231
+ // The important thing is that the result indicates approval was handled.
232
+ expect(result2.handled).toBe(true);
233
+ // Either 'approved' (creates a new session) or something else is acceptable,
234
+ // but it should not crash.
235
+ });
236
+ });
237
+
238
+ describe('access request notification delivery', () => {
239
+ beforeEach(() => {
240
+ deliverReplyCalls.length = 0;
241
+ deliverReplyError = null;
242
+ });
243
+
244
+ test('delivers verification code to guardian and returns ok', async () => {
245
+ const result = await deliverVerificationCodeToGuardian({
246
+ replyCallbackUrl: 'http://localhost:7830/deliver/telegram',
247
+ guardianChatId: 'guardian-chat-789',
248
+ requesterIdentifier: 'user-unknown-456',
249
+ verificationCode: '123456',
250
+ assistantId: 'self',
251
+ bearerToken: 'test-token',
252
+ });
253
+
254
+ expect(result.ok).toBe(true);
255
+ expect(deliverReplyCalls.length).toBe(1);
256
+ const call = deliverReplyCalls[0];
257
+ expect(call.payload.chatId).toBe('guardian-chat-789');
258
+ const text = call.payload.text as string;
259
+ expect(text).toContain('123456');
260
+ expect(text).toContain('user-unknown-456');
261
+ expect(text).toContain('10 minutes');
262
+ });
263
+
264
+ test('returns failure result when guardian code delivery fails', async () => {
265
+ deliverReplyError = new Error('Gateway timeout');
266
+
267
+ const result = await deliverVerificationCodeToGuardian({
268
+ replyCallbackUrl: 'http://localhost:7830/deliver/telegram',
269
+ guardianChatId: 'guardian-chat-789',
270
+ requesterIdentifier: 'user-unknown-456',
271
+ verificationCode: '123456',
272
+ assistantId: 'self',
273
+ bearerToken: 'test-token',
274
+ });
275
+
276
+ expect(result.ok).toBe(false);
277
+ if (!result.ok) {
278
+ expect(result.reason).toBe('Gateway timeout');
279
+ }
280
+ // No calls should have been recorded (error thrown before push)
281
+ expect(deliverReplyCalls.length).toBe(0);
282
+ });
283
+
284
+ test('notifies requester of approval', async () => {
285
+ await notifyRequesterOfApproval({
286
+ replyCallbackUrl: 'http://localhost:7830/deliver/telegram',
287
+ requesterChatId: 'chat-123',
288
+ assistantId: 'self',
289
+ bearerToken: 'test-token',
290
+ });
291
+
292
+ expect(deliverReplyCalls.length).toBe(1);
293
+ const call = deliverReplyCalls[0];
294
+ expect(call.payload.chatId).toBe('chat-123');
295
+ const text = call.payload.text as string;
296
+ expect(text).toContain('approved');
297
+ expect(text).toContain('verification code');
298
+ });
299
+
300
+ test('notifies requester of denial', async () => {
301
+ await notifyRequesterOfDenial({
302
+ replyCallbackUrl: 'http://localhost:7830/deliver/telegram',
303
+ requesterChatId: 'chat-123',
304
+ assistantId: 'self',
305
+ bearerToken: 'test-token',
306
+ });
307
+
308
+ expect(deliverReplyCalls.length).toBe(1);
309
+ const call = deliverReplyCalls[0];
310
+ expect(call.payload.chatId).toBe('chat-123');
311
+ const text = call.payload.text as string;
312
+ expect(text).toContain('denied');
313
+ });
314
+
315
+ test('notifies requester of delivery failure', async () => {
316
+ await notifyRequesterOfDeliveryFailure({
317
+ replyCallbackUrl: 'http://localhost:7830/deliver/telegram',
318
+ requesterChatId: 'chat-123',
319
+ assistantId: 'self',
320
+ bearerToken: 'test-token',
321
+ });
322
+
323
+ expect(deliverReplyCalls.length).toBe(1);
324
+ const call = deliverReplyCalls[0];
325
+ expect(call.payload.chatId).toBe('chat-123');
326
+ const text = call.payload.text as string;
327
+ expect(text).toContain('approved');
328
+ expect(text).toContain('unable to deliver');
329
+ expect(text).toContain('try again');
330
+ });
331
+ });
@@ -311,7 +311,7 @@ describe('AssetMaterializeTool visibility policy', () => {
311
311
  const standardConv = createConversation({ title: 'standard-conv' });
312
312
  const base64Content = Buffer.from('standard content').toString('base64');
313
313
  const attachment = uploadAttachment('public.txt', 'text/plain', base64Content);
314
- const msg = addMessage(standardConv.id, 'user', 'standard message');
314
+ const msg = await addMessage(standardConv.id, 'user', 'standard message');
315
315
  linkAttachmentToMessage(msg.id, attachment.id, 0);
316
316
 
317
317
  // Materialize from a different standard conversation
@@ -334,7 +334,7 @@ describe('AssetMaterializeTool visibility policy', () => {
334
334
  const privateConv = createConversation({ title: 'private-conv', threadType: 'private' });
335
335
  const base64Content = Buffer.from('private content').toString('base64');
336
336
  const attachment = uploadAttachment('secret.txt', 'text/plain', base64Content);
337
- const msg = addMessage(privateConv.id, 'user', 'private message');
337
+ const msg = await addMessage(privateConv.id, 'user', 'private message');
338
338
  linkAttachmentToMessage(msg.id, attachment.id, 0);
339
339
 
340
340
  // Materialize from the same private conversation
@@ -356,7 +356,7 @@ describe('AssetMaterializeTool visibility policy', () => {
356
356
  const privateConv = createConversation({ title: 'private-conv', threadType: 'private' });
357
357
  const base64Content = Buffer.from('private content').toString('base64');
358
358
  const attachment = uploadAttachment('secret.txt', 'text/plain', base64Content);
359
- const msg = addMessage(privateConv.id, 'user', 'private message');
359
+ const msg = await addMessage(privateConv.id, 'user', 'private message');
360
360
  linkAttachmentToMessage(msg.id, attachment.id, 0);
361
361
 
362
362
  // Attempt to materialize from a different conversation
@@ -380,7 +380,7 @@ describe('AssetMaterializeTool visibility policy', () => {
380
380
  const privateConv = createConversation({ title: 'private-conv', threadType: 'private' });
381
381
  const base64Content = Buffer.from('private content').toString('base64');
382
382
  const attachment = uploadAttachment('confidential.pdf', 'application/pdf', base64Content);
383
- const msg = addMessage(privateConv.id, 'user', 'private message');
383
+ const msg = await addMessage(privateConv.id, 'user', 'private message');
384
384
  linkAttachmentToMessage(msg.id, attachment.id, 0);
385
385
 
386
386
  // From a standard conversation
@@ -406,7 +406,7 @@ describe('AssetMaterializeTool visibility policy', () => {
406
406
  const privateConv1 = createConversation({ title: 'private-conv-1', threadType: 'private' });
407
407
  const base64Content = Buffer.from('private content').toString('base64');
408
408
  const attachment = uploadAttachment('secret.txt', 'text/plain', base64Content);
409
- const msg = addMessage(privateConv1.id, 'user', 'private message');
409
+ const msg = await addMessage(privateConv1.id, 'user', 'private message');
410
410
  linkAttachmentToMessage(msg.id, attachment.id, 0);
411
411
 
412
412
  // Attempt from a different private conversation
@@ -431,8 +431,8 @@ describe('AssetMaterializeTool visibility policy', () => {
431
431
  const base64Content = Buffer.from('shared content').toString('base64');
432
432
  const attachment = uploadAttachment('shared.txt', 'text/plain', base64Content);
433
433
 
434
- const msg1 = addMessage(privateConv.id, 'user', 'private message');
435
- const msg2 = addMessage(standardConv.id, 'user', 'standard message');
434
+ const msg1 = await addMessage(privateConv.id, 'user', 'private message');
435
+ const msg2 = await addMessage(standardConv.id, 'user', 'standard message');
436
436
  linkAttachmentToMessage(msg1.id, attachment.id, 0);
437
437
  linkAttachmentToMessage(msg2.id, attachment.id, 0);
438
438
 
@@ -219,14 +219,14 @@ describe('searchAttachments', () => {
219
219
  describe('searchAttachments with conversation_id', () => {
220
220
  beforeEach(resetTables);
221
221
 
222
- test('returns only attachments linked to the specified conversation', () => {
222
+ test('returns only attachments linked to the specified conversation', async () => {
223
223
  const png1 = uploadAttachment('in-conv.png', 'image/png', 'AAAA');
224
224
  const png2 = uploadAttachment('other-conv.png', 'image/png', 'BBBB');
225
225
 
226
226
  const conv1 = createConversation();
227
227
  const conv2 = createConversation();
228
- const msg1 = addMessage(conv1.id, 'user', 'First conv');
229
- const msg2 = addMessage(conv2.id, 'user', 'Second conv');
228
+ const msg1 = await addMessage(conv1.id, 'user', 'First conv');
229
+ const msg2 = await addMessage(conv2.id, 'user', 'Second conv');
230
230
 
231
231
  linkAttachmentToMessage(msg1.id, png1.id, 0);
232
232
  linkAttachmentToMessage(msg2.id, png2.id, 0);
@@ -236,10 +236,10 @@ describe('searchAttachments with conversation_id', () => {
236
236
  expect(results[0].id).toBe(png1.id);
237
237
  });
238
238
 
239
- test('returns empty when conversation has no attachments', () => {
239
+ test('returns empty when conversation has no attachments', async () => {
240
240
  uploadAttachment('orphan.png', 'image/png', 'AAAA');
241
241
  const conv = createConversation();
242
- addMessage(conv.id, 'user', 'No attachments here');
242
+ await addMessage(conv.id, 'user', 'No attachments here');
243
243
 
244
244
  const results = searchAttachments({ conversation_id: conv.id });
245
245
  expect(results.length).toBe(0);
@@ -251,12 +251,12 @@ describe('searchAttachments with conversation_id', () => {
251
251
  expect(results.length).toBe(0);
252
252
  });
253
253
 
254
- test('combines conversation_id with mime_type filter', () => {
254
+ test('combines conversation_id with mime_type filter', async () => {
255
255
  const png = uploadAttachment('image.png', 'image/png', 'AAAA');
256
256
  const pdf = uploadAttachment('doc.pdf', 'application/pdf', 'BBBB');
257
257
 
258
258
  const conv = createConversation();
259
- const msg = addMessage(conv.id, 'user', 'Both types');
259
+ const msg = await addMessage(conv.id, 'user', 'Both types');
260
260
 
261
261
  linkAttachmentToMessage(msg.id, png.id, 0);
262
262
  linkAttachmentToMessage(msg.id, pdf.id, 1);
@@ -266,12 +266,12 @@ describe('searchAttachments with conversation_id', () => {
266
266
  expect(results[0].mimeType).toBe('image/png');
267
267
  });
268
268
 
269
- test('combines conversation_id with filename filter', () => {
269
+ test('combines conversation_id with filename filter', async () => {
270
270
  const a = uploadAttachment('target.png', 'image/png', 'AAAA');
271
271
  const b = uploadAttachment('other.png', 'image/png', 'BBBB');
272
272
 
273
273
  const conv = createConversation();
274
- const msg = addMessage(conv.id, 'user', 'Both');
274
+ const msg = await addMessage(conv.id, 'user', 'Both');
275
275
 
276
276
  linkAttachmentToMessage(msg.id, a.id, 0);
277
277
  linkAttachmentToMessage(msg.id, b.id, 1);
@@ -367,7 +367,7 @@ describe('AssetSearchTool visibility policy', () => {
367
367
  test('attachments from standard threads are visible from any context', async () => {
368
368
  const standardConv = createConversation({ title: 'standard-conv' });
369
369
  const attachment = uploadAttachment('public.png', 'image/png', 'AAAA');
370
- const msg = addMessage(standardConv.id, 'user', 'standard message');
370
+ const msg = await addMessage(standardConv.id, 'user', 'standard message');
371
371
  linkAttachmentToMessage(msg.id, attachment.id, 0);
372
372
 
373
373
  // Search from a different standard conversation
@@ -386,7 +386,7 @@ describe('AssetSearchTool visibility policy', () => {
386
386
  test('attachments from private threads are visible within the same private thread', async () => {
387
387
  const privateConv = createConversation({ title: 'private-conv', threadType: 'private' });
388
388
  const attachment = uploadAttachment('secret.png', 'image/png', 'AAAA');
389
- const msg = addMessage(privateConv.id, 'user', 'private message');
389
+ const msg = await addMessage(privateConv.id, 'user', 'private message');
390
390
  linkAttachmentToMessage(msg.id, attachment.id, 0);
391
391
 
392
392
  // Search from the same private conversation
@@ -404,7 +404,7 @@ describe('AssetSearchTool visibility policy', () => {
404
404
  test('attachments from private threads are NOT visible from a different conversation', async () => {
405
405
  const privateConv = createConversation({ title: 'private-conv', threadType: 'private' });
406
406
  const attachment = uploadAttachment('secret.png', 'image/png', 'AAAA');
407
- const msg = addMessage(privateConv.id, 'user', 'private message');
407
+ const msg = await addMessage(privateConv.id, 'user', 'private message');
408
408
  linkAttachmentToMessage(msg.id, attachment.id, 0);
409
409
 
410
410
  // Search from a different private conversation
@@ -423,7 +423,7 @@ describe('AssetSearchTool visibility policy', () => {
423
423
  test('attachments from private threads are NOT visible from standard threads', async () => {
424
424
  const privateConv = createConversation({ title: 'private-conv', threadType: 'private' });
425
425
  const attachment = uploadAttachment('secret.png', 'image/png', 'AAAA');
426
- const msg = addMessage(privateConv.id, 'user', 'private message');
426
+ const msg = await addMessage(privateConv.id, 'user', 'private message');
427
427
  linkAttachmentToMessage(msg.id, attachment.id, 0);
428
428
 
429
429
  // Search from a standard conversation
@@ -444,8 +444,8 @@ describe('AssetSearchTool visibility policy', () => {
444
444
  const standardConv = createConversation({ title: 'standard-conv' });
445
445
  const attachment = uploadAttachment('shared.png', 'image/png', 'AAAA');
446
446
 
447
- const msg1 = addMessage(privateConv.id, 'user', 'private message');
448
- const msg2 = addMessage(standardConv.id, 'user', 'standard message');
447
+ const msg1 = await addMessage(privateConv.id, 'user', 'private message');
448
+ const msg2 = await addMessage(standardConv.id, 'user', 'standard message');
449
449
  linkAttachmentToMessage(msg1.id, attachment.id, 0);
450
450
  linkAttachmentToMessage(msg2.id, attachment.id, 0);
451
451
 
@@ -206,10 +206,10 @@ describe('deleteAttachment', () => {
206
206
  expect(result).toBe('not_found');
207
207
  });
208
208
 
209
- test('returns still_referenced when messages reference the attachment', () => {
209
+ test('returns still_referenced when messages reference the attachment', async () => {
210
210
  const conv = createConversation();
211
- const msg1 = addMessage(conv.id, 'user', 'First upload');
212
- const msg2 = addMessage(conv.id, 'user', 'Duplicate upload');
211
+ const msg1 = await addMessage(conv.id, 'user', 'First upload');
212
+ const msg2 = await addMessage(conv.id, 'user', 'Duplicate upload');
213
213
 
214
214
  // Dedup: both uploads return the same attachment row
215
215
  const first = uploadAttachment('photo.png', 'image/png', 'SHAREDCONTENT1');
@@ -304,9 +304,9 @@ describe('getAttachmentById', () => {
304
304
  describe('linkAttachmentToMessage + getAttachmentsForMessage', () => {
305
305
  beforeEach(resetTables);
306
306
 
307
- test('links attachment and retrieves it by message', () => {
307
+ test('links attachment and retrieves it by message', async () => {
308
308
  const conv = createConversation();
309
- const msg = addMessage(conv.id, 'assistant', 'Here is a chart');
309
+ const msg = await addMessage(conv.id, 'assistant', 'Here is a chart');
310
310
  const stored = uploadAttachment('chart.png', 'image/png', 'iVBORw0K');
311
311
 
312
312
  linkAttachmentToMessage(msg.id, stored.id, 0);
@@ -318,9 +318,9 @@ describe('linkAttachmentToMessage + getAttachmentsForMessage', () => {
318
318
  expect(linked[0].dataBase64).toBe('iVBORw0K');
319
319
  });
320
320
 
321
- test('returns attachments in position order', () => {
321
+ test('returns attachments in position order', async () => {
322
322
  const conv = createConversation();
323
- const msg = addMessage(conv.id, 'assistant', 'Multiple files');
323
+ const msg = await addMessage(conv.id, 'assistant', 'Multiple files');
324
324
  const a = uploadAttachment('first.txt', 'text/plain', 'AAAA');
325
325
  const b = uploadAttachment('second.txt', 'text/plain', 'BBBB');
326
326
 
@@ -334,9 +334,9 @@ describe('linkAttachmentToMessage + getAttachmentsForMessage', () => {
334
334
  expect(linked[1].originalFilename).toBe('second.txt');
335
335
  });
336
336
 
337
- test('returns empty for message with no attachments', () => {
337
+ test('returns empty for message with no attachments', async () => {
338
338
  const conv = createConversation();
339
- const msg = addMessage(conv.id, 'assistant', 'No attachments');
339
+ const msg = await addMessage(conv.id, 'assistant', 'No attachments');
340
340
 
341
341
  const linked = getAttachmentsForMessage(msg.id);
342
342
  expect(linked).toHaveLength(0);
@@ -357,9 +357,9 @@ describe('deleteOrphanAttachments', () => {
357
357
  expect(removed).toBe(1);
358
358
  });
359
359
 
360
- test('preserves attachments that are still linked', () => {
360
+ test('preserves attachments that are still linked', async () => {
361
361
  const conv = createConversation();
362
- const msg = addMessage(conv.id, 'assistant', 'With attachment');
362
+ const msg = await addMessage(conv.id, 'assistant', 'With attachment');
363
363
  const stored = uploadAttachment('linked.txt', 'text/plain', 'ZGF0YQ==');
364
364
  linkAttachmentToMessage(msg.id, stored.id, 0);
365
365
 
@@ -370,9 +370,9 @@ describe('deleteOrphanAttachments', () => {
370
370
  expect(fetched).not.toBeNull();
371
371
  });
372
372
 
373
- test('removes only orphans when mixed candidates provided', () => {
373
+ test('removes only orphans when mixed candidates provided', async () => {
374
374
  const conv = createConversation();
375
- const msg = addMessage(conv.id, 'assistant', 'Mixed');
375
+ const msg = await addMessage(conv.id, 'assistant', 'Mixed');
376
376
  const linked = uploadAttachment('linked.txt', 'text/plain', 'AAAA');
377
377
  const orphan = uploadAttachment('orphan.txt', 'text/plain', 'BBBB');
378
378
  linkAttachmentToMessage(msg.id, linked.id, 0);