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
JavaScript
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`));
});
});