@vellumai/assistant 0.3.16 → 0.3.19

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 (114) hide show
  1. package/ARCHITECTURE.md +74 -13
  2. package/README.md +6 -0
  3. package/docs/architecture/http-token-refresh.md +23 -1
  4. package/docs/architecture/security.md +80 -0
  5. package/package.json +1 -1
  6. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +4 -0
  7. package/src/__tests__/access-request-decision.test.ts +4 -7
  8. package/src/__tests__/call-controller.test.ts +170 -0
  9. package/src/__tests__/channel-guardian.test.ts +3 -1
  10. package/src/__tests__/checker.test.ts +139 -48
  11. package/src/__tests__/config-watcher.test.ts +11 -13
  12. package/src/__tests__/conversation-pairing.test.ts +103 -3
  13. package/src/__tests__/guardian-action-conversation-turn.test.ts +1 -1
  14. package/src/__tests__/guardian-action-followup-executor.test.ts +1 -1
  15. package/src/__tests__/guardian-action-grant-mint-consume.test.ts +511 -0
  16. package/src/__tests__/guardian-action-late-reply.test.ts +131 -0
  17. package/src/__tests__/guardian-action-store.test.ts +182 -0
  18. package/src/__tests__/guardian-dispatch.test.ts +180 -0
  19. package/src/__tests__/guardian-grant-minting.test.ts +543 -0
  20. package/src/__tests__/ipc-snapshot.test.ts +22 -0
  21. package/src/__tests__/non-member-access-request.test.ts +1 -2
  22. package/src/__tests__/notification-broadcaster.test.ts +115 -4
  23. package/src/__tests__/notification-decision-strategy.test.ts +2 -1
  24. package/src/__tests__/notification-deep-link.test.ts +44 -1
  25. package/src/__tests__/notification-guardian-path.test.ts +157 -0
  26. package/src/__tests__/notification-thread-candidate-validation.test.ts +215 -0
  27. package/src/__tests__/remote-skill-policy.test.ts +215 -0
  28. package/src/__tests__/scoped-approval-grants.test.ts +521 -0
  29. package/src/__tests__/scoped-grant-security-matrix.test.ts +443 -0
  30. package/src/__tests__/slack-channel-config.test.ts +3 -3
  31. package/src/__tests__/trust-store.test.ts +23 -21
  32. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +5 -7
  33. package/src/__tests__/trusted-contact-multichannel.test.ts +2 -6
  34. package/src/__tests__/trusted-contact-verification.test.ts +9 -9
  35. package/src/__tests__/update-bulletin-state.test.ts +1 -1
  36. package/src/__tests__/update-bulletin.test.ts +66 -3
  37. package/src/__tests__/update-template-contract.test.ts +6 -11
  38. package/src/__tests__/voice-scoped-grant-consumer.test.ts +571 -0
  39. package/src/__tests__/voice-session-bridge.test.ts +109 -9
  40. package/src/calls/call-controller.ts +150 -8
  41. package/src/calls/call-domain.ts +12 -0
  42. package/src/calls/guardian-action-sweep.ts +1 -1
  43. package/src/calls/guardian-dispatch.ts +16 -0
  44. package/src/calls/relay-server.ts +13 -0
  45. package/src/calls/voice-session-bridge.ts +46 -5
  46. package/src/cli/core-commands.ts +41 -1
  47. package/src/config/bundled-skills/notifications/SKILL.md +18 -0
  48. package/src/config/schema.ts +6 -0
  49. package/src/config/skills-schema.ts +27 -0
  50. package/src/config/templates/UPDATES.md +5 -6
  51. package/src/config/update-bulletin-format.ts +2 -0
  52. package/src/config/update-bulletin-state.ts +1 -1
  53. package/src/config/update-bulletin-template-path.ts +6 -0
  54. package/src/config/update-bulletin.ts +21 -6
  55. package/src/daemon/config-watcher.ts +3 -2
  56. package/src/daemon/daemon-control.ts +64 -10
  57. package/src/daemon/handlers/config-channels.ts +18 -0
  58. package/src/daemon/handlers/config-slack-channel.ts +1 -1
  59. package/src/daemon/handlers/identity.ts +45 -25
  60. package/src/daemon/handlers/sessions.ts +1 -1
  61. package/src/daemon/handlers/skills.ts +45 -2
  62. package/src/daemon/ipc-contract/sessions.ts +1 -1
  63. package/src/daemon/ipc-contract/skills.ts +1 -0
  64. package/src/daemon/ipc-contract/workspace.ts +12 -1
  65. package/src/daemon/ipc-contract-inventory.json +1 -0
  66. package/src/daemon/lifecycle.ts +8 -0
  67. package/src/daemon/server.ts +25 -3
  68. package/src/daemon/session-process.ts +450 -184
  69. package/src/daemon/tls-certs.ts +17 -12
  70. package/src/daemon/tool-side-effects.ts +1 -1
  71. package/src/memory/channel-delivery-store.ts +18 -20
  72. package/src/memory/channel-guardian-store.ts +39 -42
  73. package/src/memory/conversation-crud.ts +2 -2
  74. package/src/memory/conversation-queries.ts +2 -2
  75. package/src/memory/conversation-store.ts +24 -25
  76. package/src/memory/db-init.ts +17 -1
  77. package/src/memory/embedding-local.ts +16 -7
  78. package/src/memory/fts-reconciler.ts +41 -26
  79. package/src/memory/guardian-action-store.ts +65 -7
  80. package/src/memory/guardian-verification.ts +1 -0
  81. package/src/memory/jobs-worker.ts +2 -2
  82. package/src/memory/migrations/032-guardian-delivery-conversation-index.ts +15 -0
  83. package/src/memory/migrations/032-notification-delivery-thread-decision.ts +20 -0
  84. package/src/memory/migrations/033-scoped-approval-grants.ts +51 -0
  85. package/src/memory/migrations/034-guardian-action-tool-metadata.ts +12 -0
  86. package/src/memory/migrations/index.ts +6 -2
  87. package/src/memory/schema-migration.ts +1 -0
  88. package/src/memory/schema.ts +36 -1
  89. package/src/memory/scoped-approval-grants.ts +509 -0
  90. package/src/memory/search/semantic.ts +3 -3
  91. package/src/notifications/README.md +158 -17
  92. package/src/notifications/broadcaster.ts +68 -50
  93. package/src/notifications/conversation-pairing.ts +96 -18
  94. package/src/notifications/decision-engine.ts +6 -3
  95. package/src/notifications/deliveries-store.ts +12 -0
  96. package/src/notifications/emit-signal.ts +1 -0
  97. package/src/notifications/thread-candidates.ts +60 -25
  98. package/src/notifications/types.ts +2 -1
  99. package/src/permissions/checker.ts +28 -16
  100. package/src/permissions/defaults.ts +14 -4
  101. package/src/runtime/guardian-action-followup-executor.ts +1 -1
  102. package/src/runtime/guardian-action-grant-minter.ts +97 -0
  103. package/src/runtime/http-server.ts +11 -11
  104. package/src/runtime/routes/access-request-decision.ts +1 -1
  105. package/src/runtime/routes/debug-routes.ts +4 -4
  106. package/src/runtime/routes/guardian-approval-interception.ts +120 -4
  107. package/src/runtime/routes/inbound-message-handler.ts +100 -33
  108. package/src/runtime/routes/integration-routes.ts +2 -2
  109. package/src/security/tool-approval-digest.ts +67 -0
  110. package/src/skills/remote-skill-policy.ts +131 -0
  111. package/src/tools/permission-checker.ts +1 -2
  112. package/src/tools/secret-detection-handler.ts +1 -1
  113. package/src/tools/system/voice-config.ts +1 -1
  114. package/src/version.ts +29 -2
@@ -0,0 +1,511 @@
1
+ /**
2
+ * Integration test: guardian-action answer resolution mints a scoped grant
3
+ * that the voice consumer can consume exactly once.
4
+ *
5
+ * Exercises the original voice bug scenario end-to-end:
6
+ * 1. Voice ASK_GUARDIAN fires -> guardian action request created with tool metadata
7
+ * 2. Guardian answers via desktop/Telegram -> request resolved
8
+ * 3. tryMintGuardianActionGrant mints a tool_signature grant
9
+ * 4. Voice consumer can consume the grant for the same tool+input
10
+ * 5. Second consume attempt is denied (one-time use)
11
+ * 6. Grant for a different assistantId is not consumable
12
+ */
13
+
14
+ import { mkdtempSync, rmSync } from 'node:fs';
15
+ import { tmpdir } from 'node:os';
16
+ import { join } from 'node:path';
17
+
18
+ import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test';
19
+
20
+ const testDir = mkdtempSync(join(tmpdir(), 'guardian-action-grant-e2e-'));
21
+
22
+ // ── Platform + logger mocks ─────────────────────────────────────────
23
+
24
+ mock.module('../util/platform.js', () => ({
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
+ migrateToDataLayout: () => {},
35
+ migrateToWorkspaceLayout: () => {},
36
+ }));
37
+
38
+ mock.module('../util/logger.js', () => ({
39
+ getLogger: () =>
40
+ new Proxy({} as Record<string, unknown>, {
41
+ get: () => () => {},
42
+ }),
43
+ isDebug: () => false,
44
+ truncateForLog: (value: string) => value,
45
+ }));
46
+
47
+ // ── Imports (after mocks) ───────────────────────────────────────────
48
+
49
+ import {
50
+ createGuardianActionRequest,
51
+ resolveGuardianActionRequest,
52
+ } from '../memory/guardian-action-store.js';
53
+ import {
54
+ consumeScopedApprovalGrantByToolSignature,
55
+ type CreateScopedApprovalGrantParams,
56
+ createScopedApprovalGrant,
57
+ } from '../memory/scoped-approval-grants.js';
58
+ import { getDb, initializeDb, resetDb } from '../memory/db.js';
59
+ import { conversations, scopedApprovalGrants } from '../memory/schema.js';
60
+ import { computeToolApprovalDigest } from '../security/tool-approval-digest.js';
61
+ import { tryMintGuardianActionGrant } from '../runtime/guardian-action-grant-minter.js';
62
+ import { createCallSession, createPendingQuestion } from '../calls/call-store.js';
63
+
64
+ initializeDb();
65
+
66
+ afterAll(() => {
67
+ resetDb();
68
+ try {
69
+ rmSync(testDir, { recursive: true });
70
+ } catch {
71
+ /* best effort */
72
+ }
73
+ });
74
+
75
+ // ── Constants ───────────────────────────────────────────────────────
76
+
77
+ const ASSISTANT_ID = 'self';
78
+ const TOOL_NAME = 'execute_shell';
79
+ const TOOL_INPUT = { command: 'rm -rf /tmp/test' };
80
+ const CONVERSATION_ID = 'conv-e2e';
81
+
82
+ // Mutable references populated by ensureFkParents()
83
+ let CALL_SESSION_ID = '';
84
+ let PENDING_QUESTION_IDS: string[] = [];
85
+ let pqIndex = 0;
86
+
87
+ function ensureConversation(id: string): void {
88
+ const db = getDb();
89
+ const now = Date.now();
90
+ db.insert(conversations).values({
91
+ id,
92
+ title: `Conversation ${id}`,
93
+ createdAt: now,
94
+ updatedAt: now,
95
+ }).run();
96
+ }
97
+
98
+ /** Create the FK parent rows required by guardian_action_requests. */
99
+ function ensureFkParents(): void {
100
+ ensureConversation(CONVERSATION_ID);
101
+ const session = createCallSession({
102
+ conversationId: CONVERSATION_ID,
103
+ provider: 'twilio',
104
+ fromNumber: '+15550001111',
105
+ toNumber: '+15550002222',
106
+ });
107
+ CALL_SESSION_ID = session.id;
108
+
109
+ // Pre-create enough pending questions for all tests in a suite run
110
+ PENDING_QUESTION_IDS = [];
111
+ pqIndex = 0;
112
+ for (let i = 0; i < 10; i++) {
113
+ const pq = createPendingQuestion(session.id, `Question ${i}`);
114
+ PENDING_QUESTION_IDS.push(pq.id);
115
+ }
116
+ }
117
+
118
+ function nextPendingQuestionId(): string {
119
+ return PENDING_QUESTION_IDS[pqIndex++];
120
+ }
121
+
122
+ function clearTables(): void {
123
+ const db = getDb();
124
+ try {
125
+ db.run('DELETE FROM scoped_approval_grants');
126
+ } catch {
127
+ /* table may not exist */
128
+ }
129
+ try {
130
+ db.run('DELETE FROM guardian_action_deliveries');
131
+ } catch {
132
+ /* table may not exist */
133
+ }
134
+ try {
135
+ db.run('DELETE FROM guardian_action_requests');
136
+ } catch {
137
+ /* table may not exist */
138
+ }
139
+ try {
140
+ db.run('DELETE FROM call_pending_questions');
141
+ } catch {
142
+ /* table may not exist */
143
+ }
144
+ try {
145
+ db.run('DELETE FROM call_events');
146
+ } catch {
147
+ /* table may not exist */
148
+ }
149
+ try {
150
+ db.run('DELETE FROM call_sessions');
151
+ } catch {
152
+ /* table may not exist */
153
+ }
154
+ try {
155
+ db.run('DELETE FROM conversations');
156
+ } catch {
157
+ /* table may not exist */
158
+ }
159
+ }
160
+
161
+ // ── Tests ───────────────────────────────────────────────────────────
162
+
163
+ describe('guardian-action grant mint -> voice consume integration', () => {
164
+ beforeEach(() => {
165
+ clearTables();
166
+ ensureFkParents();
167
+ });
168
+
169
+ test('full flow: resolve guardian action with tool metadata -> mint grant -> voice consume succeeds once', () => {
170
+ const inputDigest = computeToolApprovalDigest(TOOL_NAME, TOOL_INPUT);
171
+
172
+ // Step 1: Create a guardian action request with tool metadata
173
+ // (simulates the voice ASK_GUARDIAN path)
174
+ const request = createGuardianActionRequest({
175
+ assistantId: ASSISTANT_ID,
176
+ kind: 'ask_guardian',
177
+ sourceChannel: 'voice',
178
+ sourceConversationId: CONVERSATION_ID,
179
+ callSessionId: CALL_SESSION_ID,
180
+ pendingQuestionId: nextPendingQuestionId(),
181
+ questionText: 'Can I run rm -rf /tmp/test?',
182
+ expiresAt: Date.now() + 60_000,
183
+ toolName: TOOL_NAME,
184
+ inputDigest,
185
+ });
186
+
187
+ expect(request.toolName).toBe(TOOL_NAME);
188
+ expect(request.inputDigest).toBe(inputDigest);
189
+ expect(request.status).toBe('pending');
190
+
191
+ // Step 2: Guardian answers -> resolve the request
192
+ const resolved = resolveGuardianActionRequest(
193
+ request.id,
194
+ 'yes',
195
+ 'telegram',
196
+ 'guardian-user-123',
197
+ );
198
+ expect(resolved).not.toBeNull();
199
+ expect(resolved!.status).toBe('answered');
200
+
201
+ // Step 3: Mint a scoped grant from the resolved request
202
+ tryMintGuardianActionGrant({
203
+ resolvedRequest: resolved!,
204
+ answerText: 'yes',
205
+ decisionChannel: 'telegram',
206
+ guardianExternalUserId: 'guardian-user-123',
207
+ });
208
+
209
+ // Verify the grant was created
210
+ const db = getDb();
211
+ const grants = db
212
+ .select()
213
+ .from(scopedApprovalGrants)
214
+ .all();
215
+ expect(grants.length).toBe(1);
216
+ expect(grants[0].toolName).toBe(TOOL_NAME);
217
+ expect(grants[0].inputDigest).toBe(inputDigest);
218
+ expect(grants[0].scopeMode).toBe('tool_signature');
219
+ expect(grants[0].status).toBe('active');
220
+ expect(grants[0].assistantId).toBe(ASSISTANT_ID);
221
+ expect(grants[0].callSessionId).toBe(CALL_SESSION_ID);
222
+
223
+ // Step 4: Voice consumer consumes the grant
224
+ const consumeResult = consumeScopedApprovalGrantByToolSignature({
225
+ toolName: TOOL_NAME,
226
+ inputDigest,
227
+ consumingRequestId: 'voice-req-1',
228
+ assistantId: ASSISTANT_ID,
229
+ executionChannel: 'voice',
230
+ callSessionId: CALL_SESSION_ID,
231
+ conversationId: CONVERSATION_ID,
232
+ });
233
+ expect(consumeResult.ok).toBe(true);
234
+ expect(consumeResult.grant).not.toBeNull();
235
+ expect(consumeResult.grant!.status).toBe('consumed');
236
+ expect(consumeResult.grant!.consumedByRequestId).toBe('voice-req-1');
237
+
238
+ // Step 5: Second consume attempt fails (one-time use)
239
+ const secondConsume = consumeScopedApprovalGrantByToolSignature({
240
+ toolName: TOOL_NAME,
241
+ inputDigest,
242
+ consumingRequestId: 'voice-req-2',
243
+ assistantId: ASSISTANT_ID,
244
+ executionChannel: 'voice',
245
+ callSessionId: CALL_SESSION_ID,
246
+ conversationId: CONVERSATION_ID,
247
+ });
248
+ expect(secondConsume.ok).toBe(false);
249
+ expect(secondConsume.grant).toBeNull();
250
+ });
251
+
252
+ test('grant minted for one assistantId cannot be consumed by another', () => {
253
+ const inputDigest = computeToolApprovalDigest(TOOL_NAME, TOOL_INPUT);
254
+
255
+ const request = createGuardianActionRequest({
256
+ assistantId: ASSISTANT_ID,
257
+ kind: 'ask_guardian',
258
+ sourceChannel: 'voice',
259
+ sourceConversationId: CONVERSATION_ID,
260
+ callSessionId: CALL_SESSION_ID,
261
+ pendingQuestionId: nextPendingQuestionId(),
262
+ questionText: 'Can I run the command?',
263
+ expiresAt: Date.now() + 60_000,
264
+ toolName: TOOL_NAME,
265
+ inputDigest,
266
+ });
267
+
268
+ const resolved = resolveGuardianActionRequest(request.id, 'Yes', 'telegram');
269
+ expect(resolved).not.toBeNull();
270
+
271
+ tryMintGuardianActionGrant({
272
+ resolvedRequest: resolved!,
273
+ answerText: 'Yes',
274
+ decisionChannel: 'telegram',
275
+ });
276
+
277
+ // Attempt to consume with a different assistantId
278
+ const wrongAssistant = consumeScopedApprovalGrantByToolSignature({
279
+ toolName: TOOL_NAME,
280
+ inputDigest,
281
+ consumingRequestId: 'voice-req-wrong',
282
+ assistantId: 'other-assistant',
283
+ executionChannel: 'voice',
284
+ callSessionId: CALL_SESSION_ID,
285
+ conversationId: CONVERSATION_ID,
286
+ });
287
+ expect(wrongAssistant.ok).toBe(false);
288
+
289
+ // Correct assistantId succeeds
290
+ const correctAssistant = consumeScopedApprovalGrantByToolSignature({
291
+ toolName: TOOL_NAME,
292
+ inputDigest,
293
+ consumingRequestId: 'voice-req-correct',
294
+ assistantId: ASSISTANT_ID,
295
+ executionChannel: 'voice',
296
+ callSessionId: CALL_SESSION_ID,
297
+ conversationId: CONVERSATION_ID,
298
+ });
299
+ expect(correctAssistant.ok).toBe(true);
300
+ });
301
+
302
+ test('no grant minted when guardian action request lacks tool metadata', () => {
303
+ // Create a request without toolName/inputDigest (informational consult)
304
+ const request = createGuardianActionRequest({
305
+ assistantId: ASSISTANT_ID,
306
+ kind: 'ask_guardian',
307
+ sourceChannel: 'voice',
308
+ sourceConversationId: CONVERSATION_ID,
309
+ callSessionId: CALL_SESSION_ID,
310
+ pendingQuestionId: nextPendingQuestionId(),
311
+ questionText: 'What should I tell the caller?',
312
+ expiresAt: Date.now() + 60_000,
313
+ // No toolName or inputDigest
314
+ });
315
+
316
+ const resolved = resolveGuardianActionRequest(request.id, 'Tell them to call back', 'vellum');
317
+ expect(resolved).not.toBeNull();
318
+
319
+ tryMintGuardianActionGrant({
320
+ resolvedRequest: resolved!,
321
+ answerText: 'Tell them to call back',
322
+ decisionChannel: 'vellum',
323
+ });
324
+
325
+ // No grant should have been created
326
+ const db = getDb();
327
+ const grants = db
328
+ .select()
329
+ .from(scopedApprovalGrants)
330
+ .all();
331
+ expect(grants.length).toBe(0);
332
+ });
333
+
334
+ test('grant minted via desktop/vellum channel also consumable by voice', () => {
335
+ const inputDigest = computeToolApprovalDigest(TOOL_NAME, TOOL_INPUT);
336
+
337
+ const request = createGuardianActionRequest({
338
+ assistantId: ASSISTANT_ID,
339
+ kind: 'ask_guardian',
340
+ sourceChannel: 'voice',
341
+ sourceConversationId: CONVERSATION_ID,
342
+ callSessionId: CALL_SESSION_ID,
343
+ pendingQuestionId: nextPendingQuestionId(),
344
+ questionText: 'Permission to execute?',
345
+ expiresAt: Date.now() + 60_000,
346
+ toolName: TOOL_NAME,
347
+ inputDigest,
348
+ });
349
+
350
+ // Guardian answers via desktop (vellum channel)
351
+ const resolved = resolveGuardianActionRequest(request.id, 'approve', 'vellum');
352
+ expect(resolved).not.toBeNull();
353
+
354
+ // Mint with decisionChannel: 'vellum' (desktop path)
355
+ tryMintGuardianActionGrant({
356
+ resolvedRequest: resolved!,
357
+ answerText: 'approve',
358
+ decisionChannel: 'vellum',
359
+ });
360
+
361
+ // The grant should have executionChannel: null (wildcard), so voice can consume
362
+ const consumeResult = consumeScopedApprovalGrantByToolSignature({
363
+ toolName: TOOL_NAME,
364
+ inputDigest,
365
+ consumingRequestId: 'voice-req-desktop',
366
+ assistantId: ASSISTANT_ID,
367
+ executionChannel: 'voice',
368
+ callSessionId: CALL_SESSION_ID,
369
+ conversationId: CONVERSATION_ID,
370
+ });
371
+ expect(consumeResult.ok).toBe(true);
372
+ });
373
+
374
+ test('no grant minted when guardian answer is a denial', () => {
375
+ const inputDigest = computeToolApprovalDigest(TOOL_NAME, TOOL_INPUT);
376
+
377
+ const request = createGuardianActionRequest({
378
+ assistantId: ASSISTANT_ID,
379
+ kind: 'ask_guardian',
380
+ sourceChannel: 'voice',
381
+ sourceConversationId: CONVERSATION_ID,
382
+ callSessionId: CALL_SESSION_ID,
383
+ pendingQuestionId: nextPendingQuestionId(),
384
+ questionText: 'Can I run rm -rf /tmp/test?',
385
+ expiresAt: Date.now() + 60_000,
386
+ toolName: TOOL_NAME,
387
+ inputDigest,
388
+ });
389
+
390
+ // Guardian explicitly denies the action
391
+ const resolved = resolveGuardianActionRequest(request.id, 'No', 'telegram', 'guardian-user-456');
392
+ expect(resolved).not.toBeNull();
393
+
394
+ tryMintGuardianActionGrant({
395
+ resolvedRequest: resolved!,
396
+ answerText: 'No',
397
+ decisionChannel: 'telegram',
398
+ guardianExternalUserId: 'guardian-user-456',
399
+ });
400
+
401
+ // No grant should have been created for a denial
402
+ const db = getDb();
403
+ const grants = db
404
+ .select()
405
+ .from(scopedApprovalGrants)
406
+ .all();
407
+ expect(grants.length).toBe(0);
408
+ });
409
+
410
+ test.each(['no', 'reject', 'deny', 'cancel'])('no grant minted for denial keyword: %s', (denialWord) => {
411
+ const inputDigest = computeToolApprovalDigest(TOOL_NAME, TOOL_INPUT);
412
+
413
+ const request = createGuardianActionRequest({
414
+ assistantId: ASSISTANT_ID,
415
+ kind: 'ask_guardian',
416
+ sourceChannel: 'voice',
417
+ sourceConversationId: CONVERSATION_ID,
418
+ callSessionId: CALL_SESSION_ID,
419
+ pendingQuestionId: nextPendingQuestionId(),
420
+ questionText: 'Permission to execute?',
421
+ expiresAt: Date.now() + 60_000,
422
+ toolName: TOOL_NAME,
423
+ inputDigest,
424
+ });
425
+
426
+ const resolved = resolveGuardianActionRequest(request.id, denialWord, 'telegram');
427
+ expect(resolved).not.toBeNull();
428
+
429
+ tryMintGuardianActionGrant({
430
+ resolvedRequest: resolved!,
431
+ answerText: denialWord,
432
+ decisionChannel: 'telegram',
433
+ });
434
+
435
+ const db = getDb();
436
+ const grants = db
437
+ .select()
438
+ .from(scopedApprovalGrants)
439
+ .all();
440
+ expect(grants.length).toBe(0);
441
+ });
442
+
443
+ test('no grant minted for unrecognised free-form answer (fail-closed)', () => {
444
+ const inputDigest = computeToolApprovalDigest(TOOL_NAME, TOOL_INPUT);
445
+
446
+ const request = createGuardianActionRequest({
447
+ assistantId: ASSISTANT_ID,
448
+ kind: 'ask_guardian',
449
+ sourceChannel: 'voice',
450
+ sourceConversationId: CONVERSATION_ID,
451
+ callSessionId: CALL_SESSION_ID,
452
+ pendingQuestionId: nextPendingQuestionId(),
453
+ questionText: 'Can I run the command?',
454
+ expiresAt: Date.now() + 60_000,
455
+ toolName: TOOL_NAME,
456
+ inputDigest,
457
+ });
458
+
459
+ // Free-form text that doesn't match a known approval phrase
460
+ const resolved = resolveGuardianActionRequest(request.id, 'Sure, go ahead and run it', 'telegram');
461
+ expect(resolved).not.toBeNull();
462
+
463
+ tryMintGuardianActionGrant({
464
+ resolvedRequest: resolved!,
465
+ answerText: 'Sure, go ahead and run it',
466
+ decisionChannel: 'telegram',
467
+ });
468
+
469
+ // No grant — unrecognised text is not treated as approval (fail-closed)
470
+ const db = getDb();
471
+ const grants = db
472
+ .select()
473
+ .from(scopedApprovalGrants)
474
+ .all();
475
+ expect(grants.length).toBe(0);
476
+ });
477
+
478
+ test.each(['yes', 'approve', 'approve once', 'allow', 'go ahead'])('grant IS minted for approval keyword: %s', (approveWord) => {
479
+ const inputDigest = computeToolApprovalDigest(TOOL_NAME, TOOL_INPUT);
480
+
481
+ const request = createGuardianActionRequest({
482
+ assistantId: ASSISTANT_ID,
483
+ kind: 'ask_guardian',
484
+ sourceChannel: 'voice',
485
+ sourceConversationId: CONVERSATION_ID,
486
+ callSessionId: CALL_SESSION_ID,
487
+ pendingQuestionId: nextPendingQuestionId(),
488
+ questionText: 'Can I run the command?',
489
+ expiresAt: Date.now() + 60_000,
490
+ toolName: TOOL_NAME,
491
+ inputDigest,
492
+ });
493
+
494
+ const resolved = resolveGuardianActionRequest(request.id, approveWord, 'telegram');
495
+ expect(resolved).not.toBeNull();
496
+
497
+ tryMintGuardianActionGrant({
498
+ resolvedRequest: resolved!,
499
+ answerText: approveWord,
500
+ decisionChannel: 'telegram',
501
+ });
502
+
503
+ const db = getDb();
504
+ const grants = db
505
+ .select()
506
+ .from(scopedApprovalGrants)
507
+ .all();
508
+ expect(grants.length).toBe(1);
509
+ expect(grants[0].toolName).toBe(TOOL_NAME);
510
+ });
511
+ });
@@ -35,9 +35,12 @@ import {
35
35
  createGuardianActionDelivery,
36
36
  createGuardianActionRequest,
37
37
  expireGuardianActionRequest,
38
+ getExpiredDeliveriesByConversation,
38
39
  getExpiredDeliveriesByDestination,
39
40
  getExpiredDeliveryByConversation,
41
+ getFollowupDeliveriesByConversation,
40
42
  getGuardianActionRequest,
43
+ getPendingDeliveriesByConversation,
41
44
  resolveGuardianActionRequest,
42
45
  startFollowupFromExpiredRequest,
43
46
  updateDeliveryStatus,
@@ -291,4 +294,132 @@ describe('guardian-action-late-reply', () => {
291
294
 
292
295
  expect(text).toContain('expired');
293
296
  });
297
+
298
+ // ── Multiple deliveries in one conversation (disambiguation) ──────
299
+
300
+ describe('multi-delivery disambiguation in reused conversations', () => {
301
+ // Helper to create a pending request with delivery in a shared conversation
302
+ function createPendingInSharedConv(sourceConvId: string, sharedDeliveryConvId: string) {
303
+ ensureConversation(sourceConvId);
304
+ const session = createCallSession({
305
+ conversationId: sourceConvId,
306
+ provider: 'twilio',
307
+ fromNumber: '+15550001111',
308
+ toNumber: '+15550002222',
309
+ });
310
+ const pq = createPendingQuestion(session.id, `Question from ${sourceConvId}`);
311
+ const request = createGuardianActionRequest({
312
+ kind: 'ask_guardian',
313
+ sourceChannel: 'voice',
314
+ sourceConversationId: sourceConvId,
315
+ callSessionId: session.id,
316
+ pendingQuestionId: pq.id,
317
+ questionText: pq.questionText,
318
+ expiresAt: Date.now() + 60_000,
319
+ });
320
+ const delivery = createGuardianActionDelivery({
321
+ requestId: request.id,
322
+ destinationChannel: 'vellum',
323
+ destinationConversationId: sharedDeliveryConvId,
324
+ });
325
+ updateDeliveryStatus(delivery.id, 'sent');
326
+ return { request, delivery };
327
+ }
328
+
329
+ test('multiple pending deliveries in same conversation are returned by getPendingDeliveriesByConversation', () => {
330
+ const sharedConv = 'shared-reused-conv-pending';
331
+ ensureConversation(sharedConv);
332
+
333
+ const { request: req1 } = createPendingInSharedConv('src-p1', sharedConv);
334
+ const { request: req2 } = createPendingInSharedConv('src-p2', sharedConv);
335
+
336
+ const deliveries = getPendingDeliveriesByConversation(sharedConv);
337
+ expect(deliveries).toHaveLength(2);
338
+
339
+ const requestIds = deliveries.map((d) => d.requestId);
340
+ expect(requestIds).toContain(req1.id);
341
+ expect(requestIds).toContain(req2.id);
342
+ });
343
+
344
+ test('request codes are unique across multiple requests in same conversation', () => {
345
+ const sharedConv = 'shared-reused-conv-codes';
346
+ ensureConversation(sharedConv);
347
+
348
+ const { request: req1 } = createPendingInSharedConv('src-code1', sharedConv);
349
+ const { request: req2 } = createPendingInSharedConv('src-code2', sharedConv);
350
+
351
+ expect(req1.requestCode).not.toBe(req2.requestCode);
352
+ expect(req1.requestCode).toHaveLength(6);
353
+ expect(req2.requestCode).toHaveLength(6);
354
+ });
355
+
356
+ test('multiple expired deliveries in same conversation are returned by getExpiredDeliveriesByConversation', () => {
357
+ const sharedConv = 'shared-reused-conv-expired';
358
+ ensureConversation(sharedConv);
359
+
360
+ const { request: req1 } = createPendingInSharedConv('src-e1', sharedConv);
361
+ const { request: req2 } = createPendingInSharedConv('src-e2', sharedConv);
362
+
363
+ expireGuardianActionRequest(req1.id, 'sweep_timeout');
364
+ expireGuardianActionRequest(req2.id, 'sweep_timeout');
365
+
366
+ const deliveries = getExpiredDeliveriesByConversation(sharedConv);
367
+ expect(deliveries).toHaveLength(2);
368
+
369
+ const requestIds = deliveries.map((d) => d.requestId);
370
+ expect(requestIds).toContain(req1.id);
371
+ expect(requestIds).toContain(req2.id);
372
+ });
373
+
374
+ test('multiple followup deliveries in same conversation are returned by getFollowupDeliveriesByConversation', () => {
375
+ const sharedConv = 'shared-reused-conv-followup';
376
+ ensureConversation(sharedConv);
377
+
378
+ const { request: req1 } = createPendingInSharedConv('src-fu1', sharedConv);
379
+ const { request: req2 } = createPendingInSharedConv('src-fu2', sharedConv);
380
+
381
+ expireGuardianActionRequest(req1.id, 'sweep_timeout');
382
+ expireGuardianActionRequest(req2.id, 'sweep_timeout');
383
+ startFollowupFromExpiredRequest(req1.id, 'late answer 1');
384
+ startFollowupFromExpiredRequest(req2.id, 'late answer 2');
385
+
386
+ const deliveries = getFollowupDeliveriesByConversation(sharedConv);
387
+ expect(deliveries).toHaveLength(2);
388
+
389
+ const requestIds = deliveries.map((d) => d.requestId);
390
+ expect(requestIds).toContain(req1.id);
391
+ expect(requestIds).toContain(req2.id);
392
+ });
393
+
394
+ test('resolving one pending request leaves the other still pending in shared conversation', () => {
395
+ const sharedConv = 'shared-reused-conv-resolve-one';
396
+ ensureConversation(sharedConv);
397
+
398
+ const { request: req1 } = createPendingInSharedConv('src-r1', sharedConv);
399
+ const { request: req2 } = createPendingInSharedConv('src-r2', sharedConv);
400
+
401
+ resolveGuardianActionRequest(req1.id, 'answer to first', 'vellum');
402
+
403
+ const remaining = getPendingDeliveriesByConversation(sharedConv);
404
+ expect(remaining).toHaveLength(1);
405
+ expect(remaining[0].requestId).toBe(req2.id);
406
+ });
407
+
408
+ test('request code prefix matching is case-insensitive', () => {
409
+ const sharedConv = 'shared-reused-conv-case';
410
+ ensureConversation(sharedConv);
411
+
412
+ const { request: req1 } = createPendingInSharedConv('src-case1', sharedConv);
413
+ const code = req1.requestCode; // e.g. "A1B2C3"
414
+
415
+ // Simulate case-insensitive prefix matching as done in session-process.ts
416
+ const userInput = `${code.toLowerCase()} the answer is 42`;
417
+ const matched = userInput.toUpperCase().startsWith(code);
418
+ expect(matched).toBe(true);
419
+
420
+ // After stripping the code prefix, the answer text is extracted
421
+ const answerText = userInput.slice(code.length).trim();
422
+ expect(answerText).toBe('the answer is 42');
423
+ });
424
+ });
294
425
  });