takt: opencode (#222)
This commit is contained in:
parent
dbc296e97a
commit
b80f6d0aa0
7
package-lock.json
generated
7
package-lock.json
generated
@ -11,6 +11,7 @@
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.37",
|
||||
"@openai/codex-sdk": "^0.98.0",
|
||||
"@opencode-ai/sdk": "^1.1.53",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.1.0",
|
||||
"update-notifier": "^7.3.1",
|
||||
@ -936,6 +937,12 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@opencode-ai/sdk": {
|
||||
"version": "1.1.53",
|
||||
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.1.53.tgz",
|
||||
"integrity": "sha512-RUIVnPOP1CyyU32FrOOYuE7Ge51lOBuhaFp2NSX98ncApT7ffoNetmwzqrhOiJQgZB1KrbCHLYOCK6AZfacxag==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@pnpm/config.env-replace": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz",
|
||||
|
||||
@ -59,6 +59,7 @@
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.37",
|
||||
"@openai/codex-sdk": "^0.98.0",
|
||||
"@opencode-ai/sdk": "^1.1.53",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.1.0",
|
||||
"update-notifier": "^7.3.1",
|
||||
|
||||
@ -32,7 +32,7 @@ vi.mock('../infra/config/paths.js', async (importOriginal) => {
|
||||
});
|
||||
|
||||
// Import after mocking
|
||||
const { loadGlobalConfig, saveGlobalConfig, resolveAnthropicApiKey, resolveOpenaiApiKey, invalidateGlobalConfigCache } = await import('../infra/config/global/globalConfig.js');
|
||||
const { loadGlobalConfig, saveGlobalConfig, resolveAnthropicApiKey, resolveOpenaiApiKey, resolveOpencodeApiKey, invalidateGlobalConfigCache } = await import('../infra/config/global/globalConfig.js');
|
||||
|
||||
describe('GlobalConfigSchema API key fields', () => {
|
||||
it('should accept config without API keys', () => {
|
||||
@ -280,3 +280,65 @@ describe('resolveOpenaiApiKey', () => {
|
||||
expect(key).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveOpencodeApiKey', () => {
|
||||
const originalEnv = process.env['TAKT_OPENCODE_API_KEY'];
|
||||
|
||||
beforeEach(() => {
|
||||
invalidateGlobalConfigCache();
|
||||
mkdirSync(taktDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalEnv !== undefined) {
|
||||
process.env['TAKT_OPENCODE_API_KEY'] = originalEnv;
|
||||
} else {
|
||||
delete process.env['TAKT_OPENCODE_API_KEY'];
|
||||
}
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should return env var when set', () => {
|
||||
process.env['TAKT_OPENCODE_API_KEY'] = 'sk-opencode-from-env';
|
||||
const yaml = [
|
||||
'language: en',
|
||||
'default_piece: default',
|
||||
'log_level: info',
|
||||
'provider: claude',
|
||||
'opencode_api_key: sk-opencode-from-yaml',
|
||||
].join('\n');
|
||||
writeFileSync(configPath, yaml, 'utf-8');
|
||||
|
||||
const key = resolveOpencodeApiKey();
|
||||
expect(key).toBe('sk-opencode-from-env');
|
||||
});
|
||||
|
||||
it('should fall back to config when env var is not set', () => {
|
||||
delete process.env['TAKT_OPENCODE_API_KEY'];
|
||||
const yaml = [
|
||||
'language: en',
|
||||
'default_piece: default',
|
||||
'log_level: info',
|
||||
'provider: claude',
|
||||
'opencode_api_key: sk-opencode-from-yaml',
|
||||
].join('\n');
|
||||
writeFileSync(configPath, yaml, 'utf-8');
|
||||
|
||||
const key = resolveOpencodeApiKey();
|
||||
expect(key).toBe('sk-opencode-from-yaml');
|
||||
});
|
||||
|
||||
it('should return undefined when neither env var nor config is set', () => {
|
||||
delete process.env['TAKT_OPENCODE_API_KEY'];
|
||||
const yaml = [
|
||||
'language: en',
|
||||
'default_piece: default',
|
||||
'log_level: info',
|
||||
'provider: claude',
|
||||
].join('\n');
|
||||
writeFileSync(configPath, yaml, 'utf-8');
|
||||
|
||||
const key = resolveOpencodeApiKey();
|
||||
expect(key).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@ -518,5 +518,65 @@ describe('loadGlobalConfig', () => {
|
||||
|
||||
expect(() => loadGlobalConfig()).not.toThrow();
|
||||
});
|
||||
|
||||
it('should throw when provider is opencode but model is a Claude alias (opus)', () => {
|
||||
const taktDir = join(testHomeDir, '.takt');
|
||||
mkdirSync(taktDir, { recursive: true });
|
||||
writeFileSync(
|
||||
getGlobalConfigPath(),
|
||||
'provider: opencode\nmodel: opus\n',
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
expect(() => loadGlobalConfig()).toThrow(/model 'opus' is a Claude model alias but provider is 'opencode'/);
|
||||
});
|
||||
|
||||
it('should throw when provider is opencode but model is sonnet', () => {
|
||||
const taktDir = join(testHomeDir, '.takt');
|
||||
mkdirSync(taktDir, { recursive: true });
|
||||
writeFileSync(
|
||||
getGlobalConfigPath(),
|
||||
'provider: opencode\nmodel: sonnet\n',
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
expect(() => loadGlobalConfig()).toThrow(/model 'sonnet' is a Claude model alias but provider is 'opencode'/);
|
||||
});
|
||||
|
||||
it('should throw when provider is opencode but model is haiku', () => {
|
||||
const taktDir = join(testHomeDir, '.takt');
|
||||
mkdirSync(taktDir, { recursive: true });
|
||||
writeFileSync(
|
||||
getGlobalConfigPath(),
|
||||
'provider: opencode\nmodel: haiku\n',
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
expect(() => loadGlobalConfig()).toThrow(/model 'haiku' is a Claude model alias but provider is 'opencode'/);
|
||||
});
|
||||
|
||||
it('should not throw when provider is opencode with a compatible model', () => {
|
||||
const taktDir = join(testHomeDir, '.takt');
|
||||
mkdirSync(taktDir, { recursive: true });
|
||||
writeFileSync(
|
||||
getGlobalConfigPath(),
|
||||
'provider: opencode\nmodel: gpt-4o\n',
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
expect(() => loadGlobalConfig()).not.toThrow();
|
||||
});
|
||||
|
||||
it('should not throw when provider is opencode without a model', () => {
|
||||
const taktDir = join(testHomeDir, '.takt');
|
||||
mkdirSync(taktDir, { recursive: true });
|
||||
writeFileSync(
|
||||
getGlobalConfigPath(),
|
||||
'provider: opencode\n',
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
expect(() => loadGlobalConfig()).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
70
src/__tests__/opencode-config.test.ts
Normal file
70
src/__tests__/opencode-config.test.ts
Normal file
@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Tests for OpenCode integration in schemas and global config
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
GlobalConfigSchema,
|
||||
ProjectConfigSchema,
|
||||
CustomAgentConfigSchema,
|
||||
PieceMovementRawSchema,
|
||||
ParallelSubMovementRawSchema,
|
||||
} from '../core/models/index.js';
|
||||
|
||||
describe('Schemas accept opencode provider', () => {
|
||||
it('should accept opencode in GlobalConfigSchema provider field', () => {
|
||||
const result = GlobalConfigSchema.parse({ provider: 'opencode' });
|
||||
expect(result.provider).toBe('opencode');
|
||||
});
|
||||
|
||||
it('should accept opencode in GlobalConfigSchema persona_providers field', () => {
|
||||
const result = GlobalConfigSchema.parse({
|
||||
persona_providers: { coder: 'opencode' },
|
||||
});
|
||||
expect(result.persona_providers).toEqual({ coder: 'opencode' });
|
||||
});
|
||||
|
||||
it('should accept opencode_api_key in GlobalConfigSchema', () => {
|
||||
const result = GlobalConfigSchema.parse({
|
||||
opencode_api_key: 'test-key-123',
|
||||
});
|
||||
expect(result.opencode_api_key).toBe('test-key-123');
|
||||
});
|
||||
|
||||
it('should accept opencode in ProjectConfigSchema', () => {
|
||||
const result = ProjectConfigSchema.parse({ provider: 'opencode' });
|
||||
expect(result.provider).toBe('opencode');
|
||||
});
|
||||
|
||||
it('should accept opencode in CustomAgentConfigSchema', () => {
|
||||
const result = CustomAgentConfigSchema.parse({
|
||||
name: 'test',
|
||||
prompt: 'You are a test agent',
|
||||
provider: 'opencode',
|
||||
});
|
||||
expect(result.provider).toBe('opencode');
|
||||
});
|
||||
|
||||
it('should accept opencode in PieceMovementRawSchema', () => {
|
||||
const result = PieceMovementRawSchema.parse({
|
||||
name: 'test-movement',
|
||||
provider: 'opencode',
|
||||
});
|
||||
expect(result.provider).toBe('opencode');
|
||||
});
|
||||
|
||||
it('should accept opencode in ParallelSubMovementRawSchema', () => {
|
||||
const result = ParallelSubMovementRawSchema.parse({
|
||||
name: 'sub-1',
|
||||
provider: 'opencode',
|
||||
});
|
||||
expect(result.provider).toBe('opencode');
|
||||
});
|
||||
|
||||
it('should still accept existing providers (claude, codex, mock)', () => {
|
||||
for (const provider of ['claude', 'codex', 'mock']) {
|
||||
const result = GlobalConfigSchema.parse({ provider });
|
||||
expect(result.provider).toBe(provider);
|
||||
}
|
||||
});
|
||||
});
|
||||
67
src/__tests__/opencode-provider.test.ts
Normal file
67
src/__tests__/opencode-provider.test.ts
Normal file
@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Tests for OpenCode provider implementation
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { OpenCodeProvider } from '../infra/providers/opencode.js';
|
||||
import { ProviderRegistry } from '../infra/providers/index.js';
|
||||
|
||||
describe('OpenCodeProvider', () => {
|
||||
it('should throw when claudeAgent is specified', () => {
|
||||
const provider = new OpenCodeProvider();
|
||||
|
||||
expect(() => provider.setup({
|
||||
name: 'test',
|
||||
claudeAgent: 'some-agent',
|
||||
})).toThrow('Claude Code agent calls are not supported by the OpenCode provider');
|
||||
});
|
||||
|
||||
it('should throw when claudeSkill is specified', () => {
|
||||
const provider = new OpenCodeProvider();
|
||||
|
||||
expect(() => provider.setup({
|
||||
name: 'test',
|
||||
claudeSkill: 'some-skill',
|
||||
})).toThrow('Claude Code skill calls are not supported by the OpenCode provider');
|
||||
});
|
||||
|
||||
it('should return a ProviderAgent when setup with name only', () => {
|
||||
const provider = new OpenCodeProvider();
|
||||
const agent = provider.setup({ name: 'test' });
|
||||
|
||||
expect(agent).toBeDefined();
|
||||
expect(typeof agent.call).toBe('function');
|
||||
});
|
||||
|
||||
it('should return a ProviderAgent when setup with systemPrompt', () => {
|
||||
const provider = new OpenCodeProvider();
|
||||
const agent = provider.setup({
|
||||
name: 'test',
|
||||
systemPrompt: 'You are a helpful assistant.',
|
||||
});
|
||||
|
||||
expect(agent).toBeDefined();
|
||||
expect(typeof agent.call).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ProviderRegistry with OpenCode', () => {
|
||||
it('should return OpenCode provider from registry', () => {
|
||||
ProviderRegistry.resetInstance();
|
||||
const registry = ProviderRegistry.getInstance();
|
||||
const provider = registry.get('opencode');
|
||||
|
||||
expect(provider).toBeDefined();
|
||||
expect(provider).toBeInstanceOf(OpenCodeProvider);
|
||||
});
|
||||
|
||||
it('should setup an agent through the registry', () => {
|
||||
ProviderRegistry.resetInstance();
|
||||
const registry = ProviderRegistry.getInstance();
|
||||
const provider = registry.get('opencode');
|
||||
const agent = provider.setup({ name: 'test' });
|
||||
|
||||
expect(agent).toBeDefined();
|
||||
expect(typeof agent.call).toBe('function');
|
||||
});
|
||||
});
|
||||
364
src/__tests__/opencode-stream-handler.test.ts
Normal file
364
src/__tests__/opencode-stream-handler.test.ts
Normal file
@ -0,0 +1,364 @@
|
||||
/**
|
||||
* Tests for OpenCode stream event handling
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import {
|
||||
createStreamTrackingState,
|
||||
emitInit,
|
||||
emitText,
|
||||
emitThinking,
|
||||
emitToolUse,
|
||||
emitToolResult,
|
||||
emitResult,
|
||||
handlePartUpdated,
|
||||
type OpenCodeTextPart,
|
||||
type OpenCodeReasoningPart,
|
||||
type OpenCodeToolPart,
|
||||
} from '../infra/opencode/OpenCodeStreamHandler.js';
|
||||
import type { StreamCallback } from '../core/piece/types.js';
|
||||
|
||||
describe('createStreamTrackingState', () => {
|
||||
it('should create fresh state with empty collections', () => {
|
||||
const state = createStreamTrackingState();
|
||||
|
||||
expect(state.textOffsets.size).toBe(0);
|
||||
expect(state.thinkingOffsets.size).toBe(0);
|
||||
expect(state.startedTools.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitInit', () => {
|
||||
it('should emit init event with model and sessionId', () => {
|
||||
const onStream = vi.fn();
|
||||
|
||||
emitInit(onStream, 'gpt-4', 'session-123');
|
||||
|
||||
expect(onStream).toHaveBeenCalledOnce();
|
||||
expect(onStream).toHaveBeenCalledWith({
|
||||
type: 'init',
|
||||
data: { model: 'gpt-4', sessionId: 'session-123' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should use default model name when model is undefined', () => {
|
||||
const onStream = vi.fn();
|
||||
|
||||
emitInit(onStream, undefined, 'session-abc');
|
||||
|
||||
expect(onStream).toHaveBeenCalledWith({
|
||||
type: 'init',
|
||||
data: { model: 'opencode', sessionId: 'session-abc' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should not emit when onStream is undefined', () => {
|
||||
emitInit(undefined, 'gpt-4', 'session-123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitText', () => {
|
||||
it('should emit text event', () => {
|
||||
const onStream = vi.fn();
|
||||
|
||||
emitText(onStream, 'Hello world');
|
||||
|
||||
expect(onStream).toHaveBeenCalledWith({
|
||||
type: 'text',
|
||||
data: { text: 'Hello world' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should not emit when text is empty', () => {
|
||||
const onStream = vi.fn();
|
||||
|
||||
emitText(onStream, '');
|
||||
|
||||
expect(onStream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not emit when onStream is undefined', () => {
|
||||
emitText(undefined, 'Hello');
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitThinking', () => {
|
||||
it('should emit thinking event', () => {
|
||||
const onStream = vi.fn();
|
||||
|
||||
emitThinking(onStream, 'Reasoning...');
|
||||
|
||||
expect(onStream).toHaveBeenCalledWith({
|
||||
type: 'thinking',
|
||||
data: { thinking: 'Reasoning...' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should not emit when thinking is empty', () => {
|
||||
const onStream = vi.fn();
|
||||
|
||||
emitThinking(onStream, '');
|
||||
|
||||
expect(onStream).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitToolUse', () => {
|
||||
it('should emit tool_use event', () => {
|
||||
const onStream = vi.fn();
|
||||
|
||||
emitToolUse(onStream, 'Bash', { command: 'ls' }, 'tool-1');
|
||||
|
||||
expect(onStream).toHaveBeenCalledWith({
|
||||
type: 'tool_use',
|
||||
data: { tool: 'Bash', input: { command: 'ls' }, id: 'tool-1' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitToolResult', () => {
|
||||
it('should emit tool_result event for success', () => {
|
||||
const onStream = vi.fn();
|
||||
|
||||
emitToolResult(onStream, 'file.txt', false);
|
||||
|
||||
expect(onStream).toHaveBeenCalledWith({
|
||||
type: 'tool_result',
|
||||
data: { content: 'file.txt', isError: false },
|
||||
});
|
||||
});
|
||||
|
||||
it('should emit tool_result event for error', () => {
|
||||
const onStream = vi.fn();
|
||||
|
||||
emitToolResult(onStream, 'command not found', true);
|
||||
|
||||
expect(onStream).toHaveBeenCalledWith({
|
||||
type: 'tool_result',
|
||||
data: { content: 'command not found', isError: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitResult', () => {
|
||||
it('should emit result event for success', () => {
|
||||
const onStream = vi.fn();
|
||||
|
||||
emitResult(onStream, true, 'Completed', 'session-1');
|
||||
|
||||
expect(onStream).toHaveBeenCalledWith({
|
||||
type: 'result',
|
||||
data: {
|
||||
result: 'Completed',
|
||||
sessionId: 'session-1',
|
||||
success: true,
|
||||
error: undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should emit result event for failure', () => {
|
||||
const onStream = vi.fn();
|
||||
|
||||
emitResult(onStream, false, 'Network error', 'session-1');
|
||||
|
||||
expect(onStream).toHaveBeenCalledWith({
|
||||
type: 'result',
|
||||
data: {
|
||||
result: 'Network error',
|
||||
sessionId: 'session-1',
|
||||
success: false,
|
||||
error: 'Network error',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('handlePartUpdated', () => {
|
||||
it('should handle text part with delta', () => {
|
||||
const onStream = vi.fn();
|
||||
const state = createStreamTrackingState();
|
||||
|
||||
const part: OpenCodeTextPart = { id: 'p1', type: 'text', text: 'Hello world' };
|
||||
|
||||
handlePartUpdated(part, 'Hello', onStream, state);
|
||||
|
||||
expect(onStream).toHaveBeenCalledWith({
|
||||
type: 'text',
|
||||
data: { text: 'Hello' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle text part without delta using offset tracking', () => {
|
||||
const onStream = vi.fn();
|
||||
const state = createStreamTrackingState();
|
||||
|
||||
const part1: OpenCodeTextPart = { id: 'p1', type: 'text', text: 'Hello' };
|
||||
handlePartUpdated(part1, undefined, onStream, state);
|
||||
|
||||
expect(onStream).toHaveBeenCalledWith({
|
||||
type: 'text',
|
||||
data: { text: 'Hello' },
|
||||
});
|
||||
|
||||
onStream.mockClear();
|
||||
|
||||
const part2: OpenCodeTextPart = { id: 'p1', type: 'text', text: 'Hello world' };
|
||||
handlePartUpdated(part2, undefined, onStream, state);
|
||||
|
||||
expect(onStream).toHaveBeenCalledWith({
|
||||
type: 'text',
|
||||
data: { text: ' world' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should not emit duplicate text when offset has not changed', () => {
|
||||
const onStream = vi.fn();
|
||||
const state = createStreamTrackingState();
|
||||
|
||||
const part: OpenCodeTextPart = { id: 'p1', type: 'text', text: 'Hello' };
|
||||
handlePartUpdated(part, undefined, onStream, state);
|
||||
onStream.mockClear();
|
||||
|
||||
handlePartUpdated(part, undefined, onStream, state);
|
||||
|
||||
expect(onStream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle reasoning part with delta', () => {
|
||||
const onStream = vi.fn();
|
||||
const state = createStreamTrackingState();
|
||||
|
||||
const part: OpenCodeReasoningPart = { id: 'r1', type: 'reasoning', text: 'Thinking...' };
|
||||
|
||||
handlePartUpdated(part, 'Thinking', onStream, state);
|
||||
|
||||
expect(onStream).toHaveBeenCalledWith({
|
||||
type: 'thinking',
|
||||
data: { thinking: 'Thinking' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle reasoning part without delta using offset tracking', () => {
|
||||
const onStream = vi.fn();
|
||||
const state = createStreamTrackingState();
|
||||
|
||||
const part: OpenCodeReasoningPart = { id: 'r1', type: 'reasoning', text: 'Step 1' };
|
||||
handlePartUpdated(part, undefined, onStream, state);
|
||||
|
||||
expect(onStream).toHaveBeenCalledWith({
|
||||
type: 'thinking',
|
||||
data: { thinking: 'Step 1' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle tool part in running state', () => {
|
||||
const onStream = vi.fn();
|
||||
const state = createStreamTrackingState();
|
||||
|
||||
const part: OpenCodeToolPart = {
|
||||
id: 't1',
|
||||
type: 'tool',
|
||||
callID: 'call-1',
|
||||
tool: 'Bash',
|
||||
state: { status: 'running', input: { command: 'ls' } },
|
||||
};
|
||||
|
||||
handlePartUpdated(part, undefined, onStream, state);
|
||||
|
||||
expect(onStream).toHaveBeenCalledWith({
|
||||
type: 'tool_use',
|
||||
data: { tool: 'Bash', input: { command: 'ls' }, id: 'call-1' },
|
||||
});
|
||||
expect(state.startedTools.has('call-1')).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle tool part in completed state', () => {
|
||||
const onStream: StreamCallback = vi.fn();
|
||||
const state = createStreamTrackingState();
|
||||
|
||||
const part: OpenCodeToolPart = {
|
||||
id: 't1',
|
||||
type: 'tool',
|
||||
callID: 'call-1',
|
||||
tool: 'Bash',
|
||||
state: {
|
||||
status: 'completed',
|
||||
input: { command: 'ls' },
|
||||
output: 'file.txt',
|
||||
title: 'List files',
|
||||
},
|
||||
};
|
||||
|
||||
handlePartUpdated(part, undefined, onStream, state);
|
||||
|
||||
expect(onStream).toHaveBeenCalledTimes(2);
|
||||
expect(onStream).toHaveBeenNthCalledWith(1, {
|
||||
type: 'tool_use',
|
||||
data: { tool: 'Bash', input: { command: 'ls' }, id: 'call-1' },
|
||||
});
|
||||
expect(onStream).toHaveBeenNthCalledWith(2, {
|
||||
type: 'tool_result',
|
||||
data: { content: 'file.txt', isError: false },
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle tool part in error state', () => {
|
||||
const onStream: StreamCallback = vi.fn();
|
||||
const state = createStreamTrackingState();
|
||||
|
||||
const part: OpenCodeToolPart = {
|
||||
id: 't1',
|
||||
type: 'tool',
|
||||
callID: 'call-1',
|
||||
tool: 'Bash',
|
||||
state: {
|
||||
status: 'error',
|
||||
input: { command: 'rm -rf /' },
|
||||
error: 'Permission denied',
|
||||
},
|
||||
};
|
||||
|
||||
handlePartUpdated(part, undefined, onStream, state);
|
||||
|
||||
expect(onStream).toHaveBeenCalledTimes(2);
|
||||
expect(onStream).toHaveBeenNthCalledWith(2, {
|
||||
type: 'tool_result',
|
||||
data: { content: 'Permission denied', isError: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('should not emit duplicate tool_use for already-started tool', () => {
|
||||
const onStream: StreamCallback = vi.fn();
|
||||
const state = createStreamTrackingState();
|
||||
state.startedTools.add('call-1');
|
||||
|
||||
const part: OpenCodeToolPart = {
|
||||
id: 't1',
|
||||
type: 'tool',
|
||||
callID: 'call-1',
|
||||
tool: 'Bash',
|
||||
state: { status: 'running', input: { command: 'ls' } },
|
||||
};
|
||||
|
||||
handlePartUpdated(part, undefined, onStream, state);
|
||||
|
||||
expect(onStream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should ignore unknown part types', () => {
|
||||
const onStream = vi.fn();
|
||||
const state = createStreamTrackingState();
|
||||
|
||||
handlePartUpdated({ id: 'x1', type: 'unknown' }, undefined, onStream, state);
|
||||
|
||||
expect(onStream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not emit when onStream is undefined', () => {
|
||||
const state = createStreamTrackingState();
|
||||
|
||||
const part: OpenCodeTextPart = { id: 'p1', type: 'text', text: 'Hello' };
|
||||
handlePartUpdated(part, 'Hello', undefined, state);
|
||||
});
|
||||
});
|
||||
30
src/__tests__/opencode-types.test.ts
Normal file
30
src/__tests__/opencode-types.test.ts
Normal file
@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Tests for OpenCode type definitions and permission mapping
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mapToOpenCodePermissionReply } from '../infra/opencode/types.js';
|
||||
import type { PermissionMode } from '../core/models/index.js';
|
||||
|
||||
describe('mapToOpenCodePermissionReply', () => {
|
||||
it('should map readonly to reject', () => {
|
||||
expect(mapToOpenCodePermissionReply('readonly')).toBe('reject');
|
||||
});
|
||||
|
||||
it('should map edit to once', () => {
|
||||
expect(mapToOpenCodePermissionReply('edit')).toBe('once');
|
||||
});
|
||||
|
||||
it('should map full to always', () => {
|
||||
expect(mapToOpenCodePermissionReply('full')).toBe('always');
|
||||
});
|
||||
|
||||
it('should handle all PermissionMode values', () => {
|
||||
const modes: PermissionMode[] = ['readonly', 'edit', 'full'];
|
||||
const expectedReplies = ['reject', 'once', 'always'];
|
||||
|
||||
modes.forEach((mode, index) => {
|
||||
expect(mapToOpenCodePermissionReply(mode)).toBe(expectedReplies[index]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -13,7 +13,7 @@ export interface RunAgentOptions {
|
||||
abortSignal?: AbortSignal;
|
||||
sessionId?: string;
|
||||
model?: string;
|
||||
provider?: 'claude' | 'codex' | 'mock';
|
||||
provider?: 'claude' | 'codex' | 'opencode' | 'mock';
|
||||
/** Resolved path to persona prompt file */
|
||||
personaPath?: string;
|
||||
/** Allowed tools for this agent run */
|
||||
|
||||
@ -46,7 +46,7 @@ program
|
||||
.option('-b, --branch <name>', 'Branch name (auto-generated if omitted)')
|
||||
.option('--auto-pr', 'Create PR after successful execution')
|
||||
.option('--repo <owner/repo>', 'Repository (defaults to current)')
|
||||
.option('--provider <name>', 'Override agent provider (claude|codex|mock)')
|
||||
.option('--provider <name>', 'Override agent provider (claude|codex|opencode|mock)')
|
||||
.option('--model <name>', 'Override agent model')
|
||||
.option('-t, --task <string>', 'Task content (as alternative to GitHub issue)')
|
||||
.option('--pipeline', 'Pipeline mode: non-interactive, no worktree, direct branch creation')
|
||||
|
||||
@ -10,7 +10,7 @@ export interface CustomAgentConfig {
|
||||
allowedTools?: string[];
|
||||
claudeAgent?: string;
|
||||
claudeSkill?: string;
|
||||
provider?: 'claude' | 'codex' | 'mock';
|
||||
provider?: 'claude' | 'codex' | 'opencode' | 'mock';
|
||||
model?: string;
|
||||
}
|
||||
|
||||
@ -52,7 +52,7 @@ export interface GlobalConfig {
|
||||
language: Language;
|
||||
defaultPiece: string;
|
||||
logLevel: 'debug' | 'info' | 'warn' | 'error';
|
||||
provider?: 'claude' | 'codex' | 'mock';
|
||||
provider?: 'claude' | 'codex' | 'opencode' | 'mock';
|
||||
model?: string;
|
||||
debug?: DebugConfig;
|
||||
/** Directory for shared clones (worktree_dir in config). If empty, uses ../{clone-name} relative to project */
|
||||
@ -67,6 +67,8 @@ export interface GlobalConfig {
|
||||
anthropicApiKey?: string;
|
||||
/** OpenAI API key for Codex SDK (overridden by TAKT_OPENAI_API_KEY env var) */
|
||||
openaiApiKey?: string;
|
||||
/** OpenCode API key for OpenCode SDK (overridden by TAKT_OPENCODE_API_KEY env var) */
|
||||
opencodeApiKey?: string;
|
||||
/** Pipeline execution settings */
|
||||
pipeline?: PipelineConfig;
|
||||
/** Minimal output mode for CI - suppress AI output to prevent sensitive information leaks */
|
||||
@ -76,7 +78,7 @@ export interface GlobalConfig {
|
||||
/** Path to piece categories file (default: ~/.takt/preferences/piece-categories.yaml) */
|
||||
pieceCategoriesFile?: string;
|
||||
/** Per-persona provider overrides (e.g., { coder: 'codex' }) */
|
||||
personaProviders?: Record<string, 'claude' | 'codex' | 'mock'>;
|
||||
personaProviders?: Record<string, 'claude' | 'codex' | 'opencode' | 'mock'>;
|
||||
/** Branch name generation strategy: 'romaji' (fast, default) or 'ai' (slow) */
|
||||
branchNameStrategy?: 'romaji' | 'ai';
|
||||
/** Prevent macOS idle sleep during takt execution using caffeinate (default: false) */
|
||||
@ -97,5 +99,5 @@ export interface GlobalConfig {
|
||||
export interface ProjectConfig {
|
||||
piece?: string;
|
||||
agents?: CustomAgentConfig[];
|
||||
provider?: 'claude' | 'codex' | 'mock';
|
||||
provider?: 'claude' | 'codex' | 'opencode' | 'mock';
|
||||
}
|
||||
|
||||
@ -97,7 +97,7 @@ export interface PieceMovement {
|
||||
/** Resolved absolute path to persona prompt file (set by loader) */
|
||||
personaPath?: string;
|
||||
/** Provider override for this movement */
|
||||
provider?: 'claude' | 'codex' | 'mock';
|
||||
provider?: 'claude' | 'codex' | 'opencode' | 'mock';
|
||||
/** Model override for this movement */
|
||||
model?: string;
|
||||
/** Permission mode for tool execution in this movement */
|
||||
|
||||
@ -183,7 +183,7 @@ export const ParallelSubMovementRawSchema = z.object({
|
||||
knowledge: z.union([z.string(), z.array(z.string())]).optional(),
|
||||
allowed_tools: z.array(z.string()).optional(),
|
||||
mcp_servers: McpServersSchema,
|
||||
provider: z.enum(['claude', 'codex', 'mock']).optional(),
|
||||
provider: z.enum(['claude', 'codex', 'opencode', 'mock']).optional(),
|
||||
model: z.string().optional(),
|
||||
permission_mode: PermissionModeSchema.optional(),
|
||||
edit: z.boolean().optional(),
|
||||
@ -213,7 +213,7 @@ export const PieceMovementRawSchema = z.object({
|
||||
knowledge: z.union([z.string(), z.array(z.string())]).optional(),
|
||||
allowed_tools: z.array(z.string()).optional(),
|
||||
mcp_servers: McpServersSchema,
|
||||
provider: z.enum(['claude', 'codex', 'mock']).optional(),
|
||||
provider: z.enum(['claude', 'codex', 'opencode', 'mock']).optional(),
|
||||
model: z.string().optional(),
|
||||
/** Permission mode for tool execution in this movement */
|
||||
permission_mode: PermissionModeSchema.optional(),
|
||||
@ -296,7 +296,7 @@ export const CustomAgentConfigSchema = z.object({
|
||||
allowed_tools: z.array(z.string()).optional(),
|
||||
claude_agent: z.string().optional(),
|
||||
claude_skill: z.string().optional(),
|
||||
provider: z.enum(['claude', 'codex', 'mock']).optional(),
|
||||
provider: z.enum(['claude', 'codex', 'opencode', 'mock']).optional(),
|
||||
model: z.string().optional(),
|
||||
}).refine(
|
||||
(data) => data.prompt_file || data.prompt || data.claude_agent || data.claude_skill,
|
||||
@ -338,7 +338,7 @@ export const GlobalConfigSchema = z.object({
|
||||
language: LanguageSchema.optional().default(DEFAULT_LANGUAGE),
|
||||
default_piece: z.string().optional().default('default'),
|
||||
log_level: z.enum(['debug', 'info', 'warn', 'error']).optional().default('info'),
|
||||
provider: z.enum(['claude', 'codex', 'mock']).optional().default('claude'),
|
||||
provider: z.enum(['claude', 'codex', 'opencode', 'mock']).optional().default('claude'),
|
||||
model: z.string().optional(),
|
||||
debug: DebugConfigSchema.optional(),
|
||||
/** Directory for shared clones (worktree_dir in config). If empty, uses ../{clone-name} relative to project */
|
||||
@ -353,6 +353,8 @@ export const GlobalConfigSchema = z.object({
|
||||
anthropic_api_key: z.string().optional(),
|
||||
/** OpenAI API key for Codex SDK (overridden by TAKT_OPENAI_API_KEY env var) */
|
||||
openai_api_key: z.string().optional(),
|
||||
/** OpenCode API key for OpenCode SDK (overridden by TAKT_OPENCODE_API_KEY env var) */
|
||||
opencode_api_key: z.string().optional(),
|
||||
/** Pipeline execution settings */
|
||||
pipeline: PipelineConfigSchema.optional(),
|
||||
/** Minimal output mode for CI - suppress AI output to prevent sensitive information leaks */
|
||||
@ -362,7 +364,7 @@ export const GlobalConfigSchema = z.object({
|
||||
/** Path to piece categories file (default: ~/.takt/preferences/piece-categories.yaml) */
|
||||
piece_categories_file: z.string().optional(),
|
||||
/** Per-persona provider overrides (e.g., { coder: 'codex' }) */
|
||||
persona_providers: z.record(z.string(), z.enum(['claude', 'codex', 'mock'])).optional(),
|
||||
persona_providers: z.record(z.string(), z.enum(['claude', 'codex', 'opencode', 'mock'])).optional(),
|
||||
/** Branch name generation strategy: 'romaji' (fast, default) or 'ai' (slow) */
|
||||
branch_name_strategy: z.enum(['romaji', 'ai']).optional(),
|
||||
/** Prevent macOS idle sleep during takt execution using caffeinate (default: false) */
|
||||
@ -389,5 +391,5 @@ export const GlobalConfigSchema = z.object({
|
||||
export const ProjectConfigSchema = z.object({
|
||||
piece: z.string().optional(),
|
||||
agents: z.array(CustomAgentConfigSchema).optional(),
|
||||
provider: z.enum(['claude', 'codex', 'mock']).optional(),
|
||||
provider: z.enum(['claude', 'codex', 'opencode', 'mock']).optional(),
|
||||
});
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
import type { PermissionResult, PermissionUpdate } from '@anthropic-ai/claude-agent-sdk';
|
||||
import type { PieceMovement, AgentResponse, PieceState, Language, LoopMonitorConfig } from '../models/types.js';
|
||||
|
||||
export type ProviderType = 'claude' | 'codex' | 'mock';
|
||||
export type ProviderType = 'claude' | 'codex' | 'opencode' | 'mock';
|
||||
|
||||
export interface StreamInitEventData {
|
||||
model: string;
|
||||
|
||||
@ -19,10 +19,10 @@ const CLAUDE_MODEL_ALIASES = new Set(['opus', 'sonnet', 'haiku']);
|
||||
function validateProviderModelCompatibility(provider: string | undefined, model: string | undefined): void {
|
||||
if (!provider || !model) return;
|
||||
|
||||
if (provider === 'codex' && CLAUDE_MODEL_ALIASES.has(model)) {
|
||||
if ((provider === 'codex' || provider === 'opencode') && CLAUDE_MODEL_ALIASES.has(model)) {
|
||||
throw new Error(
|
||||
`Configuration error: model '${model}' is a Claude model alias but provider is '${provider}'. ` +
|
||||
`Either change the provider to 'claude' or specify a Codex-compatible model.`
|
||||
`Either change the provider to 'claude' or specify a ${provider}-compatible model.`
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -98,6 +98,7 @@ export class GlobalConfigManager {
|
||||
enableBuiltinPieces: parsed.enable_builtin_pieces,
|
||||
anthropicApiKey: parsed.anthropic_api_key,
|
||||
openaiApiKey: parsed.openai_api_key,
|
||||
opencodeApiKey: parsed.opencode_api_key,
|
||||
pipeline: parsed.pipeline ? {
|
||||
defaultBranchPrefix: parsed.pipeline.default_branch_prefix,
|
||||
commitMessageTemplate: parsed.pipeline.commit_message_template,
|
||||
@ -162,6 +163,9 @@ export class GlobalConfigManager {
|
||||
if (config.openaiApiKey) {
|
||||
raw.openai_api_key = config.openaiApiKey;
|
||||
}
|
||||
if (config.opencodeApiKey) {
|
||||
raw.opencode_api_key = config.opencodeApiKey;
|
||||
}
|
||||
if (config.pipeline) {
|
||||
const pipelineRaw: Record<string, unknown> = {};
|
||||
if (config.pipeline.defaultBranchPrefix) pipelineRaw.default_branch_prefix = config.pipeline.defaultBranchPrefix;
|
||||
@ -272,7 +276,7 @@ export function setLanguage(language: Language): void {
|
||||
saveGlobalConfig(config);
|
||||
}
|
||||
|
||||
export function setProvider(provider: 'claude' | 'codex'): void {
|
||||
export function setProvider(provider: 'claude' | 'codex' | 'opencode'): void {
|
||||
const config = loadGlobalConfig();
|
||||
config.provider = provider;
|
||||
saveGlobalConfig(config);
|
||||
@ -310,6 +314,22 @@ export function resolveOpenaiApiKey(): string | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the OpenCode API key.
|
||||
* Priority: TAKT_OPENCODE_API_KEY env var > config.yaml > undefined
|
||||
*/
|
||||
export function resolveOpencodeApiKey(): string | undefined {
|
||||
const envKey = process.env['TAKT_OPENCODE_API_KEY'];
|
||||
if (envKey) return envKey;
|
||||
|
||||
try {
|
||||
const config = loadGlobalConfig();
|
||||
return config.opencodeApiKey;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Load project-level debug configuration (from .takt/config.yaml) */
|
||||
export function loadProjectDebugConfig(projectDir: string): DebugConfig | undefined {
|
||||
const configPath = getProjectConfigPath(projectDir);
|
||||
|
||||
@ -14,6 +14,7 @@ export {
|
||||
setProvider,
|
||||
resolveAnthropicApiKey,
|
||||
resolveOpenaiApiKey,
|
||||
resolveOpencodeApiKey,
|
||||
loadProjectDebugConfig,
|
||||
getEffectiveDebugConfig,
|
||||
} from './globalConfig.js';
|
||||
|
||||
@ -56,14 +56,15 @@ export async function promptLanguageSelection(): Promise<Language> {
|
||||
* Prompt user to select provider for resources.
|
||||
* Exits process if cancelled (initial setup is required).
|
||||
*/
|
||||
export async function promptProviderSelection(): Promise<'claude' | 'codex'> {
|
||||
const options: { label: string; value: 'claude' | 'codex' }[] = [
|
||||
export async function promptProviderSelection(): Promise<'claude' | 'codex' | 'opencode'> {
|
||||
const options: { label: string; value: 'claude' | 'codex' | 'opencode' }[] = [
|
||||
{ label: 'Claude Code', value: 'claude' },
|
||||
{ label: 'Codex', value: 'codex' },
|
||||
{ label: 'OpenCode', value: 'opencode' },
|
||||
];
|
||||
|
||||
const result = await selectOptionWithDefault(
|
||||
'Select provider (Claude Code or Codex) / プロバイダーを選択してください:',
|
||||
'Select provider / プロバイダーを選択してください:',
|
||||
options,
|
||||
'claude'
|
||||
);
|
||||
|
||||
@ -17,7 +17,7 @@ export interface ProjectLocalConfig {
|
||||
/** Current piece name */
|
||||
piece?: string;
|
||||
/** Provider selection for agent runtime */
|
||||
provider?: 'claude' | 'codex';
|
||||
provider?: 'claude' | 'codex' | 'opencode';
|
||||
/** Permission mode setting */
|
||||
permissionMode?: PermissionMode;
|
||||
/** Verbose output mode */
|
||||
|
||||
222
src/infra/opencode/OpenCodeStreamHandler.ts
Normal file
222
src/infra/opencode/OpenCodeStreamHandler.ts
Normal file
@ -0,0 +1,222 @@
|
||||
/**
|
||||
* OpenCode stream event handling.
|
||||
*
|
||||
* Converts OpenCode SDK SSE events into the unified StreamCallback format
|
||||
* used throughout the takt codebase.
|
||||
*/
|
||||
|
||||
import type { StreamCallback } from '../claude/index.js';
|
||||
|
||||
/** Subset of OpenCode Part types relevant for stream handling */
|
||||
export interface OpenCodeTextPart {
|
||||
id: string;
|
||||
type: 'text';
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface OpenCodeReasoningPart {
|
||||
id: string;
|
||||
type: 'reasoning';
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface OpenCodeToolPart {
|
||||
id: string;
|
||||
type: 'tool';
|
||||
callID: string;
|
||||
tool: string;
|
||||
state: OpenCodeToolState;
|
||||
}
|
||||
|
||||
export type OpenCodeToolState =
|
||||
| { status: 'pending'; input: Record<string, unknown> }
|
||||
| { status: 'running'; input: Record<string, unknown>; title?: string }
|
||||
| { status: 'completed'; input: Record<string, unknown>; output: string; title: string }
|
||||
| { status: 'error'; input: Record<string, unknown>; error: string };
|
||||
|
||||
export type OpenCodePart = OpenCodeTextPart | OpenCodeReasoningPart | OpenCodeToolPart | { id: string; type: string };
|
||||
|
||||
/** OpenCode SSE event types relevant for stream handling */
|
||||
export interface OpenCodeMessagePartUpdatedEvent {
|
||||
type: 'message.part.updated';
|
||||
properties: { part: OpenCodePart; delta?: string };
|
||||
}
|
||||
|
||||
export interface OpenCodeSessionIdleEvent {
|
||||
type: 'session.idle';
|
||||
properties: { sessionID: string };
|
||||
}
|
||||
|
||||
export interface OpenCodeSessionErrorEvent {
|
||||
type: 'session.error';
|
||||
properties: {
|
||||
sessionID?: string;
|
||||
error?: { name: string; data: { message: string } };
|
||||
};
|
||||
}
|
||||
|
||||
export interface OpenCodePermissionAskedEvent {
|
||||
type: 'permission.asked';
|
||||
properties: {
|
||||
id: string;
|
||||
sessionID: string;
|
||||
permission: string;
|
||||
patterns: string[];
|
||||
metadata: Record<string, unknown>;
|
||||
always: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export type OpenCodeStreamEvent =
|
||||
| OpenCodeMessagePartUpdatedEvent
|
||||
| OpenCodeSessionIdleEvent
|
||||
| OpenCodeSessionErrorEvent
|
||||
| OpenCodePermissionAskedEvent
|
||||
| { type: string; properties: Record<string, unknown> };
|
||||
|
||||
/** Tracking state for stream offsets during a single OpenCode session */
|
||||
export interface StreamTrackingState {
|
||||
textOffsets: Map<string, number>;
|
||||
thinkingOffsets: Map<string, number>;
|
||||
startedTools: Set<string>;
|
||||
}
|
||||
|
||||
export function createStreamTrackingState(): StreamTrackingState {
|
||||
return {
|
||||
textOffsets: new Map<string, number>(),
|
||||
thinkingOffsets: new Map<string, number>(),
|
||||
startedTools: new Set<string>(),
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Stream emission helpers ----
|
||||
|
||||
export function emitInit(
|
||||
onStream: StreamCallback | undefined,
|
||||
model: string | undefined,
|
||||
sessionId: string,
|
||||
): void {
|
||||
if (!onStream) return;
|
||||
onStream({
|
||||
type: 'init',
|
||||
data: {
|
||||
model: model || 'opencode',
|
||||
sessionId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function emitText(onStream: StreamCallback | undefined, text: string): void {
|
||||
if (!onStream || !text) return;
|
||||
onStream({ type: 'text', data: { text } });
|
||||
}
|
||||
|
||||
export function emitThinking(onStream: StreamCallback | undefined, thinking: string): void {
|
||||
if (!onStream || !thinking) return;
|
||||
onStream({ type: 'thinking', data: { thinking } });
|
||||
}
|
||||
|
||||
export function emitToolUse(
|
||||
onStream: StreamCallback | undefined,
|
||||
tool: string,
|
||||
input: Record<string, unknown>,
|
||||
id: string,
|
||||
): void {
|
||||
if (!onStream) return;
|
||||
onStream({ type: 'tool_use', data: { tool, input, id } });
|
||||
}
|
||||
|
||||
export function emitToolResult(
|
||||
onStream: StreamCallback | undefined,
|
||||
content: string,
|
||||
isError: boolean,
|
||||
): void {
|
||||
if (!onStream) return;
|
||||
onStream({ type: 'tool_result', data: { content, isError } });
|
||||
}
|
||||
|
||||
export function emitResult(
|
||||
onStream: StreamCallback | undefined,
|
||||
success: boolean,
|
||||
result: string,
|
||||
sessionId: string,
|
||||
): void {
|
||||
if (!onStream) return;
|
||||
onStream({
|
||||
type: 'result',
|
||||
data: {
|
||||
result,
|
||||
sessionId,
|
||||
success,
|
||||
error: success ? undefined : result || undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Process a message.part.updated event and emit appropriate stream events */
|
||||
export function handlePartUpdated(
|
||||
part: OpenCodePart,
|
||||
delta: string | undefined,
|
||||
onStream: StreamCallback | undefined,
|
||||
state: StreamTrackingState,
|
||||
): void {
|
||||
if (!onStream) return;
|
||||
|
||||
switch (part.type) {
|
||||
case 'text': {
|
||||
const textPart = part as OpenCodeTextPart;
|
||||
if (delta) {
|
||||
emitText(onStream, delta);
|
||||
} else {
|
||||
const prev = state.textOffsets.get(textPart.id) ?? 0;
|
||||
if (textPart.text.length > prev) {
|
||||
emitText(onStream, textPart.text.slice(prev));
|
||||
state.textOffsets.set(textPart.id, textPart.text.length);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'reasoning': {
|
||||
const reasoningPart = part as OpenCodeReasoningPart;
|
||||
if (delta) {
|
||||
emitThinking(onStream, delta);
|
||||
} else {
|
||||
const prev = state.thinkingOffsets.get(reasoningPart.id) ?? 0;
|
||||
if (reasoningPart.text.length > prev) {
|
||||
emitThinking(onStream, reasoningPart.text.slice(prev));
|
||||
state.thinkingOffsets.set(reasoningPart.id, reasoningPart.text.length);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'tool': {
|
||||
const toolPart = part as OpenCodeToolPart;
|
||||
handleToolPartUpdated(toolPart, onStream, state);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function handleToolPartUpdated(
|
||||
toolPart: OpenCodeToolPart,
|
||||
onStream: StreamCallback,
|
||||
state: StreamTrackingState,
|
||||
): void {
|
||||
const toolId = toolPart.callID || toolPart.id;
|
||||
|
||||
if (!state.startedTools.has(toolId)) {
|
||||
emitToolUse(onStream, toolPart.tool, toolPart.state.input, toolId);
|
||||
state.startedTools.add(toolId);
|
||||
}
|
||||
|
||||
switch (toolPart.state.status) {
|
||||
case 'completed':
|
||||
emitToolResult(onStream, toolPart.state.output, false);
|
||||
break;
|
||||
case 'error':
|
||||
emitToolResult(onStream, toolPart.state.error, true);
|
||||
break;
|
||||
}
|
||||
}
|
||||
331
src/infra/opencode/client.ts
Normal file
331
src/infra/opencode/client.ts
Normal file
@ -0,0 +1,331 @@
|
||||
/**
|
||||
* OpenCode SDK integration for agent interactions
|
||||
*
|
||||
* Uses @opencode-ai/sdk/v2 for native TypeScript integration.
|
||||
* Follows the same patterns as the Codex client.
|
||||
*/
|
||||
|
||||
import { createOpencode } from '@opencode-ai/sdk/v2';
|
||||
import type { AgentResponse } from '../../core/models/index.js';
|
||||
import { createLogger, getErrorMessage } from '../../shared/utils/index.js';
|
||||
import { mapToOpenCodePermissionReply, type OpenCodeCallOptions } from './types.js';
|
||||
import {
|
||||
type OpenCodeStreamEvent,
|
||||
type OpenCodePart,
|
||||
type OpenCodeTextPart,
|
||||
createStreamTrackingState,
|
||||
emitInit,
|
||||
emitResult,
|
||||
handlePartUpdated,
|
||||
} from './OpenCodeStreamHandler.js';
|
||||
|
||||
export type { OpenCodeCallOptions } from './types.js';
|
||||
|
||||
const log = createLogger('opencode-sdk');
|
||||
const OPENCODE_STREAM_IDLE_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
const OPENCODE_STREAM_ABORTED_MESSAGE = 'OpenCode execution aborted';
|
||||
const OPENCODE_RETRY_MAX_ATTEMPTS = 3;
|
||||
const OPENCODE_RETRY_BASE_DELAY_MS = 250;
|
||||
const OPENCODE_RETRYABLE_ERROR_PATTERNS = [
|
||||
'stream disconnected before completion',
|
||||
'transport error',
|
||||
'network error',
|
||||
'error decoding response body',
|
||||
'econnreset',
|
||||
'etimedout',
|
||||
'eai_again',
|
||||
'fetch failed',
|
||||
];
|
||||
|
||||
/**
|
||||
* Client for OpenCode SDK agent interactions.
|
||||
*
|
||||
* Handles session management, streaming event conversion,
|
||||
* permission auto-reply, and response processing.
|
||||
*/
|
||||
export class OpenCodeClient {
|
||||
private isRetriableError(message: string, aborted: boolean, abortCause?: 'timeout' | 'external'): boolean {
|
||||
if (aborted || abortCause) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const lower = message.toLowerCase();
|
||||
return OPENCODE_RETRYABLE_ERROR_PATTERNS.some((pattern) => lower.includes(pattern));
|
||||
}
|
||||
|
||||
private async waitForRetryDelay(attempt: number, signal?: AbortSignal): Promise<void> {
|
||||
const delayMs = OPENCODE_RETRY_BASE_DELAY_MS * (2 ** Math.max(0, attempt - 1));
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (signal) {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
}
|
||||
resolve();
|
||||
}, delayMs);
|
||||
|
||||
const onAbort = (): void => {
|
||||
clearTimeout(timeoutId);
|
||||
if (signal) {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
}
|
||||
reject(new Error(OPENCODE_STREAM_ABORTED_MESSAGE));
|
||||
};
|
||||
|
||||
if (signal) {
|
||||
if (signal.aborted) {
|
||||
onAbort();
|
||||
return;
|
||||
}
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Call OpenCode with an agent prompt */
|
||||
async call(
|
||||
agentType: string,
|
||||
prompt: string,
|
||||
options: OpenCodeCallOptions,
|
||||
): Promise<AgentResponse> {
|
||||
const fullPrompt = options.systemPrompt
|
||||
? `${options.systemPrompt}\n\n${prompt}`
|
||||
: prompt;
|
||||
|
||||
for (let attempt = 1; attempt <= OPENCODE_RETRY_MAX_ATTEMPTS; attempt++) {
|
||||
let idleTimeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
const streamAbortController = new AbortController();
|
||||
const timeoutMessage = `OpenCode stream timed out after ${Math.floor(OPENCODE_STREAM_IDLE_TIMEOUT_MS / 60000)} minutes of inactivity`;
|
||||
let abortCause: 'timeout' | 'external' | undefined;
|
||||
let serverClose: (() => void) | undefined;
|
||||
|
||||
const resetIdleTimeout = (): void => {
|
||||
if (idleTimeoutId !== undefined) {
|
||||
clearTimeout(idleTimeoutId);
|
||||
}
|
||||
idleTimeoutId = setTimeout(() => {
|
||||
abortCause = 'timeout';
|
||||
streamAbortController.abort();
|
||||
}, OPENCODE_STREAM_IDLE_TIMEOUT_MS);
|
||||
};
|
||||
|
||||
const onExternalAbort = (): void => {
|
||||
abortCause = 'external';
|
||||
streamAbortController.abort();
|
||||
};
|
||||
|
||||
if (options.abortSignal) {
|
||||
if (options.abortSignal.aborted) {
|
||||
streamAbortController.abort();
|
||||
} else {
|
||||
options.abortSignal.addEventListener('abort', onExternalAbort, { once: true });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
log.debug('Starting OpenCode session', {
|
||||
agentType,
|
||||
model: options.model,
|
||||
hasSystemPrompt: !!options.systemPrompt,
|
||||
attempt,
|
||||
});
|
||||
|
||||
const { client, server } = await createOpencode({
|
||||
signal: streamAbortController.signal,
|
||||
...(options.opencodeApiKey
|
||||
? { config: { provider: { opencode: { options: { apiKey: options.opencodeApiKey } } } } }
|
||||
: {}),
|
||||
});
|
||||
serverClose = server.close;
|
||||
|
||||
const sessionResult = options.sessionId
|
||||
? { data: { id: options.sessionId } }
|
||||
: await client.session.create({ directory: options.cwd });
|
||||
|
||||
const sessionId = sessionResult.data?.id;
|
||||
if (!sessionId) {
|
||||
throw new Error('Failed to create OpenCode session');
|
||||
}
|
||||
|
||||
const { stream } = await client.event.subscribe({ directory: options.cwd });
|
||||
resetIdleTimeout();
|
||||
|
||||
await client.session.promptAsync({
|
||||
sessionID: sessionId,
|
||||
directory: options.cwd,
|
||||
...(options.model ? { model: { providerID: 'opencode', modelID: options.model } } : {}),
|
||||
parts: [{ type: 'text' as const, text: fullPrompt }],
|
||||
});
|
||||
|
||||
emitInit(options.onStream, options.model, sessionId);
|
||||
|
||||
let content = '';
|
||||
let success = true;
|
||||
let failureMessage = '';
|
||||
const state = createStreamTrackingState();
|
||||
const textContentParts = new Map<string, string>();
|
||||
|
||||
for await (const event of stream) {
|
||||
if (streamAbortController.signal.aborted) break;
|
||||
resetIdleTimeout();
|
||||
|
||||
const sseEvent = event as OpenCodeStreamEvent;
|
||||
|
||||
if (sseEvent.type === 'message.part.updated') {
|
||||
const props = sseEvent.properties as { part: OpenCodePart; delta?: string };
|
||||
const part = props.part;
|
||||
const delta = props.delta;
|
||||
|
||||
if (part.type === 'text') {
|
||||
const textPart = part as OpenCodeTextPart;
|
||||
textContentParts.set(textPart.id, textPart.text);
|
||||
}
|
||||
|
||||
handlePartUpdated(part, delta, options.onStream, state);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (sseEvent.type === 'permission.asked') {
|
||||
const permProps = sseEvent.properties as {
|
||||
id: string;
|
||||
sessionID: string;
|
||||
};
|
||||
if (permProps.sessionID === sessionId) {
|
||||
const reply = options.permissionMode
|
||||
? mapToOpenCodePermissionReply(options.permissionMode)
|
||||
: 'once';
|
||||
await client.permission.reply({
|
||||
requestID: permProps.id,
|
||||
directory: options.cwd,
|
||||
reply,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (sseEvent.type === 'session.idle') {
|
||||
const idleProps = sseEvent.properties as { sessionID: string };
|
||||
if (idleProps.sessionID === sessionId) {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (sseEvent.type === 'session.error') {
|
||||
const errorProps = sseEvent.properties as {
|
||||
sessionID?: string;
|
||||
error?: { name: string; data: { message: string } };
|
||||
};
|
||||
if (!errorProps.sessionID || errorProps.sessionID === sessionId) {
|
||||
success = false;
|
||||
failureMessage = errorProps.error?.data?.message ?? 'OpenCode session error';
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
content = [...textContentParts.values()].join('\n');
|
||||
|
||||
if (!success) {
|
||||
const message = failureMessage || 'OpenCode execution failed';
|
||||
const retriable = this.isRetriableError(message, streamAbortController.signal.aborted, abortCause);
|
||||
if (retriable && attempt < OPENCODE_RETRY_MAX_ATTEMPTS) {
|
||||
log.info('Retrying OpenCode call after transient failure', { agentType, attempt, message });
|
||||
await this.waitForRetryDelay(attempt, options.abortSignal);
|
||||
continue;
|
||||
}
|
||||
|
||||
emitResult(options.onStream, false, message, sessionId);
|
||||
return {
|
||||
persona: agentType,
|
||||
status: 'error',
|
||||
content: message,
|
||||
timestamp: new Date(),
|
||||
sessionId,
|
||||
};
|
||||
}
|
||||
|
||||
const trimmed = content.trim();
|
||||
emitResult(options.onStream, true, trimmed, sessionId);
|
||||
|
||||
return {
|
||||
persona: agentType,
|
||||
status: 'done',
|
||||
content: trimmed,
|
||||
timestamp: new Date(),
|
||||
sessionId,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
const errorMessage = streamAbortController.signal.aborted
|
||||
? abortCause === 'timeout'
|
||||
? timeoutMessage
|
||||
: OPENCODE_STREAM_ABORTED_MESSAGE
|
||||
: message;
|
||||
|
||||
const retriable = this.isRetriableError(errorMessage, streamAbortController.signal.aborted, abortCause);
|
||||
if (retriable && attempt < OPENCODE_RETRY_MAX_ATTEMPTS) {
|
||||
log.info('Retrying OpenCode call after transient exception', { agentType, attempt, errorMessage });
|
||||
await this.waitForRetryDelay(attempt, options.abortSignal);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (options.sessionId) {
|
||||
emitResult(options.onStream, false, errorMessage, options.sessionId);
|
||||
}
|
||||
|
||||
return {
|
||||
persona: agentType,
|
||||
status: 'error',
|
||||
content: errorMessage,
|
||||
timestamp: new Date(),
|
||||
sessionId: options.sessionId,
|
||||
};
|
||||
} finally {
|
||||
if (idleTimeoutId !== undefined) {
|
||||
clearTimeout(idleTimeoutId);
|
||||
}
|
||||
if (options.abortSignal) {
|
||||
options.abortSignal.removeEventListener('abort', onExternalAbort);
|
||||
}
|
||||
if (serverClose) {
|
||||
serverClose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Unreachable: OpenCode retry loop exhausted without returning');
|
||||
}
|
||||
|
||||
/** Call OpenCode with a custom agent configuration (system prompt + prompt) */
|
||||
async callCustom(
|
||||
agentName: string,
|
||||
prompt: string,
|
||||
systemPrompt: string,
|
||||
options: OpenCodeCallOptions,
|
||||
): Promise<AgentResponse> {
|
||||
return this.call(agentName, prompt, {
|
||||
...options,
|
||||
systemPrompt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const defaultClient = new OpenCodeClient();
|
||||
|
||||
export async function callOpenCode(
|
||||
agentType: string,
|
||||
prompt: string,
|
||||
options: OpenCodeCallOptions,
|
||||
): Promise<AgentResponse> {
|
||||
return defaultClient.call(agentType, prompt, options);
|
||||
}
|
||||
|
||||
export async function callOpenCodeCustom(
|
||||
agentName: string,
|
||||
prompt: string,
|
||||
systemPrompt: string,
|
||||
options: OpenCodeCallOptions,
|
||||
): Promise<AgentResponse> {
|
||||
return defaultClient.callCustom(agentName, prompt, systemPrompt, options);
|
||||
}
|
||||
7
src/infra/opencode/index.ts
Normal file
7
src/infra/opencode/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
/**
|
||||
* OpenCode integration exports
|
||||
*/
|
||||
|
||||
export { OpenCodeClient, callOpenCode, callOpenCodeCustom } from './client.js';
|
||||
export { mapToOpenCodePermissionReply } from './types.js';
|
||||
export type { OpenCodeCallOptions, OpenCodePermissionReply } from './types.js';
|
||||
34
src/infra/opencode/types.ts
Normal file
34
src/infra/opencode/types.ts
Normal file
@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Type definitions for OpenCode SDK integration
|
||||
*/
|
||||
|
||||
import type { StreamCallback } from '../claude/index.js';
|
||||
import type { PermissionMode } from '../../core/models/index.js';
|
||||
|
||||
/** OpenCode permission reply values */
|
||||
export type OpenCodePermissionReply = 'once' | 'always' | 'reject';
|
||||
|
||||
/** Map TAKT PermissionMode to OpenCode permission reply */
|
||||
export function mapToOpenCodePermissionReply(mode: PermissionMode): OpenCodePermissionReply {
|
||||
const mapping: Record<PermissionMode, OpenCodePermissionReply> = {
|
||||
readonly: 'reject',
|
||||
edit: 'once',
|
||||
full: 'always',
|
||||
};
|
||||
return mapping[mode];
|
||||
}
|
||||
|
||||
/** Options for calling OpenCode */
|
||||
export interface OpenCodeCallOptions {
|
||||
cwd: string;
|
||||
abortSignal?: AbortSignal;
|
||||
sessionId?: string;
|
||||
model?: string;
|
||||
systemPrompt?: string;
|
||||
/** Permission mode for automatic permission handling */
|
||||
permissionMode?: PermissionMode;
|
||||
/** Enable streaming mode with callback (best-effort) */
|
||||
onStream?: StreamCallback;
|
||||
/** OpenCode API key */
|
||||
opencodeApiKey?: string;
|
||||
}
|
||||
@ -1,12 +1,13 @@
|
||||
/**
|
||||
* Provider abstraction layer
|
||||
*
|
||||
* Provides a unified interface for different agent providers (Claude, Codex, Mock).
|
||||
* Provides a unified interface for different agent providers (Claude, Codex, OpenCode, Mock).
|
||||
* This enables adding new providers without modifying the runner logic.
|
||||
*/
|
||||
|
||||
import { ClaudeProvider } from './claude.js';
|
||||
import { CodexProvider } from './codex.js';
|
||||
import { OpenCodeProvider } from './opencode.js';
|
||||
import { MockProvider } from './mock.js';
|
||||
import type { Provider, ProviderType } from './types.js';
|
||||
|
||||
@ -24,6 +25,7 @@ export class ProviderRegistry {
|
||||
this.providers = {
|
||||
claude: new ClaudeProvider(),
|
||||
codex: new CodexProvider(),
|
||||
opencode: new OpenCodeProvider(),
|
||||
mock: new MockProvider(),
|
||||
};
|
||||
}
|
||||
|
||||
47
src/infra/providers/opencode.ts
Normal file
47
src/infra/providers/opencode.ts
Normal file
@ -0,0 +1,47 @@
|
||||
/**
|
||||
* OpenCode provider implementation
|
||||
*/
|
||||
|
||||
import { callOpenCode, callOpenCodeCustom, type OpenCodeCallOptions } from '../opencode/index.js';
|
||||
import { resolveOpencodeApiKey } from '../config/index.js';
|
||||
import type { AgentResponse } from '../../core/models/index.js';
|
||||
import type { AgentSetup, Provider, ProviderAgent, ProviderCallOptions } from './types.js';
|
||||
|
||||
function toOpenCodeOptions(options: ProviderCallOptions): OpenCodeCallOptions {
|
||||
return {
|
||||
cwd: options.cwd,
|
||||
abortSignal: options.abortSignal,
|
||||
sessionId: options.sessionId,
|
||||
model: options.model,
|
||||
permissionMode: options.permissionMode,
|
||||
onStream: options.onStream,
|
||||
opencodeApiKey: options.opencodeApiKey ?? resolveOpencodeApiKey(),
|
||||
};
|
||||
}
|
||||
|
||||
/** OpenCode provider — delegates to OpenCode SDK */
|
||||
export class OpenCodeProvider implements Provider {
|
||||
setup(config: AgentSetup): ProviderAgent {
|
||||
if (config.claudeAgent) {
|
||||
throw new Error('Claude Code agent calls are not supported by the OpenCode provider');
|
||||
}
|
||||
if (config.claudeSkill) {
|
||||
throw new Error('Claude Code skill calls are not supported by the OpenCode provider');
|
||||
}
|
||||
|
||||
const { name, systemPrompt } = config;
|
||||
if (systemPrompt) {
|
||||
return {
|
||||
call: async (prompt: string, options: ProviderCallOptions): Promise<AgentResponse> => {
|
||||
return callOpenCodeCustom(name, prompt, systemPrompt, toOpenCodeOptions(options));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
call: async (prompt: string, options: ProviderCallOptions): Promise<AgentResponse> => {
|
||||
return callOpenCode(name, prompt, toOpenCodeOptions(options));
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -38,6 +38,8 @@ export interface ProviderCallOptions {
|
||||
anthropicApiKey?: string;
|
||||
/** OpenAI API key for Codex provider */
|
||||
openaiApiKey?: string;
|
||||
/** OpenCode API key for OpenCode provider */
|
||||
opencodeApiKey?: string;
|
||||
}
|
||||
|
||||
/** A configured agent ready to be called */
|
||||
@ -51,4 +53,4 @@ export interface Provider {
|
||||
}
|
||||
|
||||
/** Provider type */
|
||||
export type ProviderType = 'claude' | 'codex' | 'mock';
|
||||
export type ProviderType = 'claude' | 'codex' | 'opencode' | 'mock';
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user