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": {
|
"dependencies": {
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.2.37",
|
"@anthropic-ai/claude-agent-sdk": "^0.2.37",
|
||||||
"@openai/codex-sdk": "^0.98.0",
|
"@openai/codex-sdk": "^0.98.0",
|
||||||
|
"@opencode-ai/sdk": "^1.1.53",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"commander": "^12.1.0",
|
"commander": "^12.1.0",
|
||||||
"update-notifier": "^7.3.1",
|
"update-notifier": "^7.3.1",
|
||||||
@ -936,6 +937,12 @@
|
|||||||
"node": ">=18"
|
"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": {
|
"node_modules/@pnpm/config.env-replace": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz",
|
||||||
|
|||||||
@ -59,6 +59,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.2.37",
|
"@anthropic-ai/claude-agent-sdk": "^0.2.37",
|
||||||
"@openai/codex-sdk": "^0.98.0",
|
"@openai/codex-sdk": "^0.98.0",
|
||||||
|
"@opencode-ai/sdk": "^1.1.53",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"commander": "^12.1.0",
|
"commander": "^12.1.0",
|
||||||
"update-notifier": "^7.3.1",
|
"update-notifier": "^7.3.1",
|
||||||
|
|||||||
@ -32,7 +32,7 @@ vi.mock('../infra/config/paths.js', async (importOriginal) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Import after mocking
|
// 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', () => {
|
describe('GlobalConfigSchema API key fields', () => {
|
||||||
it('should accept config without API keys', () => {
|
it('should accept config without API keys', () => {
|
||||||
@ -280,3 +280,65 @@ describe('resolveOpenaiApiKey', () => {
|
|||||||
expect(key).toBeUndefined();
|
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();
|
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;
|
abortSignal?: AbortSignal;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
provider?: 'claude' | 'codex' | 'mock';
|
provider?: 'claude' | 'codex' | 'opencode' | 'mock';
|
||||||
/** Resolved path to persona prompt file */
|
/** Resolved path to persona prompt file */
|
||||||
personaPath?: string;
|
personaPath?: string;
|
||||||
/** Allowed tools for this agent run */
|
/** Allowed tools for this agent run */
|
||||||
|
|||||||
@ -46,7 +46,7 @@ program
|
|||||||
.option('-b, --branch <name>', 'Branch name (auto-generated if omitted)')
|
.option('-b, --branch <name>', 'Branch name (auto-generated if omitted)')
|
||||||
.option('--auto-pr', 'Create PR after successful execution')
|
.option('--auto-pr', 'Create PR after successful execution')
|
||||||
.option('--repo <owner/repo>', 'Repository (defaults to current)')
|
.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('--model <name>', 'Override agent model')
|
||||||
.option('-t, --task <string>', 'Task content (as alternative to GitHub issue)')
|
.option('-t, --task <string>', 'Task content (as alternative to GitHub issue)')
|
||||||
.option('--pipeline', 'Pipeline mode: non-interactive, no worktree, direct branch creation')
|
.option('--pipeline', 'Pipeline mode: non-interactive, no worktree, direct branch creation')
|
||||||
|
|||||||
@ -10,7 +10,7 @@ export interface CustomAgentConfig {
|
|||||||
allowedTools?: string[];
|
allowedTools?: string[];
|
||||||
claudeAgent?: string;
|
claudeAgent?: string;
|
||||||
claudeSkill?: string;
|
claudeSkill?: string;
|
||||||
provider?: 'claude' | 'codex' | 'mock';
|
provider?: 'claude' | 'codex' | 'opencode' | 'mock';
|
||||||
model?: string;
|
model?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -52,7 +52,7 @@ export interface GlobalConfig {
|
|||||||
language: Language;
|
language: Language;
|
||||||
defaultPiece: string;
|
defaultPiece: string;
|
||||||
logLevel: 'debug' | 'info' | 'warn' | 'error';
|
logLevel: 'debug' | 'info' | 'warn' | 'error';
|
||||||
provider?: 'claude' | 'codex' | 'mock';
|
provider?: 'claude' | 'codex' | 'opencode' | 'mock';
|
||||||
model?: string;
|
model?: string;
|
||||||
debug?: DebugConfig;
|
debug?: DebugConfig;
|
||||||
/** Directory for shared clones (worktree_dir in config). If empty, uses ../{clone-name} relative to project */
|
/** 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;
|
anthropicApiKey?: string;
|
||||||
/** OpenAI API key for Codex SDK (overridden by TAKT_OPENAI_API_KEY env var) */
|
/** OpenAI API key for Codex SDK (overridden by TAKT_OPENAI_API_KEY env var) */
|
||||||
openaiApiKey?: string;
|
openaiApiKey?: string;
|
||||||
|
/** OpenCode API key for OpenCode SDK (overridden by TAKT_OPENCODE_API_KEY env var) */
|
||||||
|
opencodeApiKey?: string;
|
||||||
/** Pipeline execution settings */
|
/** Pipeline execution settings */
|
||||||
pipeline?: PipelineConfig;
|
pipeline?: PipelineConfig;
|
||||||
/** Minimal output mode for CI - suppress AI output to prevent sensitive information leaks */
|
/** 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) */
|
/** Path to piece categories file (default: ~/.takt/preferences/piece-categories.yaml) */
|
||||||
pieceCategoriesFile?: string;
|
pieceCategoriesFile?: string;
|
||||||
/** Per-persona provider overrides (e.g., { coder: 'codex' }) */
|
/** 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) */
|
/** Branch name generation strategy: 'romaji' (fast, default) or 'ai' (slow) */
|
||||||
branchNameStrategy?: 'romaji' | 'ai';
|
branchNameStrategy?: 'romaji' | 'ai';
|
||||||
/** Prevent macOS idle sleep during takt execution using caffeinate (default: false) */
|
/** Prevent macOS idle sleep during takt execution using caffeinate (default: false) */
|
||||||
@ -97,5 +99,5 @@ export interface GlobalConfig {
|
|||||||
export interface ProjectConfig {
|
export interface ProjectConfig {
|
||||||
piece?: string;
|
piece?: string;
|
||||||
agents?: CustomAgentConfig[];
|
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) */
|
/** Resolved absolute path to persona prompt file (set by loader) */
|
||||||
personaPath?: string;
|
personaPath?: string;
|
||||||
/** Provider override for this movement */
|
/** Provider override for this movement */
|
||||||
provider?: 'claude' | 'codex' | 'mock';
|
provider?: 'claude' | 'codex' | 'opencode' | 'mock';
|
||||||
/** Model override for this movement */
|
/** Model override for this movement */
|
||||||
model?: string;
|
model?: string;
|
||||||
/** Permission mode for tool execution in this movement */
|
/** 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(),
|
knowledge: z.union([z.string(), z.array(z.string())]).optional(),
|
||||||
allowed_tools: z.array(z.string()).optional(),
|
allowed_tools: z.array(z.string()).optional(),
|
||||||
mcp_servers: McpServersSchema,
|
mcp_servers: McpServersSchema,
|
||||||
provider: z.enum(['claude', 'codex', 'mock']).optional(),
|
provider: z.enum(['claude', 'codex', 'opencode', 'mock']).optional(),
|
||||||
model: z.string().optional(),
|
model: z.string().optional(),
|
||||||
permission_mode: PermissionModeSchema.optional(),
|
permission_mode: PermissionModeSchema.optional(),
|
||||||
edit: z.boolean().optional(),
|
edit: z.boolean().optional(),
|
||||||
@ -213,7 +213,7 @@ export const PieceMovementRawSchema = z.object({
|
|||||||
knowledge: z.union([z.string(), z.array(z.string())]).optional(),
|
knowledge: z.union([z.string(), z.array(z.string())]).optional(),
|
||||||
allowed_tools: z.array(z.string()).optional(),
|
allowed_tools: z.array(z.string()).optional(),
|
||||||
mcp_servers: McpServersSchema,
|
mcp_servers: McpServersSchema,
|
||||||
provider: z.enum(['claude', 'codex', 'mock']).optional(),
|
provider: z.enum(['claude', 'codex', 'opencode', 'mock']).optional(),
|
||||||
model: z.string().optional(),
|
model: z.string().optional(),
|
||||||
/** Permission mode for tool execution in this movement */
|
/** Permission mode for tool execution in this movement */
|
||||||
permission_mode: PermissionModeSchema.optional(),
|
permission_mode: PermissionModeSchema.optional(),
|
||||||
@ -296,7 +296,7 @@ export const CustomAgentConfigSchema = z.object({
|
|||||||
allowed_tools: z.array(z.string()).optional(),
|
allowed_tools: z.array(z.string()).optional(),
|
||||||
claude_agent: z.string().optional(),
|
claude_agent: z.string().optional(),
|
||||||
claude_skill: 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(),
|
model: z.string().optional(),
|
||||||
}).refine(
|
}).refine(
|
||||||
(data) => data.prompt_file || data.prompt || data.claude_agent || data.claude_skill,
|
(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),
|
language: LanguageSchema.optional().default(DEFAULT_LANGUAGE),
|
||||||
default_piece: z.string().optional().default('default'),
|
default_piece: z.string().optional().default('default'),
|
||||||
log_level: z.enum(['debug', 'info', 'warn', 'error']).optional().default('info'),
|
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(),
|
model: z.string().optional(),
|
||||||
debug: DebugConfigSchema.optional(),
|
debug: DebugConfigSchema.optional(),
|
||||||
/** Directory for shared clones (worktree_dir in config). If empty, uses ../{clone-name} relative to project */
|
/** 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(),
|
anthropic_api_key: z.string().optional(),
|
||||||
/** OpenAI API key for Codex SDK (overridden by TAKT_OPENAI_API_KEY env var) */
|
/** OpenAI API key for Codex SDK (overridden by TAKT_OPENAI_API_KEY env var) */
|
||||||
openai_api_key: z.string().optional(),
|
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 execution settings */
|
||||||
pipeline: PipelineConfigSchema.optional(),
|
pipeline: PipelineConfigSchema.optional(),
|
||||||
/** Minimal output mode for CI - suppress AI output to prevent sensitive information leaks */
|
/** 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) */
|
/** Path to piece categories file (default: ~/.takt/preferences/piece-categories.yaml) */
|
||||||
piece_categories_file: z.string().optional(),
|
piece_categories_file: z.string().optional(),
|
||||||
/** Per-persona provider overrides (e.g., { coder: 'codex' }) */
|
/** 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 generation strategy: 'romaji' (fast, default) or 'ai' (slow) */
|
||||||
branch_name_strategy: z.enum(['romaji', 'ai']).optional(),
|
branch_name_strategy: z.enum(['romaji', 'ai']).optional(),
|
||||||
/** Prevent macOS idle sleep during takt execution using caffeinate (default: false) */
|
/** 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({
|
export const ProjectConfigSchema = z.object({
|
||||||
piece: z.string().optional(),
|
piece: z.string().optional(),
|
||||||
agents: z.array(CustomAgentConfigSchema).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 { PermissionResult, PermissionUpdate } from '@anthropic-ai/claude-agent-sdk';
|
||||||
import type { PieceMovement, AgentResponse, PieceState, Language, LoopMonitorConfig } from '../models/types.js';
|
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 {
|
export interface StreamInitEventData {
|
||||||
model: string;
|
model: string;
|
||||||
|
|||||||
@ -19,10 +19,10 @@ const CLAUDE_MODEL_ALIASES = new Set(['opus', 'sonnet', 'haiku']);
|
|||||||
function validateProviderModelCompatibility(provider: string | undefined, model: string | undefined): void {
|
function validateProviderModelCompatibility(provider: string | undefined, model: string | undefined): void {
|
||||||
if (!provider || !model) return;
|
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(
|
throw new Error(
|
||||||
`Configuration error: model '${model}' is a Claude model alias but provider is '${provider}'. ` +
|
`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,
|
enableBuiltinPieces: parsed.enable_builtin_pieces,
|
||||||
anthropicApiKey: parsed.anthropic_api_key,
|
anthropicApiKey: parsed.anthropic_api_key,
|
||||||
openaiApiKey: parsed.openai_api_key,
|
openaiApiKey: parsed.openai_api_key,
|
||||||
|
opencodeApiKey: parsed.opencode_api_key,
|
||||||
pipeline: parsed.pipeline ? {
|
pipeline: parsed.pipeline ? {
|
||||||
defaultBranchPrefix: parsed.pipeline.default_branch_prefix,
|
defaultBranchPrefix: parsed.pipeline.default_branch_prefix,
|
||||||
commitMessageTemplate: parsed.pipeline.commit_message_template,
|
commitMessageTemplate: parsed.pipeline.commit_message_template,
|
||||||
@ -162,6 +163,9 @@ export class GlobalConfigManager {
|
|||||||
if (config.openaiApiKey) {
|
if (config.openaiApiKey) {
|
||||||
raw.openai_api_key = config.openaiApiKey;
|
raw.openai_api_key = config.openaiApiKey;
|
||||||
}
|
}
|
||||||
|
if (config.opencodeApiKey) {
|
||||||
|
raw.opencode_api_key = config.opencodeApiKey;
|
||||||
|
}
|
||||||
if (config.pipeline) {
|
if (config.pipeline) {
|
||||||
const pipelineRaw: Record<string, unknown> = {};
|
const pipelineRaw: Record<string, unknown> = {};
|
||||||
if (config.pipeline.defaultBranchPrefix) pipelineRaw.default_branch_prefix = config.pipeline.defaultBranchPrefix;
|
if (config.pipeline.defaultBranchPrefix) pipelineRaw.default_branch_prefix = config.pipeline.defaultBranchPrefix;
|
||||||
@ -272,7 +276,7 @@ export function setLanguage(language: Language): void {
|
|||||||
saveGlobalConfig(config);
|
saveGlobalConfig(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setProvider(provider: 'claude' | 'codex'): void {
|
export function setProvider(provider: 'claude' | 'codex' | 'opencode'): void {
|
||||||
const config = loadGlobalConfig();
|
const config = loadGlobalConfig();
|
||||||
config.provider = provider;
|
config.provider = provider;
|
||||||
saveGlobalConfig(config);
|
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) */
|
/** Load project-level debug configuration (from .takt/config.yaml) */
|
||||||
export function loadProjectDebugConfig(projectDir: string): DebugConfig | undefined {
|
export function loadProjectDebugConfig(projectDir: string): DebugConfig | undefined {
|
||||||
const configPath = getProjectConfigPath(projectDir);
|
const configPath = getProjectConfigPath(projectDir);
|
||||||
|
|||||||
@ -14,6 +14,7 @@ export {
|
|||||||
setProvider,
|
setProvider,
|
||||||
resolveAnthropicApiKey,
|
resolveAnthropicApiKey,
|
||||||
resolveOpenaiApiKey,
|
resolveOpenaiApiKey,
|
||||||
|
resolveOpencodeApiKey,
|
||||||
loadProjectDebugConfig,
|
loadProjectDebugConfig,
|
||||||
getEffectiveDebugConfig,
|
getEffectiveDebugConfig,
|
||||||
} from './globalConfig.js';
|
} from './globalConfig.js';
|
||||||
|
|||||||
@ -56,14 +56,15 @@ export async function promptLanguageSelection(): Promise<Language> {
|
|||||||
* Prompt user to select provider for resources.
|
* Prompt user to select provider for resources.
|
||||||
* Exits process if cancelled (initial setup is required).
|
* Exits process if cancelled (initial setup is required).
|
||||||
*/
|
*/
|
||||||
export async function promptProviderSelection(): Promise<'claude' | 'codex'> {
|
export async function promptProviderSelection(): Promise<'claude' | 'codex' | 'opencode'> {
|
||||||
const options: { label: string; value: 'claude' | 'codex' }[] = [
|
const options: { label: string; value: 'claude' | 'codex' | 'opencode' }[] = [
|
||||||
{ label: 'Claude Code', value: 'claude' },
|
{ label: 'Claude Code', value: 'claude' },
|
||||||
{ label: 'Codex', value: 'codex' },
|
{ label: 'Codex', value: 'codex' },
|
||||||
|
{ label: 'OpenCode', value: 'opencode' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const result = await selectOptionWithDefault(
|
const result = await selectOptionWithDefault(
|
||||||
'Select provider (Claude Code or Codex) / プロバイダーを選択してください:',
|
'Select provider / プロバイダーを選択してください:',
|
||||||
options,
|
options,
|
||||||
'claude'
|
'claude'
|
||||||
);
|
);
|
||||||
|
|||||||
@ -17,7 +17,7 @@ export interface ProjectLocalConfig {
|
|||||||
/** Current piece name */
|
/** Current piece name */
|
||||||
piece?: string;
|
piece?: string;
|
||||||
/** Provider selection for agent runtime */
|
/** Provider selection for agent runtime */
|
||||||
provider?: 'claude' | 'codex';
|
provider?: 'claude' | 'codex' | 'opencode';
|
||||||
/** Permission mode setting */
|
/** Permission mode setting */
|
||||||
permissionMode?: PermissionMode;
|
permissionMode?: PermissionMode;
|
||||||
/** Verbose output mode */
|
/** 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
|
* 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.
|
* This enables adding new providers without modifying the runner logic.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ClaudeProvider } from './claude.js';
|
import { ClaudeProvider } from './claude.js';
|
||||||
import { CodexProvider } from './codex.js';
|
import { CodexProvider } from './codex.js';
|
||||||
|
import { OpenCodeProvider } from './opencode.js';
|
||||||
import { MockProvider } from './mock.js';
|
import { MockProvider } from './mock.js';
|
||||||
import type { Provider, ProviderType } from './types.js';
|
import type { Provider, ProviderType } from './types.js';
|
||||||
|
|
||||||
@ -24,6 +25,7 @@ export class ProviderRegistry {
|
|||||||
this.providers = {
|
this.providers = {
|
||||||
claude: new ClaudeProvider(),
|
claude: new ClaudeProvider(),
|
||||||
codex: new CodexProvider(),
|
codex: new CodexProvider(),
|
||||||
|
opencode: new OpenCodeProvider(),
|
||||||
mock: new MockProvider(),
|
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;
|
anthropicApiKey?: string;
|
||||||
/** OpenAI API key for Codex provider */
|
/** OpenAI API key for Codex provider */
|
||||||
openaiApiKey?: string;
|
openaiApiKey?: string;
|
||||||
|
/** OpenCode API key for OpenCode provider */
|
||||||
|
opencodeApiKey?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A configured agent ready to be called */
|
/** A configured agent ready to be called */
|
||||||
@ -51,4 +53,4 @@ export interface Provider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Provider type */
|
/** Provider type */
|
||||||
export type ProviderType = 'claude' | 'codex' | 'mock';
|
export type ProviderType = 'claude' | 'codex' | 'opencode' | 'mock';
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user