@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
@@ -73,6 +73,7 @@ const fakeWatcher = {
73
73
  };
74
74
 
75
75
  mock.module('node:fs', () => {
76
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
76
77
  const actual = require('node:fs');
77
78
  return {
78
79
  ...actual,
@@ -93,10 +94,6 @@ mock.module('node:fs', () => {
93
94
  };
94
95
  });
95
96
 
96
- // Track refreshConfigFromSources calls
97
- let refreshConfigCalled = false;
98
- let refreshConfigReturn = false;
99
-
100
97
  // Mock config/loader and other dependencies that ConfigWatcher imports
101
98
  mock.module('../config/loader.js', () => ({
102
99
  getConfig: () => ({}),
@@ -107,16 +104,18 @@ mock.module('../memory/embedding-backend.js', () => ({
107
104
  clearEmbeddingBackendCache: () => {},
108
105
  }));
109
106
 
107
+ let trustClearCacheCallCount = 0;
110
108
  mock.module('../permissions/trust-store.js', () => ({
111
- clearCache: () => {},
109
+ clearCache: () => { trustClearCacheCallCount++; },
112
110
  }));
113
111
 
114
112
  mock.module('../providers/registry.js', () => ({
115
113
  initializeProviders: () => {},
116
114
  }));
117
115
 
116
+ let resetAllowlistCallCount = 0;
118
117
  mock.module('../security/secret-allowlist.js', () => ({
119
- resetAllowlist: () => {},
118
+ resetAllowlist: () => { resetAllowlistCallCount++; },
120
119
  validateAllowlistFile: () => [],
121
120
  }));
122
121
 
@@ -159,6 +158,8 @@ const onSessionEvict = () => { evictCallCount++; };
159
158
  beforeEach(() => {
160
159
  capturedWatchers.length = 0;
161
160
  evictCallCount = 0;
161
+ trustClearCacheCallCount = 0;
162
+ resetAllowlistCallCount = 0;
162
163
  watcher = new ConfigWatcher();
163
164
  });
164
165
 
@@ -209,8 +210,6 @@ describe('ConfigWatcher workspace file handlers', () => {
209
210
  });
210
211
 
211
212
  test('config.json change calls refreshConfigFromSources', async () => {
212
- // Spy on refreshConfigFromSources to verify it is called
213
- const originalRefresh = watcher.refreshConfigFromSources.bind(watcher);
214
213
  let refreshCalled = false;
215
214
  watcher.refreshConfigFromSources = () => {
216
215
  refreshCalled = true;
@@ -273,11 +272,6 @@ describe('ConfigWatcher workspace file handlers', () => {
273
272
 
274
273
  describe('ConfigWatcher protected directory handlers', () => {
275
274
  test('trust.json change calls clearTrustCache', async () => {
276
- let trustCacheClearCalled = false;
277
-
278
- // Re-mock trust-store to track calls
279
- const { clearCache } = await import('../permissions/trust-store.js');
280
-
281
275
  watcher.start(onSessionEvict);
282
276
  const protectedWatcher = findWatcher(PROTECTED_DIR);
283
277
  expect(protectedWatcher).toBeDefined();
@@ -286,6 +280,8 @@ describe('ConfigWatcher protected directory handlers', () => {
286
280
  await new Promise((r) => setTimeout(r, 300));
287
281
  // trust.json should NOT trigger session eviction
288
282
  expect(evictCallCount).toBe(0);
283
+ // but clearCache should have been called
284
+ expect(trustClearCacheCallCount).toBe(1);
289
285
  });
290
286
 
291
287
  test('secret-allowlist.json change calls resetAllowlist', async () => {
@@ -297,6 +293,8 @@ describe('ConfigWatcher protected directory handlers', () => {
297
293
  await new Promise((r) => setTimeout(r, 300));
298
294
  // secret-allowlist.json should NOT trigger session eviction
299
295
  expect(evictCallCount).toBe(0);
296
+ // but resetAllowlist should have been called
297
+ expect(resetAllowlistCallCount).toBe(1);
300
298
  });
301
299
  });
302
300
 
@@ -2,8 +2,9 @@
2
2
  * Regression tests for notification conversation pairing.
3
3
  *
4
4
  * Validates that pairDeliveryWithConversation materializes conversations
5
- * and messages according to the channel's conversation strategy, and that
6
- * errors in pairing never break the notification pipeline.
5
+ * and messages according to the channel's conversation strategy, handles
6
+ * thread reuse decisions, and that errors in pairing never break the
7
+ * notification pipeline.
7
8
  */
8
9
 
9
10
  import { beforeEach, describe, expect, mock, test } from 'bun:test';
@@ -22,6 +23,9 @@ let mockMessageId = 'msg-001';
22
23
  let createConversationShouldThrow = false;
23
24
  let addMessageShouldThrow = false;
24
25
 
26
+ /** Simulated existing conversations for getConversation mock. */
27
+ let mockExistingConversations: Record<string, { id: string; source: string; title: string | null }> = {};
28
+
25
29
  const createConversationMock = mock((_opts?: unknown) => {
26
30
  if (createConversationShouldThrow) throw new Error('DB write failed');
27
31
  return { id: mockConversationId };
@@ -40,14 +44,19 @@ const addMessageMock = mock(
40
44
  },
41
45
  );
42
46
 
47
+ const getConversationMock = mock((id: string) => {
48
+ return mockExistingConversations[id] ?? null;
49
+ });
50
+
43
51
  mock.module('../memory/conversation-store.js', () => ({
44
52
  createConversation: createConversationMock,
45
53
  addMessage: addMessageMock,
54
+ getConversation: getConversationMock,
46
55
  }));
47
56
 
48
57
  import { pairDeliveryWithConversation } from '../notifications/conversation-pairing.js';
49
58
  import type { NotificationSignal } from '../notifications/signal.js';
50
- import type { NotificationChannel, RenderedChannelCopy } from '../notifications/types.js';
59
+ import type { NotificationChannel, RenderedChannelCopy, ThreadAction } from '../notifications/types.js';
51
60
 
52
61
  // ── Test helpers ────────────────────────────────────────────────────────
53
62
 
@@ -82,10 +91,12 @@ describe('pairDeliveryWithConversation', () => {
82
91
  beforeEach(() => {
83
92
  createConversationMock.mockClear();
84
93
  addMessageMock.mockClear();
94
+ getConversationMock.mockClear();
85
95
  mockConversationId = 'conv-001';
86
96
  mockMessageId = 'msg-001';
87
97
  createConversationShouldThrow = false;
88
98
  addMessageShouldThrow = false;
99
+ mockExistingConversations = {};
89
100
  });
90
101
 
91
102
  // ── start_new_conversation (vellum) ─────────────────────────────────
@@ -99,6 +110,8 @@ describe('pairDeliveryWithConversation', () => {
99
110
  expect(result.conversationId).toBe('conv-001');
100
111
  expect(result.messageId).toBe('msg-001');
101
112
  expect(result.strategy).toBe('start_new_conversation');
113
+ expect(result.createdNewConversation).toBe(true);
114
+ expect(result.threadDecisionFallbackUsed).toBe(false);
102
115
  expect(createConversationMock).toHaveBeenCalledTimes(1);
103
116
  expect(addMessageMock).toHaveBeenCalledTimes(1);
104
117
  const callArgs = createConversationMock.mock.calls[0]![0] as Record<string, unknown>;
@@ -195,6 +208,7 @@ describe('pairDeliveryWithConversation', () => {
195
208
  expect(result.conversationId).toBe('conv-001');
196
209
  expect(result.messageId).toBe('msg-001');
197
210
  expect(result.strategy).toBe('continue_existing_conversation');
211
+ expect(result.createdNewConversation).toBe(true);
198
212
  expect(createConversationMock).toHaveBeenCalledTimes(1);
199
213
  const callArgs = createConversationMock.mock.calls[0]![0] as Record<string, unknown>;
200
214
  expect(callArgs.threadType).toBe('background');
@@ -218,10 +232,95 @@ describe('pairDeliveryWithConversation', () => {
218
232
  expect(result.conversationId).toBeNull();
219
233
  expect(result.messageId).toBeNull();
220
234
  expect(result.strategy).toBe('not_deliverable');
235
+ expect(result.createdNewConversation).toBe(false);
221
236
  expect(createConversationMock).not.toHaveBeenCalled();
222
237
  expect(addMessageMock).not.toHaveBeenCalled();
223
238
  });
224
239
 
240
+ // ── Thread reuse (reuse_existing) ─────────────────────────────────
241
+
242
+ test('reuses existing conversation when threadAction is reuse_existing and target is valid', async () => {
243
+ mockExistingConversations['conv-existing'] = {
244
+ id: 'conv-existing',
245
+ source: 'notification',
246
+ title: 'Previous Thread',
247
+ };
248
+
249
+ const signal = makeSignal();
250
+ const copy = makeCopy({ threadSeedMessage: 'Follow-up notification message content' });
251
+ const threadAction: ThreadAction = { action: 'reuse_existing', conversationId: 'conv-existing' };
252
+
253
+ const result = await pairDeliveryWithConversation(signal, 'vellum' as NotificationChannel, copy, { threadAction });
254
+
255
+ expect(result.conversationId).toBe('conv-existing');
256
+ expect(result.messageId).toBe('msg-001');
257
+ expect(result.createdNewConversation).toBe(false);
258
+ expect(result.threadDecisionFallbackUsed).toBe(false);
259
+ // Should NOT have created a new conversation — only addMessage should be called
260
+ expect(createConversationMock).not.toHaveBeenCalled();
261
+ expect(addMessageMock).toHaveBeenCalledTimes(1);
262
+ // Verify addMessage was called with the existing conversation ID
263
+ expect(addMessageMock.mock.calls[0]![0]).toBe('conv-existing');
264
+ });
265
+
266
+ test('falls back to new conversation when reuse target does not exist', async () => {
267
+ // No existing conversations — target is stale/invalid
268
+ const signal = makeSignal();
269
+ const copy = makeCopy();
270
+ const threadAction: ThreadAction = { action: 'reuse_existing', conversationId: 'conv-nonexistent' };
271
+
272
+ const result = await pairDeliveryWithConversation(signal, 'vellum' as NotificationChannel, copy, { threadAction });
273
+
274
+ expect(result.conversationId).toBe('conv-001');
275
+ expect(result.messageId).toBe('msg-001');
276
+ expect(result.createdNewConversation).toBe(true);
277
+ expect(result.threadDecisionFallbackUsed).toBe(true);
278
+ expect(createConversationMock).toHaveBeenCalledTimes(1);
279
+ });
280
+
281
+ test('falls back to new conversation when reuse target has wrong source', async () => {
282
+ // Conversation exists but was created by user, not notification
283
+ mockExistingConversations['conv-user'] = {
284
+ id: 'conv-user',
285
+ source: 'user',
286
+ title: 'User Thread',
287
+ };
288
+
289
+ const signal = makeSignal();
290
+ const copy = makeCopy();
291
+ const threadAction: ThreadAction = { action: 'reuse_existing', conversationId: 'conv-user' };
292
+
293
+ const result = await pairDeliveryWithConversation(signal, 'vellum' as NotificationChannel, copy, { threadAction });
294
+
295
+ expect(result.conversationId).toBe('conv-001');
296
+ expect(result.createdNewConversation).toBe(true);
297
+ expect(result.threadDecisionFallbackUsed).toBe(true);
298
+ });
299
+
300
+ test('creates new conversation when threadAction is start_new', async () => {
301
+ const signal = makeSignal();
302
+ const copy = makeCopy();
303
+ const threadAction: ThreadAction = { action: 'start_new' };
304
+
305
+ const result = await pairDeliveryWithConversation(signal, 'vellum' as NotificationChannel, copy, { threadAction });
306
+
307
+ expect(result.conversationId).toBe('conv-001');
308
+ expect(result.createdNewConversation).toBe(true);
309
+ expect(result.threadDecisionFallbackUsed).toBe(false);
310
+ expect(createConversationMock).toHaveBeenCalledTimes(1);
311
+ });
312
+
313
+ test('creates new conversation when threadAction is undefined (default)', async () => {
314
+ const signal = makeSignal();
315
+ const copy = makeCopy();
316
+
317
+ const result = await pairDeliveryWithConversation(signal, 'vellum' as NotificationChannel, copy);
318
+
319
+ expect(result.conversationId).toBe('conv-001');
320
+ expect(result.createdNewConversation).toBe(true);
321
+ expect(result.threadDecisionFallbackUsed).toBe(false);
322
+ });
323
+
225
324
  // ── Error resilience ──────────────────────────────────────────────
226
325
 
227
326
  test('catches createConversation errors and returns null IDs without throwing', async () => {
@@ -236,6 +335,7 @@ describe('pairDeliveryWithConversation', () => {
236
335
  expect(result.messageId).toBeNull();
237
336
  // Strategy should still be resolved from the policy registry
238
337
  expect(result.strategy).toBe('start_new_conversation');
338
+ expect(result.createdNewConversation).toBe(false);
239
339
  });
240
340
 
241
341
  test('catches addMessage errors and returns null IDs without throwing', async () => {
@@ -39,13 +39,13 @@ import {
39
39
  startFollowupFromExpiredRequest,
40
40
  updateDeliveryStatus,
41
41
  } from '../memory/guardian-action-store.js';
42
+ import { conversations } from '../memory/schema.js';
42
43
  import { processGuardianFollowUpTurn } from '../runtime/guardian-action-conversation-turn.js';
43
44
  import type {
44
45
  GuardianFollowUpConversationContext,
45
46
  GuardianFollowUpConversationGenerator,
46
47
  GuardianFollowUpTurnResult,
47
48
  } from '../runtime/http-types.js';
48
- import { conversations } from '../memory/schema.js';
49
49
 
50
50
  initializeDb();
51
51
 
@@ -71,9 +71,9 @@ import {
71
71
  startFollowupFromExpiredRequest,
72
72
  updateDeliveryStatus,
73
73
  } from '../memory/guardian-action-store.js';
74
+ import { conversations } from '../memory/schema.js';
74
75
  import { executeFollowupAction } from '../runtime/guardian-action-followup-executor.js';
75
76
  import { resolveCounterparty } from '../runtime/guardian-action-followup-executor.js';
76
- import { conversations } from '../memory/schema.js';
77
77
 
78
78
  initializeDb();
79
79
 
@@ -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
  });
@@ -31,8 +31,16 @@ import {
31
31
  cancelGuardianActionRequest,
32
32
  createGuardianActionDelivery,
33
33
  createGuardianActionRequest,
34
+ expireGuardianActionRequest,
34
35
  getDeliveriesByRequestId,
36
+ getExpiredDeliveriesByConversation,
37
+ getExpiredDeliveryByConversation,
38
+ getFollowupDeliveriesByConversation,
39
+ getFollowupDeliveryByConversation,
35
40
  getGuardianActionRequest,
41
+ getPendingDeliveriesByConversation,
42
+ getPendingDeliveryByConversation,
43
+ startFollowupFromExpiredRequest,
36
44
  updateDeliveryStatus,
37
45
  } from '../memory/guardian-action-store.js';
38
46
  import { conversations } from '../memory/schema.js';
@@ -74,6 +82,180 @@ describe('guardian-action-store', () => {
74
82
  }
75
83
  });
76
84
 
85
+ // ── Helper to create a pending request+delivery targeting a conversation ──
86
+ function createPendingRequestWithDelivery(convId: string, deliveryConvId: string) {
87
+ ensureConversation(convId);
88
+ const session = createCallSession({
89
+ conversationId: convId,
90
+ provider: 'twilio',
91
+ fromNumber: '+15550001111',
92
+ toNumber: '+15550002222',
93
+ });
94
+ const pq = createPendingQuestion(session.id, `Question for ${convId}`);
95
+ const request = createGuardianActionRequest({
96
+ kind: 'ask_guardian',
97
+ sourceChannel: 'voice',
98
+ sourceConversationId: convId,
99
+ callSessionId: session.id,
100
+ pendingQuestionId: pq.id,
101
+ questionText: pq.questionText,
102
+ expiresAt: Date.now() + 60_000,
103
+ });
104
+ const delivery = createGuardianActionDelivery({
105
+ requestId: request.id,
106
+ destinationChannel: 'vellum',
107
+ destinationConversationId: deliveryConvId,
108
+ });
109
+ updateDeliveryStatus(delivery.id, 'sent');
110
+ return { request, delivery };
111
+ }
112
+
113
+ // ── getPendingDeliveriesByConversation ──────────────────────────────
114
+
115
+ test('getPendingDeliveriesByConversation returns all pending deliveries for a conversation', () => {
116
+ const sharedConvId = 'shared-pending-conv';
117
+ ensureConversation(sharedConvId);
118
+
119
+ const { request: req1 } = createPendingRequestWithDelivery('source-conv-p1', sharedConvId);
120
+ const { request: req2 } = createPendingRequestWithDelivery('source-conv-p2', sharedConvId);
121
+
122
+ const deliveries = getPendingDeliveriesByConversation(sharedConvId);
123
+ expect(deliveries).toHaveLength(2);
124
+
125
+ const requestIds = deliveries.map((d) => d.requestId);
126
+ expect(requestIds).toContain(req1.id);
127
+ expect(requestIds).toContain(req2.id);
128
+ });
129
+
130
+ test('getPendingDeliveriesByConversation returns single delivery (fast path preserved)', () => {
131
+ const convId = 'single-pending-conv';
132
+ ensureConversation(convId);
133
+
134
+ const { request } = createPendingRequestWithDelivery('source-conv-single-p', convId);
135
+
136
+ const deliveries = getPendingDeliveriesByConversation(convId);
137
+ expect(deliveries).toHaveLength(1);
138
+ expect(deliveries[0].requestId).toBe(request.id);
139
+ });
140
+
141
+ test('getPendingDeliveryByConversation returns first from multiple (backward compat)', () => {
142
+ const convId = 'compat-pending-conv';
143
+ ensureConversation(convId);
144
+
145
+ createPendingRequestWithDelivery('source-conv-compat-p1', convId);
146
+ createPendingRequestWithDelivery('source-conv-compat-p2', convId);
147
+
148
+ const single = getPendingDeliveryByConversation(convId);
149
+ expect(single).not.toBeNull();
150
+ });
151
+
152
+ test('getPendingDeliveriesByConversation returns empty for non-matching conversation', () => {
153
+ ensureConversation('other-conv');
154
+ createPendingRequestWithDelivery('source-conv-no-match', 'other-conv');
155
+
156
+ const deliveries = getPendingDeliveriesByConversation('nonexistent-conv');
157
+ expect(deliveries).toHaveLength(0);
158
+ });
159
+
160
+ // ── getExpiredDeliveriesByConversation ──────────────────────────────
161
+
162
+ test('getExpiredDeliveriesByConversation returns all expired deliveries for a conversation', () => {
163
+ const sharedConvId = 'shared-expired-conv';
164
+ ensureConversation(sharedConvId);
165
+
166
+ const { request: req1 } = createPendingRequestWithDelivery('source-conv-e1', sharedConvId);
167
+ const { request: req2 } = createPendingRequestWithDelivery('source-conv-e2', sharedConvId);
168
+
169
+ expireGuardianActionRequest(req1.id, 'sweep_timeout');
170
+ expireGuardianActionRequest(req2.id, 'sweep_timeout');
171
+
172
+ const deliveries = getExpiredDeliveriesByConversation(sharedConvId);
173
+ expect(deliveries).toHaveLength(2);
174
+
175
+ const requestIds = deliveries.map((d) => d.requestId);
176
+ expect(requestIds).toContain(req1.id);
177
+ expect(requestIds).toContain(req2.id);
178
+ });
179
+
180
+ test('getExpiredDeliveryByConversation returns first from multiple (backward compat)', () => {
181
+ const convId = 'compat-expired-conv';
182
+ ensureConversation(convId);
183
+
184
+ const { request: req1 } = createPendingRequestWithDelivery('source-conv-compat-e1', convId);
185
+ const { request: req2 } = createPendingRequestWithDelivery('source-conv-compat-e2', convId);
186
+
187
+ expireGuardianActionRequest(req1.id, 'sweep_timeout');
188
+ expireGuardianActionRequest(req2.id, 'sweep_timeout');
189
+
190
+ const single = getExpiredDeliveryByConversation(convId);
191
+ expect(single).not.toBeNull();
192
+ });
193
+
194
+ test('getExpiredDeliveriesByConversation excludes deliveries with followup already started', () => {
195
+ const convId = 'expired-with-followup-conv';
196
+ ensureConversation(convId);
197
+
198
+ const { request: req1 } = createPendingRequestWithDelivery('source-conv-ef1', convId);
199
+ const { request: req2 } = createPendingRequestWithDelivery('source-conv-ef2', convId);
200
+
201
+ expireGuardianActionRequest(req1.id, 'sweep_timeout');
202
+ expireGuardianActionRequest(req2.id, 'sweep_timeout');
203
+
204
+ // Start followup on req1 — only req2 should remain in the expired query
205
+ startFollowupFromExpiredRequest(req1.id, 'late answer');
206
+
207
+ const deliveries = getExpiredDeliveriesByConversation(convId);
208
+ expect(deliveries).toHaveLength(1);
209
+ expect(deliveries[0].requestId).toBe(req2.id);
210
+ });
211
+
212
+ // ── getFollowupDeliveriesByConversation ─────────────────────────────
213
+
214
+ test('getFollowupDeliveriesByConversation returns all awaiting_guardian_choice deliveries', () => {
215
+ const convId = 'shared-followup-conv';
216
+ ensureConversation(convId);
217
+
218
+ const { request: req1 } = createPendingRequestWithDelivery('source-conv-f1', convId);
219
+ const { request: req2 } = createPendingRequestWithDelivery('source-conv-f2', convId);
220
+
221
+ expireGuardianActionRequest(req1.id, 'sweep_timeout');
222
+ expireGuardianActionRequest(req2.id, 'sweep_timeout');
223
+
224
+ startFollowupFromExpiredRequest(req1.id, 'late answer 1');
225
+ startFollowupFromExpiredRequest(req2.id, 'late answer 2');
226
+
227
+ const deliveries = getFollowupDeliveriesByConversation(convId);
228
+ expect(deliveries).toHaveLength(2);
229
+
230
+ const requestIds = deliveries.map((d) => d.requestId);
231
+ expect(requestIds).toContain(req1.id);
232
+ expect(requestIds).toContain(req2.id);
233
+ });
234
+
235
+ test('getFollowupDeliveryByConversation returns first from multiple (backward compat)', () => {
236
+ const convId = 'compat-followup-conv';
237
+ ensureConversation(convId);
238
+
239
+ const { request: req1 } = createPendingRequestWithDelivery('source-conv-compat-f1', convId);
240
+ const { request: req2 } = createPendingRequestWithDelivery('source-conv-compat-f2', convId);
241
+
242
+ expireGuardianActionRequest(req1.id, 'sweep_timeout');
243
+ expireGuardianActionRequest(req2.id, 'sweep_timeout');
244
+
245
+ startFollowupFromExpiredRequest(req1.id, 'late 1');
246
+ startFollowupFromExpiredRequest(req2.id, 'late 2');
247
+
248
+ const single = getFollowupDeliveryByConversation(convId);
249
+ expect(single).not.toBeNull();
250
+ });
251
+
252
+ test('getFollowupDeliveriesByConversation returns empty for non-matching conversation', () => {
253
+ const deliveries = getFollowupDeliveriesByConversation('nonexistent-conv');
254
+ expect(deliveries).toHaveLength(0);
255
+ });
256
+
257
+ // ── cancelGuardianActionRequest ─────────────────────────────────────
258
+
77
259
  test('cancelGuardianActionRequest cancels both pending and sent deliveries', () => {
78
260
  const conversationId = 'conv-guardian-cancel';
79
261
  ensureConversation(conversationId);