Skip to content

Commit ad4af12

Browse files
committed
feat(mcp): add test file hints to dev_search results
After semantic search results, dev_search now shows related test files (e.g., utils.ts -> utils.test.ts) in a separate 'Related test files:' section. Design decisions: - Structural matching (*.test.ts, *.spec.ts) not semantic search - Keeps semantic search pure - test hints don't affect rankings - Patterns are parameterized for future configurability Implementation: - New related-files.ts utility with findTestFile, findRelatedTestFiles - SearchAdapter calls utility after formatting results - MCPMetadata extended with related_files_count field - 15 tests for utility functions - Updated PLAN.md with design rationale and marked Phase 1 complete Closes benchmark gap: 'Baseline read test files; dev-agent skipped them'
1 parent 8f32923 commit ad4af12

File tree

8 files changed

+435
-6
lines changed

8 files changed

+435
-6
lines changed

.changeset/test-file-hints.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@lytics/dev-agent-mcp": patch
3+
"@lytics/dev-agent": patch
4+
---
5+
6+
### Features
7+
8+
- **Test file hints in search results**: `dev_search` now shows related test files (e.g., `utils.test.ts`) after search results. This surfaces test files without polluting semantic search rankings.
9+
10+
### Design
11+
12+
- Uses structural matching (`.test.ts`, `.spec.ts` patterns) rather than semantic search
13+
- Keeps semantic search pure - test hints are in a separate "Related test files:" section
14+
- Patterns are configurable for future extensibility via function parameters
15+

PLAN.md

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,11 +192,42 @@ Git history is valuable context that LLMs can't easily access. We add intelligen
192192
| Task | Gap Identified | Priority | Status |
193193
|------|----------------|----------|--------|
194194
| Diagnostic command suggestions | Baseline provided shell commands for debugging; dev-agent didn't | 🔴 High | 🔲 Todo |
195-
| Test file inclusion hints | Baseline read test files; dev-agent skipped them | 🔴 High | 🔲 Todo |
195+
| Test file inclusion hints | Baseline read test files; dev-agent skipped them | 🔴 High | ✅ Done |
196196
| Code example extraction | Baseline included more code snippets in responses | 🟡 Medium | 🔲 Todo |
197197
| Exhaustive mode for debugging | Option for thorough exploration vs fast satisficing | 🟡 Medium | 🔲 Todo |
198198
| Related files suggestions | "You might also want to check: X, Y, Z" | 🟡 Medium | 🔲 Todo |
199199

200+
### Test File Hints - Design (v0.4.4)
201+
202+
**Problem:** Benchmark showed baseline Claude read test files, but dev-agent didn't surface them.
203+
204+
**Root cause:** Test files have *structural* relationship (same name), not *semantic* relationship:
205+
- Searching "authentication middleware" finds `auth/middleware.ts` (semantic match ✓)
206+
- `auth/middleware.test.ts` might NOT appear because test semantics differ
207+
- "describe('AuthMiddleware', () => {...})" doesn't semantically match the query
208+
209+
**Design decision:** Keep semantic search pure. Add "Related files:" section using filesystem lookup.
210+
211+
| Approach | Decision | Rationale |
212+
|----------|----------|-----------|
213+
| Filename matching | ✅ Chosen | Fast, reliable, honest about what it is |
214+
| Boost test queries | ❌ Rejected | Might return unrelated tests |
215+
| Index-time metadata | ❌ Rejected | Requires re-index, complex |
216+
217+
**Phased rollout:**
218+
219+
| Phase | Tool | Status |
220+
|-------|------|--------|
221+
| 1 (v0.4.4) | `dev_search` | ✅ Done |
222+
| 2 | `dev_refs`, `dev_explore` | 🔲 Todo |
223+
| 3 | `dev_map`, `dev_status` | 🔲 Todo |
224+
225+
**Implementation (Phase 1):**
226+
- After search results, check filesystem for test siblings
227+
- Patterns: `*.test.ts`, `*.spec.ts`, `__tests__/*.ts`
228+
- Add "Related files:" section to output
229+
- No change to semantic search scoring
230+
200231
### Tool Description Refinements (Done in v0.4.2)
201232

202233
| Task | Status |

packages/mcp-server/bin/dev-agent-mcp.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ async function main() {
145145
// Create and register adapters
146146
const searchAdapter = new SearchAdapter({
147147
repositoryIndexer: indexer,
148+
repositoryPath,
148149
defaultFormat: 'compact',
149150
defaultLimit: 10,
150151
});

packages/mcp-server/src/adapters/built-in/search-adapter.ts

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import type { RepositoryIndexer } from '@lytics/dev-agent-core';
77
import { CompactFormatter, type FormatMode, VerboseFormatter } from '../../formatters';
8+
import { findRelatedTestFiles, formatRelatedFiles } from '../../utils/related-files';
89
import { ToolAdapter } from '../tool-adapter';
910
import type { AdapterContext, ToolDefinition, ToolExecutionContext, ToolResult } from '../types';
1011

@@ -17,6 +18,11 @@ export interface SearchAdapterConfig {
1718
*/
1819
repositoryIndexer: RepositoryIndexer;
1920

21+
/**
22+
* Repository root path (for finding related files)
23+
*/
24+
repositoryPath?: string;
25+
2026
/**
2127
* Default format mode
2228
*/
@@ -26,6 +32,11 @@ export interface SearchAdapterConfig {
2632
* Default result limit
2733
*/
2834
defaultLimit?: number;
35+
36+
/**
37+
* Include related test files in results
38+
*/
39+
includeRelatedFiles?: boolean;
2940
}
3041

3142
/**
@@ -41,15 +52,19 @@ export class SearchAdapter extends ToolAdapter {
4152
};
4253

4354
private indexer: RepositoryIndexer;
44-
private config: Required<SearchAdapterConfig>;
55+
private config: Required<Omit<SearchAdapterConfig, 'repositoryPath'>> & {
56+
repositoryPath?: string;
57+
};
4558

4659
constructor(config: SearchAdapterConfig) {
4760
super();
4861
this.indexer = config.repositoryIndexer;
4962
this.config = {
5063
repositoryIndexer: config.repositoryIndexer,
64+
repositoryPath: config.repositoryPath,
5165
defaultFormat: config.defaultFormat ?? 'compact',
5266
defaultLimit: config.defaultLimit ?? 10,
67+
includeRelatedFiles: config.includeRelatedFiles ?? true,
5368
};
5469
}
5570

@@ -210,11 +225,27 @@ export class SearchAdapter extends ToolAdapter {
210225

211226
const formatted = formatter.formatResults(results);
212227

228+
// Find related test files if enabled and repository path is available
229+
let relatedFilesSection = '';
230+
let relatedFilesCount = 0;
231+
if (this.config.includeRelatedFiles && this.config.repositoryPath && results.length > 0) {
232+
const sourcePaths = results
233+
.map((r) => r.metadata.path)
234+
.filter((p): p is string => typeof p === 'string');
235+
236+
if (sourcePaths.length > 0) {
237+
const relatedFiles = await findRelatedTestFiles(sourcePaths, this.config.repositoryPath);
238+
relatedFilesCount = relatedFiles.length;
239+
relatedFilesSection = formatRelatedFiles(relatedFiles);
240+
}
241+
}
242+
213243
const duration_ms = Date.now() - startTime;
214244

215245
context.logger.info('Search completed', {
216246
query,
217247
resultCount: results.length,
248+
relatedFilesCount,
218249
tokens: formatted.tokens,
219250
duration_ms,
220251
});
@@ -224,7 +255,7 @@ export class SearchAdapter extends ToolAdapter {
224255
data: {
225256
query,
226257
format,
227-
content: formatted.content,
258+
content: formatted.content + relatedFilesSection,
228259
},
229260
metadata: {
230261
tokens: formatted.tokens,
@@ -234,6 +265,7 @@ export class SearchAdapter extends ToolAdapter {
234265
results_total: results.length,
235266
results_returned: Math.min(results.length, limit as number),
236267
results_truncated: results.length > (limit as number),
268+
related_files_count: relatedFilesCount,
237269
},
238270
};
239271
} catch (error) {

packages/mcp-server/src/adapters/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@ export interface MCPMetadata {
6565
results_returned?: number;
6666
/** Whether results were truncated due to limits */
6767
results_truncated?: boolean;
68+
69+
// Related files (optional)
70+
/** Number of related test files found */
71+
related_files_count?: number;
6872
}
6973

7074
// Tool Result

packages/mcp-server/src/formatters/__tests__/utils.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -208,11 +208,11 @@ describe('Formatter Utils', () => {
208208
it('should return elapsed time', async () => {
209209
const timer = startTimer();
210210

211-
// Wait a bit
212-
await new Promise((resolve) => setTimeout(resolve, 10));
211+
// Wait a bit (use 15ms to avoid flaky timing issues)
212+
await new Promise((resolve) => setTimeout(resolve, 15));
213213

214214
const elapsed = timer.elapsed();
215-
expect(elapsed).toBeGreaterThanOrEqual(10);
215+
expect(elapsed).toBeGreaterThanOrEqual(10); // Allow some timing variance
216216
expect(elapsed).toBeLessThan(100); // Should be fast
217217
});
218218

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
/**
2+
* Tests for Related Files Utility
3+
*/
4+
5+
import * as fs from 'node:fs/promises';
6+
import * as os from 'node:os';
7+
import * as path from 'node:path';
8+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
9+
import {
10+
DEFAULT_TEST_PATTERNS,
11+
findRelatedTestFiles,
12+
findTestFile,
13+
formatRelatedFiles,
14+
type RelatedFile,
15+
type TestPatternFn,
16+
} from '../related-files';
17+
18+
describe('Related Files Utility', () => {
19+
let tempDir: string;
20+
21+
beforeEach(async () => {
22+
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'related-files-test-'));
23+
});
24+
25+
afterEach(async () => {
26+
await fs.rm(tempDir, { recursive: true, force: true });
27+
});
28+
29+
describe('findTestFile', () => {
30+
it('should find .test.ts sibling', async () => {
31+
// Create source and test files
32+
await fs.writeFile(path.join(tempDir, 'utils.ts'), 'export const x = 1;');
33+
await fs.writeFile(path.join(tempDir, 'utils.test.ts'), 'test("x", () => {});');
34+
35+
const result = await findTestFile('utils.ts', tempDir);
36+
37+
expect(result).not.toBeNull();
38+
expect(result?.relatedPath).toBe('utils.test.ts');
39+
expect(result?.type).toBe('test');
40+
});
41+
42+
it('should find .spec.ts sibling', async () => {
43+
await fs.writeFile(path.join(tempDir, 'utils.ts'), 'export const x = 1;');
44+
await fs.writeFile(path.join(tempDir, 'utils.spec.ts'), 'test("x", () => {});');
45+
46+
const result = await findTestFile('utils.ts', tempDir);
47+
48+
expect(result).not.toBeNull();
49+
expect(result?.relatedPath).toBe('utils.spec.ts');
50+
expect(result?.type).toBe('spec');
51+
});
52+
53+
it('should return null for files without tests', async () => {
54+
await fs.writeFile(path.join(tempDir, 'utils.ts'), 'export const x = 1;');
55+
56+
const result = await findTestFile('utils.ts', tempDir);
57+
58+
expect(result).toBeNull();
59+
});
60+
61+
it('should return null for test files (skip self)', async () => {
62+
await fs.writeFile(path.join(tempDir, 'utils.test.ts'), 'test("x", () => {});');
63+
64+
const result = await findTestFile('utils.test.ts', tempDir);
65+
66+
expect(result).toBeNull();
67+
});
68+
69+
it('should return null for spec files (skip self)', async () => {
70+
await fs.writeFile(path.join(tempDir, 'utils.spec.ts'), 'test("x", () => {});');
71+
72+
const result = await findTestFile('utils.spec.ts', tempDir);
73+
74+
expect(result).toBeNull();
75+
});
76+
77+
it('should handle nested directories', async () => {
78+
await fs.mkdir(path.join(tempDir, 'src', 'utils'), { recursive: true });
79+
await fs.writeFile(path.join(tempDir, 'src', 'utils', 'helper.ts'), 'export const x = 1;');
80+
await fs.writeFile(
81+
path.join(tempDir, 'src', 'utils', 'helper.test.ts'),
82+
'test("x", () => {});'
83+
);
84+
85+
const result = await findTestFile('src/utils/helper.ts', tempDir);
86+
87+
expect(result).not.toBeNull();
88+
expect(result?.relatedPath).toBe('src/utils/helper.test.ts');
89+
});
90+
});
91+
92+
describe('findRelatedTestFiles', () => {
93+
it('should find test files for multiple sources', async () => {
94+
await fs.writeFile(path.join(tempDir, 'a.ts'), 'export const a = 1;');
95+
await fs.writeFile(path.join(tempDir, 'a.test.ts'), 'test("a", () => {});');
96+
await fs.writeFile(path.join(tempDir, 'b.ts'), 'export const b = 1;');
97+
await fs.writeFile(path.join(tempDir, 'b.test.ts'), 'test("b", () => {});');
98+
await fs.writeFile(path.join(tempDir, 'c.ts'), 'export const c = 1;');
99+
// No test for c.ts
100+
101+
const results = await findRelatedTestFiles(['a.ts', 'b.ts', 'c.ts'], tempDir);
102+
103+
expect(results).toHaveLength(2);
104+
expect(results.map((r) => r.relatedPath).sort()).toEqual(['a.test.ts', 'b.test.ts']);
105+
});
106+
107+
it('should deduplicate source paths', async () => {
108+
await fs.writeFile(path.join(tempDir, 'utils.ts'), 'export const x = 1;');
109+
await fs.writeFile(path.join(tempDir, 'utils.test.ts'), 'test("x", () => {});');
110+
111+
const results = await findRelatedTestFiles(['utils.ts', 'utils.ts', 'utils.ts'], tempDir);
112+
113+
expect(results).toHaveLength(1);
114+
});
115+
116+
it('should return empty array when no tests found', async () => {
117+
await fs.writeFile(path.join(tempDir, 'a.ts'), 'export const a = 1;');
118+
await fs.writeFile(path.join(tempDir, 'b.ts'), 'export const b = 1;');
119+
120+
const results = await findRelatedTestFiles(['a.ts', 'b.ts'], tempDir);
121+
122+
expect(results).toEqual([]);
123+
});
124+
125+
it('should return empty array for empty input', async () => {
126+
const results = await findRelatedTestFiles([], tempDir);
127+
128+
expect(results).toEqual([]);
129+
});
130+
});
131+
132+
describe('formatRelatedFiles', () => {
133+
it('should format related files as string', () => {
134+
const files: RelatedFile[] = [
135+
{ sourcePath: 'utils.ts', relatedPath: 'utils.test.ts', type: 'test' },
136+
{ sourcePath: 'helper.ts', relatedPath: 'helper.spec.ts', type: 'spec' },
137+
];
138+
139+
const result = formatRelatedFiles(files);
140+
141+
expect(result).toContain('Related test files:');
142+
expect(result).toContain('utils.test.ts');
143+
expect(result).toContain('helper.spec.ts');
144+
});
145+
146+
it('should return empty string for empty array', () => {
147+
const result = formatRelatedFiles([]);
148+
149+
expect(result).toBe('');
150+
});
151+
152+
it('should include separator line', () => {
153+
const files: RelatedFile[] = [
154+
{ sourcePath: 'utils.ts', relatedPath: 'utils.test.ts', type: 'test' },
155+
];
156+
157+
const result = formatRelatedFiles(files);
158+
159+
expect(result).toContain('---');
160+
});
161+
});
162+
163+
describe('custom patterns', () => {
164+
it('should support custom test patterns', async () => {
165+
// Create a custom pattern for __tests__ directory
166+
const customPatterns: TestPatternFn[] = [
167+
...DEFAULT_TEST_PATTERNS,
168+
(base, ext, dir) => path.join(dir, '__tests__', `${path.basename(base)}${ext}`),
169+
];
170+
171+
await fs.mkdir(path.join(tempDir, '__tests__'));
172+
await fs.writeFile(path.join(tempDir, 'utils.ts'), 'export const x = 1;');
173+
await fs.writeFile(path.join(tempDir, '__tests__', 'utils.ts'), 'test("x", () => {});');
174+
175+
const result = await findTestFile('utils.ts', tempDir, customPatterns);
176+
177+
expect(result).not.toBeNull();
178+
expect(result?.relatedPath).toBe('__tests__/utils.ts');
179+
});
180+
181+
it('should use default patterns when none provided', async () => {
182+
await fs.writeFile(path.join(tempDir, 'utils.ts'), 'export const x = 1;');
183+
await fs.writeFile(path.join(tempDir, 'utils.test.ts'), 'test("x", () => {});');
184+
185+
// Call without patterns parameter - should use defaults
186+
const result = await findTestFile('utils.ts', tempDir);
187+
188+
expect(result).not.toBeNull();
189+
expect(result?.relatedPath).toBe('utils.test.ts');
190+
});
191+
});
192+
});

0 commit comments

Comments
 (0)