@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
@@ -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
  });
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Focused tests for thread candidate validation in the notification decision
3
+ * engine. Validates that:
4
+ * - Valid reuse targets pass validation
5
+ * - Invalid reuse targets are rejected and downgraded to start_new
6
+ * - Candidate context is structurally correct and auditable
7
+ */
8
+
9
+ import { describe, expect, test } from 'bun:test';
10
+
11
+ import { validateThreadActions } from '../notifications/decision-engine.js';
12
+ import type {
13
+ ThreadCandidate,
14
+ ThreadCandidateSet,
15
+ } from '../notifications/thread-candidates.js';
16
+ import type {
17
+ NotificationChannel,
18
+ ThreadAction,
19
+ } from '../notifications/types.js';
20
+
21
+ // -- Helpers -----------------------------------------------------------------
22
+
23
+ function makeCandidate(overrides?: Partial<ThreadCandidate>): ThreadCandidate {
24
+ return {
25
+ conversationId: 'conv-default',
26
+ title: 'Test Thread',
27
+ updatedAt: Date.now(),
28
+ latestSourceEventName: 'test.event',
29
+ channel: 'vellum' as NotificationChannel,
30
+ ...overrides,
31
+ };
32
+ }
33
+
34
+ /**
35
+ * Simple candidate ID check equivalent to the removed isValidCandidateId.
36
+ * Used in tests to verify candidate matching semantics.
37
+ */
38
+ function isCandidateIdPresent(id: string, candidates: ThreadCandidate[]): boolean {
39
+ return candidates.some((c) => c.conversationId === id);
40
+ }
41
+
42
+ // -- Tests -------------------------------------------------------------------
43
+
44
+ describe('thread candidate validation', () => {
45
+ describe('candidate ID matching', () => {
46
+ test('returns true when conversationId matches a candidate', () => {
47
+ const candidates = [
48
+ makeCandidate({ conversationId: 'conv-001' }),
49
+ makeCandidate({ conversationId: 'conv-002' }),
50
+ ];
51
+
52
+ expect(isCandidateIdPresent('conv-001', candidates)).toBe(true);
53
+ expect(isCandidateIdPresent('conv-002', candidates)).toBe(true);
54
+ });
55
+
56
+ test('returns false when conversationId does not match any candidate', () => {
57
+ const candidates = [
58
+ makeCandidate({ conversationId: 'conv-001' }),
59
+ ];
60
+
61
+ expect(isCandidateIdPresent('conv-999', candidates)).toBe(false);
62
+ });
63
+
64
+ test('returns false for empty candidate list', () => {
65
+ expect(isCandidateIdPresent('conv-001', [])).toBe(false);
66
+ });
67
+
68
+ test('returns false for empty string conversationId', () => {
69
+ const candidates = [
70
+ makeCandidate({ conversationId: 'conv-001' }),
71
+ ];
72
+
73
+ expect(isCandidateIdPresent('', candidates)).toBe(false);
74
+ });
75
+
76
+ test('matching is exact (no substring or prefix matching)', () => {
77
+ const candidates = [
78
+ makeCandidate({ conversationId: 'conv-001' }),
79
+ ];
80
+
81
+ expect(isCandidateIdPresent('conv-00', candidates)).toBe(false);
82
+ expect(isCandidateIdPresent('conv-0011', candidates)).toBe(false);
83
+ expect(isCandidateIdPresent('CONV-001', candidates)).toBe(false);
84
+ });
85
+ });
86
+
87
+ describe('candidate metadata structure', () => {
88
+ test('candidate without guardian context has no optional fields', () => {
89
+ const candidate = makeCandidate();
90
+
91
+ expect(candidate.guardianContext).toBeUndefined();
92
+ });
93
+
94
+ test('candidate with guardian context includes pending counts', () => {
95
+ const candidate = makeCandidate({
96
+ guardianContext: { pendingUnresolvedRequestCount: 3 },
97
+ });
98
+
99
+ expect(candidate.guardianContext?.pendingUnresolvedRequestCount).toBe(3);
100
+ });
101
+
102
+ test('candidate with null title is valid', () => {
103
+ const candidate = makeCandidate({ title: null });
104
+ expect(candidate.title).toBeNull();
105
+ });
106
+
107
+ test('candidate with null latestSourceEventName is valid', () => {
108
+ const candidate = makeCandidate({ latestSourceEventName: null });
109
+ expect(candidate.latestSourceEventName).toBeNull();
110
+ });
111
+ });
112
+
113
+ describe('thread action downgrade semantics', () => {
114
+ test('start_new action does not require a conversationId', () => {
115
+ const action: ThreadAction = { action: 'start_new' };
116
+ expect(action.action).toBe('start_new');
117
+ expect('conversationId' in action).toBe(false);
118
+ });
119
+
120
+ test('reuse_existing with valid candidate is accepted via validateThreadActions', () => {
121
+ const candidateSet: ThreadCandidateSet = {
122
+ vellum: [makeCandidate({ conversationId: 'conv-valid' })],
123
+ };
124
+
125
+ const result = validateThreadActions(
126
+ { vellum: { action: 'reuse_existing', conversationId: 'conv-valid' } },
127
+ ['vellum'] as NotificationChannel[],
128
+ candidateSet,
129
+ );
130
+
131
+ expect(result.vellum?.action).toBe('reuse_existing');
132
+ if (result.vellum?.action === 'reuse_existing') {
133
+ expect(result.vellum.conversationId).toBe('conv-valid');
134
+ }
135
+ });
136
+
137
+ test('reuse_existing with invalid candidate is downgraded to start_new', () => {
138
+ const candidateSet: ThreadCandidateSet = {
139
+ vellum: [makeCandidate({ conversationId: 'conv-valid' })],
140
+ };
141
+
142
+ const result = validateThreadActions(
143
+ { vellum: { action: 'reuse_existing', conversationId: 'conv-hacked' } },
144
+ ['vellum'] as NotificationChannel[],
145
+ candidateSet,
146
+ );
147
+
148
+ expect(result.vellum?.action).toBe('start_new');
149
+ });
150
+
151
+ test('reuse_existing with empty candidate set is downgraded to start_new', () => {
152
+ const result = validateThreadActions(
153
+ { vellum: { action: 'reuse_existing', conversationId: 'conv-any' } },
154
+ ['vellum'] as NotificationChannel[],
155
+ undefined,
156
+ );
157
+
158
+ expect(result.vellum?.action).toBe('start_new');
159
+ });
160
+ });
161
+
162
+ describe('candidate set per channel', () => {
163
+ test('channels without candidates result in empty map entries', () => {
164
+ const candidateMap: ThreadCandidateSet = {};
165
+
166
+ // When no candidates exist for vellum, the map has no entry
167
+ expect(candidateMap.vellum).toBeUndefined();
168
+ });
169
+
170
+ test('candidate set preserves channel association via validateThreadActions', () => {
171
+ const vellumCandidates = [
172
+ makeCandidate({ conversationId: 'conv-v1', channel: 'vellum' as NotificationChannel }),
173
+ ];
174
+ const telegramCandidates = [
175
+ makeCandidate({ conversationId: 'conv-t1', channel: 'telegram' as NotificationChannel }),
176
+ ];
177
+
178
+ const candidateSet: ThreadCandidateSet = {
179
+ vellum: vellumCandidates,
180
+ telegram: telegramCandidates,
181
+ };
182
+
183
+ // Vellum candidate should not be valid for telegram and vice versa
184
+ const validChannels: NotificationChannel[] = ['vellum', 'telegram'];
185
+
186
+ const result1 = validateThreadActions(
187
+ { vellum: { action: 'reuse_existing', conversationId: 'conv-v1' } },
188
+ validChannels,
189
+ candidateSet,
190
+ );
191
+ expect(result1.vellum?.action).toBe('reuse_existing');
192
+
193
+ const result2 = validateThreadActions(
194
+ { vellum: { action: 'reuse_existing', conversationId: 'conv-t1' } },
195
+ validChannels,
196
+ candidateSet,
197
+ );
198
+ expect(result2.vellum?.action).toBe('start_new');
199
+
200
+ const result3 = validateThreadActions(
201
+ { telegram: { action: 'reuse_existing', conversationId: 'conv-t1' } },
202
+ validChannels,
203
+ candidateSet,
204
+ );
205
+ expect(result3.telegram?.action).toBe('reuse_existing');
206
+
207
+ const result4 = validateThreadActions(
208
+ { telegram: { action: 'reuse_existing', conversationId: 'conv-v1' } },
209
+ validChannels,
210
+ candidateSet,
211
+ );
212
+ expect(result4.telegram?.action).toBe('start_new');
213
+ });
214
+ });
215
+ });