mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 14:22:02 +00:00
1315 lines
40 KiB
TypeScript
1315 lines
40 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import {
|
|
OpencodeProvider,
|
|
resetToolUseIdCounter,
|
|
} from '../../../src/providers/opencode-provider.js';
|
|
import type { ProviderMessage, ModelDefinition } from '@automaker/types';
|
|
import { collectAsyncGenerator } from '../../utils/helpers.js';
|
|
import { spawnJSONLProcess, getOpenCodeAuthIndicators } from '@automaker/platform';
|
|
|
|
vi.mock('@automaker/platform', () => ({
|
|
spawnJSONLProcess: vi.fn(),
|
|
isWslAvailable: vi.fn().mockReturnValue(false),
|
|
findCliInWsl: vi.fn().mockReturnValue(null),
|
|
createWslCommand: vi.fn(),
|
|
windowsToWslPath: vi.fn(),
|
|
getOpenCodeAuthIndicators: vi.fn().mockResolvedValue({
|
|
hasAuthFile: false,
|
|
hasOAuthToken: false,
|
|
hasApiKey: false,
|
|
}),
|
|
}));
|
|
|
|
describe('opencode-provider.ts', () => {
|
|
let provider: OpencodeProvider;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
resetToolUseIdCounter();
|
|
provider = new OpencodeProvider();
|
|
});
|
|
|
|
afterEach(() => {
|
|
// Note: Don't use vi.restoreAllMocks() here as it would undo the module-level
|
|
// mock implementations (like getOpenCodeAuthIndicators) set up with vi.mock()
|
|
});
|
|
|
|
// ==========================================================================
|
|
// Basic Provider Tests
|
|
// ==========================================================================
|
|
|
|
describe('getName', () => {
|
|
it("should return 'opencode' as provider name", () => {
|
|
expect(provider.getName()).toBe('opencode');
|
|
});
|
|
});
|
|
|
|
describe('getCliName', () => {
|
|
it("should return 'opencode' as CLI name", () => {
|
|
expect(provider.getCliName()).toBe('opencode');
|
|
});
|
|
});
|
|
|
|
describe('getAvailableModels', () => {
|
|
it('should return 5 models', () => {
|
|
const models = provider.getAvailableModels();
|
|
expect(models).toHaveLength(5);
|
|
});
|
|
|
|
it('should include Big Pickle as default', () => {
|
|
const models = provider.getAvailableModels();
|
|
const bigPickle = models.find((m) => m.id === 'opencode/big-pickle');
|
|
|
|
expect(bigPickle).toBeDefined();
|
|
expect(bigPickle?.name).toBe('Big Pickle (Free)');
|
|
expect(bigPickle?.provider).toBe('opencode');
|
|
expect(bigPickle?.default).toBe(true);
|
|
expect(bigPickle?.modelString).toBe('opencode/big-pickle');
|
|
});
|
|
|
|
it('should include free tier GLM model', () => {
|
|
const models = provider.getAvailableModels();
|
|
const glm = models.find((m) => m.id === 'opencode/glm-4.7-free');
|
|
|
|
expect(glm).toBeDefined();
|
|
expect(glm?.name).toBe('GLM 4.7 Free');
|
|
expect(glm?.tier).toBe('basic');
|
|
});
|
|
|
|
it('should include free tier MiniMax model', () => {
|
|
const models = provider.getAvailableModels();
|
|
const minimax = models.find((m) => m.id === 'opencode/minimax-m2.1-free');
|
|
|
|
expect(minimax).toBeDefined();
|
|
expect(minimax?.name).toBe('MiniMax M2.1 Free');
|
|
expect(minimax?.tier).toBe('basic');
|
|
});
|
|
|
|
it('should have all models support tools', () => {
|
|
const models = provider.getAvailableModels();
|
|
|
|
models.forEach((model) => {
|
|
expect(model.supportsTools).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('should have models with modelString property', () => {
|
|
const models = provider.getAvailableModels();
|
|
|
|
for (const model of models) {
|
|
expect(model).toHaveProperty('modelString');
|
|
expect(typeof model.modelString).toBe('string');
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('parseModelsOutput', () => {
|
|
it('should parse nested provider model IDs', () => {
|
|
const output = ['openrouter/anthropic/claude-3.5-sonnet', 'openai/gpt-4o'].join('\n');
|
|
|
|
const parseModelsOutput = (
|
|
provider as unknown as { parseModelsOutput: (output: string) => ModelDefinition[] }
|
|
).parseModelsOutput;
|
|
const models = parseModelsOutput(output);
|
|
|
|
expect(models).toHaveLength(2);
|
|
const openrouterModel = models.find((model) => model.id.startsWith('openrouter/'));
|
|
|
|
expect(openrouterModel).toBeDefined();
|
|
expect(openrouterModel?.provider).toBe('openrouter');
|
|
expect(openrouterModel?.modelString).toBe('openrouter/anthropic/claude-3.5-sonnet');
|
|
});
|
|
});
|
|
|
|
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 not support 'thinking' feature", () => {
|
|
expect(provider.supportsFeature('thinking')).toBe(false);
|
|
});
|
|
|
|
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 return false for unknown features', () => {
|
|
expect(provider.supportsFeature('unknown-feature')).toBe(false);
|
|
expect(provider.supportsFeature('nonexistent')).toBe(false);
|
|
expect(provider.supportsFeature('')).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ==========================================================================
|
|
// buildCliArgs Tests
|
|
// ==========================================================================
|
|
|
|
describe('buildCliArgs', () => {
|
|
it('should build correct args with run subcommand', () => {
|
|
const args = provider.buildCliArgs({
|
|
prompt: 'Hello',
|
|
model: 'opencode/big-pickle',
|
|
cwd: '/tmp/project',
|
|
});
|
|
|
|
expect(args[0]).toBe('run');
|
|
});
|
|
|
|
it('should include --format json for streaming output', () => {
|
|
const args = provider.buildCliArgs({
|
|
prompt: 'Hello',
|
|
model: 'opencode/big-pickle',
|
|
cwd: '/tmp/project',
|
|
});
|
|
|
|
const formatIndex = args.indexOf('--format');
|
|
expect(formatIndex).toBeGreaterThan(-1);
|
|
expect(args[formatIndex + 1]).toBe('json');
|
|
});
|
|
|
|
it('should include model with --model flag', () => {
|
|
const args = provider.buildCliArgs({
|
|
prompt: 'Hello',
|
|
model: 'anthropic/claude-sonnet-4-5',
|
|
cwd: '/tmp/project',
|
|
});
|
|
|
|
const modelIndex = args.indexOf('--model');
|
|
expect(modelIndex).toBeGreaterThan(-1);
|
|
expect(args[modelIndex + 1]).toBe('anthropic/claude-sonnet-4-5');
|
|
});
|
|
|
|
it('should strip opencode- prefix from model', () => {
|
|
const args = provider.buildCliArgs({
|
|
prompt: 'Hello',
|
|
model: 'opencode-anthropic/claude-sonnet-4-5',
|
|
cwd: '/tmp/project',
|
|
});
|
|
|
|
const modelIndex = args.indexOf('--model');
|
|
expect(args[modelIndex + 1]).toBe('anthropic/claude-sonnet-4-5');
|
|
});
|
|
|
|
it('should handle missing cwd', () => {
|
|
const args = provider.buildCliArgs({
|
|
prompt: 'Hello',
|
|
model: 'opencode/big-pickle',
|
|
});
|
|
|
|
expect(args).not.toContain('-c');
|
|
});
|
|
|
|
it('should handle model from opencode provider', () => {
|
|
const args = provider.buildCliArgs({
|
|
prompt: 'Hello',
|
|
model: 'opencode/big-pickle',
|
|
cwd: '/tmp/project',
|
|
});
|
|
|
|
expect(args).toContain('--model');
|
|
expect(args).toContain('opencode/big-pickle');
|
|
});
|
|
});
|
|
|
|
// ==========================================================================
|
|
// normalizeEvent Tests
|
|
// ==========================================================================
|
|
|
|
describe('normalizeEvent', () => {
|
|
describe('text events (new OpenCode format)', () => {
|
|
it('should convert text to assistant message with text content', () => {
|
|
const event = {
|
|
type: 'text',
|
|
part: {
|
|
type: 'text',
|
|
text: 'Hello, world!',
|
|
},
|
|
sessionID: 'test-session',
|
|
};
|
|
|
|
const result = provider.normalizeEvent(event);
|
|
|
|
expect(result).toEqual({
|
|
type: 'assistant',
|
|
session_id: 'test-session',
|
|
message: {
|
|
role: 'assistant',
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: 'Hello, world!',
|
|
},
|
|
],
|
|
},
|
|
});
|
|
});
|
|
|
|
it('should return null for empty text', () => {
|
|
const event = {
|
|
type: 'text',
|
|
part: {
|
|
type: 'text',
|
|
text: '',
|
|
},
|
|
};
|
|
|
|
const result = provider.normalizeEvent(event);
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('should return null for text with undefined text', () => {
|
|
const event = {
|
|
type: 'text',
|
|
part: {},
|
|
};
|
|
|
|
const result = provider.normalizeEvent(event);
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('tool_call events', () => {
|
|
it('should convert tool_call to assistant message with tool_use content', () => {
|
|
const event = {
|
|
type: 'tool_call',
|
|
part: {
|
|
type: 'tool-call',
|
|
call_id: 'call-123',
|
|
name: 'Read',
|
|
args: { file_path: '/tmp/test.txt' },
|
|
},
|
|
sessionID: 'test-session',
|
|
};
|
|
|
|
const result = provider.normalizeEvent(event);
|
|
|
|
expect(result).toEqual({
|
|
type: 'assistant',
|
|
session_id: 'test-session',
|
|
message: {
|
|
role: 'assistant',
|
|
content: [
|
|
{
|
|
type: 'tool_use',
|
|
name: 'Read',
|
|
tool_use_id: 'call-123',
|
|
input: { file_path: '/tmp/test.txt' },
|
|
},
|
|
],
|
|
},
|
|
});
|
|
});
|
|
|
|
it('should generate tool_use_id when call_id is missing', () => {
|
|
const event = {
|
|
type: 'tool_call',
|
|
part: {
|
|
type: 'tool-call',
|
|
name: 'Write',
|
|
args: { content: 'test' },
|
|
},
|
|
};
|
|
|
|
const result = provider.normalizeEvent(event);
|
|
|
|
expect(result?.message?.content[0].type).toBe('tool_use');
|
|
expect(result?.message?.content[0].tool_use_id).toBe('opencode-tool-1');
|
|
|
|
// Second call should increment
|
|
const result2 = provider.normalizeEvent({
|
|
type: 'tool_call',
|
|
part: {
|
|
type: 'tool-call',
|
|
name: 'Edit',
|
|
args: {},
|
|
},
|
|
});
|
|
expect(result2?.message?.content[0].tool_use_id).toBe('opencode-tool-2');
|
|
});
|
|
});
|
|
|
|
describe('tool_result events', () => {
|
|
it('should convert tool_result to assistant message with tool_result content', () => {
|
|
const event = {
|
|
type: 'tool_result',
|
|
part: {
|
|
type: 'tool-result',
|
|
call_id: 'call-123',
|
|
output: 'File contents here',
|
|
},
|
|
sessionID: 'test-session',
|
|
};
|
|
|
|
const result = provider.normalizeEvent(event);
|
|
|
|
expect(result).toEqual({
|
|
type: 'assistant',
|
|
session_id: 'test-session',
|
|
message: {
|
|
role: 'assistant',
|
|
content: [
|
|
{
|
|
type: 'tool_result',
|
|
tool_use_id: 'call-123',
|
|
content: 'File contents here',
|
|
},
|
|
],
|
|
},
|
|
});
|
|
});
|
|
|
|
it('should handle tool_result without call_id', () => {
|
|
const event = {
|
|
type: 'tool_result',
|
|
part: {
|
|
type: 'tool-result',
|
|
output: 'Result without ID',
|
|
},
|
|
};
|
|
|
|
const result = provider.normalizeEvent(event);
|
|
|
|
expect(result?.message?.content[0].type).toBe('tool_result');
|
|
expect(result?.message?.content[0].tool_use_id).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('tool_error events', () => {
|
|
it('should convert tool_error to error message', () => {
|
|
const event = {
|
|
type: 'tool_error',
|
|
part: {
|
|
type: 'tool-error',
|
|
call_id: 'call-123',
|
|
error: 'File not found',
|
|
},
|
|
sessionID: 'test-session',
|
|
};
|
|
|
|
const result = provider.normalizeEvent(event);
|
|
|
|
expect(result).toEqual({
|
|
type: 'error',
|
|
session_id: 'test-session',
|
|
error: 'File not found',
|
|
});
|
|
});
|
|
|
|
it('should provide default error message when error is missing', () => {
|
|
const event = {
|
|
type: 'tool_error',
|
|
part: {
|
|
type: 'tool-error',
|
|
call_id: 'call-123',
|
|
},
|
|
};
|
|
|
|
const result = provider.normalizeEvent(event);
|
|
|
|
expect(result?.type).toBe('error');
|
|
expect(result?.error).toBe('Tool execution failed');
|
|
});
|
|
});
|
|
|
|
describe('step_start events', () => {
|
|
it('should return null for step_start events (informational)', () => {
|
|
const event = {
|
|
type: 'step_start',
|
|
part: {
|
|
type: 'step-start',
|
|
},
|
|
sessionID: 'test-session',
|
|
};
|
|
|
|
const result = provider.normalizeEvent(event);
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('step_finish events', () => {
|
|
it('should convert successful step_finish to result message', () => {
|
|
const event = {
|
|
type: 'step_finish',
|
|
part: {
|
|
type: 'step-finish',
|
|
reason: 'stop',
|
|
result: 'Task completed successfully',
|
|
},
|
|
sessionID: 'test-session',
|
|
};
|
|
|
|
const result = provider.normalizeEvent(event);
|
|
|
|
expect(result).toEqual({
|
|
type: 'result',
|
|
subtype: 'success',
|
|
session_id: 'test-session',
|
|
result: 'Task completed successfully',
|
|
});
|
|
});
|
|
|
|
it('should convert step_finish with error to error message', () => {
|
|
const event = {
|
|
type: 'step_finish',
|
|
part: {
|
|
type: 'step-finish',
|
|
reason: 'error',
|
|
error: 'Something went wrong',
|
|
},
|
|
sessionID: 'test-session',
|
|
};
|
|
|
|
const result = provider.normalizeEvent(event);
|
|
|
|
expect(result).toEqual({
|
|
type: 'error',
|
|
session_id: 'test-session',
|
|
error: 'Something went wrong',
|
|
});
|
|
});
|
|
|
|
it('should convert step_finish with error property to error message', () => {
|
|
const event = {
|
|
type: 'step_finish',
|
|
part: {
|
|
type: 'step-finish',
|
|
error: 'Process failed',
|
|
},
|
|
};
|
|
|
|
const result = provider.normalizeEvent(event);
|
|
|
|
expect(result?.type).toBe('error');
|
|
expect(result?.error).toBe('Process failed');
|
|
});
|
|
|
|
it('should provide default error message for failed step without error text', () => {
|
|
const event = {
|
|
type: 'step_finish',
|
|
part: {
|
|
type: 'step-finish',
|
|
reason: 'error',
|
|
},
|
|
};
|
|
|
|
const result = provider.normalizeEvent(event);
|
|
|
|
expect(result?.type).toBe('error');
|
|
expect(result?.error).toBe('Step execution failed');
|
|
});
|
|
|
|
it('should treat step_finish with reason=stop as success', () => {
|
|
const event = {
|
|
type: 'step_finish',
|
|
part: {
|
|
type: 'step-finish',
|
|
reason: 'stop',
|
|
result: 'Done',
|
|
},
|
|
};
|
|
|
|
const result = provider.normalizeEvent(event);
|
|
|
|
expect(result?.type).toBe('result');
|
|
expect(result?.subtype).toBe('success');
|
|
});
|
|
});
|
|
|
|
describe('unknown events', () => {
|
|
it('should return null for unknown event types', () => {
|
|
const event = {
|
|
type: 'unknown-event',
|
|
data: 'some data',
|
|
};
|
|
|
|
const result = provider.normalizeEvent(event);
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('should return null for null input', () => {
|
|
const result = provider.normalizeEvent(null);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('should return null for undefined input', () => {
|
|
const result = provider.normalizeEvent(undefined);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('should return null for non-object input', () => {
|
|
expect(provider.normalizeEvent('string')).toBeNull();
|
|
expect(provider.normalizeEvent(123)).toBeNull();
|
|
expect(provider.normalizeEvent(true)).toBeNull();
|
|
});
|
|
|
|
it('should return null for events without type', () => {
|
|
expect(provider.normalizeEvent({})).toBeNull();
|
|
expect(provider.normalizeEvent({ data: 'no type' })).toBeNull();
|
|
});
|
|
});
|
|
});
|
|
|
|
// ==========================================================================
|
|
// executeQuery Tests
|
|
// ==========================================================================
|
|
|
|
describe('executeQuery', () => {
|
|
/**
|
|
* Helper to set up the provider with a mocked CLI path
|
|
* This bypasses CLI detection for testing
|
|
*/
|
|
function setupMockedProvider(): OpencodeProvider {
|
|
const mockedProvider = new OpencodeProvider();
|
|
// Access protected property to simulate CLI detection
|
|
(mockedProvider as unknown as { cliPath: string }).cliPath = '/usr/bin/opencode';
|
|
(mockedProvider as unknown as { detectedStrategy: string }).detectedStrategy = 'native';
|
|
return mockedProvider;
|
|
}
|
|
|
|
it('should stream text events as assistant messages', async () => {
|
|
const mockedProvider = setupMockedProvider();
|
|
|
|
const mockEvents = [
|
|
{ type: 'text', part: { type: 'text', text: 'Hello ' } },
|
|
{ type: 'text', part: { type: 'text', text: 'World!' } },
|
|
];
|
|
|
|
vi.mocked(spawnJSONLProcess).mockReturnValue(
|
|
(async function* () {
|
|
for (const event of mockEvents) {
|
|
yield event;
|
|
}
|
|
})()
|
|
);
|
|
|
|
const results = await collectAsyncGenerator<ProviderMessage>(
|
|
mockedProvider.executeQuery({
|
|
prompt: 'Say hello',
|
|
model: 'anthropic/claude-sonnet-4-5',
|
|
cwd: '/tmp',
|
|
})
|
|
);
|
|
|
|
expect(results).toHaveLength(2);
|
|
expect(results[0].type).toBe('assistant');
|
|
expect(results[0].message?.content[0].text).toBe('Hello ');
|
|
expect(results[1].message?.content[0].text).toBe('World!');
|
|
});
|
|
|
|
it('should emit tool_use and tool_result with matching IDs', async () => {
|
|
const mockedProvider = setupMockedProvider();
|
|
|
|
const mockEvents = [
|
|
{
|
|
type: 'tool_call',
|
|
part: {
|
|
type: 'tool-call',
|
|
call_id: 'tool-1',
|
|
name: 'Read',
|
|
args: { file_path: '/tmp/test.txt' },
|
|
},
|
|
},
|
|
{
|
|
type: 'tool_result',
|
|
part: {
|
|
type: 'tool-result',
|
|
call_id: 'tool-1',
|
|
output: 'File contents',
|
|
},
|
|
},
|
|
];
|
|
|
|
vi.mocked(spawnJSONLProcess).mockReturnValue(
|
|
(async function* () {
|
|
for (const event of mockEvents) {
|
|
yield event;
|
|
}
|
|
})()
|
|
);
|
|
|
|
const results = await collectAsyncGenerator<ProviderMessage>(
|
|
mockedProvider.executeQuery({
|
|
prompt: 'Read a file',
|
|
cwd: '/tmp',
|
|
})
|
|
);
|
|
|
|
expect(results).toHaveLength(2);
|
|
|
|
const toolUse = results[0];
|
|
const toolResult = results[1];
|
|
|
|
expect(toolUse.type).toBe('assistant');
|
|
expect(toolUse.message?.content[0].type).toBe('tool_use');
|
|
expect(toolUse.message?.content[0].tool_use_id).toBe('tool-1');
|
|
|
|
expect(toolResult.type).toBe('assistant');
|
|
expect(toolResult.message?.content[0].type).toBe('tool_result');
|
|
expect(toolResult.message?.content[0].tool_use_id).toBe('tool-1');
|
|
});
|
|
|
|
it('should pass stdinData containing the prompt', async () => {
|
|
const mockedProvider = setupMockedProvider();
|
|
|
|
vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})());
|
|
|
|
await collectAsyncGenerator(
|
|
mockedProvider.executeQuery({
|
|
prompt: 'My test prompt',
|
|
cwd: '/tmp',
|
|
})
|
|
);
|
|
|
|
const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0];
|
|
expect(call.stdinData).toBe('My test prompt');
|
|
});
|
|
|
|
it('should extract text from array prompt', async () => {
|
|
const mockedProvider = setupMockedProvider();
|
|
|
|
vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})());
|
|
|
|
const arrayPrompt = [
|
|
{ type: 'text', text: 'First part' },
|
|
{ type: 'image', source: { type: 'base64', data: '...' } },
|
|
{ type: 'text', text: 'Second part' },
|
|
];
|
|
|
|
await collectAsyncGenerator(
|
|
mockedProvider.executeQuery({
|
|
prompt: arrayPrompt as unknown as string,
|
|
cwd: '/tmp',
|
|
})
|
|
);
|
|
|
|
const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0];
|
|
expect(call.stdinData).toBe('First part\nSecond part');
|
|
});
|
|
|
|
it('should include correct CLI args in subprocess options', async () => {
|
|
const mockedProvider = setupMockedProvider();
|
|
|
|
vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})());
|
|
|
|
await collectAsyncGenerator(
|
|
mockedProvider.executeQuery({
|
|
prompt: 'Test',
|
|
model: 'opencode-anthropic/claude-opus-4-5',
|
|
cwd: '/tmp/workspace',
|
|
})
|
|
);
|
|
|
|
const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0];
|
|
expect(call.args).toContain('run');
|
|
expect(call.args).toContain('--format');
|
|
expect(call.args).toContain('json');
|
|
expect(call.args).toContain('--model');
|
|
expect(call.args).toContain('anthropic/claude-opus-4-5');
|
|
});
|
|
|
|
it('should skip null-normalized events', async () => {
|
|
const mockedProvider = setupMockedProvider();
|
|
|
|
const mockEvents = [
|
|
{ type: 'unknown-internal-event', data: 'ignored' },
|
|
{ type: 'text', part: { type: 'text', text: 'Valid text' } },
|
|
{ type: 'another-unknown', foo: 'bar' },
|
|
{ type: 'step_finish', part: { type: 'step-finish', reason: 'stop', result: 'Done' } },
|
|
];
|
|
|
|
vi.mocked(spawnJSONLProcess).mockReturnValue(
|
|
(async function* () {
|
|
for (const event of mockEvents) {
|
|
yield event;
|
|
}
|
|
})()
|
|
);
|
|
|
|
const results = await collectAsyncGenerator<ProviderMessage>(
|
|
mockedProvider.executeQuery({
|
|
prompt: 'Test',
|
|
model: 'opencode/big-pickle',
|
|
cwd: '/test',
|
|
})
|
|
);
|
|
|
|
// Should only have valid events (text and result), not the unknown ones
|
|
expect(results.length).toBe(2);
|
|
});
|
|
|
|
it('should throw error when CLI is not installed', async () => {
|
|
// Create provider and explicitly set cliPath to null to simulate not installed
|
|
// Set detectedStrategy to 'npx' to prevent ensureCliDetected from re-running detection
|
|
const unmockedProvider = new OpencodeProvider();
|
|
(unmockedProvider as unknown as { cliPath: string | null }).cliPath = null;
|
|
(unmockedProvider as unknown as { detectedStrategy: string }).detectedStrategy = 'npx';
|
|
|
|
await expect(
|
|
collectAsyncGenerator(
|
|
unmockedProvider.executeQuery({
|
|
prompt: 'Test',
|
|
cwd: '/test',
|
|
})
|
|
)
|
|
).rejects.toThrow(/CLI not found/);
|
|
});
|
|
});
|
|
|
|
// ==========================================================================
|
|
// getSpawnConfig Tests
|
|
// ==========================================================================
|
|
|
|
describe('getSpawnConfig', () => {
|
|
it('should return npx as Windows strategy', () => {
|
|
const config = provider.getSpawnConfig();
|
|
expect(config.windowsStrategy).toBe('npx');
|
|
});
|
|
|
|
it('should specify opencode-ai@latest as npx package', () => {
|
|
const config = provider.getSpawnConfig();
|
|
expect(config.npxPackage).toBe('opencode-ai@latest');
|
|
});
|
|
|
|
it('should include common paths for Linux', () => {
|
|
const config = provider.getSpawnConfig();
|
|
const linuxPaths = config.commonPaths['linux'];
|
|
|
|
expect(linuxPaths).toBeDefined();
|
|
expect(linuxPaths.length).toBeGreaterThan(0);
|
|
expect(linuxPaths.some((p) => p.includes('opencode'))).toBe(true);
|
|
});
|
|
|
|
it('should include common paths for macOS', () => {
|
|
const config = provider.getSpawnConfig();
|
|
const darwinPaths = config.commonPaths['darwin'];
|
|
|
|
expect(darwinPaths).toBeDefined();
|
|
expect(darwinPaths.length).toBeGreaterThan(0);
|
|
expect(darwinPaths.some((p) => p.includes('homebrew'))).toBe(true);
|
|
});
|
|
|
|
it('should include common paths for Windows', () => {
|
|
const config = provider.getSpawnConfig();
|
|
const win32Paths = config.commonPaths['win32'];
|
|
|
|
expect(win32Paths).toBeDefined();
|
|
expect(win32Paths.length).toBeGreaterThan(0);
|
|
expect(win32Paths.some((p) => p.includes('npm'))).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ==========================================================================
|
|
// detectInstallation Tests
|
|
// ==========================================================================
|
|
|
|
describe('detectInstallation', () => {
|
|
beforeEach(() => {
|
|
// Ensure the mock implementation is set up for each test
|
|
vi.mocked(getOpenCodeAuthIndicators).mockResolvedValue({
|
|
hasAuthFile: false,
|
|
hasOAuthToken: false,
|
|
hasApiKey: false,
|
|
});
|
|
});
|
|
|
|
it('should return installed true when CLI is found', async () => {
|
|
(provider as unknown as { cliPath: string }).cliPath = '/usr/local/bin/opencode';
|
|
(provider as unknown as { detectedStrategy: string }).detectedStrategy = 'native';
|
|
|
|
const result = await provider.detectInstallation();
|
|
|
|
expect(result.installed).toBe(true);
|
|
expect(result.path).toBe('/usr/local/bin/opencode');
|
|
});
|
|
|
|
it('should return installed false when CLI is not found', async () => {
|
|
// Set both cliPath to null and detectedStrategy to something other than 'native'
|
|
// to prevent ensureCliDetected from re-detecting
|
|
(provider as unknown as { cliPath: string | null }).cliPath = null;
|
|
(provider as unknown as { detectedStrategy: string }).detectedStrategy = 'npx';
|
|
|
|
const result = await provider.detectInstallation();
|
|
|
|
expect(result.installed).toBe(false);
|
|
});
|
|
|
|
it('should return method as npm when using npx strategy', async () => {
|
|
(provider as unknown as { cliPath: string }).cliPath = 'npx';
|
|
(provider as unknown as { detectedStrategy: string }).detectedStrategy = 'npx';
|
|
|
|
const result = await provider.detectInstallation();
|
|
|
|
expect(result.method).toBe('npm');
|
|
});
|
|
|
|
it('should return method as cli when using native strategy', async () => {
|
|
(provider as unknown as { cliPath: string }).cliPath = '/usr/local/bin/opencode';
|
|
(provider as unknown as { detectedStrategy: string }).detectedStrategy = 'native';
|
|
|
|
const result = await provider.detectInstallation();
|
|
|
|
expect(result.method).toBe('cli');
|
|
});
|
|
});
|
|
|
|
// ==========================================================================
|
|
// Config Management Tests (inherited from BaseProvider)
|
|
// ==========================================================================
|
|
|
|
describe('config management', () => {
|
|
it('should get and set config', () => {
|
|
provider.setConfig({ apiKey: 'test-api-key' });
|
|
|
|
const config = provider.getConfig();
|
|
expect(config.apiKey).toBe('test-api-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');
|
|
});
|
|
});
|
|
|
|
describe('validateConfig', () => {
|
|
it('should validate config from base class', () => {
|
|
const result = provider.validateConfig();
|
|
|
|
expect(result.valid).toBe(true);
|
|
expect(result.errors).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
// ==========================================================================
|
|
// Additional Edge Case Tests
|
|
// ==========================================================================
|
|
|
|
describe('extractPromptText edge cases', () => {
|
|
function setupMockedProvider(): OpencodeProvider {
|
|
const mockedProvider = new OpencodeProvider();
|
|
(mockedProvider as unknown as { cliPath: string }).cliPath = '/usr/bin/opencode';
|
|
(mockedProvider as unknown as { detectedStrategy: string }).detectedStrategy = 'native';
|
|
return mockedProvider;
|
|
}
|
|
|
|
it('should handle empty array prompt', async () => {
|
|
const mockedProvider = setupMockedProvider();
|
|
|
|
vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})());
|
|
|
|
await collectAsyncGenerator(
|
|
mockedProvider.executeQuery({
|
|
prompt: [] as unknown as string,
|
|
cwd: '/tmp',
|
|
})
|
|
);
|
|
|
|
const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0];
|
|
expect(call.stdinData).toBe('');
|
|
});
|
|
|
|
it('should handle array prompt with only image blocks (no text)', async () => {
|
|
const mockedProvider = setupMockedProvider();
|
|
|
|
vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})());
|
|
|
|
const imageOnlyPrompt = [
|
|
{ type: 'image', source: { type: 'base64', data: 'abc123' } },
|
|
{ type: 'image', source: { type: 'base64', data: 'def456' } },
|
|
];
|
|
|
|
await collectAsyncGenerator(
|
|
mockedProvider.executeQuery({
|
|
prompt: imageOnlyPrompt as unknown as string,
|
|
cwd: '/tmp',
|
|
})
|
|
);
|
|
|
|
const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0];
|
|
expect(call.stdinData).toBe('');
|
|
});
|
|
|
|
it('should handle array prompt with mixed content types', async () => {
|
|
const mockedProvider = setupMockedProvider();
|
|
|
|
vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})());
|
|
|
|
const mixedPrompt = [
|
|
{ type: 'text', text: 'Analyze this image' },
|
|
{ type: 'image', source: { type: 'base64', data: 'abc123' } },
|
|
{ type: 'text', text: 'And this one' },
|
|
{ type: 'image', source: { type: 'base64', data: 'def456' } },
|
|
{ type: 'text', text: 'What differences do you see?' },
|
|
];
|
|
|
|
await collectAsyncGenerator(
|
|
mockedProvider.executeQuery({
|
|
prompt: mixedPrompt as unknown as string,
|
|
cwd: '/tmp',
|
|
})
|
|
);
|
|
|
|
const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0];
|
|
expect(call.stdinData).toBe('Analyze this image\nAnd this one\nWhat differences do you see?');
|
|
});
|
|
|
|
it('should handle text blocks with empty text property', async () => {
|
|
const mockedProvider = setupMockedProvider();
|
|
|
|
vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})());
|
|
|
|
const promptWithEmptyText = [
|
|
{ type: 'text', text: 'Hello' },
|
|
{ type: 'text', text: '' },
|
|
{ type: 'text', text: 'World' },
|
|
];
|
|
|
|
await collectAsyncGenerator(
|
|
mockedProvider.executeQuery({
|
|
prompt: promptWithEmptyText as unknown as string,
|
|
cwd: '/tmp',
|
|
})
|
|
);
|
|
|
|
const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0];
|
|
// Empty text blocks should be filtered out
|
|
expect(call.stdinData).toBe('Hello\nWorld');
|
|
});
|
|
});
|
|
|
|
describe('abort handling', () => {
|
|
function setupMockedProvider(): OpencodeProvider {
|
|
const mockedProvider = new OpencodeProvider();
|
|
(mockedProvider as unknown as { cliPath: string }).cliPath = '/usr/bin/opencode';
|
|
(mockedProvider as unknown as { detectedStrategy: string }).detectedStrategy = 'native';
|
|
return mockedProvider;
|
|
}
|
|
|
|
it('should pass abortController to subprocess options', async () => {
|
|
const mockedProvider = setupMockedProvider();
|
|
|
|
vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})());
|
|
|
|
const abortController = new AbortController();
|
|
|
|
await collectAsyncGenerator(
|
|
mockedProvider.executeQuery({
|
|
prompt: 'Test',
|
|
cwd: '/tmp',
|
|
abortController,
|
|
})
|
|
);
|
|
|
|
const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0];
|
|
expect(call.abortController).toBe(abortController);
|
|
});
|
|
});
|
|
|
|
describe('session_id preservation', () => {
|
|
function setupMockedProvider(): OpencodeProvider {
|
|
const mockedProvider = new OpencodeProvider();
|
|
(mockedProvider as unknown as { cliPath: string }).cliPath = '/usr/bin/opencode';
|
|
(mockedProvider as unknown as { detectedStrategy: string }).detectedStrategy = 'native';
|
|
return mockedProvider;
|
|
}
|
|
|
|
it('should preserve session_id through the full executeQuery flow', async () => {
|
|
const mockedProvider = setupMockedProvider();
|
|
const sessionId = 'test-session-123';
|
|
|
|
const mockEvents = [
|
|
{ type: 'text', part: { type: 'text', text: 'Hello ' }, sessionID: sessionId },
|
|
{
|
|
type: 'tool_call',
|
|
part: { type: 'tool-call', name: 'Read', args: {}, call_id: 'c1' },
|
|
sessionID: sessionId,
|
|
},
|
|
{
|
|
type: 'tool_result',
|
|
part: { type: 'tool-result', call_id: 'c1', output: 'file content' },
|
|
sessionID: sessionId,
|
|
},
|
|
{
|
|
type: 'step_finish',
|
|
part: { type: 'step-finish', reason: 'stop', result: 'Done' },
|
|
sessionID: sessionId,
|
|
},
|
|
];
|
|
|
|
vi.mocked(spawnJSONLProcess).mockReturnValue(
|
|
(async function* () {
|
|
for (const event of mockEvents) {
|
|
yield event;
|
|
}
|
|
})()
|
|
);
|
|
|
|
const results = await collectAsyncGenerator<ProviderMessage>(
|
|
mockedProvider.executeQuery({
|
|
prompt: 'Test',
|
|
model: 'opencode/big-pickle',
|
|
cwd: '/tmp',
|
|
})
|
|
);
|
|
|
|
// All emitted messages should have the session_id
|
|
expect(results).toHaveLength(4);
|
|
results.forEach((result) => {
|
|
expect(result.session_id).toBe(sessionId);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('normalizeEvent additional edge cases', () => {
|
|
it('should handle tool_call with empty args object', () => {
|
|
const event = {
|
|
type: 'tool_call',
|
|
part: {
|
|
type: 'tool-call',
|
|
call_id: 'call-123',
|
|
name: 'Glob',
|
|
args: {},
|
|
},
|
|
};
|
|
|
|
const result = provider.normalizeEvent(event);
|
|
|
|
expect(result?.message?.content[0].type).toBe('tool_use');
|
|
expect(result?.message?.content[0].input).toEqual({});
|
|
});
|
|
|
|
it('should handle tool_call with null args', () => {
|
|
const event = {
|
|
type: 'tool_call',
|
|
part: {
|
|
type: 'tool-call',
|
|
call_id: 'call-123',
|
|
name: 'Glob',
|
|
args: null,
|
|
},
|
|
};
|
|
|
|
const result = provider.normalizeEvent(event);
|
|
|
|
expect(result?.message?.content[0].type).toBe('tool_use');
|
|
expect(result?.message?.content[0].input).toBeNull();
|
|
});
|
|
|
|
it('should handle tool_call with complex nested args', () => {
|
|
const event = {
|
|
type: 'tool_call',
|
|
part: {
|
|
type: 'tool-call',
|
|
call_id: 'call-123',
|
|
name: 'Edit',
|
|
args: {
|
|
file_path: '/tmp/test.ts',
|
|
changes: [
|
|
{ line: 10, old: 'foo', new: 'bar' },
|
|
{ line: 20, old: 'baz', new: 'qux' },
|
|
],
|
|
options: { replace_all: true },
|
|
},
|
|
},
|
|
};
|
|
|
|
const result = provider.normalizeEvent(event);
|
|
|
|
expect(result?.message?.content[0].type).toBe('tool_use');
|
|
expect(result?.message?.content[0].input).toEqual({
|
|
file_path: '/tmp/test.ts',
|
|
changes: [
|
|
{ line: 10, old: 'foo', new: 'bar' },
|
|
{ line: 20, old: 'baz', new: 'qux' },
|
|
],
|
|
options: { replace_all: true },
|
|
});
|
|
});
|
|
|
|
it('should handle tool_result with empty output', () => {
|
|
const event = {
|
|
type: 'tool_result',
|
|
part: {
|
|
type: 'tool-result',
|
|
call_id: 'call-123',
|
|
output: '',
|
|
},
|
|
};
|
|
|
|
const result = provider.normalizeEvent(event);
|
|
|
|
expect(result?.message?.content[0].type).toBe('tool_result');
|
|
expect(result?.message?.content[0].content).toBe('');
|
|
});
|
|
|
|
it('should handle text with whitespace-only text', () => {
|
|
const event = {
|
|
type: 'text',
|
|
part: {
|
|
type: 'text',
|
|
text: ' ',
|
|
},
|
|
};
|
|
|
|
const result = provider.normalizeEvent(event);
|
|
|
|
// Whitespace should be preserved (not filtered like empty string)
|
|
expect(result).not.toBeNull();
|
|
expect(result?.message?.content[0].text).toBe(' ');
|
|
});
|
|
|
|
it('should handle text with newlines', () => {
|
|
const event = {
|
|
type: 'text',
|
|
part: {
|
|
type: 'text',
|
|
text: 'Line 1\nLine 2\nLine 3',
|
|
},
|
|
};
|
|
|
|
const result = provider.normalizeEvent(event);
|
|
|
|
expect(result?.message?.content[0].text).toBe('Line 1\nLine 2\nLine 3');
|
|
});
|
|
|
|
it('should handle step_finish with both result and error (error takes precedence)', () => {
|
|
const event = {
|
|
type: 'step_finish',
|
|
part: {
|
|
type: 'step-finish',
|
|
reason: 'stop',
|
|
result: 'Some result',
|
|
error: 'But also an error',
|
|
},
|
|
};
|
|
|
|
const result = provider.normalizeEvent(event);
|
|
|
|
expect(result?.type).toBe('error');
|
|
expect(result?.error).toBe('But also an error');
|
|
});
|
|
});
|
|
|
|
describe('isInstalled', () => {
|
|
it('should return true when CLI path is set', async () => {
|
|
(provider as unknown as { cliPath: string }).cliPath = '/usr/bin/opencode';
|
|
(provider as unknown as { detectedStrategy: string }).detectedStrategy = 'native';
|
|
|
|
const result = await provider.isInstalled();
|
|
|
|
expect(result).toBe(true);
|
|
});
|
|
|
|
it('should return false when CLI path is null', async () => {
|
|
(provider as unknown as { cliPath: string | null }).cliPath = null;
|
|
(provider as unknown as { detectedStrategy: string }).detectedStrategy = 'npx';
|
|
|
|
const result = await provider.isInstalled();
|
|
|
|
expect(result).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('model tier validation', () => {
|
|
it('should have exactly one default model', () => {
|
|
const models = provider.getAvailableModels();
|
|
const defaultModels = models.filter((m) => m.default === true);
|
|
|
|
expect(defaultModels).toHaveLength(1);
|
|
expect(defaultModels[0].id).toBe('opencode/big-pickle');
|
|
});
|
|
|
|
it('should have valid tier values for all models', () => {
|
|
const models = provider.getAvailableModels();
|
|
const validTiers = ['basic', 'standard', 'premium'];
|
|
|
|
models.forEach((model) => {
|
|
expect(validTiers).toContain(model.tier);
|
|
});
|
|
});
|
|
|
|
it('should have descriptions for all models', () => {
|
|
const models = provider.getAvailableModels();
|
|
|
|
models.forEach((model) => {
|
|
expect(model.description).toBeDefined();
|
|
expect(typeof model.description).toBe('string');
|
|
expect(model.description!.length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('buildCliArgs edge cases', () => {
|
|
it('should handle very long prompts', () => {
|
|
const longPrompt = 'a'.repeat(10000);
|
|
const args = provider.buildCliArgs({
|
|
prompt: longPrompt,
|
|
model: 'opencode/big-pickle',
|
|
cwd: '/tmp',
|
|
});
|
|
|
|
// The prompt is NOT in args (it's passed via stdin)
|
|
// Just verify the args structure is correct
|
|
expect(args).toContain('run');
|
|
expect(args).not.toContain('-');
|
|
expect(args.join(' ')).not.toContain(longPrompt);
|
|
});
|
|
|
|
it('should handle prompts with special characters', () => {
|
|
const specialPrompt = 'Test $HOME $(rm -rf /) `command` "quotes" \'single\'';
|
|
const args = provider.buildCliArgs({
|
|
prompt: specialPrompt,
|
|
model: 'opencode/big-pickle',
|
|
cwd: '/tmp',
|
|
});
|
|
|
|
// Special chars in prompt should not affect args (prompt is via stdin)
|
|
expect(args).toContain('run');
|
|
expect(args).not.toContain('-');
|
|
});
|
|
|
|
it('should handle cwd with spaces', () => {
|
|
const args = provider.buildCliArgs({
|
|
prompt: 'Test',
|
|
model: 'opencode/big-pickle',
|
|
cwd: '/tmp/path with spaces/project',
|
|
});
|
|
|
|
// cwd is set at subprocess level, not via CLI args
|
|
expect(args).not.toContain('-c');
|
|
expect(args).not.toContain('/tmp/path with spaces/project');
|
|
});
|
|
|
|
it('should handle model with unusual characters', () => {
|
|
const args = provider.buildCliArgs({
|
|
prompt: 'Test',
|
|
model: 'opencode-provider/model-v1.2.3-beta',
|
|
cwd: '/tmp',
|
|
});
|
|
|
|
const modelIndex = args.indexOf('--model');
|
|
expect(args[modelIndex + 1]).toBe('provider/model-v1.2.3-beta');
|
|
});
|
|
});
|
|
});
|