@vellumai/assistant 0.3.16 → 0.3.18

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 (90) hide show
  1. package/ARCHITECTURE.md +70 -13
  2. package/README.md +6 -0
  3. package/docs/architecture/http-token-refresh.md +23 -1
  4. package/package.json +1 -1
  5. package/src/__tests__/access-request-decision.test.ts +4 -7
  6. package/src/__tests__/channel-guardian.test.ts +3 -1
  7. package/src/__tests__/checker.test.ts +79 -48
  8. package/src/__tests__/config-watcher.test.ts +11 -13
  9. package/src/__tests__/conversation-pairing.test.ts +103 -3
  10. package/src/__tests__/guardian-action-conversation-turn.test.ts +1 -1
  11. package/src/__tests__/guardian-action-followup-executor.test.ts +1 -1
  12. package/src/__tests__/guardian-action-late-reply.test.ts +131 -0
  13. package/src/__tests__/guardian-action-store.test.ts +182 -0
  14. package/src/__tests__/guardian-dispatch.test.ts +120 -0
  15. package/src/__tests__/ipc-snapshot.test.ts +21 -0
  16. package/src/__tests__/non-member-access-request.test.ts +1 -2
  17. package/src/__tests__/notification-broadcaster.test.ts +115 -4
  18. package/src/__tests__/notification-decision-strategy.test.ts +2 -1
  19. package/src/__tests__/notification-deep-link.test.ts +44 -1
  20. package/src/__tests__/notification-guardian-path.test.ts +157 -0
  21. package/src/__tests__/notification-thread-candidate-validation.test.ts +215 -0
  22. package/src/__tests__/slack-channel-config.test.ts +3 -3
  23. package/src/__tests__/trust-store.test.ts +21 -21
  24. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +5 -7
  25. package/src/__tests__/trusted-contact-multichannel.test.ts +2 -6
  26. package/src/__tests__/trusted-contact-verification.test.ts +9 -9
  27. package/src/__tests__/update-bulletin-state.test.ts +1 -1
  28. package/src/__tests__/update-bulletin.test.ts +66 -3
  29. package/src/__tests__/update-template-contract.test.ts +6 -11
  30. package/src/__tests__/voice-session-bridge.test.ts +109 -9
  31. package/src/calls/call-controller.ts +129 -8
  32. package/src/calls/guardian-action-sweep.ts +1 -1
  33. package/src/calls/guardian-dispatch.ts +8 -0
  34. package/src/calls/voice-session-bridge.ts +4 -2
  35. package/src/cli/core-commands.ts +41 -1
  36. package/src/config/templates/UPDATES.md +5 -6
  37. package/src/config/update-bulletin-format.ts +2 -0
  38. package/src/config/update-bulletin-state.ts +1 -1
  39. package/src/config/update-bulletin-template-path.ts +6 -0
  40. package/src/config/update-bulletin.ts +21 -6
  41. package/src/daemon/config-watcher.ts +3 -2
  42. package/src/daemon/daemon-control.ts +64 -10
  43. package/src/daemon/handlers/config-slack-channel.ts +1 -1
  44. package/src/daemon/handlers/identity.ts +45 -25
  45. package/src/daemon/handlers/sessions.ts +1 -1
  46. package/src/daemon/ipc-contract/sessions.ts +1 -1
  47. package/src/daemon/ipc-contract/workspace.ts +12 -1
  48. package/src/daemon/ipc-contract-inventory.json +1 -0
  49. package/src/daemon/lifecycle.ts +8 -0
  50. package/src/daemon/server.ts +25 -3
  51. package/src/daemon/session-process.ts +438 -184
  52. package/src/daemon/tls-certs.ts +17 -12
  53. package/src/daemon/tool-side-effects.ts +1 -1
  54. package/src/memory/channel-delivery-store.ts +18 -20
  55. package/src/memory/channel-guardian-store.ts +39 -42
  56. package/src/memory/conversation-crud.ts +2 -2
  57. package/src/memory/conversation-queries.ts +2 -2
  58. package/src/memory/conversation-store.ts +24 -25
  59. package/src/memory/db-init.ts +9 -1
  60. package/src/memory/fts-reconciler.ts +41 -26
  61. package/src/memory/guardian-action-store.ts +57 -7
  62. package/src/memory/guardian-verification.ts +1 -0
  63. package/src/memory/jobs-worker.ts +2 -2
  64. package/src/memory/migrations/032-guardian-delivery-conversation-index.ts +15 -0
  65. package/src/memory/migrations/032-notification-delivery-thread-decision.ts +20 -0
  66. package/src/memory/migrations/index.ts +4 -2
  67. package/src/memory/schema-migration.ts +1 -0
  68. package/src/memory/schema.ts +6 -1
  69. package/src/memory/search/semantic.ts +3 -3
  70. package/src/notifications/README.md +158 -17
  71. package/src/notifications/broadcaster.ts +68 -50
  72. package/src/notifications/conversation-pairing.ts +96 -18
  73. package/src/notifications/decision-engine.ts +6 -3
  74. package/src/notifications/deliveries-store.ts +12 -0
  75. package/src/notifications/emit-signal.ts +1 -0
  76. package/src/notifications/thread-candidates.ts +60 -25
  77. package/src/notifications/types.ts +2 -1
  78. package/src/permissions/checker.ts +1 -16
  79. package/src/permissions/defaults.ts +14 -4
  80. package/src/runtime/guardian-action-followup-executor.ts +1 -1
  81. package/src/runtime/http-server.ts +11 -11
  82. package/src/runtime/routes/access-request-decision.ts +1 -1
  83. package/src/runtime/routes/debug-routes.ts +4 -4
  84. package/src/runtime/routes/guardian-approval-interception.ts +4 -4
  85. package/src/runtime/routes/inbound-message-handler.ts +6 -6
  86. package/src/runtime/routes/integration-routes.ts +2 -2
  87. package/src/tools/permission-checker.ts +1 -2
  88. package/src/tools/secret-detection-handler.ts +1 -1
  89. package/src/tools/system/voice-config.ts +1 -1
  90. package/src/version.ts +29 -2
@@ -335,4 +335,124 @@ describe('guardian-dispatch', () => {
335
335
  expect(vellumDelivery).toBeDefined();
336
336
  expect(vellumDelivery!.destination_conversation_id).toBe('conv-from-thread-created');
337
337
  });
338
+
339
+ test('includes activeGuardianRequestCount in context payload', async () => {
340
+ const convId = 'conv-dispatch-5';
341
+ ensureConversation(convId);
342
+
343
+ const session = createCallSession({
344
+ conversationId: convId,
345
+ provider: 'twilio',
346
+ fromNumber: '+15550001111',
347
+ toNumber: '+15550002222',
348
+ });
349
+ const pq = createPendingQuestion(session.id, 'First question');
350
+
351
+ await dispatchGuardianQuestion({
352
+ callSessionId: session.id,
353
+ conversationId: convId,
354
+ assistantId: 'self',
355
+ pendingQuestion: pq,
356
+ });
357
+
358
+ const signalParams = emitCalls[0] as Record<string, unknown>;
359
+ const payload = signalParams.contextPayload as Record<string, unknown>;
360
+ // The request was just created so there is 1 pending request for this session
361
+ expect(payload.activeGuardianRequestCount).toBe(1);
362
+ expect(payload.callSessionId).toBe(session.id);
363
+ });
364
+
365
+ test('repeated guardian questions in the same call each create per-request delivery rows even when sharing a conversation', async () => {
366
+ const convId = 'conv-dispatch-reuse-1';
367
+ ensureConversation(convId);
368
+
369
+ // Both dispatches deliver to the same vellum conversation (simulating thread reuse)
370
+ const sharedConversationId = 'conv-shared-guardian';
371
+
372
+ const session = createCallSession({
373
+ conversationId: convId,
374
+ provider: 'twilio',
375
+ fromNumber: '+15550001111',
376
+ toNumber: '+15550002222',
377
+ });
378
+
379
+ // First dispatch
380
+ const pq1 = createPendingQuestion(session.id, 'What is the gate code?');
381
+ mockEmitResult = {
382
+ signalId: 'sig-reuse-1',
383
+ deduplicated: false,
384
+ dispatched: true,
385
+ reason: 'ok',
386
+ deliveryResults: [
387
+ {
388
+ channel: 'vellum',
389
+ destination: 'vellum',
390
+ status: 'sent',
391
+ conversationId: sharedConversationId,
392
+ },
393
+ ],
394
+ };
395
+
396
+ await dispatchGuardianQuestion({
397
+ callSessionId: session.id,
398
+ conversationId: convId,
399
+ assistantId: 'self',
400
+ pendingQuestion: pq1,
401
+ });
402
+
403
+ // Second dispatch (same call session, same shared conversation)
404
+ emitCalls.length = 0;
405
+ const pq2 = createPendingQuestion(session.id, 'Should I let them in?');
406
+ mockEmitResult = {
407
+ signalId: 'sig-reuse-2',
408
+ deduplicated: false,
409
+ dispatched: true,
410
+ reason: 'ok',
411
+ deliveryResults: [
412
+ {
413
+ channel: 'vellum',
414
+ destination: 'vellum',
415
+ status: 'sent',
416
+ conversationId: sharedConversationId,
417
+ },
418
+ ],
419
+ };
420
+
421
+ await dispatchGuardianQuestion({
422
+ callSessionId: session.id,
423
+ conversationId: convId,
424
+ assistantId: 'self',
425
+ pendingQuestion: pq2,
426
+ });
427
+
428
+ // Both dispatches should have created separate action requests
429
+ const db = getDb();
430
+ const raw = (db as unknown as { $client: import('bun:sqlite').Database }).$client;
431
+ const requests = raw.query(
432
+ 'SELECT * FROM guardian_action_requests WHERE call_session_id = ? ORDER BY created_at ASC',
433
+ ).all(session.id) as Array<{ id: string; question_text: string }>;
434
+ expect(requests).toHaveLength(2);
435
+ expect(requests[0].question_text).toBe('What is the gate code?');
436
+ expect(requests[1].question_text).toBe('Should I let them in?');
437
+
438
+ // Each request should have its own delivery row, both pointing to the shared conversation
439
+ for (const req of requests) {
440
+ const delivery = raw.query(
441
+ 'SELECT * FROM guardian_action_deliveries WHERE request_id = ? AND destination_channel = ?',
442
+ ).get(req.id, 'vellum') as { status: string; destination_conversation_id: string | null } | undefined;
443
+ expect(delivery).toBeDefined();
444
+ expect(delivery!.status).toBe('sent');
445
+ expect(delivery!.destination_conversation_id).toBe(sharedConversationId);
446
+ }
447
+
448
+ // Total delivery rows should be 2 (one per request), not 1
449
+ const allDeliveries = raw.query(
450
+ 'SELECT * FROM guardian_action_deliveries WHERE destination_conversation_id = ?',
451
+ ).all(sharedConversationId) as Array<{ request_id: string }>;
452
+ expect(allDeliveries).toHaveLength(2);
453
+
454
+ // Second dispatch should report a higher activeGuardianRequestCount
455
+ const secondPayload = (emitCalls[0] as Record<string, unknown>).contextPayload as Record<string, unknown>;
456
+ expect(secondPayload.activeGuardianRequestCount).toBe(2);
457
+ });
338
458
  });
@@ -727,6 +727,10 @@ const clientMessages: Record<ClientMessageType, ClientMessage> = {
727
727
  type: 'heartbeat_checklist_write',
728
728
  content: '- [ ] Check email\n- [ ] Review PRs',
729
729
  },
730
+ voice_config_update: {
731
+ type: 'voice_config_update',
732
+ activationKey: 'fn',
733
+ },
730
734
  };
731
735
 
732
736
  // ---------------------------------------------------------------------------
@@ -1998,6 +2002,23 @@ const serverMessages: Record<ServerMessageType, ServerMessage> = {
1998
2002
  type: 'heartbeat_checklist_write_response',
1999
2003
  success: true,
2000
2004
  },
2005
+ navigate_settings: {
2006
+ type: 'navigate_settings',
2007
+ tab: 'general',
2008
+ },
2009
+ client_settings_update: {
2010
+ type: 'client_settings_update',
2011
+ key: 'activationKey',
2012
+ value: 'fn',
2013
+ },
2014
+ identity_changed: {
2015
+ type: 'identity_changed',
2016
+ name: 'Vellum',
2017
+ role: 'assistant',
2018
+ personality: 'friendly',
2019
+ emoji: '',
2020
+ home: '',
2021
+ },
2001
2022
  };
2002
2023
 
2003
2024
  // ---------------------------------------------------------------------------
@@ -84,7 +84,7 @@ import {
84
84
  createBinding,
85
85
  findPendingAccessRequestForRequester,
86
86
  } from '../memory/channel-guardian-store.js';
87
- import { initializeDb, resetDb } from '../memory/db.js';
87
+ import { getDb, initializeDb, resetDb } from '../memory/db.js';
88
88
  import { handleChannelInbound } from '../runtime/routes/channel-routes.js';
89
89
 
90
90
  initializeDb();
@@ -101,7 +101,6 @@ afterAll(() => {
101
101
  const TEST_BEARER_TOKEN = 'test-token';
102
102
 
103
103
  function resetState(): void {
104
- const { getDb } = require('../memory/db.js');
105
104
  const db = getDb();
106
105
  db.run('DELETE FROM channel_guardian_approval_requests');
107
106
  db.run('DELETE FROM channel_guardian_bindings');
@@ -6,6 +6,8 @@
6
6
  * - Handles missing adapters gracefully
7
7
  * - Falls back to copy-composer when decision copy is missing
8
8
  * - Reports delivery results per channel
9
+ * - Emits notification_thread_created only when a new conversation is created
10
+ * - Does NOT emit notification_thread_created when reusing an existing thread
9
11
  */
10
12
 
11
13
  import { describe, expect, mock, test } from 'bun:test';
@@ -36,6 +38,32 @@ mock.module('../notifications/deliveries-store.js', () => ({
36
38
  updateDeliveryStatus: () => {},
37
39
  }));
38
40
 
41
+ // Configurable mock for conversation-pairing.
42
+ // By default returns a "new conversation" result with a stable UUID.
43
+ // Set `nextPairingResult` to override the return value for a single call.
44
+ let nextPairingResult: import('../notifications/conversation-pairing.js').PairingResult | null = null;
45
+ let pairingCallCount = 0;
46
+
47
+ mock.module('../notifications/conversation-pairing.js', () => ({
48
+ pairDeliveryWithConversation: async () => {
49
+ if (nextPairingResult) {
50
+ const result = nextPairingResult;
51
+ nextPairingResult = null;
52
+ return result;
53
+ }
54
+ // Default: simulate creating a new conversation with a unique ID
55
+ const id = `mock-conv-${++pairingCallCount}`;
56
+ return {
57
+ conversationId: id,
58
+ messageId: `mock-msg-${pairingCallCount}`,
59
+ strategy: 'start_new_conversation' as const,
60
+ createdNewConversation: true,
61
+ threadDecisionFallbackUsed: false,
62
+ };
63
+ },
64
+ }));
65
+
66
+ import type { ThreadCreatedInfo } from '../notifications/broadcaster.js';
39
67
  import { NotificationBroadcaster } from '../notifications/broadcaster.js';
40
68
  import type { NotificationSignal } from '../notifications/signal.js';
41
69
  import type {
@@ -167,10 +195,17 @@ describe('notification broadcaster', () => {
167
195
  await broadcaster.broadcastDecision(signal, decision);
168
196
 
169
197
  expect(vellumAdapter.sent).toHaveLength(1);
170
- expect(vellumAdapter.sent[0].deepLinkTarget).toEqual({
171
- conversationId: 'conv-123',
172
- screen: 'thread',
173
- });
198
+ // The broadcaster overwrites deepLinkTarget.conversationId with the
199
+ // paired conversation ID, so the original 'conv-123' is replaced.
200
+ // Verify the structure is correct and that conversationId comes from
201
+ // the pairing result, not the pre-pairing placeholder.
202
+ const deepLink = vellumAdapter.sent[0].deepLinkTarget;
203
+ expect(deepLink).toBeDefined();
204
+ expect(deepLink!.screen).toBe('thread');
205
+ expect(deepLink!.conversationId).toBeDefined();
206
+ expect(deepLink!.conversationId).not.toBe('conv-123');
207
+ // Should be the paired conversation ID from conversation-pairing
208
+ expect(deepLink!.conversationId).toMatch(/^mock-conv-\d+$/);
174
209
  });
175
210
 
176
211
  test('multiple channels receive independent copy from the decision', async () => {
@@ -253,4 +288,80 @@ describe('notification broadcaster', () => {
253
288
  expect(results).toHaveLength(0);
254
289
  expect(vellumAdapter.sent).toHaveLength(0);
255
290
  });
291
+
292
+ // ── Thread-created IPC emission ─────────────────────────────────────
293
+
294
+ test('fires onThreadCreated when a new vellum conversation is created (start_new)', async () => {
295
+ const vellumAdapter = new MockAdapter('vellum');
296
+ const broadcaster = new NotificationBroadcaster([vellumAdapter]);
297
+ const threadCreatedCalls: ThreadCreatedInfo[] = [];
298
+ broadcaster.setOnThreadCreated((info) => threadCreatedCalls.push(info));
299
+
300
+ const signal = makeSignal();
301
+ // No threadActions means default start_new behavior
302
+ const decision = makeDecision();
303
+
304
+ await broadcaster.broadcastDecision(signal, decision);
305
+
306
+ // Pairing creates a new conversation by default, so onThreadCreated should fire
307
+ expect(threadCreatedCalls).toHaveLength(1);
308
+ expect(threadCreatedCalls[0].sourceEventName).toBe('test.event');
309
+ });
310
+
311
+ test('fires per-dispatch onThreadCreated callback on new conversation', async () => {
312
+ const vellumAdapter = new MockAdapter('vellum');
313
+ const broadcaster = new NotificationBroadcaster([vellumAdapter]);
314
+ const dispatchCalls: ThreadCreatedInfo[] = [];
315
+
316
+ const signal = makeSignal();
317
+ const decision = makeDecision();
318
+
319
+ await broadcaster.broadcastDecision(signal, decision, {
320
+ onThreadCreated: (info) => dispatchCalls.push(info),
321
+ });
322
+
323
+ expect(dispatchCalls).toHaveLength(1);
324
+ });
325
+
326
+ test('does NOT fire class-level onThreadCreated when reusing an existing thread', async () => {
327
+ const vellumAdapter = new MockAdapter('vellum');
328
+ const broadcaster = new NotificationBroadcaster([vellumAdapter]);
329
+ const ipcCalls: ThreadCreatedInfo[] = [];
330
+ const dispatchCalls: ThreadCreatedInfo[] = [];
331
+ broadcaster.setOnThreadCreated((info) => ipcCalls.push(info));
332
+
333
+ // Simulate a successful reuse by injecting a pairing result with
334
+ // createdNewConversation=false. This bypasses the real conversation
335
+ // store (which would fall back to creating a new conversation since
336
+ // the target does not exist in the test DB).
337
+ nextPairingResult = {
338
+ conversationId: 'conv-reused-456',
339
+ messageId: 'msg-reused-789',
340
+ strategy: 'start_new_conversation',
341
+ createdNewConversation: false,
342
+ threadDecisionFallbackUsed: false,
343
+ };
344
+
345
+ const signal = makeSignal();
346
+ const decision = makeDecision({
347
+ threadActions: {
348
+ vellum: { action: 'reuse_existing', conversationId: 'conv-existing-123' },
349
+ },
350
+ });
351
+
352
+ await broadcaster.broadcastDecision(signal, decision, {
353
+ onThreadCreated: (info) => dispatchCalls.push(info),
354
+ });
355
+
356
+ // The class-level IPC callback should NOT fire because
357
+ // createdNewConversation is false — the client already knows about
358
+ // the reused conversation.
359
+ expect(ipcCalls).toHaveLength(0);
360
+
361
+ // The per-dispatch callback SHOULD fire for both new and reused
362
+ // pairings (used by callers like dispatchGuardianQuestion for
363
+ // delivery bookkeeping).
364
+ expect(dispatchCalls).toHaveLength(1);
365
+ expect(dispatchCalls[0].conversationId).toBe('conv-reused-456');
366
+ });
256
367
  });
@@ -3,7 +3,8 @@
3
3
  *
4
4
  * Validates that the deterministic fallback correctly classifies signals based
5
5
  * on urgency + requiresAction, that channel selection respects connected channels,
6
- * and that the copy-composer generates correct fallback copy for known event names.
6
+ * the copy-composer generates correct fallback copy for known event names, and
7
+ * thread action types are structurally correct.
7
8
  */
8
9
 
9
10
  import { describe, expect, test } from 'bun:test';
@@ -3,7 +3,8 @@
3
3
  *
4
4
  * Validates that the VellumAdapter broadcasts notification_intent with
5
5
  * deepLinkMetadata, and that the broadcaster correctly passes deepLinkTarget
6
- * from the decision through to the adapter payload.
6
+ * from the decision through to the adapter payload — regardless of whether
7
+ * the conversation was newly created or reused.
7
8
  */
8
9
 
9
10
  import { describe, expect, mock, test } from 'bun:test';
@@ -154,5 +155,47 @@ describe('notification deep-link metadata', () => {
154
155
  expect(metadata.conversationId).toBe('conv-task-run-42');
155
156
  expect(metadata.workItemId).toBe('work-item-7');
156
157
  });
158
+
159
+ // ── Deep-link conversationId present regardless of reuse/new ──────
160
+
161
+ test('deep-link payload includes conversationId for a newly created conversation', async () => {
162
+ const messages: ServerMessage[] = [];
163
+ const adapter = new VellumAdapter((msg) => messages.push(msg));
164
+
165
+ // Simulates the broadcaster merging pairing.conversationId into deep-link
166
+ // for a newly created notification thread (start_new path)
167
+ await adapter.send(
168
+ {
169
+ sourceEventName: 'reminder.fired',
170
+ copy: { title: 'Reminder', body: 'Take out the trash' },
171
+ deepLinkTarget: { conversationId: 'conv-new-thread-001' },
172
+ },
173
+ { channel: 'vellum' },
174
+ );
175
+
176
+ const msg = messages[0] as unknown as Record<string, unknown>;
177
+ const metadata = msg.deepLinkMetadata as Record<string, unknown>;
178
+ expect(metadata.conversationId).toBe('conv-new-thread-001');
179
+ });
180
+
181
+ test('deep-link payload includes conversationId for a reused conversation', async () => {
182
+ const messages: ServerMessage[] = [];
183
+ const adapter = new VellumAdapter((msg) => messages.push(msg));
184
+
185
+ // Simulates the broadcaster merging pairing.conversationId into deep-link
186
+ // for a reused notification thread (reuse_existing path)
187
+ await adapter.send(
188
+ {
189
+ sourceEventName: 'reminder.fired',
190
+ copy: { title: 'Follow-up', body: 'Still need to take out the trash' },
191
+ deepLinkTarget: { conversationId: 'conv-reused-thread-042' },
192
+ },
193
+ { channel: 'vellum' },
194
+ );
195
+
196
+ const msg = messages[0] as unknown as Record<string, unknown>;
197
+ const metadata = msg.deepLinkMetadata as Record<string, unknown>;
198
+ expect(metadata.conversationId).toBe('conv-reused-thread-042');
199
+ });
157
200
  });
158
201
  });
@@ -333,4 +333,161 @@ describe('ASK_GUARDIAN canonical notification path', () => {
333
333
  expect(vellumDelivery!.status).toBe('failed');
334
334
  expect(vellumDelivery!.last_error).toContain('No vellum delivery result');
335
335
  });
336
+
337
+ test('context payload includes callSessionId and activeGuardianRequestCount for candidate-affinity', async () => {
338
+ const convId = 'conv-guardian-notif-affinity';
339
+ ensureConversation(convId);
340
+
341
+ const session = createCallSession({
342
+ conversationId: convId,
343
+ provider: 'twilio',
344
+ fromNumber: '+15550001111',
345
+ toNumber: '+15550002222',
346
+ });
347
+ const pq = createPendingQuestion(session.id, 'Affinity test question');
348
+
349
+ await dispatchGuardianQuestion({
350
+ callSessionId: session.id,
351
+ conversationId: convId,
352
+ assistantId: 'self',
353
+ pendingQuestion: pq,
354
+ });
355
+
356
+ expect(emitCalls.length).toBe(1);
357
+ const signalParams = emitCalls[0] as Record<string, unknown>;
358
+ const payload = signalParams.contextPayload as Record<string, unknown>;
359
+
360
+ // callSessionId is present for the decision engine to match candidates to the current call
361
+ expect(payload.callSessionId).toBe(session.id);
362
+ // activeGuardianRequestCount provides a hint about whether to reuse an existing thread
363
+ expect(typeof payload.activeGuardianRequestCount).toBe('number');
364
+ expect(payload.activeGuardianRequestCount).toBeGreaterThanOrEqual(1);
365
+ });
366
+
367
+ test('repeated guardian questions retain per-request delivery records when sharing a conversation', async () => {
368
+ const convId = 'conv-guardian-notif-reuse';
369
+ ensureConversation(convId);
370
+
371
+ const sharedConvId = 'conv-guardian-shared-thread';
372
+
373
+ const session = createCallSession({
374
+ conversationId: convId,
375
+ provider: 'twilio',
376
+ fromNumber: '+15550001111',
377
+ toNumber: '+15550002222',
378
+ });
379
+
380
+ // First guardian question
381
+ const pq1 = createPendingQuestion(session.id, 'Can they enter through the side gate?');
382
+ mockEmitResult = {
383
+ signalId: 'sig-reuse-a',
384
+ deduplicated: false,
385
+ dispatched: true,
386
+ reason: 'ok',
387
+ deliveryResults: [
388
+ {
389
+ channel: 'vellum',
390
+ destination: 'vellum',
391
+ status: 'sent',
392
+ conversationId: sharedConvId,
393
+ },
394
+ ],
395
+ };
396
+
397
+ await dispatchGuardianQuestion({
398
+ callSessionId: session.id,
399
+ conversationId: convId,
400
+ assistantId: 'self',
401
+ pendingQuestion: pq1,
402
+ });
403
+
404
+ // Second guardian question (same call, pipeline reuses the same conversation)
405
+ emitCalls.length = 0;
406
+ const pq2 = createPendingQuestion(session.id, 'What about the back door?');
407
+ mockEmitResult = {
408
+ signalId: 'sig-reuse-b',
409
+ deduplicated: false,
410
+ dispatched: true,
411
+ reason: 'ok',
412
+ deliveryResults: [
413
+ {
414
+ channel: 'vellum',
415
+ destination: 'vellum',
416
+ status: 'sent',
417
+ conversationId: sharedConvId,
418
+ },
419
+ ],
420
+ };
421
+
422
+ await dispatchGuardianQuestion({
423
+ callSessionId: session.id,
424
+ conversationId: convId,
425
+ assistantId: 'self',
426
+ pendingQuestion: pq2,
427
+ });
428
+
429
+ // Verify: two distinct guardian_action_requests exist
430
+ const db = getDb();
431
+ const raw = (db as unknown as { $client: import('bun:sqlite').Database }).$client;
432
+ const requests = raw.query(
433
+ 'SELECT id, question_text FROM guardian_action_requests WHERE call_session_id = ? ORDER BY created_at ASC',
434
+ ).all(session.id) as Array<{ id: string; question_text: string }>;
435
+ expect(requests).toHaveLength(2);
436
+ expect(requests[0].question_text).toBe('Can they enter through the side gate?');
437
+ expect(requests[1].question_text).toBe('What about the back door?');
438
+
439
+ // Verify: each request has its own delivery row pointing to the shared conversation
440
+ const deliveries = raw.query(
441
+ 'SELECT request_id, destination_conversation_id, status FROM guardian_action_deliveries WHERE destination_conversation_id = ? ORDER BY created_at ASC',
442
+ ).all(sharedConvId) as Array<{ request_id: string; destination_conversation_id: string; status: string }>;
443
+ expect(deliveries).toHaveLength(2);
444
+ expect(deliveries[0].request_id).toBe(requests[0].id);
445
+ expect(deliveries[1].request_id).toBe(requests[1].id);
446
+ expect(deliveries[0].status).toBe('sent');
447
+ expect(deliveries[1].status).toBe('sent');
448
+ });
449
+
450
+ test('follow-up/timeout flow is unchanged — expired request still gets fallback delivery on no pipeline result', async () => {
451
+ const convId = 'conv-guardian-notif-timeout';
452
+ ensureConversation(convId);
453
+
454
+ // Simulate a scenario where the pipeline returns no delivery results (e.g. blocked)
455
+ mockEmitResult = {
456
+ signalId: 'sig-timeout',
457
+ deduplicated: false,
458
+ dispatched: false,
459
+ reason: 'blocked by deterministic checks',
460
+ deliveryResults: [],
461
+ };
462
+
463
+ const session = createCallSession({
464
+ conversationId: convId,
465
+ provider: 'twilio',
466
+ fromNumber: '+15550001111',
467
+ toNumber: '+15550002222',
468
+ });
469
+ const pq = createPendingQuestion(session.id, 'Timeout scenario');
470
+
471
+ await dispatchGuardianQuestion({
472
+ callSessionId: session.id,
473
+ conversationId: convId,
474
+ assistantId: 'self',
475
+ pendingQuestion: pq,
476
+ });
477
+
478
+ // The dispatch should still create a failed fallback delivery row
479
+ const db = getDb();
480
+ const raw = (db as unknown as { $client: import('bun:sqlite').Database }).$client;
481
+ const request = raw.query('SELECT id FROM guardian_action_requests WHERE call_session_id = ?').get(session.id) as
482
+ | { id: string }
483
+ | undefined;
484
+ expect(request).toBeDefined();
485
+
486
+ const delivery = raw.query(
487
+ 'SELECT status, last_error FROM guardian_action_deliveries WHERE request_id = ? AND destination_channel = ?',
488
+ ).get(request!.id, 'vellum') as { status: string; last_error: string | null } | undefined;
489
+ expect(delivery).toBeDefined();
490
+ expect(delivery!.status).toBe('failed');
491
+ expect(delivery!.last_error).toContain('No vellum delivery result');
492
+ });
336
493
  });