/** * Tests for ClaudeCodeProvider * * The provider shells out to the local Claude Code CLI via * @anthropic-ai/claude-agent-sdk. Tests cover the public surface and the * provider-specific hooks; full integration is not exercised because it * would spawn a real CLI process. */ import { describe, it, expect, beforeEach } from 'bun:test'; import { ClaudeCodeProvider } from '../src/providers/claude-code.js'; import { AIProviderError, AIErrorType } from '../src/types/index.js'; describe('ClaudeCodeProvider', () => { let provider: ClaudeCodeProvider; beforeEach(() => { provider = new ClaudeCodeProvider({ apiKey: 'test-key' }); }); describe('Constructor', () => { it('should accept empty config (subscription mode)', () => { const p = new ClaudeCodeProvider({}); expect(p).toBeInstanceOf(ClaudeCodeProvider); }); it('should accept apiKey override', () => { const p = new ClaudeCodeProvider({ apiKey: 'sk-...' }); expect(p).toBeInstanceOf(ClaudeCodeProvider); }); it('should accept custom defaults', () => { const p = new ClaudeCodeProvider({ defaultModel: 'opus', maxTurns: 5, allowedTools: ['Read', 'Bash'], cwd: '/tmp/agent' }); expect(p).toBeInstanceOf(ClaudeCodeProvider); }); it('should reject non-string apiKey', () => { expect(() => new ClaudeCodeProvider({ apiKey: 123 as any })) .toThrow(AIProviderError); }); }); describe('getInfo', () => { it('should advertise subscriptionAuth capability', () => { const info = provider.getInfo(); expect(info.name).toBe('Claude Code'); expect(info.supportsStreaming).toBe(true); expect(info.capabilities).toMatchObject({ subscriptionAuth: true, requiresLocalCli: true, systemMessages: true }); expect(info.models).toContain('sonnet'); expect(info.models).toContain('opus'); }); }); describe('Error mapping', () => { const mapError = (err: any) => (provider as any).mapProviderError(err); it('should detect missing CLI', () => { const err = new Error('spawn claude ENOENT'); const mapped = mapError(err); expect(mapped).toBeInstanceOf(AIProviderError); expect(mapped.type).toBe(AIErrorType.INVALID_REQUEST); expect(mapped.message).toContain('CLI'); }); it('should detect missing authentication', () => { const err = new Error('not logged in'); const mapped = mapError(err); expect(mapped).toBeInstanceOf(AIProviderError); expect(mapped.type).toBe(AIErrorType.AUTHENTICATION); }); it('should return null for unrelated errors', () => { expect(mapError(new Error('something else'))).toBeNull(); }); }); describe('SDK assistant error mapping', () => { const mapSdkError = (code: string) => (provider as any).errorForSdkAssistantError(code); it('should map authentication_failed', () => { const err = mapSdkError('authentication_failed'); expect(err.type).toBe(AIErrorType.AUTHENTICATION); }); it('should map rate_limit', () => { const err = mapSdkError('rate_limit'); expect(err.type).toBe(AIErrorType.RATE_LIMIT); }); it('should map model_not_found', () => { const err = mapSdkError('model_not_found'); expect(err.type).toBe(AIErrorType.MODEL_NOT_FOUND); }); it('should map server_error to NETWORK', () => { const err = mapSdkError('server_error'); expect(err.type).toBe(AIErrorType.NETWORK); }); it('should fall back to UNKNOWN for unrecognized codes', () => { const err = mapSdkError('weird_new_code'); expect(err.type).toBe(AIErrorType.UNKNOWN); }); }); describe('Prompt building', () => { const build = (messages: any[]) => (provider as any).buildPrompt(messages); it('should pass single user turn through unchanged', () => { const result = build([{ role: 'user', content: 'Hello' }]); expect(result.prompt).toBe('Hello'); expect(result.systemPrompt).toBeUndefined(); }); it('should combine multiple system messages', () => { const result = build([ { role: 'system', content: 'Be brief' }, { role: 'system', content: 'Use British spelling' }, { role: 'user', content: 'Hi' } ]); expect(result.systemPrompt).toBe('Be brief\n\nUse British spelling'); expect(result.prompt).toBe('Hi'); }); it('should flatten multi-turn conversation with role labels', () => { const result = build([ { role: 'user', content: 'What is 2+2?' }, { role: 'assistant', content: '4' }, { role: 'user', content: 'Why?' } ]); expect(result.prompt).toContain('Human: What is 2+2?'); expect(result.prompt).toContain('Assistant: 4'); expect(result.prompt).toContain('Human: Why?'); expect(result.prompt.endsWith('Assistant:')).toBe(true); }); it('should reject empty turn list', () => { expect(() => build([{ role: 'system', content: 'only system' }])) .toThrow(AIProviderError); }); }); describe('Validation', () => { it('should require initialization before complete()', async () => { await expect( provider.complete({ messages: [{ role: 'user', content: 'test' }] }) ).rejects.toThrow('Provider must be initialized before use'); }); it('should validate temperature range when initialized', async () => { (provider as any).initialized = true; await expect( provider.complete({ messages: [{ role: 'user', content: 'test' }], temperature: 1.5 }) ).rejects.toThrow(/temperature must be at most 1/); }); }); });