@mmnto/mcp 1.13.0 → 1.14.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.
@@ -3,13 +3,28 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
3
3
  // Mocks — must be declared before imports that reference them
4
4
  // ---------------------------------------------------------------------------
5
5
  let capturedHandler;
6
- /** Tracks the mock store behaviour for each test. */
6
+ /**
7
+ * Tracks the mock store behaviour for each test. `absoluteFilePath` is
8
+ * optional — when omitted the mock store fills it in from the fake
9
+ * `projectRoot` (mirroring real `LanceStore` which constructs it via
10
+ * `path.join(sourceContext.absolutePathRoot, filePath)`).
11
+ */
7
12
  let mockSearchResults = [];
8
13
  let mockHealthCheckResult = { healthy: true };
9
14
  let mockHealthCheckThrows = false;
10
15
  let mockSearchThrows = false;
11
16
  let mockSearchThrowsOnce = false;
12
17
  let mockReconnectCalled = false;
18
+ /**
19
+ * mmnto/totem#1295 CR minor: number of upcoming `getContext()` calls
20
+ * that should throw before getContext starts returning normally.
21
+ * Decremented on each throw. The test for the one-shot flag fix sets
22
+ * this to a high enough number to cover every getContext call within
23
+ * a single handle() invocation, then expects subsequent calls to work.
24
+ */
25
+ let mockGetContextFailuresRemaining = 0;
26
+ let mockLinkedStores = new Map();
27
+ let mockLinkedStoreInitErrors = new Map();
13
28
  vi.mock('@modelcontextprotocol/sdk/server/mcp.js', () => ({
14
29
  McpServer: class {
15
30
  },
@@ -20,35 +35,67 @@ vi.mock('@mmnto/totem', () => ({
20
35
  },
21
36
  }));
22
37
  vi.mock('../context.js', () => ({
23
- getContext: vi.fn(async () => ({
24
- projectRoot: '/fake/project',
25
- config: {
26
- totemDir: '.totem',
27
- lanceDir: '.totem/.lance',
28
- contextWarningThreshold: 50_000,
29
- partitions: { core: ['packages/core/'] },
30
- },
31
- store: {
32
- search: vi.fn(async () => {
33
- if (mockSearchThrows) {
34
- throw new Error('LanceDB search failed');
35
- }
36
- if (mockSearchThrowsOnce) {
37
- mockSearchThrowsOnce = false;
38
- throw new Error('Stale handle error');
39
- }
40
- return mockSearchResults;
41
- }),
42
- healthCheck: vi.fn(async () => {
43
- if (mockHealthCheckThrows) {
44
- throw new Error('Health check exploded');
45
- }
46
- return mockHealthCheckResult;
47
- }),
48
- },
49
- })),
38
+ getContext: vi.fn(async () => {
39
+ if (mockGetContextFailuresRemaining > 0) {
40
+ mockGetContextFailuresRemaining -= 1;
41
+ throw new Error('Transient init failure');
42
+ }
43
+ return {
44
+ projectRoot: '/fake/project',
45
+ config: {
46
+ totemDir: '.totem',
47
+ lanceDir: '.totem/.lance',
48
+ contextWarningThreshold: 50_000,
49
+ partitions: { core: ['packages/core/'] },
50
+ },
51
+ store: {
52
+ search: vi.fn(async () => {
53
+ if (mockSearchThrows) {
54
+ throw new Error('LanceDB search failed');
55
+ }
56
+ if (mockSearchThrowsOnce) {
57
+ mockSearchThrowsOnce = false;
58
+ throw new Error('Stale handle error');
59
+ }
60
+ // Fill in absoluteFilePath from the fake projectRoot when the
61
+ // test didn't set one explicitly — mirrors real LanceStore.
62
+ return mockSearchResults.map((r) => ({
63
+ ...r,
64
+ absoluteFilePath: r.absoluteFilePath ?? `/fake/project/${r.filePath}`,
65
+ }));
66
+ }),
67
+ reconnect: vi.fn(async () => { }),
68
+ healthCheck: vi.fn(async () => {
69
+ if (mockHealthCheckThrows) {
70
+ throw new Error('Health check exploded');
71
+ }
72
+ return mockHealthCheckResult;
73
+ }),
74
+ },
75
+ // mmnto/totem#1294 Phase 2: linked store fields. Tests that want
76
+ // to exercise the federation path assign to `mockLinkedStores`
77
+ // before calling the handler; most tests leave it as an empty Map.
78
+ linkedStores: mockLinkedStores,
79
+ linkedStoreInitErrors: mockLinkedStoreInitErrors,
80
+ };
81
+ }),
50
82
  reconnectStore: vi.fn(async () => {
51
83
  mockReconnectCalled = true;
84
+ // mmnto/totem#1295 CR minor: mirror the real reconnectStore — iterate
85
+ // linked stores and call each one's reconnect spy so tests can assert
86
+ // per-store reconnect actually fired.
87
+ for (const linkedStore of mockLinkedStores.values()) {
88
+ try {
89
+ await linkedStore.reconnect();
90
+ }
91
+ catch (err) {
92
+ // Best-effort, mirror the real reconnectStore behavior.
93
+ // Suppression is intentional — discard the error explicitly so
94
+ // the bare-catch lint rule doesn't fire and unexpected mock
95
+ // breakage stays grep-able.
96
+ void err;
97
+ }
98
+ }
52
99
  }),
53
100
  }));
54
101
  vi.mock('../xml-format.js', () => ({
@@ -92,6 +139,9 @@ describe('search_knowledge', () => {
92
139
  mockSearchThrows = false;
93
140
  mockSearchThrowsOnce = false;
94
141
  mockReconnectCalled = false;
142
+ mockGetContextFailuresRemaining = 0;
143
+ mockLinkedStores = new Map();
144
+ mockLinkedStoreInitErrors = new Map();
95
145
  // Reset modules to clear the firstHealthCheckDone flag
96
146
  vi.resetModules();
97
147
  // Re-apply all mocks after module reset
@@ -105,35 +155,62 @@ describe('search_knowledge', () => {
105
155
  },
106
156
  }));
107
157
  vi.doMock('../context.js', () => ({
108
- getContext: vi.fn(async () => ({
109
- projectRoot: '/fake/project',
110
- config: {
111
- totemDir: '.totem',
112
- lanceDir: '.totem/.lance',
113
- contextWarningThreshold: 50_000,
114
- partitions: { core: ['packages/core/'] },
115
- },
116
- store: {
117
- search: vi.fn(async () => {
118
- if (mockSearchThrows) {
119
- throw new Error('LanceDB search failed');
120
- }
121
- if (mockSearchThrowsOnce) {
122
- mockSearchThrowsOnce = false;
123
- throw new Error('Stale handle error');
124
- }
125
- return mockSearchResults;
126
- }),
127
- healthCheck: vi.fn(async () => {
128
- if (mockHealthCheckThrows) {
129
- throw new Error('Health check exploded');
130
- }
131
- return mockHealthCheckResult;
132
- }),
133
- },
134
- })),
158
+ getContext: vi.fn(async () => {
159
+ if (mockGetContextFailuresRemaining > 0) {
160
+ mockGetContextFailuresRemaining -= 1;
161
+ throw new Error('Transient init failure');
162
+ }
163
+ return {
164
+ projectRoot: '/fake/project',
165
+ config: {
166
+ totemDir: '.totem',
167
+ lanceDir: '.totem/.lance',
168
+ contextWarningThreshold: 50_000,
169
+ partitions: { core: ['packages/core/'] },
170
+ },
171
+ store: {
172
+ search: vi.fn(async () => {
173
+ if (mockSearchThrows) {
174
+ throw new Error('LanceDB search failed');
175
+ }
176
+ if (mockSearchThrowsOnce) {
177
+ mockSearchThrowsOnce = false;
178
+ throw new Error('Stale handle error');
179
+ }
180
+ // Fill in absoluteFilePath from the fake projectRoot when
181
+ // the test didn't set one explicitly — mirrors LanceStore.
182
+ return mockSearchResults.map((r) => ({
183
+ ...r,
184
+ absoluteFilePath: r.absoluteFilePath ?? `/fake/project/${r.filePath}`,
185
+ }));
186
+ }),
187
+ reconnect: vi.fn(async () => { }),
188
+ healthCheck: vi.fn(async () => {
189
+ if (mockHealthCheckThrows) {
190
+ throw new Error('Health check exploded');
191
+ }
192
+ return mockHealthCheckResult;
193
+ }),
194
+ },
195
+ // mmnto/totem#1294 Phase 2: linked store fields
196
+ linkedStores: mockLinkedStores,
197
+ linkedStoreInitErrors: mockLinkedStoreInitErrors,
198
+ };
199
+ }),
135
200
  reconnectStore: vi.fn(async () => {
136
201
  mockReconnectCalled = true;
202
+ // mmnto/totem#1295 CR minor: mirror reconnectStore — iterate
203
+ // linked stores so tests can assert per-store reconnect fired.
204
+ for (const linkedStore of mockLinkedStores.values()) {
205
+ try {
206
+ await linkedStore.reconnect();
207
+ }
208
+ catch (err) {
209
+ // Best-effort, mirror the real reconnectStore behavior.
210
+ // Suppression is intentional — discard the error explicitly.
211
+ void err;
212
+ }
213
+ }
137
214
  }),
138
215
  }));
139
216
  vi.doMock('../xml-format.js', () => ({
@@ -214,6 +291,83 @@ describe('search_knowledge', () => {
214
291
  expect(result.isError).toBe(true);
215
292
  expect(result.content[0].text).toContain('DIMENSION MISMATCH');
216
293
  });
294
+ it('dimension mismatch warning persists across queries until the index is fixed (CR MAJOR)', async () => {
295
+ // mmnto/totem#1295 CR MAJOR: the round-7 fix moved
296
+ // `firstHealthCheckDone = true` to after the healthCheck() await, but
297
+ // still consumed the flag on UNHEALTHY results. That meant a persistent
298
+ // dimension mismatch showed the actionable "rm -rf .lancedb &&
299
+ // totem sync --full" guidance ONCE, then the next query skipped the
300
+ // gate and fell back to the cryptic LanceDB "vector dimension mismatch"
301
+ // error — exactly what this gate exists to prevent.
302
+ //
303
+ // The fix: don't consume the flag on dimension mismatch. The friendly
304
+ // diagnostic fires on EVERY query until the state is actually fixed.
305
+ mockHealthCheckResult = {
306
+ healthy: false,
307
+ dimensionMatch: false,
308
+ storedDimensions: 768,
309
+ expectedDimensions: 1536,
310
+ };
311
+ const first = (await handle({ query: 'anything' }));
312
+ expect(first.isError).toBe(true);
313
+ expect(first.content[0].text).toContain('DIMENSION MISMATCH');
314
+ expect(first.content[0].text).toContain('rm -rf .lancedb');
315
+ // Second query: WITHOUT the fix, this would return a regular search
316
+ // (the flag would have been consumed, runFirstQueryHealthCheck would
317
+ // return null, the outer catch would see a cryptic LanceDB error from
318
+ // performSearch OR worse, fall into the success path silently).
319
+ // WITH the fix, the dimension mismatch warning fires again and the
320
+ // search is blocked again.
321
+ const second = (await handle({ query: 'anything' }));
322
+ expect(second.isError).toBe(true);
323
+ expect(second.content[0].text).toContain('DIMENSION MISMATCH');
324
+ expect(second.content[0].text).toContain('rm -rf .lancedb');
325
+ // Simulate the user fixing the index: healthCheck now returns healthy.
326
+ // After the fix-then-retry, the warning should stop firing (normal
327
+ // one-shot semantics resume).
328
+ mockHealthCheckResult = { healthy: true };
329
+ mockSearchResults = [
330
+ {
331
+ label: 'Post-fix hit',
332
+ type: 'code',
333
+ filePath: 'src/foo.ts',
334
+ score: 0.8,
335
+ content: 'recovered',
336
+ },
337
+ ];
338
+ const third = (await handle({ query: 'anything' }));
339
+ expect(third.isError).toBeUndefined();
340
+ expect(third.content[0].text).not.toContain('DIMENSION MISMATCH');
341
+ expect(third.content[0].text).toContain('Post-fix hit');
342
+ });
343
+ it('non-fatal health warnings stay one-shot even after dimension-mismatch fix', async () => {
344
+ // Sanity check for the companion rule: non-fatal health issues (stale
345
+ // rows, missing partitions) still consume the flag after one warning.
346
+ // Only dimension mismatch is special-cased to persist.
347
+ mockHealthCheckResult = {
348
+ healthy: false,
349
+ dimensionMatch: true, // dim is fine — other issues
350
+ issues: ['Partition "core" has no rows'],
351
+ };
352
+ mockSearchResults = [
353
+ {
354
+ label: 'Hit',
355
+ type: 'code',
356
+ filePath: 'src/foo.ts',
357
+ score: 0.7,
358
+ content: 'content',
359
+ },
360
+ ];
361
+ const first = (await handle({ query: 'test' }));
362
+ expect(first.isError).toBeUndefined();
363
+ expect(first.content[0].text).toContain('Index health issues detected');
364
+ expect(first.content[0].text).toContain('Hit');
365
+ // Second query: the non-fatal warning should NOT repeat (one-shot)
366
+ const second = (await handle({ query: 'test' }));
367
+ expect(second.isError).toBeUndefined();
368
+ expect(second.content[0].text).not.toContain('Index health issues detected');
369
+ expect(second.content[0].text).toContain('Hit');
370
+ });
217
371
  it('does not block search when health check itself fails', async () => {
218
372
  mockHealthCheckThrows = true;
219
373
  mockSearchResults = [
@@ -284,7 +438,9 @@ describe('search_knowledge', () => {
284
438
  expect(text).toContain('### 1. First result (lesson)');
285
439
  expect(text).toContain('### 2. Second result (spec)');
286
440
  expect(text).toContain('### 3. Third result (code)');
287
- expect(text).toContain('**File:** lessons/first.md | **Score:** 0.990');
441
+ // mmnto/totem#1295 CR MAJOR: File line uses absolute path (mock fills
442
+ // absoluteFilePath from /fake/project + filePath when not set explicitly)
443
+ expect(text).toContain('**File:** /fake/project/lessons/first.md | **Score:** 0.990');
288
444
  expect(text).toContain('---');
289
445
  });
290
446
  it('passes type_filter and max_results to store.search', async () => {
@@ -313,5 +469,694 @@ describe('search_knowledge', () => {
313
469
  expect(result.isError).toBeUndefined();
314
470
  expect(result.content[0].text).toContain('No results found');
315
471
  });
472
+ // ─── Cross-Repo Context Mesh (mmnto/totem#1294 Phase 2) ───────
473
+ describe('federated search (linkedIndexes)', () => {
474
+ function makeLinkedStore(results) {
475
+ return {
476
+ search: vi.fn(async () => results),
477
+ // mmnto/totem#1295 CR minor: every linked-store mock now exposes
478
+ // a reconnect spy so tests can assert per-store reconnect actually
479
+ // fired during the reconnect path.
480
+ reconnect: vi.fn(async () => { }),
481
+ };
482
+ }
483
+ it('federates across primary + linked stores via fair RRF rank merge', async () => {
484
+ // mmnto/totem#1295 GCA CRITICAL: federation must merge by
485
+ // rank-within-store (RRF), not raw score, because LanceStore returns
486
+ // scores in incompatible scales (hybrid RRF ~0.03 vs vector-only
487
+ // ~0.85). This test sets up scores where the OLD raw-score sort
488
+ // would give the wrong answer:
489
+ //
490
+ // Primary: P1 (0.95), P2 (0.94) — both very high (vector scale)
491
+ // Linked: S1 (0.04), S2 (0.03) — both very low (RRF scale)
492
+ //
493
+ // Old raw-score sort: P1, P2, S1, S2 ← linked store starved
494
+ // New RRF sort: P1, S1, P2, S2 ← rank 0s interleave fairly
495
+ //
496
+ // The "rank 0 from each store should beat rank 1 from any store"
497
+ // property is the architectural guarantee RRF provides.
498
+ mockSearchResults = [
499
+ {
500
+ label: 'P1 primary top',
501
+ type: 'code',
502
+ filePath: 'src/foo.ts',
503
+ score: 0.95,
504
+ content: 'primary top content',
505
+ },
506
+ {
507
+ label: 'P2 primary second',
508
+ type: 'code',
509
+ filePath: 'src/bar.ts',
510
+ score: 0.94,
511
+ content: 'primary second content',
512
+ },
513
+ ];
514
+ mockLinkedStores.set('strategy', makeLinkedStore([
515
+ {
516
+ label: 'S1 strategy top',
517
+ type: 'spec',
518
+ filePath: 'adr/adr-001.md',
519
+ absoluteFilePath: '/abs/totem-strategy/adr/adr-001.md',
520
+ sourceRepo: 'strategy',
521
+ score: 0.04, // raw-score sort would put this BEHIND both P1 and P2
522
+ content: 'strategy top content',
523
+ },
524
+ {
525
+ label: 'S2 strategy second',
526
+ type: 'spec',
527
+ filePath: 'adr/adr-002.md',
528
+ absoluteFilePath: '/abs/totem-strategy/adr/adr-002.md',
529
+ sourceRepo: 'strategy',
530
+ score: 0.03,
531
+ content: 'strategy second content',
532
+ },
533
+ ]));
534
+ const result = (await handle({ query: 'architecture', max_results: 10 }));
535
+ expect(result.isError).toBeUndefined();
536
+ const text = result.content[0].text;
537
+ // All four results present
538
+ expect(text).toContain('P1 primary top');
539
+ expect(text).toContain('S1 strategy top');
540
+ expect(text).toContain('P2 primary second');
541
+ expect(text).toContain('S2 strategy second');
542
+ // RRF interleaving: S1 (rank 0 in its store) must appear BEFORE
543
+ // P2 (rank 1 in primary), even though P2 has 23x the raw score.
544
+ // This is the architectural fix — without RRF, S1 would be last.
545
+ const p1Idx = text.indexOf('P1 primary top');
546
+ const s1Idx = text.indexOf('S1 strategy top');
547
+ const p2Idx = text.indexOf('P2 primary second');
548
+ const s2Idx = text.indexOf('S2 strategy second');
549
+ // Stable sort within ties: bucket order is primary-first then linked,
550
+ // so among rank-0 results P1 comes before S1; among rank-1 results
551
+ // P2 comes before S2.
552
+ expect(p1Idx).toBeLessThan(s1Idx);
553
+ expect(s1Idx).toBeLessThan(p2Idx); // ← THE KEY ASSERTION
554
+ expect(p2Idx).toBeLessThan(s2Idx);
555
+ });
556
+ it('federation displays normalized RRF scores, not raw store scores', async () => {
557
+ // mmnto/totem#1295 GCA CRITICAL: the visible `score` field is
558
+ // overwritten with the RRF score during federation so the displayed
559
+ // order matches the displayed numbers. Otherwise users would see
560
+ // results sorted by an invisible secondary key, which is confusing.
561
+ mockSearchResults = [
562
+ {
563
+ label: 'P1',
564
+ type: 'code',
565
+ filePath: 'src/foo.ts',
566
+ score: 0.99, // raw vector-distance scale
567
+ content: 'primary',
568
+ },
569
+ ];
570
+ mockLinkedStores.set('strategy', makeLinkedStore([
571
+ {
572
+ label: 'S1',
573
+ type: 'spec',
574
+ filePath: 'adr/adr-001.md',
575
+ absoluteFilePath: '/abs/strategy/adr/adr-001.md',
576
+ sourceRepo: 'strategy',
577
+ score: 0.04, // raw RRF scale
578
+ content: 'strategy',
579
+ },
580
+ ]));
581
+ const result = (await handle({ query: 'test' }));
582
+ const text = result.content[0].text;
583
+ // The original raw scores (0.990 / 0.040) must NOT appear — they
584
+ // would mislead the user about cross-store comparability.
585
+ expect(text).not.toContain('0.990');
586
+ expect(text).not.toContain('0.040');
587
+ // The RRF score for rank 0 with k=60 is 1/61 ≈ 0.016
588
+ expect(text).toContain('0.016');
589
+ });
590
+ it('boundary matching a linked store name routes ONLY to that store', async () => {
591
+ mockSearchResults = [
592
+ {
593
+ label: 'Primary hit',
594
+ type: 'code',
595
+ filePath: 'src/foo.ts',
596
+ score: 0.99,
597
+ content: 'primary content',
598
+ },
599
+ ];
600
+ const strategyStore = makeLinkedStore([
601
+ {
602
+ label: 'Strategy ADR',
603
+ type: 'spec',
604
+ filePath: 'adr/adr-001.md',
605
+ absoluteFilePath: '/abs/totem-strategy/adr/adr-001.md',
606
+ sourceRepo: 'strategy',
607
+ score: 0.5,
608
+ content: 'adr content',
609
+ },
610
+ ]);
611
+ mockLinkedStores.set('strategy', strategyStore);
612
+ const result = (await handle({
613
+ query: 'architecture',
614
+ boundary: 'strategy',
615
+ }));
616
+ expect(result.isError).toBeUndefined();
617
+ expect(strategyStore.search).toHaveBeenCalled();
618
+ // Only strategy results should appear — primary is not queried
619
+ expect(result.content[0].text).toContain('Strategy ADR');
620
+ expect(result.content[0].text).not.toContain('Primary hit');
621
+ });
622
+ it('boundary matching a partition name still routes to primary (partitions win over links)', async () => {
623
+ mockSearchResults = [
624
+ {
625
+ label: 'Core file',
626
+ type: 'code',
627
+ filePath: 'packages/core/src/foo.ts',
628
+ score: 0.9,
629
+ content: 'core content',
630
+ },
631
+ ];
632
+ const linkedStore = makeLinkedStore([
633
+ {
634
+ label: 'Should not appear',
635
+ type: 'spec',
636
+ filePath: 'other.md',
637
+ absoluteFilePath: '/abs/other/other.md',
638
+ sourceRepo: 'core',
639
+ score: 0.99,
640
+ content: 'linked content',
641
+ },
642
+ ]);
643
+ // Collision: link name "core" matches a partition name
644
+ mockLinkedStores.set('core', linkedStore);
645
+ const result = (await handle({
646
+ query: 'anything',
647
+ boundary: 'core', // partition "core" wins
648
+ }));
649
+ expect(result.isError).toBeUndefined();
650
+ expect(result.content[0].text).toContain('Core file');
651
+ expect(result.content[0].text).not.toContain('Should not appear');
652
+ // Linked store was never queried
653
+ expect(linkedStore.search).not.toHaveBeenCalled();
654
+ });
655
+ it('unknown boundary falls back to raw prefix on primary only', async () => {
656
+ mockSearchResults = [];
657
+ const linkedStore = makeLinkedStore([
658
+ {
659
+ label: 'Should not appear',
660
+ type: 'spec',
661
+ filePath: 'other.md',
662
+ absoluteFilePath: '/abs/other/other.md',
663
+ sourceRepo: 'strategy',
664
+ score: 0.99,
665
+ content: 'linked content',
666
+ },
667
+ ]);
668
+ mockLinkedStores.set('strategy', linkedStore);
669
+ const result = (await handle({
670
+ query: 'test',
671
+ boundary: 'some/random/prefix/',
672
+ }));
673
+ expect(result.isError).toBeUndefined();
674
+ expect(linkedStore.search).not.toHaveBeenCalled();
675
+ });
676
+ it('linked store runtime failure surfaces per-query warning (non-blocking)', async () => {
677
+ // mmnto/totem#1295 GCA/CR fix: previously this path silently dropped
678
+ // linked failures with no user-visible signal (Tenet 4 violation).
679
+ // The new per-query runtime-warning architecture surfaces the failure
680
+ // inline on every query it occurred on, without mutating global state
681
+ // (so transient issues don't cause permanent session drift).
682
+ mockSearchResults = [
683
+ {
684
+ label: 'Primary hit',
685
+ type: 'code',
686
+ filePath: 'src/foo.ts',
687
+ score: 0.8,
688
+ content: 'primary content',
689
+ },
690
+ ];
691
+ // Use mockRejectedValue to avoid an inline `throw new Error` that
692
+ // trips the over-broad "error normalization" and "[Totem Error]
693
+ // prefix" lint rules on test fixtures (tracked for refinement in
694
+ // mmnto/totem#1286). The rejected-Promise shape is semantically
695
+ // equivalent for the federation-failure path under test.
696
+ const brokenLink = {
697
+ search: vi.fn().mockRejectedValue(new Error('Connection refused')),
698
+ reconnect: vi.fn().mockRejectedValue(new Error('Reconnect also broken')),
699
+ };
700
+ mockLinkedStores.set('broken', brokenLink);
701
+ const result = (await handle({ query: 'test' }));
702
+ // Federation is non-blocking — primary results still land
703
+ expect(result.isError).toBeUndefined();
704
+ expect(brokenLink.search).toHaveBeenCalled();
705
+ expect(result.content[0].text).toContain('Primary hit');
706
+ // Runtime failure is surfaced as a per-query system warning so the
707
+ // agent sees the drift in-context (Tenet 4: Fail Loud)
708
+ expect(result.content[0].text).toContain('[SYSTEM WARNING]');
709
+ expect(result.content[0].text).toContain('Federated search');
710
+ expect(result.content[0].text).toContain('broken');
711
+ expect(result.content[0].text).toContain('Connection refused');
712
+ });
713
+ it('linked store literally named "primary" does not collide with actual primary failure slot (CR MAJOR)', async () => {
714
+ // mmnto/totem#1295 CR MAJOR: `deriveLinkName` strips leading dots
715
+ // from the basename, so a linked repo at `.primary/` would derive
716
+ // to the link name `'primary'`. The earlier implementation stored
717
+ // primary store failures under `runtimeFailures.set('primary', ...)`,
718
+ // which would have either overwritten or been overwritten by the
719
+ // legitimate linked store named 'primary'.
720
+ //
721
+ // The fix splits primary into a dedicated `failures.primary` slot
722
+ // (string | null), keeping the linked-store map free of reserved
723
+ // keys. This test exercises the collision scenario:
724
+ //
725
+ // 1. The actual primary store throws (so failures.primary is set).
726
+ // 2. A linked store literally named 'primary' returns results
727
+ // successfully — its results must NOT be misreported as the
728
+ // primary store, AND the warning copy must distinguish them.
729
+ mockSearchThrows = true;
730
+ const linkedNamedPrimary = makeLinkedStore([
731
+ {
732
+ label: 'Linked-primary hit',
733
+ type: 'spec',
734
+ filePath: 'adr/adr-001.md',
735
+ absoluteFilePath: '/abs/.primary/adr/adr-001.md',
736
+ sourceRepo: 'primary',
737
+ score: 0.8,
738
+ content: 'linked content from a repo named primary',
739
+ },
740
+ ]);
741
+ mockLinkedStores.set('primary', linkedNamedPrimary);
742
+ const result = (await handle({ query: 'test' }));
743
+ // Federation succeeds with the linked-named-primary results
744
+ expect(result.isError).toBeUndefined();
745
+ expect(result.content[0].text).toContain('Linked-primary hit');
746
+ // The warning surfaces the actual primary store failure WITHOUT
747
+ // collision. With the bug, the linked store's success would have
748
+ // either overwritten the primary failure (no warning) or the
749
+ // primary failure would have overwritten the linked store's entry.
750
+ const text = result.content[0].text;
751
+ expect(text).toContain('[SYSTEM WARNING]');
752
+ // The warning must reference primary store failure
753
+ expect(text).toContain('primary store');
754
+ // The warning must NOT reference any linked store failure (the
755
+ // 'primary' linked store succeeded)
756
+ expect(text).not.toContain('1 linked index(es) failed');
757
+ // Linked store named 'primary' got its tag prefix
758
+ expect(text).toContain('[primary] Linked-primary hit');
759
+ });
760
+ it('entire federation down returns isError (CR MAJOR — do not mask outage as "no results")', async () => {
761
+ // mmnto/totem#1295 CR MAJOR catch: when primary AND every linked
762
+ // store fail, results.length === 0 but the previous code fell
763
+ // through to a success-shaped "No results found" body with the
764
+ // warning prepended. The agent reads that as "no relevant knowledge
765
+ // in the index" when actually the entire search plane is broken.
766
+ // Tenet 4 violation (silent degradation).
767
+ //
768
+ // The fix: detect the federated case where `failures.primary !== null`
769
+ // AND `failures.linked.size === linkedStores.size` AND results are
770
+ // empty, and return isError: true with the runtime warning as the
771
+ // error text so the agent sees the breakdown of what's down.
772
+ mockSearchThrows = true;
773
+ const brokenLinkA = {
774
+ search: vi.fn().mockRejectedValue(new Error('Linked A down')),
775
+ reconnect: vi.fn().mockRejectedValue(new Error('A reconnect failed')),
776
+ };
777
+ const brokenLinkB = {
778
+ search: vi.fn().mockRejectedValue(new Error('Linked B down')),
779
+ reconnect: vi.fn().mockRejectedValue(new Error('B reconnect failed')),
780
+ };
781
+ mockLinkedStores.set('strategy', brokenLinkA);
782
+ mockLinkedStores.set('playground', brokenLinkB);
783
+ const result = (await handle({ query: 'test' }));
784
+ // Must be isError — not a silent "no results found" body
785
+ expect(result.isError).toBe(true);
786
+ const text = result.content[0].text;
787
+ // The runtime warning (breakdown of what failed) is the error text
788
+ expect(text).toContain('[SYSTEM WARNING]');
789
+ expect(text).toContain('primary store');
790
+ expect(text).toContain('2 linked index(es) failed');
791
+ // Each broken store is named in the detail lines
792
+ expect(text).toContain('strategy');
793
+ expect(text).toContain('playground');
794
+ // Critically: the "No results found." body must NOT appear
795
+ expect(text).not.toContain('No results found');
796
+ });
797
+ it('partial-failure federation (some linked OK, some broken) still returns success-shape "no results"', async () => {
798
+ // mmnto/totem#1295 CR MAJOR follow-up: verify the all-failed check
799
+ // is narrow enough. When at least ONE store successfully returned
800
+ // zero results, the zero-ness is authoritative and the response
801
+ // should be a normal "No results found" body with the warning
802
+ // prepended — NOT isError. The agent can see the warning and
803
+ // decide whether to retry or accept the zero-result answer.
804
+ const brokenLink = {
805
+ search: vi.fn().mockRejectedValue(new Error('Linked down')),
806
+ reconnect: vi.fn().mockRejectedValue(new Error('Reconnect failed')),
807
+ };
808
+ const healthyLink = makeLinkedStore([]); // healthy, empty results
809
+ mockLinkedStores.set('strategy', brokenLink);
810
+ mockLinkedStores.set('playground', healthyLink);
811
+ mockSearchResults = []; // primary also empty but healthy
812
+ const result = (await handle({ query: 'test' }));
813
+ // Not isError — at least one store answered authoritatively
814
+ expect(result.isError).toBeUndefined();
815
+ const text = result.content[0].text;
816
+ // Runtime warning surfaces the partial failure
817
+ expect(text).toContain('[SYSTEM WARNING]');
818
+ expect(text).toContain('strategy');
819
+ // "No results found" body IS present because primary + healthy linked
820
+ // both returned zero
821
+ expect(text).toContain('No results found');
822
+ });
823
+ it('primary store failure does not block linked-store results (GCA HIGH)', async () => {
824
+ // mmnto/totem#1295 GCA HIGH catch: previously the primary store
825
+ // search bubbled out of `Promise.all` and killed the entire
826
+ // federation, even when linked stores were healthy. Now primary
827
+ // failures are caught inside `federatedSearch` and routed through
828
+ // the same per-query runtime-warning path as linked failures —
829
+ // populated under the reserved `'primary'` key.
830
+ mockSearchThrows = true;
831
+ mockLinkedStores.set('strategy', makeLinkedStore([
832
+ {
833
+ label: 'Linked still works',
834
+ type: 'spec',
835
+ filePath: 'adr/adr-001.md',
836
+ absoluteFilePath: '/abs/strategy/adr/adr-001.md',
837
+ sourceRepo: 'strategy',
838
+ score: 0.8,
839
+ content: 'strategy content',
840
+ },
841
+ ]));
842
+ const result = (await handle({ query: 'test' }));
843
+ // Federation is non-blocking — linked results still land
844
+ expect(result.isError).toBeUndefined();
845
+ expect(result.content[0].text).toContain('Linked still works');
846
+ // Primary failure is surfaced as a per-query runtime warning under
847
+ // the reserved 'primary' key
848
+ expect(result.content[0].text).toContain('[SYSTEM WARNING]');
849
+ expect(result.content[0].text).toContain('primary');
850
+ });
851
+ it('per-query runtime warnings do not mutate global init errors (transient failures stay transient)', async () => {
852
+ // mmnto/totem#1295 CR/GCA architectural fix: the original Phase 2
853
+ // implementation mutated linkedStoreInitErrors on runtime failure,
854
+ // which permanently degraded the session for transient issues like
855
+ // a file lock during a parallel `totem sync`. The fix: runtime
856
+ // failures populate a per-query Map that is discarded after the
857
+ // response is built. A subsequent successful query sees zero warning.
858
+ const flaky = {
859
+ search: vi
860
+ .fn()
861
+ .mockRejectedValueOnce(new Error('Transient lock'))
862
+ .mockResolvedValueOnce([
863
+ {
864
+ label: 'Recovered',
865
+ type: 'spec',
866
+ filePath: 'adr/adr-001.md',
867
+ absoluteFilePath: '/abs/flaky/adr/adr-001.md',
868
+ sourceRepo: 'flaky',
869
+ score: 0.8,
870
+ content: 'recovered content',
871
+ },
872
+ ]),
873
+ // First-query reconnect attempt fails (so the runtime warning fires);
874
+ // second query bypasses the catch path entirely.
875
+ reconnect: vi.fn().mockRejectedValue(new Error('Reconnect lock')),
876
+ };
877
+ mockLinkedStores.set('flaky', flaky);
878
+ mockSearchResults = [
879
+ {
880
+ label: 'Primary',
881
+ type: 'code',
882
+ filePath: 'src/foo.ts',
883
+ score: 0.7,
884
+ content: 'primary',
885
+ },
886
+ ];
887
+ // First query: runtime failure → warning present
888
+ const first = (await handle({ query: 'test' }));
889
+ expect(first.content[0].text).toContain('[SYSTEM WARNING]');
890
+ expect(first.content[0].text).toContain('flaky');
891
+ // Second query: linked store recovers → NO warning carried over
892
+ const second = (await handle({ query: 'test' }));
893
+ expect(second.content[0].text).not.toContain('[SYSTEM WARNING]');
894
+ expect(second.content[0].text).toContain('Recovered');
895
+ });
896
+ it('targeted Case 2 returns isError when the linked store fails completely (GCA HIGH)', async () => {
897
+ // mmnto/totem#1295 GCA HIGH: when the user explicitly names a
898
+ // boundary and the targeted linked store fails (initial AND
899
+ // reconnect+retry), the response must signal isError: true rather
900
+ // than falling through to a "no results found" body. The agent
901
+ // should not misinterpret a real outage as an absence of relevant
902
+ // knowledge. This is symmetric with Case 3 (boundary matches a
903
+ // failed-init linked store).
904
+ // The search throws on EVERY call (initial + post-reconnect retry).
905
+ // Reconnect succeeds so the retry actually fires — proves we go all
906
+ // the way through the retry path before returning isError.
907
+ const targeted = {
908
+ search: vi.fn().mockRejectedValue(new Error('Connection refused')),
909
+ reconnect: vi.fn(async () => { }),
910
+ };
911
+ mockLinkedStores.set('strategy', targeted);
912
+ // Primary results are deliberately set so we can prove they are
913
+ // NOT leaked into the response (a regression would show them).
914
+ mockSearchResults = [
915
+ {
916
+ label: 'Bogus primary hit',
917
+ type: 'code',
918
+ filePath: 'src/strategy.ts',
919
+ score: 0.9,
920
+ content: 'unrelated local',
921
+ },
922
+ ];
923
+ const result = (await handle({ query: 'test', boundary: 'strategy' }));
924
+ expect(result.isError).toBe(true);
925
+ // Both reconnect and the second search were attempted
926
+ expect(targeted.reconnect).toHaveBeenCalledOnce();
927
+ expect(targeted.search).toHaveBeenCalledTimes(2);
928
+ // Error message names the targeted boundary and includes both errors
929
+ const text = result.content[0].text;
930
+ expect(text).toContain('strategy');
931
+ expect(text).toContain('Connection refused');
932
+ // Critically: NO bogus primary results leaked into the response
933
+ expect(text).not.toContain('Bogus primary hit');
934
+ });
935
+ it('boundary matching a name-collision error keyed under the bare derived name routes via Case 3', async () => {
936
+ // mmnto/totem#1295 GCA HIGH catch: the collision detection in
937
+ // initContext used to key the error under a descriptive composite
938
+ // (e.g., `strategy (collision at .strategy2)`), so a user typing
939
+ // `boundary: 'strategy'` could not find the entry via
940
+ // `linkedStoreInitErrors.has('strategy')` and would fall through to
941
+ // raw-prefix search on the primary — exactly the silent drift this
942
+ // PR is supposed to prevent.
943
+ //
944
+ // The fix in context.ts now keys collisions under the BARE derived
945
+ // name. This test asserts the contract from the consumer side:
946
+ // a collision-style error message keyed under the bare name MUST
947
+ // route via Case 3 (explicit isError) rather than Case 4 (raw
948
+ // prefix). This is the integration guarantee the bare-name keying
949
+ // change unlocks.
950
+ mockLinkedStoreInitErrors.set('strategy', 'Another linked index already claims the name "strategy". ' +
951
+ 'Path "./strategy2" also derives the link name "strategy". ' +
952
+ 'Rename one of the linked directories or remove the duplicate from config.linkedIndexes.');
953
+ mockSearchResults = [
954
+ {
955
+ label: 'Bogus primary hit that happens to match "strategy"',
956
+ type: 'code',
957
+ filePath: 'src/strategy-pattern.ts',
958
+ score: 0.9,
959
+ content: 'some code',
960
+ },
961
+ ];
962
+ const result = (await handle({ query: 'test', boundary: 'strategy' }));
963
+ expect(result.isError).toBe(true);
964
+ // The collision-style message should be surfaced via Case 3 wrapping
965
+ expect(result.content[0].text).toContain('strategy');
966
+ expect(result.content[0].text).toContain('not available');
967
+ expect(result.content[0].text).toContain('already claims');
968
+ // Bogus primary hit MUST NOT appear (no raw-prefix fallback)
969
+ expect(result.content[0].text).not.toContain('Bogus primary hit');
970
+ });
971
+ it('boundary matching a failed-init linked store returns explicit error (no silent primary fallback)', async () => {
972
+ // Shield AI catch: if a linked store name is in linkedStoreInitErrors
973
+ // but not in linkedStores (e.g., init failed, or reconnect blew up),
974
+ // the previous implementation silently fell through to querying the
975
+ // primary store with the name as a raw path prefix — returning
976
+ // unrelated local hits. Tenet 4 violation (silent drift).
977
+ mockLinkedStoreInitErrors.set('strategy', 'Linked index is empty (0 rows).');
978
+ mockSearchResults = [
979
+ {
980
+ label: 'Bogus primary hit that happens to match "strategy"',
981
+ type: 'code',
982
+ filePath: 'src/strategy-pattern.ts',
983
+ score: 0.9,
984
+ content: 'some code',
985
+ },
986
+ ];
987
+ const result = (await handle({ query: 'test', boundary: 'strategy' }));
988
+ // Must return isError with a clear message naming the broken link,
989
+ // NOT bogus primary results from the raw-prefix fallback
990
+ expect(result.isError).toBe(true);
991
+ expect(result.content[0].text).toContain('strategy');
992
+ expect(result.content[0].text).toContain('not available');
993
+ // Explicitly check the primary results are NOT in the response
994
+ expect(result.content[0].text).not.toContain('Bogus primary hit');
995
+ });
996
+ it('one-shot linked-stores warning is NOT consumed by a transient getContext failure (CR minor)', async () => {
997
+ // mmnto/totem#1295 CR minor: previously `firstLinkedStoresCheckDone`
998
+ // was set BEFORE awaiting `getContext()`, so a transient init error
999
+ // on the very first call permanently suppressed the startup warning
1000
+ // for the rest of the session. The fix moves the flag write to
1001
+ // AFTER getContext resolves successfully.
1002
+ //
1003
+ // This test exercises the bug-fixed contract end-to-end:
1004
+ // 1. First handle() call: getContext throws on every call (4+ times
1005
+ // to cover setLogDir, runFirstQueryHealthCheck, runFirstLinkedStoresCheck,
1006
+ // and performSearch). The handler returns isError but neither
1007
+ // first-query flag is consumed.
1008
+ // 2. Second handle() call: getContext succeeds. The first-query gates
1009
+ // run for real, the linkedStoreInitErrors warning is surfaced,
1010
+ // and the search returns normally.
1011
+ mockLinkedStoreInitErrors.set('strategy', 'Linked index is empty (0 rows).');
1012
+ mockSearchResults = [
1013
+ {
1014
+ label: 'Primary result',
1015
+ type: 'code',
1016
+ filePath: 'src/foo.ts',
1017
+ score: 0.9,
1018
+ content: 'content',
1019
+ },
1020
+ ];
1021
+ // First call: 8 failures is enough to cover every getContext
1022
+ // invocation in a single handle() call (setLogDir, healthCheck,
1023
+ // linkedStores, performSearch, plus the outer-retry path).
1024
+ mockGetContextFailuresRemaining = 8;
1025
+ const first = (await handle({ query: 'test' }));
1026
+ // The first call hard-fails (no context, no results)
1027
+ expect(first.isError).toBe(true);
1028
+ // Critically: the warning is NOT surfaced in the failure response
1029
+ // (linkedStoresWarning was null because getContext threw)
1030
+ expect(first.content[0].text).not.toContain('Linked index is empty');
1031
+ // Second call: getContext now succeeds. With the fix, the first-query
1032
+ // flag was NOT consumed on the first attempt, so the warning surfaces.
1033
+ mockGetContextFailuresRemaining = 0;
1034
+ const second = (await handle({ query: 'test' }));
1035
+ expect(second.isError).toBeUndefined();
1036
+ // The startup warning is now surfaced — the bug fix is observable
1037
+ expect(second.content[0].text).toContain('[SYSTEM WARNING]');
1038
+ expect(second.content[0].text).toContain('strategy');
1039
+ expect(second.content[0].text).toContain('empty (0 rows)');
1040
+ // And the actual search result is also present
1041
+ expect(second.content[0].text).toContain('Primary result');
1042
+ });
1043
+ it('first-query-warn-block surfaces linkedStoreInitErrors', async () => {
1044
+ mockLinkedStoreInitErrors.set('strategy', 'Linked index at /abs/strategy is empty (0 rows).');
1045
+ mockSearchResults = [
1046
+ {
1047
+ label: 'Primary result',
1048
+ type: 'code',
1049
+ filePath: 'src/foo.ts',
1050
+ score: 0.9,
1051
+ content: 'content',
1052
+ },
1053
+ ];
1054
+ const result = (await handle({ query: 'test' }));
1055
+ // Not blocking — primary results still return
1056
+ expect(result.isError).toBeUndefined();
1057
+ // But the init error is surfaced in the response via system warning
1058
+ expect(result.content[0].text).toContain('[SYSTEM WARNING]');
1059
+ expect(result.content[0].text).toContain('strategy');
1060
+ expect(result.content[0].text).toContain('empty (0 rows)');
1061
+ // Primary result still present
1062
+ expect(result.content[0].text).toContain('Primary result');
1063
+ });
1064
+ it('linked store stale-handle recovers via per-store reconnect+retry (Shield AI catch)', async () => {
1065
+ // Shield AI finding on the Phase 2 review: if a linked index gets
1066
+ // rebuilt by a concurrent `totem sync`, its table handle goes stale
1067
+ // and queries fail. The fix is the per-linked-store catch+reconnect+
1068
+ // retry inside `federatedSearch`: when a linked store's search
1069
+ // throws, federatedSearch calls its `.reconnect()` and retries the
1070
+ // search before recording a runtime failure.
1071
+ //
1072
+ // mmnto/totem#1295 CR minor: this test asserts the linked-store
1073
+ // reconnect spy was actually invoked AND that the retry produces
1074
+ // recovered results. Without the spy assertion, the test would still
1075
+ // pass if federatedSearch stopped reconnecting linked stores entirely
1076
+ // — the exact regression Shield AI was guarding against.
1077
+ mockSearchResults = [
1078
+ {
1079
+ label: 'Primary',
1080
+ type: 'code',
1081
+ filePath: 'src/foo.ts',
1082
+ score: 0.7,
1083
+ content: 'content',
1084
+ },
1085
+ ];
1086
+ const strategyStore = {
1087
+ // First search call throws (stale handle), second call (after
1088
+ // reconnect) returns the recovered result.
1089
+ search: vi
1090
+ .fn()
1091
+ .mockRejectedValueOnce(new Error('Stale handle'))
1092
+ .mockResolvedValueOnce([
1093
+ {
1094
+ label: 'Linked recovered',
1095
+ type: 'spec',
1096
+ filePath: 'adr/adr-001.md',
1097
+ absoluteFilePath: '/abs/strategy/adr/adr-001.md',
1098
+ sourceRepo: 'strategy',
1099
+ score: 0.8,
1100
+ content: 'strategy content after reconnect',
1101
+ },
1102
+ ]),
1103
+ reconnect: vi.fn(async () => { }),
1104
+ };
1105
+ mockLinkedStores.set('strategy', strategyStore);
1106
+ const result = (await handle({ query: 'test' }));
1107
+ // The linked store's reconnect spy was actually invoked
1108
+ expect(strategyStore.reconnect).toHaveBeenCalledOnce();
1109
+ // The search spy was called twice: once that threw, once after reconnect
1110
+ expect(strategyStore.search).toHaveBeenCalledTimes(2);
1111
+ expect(result.isError).toBeUndefined();
1112
+ // Both primary and the recovered linked result are in the merged response
1113
+ expect(result.content[0].text).toContain('Primary');
1114
+ expect(result.content[0].text).toContain('Linked recovered');
1115
+ // No runtime warning because the recovery succeeded
1116
+ expect(result.content[0].text).not.toContain('[SYSTEM WARNING]');
1117
+ });
1118
+ it('all results display absolute paths so the agent can Read/Edit unambiguously', async () => {
1119
+ // mmnto/totem#1295 CR MAJOR: `formatResult` previously fell back to
1120
+ // relative `filePath` for primary hits, reintroducing repo-root
1121
+ // ambiguity in the common case — exactly the bug `absoluteFilePath`
1122
+ // was added to fix. Both primary and linked hits must now display
1123
+ // absolute paths in the File line.
1124
+ mockSearchResults = [
1125
+ {
1126
+ label: 'Primary',
1127
+ type: 'code',
1128
+ filePath: 'src/primary.ts',
1129
+ score: 0.7,
1130
+ content: 'primary content',
1131
+ },
1132
+ ];
1133
+ mockLinkedStores.set('strategy', makeLinkedStore([
1134
+ {
1135
+ label: 'Linked',
1136
+ type: 'spec',
1137
+ filePath: 'adr/linked.md',
1138
+ absoluteFilePath: '/abs/totem-strategy/adr/linked.md',
1139
+ sourceRepo: 'strategy',
1140
+ score: 0.6,
1141
+ content: 'linked content',
1142
+ },
1143
+ ]));
1144
+ const result = (await handle({ query: 'test' }));
1145
+ const text = result.content[0].text;
1146
+ // Primary: absolute path (constructed by the mock from projectRoot
1147
+ // + filePath, mirroring real LanceStore behavior)
1148
+ expect(text).toContain('**File:** /fake/project/src/primary.ts');
1149
+ // Primary's relative path must NOT appear in the File line — the
1150
+ // regression would be the bare `src/primary.ts` (without the
1151
+ // `/fake/project/` prefix) appearing where the absolute path goes
1152
+ expect(text).not.toContain('**File:** src/primary.ts ');
1153
+ // Linked: absolute path in the File field (unchanged)
1154
+ expect(text).toContain('**File:** /abs/totem-strategy/adr/linked.md');
1155
+ // Linked result has the [sourceRepo] tag prefix
1156
+ expect(text).toContain('[strategy] Linked');
1157
+ // Primary has no tag prefix (uses bare label)
1158
+ expect(text).toMatch(/### \d+\. Primary \(code\)/);
1159
+ });
1160
+ });
316
1161
  });
317
1162
  //# sourceMappingURL=search-knowledge.test.js.map