@vellumai/assistant 0.3.18 → 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 (42) hide show
  1. package/ARCHITECTURE.md +4 -0
  2. package/docs/architecture/security.md +80 -0
  3. package/package.json +1 -1
  4. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +4 -0
  5. package/src/__tests__/call-controller.test.ts +170 -0
  6. package/src/__tests__/checker.test.ts +60 -0
  7. package/src/__tests__/guardian-action-grant-mint-consume.test.ts +511 -0
  8. package/src/__tests__/guardian-dispatch.test.ts +61 -1
  9. package/src/__tests__/guardian-grant-minting.test.ts +543 -0
  10. package/src/__tests__/ipc-snapshot.test.ts +1 -0
  11. package/src/__tests__/remote-skill-policy.test.ts +215 -0
  12. package/src/__tests__/scoped-approval-grants.test.ts +521 -0
  13. package/src/__tests__/scoped-grant-security-matrix.test.ts +443 -0
  14. package/src/__tests__/trust-store.test.ts +2 -0
  15. package/src/__tests__/voice-scoped-grant-consumer.test.ts +571 -0
  16. package/src/calls/call-controller.ts +27 -6
  17. package/src/calls/call-domain.ts +12 -0
  18. package/src/calls/guardian-dispatch.ts +8 -0
  19. package/src/calls/relay-server.ts +13 -0
  20. package/src/calls/voice-session-bridge.ts +42 -3
  21. package/src/config/bundled-skills/notifications/SKILL.md +18 -0
  22. package/src/config/schema.ts +6 -0
  23. package/src/config/skills-schema.ts +27 -0
  24. package/src/daemon/handlers/config-channels.ts +18 -0
  25. package/src/daemon/handlers/skills.ts +45 -2
  26. package/src/daemon/ipc-contract/skills.ts +1 -0
  27. package/src/daemon/session-process.ts +12 -0
  28. package/src/memory/db-init.ts +9 -1
  29. package/src/memory/embedding-local.ts +16 -7
  30. package/src/memory/guardian-action-store.ts +8 -0
  31. package/src/memory/guardian-verification.ts +1 -1
  32. package/src/memory/migrations/033-scoped-approval-grants.ts +51 -0
  33. package/src/memory/migrations/034-guardian-action-tool-metadata.ts +12 -0
  34. package/src/memory/migrations/index.ts +2 -0
  35. package/src/memory/schema.ts +30 -0
  36. package/src/memory/scoped-approval-grants.ts +509 -0
  37. package/src/permissions/checker.ts +27 -0
  38. package/src/runtime/guardian-action-grant-minter.ts +97 -0
  39. package/src/runtime/routes/guardian-approval-interception.ts +116 -0
  40. package/src/runtime/routes/inbound-message-handler.ts +94 -27
  41. package/src/security/tool-approval-digest.ts +67 -0
  42. package/src/skills/remote-skill-policy.ts +131 -0
@@ -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
+ });
@@ -336,10 +336,70 @@ describe('guardian-dispatch', () => {
336
336
  expect(vellumDelivery!.destination_conversation_id).toBe('conv-from-thread-created');
337
337
  });
338
338
 
339
- test('includes activeGuardianRequestCount in context payload', async () => {
339
+ test('persists toolName and inputDigest on guardian action request for tool-approval dispatches', async () => {
340
340
  const convId = 'conv-dispatch-5';
341
341
  ensureConversation(convId);
342
342
 
343
+ const session = createCallSession({
344
+ conversationId: convId,
345
+ provider: 'twilio',
346
+ fromNumber: '+15550001111',
347
+ toNumber: '+15550002222',
348
+ });
349
+ const pq = createPendingQuestion(session.id, 'Allow send_email to bob@example.com?');
350
+
351
+ await dispatchGuardianQuestion({
352
+ callSessionId: session.id,
353
+ conversationId: convId,
354
+ assistantId: 'self',
355
+ pendingQuestion: pq,
356
+ toolName: 'send_email',
357
+ inputDigest: 'abc123def456',
358
+ });
359
+
360
+ const db = getDb();
361
+ const raw = (db as unknown as { $client: import('bun:sqlite').Database }).$client;
362
+ const request = raw.query('SELECT * FROM guardian_action_requests WHERE call_session_id = ?').get(session.id) as
363
+ | { id: string; tool_name: string | null; input_digest: string | null }
364
+ | undefined;
365
+ expect(request).toBeDefined();
366
+ expect(request!.tool_name).toBe('send_email');
367
+ expect(request!.input_digest).toBe('abc123def456');
368
+ });
369
+
370
+ test('omitting toolName and inputDigest stores null for informational ASK_GUARDIAN dispatches', async () => {
371
+ const convId = 'conv-dispatch-6';
372
+ ensureConversation(convId);
373
+
374
+ const session = createCallSession({
375
+ conversationId: convId,
376
+ provider: 'twilio',
377
+ fromNumber: '+15550001111',
378
+ toNumber: '+15550002222',
379
+ });
380
+ const pq = createPendingQuestion(session.id, 'What time works?');
381
+
382
+ await dispatchGuardianQuestion({
383
+ callSessionId: session.id,
384
+ conversationId: convId,
385
+ assistantId: 'self',
386
+ pendingQuestion: pq,
387
+ });
388
+
389
+ const db = getDb();
390
+ const raw = (db as unknown as { $client: import('bun:sqlite').Database }).$client;
391
+ const request = raw.query('SELECT * FROM guardian_action_requests WHERE call_session_id = ?').get(session.id) as
392
+ | { id: string; tool_name: string | null; input_digest: string | null }
393
+ | undefined;
394
+ expect(request).toBeDefined();
395
+ expect(request!.tool_name).toBeNull();
396
+ expect(request!.input_digest).toBeNull();
397
+ });
398
+
399
+ test('includes activeGuardianRequestCount in context payload', async () => {
400
+ const convId = 'conv-dispatch-7';
401
+ ensureConversation(convId);
402
+
343
403
  const session = createCallSession({
344
404
  conversationId: convId,
345
405
  provider: 'twilio',