Files
claude-task-master/tests/unit/ai-services-unified.test.js
Eyal Toledano 205a11e82c fix(config): Improve config-manager.js for MCP server integration
- Fixed MCP server initialization warnings by refactoring config-manager.js to handle missing project roots silently during startup

- Added project root tracking (loadedConfigRoot) to improve config caching and prevent unnecessary reloads

- Modified _loadAndValidateConfig to return defaults without warnings when no explicitRoot provided

- Improved getConfig to only update cache when loading config with a specific project root

- Ensured warning messages still appear when explicitly specified roots have missing/invalid configs

- Prevented console output during MCP startup that was causing JSON parsing errors

- Verified parse_prd and other MCP tools still work correctly with the new config loading approach.

- Replaces test perplexity api key in mcp.json and rolls it. It's invalid now.
2025-04-24 13:34:51 -04:00

684 lines
22 KiB
JavaScript

import { jest } from '@jest/globals';
// Mock ai-client-factory
const mockGetClient = jest.fn();
jest.unstable_mockModule('../../scripts/modules/ai-client-factory.js', () => ({
getClient: mockGetClient
}));
// Mock AI SDK Core
const mockGenerateText = jest.fn();
jest.unstable_mockModule('ai', () => ({
generateText: mockGenerateText
// Mock other AI SDK functions like streamText as needed
}));
// Mock utils logger
const mockLog = jest.fn();
jest.unstable_mockModule('../../scripts/modules/utils.js', () => ({
log: mockLog
// Keep other exports if utils has more, otherwise just log
}));
// Import the module to test (AFTER mocks)
const { generateTextService } = await import(
'../../scripts/modules/ai-services-unified.js'
);
describe('Unified AI Services', () => {
beforeEach(() => {
// Clear mocks before each test
mockGetClient.mockClear();
mockGenerateText.mockClear();
mockLog.mockClear(); // Clear log mock
});
describe('generateTextService', () => {
test('should get client and call generateText with correct parameters', async () => {
const mockClient = { type: 'mock-client' };
mockGetClient.mockResolvedValue(mockClient);
mockGenerateText.mockResolvedValue({ text: 'Mock response' });
const serviceParams = {
role: 'main',
session: { env: { SOME_KEY: 'value' } }, // Example session
overrideOptions: { provider: 'override' }, // Example overrides
prompt: 'Test prompt',
// Other generateText options like maxTokens, temperature etc.
maxTokens: 100
};
const result = await generateTextService(serviceParams);
// Verify getClient call
expect(mockGetClient).toHaveBeenCalledTimes(1);
expect(mockGetClient).toHaveBeenCalledWith(
serviceParams.role,
serviceParams.session,
serviceParams.overrideOptions
);
// Verify generateText call
expect(mockGenerateText).toHaveBeenCalledTimes(1);
expect(mockGenerateText).toHaveBeenCalledWith({
model: mockClient, // Ensure the correct client is passed
prompt: serviceParams.prompt,
maxTokens: serviceParams.maxTokens
// Add other expected generateText options here
});
// Verify result
expect(result).toEqual({ text: 'Mock response' });
});
test('should retry generateText on specific errors and succeed', async () => {
const mockClient = { type: 'mock-client' };
mockGetClient.mockResolvedValue(mockClient);
// Simulate failure then success
mockGenerateText
.mockRejectedValueOnce(new Error('Rate limit exceeded')) // Retryable error
.mockRejectedValueOnce(new Error('Service temporarily unavailable')) // Retryable error
.mockResolvedValue({ text: 'Success after retries' });
const serviceParams = { role: 'main', prompt: 'Retry test' };
// Use jest.advanceTimersByTime for delays if implemented
// jest.useFakeTimers();
const result = await generateTextService(serviceParams);
expect(mockGetClient).toHaveBeenCalledTimes(1); // Client fetched once
expect(mockGenerateText).toHaveBeenCalledTimes(3); // Initial call + 2 retries
expect(result).toEqual({ text: 'Success after retries' });
// jest.useRealTimers(); // Restore real timers if faked
});
test('should fail after exhausting retries', async () => {
jest.setTimeout(15000); // Increase timeout further
const mockClient = { type: 'mock-client' };
mockGetClient.mockResolvedValue(mockClient);
// Simulate persistent failure
mockGenerateText.mockRejectedValue(new Error('Rate limit exceeded'));
const serviceParams = { role: 'main', prompt: 'Retry failure test' };
await expect(generateTextService(serviceParams)).rejects.toThrow(
'Rate limit exceeded'
);
// Sequence is main -> fallback -> research. It tries all client gets even if main fails.
expect(mockGetClient).toHaveBeenCalledTimes(3);
expect(mockGenerateText).toHaveBeenCalledTimes(3); // Initial call + max retries (assuming 2 retries)
});
test('should not retry on non-retryable errors', async () => {
const mockMainClient = { type: 'mock-main' };
const mockFallbackClient = { type: 'mock-fallback' };
const mockResearchClient = { type: 'mock-research' };
// Simulate a non-retryable error
const nonRetryableError = new Error('Invalid request parameters');
mockGenerateText.mockRejectedValueOnce(nonRetryableError); // Fail only once
const serviceParams = { role: 'main', prompt: 'No retry test' };
// Sequence is main -> fallback -> research. Even if main fails non-retryably,
// it will still try to get clients for fallback and research before throwing.
// Let's assume getClient succeeds for all three.
mockGetClient
.mockResolvedValueOnce(mockMainClient)
.mockResolvedValueOnce(mockFallbackClient)
.mockResolvedValueOnce(mockResearchClient);
await expect(generateTextService(serviceParams)).rejects.toThrow(
'Invalid request parameters'
);
expect(mockGetClient).toHaveBeenCalledTimes(3); // Tries main, fallback, research
expect(mockGenerateText).toHaveBeenCalledTimes(1); // Called only once for main
});
test('should log service entry, client info, attempts, and success', async () => {
const mockClient = {
type: 'mock-client',
provider: 'test-provider',
model: 'test-model'
}; // Add mock details
mockGetClient.mockResolvedValue(mockClient);
mockGenerateText.mockResolvedValue({ text: 'Success' });
const serviceParams = { role: 'main', prompt: 'Log test' };
await generateTextService(serviceParams);
// Check logs (in order)
expect(mockLog).toHaveBeenNthCalledWith(
1,
'info',
'generateTextService called',
{ role: 'main' }
);
expect(mockLog).toHaveBeenNthCalledWith(
2,
'info',
'New AI service call with role: main'
);
expect(mockLog).toHaveBeenNthCalledWith(
3,
'info',
'Retrieved AI client',
{
provider: mockClient.provider,
model: mockClient.model
}
);
expect(mockLog).toHaveBeenNthCalledWith(
4,
expect.stringMatching(
/Attempt 1\/3 calling generateText for role main/i
)
);
expect(mockLog).toHaveBeenNthCalledWith(
5,
'info',
'generateText succeeded for role main on attempt 1' // Original success log from helper
);
expect(mockLog).toHaveBeenNthCalledWith(
6,
'info',
'generateTextService succeeded using role: main' // Final success log from service
);
// Ensure no failure/retry logs were called
expect(mockLog).not.toHaveBeenCalledWith(
'warn',
expect.stringContaining('failed')
);
expect(mockLog).not.toHaveBeenCalledWith(
'info',
expect.stringContaining('Retrying')
);
});
test('should log retry attempts and eventual failure', async () => {
jest.setTimeout(15000); // Increase timeout further
const mockClient = {
type: 'mock-client',
provider: 'test-provider',
model: 'test-model'
};
const mockFallbackClient = { type: 'mock-fallback' };
const mockResearchClient = { type: 'mock-research' };
mockGetClient
.mockResolvedValueOnce(mockClient)
.mockResolvedValueOnce(mockFallbackClient)
.mockResolvedValueOnce(mockResearchClient);
mockGenerateText.mockRejectedValue(new Error('Rate limit'));
const serviceParams = { role: 'main', prompt: 'Log retry failure' };
await expect(generateTextService(serviceParams)).rejects.toThrow(
'Rate limit'
);
// Check logs
expect(mockLog).toHaveBeenCalledWith(
'info',
'generateTextService called',
{ role: 'main' }
);
expect(mockLog).toHaveBeenCalledWith(
'info',
'New AI service call with role: main'
);
expect(mockLog).toHaveBeenCalledWith('info', 'Retrieved AI client', {
provider: mockClient.provider,
model: mockClient.model
});
expect(mockLog).toHaveBeenCalledWith(
expect.stringMatching(
/Attempt 1\/3 calling generateText for role main/i
)
);
expect(mockLog).toHaveBeenCalledWith(
'warn',
'Attempt 1 failed for role main: Rate limit'
);
expect(mockLog).toHaveBeenCalledWith(
'info',
'Retryable error detected. Retrying in 1s...'
);
expect(mockLog).toHaveBeenCalledWith(
expect.stringMatching(
/Attempt 2\/3 calling generateText for role main/i
)
);
expect(mockLog).toHaveBeenCalledWith(
'warn',
'Attempt 2 failed for role main: Rate limit'
);
expect(mockLog).toHaveBeenCalledWith(
'info',
'Retryable error detected. Retrying in 2s...'
);
expect(mockLog).toHaveBeenCalledWith(
expect.stringMatching(
/Attempt 3\/3 calling generateText for role main/i
)
);
expect(mockLog).toHaveBeenCalledWith(
'warn',
'Attempt 3 failed for role main: Rate limit'
);
expect(mockLog).toHaveBeenCalledWith(
'error',
'Non-retryable error or max retries reached for role main (generateText).'
);
// Check subsequent fallback attempts (which also fail)
expect(mockLog).toHaveBeenCalledWith(
'info',
'New AI service call with role: fallback'
);
expect(mockLog).toHaveBeenCalledWith(
'error',
'Service call failed for role fallback: Rate limit'
);
expect(mockLog).toHaveBeenCalledWith(
'info',
'New AI service call with role: research'
);
expect(mockLog).toHaveBeenCalledWith(
'error',
'Service call failed for role research: Rate limit'
);
expect(mockLog).toHaveBeenCalledWith(
'error',
'All roles in the sequence [main,fallback,research] failed.'
);
});
test('should use fallback client after primary fails, then succeed', async () => {
const mockMainClient = { type: 'mock-client', provider: 'main-provider' };
const mockFallbackClient = {
type: 'mock-client',
provider: 'fallback-provider'
};
// Setup calls: main client fails, fallback succeeds
mockGetClient
.mockResolvedValueOnce(mockMainClient) // First call for 'main' role
.mockResolvedValueOnce(mockFallbackClient); // Second call for 'fallback' role
mockGenerateText
.mockRejectedValueOnce(new Error('Main Rate limit')) // Main attempt 1 fail
.mockRejectedValueOnce(new Error('Main Rate limit')) // Main attempt 2 fail
.mockRejectedValueOnce(new Error('Main Rate limit')) // Main attempt 3 fail
.mockResolvedValue({ text: 'Fallback success' }); // Fallback attempt 1 success
const serviceParams = { role: 'main', prompt: 'Fallback test' };
const result = await generateTextService(serviceParams);
// Check calls
expect(mockGetClient).toHaveBeenCalledTimes(2);
expect(mockGetClient).toHaveBeenNthCalledWith(
1,
'main',
undefined,
undefined
);
expect(mockGetClient).toHaveBeenNthCalledWith(
2,
'fallback',
undefined,
undefined
);
expect(mockGenerateText).toHaveBeenCalledTimes(4); // 3 main fails, 1 fallback success
expect(mockGenerateText).toHaveBeenNthCalledWith(4, {
model: mockFallbackClient,
prompt: 'Fallback test'
});
expect(result).toEqual({ text: 'Fallback success' });
// Check logs for fallback attempt
expect(mockLog).toHaveBeenCalledWith(
'error',
'Service call failed for role main: Main Rate limit'
);
expect(mockLog).toHaveBeenCalledWith(
'warn',
'Retries exhausted or non-retryable error for role main, trying next role in sequence...'
);
expect(mockLog).toHaveBeenCalledWith(
'info',
'New AI service call with role: fallback'
);
expect(mockLog).toHaveBeenCalledWith(
'info',
'generateTextService succeeded using role: fallback'
);
});
test('should use research client after primary and fallback fail, then succeed', async () => {
const mockMainClient = { type: 'mock-client', provider: 'main-provider' };
const mockFallbackClient = {
type: 'mock-client',
provider: 'fallback-provider'
};
const mockResearchClient = {
type: 'mock-client',
provider: 'research-provider'
};
// Setup calls: main fails, fallback fails, research succeeds
mockGetClient
.mockResolvedValueOnce(mockMainClient)
.mockResolvedValueOnce(mockFallbackClient)
.mockResolvedValueOnce(mockResearchClient);
mockGenerateText
.mockRejectedValueOnce(new Error('Main fail 1')) // Main 1
.mockRejectedValueOnce(new Error('Main fail 2')) // Main 2
.mockRejectedValueOnce(new Error('Main fail 3')) // Main 3
.mockRejectedValueOnce(new Error('Fallback fail 1')) // Fallback 1
.mockRejectedValueOnce(new Error('Fallback fail 2')) // Fallback 2
.mockRejectedValueOnce(new Error('Fallback fail 3')) // Fallback 3
.mockResolvedValue({ text: 'Research success' }); // Research 1 success
const serviceParams = { role: 'main', prompt: 'Research fallback test' };
const result = await generateTextService(serviceParams);
// Check calls
expect(mockGetClient).toHaveBeenCalledTimes(3);
expect(mockGetClient).toHaveBeenNthCalledWith(
1,
'main',
undefined,
undefined
);
expect(mockGetClient).toHaveBeenNthCalledWith(
2,
'fallback',
undefined,
undefined
);
expect(mockGetClient).toHaveBeenNthCalledWith(
3,
'research',
undefined,
undefined
);
expect(mockGenerateText).toHaveBeenCalledTimes(7); // 3 main, 3 fallback, 1 research
expect(mockGenerateText).toHaveBeenNthCalledWith(7, {
model: mockResearchClient,
prompt: 'Research fallback test'
});
expect(result).toEqual({ text: 'Research success' });
// Check logs for fallback attempt
expect(mockLog).toHaveBeenCalledWith(
'error',
'Service call failed for role main: Main fail 3' // Error from last attempt for role
);
expect(mockLog).toHaveBeenCalledWith(
'warn',
'Retries exhausted or non-retryable error for role main, trying next role in sequence...'
);
expect(mockLog).toHaveBeenCalledWith(
'error',
'Service call failed for role fallback: Fallback fail 3' // Error from last attempt for role
);
expect(mockLog).toHaveBeenCalledWith(
'warn',
'Retries exhausted or non-retryable error for role fallback, trying next role in sequence...'
);
expect(mockLog).toHaveBeenCalledWith(
'info',
'New AI service call with role: research'
);
expect(mockLog).toHaveBeenCalledWith(
'info',
'generateTextService succeeded using role: research'
);
});
test('should fail if primary, fallback, and research clients all fail', async () => {
const mockMainClient = { type: 'mock-client', provider: 'main' };
const mockFallbackClient = { type: 'mock-client', provider: 'fallback' };
const mockResearchClient = { type: 'mock-client', provider: 'research' };
// Setup calls: all fail
mockGetClient
.mockResolvedValueOnce(mockMainClient)
.mockResolvedValueOnce(mockFallbackClient)
.mockResolvedValueOnce(mockResearchClient);
mockGenerateText
.mockRejectedValueOnce(new Error('Main fail 1'))
.mockRejectedValueOnce(new Error('Main fail 2'))
.mockRejectedValueOnce(new Error('Main fail 3'))
.mockRejectedValueOnce(new Error('Fallback fail 1'))
.mockRejectedValueOnce(new Error('Fallback fail 2'))
.mockRejectedValueOnce(new Error('Fallback fail 3'))
.mockRejectedValueOnce(new Error('Research fail 1'))
.mockRejectedValueOnce(new Error('Research fail 2'))
.mockRejectedValueOnce(new Error('Research fail 3')); // Last error
const serviceParams = { role: 'main', prompt: 'All fail test' };
await expect(generateTextService(serviceParams)).rejects.toThrow(
'Research fail 3' // Should throw the error from the LAST failed attempt
);
// Check calls
expect(mockGetClient).toHaveBeenCalledTimes(3);
expect(mockGenerateText).toHaveBeenCalledTimes(9); // 3 for each role
expect(mockLog).toHaveBeenCalledWith(
'error',
'All roles in the sequence [main,fallback,research] failed.'
);
});
test('should handle error getting fallback client', async () => {
const mockMainClient = { type: 'mock-client', provider: 'main' };
// Setup calls: main fails, getting fallback client fails, research succeeds (to test sequence)
const mockResearchClient = { type: 'mock-client', provider: 'research' };
mockGetClient
.mockResolvedValueOnce(mockMainClient)
.mockRejectedValueOnce(new Error('Cannot get fallback client'))
.mockResolvedValueOnce(mockResearchClient);
mockGenerateText
.mockRejectedValueOnce(new Error('Main fail 1'))
.mockRejectedValueOnce(new Error('Main fail 2'))
.mockRejectedValueOnce(new Error('Main fail 3')) // Main fails 3 times
.mockResolvedValue({ text: 'Research success' }); // Research succeeds on its 1st attempt
const serviceParams = { role: 'main', prompt: 'Fallback client error' };
// Should eventually succeed with research after main+fallback fail
const result = await generateTextService(serviceParams);
expect(result).toEqual({ text: 'Research success' });
expect(mockGetClient).toHaveBeenCalledTimes(3); // Tries main, fallback (fails), research
expect(mockGenerateText).toHaveBeenCalledTimes(4); // 3 main attempts, 1 research attempt
expect(mockLog).toHaveBeenCalledWith(
'error',
'Service call failed for role fallback: Cannot get fallback client'
);
expect(mockLog).toHaveBeenCalledWith(
'warn',
'Could not get client for role fallback, trying next role in sequence...'
);
expect(mockLog).toHaveBeenCalledWith(
'info',
'New AI service call with role: research'
);
expect(mockLog).toHaveBeenCalledWith(
'info',
expect.stringContaining(
'generateTextService succeeded using role: research'
)
);
});
test('should try research after fallback fails if initial role is fallback', async () => {
const mockFallbackClient = { type: 'mock-client', provider: 'fallback' };
const mockResearchClient = { type: 'mock-client', provider: 'research' };
mockGetClient
.mockResolvedValueOnce(mockFallbackClient)
.mockResolvedValueOnce(mockResearchClient);
mockGenerateText
.mockRejectedValueOnce(new Error('Fallback fail 1')) // Fallback 1
.mockRejectedValueOnce(new Error('Fallback fail 2')) // Fallback 2
.mockRejectedValueOnce(new Error('Fallback fail 3')) // Fallback 3
.mockResolvedValue({ text: 'Research success' }); // Research 1
const serviceParams = { role: 'fallback', prompt: 'Start with fallback' };
const result = await generateTextService(serviceParams);
expect(mockGetClient).toHaveBeenCalledTimes(2); // Fallback, Research
expect(mockGetClient).toHaveBeenNthCalledWith(
1,
'fallback',
undefined,
undefined
);
expect(mockGetClient).toHaveBeenNthCalledWith(
2,
'research',
undefined,
undefined
);
expect(mockGenerateText).toHaveBeenCalledTimes(4); // 3 fallback, 1 research
expect(result).toEqual({ text: 'Research success' });
// Check logs for sequence
expect(mockLog).toHaveBeenCalledWith(
'info',
'New AI service call with role: fallback'
);
expect(mockLog).toHaveBeenCalledWith(
'error',
'Service call failed for role fallback: Fallback fail 3'
);
expect(mockLog).toHaveBeenCalledWith(
'warn',
expect.stringContaining(
'Retries exhausted or non-retryable error for role fallback'
)
);
expect(mockLog).toHaveBeenCalledWith(
'info',
'New AI service call with role: research'
);
expect(mockLog).toHaveBeenCalledWith(
'info',
expect.stringContaining(
'generateTextService succeeded using role: research'
)
);
});
test('should try fallback after research fails if initial role is research', async () => {
const mockResearchClient = { type: 'mock-client', provider: 'research' };
const mockFallbackClient = { type: 'mock-client', provider: 'fallback' };
mockGetClient
.mockResolvedValueOnce(mockResearchClient)
.mockResolvedValueOnce(mockFallbackClient);
mockGenerateText
.mockRejectedValueOnce(new Error('Research fail 1')) // Research 1
.mockRejectedValueOnce(new Error('Research fail 2')) // Research 2
.mockRejectedValueOnce(new Error('Research fail 3')) // Research 3
.mockResolvedValue({ text: 'Fallback success' }); // Fallback 1
const serviceParams = { role: 'research', prompt: 'Start with research' };
const result = await generateTextService(serviceParams);
expect(mockGetClient).toHaveBeenCalledTimes(2); // Research, Fallback
expect(mockGetClient).toHaveBeenNthCalledWith(
1,
'research',
undefined,
undefined
);
expect(mockGetClient).toHaveBeenNthCalledWith(
2,
'fallback',
undefined,
undefined
);
expect(mockGenerateText).toHaveBeenCalledTimes(4); // 3 research, 1 fallback
expect(result).toEqual({ text: 'Fallback success' });
// Check logs for sequence
expect(mockLog).toHaveBeenCalledWith(
'info',
'New AI service call with role: research'
);
expect(mockLog).toHaveBeenCalledWith(
'error',
'Service call failed for role research: Research fail 3'
);
expect(mockLog).toHaveBeenCalledWith(
'warn',
expect.stringContaining(
'Retries exhausted or non-retryable error for role research'
)
);
expect(mockLog).toHaveBeenCalledWith(
'info',
'New AI service call with role: fallback'
);
expect(mockLog).toHaveBeenCalledWith(
'info',
expect.stringContaining(
'generateTextService succeeded using role: fallback'
)
);
});
test('should use default sequence and log warning for unknown initial role', async () => {
const mockMainClient = { type: 'mock-client', provider: 'main' };
const mockFallbackClient = { type: 'mock-client', provider: 'fallback' };
mockGetClient
.mockResolvedValueOnce(mockMainClient)
.mockResolvedValueOnce(mockFallbackClient);
mockGenerateText
.mockRejectedValueOnce(new Error('Main fail 1')) // Main 1
.mockRejectedValueOnce(new Error('Main fail 2')) // Main 2
.mockRejectedValueOnce(new Error('Main fail 3')) // Main 3
.mockResolvedValue({ text: 'Fallback success' }); // Fallback 1
const serviceParams = {
role: 'invalid-role',
prompt: 'Unknown role test'
};
const result = await generateTextService(serviceParams);
// Check warning log for unknown role
expect(mockLog).toHaveBeenCalledWith(
'warn',
'Unknown initial role: invalid-role. Defaulting to main -> fallback -> research sequence.'
);
// Check it followed the default main -> fallback sequence
expect(mockGetClient).toHaveBeenCalledTimes(2); // Main, Fallback
expect(mockGetClient).toHaveBeenNthCalledWith(
1,
'main',
undefined,
undefined
);
expect(mockGetClient).toHaveBeenNthCalledWith(
2,
'fallback',
undefined,
undefined
);
expect(mockGenerateText).toHaveBeenCalledTimes(4); // 3 main, 1 fallback
expect(result).toEqual({ text: 'Fallback success' });
});
});
});