@vellumai/assistant 0.3.27 → 0.3.28

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 (82) hide show
  1. package/ARCHITECTURE.md +48 -1
  2. package/Dockerfile +2 -2
  3. package/package.json +1 -1
  4. package/scripts/ipc/generate-swift.ts +6 -2
  5. package/src/__tests__/agent-loop.test.ts +119 -0
  6. package/src/__tests__/bundled-asset.test.ts +107 -0
  7. package/src/__tests__/canonical-guardian-store.test.ts +636 -0
  8. package/src/__tests__/channel-approval-routes.test.ts +174 -1
  9. package/src/__tests__/emit-signal-routing-intent.test.ts +43 -1
  10. package/src/__tests__/guardian-actions-endpoint.test.ts +205 -345
  11. package/src/__tests__/guardian-decision-primitive-canonical.test.ts +599 -0
  12. package/src/__tests__/guardian-dispatch.test.ts +19 -19
  13. package/src/__tests__/guardian-routing-invariants.test.ts +954 -0
  14. package/src/__tests__/mcp-cli.test.ts +77 -0
  15. package/src/__tests__/non-member-access-request.test.ts +31 -29
  16. package/src/__tests__/notification-decision-fallback.test.ts +61 -3
  17. package/src/__tests__/notification-decision-strategy.test.ts +17 -0
  18. package/src/__tests__/notification-guardian-path.test.ts +13 -15
  19. package/src/__tests__/onboarding-template-contract.test.ts +116 -21
  20. package/src/__tests__/secret-scanner-executor.test.ts +59 -0
  21. package/src/__tests__/secret-scanner.test.ts +8 -0
  22. package/src/__tests__/sensitive-output-placeholders.test.ts +208 -0
  23. package/src/__tests__/session-runtime-assembly.test.ts +76 -47
  24. package/src/__tests__/tool-grant-request-escalation.test.ts +497 -0
  25. package/src/agent/loop.ts +46 -3
  26. package/src/approvals/guardian-decision-primitive.ts +285 -0
  27. package/src/approvals/guardian-request-resolvers.ts +539 -0
  28. package/src/calls/guardian-dispatch.ts +46 -40
  29. package/src/calls/relay-server.ts +147 -2
  30. package/src/calls/types.ts +1 -1
  31. package/src/config/system-prompt.ts +2 -1
  32. package/src/config/templates/BOOTSTRAP.md +47 -31
  33. package/src/config/templates/USER.md +5 -0
  34. package/src/config/update-bulletin-template-path.ts +4 -1
  35. package/src/config/vellum-skills/trusted-contacts/SKILL.md +22 -17
  36. package/src/daemon/handlers/guardian-actions.ts +45 -66
  37. package/src/daemon/ipc-contract/guardian-actions.ts +7 -0
  38. package/src/daemon/lifecycle.ts +3 -16
  39. package/src/daemon/server.ts +18 -0
  40. package/src/daemon/session-agent-loop-handlers.ts +5 -4
  41. package/src/daemon/session-agent-loop.ts +32 -5
  42. package/src/daemon/session-process.ts +68 -307
  43. package/src/daemon/session-runtime-assembly.ts +112 -24
  44. package/src/daemon/session-tool-setup.ts +1 -0
  45. package/src/daemon/session.ts +1 -0
  46. package/src/home-base/prebuilt/seed.ts +2 -1
  47. package/src/hooks/templates.ts +2 -1
  48. package/src/memory/canonical-guardian-store.ts +524 -0
  49. package/src/memory/channel-guardian-store.ts +1 -0
  50. package/src/memory/db-init.ts +16 -0
  51. package/src/memory/guardian-action-store.ts +7 -60
  52. package/src/memory/guardian-approvals.ts +9 -4
  53. package/src/memory/migrations/036-normalize-phone-identities.ts +289 -0
  54. package/src/memory/migrations/118-reminder-routing-intent.ts +3 -3
  55. package/src/memory/migrations/121-canonical-guardian-requests.ts +59 -0
  56. package/src/memory/migrations/122-canonical-guardian-requester-chat-id.ts +15 -0
  57. package/src/memory/migrations/123-canonical-guardian-deliveries-destination-index.ts +15 -0
  58. package/src/memory/migrations/index.ts +4 -0
  59. package/src/memory/migrations/registry.ts +5 -0
  60. package/src/memory/schema-migration.ts +1 -0
  61. package/src/memory/schema.ts +52 -0
  62. package/src/notifications/copy-composer.ts +16 -4
  63. package/src/notifications/decision-engine.ts +57 -0
  64. package/src/permissions/defaults.ts +2 -0
  65. package/src/runtime/access-request-helper.ts +137 -0
  66. package/src/runtime/actor-trust-resolver.ts +225 -0
  67. package/src/runtime/channel-guardian-service.ts +12 -4
  68. package/src/runtime/guardian-context-resolver.ts +32 -7
  69. package/src/runtime/guardian-decision-types.ts +6 -0
  70. package/src/runtime/guardian-reply-router.ts +687 -0
  71. package/src/runtime/http-server.ts +8 -0
  72. package/src/runtime/routes/canonical-guardian-expiry-sweep.ts +116 -0
  73. package/src/runtime/routes/conversation-routes.ts +18 -0
  74. package/src/runtime/routes/guardian-action-routes.ts +100 -109
  75. package/src/runtime/routes/inbound-message-handler.ts +170 -525
  76. package/src/runtime/tool-grant-request-helper.ts +195 -0
  77. package/src/tools/executor.ts +13 -1
  78. package/src/tools/sensitive-output-placeholders.ts +203 -0
  79. package/src/tools/tool-approval-handler.ts +44 -1
  80. package/src/tools/types.ts +11 -0
  81. package/src/util/bundled-asset.ts +31 -0
  82. package/src/util/canonicalize-identity.ts +52 -0
@@ -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
+ });