@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.
- package/ARCHITECTURE.md +74 -13
- package/README.md +6 -0
- package/docs/architecture/http-token-refresh.md +23 -1
- package/docs/architecture/security.md +80 -0
- package/package.json +1 -1
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +4 -0
- package/src/__tests__/access-request-decision.test.ts +4 -7
- package/src/__tests__/call-controller.test.ts +170 -0
- package/src/__tests__/channel-guardian.test.ts +3 -1
- package/src/__tests__/checker.test.ts +139 -48
- package/src/__tests__/config-watcher.test.ts +11 -13
- package/src/__tests__/conversation-pairing.test.ts +103 -3
- package/src/__tests__/guardian-action-conversation-turn.test.ts +1 -1
- package/src/__tests__/guardian-action-followup-executor.test.ts +1 -1
- package/src/__tests__/guardian-action-grant-mint-consume.test.ts +511 -0
- package/src/__tests__/guardian-action-late-reply.test.ts +131 -0
- package/src/__tests__/guardian-action-store.test.ts +182 -0
- package/src/__tests__/guardian-dispatch.test.ts +180 -0
- package/src/__tests__/guardian-grant-minting.test.ts +543 -0
- package/src/__tests__/ipc-snapshot.test.ts +22 -0
- package/src/__tests__/non-member-access-request.test.ts +1 -2
- package/src/__tests__/notification-broadcaster.test.ts +115 -4
- package/src/__tests__/notification-decision-strategy.test.ts +2 -1
- package/src/__tests__/notification-deep-link.test.ts +44 -1
- package/src/__tests__/notification-guardian-path.test.ts +157 -0
- package/src/__tests__/notification-thread-candidate-validation.test.ts +215 -0
- package/src/__tests__/remote-skill-policy.test.ts +215 -0
- package/src/__tests__/scoped-approval-grants.test.ts +521 -0
- package/src/__tests__/scoped-grant-security-matrix.test.ts +443 -0
- package/src/__tests__/slack-channel-config.test.ts +3 -3
- package/src/__tests__/trust-store.test.ts +23 -21
- package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +5 -7
- package/src/__tests__/trusted-contact-multichannel.test.ts +2 -6
- package/src/__tests__/trusted-contact-verification.test.ts +9 -9
- package/src/__tests__/update-bulletin-state.test.ts +1 -1
- package/src/__tests__/update-bulletin.test.ts +66 -3
- package/src/__tests__/update-template-contract.test.ts +6 -11
- package/src/__tests__/voice-scoped-grant-consumer.test.ts +571 -0
- package/src/__tests__/voice-session-bridge.test.ts +109 -9
- package/src/calls/call-controller.ts +150 -8
- package/src/calls/call-domain.ts +12 -0
- package/src/calls/guardian-action-sweep.ts +1 -1
- package/src/calls/guardian-dispatch.ts +16 -0
- package/src/calls/relay-server.ts +13 -0
- package/src/calls/voice-session-bridge.ts +46 -5
- package/src/cli/core-commands.ts +41 -1
- package/src/config/bundled-skills/notifications/SKILL.md +18 -0
- package/src/config/schema.ts +6 -0
- package/src/config/skills-schema.ts +27 -0
- package/src/config/templates/UPDATES.md +5 -6
- package/src/config/update-bulletin-format.ts +2 -0
- package/src/config/update-bulletin-state.ts +1 -1
- package/src/config/update-bulletin-template-path.ts +6 -0
- package/src/config/update-bulletin.ts +21 -6
- package/src/daemon/config-watcher.ts +3 -2
- package/src/daemon/daemon-control.ts +64 -10
- package/src/daemon/handlers/config-channels.ts +18 -0
- package/src/daemon/handlers/config-slack-channel.ts +1 -1
- package/src/daemon/handlers/identity.ts +45 -25
- package/src/daemon/handlers/sessions.ts +1 -1
- package/src/daemon/handlers/skills.ts +45 -2
- package/src/daemon/ipc-contract/sessions.ts +1 -1
- package/src/daemon/ipc-contract/skills.ts +1 -0
- package/src/daemon/ipc-contract/workspace.ts +12 -1
- package/src/daemon/ipc-contract-inventory.json +1 -0
- package/src/daemon/lifecycle.ts +8 -0
- package/src/daemon/server.ts +25 -3
- package/src/daemon/session-process.ts +450 -184
- package/src/daemon/tls-certs.ts +17 -12
- package/src/daemon/tool-side-effects.ts +1 -1
- package/src/memory/channel-delivery-store.ts +18 -20
- package/src/memory/channel-guardian-store.ts +39 -42
- package/src/memory/conversation-crud.ts +2 -2
- package/src/memory/conversation-queries.ts +2 -2
- package/src/memory/conversation-store.ts +24 -25
- package/src/memory/db-init.ts +17 -1
- package/src/memory/embedding-local.ts +16 -7
- package/src/memory/fts-reconciler.ts +41 -26
- package/src/memory/guardian-action-store.ts +65 -7
- package/src/memory/guardian-verification.ts +1 -0
- package/src/memory/jobs-worker.ts +2 -2
- package/src/memory/migrations/032-guardian-delivery-conversation-index.ts +15 -0
- package/src/memory/migrations/032-notification-delivery-thread-decision.ts +20 -0
- package/src/memory/migrations/033-scoped-approval-grants.ts +51 -0
- package/src/memory/migrations/034-guardian-action-tool-metadata.ts +12 -0
- package/src/memory/migrations/index.ts +6 -2
- package/src/memory/schema-migration.ts +1 -0
- package/src/memory/schema.ts +36 -1
- package/src/memory/scoped-approval-grants.ts +509 -0
- package/src/memory/search/semantic.ts +3 -3
- package/src/notifications/README.md +158 -17
- package/src/notifications/broadcaster.ts +68 -50
- package/src/notifications/conversation-pairing.ts +96 -18
- package/src/notifications/decision-engine.ts +6 -3
- package/src/notifications/deliveries-store.ts +12 -0
- package/src/notifications/emit-signal.ts +1 -0
- package/src/notifications/thread-candidates.ts +60 -25
- package/src/notifications/types.ts +2 -1
- package/src/permissions/checker.ts +28 -16
- package/src/permissions/defaults.ts +14 -4
- package/src/runtime/guardian-action-followup-executor.ts +1 -1
- package/src/runtime/guardian-action-grant-minter.ts +97 -0
- package/src/runtime/http-server.ts +11 -11
- package/src/runtime/routes/access-request-decision.ts +1 -1
- package/src/runtime/routes/debug-routes.ts +4 -4
- package/src/runtime/routes/guardian-approval-interception.ts +120 -4
- package/src/runtime/routes/inbound-message-handler.ts +100 -33
- package/src/runtime/routes/integration-routes.ts +2 -2
- package/src/security/tool-approval-digest.ts +67 -0
- package/src/skills/remote-skill-policy.ts +131 -0
- package/src/tools/permission-checker.ts +1 -2
- package/src/tools/secret-detection-handler.ts +1 -1
- package/src/tools/system/voice-config.ts +1 -1
- 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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
*
|
|
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
|
+
});
|