mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
- auto-mode-service-planning.test.ts: Add taskExecutionPrompts argument to buildFeaturePrompt calls, update test for implementation instructions - claude-usage-service.test.ts: Skip deprecated Mac tests (service now uses PTY for all platforms), rename Windows tests to PTY tests, update to use process.cwd() instead of home directory - claude-provider.test.ts: Add missing model parameter to environment variable passthrough tests All tests now pass (1093 passed, 23 skipped). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
490 lines
14 KiB
TypeScript
490 lines
14 KiB
TypeScript
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');
|
|
});
|
|
});
|
|
});
|