Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions packages/mcp-server/bin/dev-agent-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,13 @@

import {
ensureStorageDirectory,
GitIndexer,
getStorageFilePaths,
getStoragePath,
LocalGitExtractor,
RepositoryIndexer,
saveMetadata,
VectorStorage,
} from '@lytics/dev-agent-core';
import {
ExplorerAgent,
Expand All @@ -21,6 +24,7 @@ import {
ExploreAdapter,
GitHubAdapter,
HealthAdapter,
HistoryAdapter,
MapAdapter,
PlanAdapter,
RefsAdapter,
Expand Down Expand Up @@ -193,6 +197,26 @@ async function main() {
defaultTokenBudget: 2000,
});

// Create git extractor and indexer for history adapter
// Note: GitIndexer uses the same vector storage for commit embeddings
const gitExtractor = new LocalGitExtractor(repositoryPath);
const gitVectorStorage = new VectorStorage({
storePath: `${filePaths.vectors}-git`,
});
await gitVectorStorage.initialize();

const gitIndexer = new GitIndexer({
extractor: gitExtractor,
vectorStorage: gitVectorStorage,
});

const historyAdapter = new HistoryAdapter({
gitIndexer,
gitExtractor,
defaultLimit: 10,
defaultTokenBudget: 2000,
});

// Create MCP server with coordinator
const server = new MCPServer({
serverInfo: {
Expand All @@ -213,6 +237,7 @@ async function main() {
healthAdapter,
refsAdapter,
mapAdapter,
historyAdapter,
],
coordinator,
});
Expand All @@ -221,6 +246,7 @@ async function main() {
const shutdown = async () => {
await server.stop();
await indexer.close();
await gitVectorStorage.close();
// Close GitHub adapter if initialized
if (githubAdapter.githubIndexer) {
await githubAdapter.githubIndexer.close();
Expand Down
281 changes: 281 additions & 0 deletions packages/mcp-server/src/adapters/__tests__/history-adapter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,281 @@
import type { GitCommit, GitIndexer, LocalGitExtractor } from '@lytics/dev-agent-core';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { HistoryAdapter } from '../built-in/history-adapter';
import type { ToolExecutionContext } from '../types';

// Mock commit data
const createMockCommit = (overrides: Partial<GitCommit> = {}): GitCommit => ({
hash: 'abc123def456789012345678901234567890abcd',
shortHash: 'abc123d',
message: 'feat: add authentication token handling\n\nThis adds token refresh logic.',
subject: 'feat: add authentication token handling',
body: 'This adds token refresh logic.',
author: {
name: 'Test User',
email: '[email protected]',
date: '2025-01-15T10:00:00Z',
},
committer: {
name: 'Test User',
email: '[email protected]',
date: '2025-01-15T10:00:00Z',
},
files: [
{ path: 'src/auth/token.ts', status: 'modified', additions: 50, deletions: 10 },
{ path: 'src/auth/index.ts', status: 'modified', additions: 5, deletions: 2 },
],
stats: {
additions: 55,
deletions: 12,
filesChanged: 2,
},
refs: {
branches: [],
tags: [],
issueRefs: [123],
prRefs: [456],
},
parents: ['parent123'],
...overrides,
});

describe('HistoryAdapter', () => {
let mockGitIndexer: GitIndexer;
let mockGitExtractor: LocalGitExtractor;
let adapter: HistoryAdapter;
let mockContext: ToolExecutionContext;

beforeEach(() => {
// Create mock git indexer
mockGitIndexer = {
index: vi.fn().mockResolvedValue({ commitsIndexed: 10, durationMs: 100, errors: [] }),
search: vi.fn().mockResolvedValue([createMockCommit()]),
getFileHistory: vi.fn().mockResolvedValue([createMockCommit()]),
getIndexedCommitCount: vi.fn().mockResolvedValue(100),
} as unknown as GitIndexer;

// Create mock git extractor
mockGitExtractor = {
getCommits: vi.fn().mockResolvedValue([createMockCommit()]),
getCommit: vi.fn(),
getBlame: vi.fn(),
getRepositoryInfo: vi.fn(),
} as unknown as LocalGitExtractor;

adapter = new HistoryAdapter({
gitIndexer: mockGitIndexer,
gitExtractor: mockGitExtractor,
defaultLimit: 10,
defaultTokenBudget: 2000,
});

mockContext = {
logger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
requestId: 'test-request',
} as unknown as ToolExecutionContext;
});

describe('getToolDefinition', () => {
it('should return correct tool definition', () => {
const definition = adapter.getToolDefinition();

expect(definition.name).toBe('dev_history');
expect(definition.description).toContain('git commit history');
expect(definition.inputSchema.properties).toHaveProperty('query');
expect(definition.inputSchema.properties).toHaveProperty('file');
expect(definition.inputSchema.properties).toHaveProperty('limit');
expect(definition.inputSchema.properties).toHaveProperty('since');
expect(definition.inputSchema.properties).toHaveProperty('author');
expect(definition.inputSchema.properties).toHaveProperty('tokenBudget');
});

it('should require either query or file', () => {
const definition = adapter.getToolDefinition();

expect(definition.inputSchema.anyOf).toEqual([
{ required: ['query'] },
{ required: ['file'] },
]);
});
});

describe('execute', () => {
describe('semantic search (query)', () => {
it('should search commits by semantic query', async () => {
const result = await adapter.execute({ query: 'authentication token' }, mockContext);

expect(result.success).toBe(true);
expect(mockGitIndexer.search).toHaveBeenCalledWith('authentication token', { limit: 10 });
expect(result.data).toMatchObject({
searchType: 'semantic',
query: 'authentication token',
});
});

it('should respect limit option', async () => {
await adapter.execute({ query: 'test', limit: 5 }, mockContext);

expect(mockGitIndexer.search).toHaveBeenCalledWith('test', { limit: 5 });
});

it('should include commit summaries in data', async () => {
const result = await adapter.execute({ query: 'test' }, mockContext);

expect(result.data?.commits).toEqual(
expect.arrayContaining([
expect.objectContaining({
hash: 'abc123d',
subject: 'feat: add authentication token handling',
author: 'Test User',
}),
])
);
});
});

describe('file history', () => {
it('should get history for a specific file', async () => {
const result = await adapter.execute({ file: 'src/auth/token.ts' }, mockContext);

expect(result.success).toBe(true);
expect(mockGitExtractor.getCommits).toHaveBeenCalledWith({
path: 'src/auth/token.ts',
limit: 10,
since: undefined,
author: undefined,
follow: true,
noMerges: true,
});
expect(result.data).toMatchObject({
searchType: 'file',
file: 'src/auth/token.ts',
});
});

it('should pass since and author filters', async () => {
await adapter.execute(
{
file: 'src/file.ts',
since: '2025-01-01',
author: '[email protected]',
},
mockContext
);

expect(mockGitExtractor.getCommits).toHaveBeenCalledWith(
expect.objectContaining({
since: '2025-01-01',
author: '[email protected]',
})
);
});
});

describe('validation', () => {
it('should require query or file', async () => {
const result = await adapter.execute({}, mockContext);

expect(result.success).toBe(false);
expect(result.error?.code).toBe('MISSING_INPUT');
});

it('should validate limit range', async () => {
const result = await adapter.execute({ query: 'test', limit: 100 }, mockContext);

expect(result.success).toBe(false);
expect(result.error?.code).toBe('INVALID_LIMIT');
});
});

describe('output formatting', () => {
it('should include formatted content', async () => {
const result = await adapter.execute({ query: 'test' }, mockContext);

expect(result.data?.content).toContain('# Git History');
expect(result.data?.content).toContain('abc123d');
expect(result.data?.content).toContain('feat: add authentication token handling');
});

it('should include file changes in output', async () => {
const result = await adapter.execute({ query: 'test' }, mockContext);

expect(result.data?.content).toContain('src/auth/token.ts');
});

it('should include issue/PR refs in output', async () => {
const result = await adapter.execute({ query: 'test' }, mockContext);

expect(result.data?.content).toContain('#123');
expect(result.data?.content).toContain('#456');
});
});

describe('token budgeting', () => {
it('should respect token budget', async () => {
// Create many commits
const manyCommits = Array.from({ length: 20 }, (_, i) =>
createMockCommit({
hash: `hash${i.toString().padStart(38, '0')}`,
shortHash: `h${i.toString().padStart(6, '0')}`,
subject: `Commit ${i}: ${Array(100).fill('word').join(' ')}`,
})
);
vi.mocked(mockGitIndexer.search).mockResolvedValue(manyCommits);

const result = await adapter.execute({ query: 'test', tokenBudget: 500 }, mockContext);

expect(result.success).toBe(true);
// Should truncate due to token budget
expect(result.data?.content).toContain('token budget reached');
});
});

describe('metadata', () => {
it('should include metadata in result', async () => {
const result = await adapter.execute({ query: 'test' }, mockContext);

expect(result.metadata).toMatchObject({
tokens: expect.any(Number),
duration_ms: expect.any(Number),
timestamp: expect.any(String),
cached: false,
});
});
});

describe('error handling', () => {
it('should handle search errors', async () => {
vi.mocked(mockGitIndexer.search).mockRejectedValue(new Error('Search failed'));

const result = await adapter.execute({ query: 'test' }, mockContext);

expect(result.success).toBe(false);
expect(result.error?.code).toBe('HISTORY_FAILED');
expect(result.error?.message).toContain('Search failed');
});

it('should handle extractor errors', async () => {
vi.mocked(mockGitExtractor.getCommits).mockRejectedValue(new Error('Git error'));

const result = await adapter.execute({ file: 'src/file.ts' }, mockContext);

expect(result.success).toBe(false);
expect(result.error?.code).toBe('HISTORY_FAILED');
});
});
});

describe('estimateTokens', () => {
it('should estimate tokens based on limit and budget', () => {
const estimate = adapter.estimateTokens({ limit: 10, tokenBudget: 2000 });

expect(estimate).toBeLessThanOrEqual(2000);
expect(estimate).toBeGreaterThan(0);
});
});
});
Loading