UNPKG

vibe-coder-mcp

Version:

Production-ready MCP server with complete agent integration, multi-transport support, and comprehensive development automation tools for AI-assisted workflows.

208 lines (207 loc) 14.2 kB
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import axios from 'axios'; import { processWithSequentialThinking, getNextThought, SEQUENTIAL_THINKING_SYSTEM_PROMPT } from './sequential-thinking.js'; import { ValidationError, ParsingError, FallbackError, ApiError } from '../utils/errors.js'; import logger from '../logger.js'; vi.mock('axios'); const mockedAxiosPost = vi.mocked(axios.post); vi.mock('../logger.js', () => ({ default: { info: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn(), } })); const mockConfig = { baseUrl: 'http://mock-api.test', apiKey: 'mock-key', geminiModel: 'mock-gemini', perplexityModel: 'mock-perplexity' }; const baseUserPrompt = 'Solve this problem'; const baseSystemPrompt = 'System prompt'; const expectedApiUrl = `${mockConfig.baseUrl}/chat/completions`; const createMockApiResponse = (content) => ({ data: { choices: [{ message: { content: JSON.stringify(content) } }] } }); const createMockApiError = (status, message) => { const error = new Error(message); error.isAxiosError = true; error.response = { status, data: null }; return error; }; describe('processWithSequentialThinking', () => { beforeEach(() => { vi.clearAllMocks(); mockedAxiosPost.mockClear(); vi.mocked(logger.info).mockClear(); vi.mocked(logger.debug).mockClear(); vi.mocked(logger.warn).mockClear(); vi.mocked(logger.error).mockClear(); }); afterEach(() => { vi.restoreAllMocks(); }); it('should complete successfully after multiple thoughts', async () => { mockedAxiosPost .mockResolvedValueOnce(createMockApiResponse({ thought: 'Step 1 analysis', next_thought_needed: true, thought_number: 1, total_thoughts: 3 })) .mockResolvedValueOnce(createMockApiResponse({ thought: 'Step 2 refinement', next_thought_needed: true, thought_number: 2, total_thoughts: 3 })) .mockResolvedValueOnce(createMockApiResponse({ thought: 'Final Answer', next_thought_needed: false, thought_number: 3, total_thoughts: 3 })); const result = await processWithSequentialThinking(baseUserPrompt, mockConfig, baseSystemPrompt); expect(result).toBe('Final Answer'); expect(mockedAxiosPost).toHaveBeenCalledTimes(3); expect(mockedAxiosPost.mock.calls[0][0]).toBe(expectedApiUrl); expect(mockedAxiosPost.mock.calls[0][1].messages[1].content).toContain('Provide your first thought:'); expect(mockedAxiosPost.mock.calls[1][0]).toBe(expectedApiUrl); expect(mockedAxiosPost.mock.calls[1][1].messages[1].content).toContain('Previous thoughts:\n[Thought 1/3]: Step 1 analysis'); expect(mockedAxiosPost.mock.calls[2][0]).toBe(expectedApiUrl); expect(mockedAxiosPost.mock.calls[2][1].messages[1].content).toContain('Previous thoughts:\n[Thought 1/3]: Step 1 analysis\n\n[Thought 2/3]: Step 2 refinement'); const combinedSystemPrompt = `${SEQUENTIAL_THINKING_SYSTEM_PROMPT}\n\n${baseSystemPrompt}`; expect(mockedAxiosPost.mock.calls[0][1].messages[0].content).toBe(combinedSystemPrompt); }); it('should retry on validation error (bad JSON) and succeed on retry', async () => { mockedAxiosPost .mockResolvedValueOnce({ data: { choices: [{ message: { content: '{ bad json' } }] } }) .mockResolvedValueOnce(createMockApiResponse({ thought: 'Successful Retry Answer', next_thought_needed: false, thought_number: 1, total_thoughts: 1 })); const result = await processWithSequentialThinking(baseUserPrompt, mockConfig); expect(result).toBe('Successful Retry Answer'); expect(mockedAxiosPost).toHaveBeenCalledTimes(2); expect(mockedAxiosPost.mock.calls[1][1].messages[1].content).toContain('Your previous attempt (attempt 1) failed with this error'); expect(mockedAxiosPost.mock.calls[1][1].messages[1].content).toContain('LLM output was not valid JSON'); expect(vi.mocked(logger.warn)).toHaveBeenCalledWith(expect.objectContaining({ err: expect.any(ParsingError), attempt: 1 }), expect.stringContaining('Attempt 1 to get thought')); expect(vi.mocked(logger.info)).toHaveBeenCalledWith(expect.stringContaining('Retrying thought generation (attempt 2)...')); }); it('should retry on validation error (schema mismatch) and succeed on retry', async () => { mockedAxiosPost .mockResolvedValueOnce({ data: { choices: [{ message: { content: JSON.stringify({ thought: 'Incomplete' }) } }] } }) .mockResolvedValueOnce(createMockApiResponse({ thought: 'Successful Retry Answer', next_thought_needed: false, thought_number: 1, total_thoughts: 1 })); const result = await processWithSequentialThinking(baseUserPrompt, mockConfig); expect(result).toBe('Successful Retry Answer'); expect(mockedAxiosPost).toHaveBeenCalledTimes(2); expect(mockedAxiosPost.mock.calls[1][1].messages[1].content).toContain('Your previous attempt (attempt 1) failed with this error'); expect(mockedAxiosPost.mock.calls[1][1].messages[1].content).toContain('Sequential thought validation failed'); expect(vi.mocked(logger.warn)).toHaveBeenCalledWith(expect.objectContaining({ err: expect.any(ValidationError), attempt: 1 }), expect.stringContaining('Attempt 1 to get thought')); expect(vi.mocked(logger.info)).toHaveBeenCalledWith(expect.stringContaining('Retrying thought generation (attempt 2)...')); }); it('should fail after exhausting retries on persistent validation errors', async () => { mockedAxiosPost.mockResolvedValue({ data: { choices: [{ message: { content: '{ bad json' } }] } }); await expect(processWithSequentialThinking(baseUserPrompt, mockConfig)) .rejects.toThrow(FallbackError); expect(mockedAxiosPost).toHaveBeenCalledTimes(2); expect(vi.mocked(logger.error)).toHaveBeenCalledWith(expect.objectContaining({ message: 'Persistent LLM formatting error after retries and cleaning. Throwing FallbackError.' })); expect(vi.mocked(logger.error)).toHaveBeenCalledWith(expect.objectContaining({ message: 'Sequential thinking aborted due to persistent LLM formatting error (FallbackError). Not retrying.' })); expect(vi.mocked(logger.warn)).toHaveBeenCalledTimes(1); }); it('should fail immediately on API error without retrying', async () => { const apiError = createMockApiError(401, 'Auth failed'); mockedAxiosPost.mockRejectedValueOnce(apiError); await expect(processWithSequentialThinking(baseUserPrompt, mockConfig)) .rejects.toThrow(ApiError); expect(mockedAxiosPost).toHaveBeenCalledTimes(1); expect(vi.mocked(logger.info)).not.toHaveBeenCalledWith(expect.stringContaining('Retrying')); expect(vi.mocked(logger.error)).toHaveBeenCalledWith(expect.objectContaining({ err: expect.any(ApiError) }), "API error occurred - not retrying"); }); it('getNextThought should successfully parse JSON wrapped in Markdown fences', async () => { const validThought = { thought: 'Valid thought inside fences', next_thought_needed: false, thought_number: 1, total_thoughts: 1 }; const fencedContent = `\`\`\`json\n${JSON.stringify(validThought, null, 2)}\n\`\`\``; mockedAxiosPost.mockResolvedValueOnce({ data: { choices: [{ message: { content: fencedContent } }] } }); const result = await getNextThought(baseUserPrompt, baseSystemPrompt, mockConfig, 1); expect(result).toEqual(validThought); expect(mockedAxiosPost).toHaveBeenCalledTimes(1); expect(vi.mocked(logger.debug)).toHaveBeenCalledWith(expect.objectContaining({ cleaned: JSON.stringify(validThought) }), "Stripped potential garbage/fences from LLM JSON response."); }); it('getNextThought should throw FallbackError on final ParsingError (bad JSON after cleaning)', async () => { const badJsonContent = '{ bad json'; const rawFencedContent = `\`\`\`json\n${badJsonContent}\n\`\`\``; mockedAxiosPost.mockResolvedValue({ data: { choices: [{ message: { content: rawFencedContent } }] } }); const expectedThoughtNumber = 1; await expect(getNextThought(baseUserPrompt, baseSystemPrompt, mockConfig, expectedThoughtNumber)) .rejects.toThrow(FallbackError); expect(mockedAxiosPost).toHaveBeenCalledTimes(2); expect(vi.mocked(logger.error)).toHaveBeenCalledWith(expect.objectContaining({ message: 'Persistent LLM formatting error after retries and cleaning. Throwing FallbackError.' })); try { await getNextThought(baseUserPrompt, baseSystemPrompt, mockConfig, expectedThoughtNumber); } catch (e) { expect(e).toBeInstanceOf(FallbackError); const fallbackError = e; expect(fallbackError.message).toContain('persistent LLM formatting error'); expect(fallbackError.rawContent).toBe(rawFencedContent); expect(fallbackError.originalError).toBeInstanceOf(ParsingError); expect(fallbackError.context?.cleanedContent).toBe(badJsonContent); } }); it('getNextThought should throw FallbackError on final ValidationError (schema mismatch after cleaning)', async () => { const incompleteJsonContent = JSON.stringify({ thought: 'Incomplete' }); const rawFencedContent = `\`\`\`json\n${incompleteJsonContent}\n\`\`\``; mockedAxiosPost.mockResolvedValue({ data: { choices: [{ message: { content: rawFencedContent } }] } }); const expectedThoughtNumber = 1; await expect(getNextThought(baseUserPrompt, baseSystemPrompt, mockConfig, expectedThoughtNumber)) .rejects.toThrow(FallbackError); expect(mockedAxiosPost).toHaveBeenCalledTimes(2); expect(vi.mocked(logger.error)).toHaveBeenCalledWith(expect.objectContaining({ message: 'Persistent LLM formatting error after retries and cleaning. Throwing FallbackError.' })); try { await getNextThought(baseUserPrompt, baseSystemPrompt, mockConfig, expectedThoughtNumber); } catch (e) { expect(e).toBeInstanceOf(FallbackError); const fallbackError = e; expect(fallbackError.message).toContain('persistent LLM formatting error'); expect(fallbackError.rawContent).toBe(rawFencedContent); expect(fallbackError.originalError).toBeInstanceOf(ValidationError); expect(fallbackError.context?.cleanedContent).toBe(incompleteJsonContent); } }); it('getNextThought should throw FallbackError when response is plain text (cleaning fails)', async () => { const plainTextResponse = "This is just plain text, not JSON."; mockedAxiosPost.mockResolvedValue({ data: { choices: [{ message: { content: plainTextResponse } }] } }); const expectedThoughtNumber = 1; await expect(getNextThought(baseUserPrompt, baseSystemPrompt, mockConfig, expectedThoughtNumber)) .rejects.toThrow(FallbackError); expect(mockedAxiosPost).toHaveBeenCalledTimes(2); expect(vi.mocked(logger.error)).toHaveBeenCalledWith(expect.objectContaining({ message: 'Persistent LLM formatting error after retries and cleaning. Throwing FallbackError.' })); try { await getNextThought(baseUserPrompt, baseSystemPrompt, mockConfig, expectedThoughtNumber); } catch (e) { expect(e).toBeInstanceOf(FallbackError); const fallbackError = e; expect(fallbackError.message).toContain('persistent LLM formatting error'); expect(fallbackError.rawContent).toBe(plainTextResponse); expect(fallbackError.originalError).toBeInstanceOf(ParsingError); expect(fallbackError.context?.cleanedContent).toBeUndefined(); } }); it('processWithSequentialThinking should abort and throw FallbackError when getNextThought throws FallbackError', async () => { const badJsonContent = '{ bad json for thought 2'; mockedAxiosPost .mockResolvedValueOnce(createMockApiResponse({ thought: 'Step 1 analysis', next_thought_needed: true, thought_number: 1, total_thoughts: 2 })) .mockResolvedValueOnce({ data: { choices: [{ message: { content: badJsonContent } }] } }) .mockResolvedValueOnce({ data: { choices: [{ message: { content: badJsonContent } }] } }); await expect(processWithSequentialThinking(baseUserPrompt, mockConfig)) .rejects.toThrow(FallbackError); expect(mockedAxiosPost).toHaveBeenCalledTimes(3); expect(vi.mocked(logger.error)).toHaveBeenCalledWith(expect.objectContaining({ message: 'Persistent LLM formatting error after retries and cleaning. Throwing FallbackError.' })); expect(vi.mocked(logger.error)).toHaveBeenCalledWith(expect.objectContaining({ message: 'Sequential thinking aborted due to persistent LLM formatting error (FallbackError). Not retrying.' })); expect(vi.mocked(logger.info)).not.toHaveBeenCalledWith(expect.stringContaining('Retrying thought generation')); }); it('should terminate and log error when MAX_SEQUENTIAL_THOUGHTS is reached', async () => { const MAX_THOUGHTS = 10; mockedAxiosPost.mockImplementation(async () => { const callCount = mockedAxiosPost.mock.calls.length; return createMockApiResponse({ thought: `Thought ${callCount}`, next_thought_needed: true, thought_number: callCount, total_thoughts: MAX_THOUGHTS + 5 }); }); const result = await processWithSequentialThinking(baseUserPrompt, mockConfig); expect(mockedAxiosPost).toHaveBeenCalledTimes(MAX_THOUGHTS); expect(result).toBe(`Thought ${MAX_THOUGHTS}`); expect(vi.mocked(logger.error)).toHaveBeenCalledWith(expect.objectContaining({ maxThoughts: MAX_THOUGHTS }), expect.stringContaining(`terminated after reaching the maximum limit of ${MAX_THOUGHTS} thoughts`)); }); });