import { describe, it, expect, jest, beforeEach } from '@jest/globals'; // Mock OpenAI BEFORE importing the provider const mockCreate = jest.fn(); jest.mock('openai', () => { const MockOpenAI = jest.fn().mockImplementation(() => ({ chat: { completions: { create: mockCreate, }, }, })); return { __esModule: true, default: MockOpenAI, }; }); // Mock config manager and logger jest.mock('../src/config/config'); jest.mock('../src/utils/logger'); import { duckVoteTool } from '../src/tools/duck-vote'; import { ProviderManager } from '../src/providers/manager'; import { ConfigManager } from '../src/config/config'; describe('duckVoteTool', () => { let mockProviderManager: ProviderManager; let mockConfigManager: jest.Mocked; beforeEach(() => { jest.clearAllMocks(); mockConfigManager = { getConfig: jest.fn().mockReturnValue({ providers: { openai: { api_key: 'key1', base_url: 'https://api.openai.com/v1', default_model: 'gpt-4', nickname: 'GPT-4', models: ['gpt-4'], }, google: { api_key: 'key2', base_url: 'https://api.gemini.com/v1', default_model: 'gemini-pro', nickname: 'Gemini', models: ['gemini-pro'], }, }, default_provider: 'openai', enable_failover: true, default_temperature: 0.7, }), } as any; mockProviderManager = new ProviderManager(mockConfigManager); // Override the client method on all providers const provider1 = mockProviderManager.getProvider('openai'); const provider2 = mockProviderManager.getProvider('google'); provider1['client'].chat.completions.create = mockCreate; provider2['client'].chat.completions.create = mockCreate; }); it('should throw error when question is missing', async () => { await expect( duckVoteTool(mockProviderManager, { options: ['A', 'B'] }) ).rejects.toThrow('Question is required'); }); it('should throw error when options are missing', async () => { await expect( duckVoteTool(mockProviderManager, { question: 'Test?' }) ).rejects.toThrow('At least 2 options are required'); }); it('should throw error when less than 2 options', async () => { await expect( duckVoteTool(mockProviderManager, { question: 'Test?', options: ['A'] }) ).rejects.toThrow('At least 2 options are required'); }); it('should throw error when more than 10 options', async () => { const options = Array.from({ length: 11 }, (_, i) => `Option ${i + 1}`); await expect( duckVoteTool(mockProviderManager, { question: 'Test?', options }) ).rejects.toThrow('Maximum 10 options allowed'); }); it('should conduct vote with all providers', async () => { // Mock responses with valid JSON votes mockCreate .mockResolvedValueOnce({ choices: [{ message: { content: '{"choice": "Option A", "confidence": 85, "reasoning": "Best for performance"}' }, finish_reason: 'stop', }], usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 }, model: 'gpt-4', }) .mockResolvedValueOnce({ choices: [{ message: { content: '{"choice": "Option A", "confidence": 75, "reasoning": "Scalable solution"}' }, finish_reason: 'stop', }], usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 }, model: 'gemini-pro', }); const result = await duckVoteTool(mockProviderManager, { question: 'Best approach?', options: ['Option A', 'Option B'], }); expect(result.content).toHaveLength(2); expect(result.content[0].type).toBe('text'); expect(result.content[1].type).toBe('text'); expect(() => JSON.parse(result.content[1].text)).not.toThrow(); const text = result.content[0].text; expect(text).toContain('Vote Results'); expect(text).toContain('Best approach?'); expect(text).toContain('Option A'); expect(text).toContain('Winner'); expect(text).toContain('unanimous'); }); it('should handle split votes', async () => { mockCreate .mockResolvedValueOnce({ choices: [{ message: { content: '{"choice": "Option A", "confidence": 60, "reasoning": "Good choice"}' }, finish_reason: 'stop', }], usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 }, model: 'gpt-4', }) .mockResolvedValueOnce({ choices: [{ message: { content: '{"choice": "Option B", "confidence": 90, "reasoning": "Better choice"}' }, finish_reason: 'stop', }], usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 }, model: 'gemini-pro', }); const result = await duckVoteTool(mockProviderManager, { question: 'Which option?', options: ['Option A', 'Option B'], }); const text = result.content[0].text; expect(text).toContain('split'); expect(text).toContain('tie-breaker'); expect(text).toContain('Option B'); // Higher confidence wins }); it('should use specific voters when provided', async () => { mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: '{"choice": "Option A", "confidence": 80, "reasoning": "Only choice"}' }, finish_reason: 'stop', }], usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 }, model: 'gpt-4', }); const result = await duckVoteTool(mockProviderManager, { question: 'Test?', options: ['Option A', 'Option B'], voters: ['openai'], }); expect(mockCreate).toHaveBeenCalledTimes(1); expect(result.content[0].text).toContain('1/1 valid votes'); }); it('should handle invalid JSON responses gracefully', async () => { mockCreate .mockResolvedValueOnce({ choices: [{ message: { content: 'I think Option A is clearly the best because of its simplicity.' }, finish_reason: 'stop', }], usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 }, model: 'gpt-4', }) .mockResolvedValueOnce({ choices: [{ message: { content: '{"choice": "Option B", "confidence": 70}' }, finish_reason: 'stop', }], usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 }, model: 'gemini-pro', }); const result = await duckVoteTool(mockProviderManager, { question: 'Test?', options: ['Option A', 'Option B'], }); // Should still work - fallback parsing should find "Option A" const text = result.content[0].text; expect(text).toContain('2/2 valid votes'); }); it('should handle provider errors gracefully', async () => { mockCreate .mockResolvedValueOnce({ choices: [{ message: { content: '{"choice": "Option A", "confidence": 85}' }, finish_reason: 'stop', }], usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 }, model: 'gpt-4', }) .mockRejectedValueOnce(new Error('API Error')); const result = await duckVoteTool(mockProviderManager, { question: 'Test?', options: ['Option A', 'Option B'], }); // One valid vote, one error const text = result.content[0].text; expect(text).toContain('Option A'); expect(text).toContain('Invalid vote'); // Error response should be marked invalid }); it('should work without reasoning requirement', async () => { mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: '{"choice": "Option A", "confidence": 80}' }, finish_reason: 'stop', }], usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 }, model: 'gpt-4', }); const result = await duckVoteTool(mockProviderManager, { question: 'Test?', options: ['Option A', 'Option B'], voters: ['openai'], require_reasoning: false, }); expect(result.content[0].text).toContain('Option A'); }); it('should throw error when no voters available', async () => { // Mock getProviderNames to return empty array const originalGetProviderNames = mockProviderManager.getProviderNames; mockProviderManager.getProviderNames = jest.fn().mockReturnValue([]); await expect( duckVoteTool(mockProviderManager, { question: 'Test?', options: ['Option A', 'Option B'], }) ).rejects.toThrow('No voters available'); // Restore original mockProviderManager.getProviderNames = originalGetProviderNames; }); it('should use compareDucksWithProgress when progress is provided', async () => { const mockProgress = { enabled: true, report: jest.fn<() => Promise>().mockResolvedValue(undefined), }; mockCreate .mockResolvedValueOnce({ choices: [{ message: { content: '{"choice": "Option A", "confidence": 80, "reasoning": "Good"}' }, finish_reason: 'stop', }], usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 }, model: 'gpt-4', }) .mockResolvedValueOnce({ choices: [{ message: { content: '{"choice": "Option A", "confidence": 75, "reasoning": "Also good"}' }, finish_reason: 'stop', }], usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 }, model: 'gemini-pro', }); await duckVoteTool(mockProviderManager, { question: 'Best approach?', options: ['Option A', 'Option B'], }, mockProgress); // 2 voters = 2 progress reports expect(mockProgress.report).toHaveBeenCalledTimes(2); expect(mockProgress.report).toHaveBeenNthCalledWith(1, 1, 2, expect.stringContaining('voted')); expect(mockProgress.report).toHaveBeenNthCalledWith(2, 2, 2, expect.stringContaining('voted')); }); it('should use compareDucks when no progress is provided', async () => { mockCreate .mockResolvedValueOnce({ choices: [{ message: { content: '{"choice": "Option A", "confidence": 80}' }, finish_reason: 'stop', }], usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 }, model: 'gpt-4', }) .mockResolvedValueOnce({ choices: [{ message: { content: '{"choice": "Option A", "confidence": 75}' }, finish_reason: 'stop', }], usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 }, model: 'gemini-pro', }); // Spy on compareDucks to ensure it's called (not compareDucksWithProgress) const compareDucksSpy = jest.spyOn(mockProviderManager, 'compareDucks'); const compareDucksWithProgressSpy = jest.spyOn(mockProviderManager, 'compareDucksWithProgress'); await duckVoteTool(mockProviderManager, { question: 'Best approach?', options: ['Option A', 'Option B'], }); expect(compareDucksSpy).toHaveBeenCalled(); expect(compareDucksWithProgressSpy).not.toHaveBeenCalled(); compareDucksSpy.mockRestore(); compareDucksWithProgressSpy.mockRestore(); }); it('should handle case when no valid votes result in no winner', async () => { // Both responses don't mention any valid option mockCreate .mockResolvedValueOnce({ choices: [{ message: { content: 'I cannot decide between these options.' }, finish_reason: 'stop', }], usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 }, model: 'gpt-4', }) .mockResolvedValueOnce({ choices: [{ message: { content: 'These options are not comparable.' }, finish_reason: 'stop', }], usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 }, model: 'gemini-pro', }); const result = await duckVoteTool(mockProviderManager, { question: 'Test?', options: ['Option A', 'Option B'], }); const text = result.content[0].text; expect(text).toContain('No valid votes'); expect(text).toContain('0/2 valid votes'); }); });