@vellumai/assistant 0.3.27 → 0.4.0

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 (247) hide show
  1. package/ARCHITECTURE.md +81 -4
  2. package/Dockerfile +2 -2
  3. package/bun.lock +4 -1
  4. package/docs/trusted-contact-access.md +9 -2
  5. package/package.json +6 -3
  6. package/scripts/ipc/generate-swift.ts +9 -5
  7. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +80 -0
  8. package/src/__tests__/agent-loop-thinking.test.ts +1 -1
  9. package/src/__tests__/agent-loop.test.ts +119 -0
  10. package/src/__tests__/approval-routes-http.test.ts +13 -5
  11. package/src/__tests__/asset-materialize-tool.test.ts +2 -0
  12. package/src/__tests__/asset-search-tool.test.ts +2 -0
  13. package/src/__tests__/assistant-events-sse-hardening.test.ts +4 -2
  14. package/src/__tests__/attachments-store.test.ts +2 -0
  15. package/src/__tests__/browser-skill-endstate.test.ts +3 -3
  16. package/src/__tests__/bundled-asset.test.ts +107 -0
  17. package/src/__tests__/call-controller.test.ts +30 -29
  18. package/src/__tests__/call-routes-http.test.ts +34 -32
  19. package/src/__tests__/call-start-guardian-guard.test.ts +2 -0
  20. package/src/__tests__/canonical-guardian-store.test.ts +636 -0
  21. package/src/__tests__/channel-approval-routes.test.ts +174 -1
  22. package/src/__tests__/channel-invite-transport.test.ts +6 -6
  23. package/src/__tests__/channel-reply-delivery.test.ts +19 -0
  24. package/src/__tests__/channel-retry-sweep.test.ts +130 -0
  25. package/src/__tests__/clarification-resolver.test.ts +2 -0
  26. package/src/__tests__/claude-code-skill-regression.test.ts +2 -0
  27. package/src/__tests__/claude-code-tool-profiles.test.ts +2 -0
  28. package/src/__tests__/commit-message-enrichment-service.test.ts +9 -1
  29. package/src/__tests__/computer-use-session-lifecycle.test.ts +2 -0
  30. package/src/__tests__/computer-use-session-working-dir.test.ts +1 -0
  31. package/src/__tests__/computer-use-skill-lifecycle-cleanup.test.ts +2 -0
  32. package/src/__tests__/config-schema.test.ts +5 -5
  33. package/src/__tests__/config-watcher.test.ts +3 -1
  34. package/src/__tests__/connection-policy.test.ts +14 -5
  35. package/src/__tests__/contacts-tools.test.ts +3 -1
  36. package/src/__tests__/contradiction-checker.test.ts +2 -0
  37. package/src/__tests__/conversation-pairing.test.ts +10 -0
  38. package/src/__tests__/conversation-routes.test.ts +1 -1
  39. package/src/__tests__/credential-security-invariants.test.ts +16 -6
  40. package/src/__tests__/credential-vault-unit.test.ts +2 -2
  41. package/src/__tests__/credential-vault.test.ts +5 -4
  42. package/src/__tests__/daemon-lifecycle.test.ts +9 -0
  43. package/src/__tests__/daemon-server-session-init.test.ts +27 -0
  44. package/src/__tests__/elevenlabs-config.test.ts +2 -0
  45. package/src/__tests__/emit-signal-routing-intent.test.ts +43 -1
  46. package/src/__tests__/encrypted-store.test.ts +10 -5
  47. package/src/__tests__/followup-tools.test.ts +3 -1
  48. package/src/__tests__/gateway-only-enforcement.test.ts +21 -21
  49. package/src/__tests__/gmail-integration.test.ts +0 -1
  50. package/src/__tests__/guardian-actions-endpoint.test.ts +205 -345
  51. package/src/__tests__/guardian-control-plane-policy.test.ts +19 -19
  52. package/src/__tests__/guardian-decision-primitive-canonical.test.ts +599 -0
  53. package/src/__tests__/guardian-dispatch.test.ts +21 -19
  54. package/src/__tests__/guardian-grant-minting.test.ts +68 -1
  55. package/src/__tests__/guardian-outbound-http.test.ts +12 -9
  56. package/src/__tests__/guardian-routing-invariants.test.ts +1092 -0
  57. package/src/__tests__/handle-user-message-secret-resume.test.ts +1 -0
  58. package/src/__tests__/handlers-slack-config.test.ts +3 -1
  59. package/src/__tests__/handlers-telegram-config.test.ts +3 -1
  60. package/src/__tests__/handlers-twilio-config.test.ts +3 -1
  61. package/src/__tests__/handlers-twitter-config.test.ts +3 -1
  62. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +318 -0
  63. package/src/__tests__/heartbeat-service.test.ts +20 -0
  64. package/src/__tests__/inbound-invite-redemption.test.ts +33 -0
  65. package/src/__tests__/ingress-reconcile.test.ts +3 -1
  66. package/src/__tests__/ingress-routes-http.test.ts +231 -4
  67. package/src/__tests__/intent-routing.test.ts +2 -0
  68. package/src/__tests__/ipc-snapshot.test.ts +13 -0
  69. package/src/__tests__/mcp-cli.test.ts +77 -0
  70. package/src/__tests__/media-generate-image.test.ts +21 -0
  71. package/src/__tests__/media-reuse-story.e2e.test.ts +2 -0
  72. package/src/__tests__/memory-regressions.test.ts +20 -20
  73. package/src/__tests__/non-member-access-request.test.ts +212 -36
  74. package/src/__tests__/notification-decision-fallback.test.ts +63 -3
  75. package/src/__tests__/notification-decision-strategy.test.ts +78 -0
  76. package/src/__tests__/notification-guardian-path.test.ts +15 -15
  77. package/src/__tests__/oauth-connect-handler.test.ts +3 -1
  78. package/src/__tests__/oauth2-gateway-transport.test.ts +2 -0
  79. package/src/__tests__/onboarding-starter-tasks.test.ts +4 -4
  80. package/src/__tests__/onboarding-template-contract.test.ts +116 -21
  81. package/src/__tests__/pairing-routes.test.ts +171 -0
  82. package/src/__tests__/playbook-execution.test.ts +3 -1
  83. package/src/__tests__/playbook-tools.test.ts +3 -1
  84. package/src/__tests__/provider-error-scenarios.test.ts +59 -8
  85. package/src/__tests__/proxy-approval-callback.test.ts +2 -0
  86. package/src/__tests__/recording-handler.test.ts +11 -0
  87. package/src/__tests__/recording-intent-handler.test.ts +15 -0
  88. package/src/__tests__/recording-state-machine.test.ts +13 -2
  89. package/src/__tests__/registry.test.ts +7 -3
  90. package/src/__tests__/relay-server.test.ts +148 -28
  91. package/src/__tests__/runtime-attachment-metadata.test.ts +4 -2
  92. package/src/__tests__/runtime-events-sse-parity.test.ts +21 -0
  93. package/src/__tests__/runtime-events-sse.test.ts +4 -2
  94. package/src/__tests__/sandbox-diagnostics.test.ts +2 -0
  95. package/src/__tests__/schedule-tools.test.ts +3 -1
  96. package/src/__tests__/secret-scanner-executor.test.ts +59 -0
  97. package/src/__tests__/secret-scanner.test.ts +8 -0
  98. package/src/__tests__/send-endpoint-busy.test.ts +4 -0
  99. package/src/__tests__/sensitive-output-placeholders.test.ts +208 -0
  100. package/src/__tests__/session-abort-tool-results.test.ts +23 -0
  101. package/src/__tests__/session-agent-loop.test.ts +16 -0
  102. package/src/__tests__/session-conflict-gate.test.ts +21 -0
  103. package/src/__tests__/session-load-history-repair.test.ts +27 -17
  104. package/src/__tests__/session-pre-run-repair.test.ts +23 -0
  105. package/src/__tests__/session-profile-injection.test.ts +21 -0
  106. package/src/__tests__/session-provider-retry-repair.test.ts +20 -0
  107. package/src/__tests__/session-queue.test.ts +23 -0
  108. package/src/__tests__/session-runtime-assembly.test.ts +126 -59
  109. package/src/__tests__/session-skill-tools.test.ts +27 -5
  110. package/src/__tests__/session-slash-known.test.ts +23 -0
  111. package/src/__tests__/session-slash-queue.test.ts +23 -0
  112. package/src/__tests__/session-slash-unknown.test.ts +23 -0
  113. package/src/__tests__/session-workspace-cache-state.test.ts +7 -0
  114. package/src/__tests__/session-workspace-injection.test.ts +21 -0
  115. package/src/__tests__/session-workspace-tool-tracking.test.ts +21 -0
  116. package/src/__tests__/shell-credential-ref.test.ts +2 -0
  117. package/src/__tests__/skill-feature-flags-integration.test.ts +6 -6
  118. package/src/__tests__/skill-load-feature-flag.test.ts +5 -4
  119. package/src/__tests__/skill-projection-feature-flag.test.ts +22 -0
  120. package/src/__tests__/skills.test.ts +8 -4
  121. package/src/__tests__/slack-channel-config.test.ts +3 -1
  122. package/src/__tests__/subagent-tools.test.ts +19 -0
  123. package/src/__tests__/swarm-recursion.test.ts +2 -0
  124. package/src/__tests__/swarm-session-integration.test.ts +2 -0
  125. package/src/__tests__/swarm-tool.test.ts +2 -0
  126. package/src/__tests__/system-prompt.test.ts +3 -1
  127. package/src/__tests__/task-compiler.test.ts +3 -1
  128. package/src/__tests__/task-management-tools.test.ts +3 -1
  129. package/src/__tests__/task-tools.test.ts +3 -1
  130. package/src/__tests__/terminal-sandbox.test.ts +13 -12
  131. package/src/__tests__/terminal-tools.test.ts +2 -0
  132. package/src/__tests__/tool-approval-handler.test.ts +15 -15
  133. package/src/__tests__/tool-execution-abort-cleanup.test.ts +2 -0
  134. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +2 -0
  135. package/src/__tests__/tool-grant-request-escalation.test.ts +497 -0
  136. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +48 -0
  137. package/src/__tests__/trusted-contact-multichannel.test.ts +22 -19
  138. package/src/__tests__/trusted-contact-verification.test.ts +91 -0
  139. package/src/__tests__/twilio-routes-elevenlabs.test.ts +2 -0
  140. package/src/__tests__/twitter-auth-handler.test.ts +3 -1
  141. package/src/__tests__/twitter-cli-routing.test.ts +3 -1
  142. package/src/__tests__/view-image-tool.test.ts +3 -1
  143. package/src/__tests__/voice-invite-redemption.test.ts +329 -0
  144. package/src/__tests__/voice-scoped-grant-consumer.test.ts +7 -5
  145. package/src/__tests__/voice-session-bridge.test.ts +10 -10
  146. package/src/__tests__/work-item-output.test.ts +3 -1
  147. package/src/__tests__/workspace-lifecycle.test.ts +13 -2
  148. package/src/agent/loop.ts +46 -3
  149. package/src/approvals/guardian-decision-primitive.ts +285 -0
  150. package/src/approvals/guardian-request-resolvers.ts +539 -0
  151. package/src/calls/call-controller.ts +26 -23
  152. package/src/calls/guardian-action-sweep.ts +10 -2
  153. package/src/calls/guardian-dispatch.ts +46 -40
  154. package/src/calls/relay-server.ts +358 -24
  155. package/src/calls/types.ts +1 -1
  156. package/src/calls/voice-session-bridge.ts +3 -3
  157. package/src/cli.ts +12 -0
  158. package/src/config/agent-schema.ts +14 -3
  159. package/src/config/calls-schema.ts +6 -6
  160. package/src/config/core-schema.ts +3 -3
  161. package/src/config/feature-flag-registry.json +8 -0
  162. package/src/config/mcp-schema.ts +1 -1
  163. package/src/config/memory-schema.ts +27 -19
  164. package/src/config/schema.ts +21 -21
  165. package/src/config/skills-schema.ts +7 -7
  166. package/src/config/system-prompt.ts +2 -1
  167. package/src/config/templates/BOOTSTRAP.md +47 -31
  168. package/src/config/templates/USER.md +5 -0
  169. package/src/config/update-bulletin-template-path.ts +4 -1
  170. package/src/config/vellum-skills/trusted-contacts/SKILL.md +149 -21
  171. package/src/daemon/handlers/config-inbox.ts +4 -4
  172. package/src/daemon/handlers/guardian-actions.ts +45 -66
  173. package/src/daemon/handlers/sessions.ts +148 -4
  174. package/src/daemon/ipc-contract/guardian-actions.ts +7 -0
  175. package/src/daemon/ipc-contract/messages.ts +16 -0
  176. package/src/daemon/ipc-contract-inventory.json +1 -0
  177. package/src/daemon/lifecycle.ts +22 -16
  178. package/src/daemon/pairing-store.ts +86 -3
  179. package/src/daemon/server.ts +18 -0
  180. package/src/daemon/session-agent-loop-handlers.ts +5 -4
  181. package/src/daemon/session-agent-loop.ts +33 -6
  182. package/src/daemon/session-lifecycle.ts +25 -17
  183. package/src/daemon/session-memory.ts +2 -2
  184. package/src/daemon/session-process.ts +68 -326
  185. package/src/daemon/session-runtime-assembly.ts +119 -25
  186. package/src/daemon/session-tool-setup.ts +3 -2
  187. package/src/daemon/session.ts +4 -3
  188. package/src/home-base/prebuilt/seed.ts +2 -1
  189. package/src/hooks/templates.ts +2 -1
  190. package/src/memory/canonical-guardian-store.ts +586 -0
  191. package/src/memory/channel-guardian-store.ts +2 -0
  192. package/src/memory/conversation-crud.ts +7 -7
  193. package/src/memory/db-init.ts +20 -0
  194. package/src/memory/embedding-local.ts +257 -39
  195. package/src/memory/embedding-runtime-manager.ts +471 -0
  196. package/src/memory/guardian-action-store.ts +7 -60
  197. package/src/memory/guardian-approvals.ts +9 -4
  198. package/src/memory/guardian-bindings.ts +25 -1
  199. package/src/memory/indexer.ts +3 -3
  200. package/src/memory/ingress-invite-store.ts +45 -0
  201. package/src/memory/job-handlers/backfill.ts +16 -9
  202. package/src/memory/migrations/036-normalize-phone-identities.ts +289 -0
  203. package/src/memory/migrations/037-voice-invite-columns.ts +16 -0
  204. package/src/memory/migrations/118-reminder-routing-intent.ts +3 -3
  205. package/src/memory/migrations/121-canonical-guardian-requests.ts +59 -0
  206. package/src/memory/migrations/122-canonical-guardian-requester-chat-id.ts +15 -0
  207. package/src/memory/migrations/123-canonical-guardian-deliveries-destination-index.ts +15 -0
  208. package/src/memory/migrations/index.ts +5 -0
  209. package/src/memory/migrations/registry.ts +5 -0
  210. package/src/memory/qdrant-client.ts +31 -22
  211. package/src/memory/schema-migration.ts +1 -0
  212. package/src/memory/schema.ts +56 -0
  213. package/src/notifications/copy-composer.ts +31 -4
  214. package/src/notifications/decision-engine.ts +57 -0
  215. package/src/permissions/defaults.ts +2 -0
  216. package/src/runtime/access-request-helper.ts +173 -0
  217. package/src/runtime/actor-trust-resolver.ts +221 -0
  218. package/src/runtime/channel-guardian-service.ts +12 -4
  219. package/src/runtime/channel-invite-transports/voice.ts +58 -0
  220. package/src/runtime/channel-retry-sweep.ts +18 -6
  221. package/src/runtime/guardian-context-resolver.ts +38 -71
  222. package/src/runtime/guardian-decision-types.ts +6 -0
  223. package/src/runtime/guardian-reply-router.ts +717 -0
  224. package/src/runtime/http-server.ts +8 -0
  225. package/src/runtime/ingress-service.ts +80 -3
  226. package/src/runtime/invite-redemption-service.ts +141 -2
  227. package/src/runtime/routes/canonical-guardian-expiry-sweep.ts +116 -0
  228. package/src/runtime/routes/channel-route-shared.ts +1 -1
  229. package/src/runtime/routes/channel-routes.ts +1 -1
  230. package/src/runtime/routes/conversation-routes.ts +20 -2
  231. package/src/runtime/routes/guardian-action-routes.ts +100 -109
  232. package/src/runtime/routes/guardian-approval-interception.ts +17 -6
  233. package/src/runtime/routes/inbound-message-handler.ts +205 -529
  234. package/src/runtime/routes/ingress-routes.ts +52 -4
  235. package/src/runtime/routes/pairing-routes.ts +3 -0
  236. package/src/runtime/tool-grant-request-helper.ts +195 -0
  237. package/src/tools/executor.ts +13 -1
  238. package/src/tools/guardian-control-plane-policy.ts +2 -2
  239. package/src/tools/sensitive-output-placeholders.ts +203 -0
  240. package/src/tools/tool-approval-handler.ts +53 -10
  241. package/src/tools/types.ts +13 -2
  242. package/src/util/bundled-asset.ts +31 -0
  243. package/src/util/canonicalize-identity.ts +52 -0
  244. package/src/util/logger.ts +20 -8
  245. package/src/util/platform.ts +10 -0
  246. package/src/util/voice-code.ts +29 -0
  247. package/src/daemon/guardian-invite-intent.ts +0 -124
@@ -0,0 +1,636 @@
1
+ import { mkdtempSync, rmSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+
5
+ import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test';
6
+
7
+ const testDir = mkdtempSync(join(tmpdir(), 'canonical-guardian-store-test-'));
8
+
9
+ mock.module('../util/platform.js', () => ({
10
+ getDataDir: () => testDir,
11
+ isMacOS: () => process.platform === 'darwin',
12
+ isLinux: () => process.platform === 'linux',
13
+ isWindows: () => process.platform === 'win32',
14
+ getSocketPath: () => join(testDir, 'test.sock'),
15
+ getPidPath: () => join(testDir, 'test.pid'),
16
+ getDbPath: () => join(testDir, 'test.db'),
17
+ getLogPath: () => join(testDir, 'test.log'),
18
+ ensureDataDir: () => {},
19
+ }));
20
+
21
+ mock.module('../util/logger.js', () => ({
22
+ getLogger: () =>
23
+ new Proxy({} as Record<string, unknown>, {
24
+ get: () => () => {},
25
+ }),
26
+ }));
27
+
28
+ import {
29
+ createCanonicalGuardianDelivery,
30
+ createCanonicalGuardianRequest,
31
+ getCanonicalGuardianRequest,
32
+ listCanonicalGuardianDeliveries,
33
+ listCanonicalGuardianRequests,
34
+ listPendingCanonicalGuardianRequestsByDestinationChat,
35
+ listPendingCanonicalGuardianRequestsByDestinationConversation,
36
+ resolveCanonicalGuardianRequest,
37
+ updateCanonicalGuardianDelivery,
38
+ updateCanonicalGuardianRequest,
39
+ } from '../memory/canonical-guardian-store.js';
40
+ import { getDb, initializeDb, resetDb } from '../memory/db.js';
41
+
42
+ initializeDb();
43
+
44
+ function resetTables(): void {
45
+ const db = getDb();
46
+ db.run('DELETE FROM canonical_guardian_deliveries');
47
+ db.run('DELETE FROM canonical_guardian_requests');
48
+ }
49
+
50
+ describe('canonical-guardian-store', () => {
51
+ beforeEach(() => {
52
+ resetTables();
53
+ });
54
+
55
+ afterAll(() => {
56
+ resetDb();
57
+ try {
58
+ rmSync(testDir, { recursive: true });
59
+ } catch {
60
+ // best-effort cleanup
61
+ }
62
+ });
63
+
64
+ // ── createCanonicalGuardianRequest ────────────────────────────────
65
+
66
+ test('creates a request with all fields populated', () => {
67
+ const req = createCanonicalGuardianRequest({
68
+ kind: 'tool_approval',
69
+ sourceType: 'voice',
70
+ sourceChannel: 'twilio',
71
+ conversationId: 'conv-1',
72
+ requesterExternalUserId: 'user-1',
73
+ guardianExternalUserId: 'guardian-1',
74
+ callSessionId: 'session-1',
75
+ pendingQuestionId: 'pq-1',
76
+ questionText: 'Can I run this tool?',
77
+ requestCode: 'ABC123',
78
+ toolName: 'file_edit',
79
+ inputDigest: 'sha256:deadbeef',
80
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
81
+ });
82
+
83
+ expect(req.id).toBeTruthy();
84
+ expect(req.kind).toBe('tool_approval');
85
+ expect(req.sourceType).toBe('voice');
86
+ expect(req.sourceChannel).toBe('twilio');
87
+ expect(req.status).toBe('pending');
88
+ expect(req.toolName).toBe('file_edit');
89
+ expect(req.createdAt).toBeTruthy();
90
+ expect(req.updatedAt).toBeTruthy();
91
+ });
92
+
93
+ test('creates a request with minimal fields', () => {
94
+ const req = createCanonicalGuardianRequest({
95
+ kind: 'access_request',
96
+ sourceType: 'channel',
97
+ });
98
+
99
+ expect(req.id).toBeTruthy();
100
+ expect(req.kind).toBe('access_request');
101
+ expect(req.sourceType).toBe('channel');
102
+ expect(req.sourceChannel).toBeNull();
103
+ expect(req.conversationId).toBeNull();
104
+ expect(req.toolName).toBeNull();
105
+ expect(req.status).toBe('pending');
106
+ });
107
+
108
+ // ── getCanonicalGuardianRequest ───────────────────────────────────
109
+
110
+ test('gets a request by ID', () => {
111
+ const created = createCanonicalGuardianRequest({
112
+ kind: 'tool_approval',
113
+ sourceType: 'voice',
114
+ });
115
+
116
+ const fetched = getCanonicalGuardianRequest(created.id);
117
+ expect(fetched).not.toBeNull();
118
+ expect(fetched!.id).toBe(created.id);
119
+ expect(fetched!.kind).toBe('tool_approval');
120
+ });
121
+
122
+ test('returns null for nonexistent ID', () => {
123
+ const fetched = getCanonicalGuardianRequest('nonexistent');
124
+ expect(fetched).toBeNull();
125
+ });
126
+
127
+ // ── listCanonicalGuardianRequests ─────────────────────────────────
128
+
129
+ test('lists all requests with no filters', () => {
130
+ createCanonicalGuardianRequest({ kind: 'tool_approval', sourceType: 'voice' });
131
+ createCanonicalGuardianRequest({ kind: 'access_request', sourceType: 'channel' });
132
+
133
+ const all = listCanonicalGuardianRequests();
134
+ expect(all).toHaveLength(2);
135
+ });
136
+
137
+ test('filters by status', () => {
138
+ createCanonicalGuardianRequest({ kind: 'tool_approval', sourceType: 'voice' });
139
+ const req2 = createCanonicalGuardianRequest({ kind: 'access_request', sourceType: 'channel' });
140
+ updateCanonicalGuardianRequest(req2.id, { status: 'approved' });
141
+
142
+ const pending = listCanonicalGuardianRequests({ status: 'pending' });
143
+ expect(pending).toHaveLength(1);
144
+ expect(pending[0].kind).toBe('tool_approval');
145
+
146
+ const approved = listCanonicalGuardianRequests({ status: 'approved' });
147
+ expect(approved).toHaveLength(1);
148
+ expect(approved[0].kind).toBe('access_request');
149
+ });
150
+
151
+ test('filters by guardianExternalUserId', () => {
152
+ createCanonicalGuardianRequest({
153
+ kind: 'tool_approval',
154
+ sourceType: 'voice',
155
+ guardianExternalUserId: 'guardian-A',
156
+ });
157
+ createCanonicalGuardianRequest({
158
+ kind: 'tool_approval',
159
+ sourceType: 'voice',
160
+ guardianExternalUserId: 'guardian-B',
161
+ });
162
+
163
+ const filtered = listCanonicalGuardianRequests({ guardianExternalUserId: 'guardian-A' });
164
+ expect(filtered).toHaveLength(1);
165
+ expect(filtered[0].guardianExternalUserId).toBe('guardian-A');
166
+ });
167
+
168
+ test('filters by conversationId', () => {
169
+ createCanonicalGuardianRequest({
170
+ kind: 'tool_approval',
171
+ sourceType: 'voice',
172
+ conversationId: 'conv-X',
173
+ });
174
+ createCanonicalGuardianRequest({
175
+ kind: 'tool_approval',
176
+ sourceType: 'voice',
177
+ conversationId: 'conv-Y',
178
+ });
179
+
180
+ const filtered = listCanonicalGuardianRequests({ conversationId: 'conv-X' });
181
+ expect(filtered).toHaveLength(1);
182
+ });
183
+
184
+ test('filters by sourceType', () => {
185
+ createCanonicalGuardianRequest({ kind: 'tool_approval', sourceType: 'voice' });
186
+ createCanonicalGuardianRequest({ kind: 'tool_approval', sourceType: 'channel' });
187
+ createCanonicalGuardianRequest({ kind: 'tool_approval', sourceType: 'desktop' });
188
+
189
+ const voiceOnly = listCanonicalGuardianRequests({ sourceType: 'voice' });
190
+ expect(voiceOnly).toHaveLength(1);
191
+ });
192
+
193
+ test('filters by kind', () => {
194
+ createCanonicalGuardianRequest({ kind: 'tool_approval', sourceType: 'voice' });
195
+ createCanonicalGuardianRequest({ kind: 'pending_question', sourceType: 'voice' });
196
+ createCanonicalGuardianRequest({ kind: 'access_request', sourceType: 'channel' });
197
+
198
+ const toolOnly = listCanonicalGuardianRequests({ kind: 'tool_approval' });
199
+ expect(toolOnly).toHaveLength(1);
200
+ });
201
+
202
+ test('combines multiple filters', () => {
203
+ createCanonicalGuardianRequest({
204
+ kind: 'tool_approval',
205
+ sourceType: 'voice',
206
+ guardianExternalUserId: 'guardian-A',
207
+ });
208
+ createCanonicalGuardianRequest({
209
+ kind: 'tool_approval',
210
+ sourceType: 'channel',
211
+ guardianExternalUserId: 'guardian-A',
212
+ });
213
+ createCanonicalGuardianRequest({
214
+ kind: 'access_request',
215
+ sourceType: 'voice',
216
+ guardianExternalUserId: 'guardian-A',
217
+ });
218
+
219
+ const filtered = listCanonicalGuardianRequests({
220
+ kind: 'tool_approval',
221
+ sourceType: 'voice',
222
+ guardianExternalUserId: 'guardian-A',
223
+ });
224
+ expect(filtered).toHaveLength(1);
225
+ });
226
+
227
+ // ── updateCanonicalGuardianRequest ────────────────────────────────
228
+
229
+ test('updates request fields', () => {
230
+ const req = createCanonicalGuardianRequest({
231
+ kind: 'tool_approval',
232
+ sourceType: 'voice',
233
+ });
234
+
235
+ const updated = updateCanonicalGuardianRequest(req.id, {
236
+ status: 'approved',
237
+ answerText: 'Looks good',
238
+ decidedByExternalUserId: 'guardian-1',
239
+ });
240
+
241
+ expect(updated).not.toBeNull();
242
+ expect(updated!.status).toBe('approved');
243
+ expect(updated!.answerText).toBe('Looks good');
244
+ expect(updated!.decidedByExternalUserId).toBe('guardian-1');
245
+ // updatedAt should be at least as recent as the original (may be the
246
+ // same millisecond when create+update run back-to-back in tests).
247
+ expect(new Date(updated!.updatedAt).getTime()).toBeGreaterThanOrEqual(
248
+ new Date(req.updatedAt).getTime(),
249
+ );
250
+ });
251
+
252
+ test('returns null when updating nonexistent request', () => {
253
+ const updated = updateCanonicalGuardianRequest('nonexistent', { status: 'approved' });
254
+ expect(updated).toBeNull();
255
+ });
256
+
257
+ // ── resolveCanonicalGuardianRequest (CAS) ─────────────────────────
258
+
259
+ test('resolves a pending request to approved', () => {
260
+ const req = createCanonicalGuardianRequest({
261
+ kind: 'tool_approval',
262
+ sourceType: 'voice',
263
+ });
264
+
265
+ const resolved = resolveCanonicalGuardianRequest(req.id, 'pending', {
266
+ status: 'approved',
267
+ answerText: 'Approved by guardian',
268
+ decidedByExternalUserId: 'guardian-1',
269
+ });
270
+
271
+ expect(resolved).not.toBeNull();
272
+ expect(resolved!.status).toBe('approved');
273
+ expect(resolved!.answerText).toBe('Approved by guardian');
274
+ expect(resolved!.decidedByExternalUserId).toBe('guardian-1');
275
+ });
276
+
277
+ test('resolves a pending request to denied', () => {
278
+ const req = createCanonicalGuardianRequest({
279
+ kind: 'tool_approval',
280
+ sourceType: 'channel',
281
+ });
282
+
283
+ const resolved = resolveCanonicalGuardianRequest(req.id, 'pending', {
284
+ status: 'denied',
285
+ answerText: 'Not allowed',
286
+ });
287
+
288
+ expect(resolved).not.toBeNull();
289
+ expect(resolved!.status).toBe('denied');
290
+ });
291
+
292
+ test('CAS fails when expectedStatus does not match', () => {
293
+ const req = createCanonicalGuardianRequest({
294
+ kind: 'tool_approval',
295
+ sourceType: 'voice',
296
+ });
297
+
298
+ // Try to resolve with wrong expected status
299
+ const result = resolveCanonicalGuardianRequest(req.id, 'approved', {
300
+ status: 'denied',
301
+ });
302
+
303
+ expect(result).toBeNull();
304
+
305
+ // Verify the request is unchanged
306
+ const unchanged = getCanonicalGuardianRequest(req.id);
307
+ expect(unchanged!.status).toBe('pending');
308
+ });
309
+
310
+ test('CAS race condition: two concurrent resolves, only one succeeds', () => {
311
+ const req = createCanonicalGuardianRequest({
312
+ kind: 'tool_approval',
313
+ sourceType: 'voice',
314
+ });
315
+
316
+ // First resolve succeeds
317
+ const first = resolveCanonicalGuardianRequest(req.id, 'pending', {
318
+ status: 'approved',
319
+ answerText: 'First approver',
320
+ decidedByExternalUserId: 'guardian-1',
321
+ });
322
+ expect(first).not.toBeNull();
323
+ expect(first!.status).toBe('approved');
324
+
325
+ // Second resolve fails because status is no longer 'pending'
326
+ const second = resolveCanonicalGuardianRequest(req.id, 'pending', {
327
+ status: 'denied',
328
+ answerText: 'Second denier',
329
+ decidedByExternalUserId: 'guardian-2',
330
+ });
331
+ expect(second).toBeNull();
332
+
333
+ // Verify the first decision stuck
334
+ const final = getCanonicalGuardianRequest(req.id);
335
+ expect(final!.status).toBe('approved');
336
+ expect(final!.answerText).toBe('First approver');
337
+ expect(final!.decidedByExternalUserId).toBe('guardian-1');
338
+ });
339
+
340
+ test('CAS returns null for nonexistent request', () => {
341
+ const result = resolveCanonicalGuardianRequest('nonexistent', 'pending', {
342
+ status: 'approved',
343
+ });
344
+ expect(result).toBeNull();
345
+ });
346
+
347
+ // ── Voice-originated and channel-originated request shapes ────────
348
+
349
+ test('voice-originated request shape is representable', () => {
350
+ const req = createCanonicalGuardianRequest({
351
+ kind: 'pending_question',
352
+ sourceType: 'voice',
353
+ sourceChannel: 'twilio',
354
+ conversationId: 'conv-voice-1',
355
+ guardianExternalUserId: 'guardian-phone',
356
+ callSessionId: 'call-123',
357
+ pendingQuestionId: 'pq-456',
358
+ questionText: 'What is the gate code?',
359
+ requestCode: 'A1B2C3',
360
+ expiresAt: new Date(Date.now() + 30_000).toISOString(),
361
+ });
362
+
363
+ expect(req.sourceType).toBe('voice');
364
+ expect(req.callSessionId).toBe('call-123');
365
+ expect(req.pendingQuestionId).toBe('pq-456');
366
+ expect(req.requestCode).toBe('A1B2C3');
367
+ });
368
+
369
+ test('channel-originated request shape is representable', () => {
370
+ const req = createCanonicalGuardianRequest({
371
+ kind: 'tool_approval',
372
+ sourceType: 'channel',
373
+ sourceChannel: 'telegram',
374
+ conversationId: 'conv-tg-1',
375
+ requesterExternalUserId: 'requester-tg-user',
376
+ guardianExternalUserId: 'guardian-tg-user',
377
+ toolName: 'execute_code',
378
+ inputDigest: 'sha256:abcdef',
379
+ expiresAt: new Date(Date.now() + 120_000).toISOString(),
380
+ });
381
+
382
+ expect(req.sourceType).toBe('channel');
383
+ expect(req.sourceChannel).toBe('telegram');
384
+ expect(req.requesterExternalUserId).toBe('requester-tg-user');
385
+ expect(req.toolName).toBe('execute_code');
386
+ // Voice-specific fields are null for channel requests
387
+ expect(req.callSessionId).toBeNull();
388
+ expect(req.pendingQuestionId).toBeNull();
389
+ });
390
+
391
+ test('desktop-originated request shape is representable', () => {
392
+ const req = createCanonicalGuardianRequest({
393
+ kind: 'access_request',
394
+ sourceType: 'desktop',
395
+ conversationId: 'conv-desktop-1',
396
+ guardianExternalUserId: 'guardian-desktop',
397
+ questionText: 'User wants to access settings',
398
+ });
399
+
400
+ expect(req.sourceType).toBe('desktop');
401
+ expect(req.sourceChannel).toBeNull();
402
+ expect(req.callSessionId).toBeNull();
403
+ });
404
+
405
+ // ── Canonical Guardian Deliveries ─────────────────────────────────
406
+
407
+ test('creates and lists deliveries for a request', () => {
408
+ const req = createCanonicalGuardianRequest({
409
+ kind: 'tool_approval',
410
+ sourceType: 'voice',
411
+ });
412
+
413
+ const d1 = createCanonicalGuardianDelivery({
414
+ requestId: req.id,
415
+ destinationChannel: 'telegram',
416
+ destinationChatId: 'chat-123',
417
+ });
418
+ createCanonicalGuardianDelivery({
419
+ requestId: req.id,
420
+ destinationChannel: 'sms',
421
+ destinationChatId: 'chat-456',
422
+ });
423
+
424
+ expect(d1.id).toBeTruthy();
425
+ expect(d1.requestId).toBe(req.id);
426
+ expect(d1.destinationChannel).toBe('telegram');
427
+ expect(d1.status).toBe('pending');
428
+
429
+ const deliveries = listCanonicalGuardianDeliveries(req.id);
430
+ expect(deliveries).toHaveLength(2);
431
+ const channels = deliveries.map((d) => d.destinationChannel).sort();
432
+ expect(channels).toEqual(['sms', 'telegram']);
433
+ });
434
+
435
+ test('lists empty deliveries for a request with none', () => {
436
+ const req = createCanonicalGuardianRequest({
437
+ kind: 'tool_approval',
438
+ sourceType: 'voice',
439
+ });
440
+
441
+ const deliveries = listCanonicalGuardianDeliveries(req.id);
442
+ expect(deliveries).toHaveLength(0);
443
+ });
444
+
445
+ test('lists pending requests by destination conversation', () => {
446
+ const pendingReq = createCanonicalGuardianRequest({
447
+ kind: 'pending_question',
448
+ sourceType: 'voice',
449
+ });
450
+ const resolvedReq = createCanonicalGuardianRequest({
451
+ kind: 'pending_question',
452
+ sourceType: 'voice',
453
+ });
454
+ updateCanonicalGuardianRequest(resolvedReq.id, { status: 'approved' });
455
+
456
+ createCanonicalGuardianDelivery({
457
+ requestId: pendingReq.id,
458
+ destinationChannel: 'vellum',
459
+ destinationConversationId: 'conv-guardian-1',
460
+ });
461
+ createCanonicalGuardianDelivery({
462
+ requestId: resolvedReq.id,
463
+ destinationChannel: 'vellum',
464
+ destinationConversationId: 'conv-guardian-1',
465
+ });
466
+
467
+ const pending = listPendingCanonicalGuardianRequestsByDestinationConversation(
468
+ 'conv-guardian-1',
469
+ 'vellum',
470
+ );
471
+ expect(pending).toHaveLength(1);
472
+ expect(pending[0].id).toBe(pendingReq.id);
473
+ });
474
+
475
+ test('destination conversation lookup deduplicates request IDs', () => {
476
+ const req = createCanonicalGuardianRequest({
477
+ kind: 'pending_question',
478
+ sourceType: 'voice',
479
+ });
480
+
481
+ createCanonicalGuardianDelivery({
482
+ requestId: req.id,
483
+ destinationChannel: 'vellum',
484
+ destinationConversationId: 'conv-guardian-2',
485
+ });
486
+ createCanonicalGuardianDelivery({
487
+ requestId: req.id,
488
+ destinationChannel: 'telegram',
489
+ destinationConversationId: 'conv-guardian-2',
490
+ });
491
+
492
+ const pending = listPendingCanonicalGuardianRequestsByDestinationConversation('conv-guardian-2');
493
+ expect(pending).toHaveLength(1);
494
+ expect(pending[0].id).toBe(req.id);
495
+ });
496
+
497
+ test('updates delivery status', () => {
498
+ const req = createCanonicalGuardianRequest({
499
+ kind: 'tool_approval',
500
+ sourceType: 'voice',
501
+ });
502
+ const delivery = createCanonicalGuardianDelivery({
503
+ requestId: req.id,
504
+ destinationChannel: 'telegram',
505
+ });
506
+
507
+ const updated = updateCanonicalGuardianDelivery(delivery.id, {
508
+ status: 'sent',
509
+ destinationMessageId: 'msg-789',
510
+ });
511
+
512
+ expect(updated).not.toBeNull();
513
+ expect(updated!.status).toBe('sent');
514
+ expect(updated!.destinationMessageId).toBe('msg-789');
515
+ });
516
+
517
+ test('returns null when updating nonexistent delivery', () => {
518
+ const updated = updateCanonicalGuardianDelivery('nonexistent', { status: 'sent' });
519
+ expect(updated).toBeNull();
520
+ });
521
+
522
+ // ── listPendingCanonicalGuardianRequestsByDestinationChat ──────────
523
+
524
+ test('returns pending requests matching (destinationChannel, destinationChatId)', () => {
525
+ const req = createCanonicalGuardianRequest({
526
+ kind: 'pending_question',
527
+ sourceType: 'voice',
528
+ });
529
+ createCanonicalGuardianDelivery({
530
+ requestId: req.id,
531
+ destinationChannel: 'telegram',
532
+ destinationChatId: 'guardian-chat-100',
533
+ });
534
+
535
+ const pending = listPendingCanonicalGuardianRequestsByDestinationChat(
536
+ 'telegram',
537
+ 'guardian-chat-100',
538
+ );
539
+ expect(pending).toHaveLength(1);
540
+ expect(pending[0].id).toBe(req.id);
541
+ });
542
+
543
+ test('excludes non-pending requests from destination chat lookup', () => {
544
+ const pendingReq = createCanonicalGuardianRequest({
545
+ kind: 'pending_question',
546
+ sourceType: 'voice',
547
+ });
548
+ const resolvedReq = createCanonicalGuardianRequest({
549
+ kind: 'pending_question',
550
+ sourceType: 'voice',
551
+ });
552
+ updateCanonicalGuardianRequest(resolvedReq.id, { status: 'approved' });
553
+
554
+ createCanonicalGuardianDelivery({
555
+ requestId: pendingReq.id,
556
+ destinationChannel: 'telegram',
557
+ destinationChatId: 'guardian-chat-200',
558
+ });
559
+ createCanonicalGuardianDelivery({
560
+ requestId: resolvedReq.id,
561
+ destinationChannel: 'telegram',
562
+ destinationChatId: 'guardian-chat-200',
563
+ });
564
+
565
+ const pending = listPendingCanonicalGuardianRequestsByDestinationChat(
566
+ 'telegram',
567
+ 'guardian-chat-200',
568
+ );
569
+ expect(pending).toHaveLength(1);
570
+ expect(pending[0].id).toBe(pendingReq.id);
571
+ });
572
+
573
+ test('deduplicates when multiple delivery rows point to same request', () => {
574
+ const req = createCanonicalGuardianRequest({
575
+ kind: 'pending_question',
576
+ sourceType: 'voice',
577
+ });
578
+
579
+ // Two delivery rows targeting the same chat for the same request
580
+ createCanonicalGuardianDelivery({
581
+ requestId: req.id,
582
+ destinationChannel: 'telegram',
583
+ destinationChatId: 'guardian-chat-300',
584
+ destinationMessageId: 'msg-1',
585
+ });
586
+ createCanonicalGuardianDelivery({
587
+ requestId: req.id,
588
+ destinationChannel: 'telegram',
589
+ destinationChatId: 'guardian-chat-300',
590
+ destinationMessageId: 'msg-2',
591
+ });
592
+
593
+ const pending = listPendingCanonicalGuardianRequestsByDestinationChat(
594
+ 'telegram',
595
+ 'guardian-chat-300',
596
+ );
597
+ expect(pending).toHaveLength(1);
598
+ expect(pending[0].id).toBe(req.id);
599
+ });
600
+
601
+ test('channel mismatch does not match in destination chat lookup', () => {
602
+ const req = createCanonicalGuardianRequest({
603
+ kind: 'pending_question',
604
+ sourceType: 'voice',
605
+ });
606
+ createCanonicalGuardianDelivery({
607
+ requestId: req.id,
608
+ destinationChannel: 'telegram',
609
+ destinationChatId: 'guardian-chat-400',
610
+ });
611
+
612
+ const pending = listPendingCanonicalGuardianRequestsByDestinationChat(
613
+ 'sms',
614
+ 'guardian-chat-400',
615
+ );
616
+ expect(pending).toHaveLength(0);
617
+ });
618
+
619
+ test('chat mismatch does not match in destination chat lookup', () => {
620
+ const req = createCanonicalGuardianRequest({
621
+ kind: 'pending_question',
622
+ sourceType: 'voice',
623
+ });
624
+ createCanonicalGuardianDelivery({
625
+ requestId: req.id,
626
+ destinationChannel: 'telegram',
627
+ destinationChatId: 'guardian-chat-500',
628
+ });
629
+
630
+ const pending = listPendingCanonicalGuardianRequestsByDestinationChat(
631
+ 'telegram',
632
+ 'different-chat-id',
633
+ );
634
+ expect(pending).toHaveLength(0);
635
+ });
636
+ });