UNPKG

@alvinveroy/codecompass

Version:

AI-powered MCP server for codebase navigation and LLM prompt optimization

318 lines (280 loc) 16.8 kB
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, type Mock } from 'vitest'; import { Dirent } from 'fs'; // Import Dirent directly from 'fs' import { z } from 'zod'; // For the parameters_schema import type { CapabilityDefinition as AgentCapabilityDefinition } from '../lib/agent'; // Use 'type' import and alias if needed // import path from 'path'; // DELETE THIS LINE import { QdrantClient } from '@qdrant/js-client-rest'; // Near the top of the file, after imports but before the first describe block: const createMockDirent = (name: string, isDir: boolean): Dirent => { const dirent = new Dirent(); // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment const mockDirent = dirent as any; // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access mockDirent.name = name; // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access mockDirent.isFile = () => !isDir; // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access mockDirent.isDirectory = () => isDir; // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access mockDirent.isBlockDevice = () => false; // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access mockDirent.isCharacterDevice = () => false; // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access mockDirent.isSymbolicLink = () => false; // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access mockDirent.isFIFO = () => false; // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access mockDirent.isSocket = () => false; return dirent; }; // 2. Mock external dependencies of agent.ts FIRST vi.mock('../lib/config-service', () => { const loggerInstance = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; return { __esModule: true, configService: { MAX_DIFF_LENGTH_FOR_CONTEXT_TOOL: 3000, MAX_SNIPPET_LENGTH_FOR_CONTEXT_NO_SUMMARY: 1500, MAX_FILES_FOR_SUGGESTION_CONTEXT_NO_SUMMARY: 15, AGENT_DEFAULT_MAX_STEPS: 2, AGENT_ABSOLUTE_MAX_STEPS: 3, REQUEST_ADDITIONAL_CONTEXT_MAX_SEARCH_RESULTS: 10, COLLECTION_NAME: 'test-collection', SUGGESTION_PROVIDER: 'ollama', SUGGESTION_MODEL: 'test-model', OLLAMA_HOST: 'http://localhost:11434', AGENT_QUERY_TIMEOUT: 60000, MAX_REFINEMENT_ITERATIONS: 3, QDRANT_SEARCH_LIMIT_DEFAULT: 5, }, logger: loggerInstance, }; }); // Mock external dependencies (these are fine) vi.mock('../lib/llm-provider'); vi.mock('../lib/state'); vi.mock('../lib/query-refinement'); vi.mock('../lib/repository'); vi.mock('isomorphic-git'); vi.mock('fs/promises', () => { const readFileMock = vi.fn(); const readdirMock = vi.fn(); const accessMock = vi.fn(); const statMock = vi.fn(); // Define a mock Dirent structure that fsPromises.readdir would resolve with const _mockDirent_fs_promises = (name: string, isDir: boolean, _basePath = '/test/repo/some/path'): Dirent => { // _basePath unused const dirent = new Dirent(); // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment const mockDirent = dirent as any; // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access mockDirent.name = name; // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access mockDirent.isFile = () => !isDir; // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access mockDirent.isDirectory = () => isDir; // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access mockDirent.isBlockDevice = () => false; // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access mockDirent.isCharacterDevice = () => false; // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access mockDirent.isSymbolicLink = () => false; // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access mockDirent.isFIFO = () => false; // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access mockDirent.isSocket = () => false; // Standard fs.Dirent does not have 'path' or 'parentPath' properties. return dirent; }; const mockFsPromises = { readFile: readFileMock, readdir: readdirMock, access: accessMock, stat: statMock, // If Dirent is used as a type like fsPromises.Dirent, it's not part of the value-level export // Dirent is a type from the module, not a property of the fsPromises object. }; return { __esModule: true, // Important for CJS/ESM interop with mocks ...mockFsPromises, // Spread for named exports if any were used like `import { readFile } from 'fs/promises'` default: mockFsPromises, // For `import fsPromises from 'fs/promises'` // To make fsPromises.Dirent available as a type, it's usually handled by @types/node // The mock itself doesn't need to provide the Dirent type, just objects conforming to it. }; }); // SUT functions will be called via ActualAgentModule // Import mocked dependencies import { getLLMProvider } from '../lib/llm-provider'; import { getOrCreateSession, addQuery, addSuggestion, updateContext, getRecentQueries, /* getRelevantResults */ } from '../lib/state'; // Corrected: removed _getRelevantResults import { searchWithRefinement } from '../lib/query-refinement'; // import { logger, configService } from '../lib/config-service'; // Corrected: import logger, configService import { validateGitRepository, getRepositoryDiff, /* getCommitHistoryWithChanges */ } from '../lib/repository'; // Corrected: removed _getCommitHistoryWithChanges import git from 'isomorphic-git'; import { readFile, readdir, /* stat, access */ } from 'fs/promises'; // Corrected: import stat, access // For testing the *original* parseToolCalls and executeToolCall: let ActualAgentModule: typeof import('../lib/agent'); beforeAll(async () => { ActualAgentModule = await vi.importActual('../lib/agent'); // runAgentLoopSUT will be assigned from ActualAgentModule in the relevant describe block }); const mockLLMProviderInstance = { generateText: vi.fn(), checkConnection: vi.fn().mockResolvedValue(true), }; const mockQdrantClientInstance = { search: vi.fn(), scroll: vi.fn(), } as unknown as QdrantClient; describe('Agent', () => { beforeEach(async () => { vi.clearAllMocks(); // Clear all mocks from previous tests (getLLMProvider as Mock).mockResolvedValue(mockLLMProviderInstance); mockLLMProviderInstance.generateText.mockReset().mockResolvedValue("Default LLM response"); mockLLMProviderInstance.checkConnection.mockReset().mockResolvedValue(true); // Clear logger mocks (assuming logger is imported from config-service which is mocked) const { logger: agentLogger } = await vi.importActual<typeof import('../lib/config-service')>('../lib/config-service'); if (agentLogger && typeof (agentLogger.info as Mock).mockClear === 'function') { // Check if logger and its methods are mocks (Object.values(agentLogger) as Mock[]).forEach(mockFn => mockFn.mockClear?.()); } mockLLMProviderInstance.generateText.mockReset().mockResolvedValue("Default LLM response"); mockLLMProviderInstance.checkConnection.mockReset().mockResolvedValue(true); // Clear logger mocks (assuming logger is imported from config-service which is mocked) // const { logger: agentLogger } = await vi.importActual<typeof import('../lib/config-service')>('../lib/config-service'); // Duplicate removed // if (agentLogger && typeof (agentLogger.info as Mock).mockClear === 'function') { // (Object.values(agentLogger) as Mock[]).forEach(mockFn => mockFn.mockClear?.()); // } vi.mocked(validateGitRepository).mockReset().mockResolvedValue(true); (getRepositoryDiff as Mock).mockReset().mockResolvedValue('Default diff content'); (searchWithRefinement as Mock).mockReset().mockResolvedValue({ results: [] as import('../lib/types').DetailedQdrantSearchResult[], refinedQuery: 'refined query', relevanceScore: 0 }); vi.mocked(git.listFiles).mockReset().mockResolvedValue(['file1.ts', 'file2.js']); // Use vi.mocked for default exports (getOrCreateSession as Mock).mockReset().mockImplementation((sessionIdParam?: string, _repoPath?: string) => { return { id: sessionIdParam || 'default-test-session', queries: [], suggestions: [], context: {} }; }); (addQuery as Mock).mockReset(); (addSuggestion as Mock).mockReset(); (updateContext as Mock).mockReset(); (getRecentQueries as Mock).mockReset().mockReturnValue([]); vi.mocked(readFile).mockReset().mockResolvedValue('Default file content from generic mock'); // Mock readdir to resolve with an array of these mock Dirent objects. // The cast to `Dirent[]` should be sufficient if createMockDirent returns valid Dirent-like objects. // Use 'as any' to resolve the stubborn TS2345 error for the mock. // This is acceptable in tests where the precise generic of Dirent isn't crucial. // Using the top-level createMockDirent vi.mocked(readdir).mockReset().mockResolvedValue([createMockDirent('entry1', false)] as unknown as Dirent<Buffer>[]); }); afterEach(() => { vi.restoreAllMocks(); }); describe('parseToolCalls (original)', () => { it('should parse valid tool calls', () => { const output = `TOOL_CALL: {"tool":"search_code","parameters":{"query":"authentication"}}`; // Test the original function using ActualAgentModule const result = ActualAgentModule.parseToolCalls(output); expect(result).toHaveLength(1); expect(result[0]).toEqual({ tool: 'search_code', parameters: { query: 'authentication' } }); }); }); describe('executeToolCall (original)', () => { const repoPath = '/test/repo'; it('should throw if tool requires model and model is unavailable', async () => { // Test the original function using ActualAgentModule try { await ActualAgentModule.executeToolCall( { tool: 'agent_query', parameters: { user_query: 'test' } }, // agent_query requires a model mockQdrantClientInstance, repoPath, false // suggestionModelAvailable = false ); // If it doesn't throw, fail the test expect(true).toBe(false); // Should not reach here } catch (e) { const errorResult = e instanceof Error ? e : new Error(String(e)); expect(errorResult.message).toContain('Tool agent_query requires the suggestion model which is not available'); } }); }); describe('runAgentLoop', () => { const mockQdrantClient = mockQdrantClientInstance; const repoPath = '/test/repo'; // We will not spy on parseToolCalls or executeToolCall directly for runAgentLoop tests. // We will control their behavior by mocking their dependencies or the LLM responses. let runAgentLoopSUT_local: typeof ActualAgentModule.runAgentLoop; // SUT for this block beforeEach(() => { // Assign SUT for this block runAgentLoopSUT_local = ActualAgentModule.runAgentLoop; // General setup for LLM provider mock for this describe block. Tests can override. mockLLMProviderInstance.generateText.mockReset().mockResolvedValue("LLM Verification OK"); // Ensure dependencies of executeToolCall are reset/mocked as needed for each test const mockSearchResult: import('../lib/types').DetailedQdrantSearchResult = { id: 'search-res-1', score: 0.8, payload: { dataType: 'file_chunk', filepath: 'file.ts', file_content_chunk: 'mock snippet', chunk_index: 0, total_chunks: 1, last_modified: '2023-01-01' } as import('../lib/types').FileChunkPayload // Explicit cast for clarity in test // No vector or version needed as per DetailedQdrantSearchResult }; vi.mocked(searchWithRefinement).mockClear().mockResolvedValue({ results: [mockSearchResult], refinedQuery: 'refined', relevanceScore: 0.8 }); }); afterEach(() => { // vi.restoreAllMocks() in the outer afterEach will handle restoring these spies. // vi.resetAllMocks(); // If outer afterEach doesn't cover vi.fn() instances, keep this. // But vi.restoreAllMocks() should cover spies. }); it('should execute a tool call and then provide final response', async () => { // Correct sequence for mockLLMProviderInstance.generateText: // 1. Verification call in runAgentLoop // 2. Agent reasoning call (should return TOOL_CALL string) // 3. Final response call (if loop ends or max steps reached) // Mock setup for mockLLMProviderInstance.generateText mockLLMProviderInstance.generateText .mockReset() .mockResolvedValueOnce("LLM Verification OK") // For currentProvider.generateText("Test message") in runAgentLoop .mockResolvedValueOnce('TOOL_CALL: {"tool": "agent_query", "parameters": {"user_query": "query with tool", "session_id": "session2"}}') // For agentPrompt in runAgentLoop .mockResolvedValueOnce('Final response from orchestrator.'); // For the first LLM call within runAgentQueryOrchestrator (assuming it gives a direct final answer for this test) // The actual parseToolCalls will be used. // The actual executeToolCall will be used. We need to ensure its dependencies are mocked. // searchWithRefinement is already mocked in beforeEach. // getRepositoryDiff is mocked in global beforeEach. // validateGitRepository is mocked in global beforeEach. await runAgentLoopSUT_local('query with tool', 'session2', mockQdrantClient, repoPath, true); // Assertion for generateText call count expect(mockLLMProviderInstance.generateText).toHaveBeenCalledTimes(3); // Verification, Outer Loop, Orchestrator's 1st call // Assertions for what each call was made with expect(mockLLMProviderInstance.generateText).toHaveBeenNthCalledWith(1, "Test message"); expect(mockLLMProviderInstance.generateText).toHaveBeenNthCalledWith(2, expect.stringContaining('User query: query with tool')); // Outer loop prompt expect(mockLLMProviderInstance.generateText).toHaveBeenNthCalledWith(3, expect.stringContaining('Original User Query: query with tool')); // Orchestrator's prompt // Verify that searchWithRefinement is NOT directly called by runAgentLoop or its immediate executeToolCall for agent_query. // searchWithRefinement would be called by a capability *inside* the orchestrator. // For this specific test, if we are mocking the orchestrator to give a direct final answer, // then searchWithRefinement (or other capabilities) wouldn't be called. // If the test intends to check a capability call, the orchestrator's LLM mock needs to output a capability call. // For now, assuming the orchestrator's first LLM call yields the final answer: expect(searchWithRefinement).not.toHaveBeenCalled(); const addSuggestionSpy = addSuggestion as Mock; expect(addSuggestionSpy).toHaveBeenCalledWith('session2', 'query with tool', expect.stringContaining('Final response from orchestrator.')); }); }); describe('createAgentState (original)', () => { it('should create a new agent state with the correct structure', () => { const result = ActualAgentModule.createAgentState('test_session', 'Find auth code'); expect(result).toEqual({ sessionId: 'test_session', query: 'Find auth code', steps: [], context: [], isComplete: false }); }); }); describe('generateAgentSystemPrompt (original)', () => { it('should include descriptions of all available tools', () => { const mockCapabilityDefinitionsForPrompt: AgentCapabilityDefinition[] = [ { name: 'capability_searchCodeSnippets', // Example description: 'Searches for code.', parameters_schema: z.object({ query: z.string().describe("The search query.") }) } // Add more mock capabilities if the test needs to verify their presence in the prompt ]; const prompt = ActualAgentModule.generateAgentSystemPrompt(mockCapabilityDefinitionsForPrompt); // Update assertions to match mockCapabilityDefinitionsForPrompt expect(prompt).toContain("capability_searchCodeSnippets"); expect(prompt).toContain("Searches for code."); expect(prompt).toContain(JSON.stringify(mockCapabilityDefinitionsForPrompt[0].parameters_schema._def || { description: mockCapabilityDefinitionsForPrompt[0].parameters_schema.description }, null, 2)); }); }); });