@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.
- package/ARCHITECTURE.md +70 -13
- package/README.md +6 -0
- package/docs/architecture/http-token-refresh.md +23 -1
- package/package.json +1 -1
- package/src/__tests__/access-request-decision.test.ts +4 -7
- package/src/__tests__/channel-guardian.test.ts +3 -1
- package/src/__tests__/checker.test.ts +79 -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-late-reply.test.ts +131 -0
- package/src/__tests__/guardian-action-store.test.ts +182 -0
- package/src/__tests__/guardian-dispatch.test.ts +120 -0
- package/src/__tests__/ipc-snapshot.test.ts +21 -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__/slack-channel-config.test.ts +3 -3
- package/src/__tests__/trust-store.test.ts +21 -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-session-bridge.test.ts +109 -9
- package/src/calls/call-controller.ts +129 -8
- package/src/calls/guardian-action-sweep.ts +1 -1
- package/src/calls/guardian-dispatch.ts +8 -0
- package/src/calls/voice-session-bridge.ts +4 -2
- package/src/cli/core-commands.ts +41 -1
- 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-slack-channel.ts +1 -1
- package/src/daemon/handlers/identity.ts +45 -25
- package/src/daemon/handlers/sessions.ts +1 -1
- package/src/daemon/ipc-contract/sessions.ts +1 -1
- 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 +438 -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 +9 -1
- package/src/memory/fts-reconciler.ts +41 -26
- package/src/memory/guardian-action-store.ts +57 -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/index.ts +4 -2
- package/src/memory/schema-migration.ts +1 -0
- package/src/memory/schema.ts +6 -1
- 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 +1 -16
- package/src/permissions/defaults.ts +14 -4
- package/src/runtime/guardian-action-followup-executor.ts +1 -1
- 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 +4 -4
- package/src/runtime/routes/inbound-message-handler.ts +6 -6
- package/src/runtime/routes/integration-routes.ts +2 -2
- 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
|
@@ -335,4 +335,124 @@ describe('guardian-dispatch', () => {
|
|
|
335
335
|
expect(vellumDelivery).toBeDefined();
|
|
336
336
|
expect(vellumDelivery!.destination_conversation_id).toBe('conv-from-thread-created');
|
|
337
337
|
});
|
|
338
|
+
|
|
339
|
+
test('includes activeGuardianRequestCount in context payload', async () => {
|
|
340
|
+
const convId = 'conv-dispatch-5';
|
|
341
|
+
ensureConversation(convId);
|
|
342
|
+
|
|
343
|
+
const session = createCallSession({
|
|
344
|
+
conversationId: convId,
|
|
345
|
+
provider: 'twilio',
|
|
346
|
+
fromNumber: '+15550001111',
|
|
347
|
+
toNumber: '+15550002222',
|
|
348
|
+
});
|
|
349
|
+
const pq = createPendingQuestion(session.id, 'First question');
|
|
350
|
+
|
|
351
|
+
await dispatchGuardianQuestion({
|
|
352
|
+
callSessionId: session.id,
|
|
353
|
+
conversationId: convId,
|
|
354
|
+
assistantId: 'self',
|
|
355
|
+
pendingQuestion: pq,
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
const signalParams = emitCalls[0] as Record<string, unknown>;
|
|
359
|
+
const payload = signalParams.contextPayload as Record<string, unknown>;
|
|
360
|
+
// The request was just created so there is 1 pending request for this session
|
|
361
|
+
expect(payload.activeGuardianRequestCount).toBe(1);
|
|
362
|
+
expect(payload.callSessionId).toBe(session.id);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
test('repeated guardian questions in the same call each create per-request delivery rows even when sharing a conversation', async () => {
|
|
366
|
+
const convId = 'conv-dispatch-reuse-1';
|
|
367
|
+
ensureConversation(convId);
|
|
368
|
+
|
|
369
|
+
// Both dispatches deliver to the same vellum conversation (simulating thread reuse)
|
|
370
|
+
const sharedConversationId = 'conv-shared-guardian';
|
|
371
|
+
|
|
372
|
+
const session = createCallSession({
|
|
373
|
+
conversationId: convId,
|
|
374
|
+
provider: 'twilio',
|
|
375
|
+
fromNumber: '+15550001111',
|
|
376
|
+
toNumber: '+15550002222',
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
// First dispatch
|
|
380
|
+
const pq1 = createPendingQuestion(session.id, 'What is the gate code?');
|
|
381
|
+
mockEmitResult = {
|
|
382
|
+
signalId: 'sig-reuse-1',
|
|
383
|
+
deduplicated: false,
|
|
384
|
+
dispatched: true,
|
|
385
|
+
reason: 'ok',
|
|
386
|
+
deliveryResults: [
|
|
387
|
+
{
|
|
388
|
+
channel: 'vellum',
|
|
389
|
+
destination: 'vellum',
|
|
390
|
+
status: 'sent',
|
|
391
|
+
conversationId: sharedConversationId,
|
|
392
|
+
},
|
|
393
|
+
],
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
await dispatchGuardianQuestion({
|
|
397
|
+
callSessionId: session.id,
|
|
398
|
+
conversationId: convId,
|
|
399
|
+
assistantId: 'self',
|
|
400
|
+
pendingQuestion: pq1,
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
// Second dispatch (same call session, same shared conversation)
|
|
404
|
+
emitCalls.length = 0;
|
|
405
|
+
const pq2 = createPendingQuestion(session.id, 'Should I let them in?');
|
|
406
|
+
mockEmitResult = {
|
|
407
|
+
signalId: 'sig-reuse-2',
|
|
408
|
+
deduplicated: false,
|
|
409
|
+
dispatched: true,
|
|
410
|
+
reason: 'ok',
|
|
411
|
+
deliveryResults: [
|
|
412
|
+
{
|
|
413
|
+
channel: 'vellum',
|
|
414
|
+
destination: 'vellum',
|
|
415
|
+
status: 'sent',
|
|
416
|
+
conversationId: sharedConversationId,
|
|
417
|
+
},
|
|
418
|
+
],
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
await dispatchGuardianQuestion({
|
|
422
|
+
callSessionId: session.id,
|
|
423
|
+
conversationId: convId,
|
|
424
|
+
assistantId: 'self',
|
|
425
|
+
pendingQuestion: pq2,
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
// Both dispatches should have created separate action requests
|
|
429
|
+
const db = getDb();
|
|
430
|
+
const raw = (db as unknown as { $client: import('bun:sqlite').Database }).$client;
|
|
431
|
+
const requests = raw.query(
|
|
432
|
+
'SELECT * FROM guardian_action_requests WHERE call_session_id = ? ORDER BY created_at ASC',
|
|
433
|
+
).all(session.id) as Array<{ id: string; question_text: string }>;
|
|
434
|
+
expect(requests).toHaveLength(2);
|
|
435
|
+
expect(requests[0].question_text).toBe('What is the gate code?');
|
|
436
|
+
expect(requests[1].question_text).toBe('Should I let them in?');
|
|
437
|
+
|
|
438
|
+
// Each request should have its own delivery row, both pointing to the shared conversation
|
|
439
|
+
for (const req of requests) {
|
|
440
|
+
const delivery = raw.query(
|
|
441
|
+
'SELECT * FROM guardian_action_deliveries WHERE request_id = ? AND destination_channel = ?',
|
|
442
|
+
).get(req.id, 'vellum') as { status: string; destination_conversation_id: string | null } | undefined;
|
|
443
|
+
expect(delivery).toBeDefined();
|
|
444
|
+
expect(delivery!.status).toBe('sent');
|
|
445
|
+
expect(delivery!.destination_conversation_id).toBe(sharedConversationId);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Total delivery rows should be 2 (one per request), not 1
|
|
449
|
+
const allDeliveries = raw.query(
|
|
450
|
+
'SELECT * FROM guardian_action_deliveries WHERE destination_conversation_id = ?',
|
|
451
|
+
).all(sharedConversationId) as Array<{ request_id: string }>;
|
|
452
|
+
expect(allDeliveries).toHaveLength(2);
|
|
453
|
+
|
|
454
|
+
// Second dispatch should report a higher activeGuardianRequestCount
|
|
455
|
+
const secondPayload = (emitCalls[0] as Record<string, unknown>).contextPayload as Record<string, unknown>;
|
|
456
|
+
expect(secondPayload.activeGuardianRequestCount).toBe(2);
|
|
457
|
+
});
|
|
338
458
|
});
|
|
@@ -727,6 +727,10 @@ const clientMessages: Record<ClientMessageType, ClientMessage> = {
|
|
|
727
727
|
type: 'heartbeat_checklist_write',
|
|
728
728
|
content: '- [ ] Check email\n- [ ] Review PRs',
|
|
729
729
|
},
|
|
730
|
+
voice_config_update: {
|
|
731
|
+
type: 'voice_config_update',
|
|
732
|
+
activationKey: 'fn',
|
|
733
|
+
},
|
|
730
734
|
};
|
|
731
735
|
|
|
732
736
|
// ---------------------------------------------------------------------------
|
|
@@ -1998,6 +2002,23 @@ const serverMessages: Record<ServerMessageType, ServerMessage> = {
|
|
|
1998
2002
|
type: 'heartbeat_checklist_write_response',
|
|
1999
2003
|
success: true,
|
|
2000
2004
|
},
|
|
2005
|
+
navigate_settings: {
|
|
2006
|
+
type: 'navigate_settings',
|
|
2007
|
+
tab: 'general',
|
|
2008
|
+
},
|
|
2009
|
+
client_settings_update: {
|
|
2010
|
+
type: 'client_settings_update',
|
|
2011
|
+
key: 'activationKey',
|
|
2012
|
+
value: 'fn',
|
|
2013
|
+
},
|
|
2014
|
+
identity_changed: {
|
|
2015
|
+
type: 'identity_changed',
|
|
2016
|
+
name: 'Vellum',
|
|
2017
|
+
role: 'assistant',
|
|
2018
|
+
personality: 'friendly',
|
|
2019
|
+
emoji: '',
|
|
2020
|
+
home: '',
|
|
2021
|
+
},
|
|
2001
2022
|
};
|
|
2002
2023
|
|
|
2003
2024
|
// ---------------------------------------------------------------------------
|
|
@@ -84,7 +84,7 @@ import {
|
|
|
84
84
|
createBinding,
|
|
85
85
|
findPendingAccessRequestForRequester,
|
|
86
86
|
} from '../memory/channel-guardian-store.js';
|
|
87
|
-
import { initializeDb, resetDb } from '../memory/db.js';
|
|
87
|
+
import { getDb, initializeDb, resetDb } from '../memory/db.js';
|
|
88
88
|
import { handleChannelInbound } from '../runtime/routes/channel-routes.js';
|
|
89
89
|
|
|
90
90
|
initializeDb();
|
|
@@ -101,7 +101,6 @@ afterAll(() => {
|
|
|
101
101
|
const TEST_BEARER_TOKEN = 'test-token';
|
|
102
102
|
|
|
103
103
|
function resetState(): void {
|
|
104
|
-
const { getDb } = require('../memory/db.js');
|
|
105
104
|
const db = getDb();
|
|
106
105
|
db.run('DELETE FROM channel_guardian_approval_requests');
|
|
107
106
|
db.run('DELETE FROM channel_guardian_bindings');
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* - Handles missing adapters gracefully
|
|
7
7
|
* - Falls back to copy-composer when decision copy is missing
|
|
8
8
|
* - Reports delivery results per channel
|
|
9
|
+
* - Emits notification_thread_created only when a new conversation is created
|
|
10
|
+
* - Does NOT emit notification_thread_created when reusing an existing thread
|
|
9
11
|
*/
|
|
10
12
|
|
|
11
13
|
import { describe, expect, mock, test } from 'bun:test';
|
|
@@ -36,6 +38,32 @@ mock.module('../notifications/deliveries-store.js', () => ({
|
|
|
36
38
|
updateDeliveryStatus: () => {},
|
|
37
39
|
}));
|
|
38
40
|
|
|
41
|
+
// Configurable mock for conversation-pairing.
|
|
42
|
+
// By default returns a "new conversation" result with a stable UUID.
|
|
43
|
+
// Set `nextPairingResult` to override the return value for a single call.
|
|
44
|
+
let nextPairingResult: import('../notifications/conversation-pairing.js').PairingResult | null = null;
|
|
45
|
+
let pairingCallCount = 0;
|
|
46
|
+
|
|
47
|
+
mock.module('../notifications/conversation-pairing.js', () => ({
|
|
48
|
+
pairDeliveryWithConversation: async () => {
|
|
49
|
+
if (nextPairingResult) {
|
|
50
|
+
const result = nextPairingResult;
|
|
51
|
+
nextPairingResult = null;
|
|
52
|
+
return result;
|
|
53
|
+
}
|
|
54
|
+
// Default: simulate creating a new conversation with a unique ID
|
|
55
|
+
const id = `mock-conv-${++pairingCallCount}`;
|
|
56
|
+
return {
|
|
57
|
+
conversationId: id,
|
|
58
|
+
messageId: `mock-msg-${pairingCallCount}`,
|
|
59
|
+
strategy: 'start_new_conversation' as const,
|
|
60
|
+
createdNewConversation: true,
|
|
61
|
+
threadDecisionFallbackUsed: false,
|
|
62
|
+
};
|
|
63
|
+
},
|
|
64
|
+
}));
|
|
65
|
+
|
|
66
|
+
import type { ThreadCreatedInfo } from '../notifications/broadcaster.js';
|
|
39
67
|
import { NotificationBroadcaster } from '../notifications/broadcaster.js';
|
|
40
68
|
import type { NotificationSignal } from '../notifications/signal.js';
|
|
41
69
|
import type {
|
|
@@ -167,10 +195,17 @@ describe('notification broadcaster', () => {
|
|
|
167
195
|
await broadcaster.broadcastDecision(signal, decision);
|
|
168
196
|
|
|
169
197
|
expect(vellumAdapter.sent).toHaveLength(1);
|
|
170
|
-
|
|
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
|
});
|