Files
automaker/apps/server/tests/unit/providers/opencode-provider.test.ts
2026-01-12 23:44:05 +05:30

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');
});
});
});