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