@operor/copilot 0.1.0

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.
@@ -0,0 +1,1007 @@
1
+ /**
2
+ * Unit tests for @operor/copilot
3
+ *
4
+ * Covers: InMemoryCopilotStore, UnansweredQueryTracker, QueryClusterer,
5
+ * CopilotCommandHandler, DigestScheduler
6
+ */
7
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
8
+ import crypto from 'node:crypto';
9
+ import { InMemoryCopilotStore } from '../InMemoryCopilotStore.js';
10
+ import { UnansweredQueryTracker } from '../UnansweredQueryTracker.js';
11
+ import { QueryClusterer } from '../QueryClusterer.js';
12
+ import { CopilotCommandHandler } from '../CopilotCommandHandler.js';
13
+ import { DigestScheduler } from '../DigestScheduler.js';
14
+ import type {
15
+ UnansweredQuery,
16
+ EmbeddingService,
17
+ CopilotStore,
18
+ MessageProcessedEvent,
19
+ } from '../types.js';
20
+ import type { KnowledgeBaseRuntime } from '@operor/core';
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Helpers
24
+ // ---------------------------------------------------------------------------
25
+
26
+ function createMockEmbedder(dim = 4): EmbeddingService {
27
+ return {
28
+ dimensions: dim,
29
+ embed: async (text: string) => {
30
+ const hash = text.split('').reduce((acc, c) => acc + c.charCodeAt(0), 0);
31
+ return Array.from({ length: dim }, (_, i) => Math.sin(hash + i));
32
+ },
33
+ };
34
+ }
35
+
36
+ function createTestQuery(overrides?: Partial<UnansweredQuery>): UnansweredQuery {
37
+ return {
38
+ id: crypto.randomUUID(),
39
+ query: 'test query',
40
+ normalizedQuery: 'test query',
41
+ channel: 'whatsapp',
42
+ customerPhone: '+1234567890',
43
+ kbTopScore: 0.3,
44
+ kbIsFaqMatch: false,
45
+ kbResultCount: 1,
46
+ status: 'pending',
47
+ timesAsked: 1,
48
+ uniqueCustomers: ['+1234567890'],
49
+ createdAt: Date.now(),
50
+ updatedAt: Date.now(),
51
+ ...overrides,
52
+ };
53
+ }
54
+
55
+ function createMockKb(): KnowledgeBaseRuntime {
56
+ return {
57
+ ingestFaq: vi.fn().mockResolvedValue({ id: 'test-id' }),
58
+ retrieve: vi.fn().mockResolvedValue({ results: [], context: '', isFaqMatch: false }),
59
+ listDocuments: vi.fn().mockResolvedValue([]),
60
+ deleteDocument: vi.fn().mockResolvedValue(undefined),
61
+ getStats: vi.fn().mockResolvedValue({
62
+ documentCount: 0,
63
+ chunkCount: 0,
64
+ embeddingDimensions: 4,
65
+ dbSizeBytes: 0,
66
+ }),
67
+ };
68
+ }
69
+
70
+ function createEvent(overrides?: Partial<MessageProcessedEvent>): MessageProcessedEvent {
71
+ return {
72
+ query: 'What is your return policy?',
73
+ channel: 'whatsapp',
74
+ customerPhone: '+1234567890',
75
+ response: {
76
+ text: 'I am not sure about that.',
77
+ metadata: {
78
+ kbTopScore: 0.3,
79
+ kbIsFaqMatch: false,
80
+ kbResultCount: 1,
81
+ },
82
+ },
83
+ ...overrides,
84
+ };
85
+ }
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // 1. InMemoryCopilotStore
89
+ // ---------------------------------------------------------------------------
90
+
91
+ describe('InMemoryCopilotStore', () => {
92
+ let store: InMemoryCopilotStore;
93
+
94
+ beforeEach(() => {
95
+ store = new InMemoryCopilotStore();
96
+ });
97
+
98
+ it('addQuery + getQuery stores and retrieves a query', async () => {
99
+ const q = createTestQuery({ id: 'q1', query: 'How to return?' });
100
+ await store.addQuery(q);
101
+
102
+ const result = await store.getQuery('q1');
103
+ expect(result).not.toBeNull();
104
+ expect(result!.id).toBe('q1');
105
+ expect(result!.query).toBe('How to return?');
106
+ });
107
+
108
+ it('getQuery returns null for unknown id', async () => {
109
+ const result = await store.getQuery('nonexistent');
110
+ expect(result).toBeNull();
111
+ });
112
+
113
+ it('getPendingQueries returns only pending, sorted by timesAsked DESC', async () => {
114
+ await store.addQuery(createTestQuery({ id: 'q1', status: 'pending', timesAsked: 2, query: 'A' }));
115
+ await store.addQuery(createTestQuery({ id: 'q2', status: 'taught', timesAsked: 5, query: 'B' }));
116
+ await store.addQuery(createTestQuery({ id: 'q3', status: 'pending', timesAsked: 10, query: 'C' }));
117
+ await store.addQuery(createTestQuery({ id: 'q4', status: 'dismissed', timesAsked: 3, query: 'D' }));
118
+ await store.addQuery(createTestQuery({ id: 'q5', status: 'pending', timesAsked: 1, query: 'E' }));
119
+
120
+ const pending = await store.getPendingQueries();
121
+ expect(pending).toHaveLength(3);
122
+ expect(pending[0].id).toBe('q3'); // timesAsked=10
123
+ expect(pending[1].id).toBe('q1'); // timesAsked=2
124
+ expect(pending[2].id).toBe('q5'); // timesAsked=1
125
+ });
126
+
127
+ it('getPendingQueries respects limit', async () => {
128
+ await store.addQuery(createTestQuery({ id: 'q1', status: 'pending', timesAsked: 5 }));
129
+ await store.addQuery(createTestQuery({ id: 'q2', status: 'pending', timesAsked: 3 }));
130
+ await store.addQuery(createTestQuery({ id: 'q3', status: 'pending', timesAsked: 1 }));
131
+
132
+ const pending = await store.getPendingQueries(2);
133
+ expect(pending).toHaveLength(2);
134
+ expect(pending[0].id).toBe('q1');
135
+ expect(pending[1].id).toBe('q2');
136
+ });
137
+
138
+ it('updateQuery updates fields and updatedAt', async () => {
139
+ const now = Date.now();
140
+ const q = createTestQuery({ id: 'q1', timesAsked: 1, updatedAt: now - 10_000 });
141
+ await store.addQuery(q);
142
+
143
+ await store.updateQuery('q1', { timesAsked: 5, status: 'taught' });
144
+
145
+ const updated = await store.getQuery('q1');
146
+ expect(updated!.timesAsked).toBe(5);
147
+ expect(updated!.status).toBe('taught');
148
+ expect(updated!.updatedAt).toBeGreaterThanOrEqual(now);
149
+ });
150
+
151
+ it('updateQuery does nothing for unknown id', async () => {
152
+ // Should not throw
153
+ await store.updateQuery('nonexistent', { timesAsked: 99 });
154
+ });
155
+
156
+ it('findSimilarQuery matches by normalizedQuery', async () => {
157
+ await store.addQuery(createTestQuery({ id: 'q1', normalizedQuery: 'return policy' }));
158
+ await store.addQuery(createTestQuery({ id: 'q2', normalizedQuery: 'shipping cost' }));
159
+
160
+ const match = await store.findSimilarQuery('return policy');
161
+ expect(match).not.toBeNull();
162
+ expect(match!.id).toBe('q1');
163
+
164
+ const noMatch = await store.findSimilarQuery('payment methods');
165
+ expect(noMatch).toBeNull();
166
+ });
167
+
168
+ it('addCluster + getCluster stores and retrieves a cluster', async () => {
169
+ await store.addCluster({
170
+ id: 'c1',
171
+ representativeQuery: 'return policy',
172
+ queryCount: 1,
173
+ uniqueCustomers: [],
174
+ status: 'pending',
175
+ createdAt: Date.now(),
176
+ updatedAt: Date.now(),
177
+ });
178
+
179
+ const cluster = await store.getCluster('c1');
180
+ expect(cluster).not.toBeNull();
181
+ expect(cluster!.representativeQuery).toBe('return policy');
182
+ });
183
+
184
+ it('getCluster returns null for unknown id', async () => {
185
+ expect(await store.getCluster('nonexistent')).toBeNull();
186
+ });
187
+
188
+ it('getOpenClusters returns only pending clusters', async () => {
189
+ await store.addCluster({
190
+ id: 'c1', representativeQuery: 'A', queryCount: 1,
191
+ uniqueCustomers: [], status: 'pending', createdAt: Date.now(), updatedAt: Date.now(),
192
+ });
193
+ await store.addCluster({
194
+ id: 'c2', representativeQuery: 'B', queryCount: 2,
195
+ uniqueCustomers: [], status: 'taught', createdAt: Date.now(), updatedAt: Date.now(),
196
+ });
197
+ await store.addCluster({
198
+ id: 'c3', representativeQuery: 'C', queryCount: 3,
199
+ uniqueCustomers: [], status: 'pending', createdAt: Date.now(), updatedAt: Date.now(),
200
+ });
201
+
202
+ const open = await store.getOpenClusters();
203
+ expect(open).toHaveLength(2);
204
+ const ids = open.map(c => c.id).sort();
205
+ expect(ids).toEqual(['c1', 'c3']);
206
+ });
207
+
208
+ it('updateCluster updates fields and updatedAt', async () => {
209
+ const now = Date.now();
210
+ await store.addCluster({
211
+ id: 'c1', representativeQuery: 'A', queryCount: 1,
212
+ uniqueCustomers: [], status: 'pending', createdAt: now, updatedAt: now - 10_000,
213
+ });
214
+
215
+ await store.updateCluster('c1', { queryCount: 5, label: 'returns' });
216
+
217
+ const cluster = await store.getCluster('c1');
218
+ expect(cluster!.queryCount).toBe(5);
219
+ expect(cluster!.label).toBe('returns');
220
+ expect(cluster!.updatedAt).toBeGreaterThanOrEqual(now);
221
+ });
222
+
223
+ it('getQueriesByCluster returns queries with matching clusterId', async () => {
224
+ await store.addQuery(createTestQuery({ id: 'q1', clusterId: 'c1' }));
225
+ await store.addQuery(createTestQuery({ id: 'q2', clusterId: 'c2' }));
226
+ await store.addQuery(createTestQuery({ id: 'q3', clusterId: 'c1' }));
227
+
228
+ const result = await store.getQueriesByCluster('c1');
229
+ expect(result).toHaveLength(2);
230
+ const ids = result.map(q => q.id).sort();
231
+ expect(ids).toEqual(['q1', 'q3']);
232
+ });
233
+
234
+ it('getImpactMetrics counts correctly', async () => {
235
+ await store.addQuery(createTestQuery({
236
+ id: 'q1', status: 'pending', timesAsked: 3,
237
+ uniqueCustomers: ['+111', '+222'],
238
+ }));
239
+ await store.addQuery(createTestQuery({
240
+ id: 'q2', status: 'taught', timesAsked: 5,
241
+ uniqueCustomers: ['+222', '+333'],
242
+ }));
243
+ await store.addQuery(createTestQuery({
244
+ id: 'q3', status: 'dismissed', timesAsked: 2,
245
+ uniqueCustomers: ['+111'],
246
+ }));
247
+ await store.addQuery(createTestQuery({
248
+ id: 'q4', status: 'pending', timesAsked: 7,
249
+ uniqueCustomers: ['+444'],
250
+ }));
251
+
252
+ const metrics = await store.getImpactMetrics();
253
+ expect(metrics.pendingCount).toBe(2);
254
+ expect(metrics.taughtCount).toBe(1);
255
+ expect(metrics.dismissedCount).toBe(1);
256
+ expect(metrics.totalCustomersAffected).toBe(4); // +111, +222, +333, +444
257
+ expect(metrics.totalTimesAsked).toBe(17); // 3+5+2+7
258
+ expect(metrics.topPendingQueries).toHaveLength(2);
259
+ // Sorted by timesAsked DESC
260
+ expect(metrics.topPendingQueries[0].id).toBe('q4');
261
+ expect(metrics.topPendingQueries[1].id).toBe('q1');
262
+ });
263
+
264
+ it('getImpactMetrics respects topN limit', async () => {
265
+ for (let i = 0; i < 5; i++) {
266
+ await store.addQuery(createTestQuery({
267
+ id: `q${i}`, status: 'pending', timesAsked: i + 1,
268
+ }));
269
+ }
270
+
271
+ const metrics = await store.getImpactMetrics(2);
272
+ expect(metrics.topPendingQueries).toHaveLength(2);
273
+ expect(metrics.topPendingQueries[0].timesAsked).toBe(5);
274
+ expect(metrics.topPendingQueries[1].timesAsked).toBe(4);
275
+ });
276
+
277
+ it('getLastDigestTime / setLastDigestTime', async () => {
278
+ expect(await store.getLastDigestTime()).toBe(0);
279
+
280
+ const now = Date.now();
281
+ await store.setLastDigestTime(now);
282
+ expect(await store.getLastDigestTime()).toBe(now);
283
+
284
+ await store.setLastDigestTime(now + 1000);
285
+ expect(await store.getLastDigestTime()).toBe(now + 1000);
286
+ });
287
+ });
288
+
289
+ // ---------------------------------------------------------------------------
290
+ // 2. UnansweredQueryTracker
291
+ // ---------------------------------------------------------------------------
292
+
293
+ describe('UnansweredQueryTracker', () => {
294
+ let store: InMemoryCopilotStore;
295
+ let embedder: EmbeddingService;
296
+ let tracker: UnansweredQueryTracker;
297
+
298
+ beforeEach(() => {
299
+ store = new InMemoryCopilotStore();
300
+ embedder = createMockEmbedder();
301
+ });
302
+
303
+ it('tracks queries below threshold', async () => {
304
+ tracker = new UnansweredQueryTracker(
305
+ store,
306
+ { enabled: true, trackingThreshold: 0.70 },
307
+ embedder,
308
+ );
309
+
310
+ await tracker.maybeTrack(createEvent({
311
+ query: 'What is your return policy?',
312
+ response: {
313
+ text: 'Not sure.',
314
+ metadata: { kbTopScore: 0.4, kbIsFaqMatch: false, kbResultCount: 1 },
315
+ },
316
+ }));
317
+
318
+ const pending = await store.getPendingQueries();
319
+ expect(pending).toHaveLength(1);
320
+ expect(pending[0].query).toBe('What is your return policy?');
321
+ expect(pending[0].status).toBe('pending');
322
+ });
323
+
324
+ it('skips queries above threshold', async () => {
325
+ tracker = new UnansweredQueryTracker(
326
+ store,
327
+ { enabled: true, trackingThreshold: 0.70 },
328
+ embedder,
329
+ );
330
+
331
+ await tracker.maybeTrack(createEvent({
332
+ response: {
333
+ text: 'Here is our return policy...',
334
+ metadata: { kbTopScore: 0.85, kbIsFaqMatch: false, kbResultCount: 2 },
335
+ },
336
+ }));
337
+
338
+ const pending = await store.getPendingQueries();
339
+ expect(pending).toHaveLength(0);
340
+ });
341
+
342
+ it('skips when kbIsFaqMatch is true and score is above threshold', async () => {
343
+ tracker = new UnansweredQueryTracker(
344
+ store,
345
+ { enabled: true, trackingThreshold: 0.70 },
346
+ embedder,
347
+ );
348
+
349
+ await tracker.maybeTrack(createEvent({
350
+ response: {
351
+ text: 'FAQ answer.',
352
+ metadata: { kbTopScore: 0.90, kbIsFaqMatch: true, kbResultCount: 1 },
353
+ },
354
+ }));
355
+
356
+ const pending = await store.getPendingQueries();
357
+ expect(pending).toHaveLength(0);
358
+ });
359
+
360
+ it('tracks when kbResultCount is 0 even if kbTopScore defaults to 0', async () => {
361
+ tracker = new UnansweredQueryTracker(
362
+ store,
363
+ { enabled: true, trackingThreshold: 0.70 },
364
+ embedder,
365
+ );
366
+
367
+ await tracker.maybeTrack(createEvent({
368
+ response: {
369
+ text: 'No results.',
370
+ metadata: { kbTopScore: 0, kbIsFaqMatch: false, kbResultCount: 0 },
371
+ },
372
+ }));
373
+
374
+ const pending = await store.getPendingQueries();
375
+ expect(pending).toHaveLength(1);
376
+ });
377
+
378
+ it('increments timesAsked for duplicate normalized queries', async () => {
379
+ tracker = new UnansweredQueryTracker(
380
+ store,
381
+ { enabled: true, trackingThreshold: 0.70 },
382
+ embedder,
383
+ );
384
+
385
+ const event = createEvent({ query: 'return policy' });
386
+
387
+ await tracker.maybeTrack(event);
388
+ await tracker.maybeTrack(event);
389
+ await tracker.maybeTrack(event);
390
+
391
+ const pending = await store.getPendingQueries();
392
+ expect(pending).toHaveLength(1);
393
+ expect(pending[0].timesAsked).toBe(3);
394
+ });
395
+
396
+ it('adds new customerPhone to uniqueCustomers on duplicate', async () => {
397
+ tracker = new UnansweredQueryTracker(
398
+ store,
399
+ { enabled: true, trackingThreshold: 0.70 },
400
+ embedder,
401
+ );
402
+
403
+ await tracker.maybeTrack(createEvent({
404
+ query: 'return policy',
405
+ customerPhone: '+111',
406
+ }));
407
+ await tracker.maybeTrack(createEvent({
408
+ query: 'return policy',
409
+ customerPhone: '+222',
410
+ }));
411
+ // Same phone again -- should not duplicate
412
+ await tracker.maybeTrack(createEvent({
413
+ query: 'return policy',
414
+ customerPhone: '+111',
415
+ }));
416
+
417
+ const pending = await store.getPendingQueries();
418
+ expect(pending).toHaveLength(1);
419
+ expect(pending[0].uniqueCustomers).toHaveLength(2);
420
+ expect(pending[0].uniqueCustomers).toContain('+111');
421
+ expect(pending[0].uniqueCustomers).toContain('+222');
422
+ });
423
+
424
+ it('does not track when disabled', async () => {
425
+ tracker = new UnansweredQueryTracker(
426
+ store,
427
+ { enabled: false, trackingThreshold: 0.70 },
428
+ embedder,
429
+ );
430
+
431
+ await tracker.maybeTrack(createEvent());
432
+
433
+ const pending = await store.getPendingQueries();
434
+ expect(pending).toHaveLength(0);
435
+ });
436
+
437
+ it('catches errors silently (non-blocking)', async () => {
438
+ const brokenStore: CopilotStore = {
439
+ ...store,
440
+ findSimilarQuery: () => { throw new Error('DB crash'); },
441
+ addQuery: () => { throw new Error('DB crash'); },
442
+ } as any;
443
+
444
+ tracker = new UnansweredQueryTracker(
445
+ brokenStore,
446
+ { enabled: true, trackingThreshold: 0.70 },
447
+ embedder,
448
+ );
449
+
450
+ // Should not throw
451
+ await expect(tracker.maybeTrack(createEvent())).resolves.toBeUndefined();
452
+ });
453
+
454
+ it('calls clusterer.assignCluster for new queries', async () => {
455
+ const mockClusterer = {
456
+ assignCluster: vi.fn().mockResolvedValue('cluster-1'),
457
+ } as any;
458
+
459
+ tracker = new UnansweredQueryTracker(
460
+ store,
461
+ { enabled: true, trackingThreshold: 0.70 },
462
+ embedder,
463
+ mockClusterer,
464
+ );
465
+
466
+ await tracker.maybeTrack(createEvent({ query: 'new question' }));
467
+
468
+ expect(mockClusterer.assignCluster).toHaveBeenCalledTimes(1);
469
+ const [queryId, normalizedText, embedding] = mockClusterer.assignCluster.mock.calls[0];
470
+ expect(typeof queryId).toBe('string');
471
+ expect(normalizedText).toBe('new question');
472
+ expect(embedding).toHaveLength(4); // mock embedder dim
473
+ });
474
+
475
+ it('does not call clusterer for duplicate queries', async () => {
476
+ const mockClusterer = {
477
+ assignCluster: vi.fn().mockResolvedValue('cluster-1'),
478
+ } as any;
479
+
480
+ tracker = new UnansweredQueryTracker(
481
+ store,
482
+ { enabled: true, trackingThreshold: 0.70 },
483
+ embedder,
484
+ mockClusterer,
485
+ );
486
+
487
+ await tracker.maybeTrack(createEvent({ query: 'same question' }));
488
+ await tracker.maybeTrack(createEvent({ query: 'same question' }));
489
+
490
+ // assignCluster only called once (for the new query, not the duplicate)
491
+ expect(mockClusterer.assignCluster).toHaveBeenCalledTimes(1);
492
+ });
493
+
494
+ it('normalizeQuery lowercases, trims, and collapses whitespace', () => {
495
+ tracker = new UnansweredQueryTracker(
496
+ store,
497
+ { enabled: true, trackingThreshold: 0.70 },
498
+ embedder,
499
+ );
500
+
501
+ expect(tracker.normalizeQuery(' HELLO WORLD ')).toBe('hello world');
502
+ expect(tracker.normalizeQuery('Return\t\nPolicy')).toBe('return policy');
503
+ });
504
+ });
505
+
506
+ // ---------------------------------------------------------------------------
507
+ // 3. QueryClusterer
508
+ // ---------------------------------------------------------------------------
509
+
510
+ describe('QueryClusterer', () => {
511
+ let store: InMemoryCopilotStore;
512
+ let embedder: EmbeddingService;
513
+ let clusterer: QueryClusterer;
514
+
515
+ beforeEach(() => {
516
+ store = new InMemoryCopilotStore();
517
+ embedder = createMockEmbedder();
518
+ });
519
+
520
+ it('creates new cluster for first query', async () => {
521
+ clusterer = new QueryClusterer(store, embedder, { clusterThreshold: 0.87 });
522
+
523
+ const embedding = await embedder.embed('return policy');
524
+ const q = createTestQuery({ id: 'q1' });
525
+ await store.addQuery(q);
526
+
527
+ const clusterId = await clusterer.assignCluster('q1', 'return policy', embedding);
528
+
529
+ expect(typeof clusterId).toBe('string');
530
+ const cluster = await store.getCluster(clusterId);
531
+ expect(cluster).not.toBeNull();
532
+ expect(cluster!.representativeQuery).toBe('return policy');
533
+ expect(cluster!.queryCount).toBe(1);
534
+ expect(cluster!.status).toBe('pending');
535
+
536
+ // Query should be updated with clusterId
537
+ const updatedQ = await store.getQuery('q1');
538
+ expect(updatedQ!.clusterId).toBe(clusterId);
539
+ });
540
+
541
+ it('assigns query to existing cluster when similarity >= threshold', async () => {
542
+ // Use a very low threshold so identical embeddings always match
543
+ clusterer = new QueryClusterer(store, embedder, { clusterThreshold: 0.5 });
544
+
545
+ const embedding = await embedder.embed('return policy');
546
+ const q1 = createTestQuery({ id: 'q1' });
547
+ await store.addQuery(q1);
548
+ const clusterId1 = await clusterer.assignCluster('q1', 'return policy', embedding);
549
+
550
+ // Same text -> same embedding -> cosine similarity = 1.0 -> should match
551
+ const q2 = createTestQuery({ id: 'q2' });
552
+ await store.addQuery(q2);
553
+ const clusterId2 = await clusterer.assignCluster('q2', 'return policy', embedding);
554
+
555
+ expect(clusterId2).toBe(clusterId1);
556
+
557
+ const cluster = await store.getCluster(clusterId1);
558
+ expect(cluster!.queryCount).toBe(2);
559
+ });
560
+
561
+ it('creates new cluster when similarity < threshold', async () => {
562
+ clusterer = new QueryClusterer(store, embedder, { clusterThreshold: 0.99 });
563
+
564
+ const embedding1 = await embedder.embed('return policy');
565
+ const q1 = createTestQuery({ id: 'q1' });
566
+ await store.addQuery(q1);
567
+ const clusterId1 = await clusterer.assignCluster('q1', 'return policy', embedding1);
568
+
569
+ // Very different text -> different embedding -> low similarity
570
+ const embedding2 = await embedder.embed('how to bake a cake from scratch');
571
+ const q2 = createTestQuery({ id: 'q2' });
572
+ await store.addQuery(q2);
573
+ const clusterId2 = await clusterer.assignCluster('q2', 'how to bake a cake from scratch', embedding2);
574
+
575
+ expect(clusterId2).not.toBe(clusterId1);
576
+
577
+ const open = await store.getOpenClusters();
578
+ expect(open).toHaveLength(2);
579
+ });
580
+
581
+ it('updates centroid with running average', () => {
582
+ clusterer = new QueryClusterer(store, embedder);
583
+
584
+ const current = [1, 0, 0, 0];
585
+ const newVec = [0, 1, 0, 0];
586
+ // count=2 means this is the 2nd vector being added
587
+ const result = clusterer.updateCentroid(current, newVec, 2);
588
+
589
+ // (1*(2-1) + 0) / 2 = 0.5, (0*(2-1) + 1) / 2 = 0.5, 0, 0
590
+ expect(result[0]).toBeCloseTo(0.5);
591
+ expect(result[1]).toBeCloseTo(0.5);
592
+ expect(result[2]).toBeCloseTo(0);
593
+ expect(result[3]).toBeCloseTo(0);
594
+ });
595
+
596
+ it('cosineSimilarity returns correct values', () => {
597
+ clusterer = new QueryClusterer(store, embedder);
598
+
599
+ // Identical vectors -> similarity = 1
600
+ expect(clusterer.cosineSimilarity([1, 0, 0], [1, 0, 0])).toBeCloseTo(1);
601
+
602
+ // Orthogonal vectors -> similarity = 0
603
+ expect(clusterer.cosineSimilarity([1, 0, 0], [0, 1, 0])).toBeCloseTo(0);
604
+
605
+ // Opposite vectors -> similarity = -1
606
+ expect(clusterer.cosineSimilarity([1, 0, 0], [-1, 0, 0])).toBeCloseTo(-1);
607
+
608
+ // Parallel but different magnitude -> similarity = 1
609
+ expect(clusterer.cosineSimilarity([2, 0, 0], [5, 0, 0])).toBeCloseTo(1);
610
+
611
+ // 45-degree angle
612
+ expect(clusterer.cosineSimilarity([1, 0], [1, 1])).toBeCloseTo(Math.SQRT1_2);
613
+ });
614
+ });
615
+
616
+ // ---------------------------------------------------------------------------
617
+ // 4. CopilotCommandHandler
618
+ // ---------------------------------------------------------------------------
619
+
620
+ describe('CopilotCommandHandler', () => {
621
+ let store: InMemoryCopilotStore;
622
+ let mockKb: KnowledgeBaseRuntime;
623
+ let handler: CopilotCommandHandler;
624
+ let replies: string[];
625
+ let reply: (text: string) => Promise<void>;
626
+ const adminPhone = '+admin';
627
+
628
+ beforeEach(() => {
629
+ store = new InMemoryCopilotStore();
630
+ mockKb = createMockKb();
631
+ handler = new CopilotCommandHandler(store, undefined, mockKb);
632
+ replies = [];
633
+ reply = async (text: string) => { replies.push(text); };
634
+ });
635
+
636
+ describe('/review (no args) and /review next', () => {
637
+ it('/review (no args) shows next pending query', async () => {
638
+ await store.addQuery(createTestQuery({
639
+ id: 'q1', query: 'How to return?', timesAsked: 3,
640
+ uniqueCustomers: ['+111', '+222', '+333'],
641
+ }));
642
+
643
+ await handler.handleCommand('/review', '', adminPhone, reply);
644
+
645
+ expect(replies).toHaveLength(1);
646
+ expect(replies[0]).toContain('How to return?');
647
+ expect(replies[0]).toContain('Times asked');
648
+ expect(replies[0]).toContain('3');
649
+ });
650
+
651
+ it('/review next shows next pending query', async () => {
652
+ await store.addQuery(createTestQuery({
653
+ id: 'q1', query: 'Shipping cost?', timesAsked: 2,
654
+ uniqueCustomers: ['+111', '+222'],
655
+ }));
656
+
657
+ await handler.handleCommand('/review', 'next', adminPhone, reply);
658
+
659
+ expect(replies).toHaveLength(1);
660
+ expect(replies[0]).toContain('Shipping cost?');
661
+ });
662
+
663
+ it('empty queue shows "No pending queries" message', async () => {
664
+ await handler.handleCommand('/review', '', adminPhone, reply);
665
+
666
+ expect(replies).toHaveLength(1);
667
+ expect(replies[0]).toContain('No pending queries');
668
+ });
669
+ });
670
+
671
+ describe('/review accept', () => {
672
+ it('teaches FAQ and marks as taught with suggested answer', async () => {
673
+ const q = createTestQuery({
674
+ id: 'q1',
675
+ query: 'How to return?',
676
+ suggestedAnswer: 'You can return within 30 days.',
677
+ });
678
+ await store.addQuery(q);
679
+
680
+ // Load the query first
681
+ await handler.handleCommand('/review', 'next', adminPhone, reply);
682
+ replies = [];
683
+
684
+ // Accept with the suggested answer (no custom answer)
685
+ await handler.handleCommand('/review', 'accept', adminPhone, reply);
686
+
687
+ expect(replies).toHaveLength(1);
688
+ expect(replies[0]).toContain('Taught');
689
+ expect(replies[0]).toContain('How to return?');
690
+ expect(replies[0]).toContain('You can return within 30 days.');
691
+
692
+ expect(mockKb.ingestFaq).toHaveBeenCalledWith('How to return?', 'You can return within 30 days.');
693
+
694
+ const updated = await store.getQuery('q1');
695
+ expect(updated!.status).toBe('taught');
696
+ expect(updated!.taughtAnswer).toBe('You can return within 30 days.');
697
+ });
698
+
699
+ it('uses custom answer when provided', async () => {
700
+ const q = createTestQuery({
701
+ id: 'q1',
702
+ query: 'How to return?',
703
+ suggestedAnswer: 'Suggested answer.',
704
+ });
705
+ await store.addQuery(q);
706
+
707
+ await handler.handleCommand('/review', 'next', adminPhone, reply);
708
+ replies = [];
709
+
710
+ await handler.handleCommand('/review', 'accept My custom answer here', adminPhone, reply);
711
+
712
+ expect(replies).toHaveLength(1);
713
+ expect(replies[0]).toContain('My custom answer here');
714
+
715
+ expect(mockKb.ingestFaq).toHaveBeenCalledWith('How to return?', 'My custom answer here');
716
+ });
717
+
718
+ it('shows error when no answer provided and no suggestion', async () => {
719
+ const q = createTestQuery({ id: 'q1', query: 'How to return?' });
720
+ // No suggestedAnswer
721
+ await store.addQuery(q);
722
+
723
+ await handler.handleCommand('/review', 'next', adminPhone, reply);
724
+ replies = [];
725
+
726
+ await handler.handleCommand('/review', 'accept', adminPhone, reply);
727
+
728
+ expect(replies).toHaveLength(1);
729
+ expect(replies[0]).toContain('No answer provided');
730
+ });
731
+ });
732
+
733
+ describe('/review edit', () => {
734
+ it('teaches with provided answer', async () => {
735
+ const q = createTestQuery({ id: 'q1', query: 'What are your hours?' });
736
+ await store.addQuery(q);
737
+
738
+ await handler.handleCommand('/review', 'next', adminPhone, reply);
739
+ replies = [];
740
+
741
+ await handler.handleCommand('/review', 'edit We are open 9-5 Mon-Fri', adminPhone, reply);
742
+
743
+ expect(replies).toHaveLength(1);
744
+ expect(replies[0]).toContain('Taught');
745
+ expect(replies[0]).toContain('We are open 9-5 Mon-Fri');
746
+
747
+ expect(mockKb.ingestFaq).toHaveBeenCalledWith('What are your hours?', 'We are open 9-5 Mon-Fri');
748
+
749
+ const updated = await store.getQuery('q1');
750
+ expect(updated!.status).toBe('taught');
751
+ });
752
+
753
+ it('shows error without answer', async () => {
754
+ const q = createTestQuery({ id: 'q1', query: 'What are your hours?' });
755
+ await store.addQuery(q);
756
+
757
+ await handler.handleCommand('/review', 'next', adminPhone, reply);
758
+ replies = [];
759
+
760
+ await handler.handleCommand('/review', 'edit', adminPhone, reply);
761
+
762
+ expect(replies).toHaveLength(1);
763
+ expect(replies[0]).toContain('provide an answer');
764
+ });
765
+ });
766
+
767
+ describe('/review skip', () => {
768
+ it('marks as dismissed', async () => {
769
+ const q = createTestQuery({ id: 'q1', query: 'Irrelevant question' });
770
+ await store.addQuery(q);
771
+
772
+ await handler.handleCommand('/review', 'next', adminPhone, reply);
773
+ replies = [];
774
+
775
+ await handler.handleCommand('/review', 'skip', adminPhone, reply);
776
+
777
+ expect(replies).toHaveLength(1);
778
+ expect(replies[0]).toContain('Skipped');
779
+ expect(replies[0]).toContain('Irrelevant question');
780
+
781
+ const updated = await store.getQuery('q1');
782
+ expect(updated!.status).toBe('dismissed');
783
+ });
784
+ });
785
+
786
+ describe('/review stats', () => {
787
+ it('shows impact metrics', async () => {
788
+ await store.addQuery(createTestQuery({
789
+ id: 'q1', status: 'pending', timesAsked: 5,
790
+ query: 'return policy', uniqueCustomers: ['+111', '+222'],
791
+ }));
792
+ await store.addQuery(createTestQuery({
793
+ id: 'q2', status: 'taught', timesAsked: 3,
794
+ query: 'shipping', uniqueCustomers: ['+333'],
795
+ }));
796
+
797
+ await handler.handleCommand('/review', 'stats', adminPhone, reply);
798
+
799
+ expect(replies).toHaveLength(1);
800
+ const text = replies[0];
801
+ expect(text).toContain('Pending');
802
+ expect(text).toContain('1');
803
+ expect(text).toContain('Taught');
804
+ expect(text).toContain('Customers affected');
805
+ expect(text).toContain('Total times asked');
806
+ });
807
+ });
808
+
809
+ describe('/review help', () => {
810
+ it('shows help text', async () => {
811
+ await handler.handleCommand('/review', 'help', adminPhone, reply);
812
+
813
+ expect(replies).toHaveLength(1);
814
+ const text = replies[0];
815
+ expect(text).toContain('Review Commands');
816
+ expect(text).toContain('/review accept');
817
+ expect(text).toContain('/review edit');
818
+ expect(text).toContain('/review skip');
819
+ expect(text).toContain('/review stats');
820
+ expect(text).toContain('/review help');
821
+ });
822
+ });
823
+
824
+ describe('no current query', () => {
825
+ it('shows "No query selected" for accept', async () => {
826
+ await handler.handleCommand('/review', 'accept some answer', adminPhone, reply);
827
+
828
+ expect(replies).toHaveLength(1);
829
+ expect(replies[0]).toContain('No query selected');
830
+ });
831
+
832
+ it('shows "No query selected" for edit', async () => {
833
+ await handler.handleCommand('/review', 'edit some answer', adminPhone, reply);
834
+
835
+ expect(replies).toHaveLength(1);
836
+ expect(replies[0]).toContain('No query selected');
837
+ });
838
+
839
+ it('shows "No query selected" for skip', async () => {
840
+ await handler.handleCommand('/review', 'skip', adminPhone, reply);
841
+
842
+ expect(replies).toHaveLength(1);
843
+ expect(replies[0]).toContain('No query selected');
844
+ });
845
+ });
846
+
847
+ it('handles unknown subcommand', async () => {
848
+ await handler.handleCommand('/review', 'foobar', adminPhone, reply);
849
+
850
+ expect(replies).toHaveLength(1);
851
+ expect(replies[0]).toContain('Unknown subcommand');
852
+ expect(replies[0]).toContain('foobar');
853
+ });
854
+
855
+ it('maintains separate sessions per admin', async () => {
856
+ const q1 = createTestQuery({ id: 'q1', query: 'Q1', timesAsked: 10 });
857
+ const q2 = createTestQuery({ id: 'q2', query: 'Q2', timesAsked: 5 });
858
+ await store.addQuery(q1);
859
+ await store.addQuery(q2);
860
+
861
+ const replies1: string[] = [];
862
+ const replies2: string[] = [];
863
+ const reply1 = async (t: string) => { replies1.push(t); };
864
+ const reply2 = async (t: string) => { replies2.push(t); };
865
+
866
+ // Admin1 loads next query (q1 has highest timesAsked)
867
+ await handler.handleCommand('/review', 'next', '+admin1', reply1);
868
+ expect(replies1[0]).toContain('Q1');
869
+
870
+ // Admin2 also loads the top query independently
871
+ await handler.handleCommand('/review', 'next', '+admin2', reply2);
872
+ expect(replies2[0]).toContain('Q1');
873
+
874
+ // Admin1 accepts -- should not affect admin2's session
875
+ await handler.handleCommand('/review', 'accept The answer is X', '+admin1', reply1);
876
+ expect(replies1[1]).toContain('Taught');
877
+
878
+ // Admin2 can still skip their own loaded query
879
+ await handler.handleCommand('/review', 'skip', '+admin2', reply2);
880
+ expect(replies2[1]).toContain('Skipped');
881
+ });
882
+ });
883
+
884
+ // ---------------------------------------------------------------------------
885
+ // 5. DigestScheduler
886
+ // ---------------------------------------------------------------------------
887
+
888
+ describe('DigestScheduler', () => {
889
+ let store: InMemoryCopilotStore;
890
+
891
+ beforeEach(() => {
892
+ store = new InMemoryCopilotStore();
893
+ });
894
+
895
+ it('buildDigest returns null when no pending queries', async () => {
896
+ const scheduler = new DigestScheduler(
897
+ store,
898
+ { digestIntervalMs: 86_400_000, digestMaxItems: 10 },
899
+ vi.fn(),
900
+ );
901
+
902
+ const digest = await scheduler.buildDigest();
903
+ expect(digest).toBeNull();
904
+ });
905
+
906
+ it('buildDigest returns null when all queries are taught/dismissed', async () => {
907
+ await store.addQuery(createTestQuery({ id: 'q1', status: 'taught' }));
908
+ await store.addQuery(createTestQuery({ id: 'q2', status: 'dismissed' }));
909
+
910
+ const scheduler = new DigestScheduler(
911
+ store,
912
+ { digestIntervalMs: 86_400_000, digestMaxItems: 10 },
913
+ vi.fn(),
914
+ );
915
+
916
+ const digest = await scheduler.buildDigest();
917
+ expect(digest).toBeNull();
918
+ });
919
+
920
+ it('buildDigest formats digest text correctly', async () => {
921
+ await store.addQuery(createTestQuery({
922
+ id: 'q1', status: 'pending', timesAsked: 5,
923
+ query: 'Return policy?', uniqueCustomers: ['+111', '+222'],
924
+ }));
925
+ await store.addQuery(createTestQuery({
926
+ id: 'q2', status: 'pending', timesAsked: 3,
927
+ query: 'Shipping cost?', uniqueCustomers: ['+333'],
928
+ }));
929
+
930
+ const scheduler = new DigestScheduler(
931
+ store,
932
+ { digestIntervalMs: 86_400_000, digestMaxItems: 10 },
933
+ vi.fn(),
934
+ );
935
+
936
+ const digest = await scheduler.buildDigest();
937
+ expect(digest).not.toBeNull();
938
+ expect(digest).toContain('Training Copilot Digest');
939
+ expect(digest).toContain('2'); // pendingCount
940
+ expect(digest).toContain('3'); // totalCustomersAffected
941
+ expect(digest).toContain('/review');
942
+ });
943
+
944
+ it('buildDigest includes top queries', async () => {
945
+ await store.addQuery(createTestQuery({
946
+ id: 'q1', status: 'pending', timesAsked: 10,
947
+ query: 'Most asked question?', uniqueCustomers: ['+111'],
948
+ }));
949
+ await store.addQuery(createTestQuery({
950
+ id: 'q2', status: 'pending', timesAsked: 3,
951
+ query: 'Less asked question?', uniqueCustomers: ['+222'],
952
+ }));
953
+
954
+ const scheduler = new DigestScheduler(
955
+ store,
956
+ { digestIntervalMs: 86_400_000, digestMaxItems: 10 },
957
+ vi.fn(),
958
+ );
959
+
960
+ const digest = await scheduler.buildDigest();
961
+ expect(digest).toContain('Most asked question?');
962
+ expect(digest).toContain('Less asked question?');
963
+ expect(digest).toContain('10'); // timesAsked for top query
964
+ });
965
+
966
+ it('buildDigest respects digestMaxItems', async () => {
967
+ for (let i = 0; i < 5; i++) {
968
+ await store.addQuery(createTestQuery({
969
+ id: `q${i}`,
970
+ status: 'pending',
971
+ timesAsked: (5 - i),
972
+ query: `Question ${i}?`,
973
+ uniqueCustomers: [`+${i}`],
974
+ }));
975
+ }
976
+
977
+ const scheduler = new DigestScheduler(
978
+ store,
979
+ { digestIntervalMs: 86_400_000, digestMaxItems: 2 },
980
+ vi.fn(),
981
+ );
982
+
983
+ const digest = await scheduler.buildDigest();
984
+ expect(digest).not.toBeNull();
985
+ // Should include the top 2 (Question 0 with 5 asks, Question 1 with 4 asks)
986
+ expect(digest).toContain('Question 0?');
987
+ expect(digest).toContain('Question 1?');
988
+ // Should NOT include Question 4 (lowest timesAsked)
989
+ expect(digest).not.toContain('Question 4?');
990
+ });
991
+
992
+ it('start/stop manages the interval timer', () => {
993
+ const sendMessage = vi.fn();
994
+ const scheduler = new DigestScheduler(
995
+ store,
996
+ { digestIntervalMs: 86_400_000, digestMaxItems: 10 },
997
+ sendMessage,
998
+ );
999
+
1000
+ // start creates a timer, stop clears it -- no assertions needed beyond
1001
+ // verifying it does not throw
1002
+ scheduler.start(['+admin1']);
1003
+ scheduler.stop();
1004
+ // Double stop should be safe
1005
+ scheduler.stop();
1006
+ });
1007
+ });