import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { ClaudeProvider } from '@/providers/claude-provider.js'; import * as sdk from '@anthropic-ai/claude-agent-sdk'; import { collectAsyncGenerator } from '../../utils/helpers.js'; vi.mock('@anthropic-ai/claude-agent-sdk'); describe('claude-provider.ts', () => { let provider: ClaudeProvider; beforeEach(() => { vi.clearAllMocks(); provider = new ClaudeProvider(); delete process.env.ANTHROPIC_API_KEY; delete process.env.ANTHROPIC_BASE_URL; delete process.env.ANTHROPIC_AUTH_TOKEN; }); describe('getName', () => { it("should return 'claude' as provider name", () => { expect(provider.getName()).toBe('claude'); }); }); describe('executeQuery', () => { it('should execute simple text query', async () => { const mockMessages = [ { type: 'text', text: 'Response 1' }, { type: 'text', text: 'Response 2' }, ]; vi.mocked(sdk.query).mockReturnValue( (async function* () { for (const msg of mockMessages) { yield msg; } })() ); const generator = provider.executeQuery({ prompt: 'Hello', model: 'claude-opus-4-5-20251101', cwd: '/test', }); const results = await collectAsyncGenerator(generator); expect(results).toHaveLength(2); expect(results[0]).toEqual({ type: 'text', text: 'Response 1' }); expect(results[1]).toEqual({ type: 'text', text: 'Response 2' }); }); it('should pass correct options to SDK', async () => { vi.mocked(sdk.query).mockReturnValue( (async function* () { yield { type: 'text', text: 'test' }; })() ); const generator = provider.executeQuery({ prompt: 'Test prompt', model: 'claude-opus-4-5-20251101', cwd: '/test/dir', systemPrompt: 'You are helpful', maxTurns: 10, allowedTools: ['Read', 'Write'], }); await collectAsyncGenerator(generator); expect(sdk.query).toHaveBeenCalledWith({ prompt: 'Test prompt', options: expect.objectContaining({ model: 'claude-opus-4-5-20251101', systemPrompt: 'You are helpful', maxTurns: 10, cwd: '/test/dir', allowedTools: ['Read', 'Write'], permissionMode: 'bypassPermissions', allowDangerouslySkipPermissions: true, }), }); }); it('should not include allowedTools when not specified (caller decides via sdk-options)', async () => { vi.mocked(sdk.query).mockReturnValue( (async function* () { yield { type: 'text', text: 'test' }; })() ); const generator = provider.executeQuery({ prompt: 'Test', model: 'claude-opus-4-5-20251101', cwd: '/test', }); await collectAsyncGenerator(generator); expect(sdk.query).toHaveBeenCalledWith({ prompt: 'Test', options: expect.not.objectContaining({ allowedTools: expect.anything(), }), }); }); it('should pass abortController if provided', async () => { vi.mocked(sdk.query).mockReturnValue( (async function* () { yield { type: 'text', text: 'test' }; })() ); const abortController = new AbortController(); const generator = provider.executeQuery({ prompt: 'Test', model: 'claude-opus-4-5-20251101', cwd: '/test', abortController, }); await collectAsyncGenerator(generator); expect(sdk.query).toHaveBeenCalledWith({ prompt: 'Test', options: expect.objectContaining({ abortController, }), }); }); it('should handle conversation history with sdkSessionId using resume option', async () => { vi.mocked(sdk.query).mockReturnValue( (async function* () { yield { type: 'text', text: 'test' }; })() ); const conversationHistory = [ { role: 'user' as const, content: 'Previous message' }, { role: 'assistant' as const, content: 'Previous response' }, ]; const generator = provider.executeQuery({ prompt: 'Current message', model: 'claude-opus-4-5-20251101', cwd: '/test', conversationHistory, sdkSessionId: 'test-session-id', }); await collectAsyncGenerator(generator); // Should use resume option when sdkSessionId is provided with history expect(sdk.query).toHaveBeenCalledWith({ prompt: 'Current message', options: expect.objectContaining({ resume: 'test-session-id', }), }); }); it('should handle array prompt (with images)', async () => { vi.mocked(sdk.query).mockReturnValue( (async function* () { yield { type: 'text', text: 'test' }; })() ); const arrayPrompt = [ { type: 'text', text: 'Describe this' }, { type: 'image', source: { type: 'base64', data: '...' } }, ]; const generator = provider.executeQuery({ prompt: arrayPrompt as any, model: 'claude-opus-4-5-20251101', cwd: '/test', }); await collectAsyncGenerator(generator); // Should pass an async generator as prompt for array inputs const callArgs = vi.mocked(sdk.query).mock.calls[0][0]; expect(typeof callArgs.prompt).not.toBe('string'); }); it('should use maxTurns default of 20', async () => { vi.mocked(sdk.query).mockReturnValue( (async function* () { yield { type: 'text', text: 'test' }; })() ); const generator = provider.executeQuery({ prompt: 'Test', model: 'claude-opus-4-5-20251101', cwd: '/test', }); await collectAsyncGenerator(generator); expect(sdk.query).toHaveBeenCalledWith({ prompt: 'Test', options: expect.objectContaining({ maxTurns: 20, }), }); }); it('should handle errors during execution and rethrow', async () => { const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); const testError = new Error('SDK execution failed'); vi.mocked(sdk.query).mockReturnValue( (async function* () { throw testError; })() ); const generator = provider.executeQuery({ prompt: 'Test', model: 'claude-opus-4-5-20251101', cwd: '/test', }); await expect(collectAsyncGenerator(generator)).rejects.toThrow('SDK execution failed'); // Should log error with classification info (via logger) // Logger format: 'ERROR [Context]' message, data const errorCall = consoleErrorSpy.mock.calls[0]; expect(errorCall[0]).toMatch(/ERROR.*\[ClaudeProvider\]/); expect(errorCall[1]).toBe('executeQuery() error during execution:'); expect(errorCall[2]).toMatchObject({ type: expect.any(String), message: 'SDK execution failed', isRateLimit: false, stack: expect.stringContaining('Error: SDK execution failed'), }); consoleErrorSpy.mockRestore(); }); }); describe('detectInstallation', () => { it('should return installed with SDK method', async () => { const result = await provider.detectInstallation(); expect(result.installed).toBe(true); expect(result.method).toBe('sdk'); }); it('should detect ANTHROPIC_API_KEY', async () => { process.env.ANTHROPIC_API_KEY = 'test-key'; const result = await provider.detectInstallation(); expect(result.hasApiKey).toBe(true); expect(result.authenticated).toBe(true); }); it('should return hasApiKey false when no keys present', async () => { const result = await provider.detectInstallation(); expect(result.hasApiKey).toBe(false); expect(result.authenticated).toBe(false); }); }); describe('environment variable passthrough', () => { afterEach(() => { delete process.env.ANTHROPIC_BASE_URL; delete process.env.ANTHROPIC_AUTH_TOKEN; }); it('should pass ANTHROPIC_BASE_URL to SDK env', async () => { process.env.ANTHROPIC_BASE_URL = 'https://custom.example.com/v1'; vi.mocked(sdk.query).mockReturnValue( (async function* () { yield { type: 'text', text: 'test' }; })() ); const generator = provider.executeQuery({ prompt: 'Test', model: 'claude-opus-4-5-20251101', cwd: '/test', }); await collectAsyncGenerator(generator); expect(sdk.query).toHaveBeenCalledWith({ prompt: 'Test', options: expect.objectContaining({ env: expect.objectContaining({ ANTHROPIC_BASE_URL: 'https://custom.example.com/v1', }), }), }); }); it('should pass ANTHROPIC_AUTH_TOKEN to SDK env', async () => { process.env.ANTHROPIC_AUTH_TOKEN = 'custom-auth-token'; vi.mocked(sdk.query).mockReturnValue( (async function* () { yield { type: 'text', text: 'test' }; })() ); const generator = provider.executeQuery({ prompt: 'Test', model: 'claude-opus-4-5-20251101', cwd: '/test', }); await collectAsyncGenerator(generator); expect(sdk.query).toHaveBeenCalledWith({ prompt: 'Test', options: expect.objectContaining({ env: expect.objectContaining({ ANTHROPIC_AUTH_TOKEN: 'custom-auth-token', }), }), }); }); it('should pass both custom endpoint vars together', async () => { process.env.ANTHROPIC_BASE_URL = 'https://gateway.example.com'; process.env.ANTHROPIC_AUTH_TOKEN = 'gateway-token'; vi.mocked(sdk.query).mockReturnValue( (async function* () { yield { type: 'text', text: 'test' }; })() ); const generator = provider.executeQuery({ prompt: 'Test', model: 'claude-opus-4-5-20251101', cwd: '/test', }); await collectAsyncGenerator(generator); expect(sdk.query).toHaveBeenCalledWith({ prompt: 'Test', options: expect.objectContaining({ env: expect.objectContaining({ ANTHROPIC_BASE_URL: 'https://gateway.example.com', ANTHROPIC_AUTH_TOKEN: 'gateway-token', }), }), }); }); }); describe('getAvailableModels', () => { it('should return 4 Claude models', () => { const models = provider.getAvailableModels(); expect(models).toHaveLength(4); }); it('should include Claude Opus 4.5', () => { const models = provider.getAvailableModels(); const opus = models.find((m) => m.id === 'claude-opus-4-5-20251101'); expect(opus).toBeDefined(); expect(opus?.name).toBe('Claude Opus 4.5'); expect(opus?.provider).toBe('anthropic'); }); it('should include Claude Sonnet 4', () => { const models = provider.getAvailableModels(); const sonnet = models.find((m) => m.id === 'claude-sonnet-4-20250514'); expect(sonnet).toBeDefined(); expect(sonnet?.name).toBe('Claude Sonnet 4'); }); it('should include Claude 3.5 Sonnet', () => { const models = provider.getAvailableModels(); const sonnet35 = models.find((m) => m.id === 'claude-3-5-sonnet-20241022'); expect(sonnet35).toBeDefined(); }); it('should include Claude Haiku 4.5', () => { const models = provider.getAvailableModels(); const haiku = models.find((m) => m.id === 'claude-haiku-4-5-20251001'); expect(haiku).toBeDefined(); }); it('should mark Opus as default', () => { const models = provider.getAvailableModels(); const opus = models.find((m) => m.id === 'claude-opus-4-5-20251101'); expect(opus?.default).toBe(true); }); it('should all support vision and tools', () => { const models = provider.getAvailableModels(); models.forEach((model) => { expect(model.supportsVision).toBe(true); expect(model.supportsTools).toBe(true); }); }); it('should have correct context windows', () => { const models = provider.getAvailableModels(); models.forEach((model) => { expect(model.contextWindow).toBe(200000); }); }); it('should have modelString field matching id', () => { const models = provider.getAvailableModels(); models.forEach((model) => { expect(model.modelString).toBe(model.id); }); }); }); describe('supportsFeature', () => { it("should support 'tools' feature", () => { expect(provider.supportsFeature('tools')).toBe(true); }); it("should support 'text' feature", () => { expect(provider.supportsFeature('text')).toBe(true); }); it("should support 'vision' feature", () => { expect(provider.supportsFeature('vision')).toBe(true); }); it("should support 'thinking' feature", () => { expect(provider.supportsFeature('thinking')).toBe(true); }); it("should not support 'mcp' feature", () => { expect(provider.supportsFeature('mcp')).toBe(false); }); it("should not support 'cli' feature", () => { expect(provider.supportsFeature('cli')).toBe(false); }); it('should not support unknown features', () => { expect(provider.supportsFeature('unknown')).toBe(false); }); }); describe('validateConfig', () => { it('should validate config from base class', () => { const result = provider.validateConfig(); expect(result.valid).toBe(true); expect(result.errors).toHaveLength(0); }); }); describe('config management', () => { it('should get and set config', () => { provider.setConfig({ apiKey: 'test-key' }); const config = provider.getConfig(); expect(config.apiKey).toBe('test-key'); }); it('should merge config updates', () => { provider.setConfig({ apiKey: 'key1' }); provider.setConfig({ model: 'model1' }); const config = provider.getConfig(); expect(config.apiKey).toBe('key1'); expect(config.model).toBe('model1'); }); }); });