@mmnto/mcp 1.12.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.
- package/dist/context.d.ts +58 -0
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +220 -6
- package/dist/context.js.map +1 -1
- package/dist/context.test.js +108 -2
- package/dist/context.test.js.map +1 -1
- package/dist/smoke-test.d.ts +2 -0
- package/dist/smoke-test.d.ts.map +1 -0
- package/dist/smoke-test.js +196 -0
- package/dist/smoke-test.js.map +1 -0
- package/dist/tools/search-knowledge.d.ts +6 -0
- package/dist/tools/search-knowledge.d.ts.map +1 -1
- package/dist/tools/search-knowledge.js +493 -40
- package/dist/tools/search-knowledge.js.map +1 -1
- package/dist/tools/search-knowledge.test.js +901 -56
- package/dist/tools/search-knowledge.test.js.map +1 -1
- package/package.json +2 -2
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
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
|