From b80f6d0aa0a0136700791aba429f4c1cef4e773b Mon Sep 17 00:00:00 2001 From: nrs <38722970+nrslib@users.noreply.github.com> Date: Wed, 11 Feb 2026 06:35:50 +0900 Subject: [PATCH] takt: opencode (#222) --- package-lock.json | 7 + package.json | 1 + src/__tests__/apiKeyAuth.test.ts | 64 ++- src/__tests__/globalConfig-defaults.test.ts | 60 +++ src/__tests__/opencode-config.test.ts | 70 ++++ src/__tests__/opencode-provider.test.ts | 67 ++++ src/__tests__/opencode-stream-handler.test.ts | 364 ++++++++++++++++++ src/__tests__/opencode-types.test.ts | 30 ++ src/agents/types.ts | 2 +- src/app/cli/program.ts | 2 +- src/core/models/global-config.ts | 10 +- src/core/models/piece-types.ts | 2 +- src/core/models/schemas.ts | 14 +- src/core/piece/types.ts | 2 +- src/infra/config/global/globalConfig.ts | 26 +- src/infra/config/global/index.ts | 1 + src/infra/config/global/initialization.ts | 7 +- src/infra/config/types.ts | 2 +- src/infra/opencode/OpenCodeStreamHandler.ts | 222 +++++++++++ src/infra/opencode/client.ts | 331 ++++++++++++++++ src/infra/opencode/index.ts | 7 + src/infra/opencode/types.ts | 34 ++ src/infra/providers/index.ts | 4 +- src/infra/providers/opencode.ts | 47 +++ src/infra/providers/types.ts | 4 +- 25 files changed, 1356 insertions(+), 24 deletions(-) create mode 100644 src/__tests__/opencode-config.test.ts create mode 100644 src/__tests__/opencode-provider.test.ts create mode 100644 src/__tests__/opencode-stream-handler.test.ts create mode 100644 src/__tests__/opencode-types.test.ts create mode 100644 src/infra/opencode/OpenCodeStreamHandler.ts create mode 100644 src/infra/opencode/client.ts create mode 100644 src/infra/opencode/index.ts create mode 100644 src/infra/opencode/types.ts create mode 100644 src/infra/providers/opencode.ts diff --git a/package-lock.json b/package-lock.json index c63fda5..fa308cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index a9b3763..f205080 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/__tests__/apiKeyAuth.test.ts b/src/__tests__/apiKeyAuth.test.ts index dc418e2..f21f5db 100644 --- a/src/__tests__/apiKeyAuth.test.ts +++ b/src/__tests__/apiKeyAuth.test.ts @@ -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(); + }); +}); diff --git a/src/__tests__/globalConfig-defaults.test.ts b/src/__tests__/globalConfig-defaults.test.ts index ec4ec51..511d54e 100644 --- a/src/__tests__/globalConfig-defaults.test.ts +++ b/src/__tests__/globalConfig-defaults.test.ts @@ -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(); + }); }); }); diff --git a/src/__tests__/opencode-config.test.ts b/src/__tests__/opencode-config.test.ts new file mode 100644 index 0000000..6e181f6 --- /dev/null +++ b/src/__tests__/opencode-config.test.ts @@ -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); + } + }); +}); diff --git a/src/__tests__/opencode-provider.test.ts b/src/__tests__/opencode-provider.test.ts new file mode 100644 index 0000000..d7cbe74 --- /dev/null +++ b/src/__tests__/opencode-provider.test.ts @@ -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'); + }); +}); diff --git a/src/__tests__/opencode-stream-handler.test.ts b/src/__tests__/opencode-stream-handler.test.ts new file mode 100644 index 0000000..b4dbe4d --- /dev/null +++ b/src/__tests__/opencode-stream-handler.test.ts @@ -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); + }); +}); diff --git a/src/__tests__/opencode-types.test.ts b/src/__tests__/opencode-types.test.ts new file mode 100644 index 0000000..6251b8d --- /dev/null +++ b/src/__tests__/opencode-types.test.ts @@ -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]); + }); + }); +}); diff --git a/src/agents/types.ts b/src/agents/types.ts index cdfd2a6..d27882a 100644 --- a/src/agents/types.ts +++ b/src/agents/types.ts @@ -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 */ diff --git a/src/app/cli/program.ts b/src/app/cli/program.ts index 75b88ae..f605732 100644 --- a/src/app/cli/program.ts +++ b/src/app/cli/program.ts @@ -46,7 +46,7 @@ program .option('-b, --branch ', 'Branch name (auto-generated if omitted)') .option('--auto-pr', 'Create PR after successful execution') .option('--repo ', 'Repository (defaults to current)') - .option('--provider ', 'Override agent provider (claude|codex|mock)') + .option('--provider ', 'Override agent provider (claude|codex|opencode|mock)') .option('--model ', 'Override agent model') .option('-t, --task ', 'Task content (as alternative to GitHub issue)') .option('--pipeline', 'Pipeline mode: non-interactive, no worktree, direct branch creation') diff --git a/src/core/models/global-config.ts b/src/core/models/global-config.ts index 4f7e168..4974cd5 100644 --- a/src/core/models/global-config.ts +++ b/src/core/models/global-config.ts @@ -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; + personaProviders?: Record; /** 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'; } diff --git a/src/core/models/piece-types.ts b/src/core/models/piece-types.ts index 5ba4bce..7c029cc 100644 --- a/src/core/models/piece-types.ts +++ b/src/core/models/piece-types.ts @@ -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 */ diff --git a/src/core/models/schemas.ts b/src/core/models/schemas.ts index 6b1d18c..64a60d7 100644 --- a/src/core/models/schemas.ts +++ b/src/core/models/schemas.ts @@ -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(), }); diff --git a/src/core/piece/types.ts b/src/core/piece/types.ts index b4482eb..73b940d 100644 --- a/src/core/piece/types.ts +++ b/src/core/piece/types.ts @@ -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; diff --git a/src/infra/config/global/globalConfig.ts b/src/infra/config/global/globalConfig.ts index 169853b..1298b9a 100644 --- a/src/infra/config/global/globalConfig.ts +++ b/src/infra/config/global/globalConfig.ts @@ -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 = {}; 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); diff --git a/src/infra/config/global/index.ts b/src/infra/config/global/index.ts index 30b0b93..7f442b0 100644 --- a/src/infra/config/global/index.ts +++ b/src/infra/config/global/index.ts @@ -14,6 +14,7 @@ export { setProvider, resolveAnthropicApiKey, resolveOpenaiApiKey, + resolveOpencodeApiKey, loadProjectDebugConfig, getEffectiveDebugConfig, } from './globalConfig.js'; diff --git a/src/infra/config/global/initialization.ts b/src/infra/config/global/initialization.ts index 1caf1c5..ec5656a 100644 --- a/src/infra/config/global/initialization.ts +++ b/src/infra/config/global/initialization.ts @@ -56,14 +56,15 @@ export async function promptLanguageSelection(): Promise { * 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' ); diff --git a/src/infra/config/types.ts b/src/infra/config/types.ts index 4d227f9..f29d537 100644 --- a/src/infra/config/types.ts +++ b/src/infra/config/types.ts @@ -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 */ diff --git a/src/infra/opencode/OpenCodeStreamHandler.ts b/src/infra/opencode/OpenCodeStreamHandler.ts new file mode 100644 index 0000000..f6cb7b4 --- /dev/null +++ b/src/infra/opencode/OpenCodeStreamHandler.ts @@ -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 } + | { status: 'running'; input: Record; title?: string } + | { status: 'completed'; input: Record; output: string; title: string } + | { status: 'error'; input: Record; 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; + always: string[]; + }; +} + +export type OpenCodeStreamEvent = + | OpenCodeMessagePartUpdatedEvent + | OpenCodeSessionIdleEvent + | OpenCodeSessionErrorEvent + | OpenCodePermissionAskedEvent + | { type: string; properties: Record }; + +/** Tracking state for stream offsets during a single OpenCode session */ +export interface StreamTrackingState { + textOffsets: Map; + thinkingOffsets: Map; + startedTools: Set; +} + +export function createStreamTrackingState(): StreamTrackingState { + return { + textOffsets: new Map(), + thinkingOffsets: new Map(), + startedTools: new Set(), + }; +} + +// ---- 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, + 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; + } +} diff --git a/src/infra/opencode/client.ts b/src/infra/opencode/client.ts new file mode 100644 index 0000000..42f2918 --- /dev/null +++ b/src/infra/opencode/client.ts @@ -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 { + const delayMs = OPENCODE_RETRY_BASE_DELAY_MS * (2 ** Math.max(0, attempt - 1)); + await new Promise((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 { + const fullPrompt = options.systemPrompt + ? `${options.systemPrompt}\n\n${prompt}` + : prompt; + + for (let attempt = 1; attempt <= OPENCODE_RETRY_MAX_ATTEMPTS; attempt++) { + let idleTimeoutId: ReturnType | 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(); + + 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 { + return this.call(agentName, prompt, { + ...options, + systemPrompt, + }); + } +} + +const defaultClient = new OpenCodeClient(); + +export async function callOpenCode( + agentType: string, + prompt: string, + options: OpenCodeCallOptions, +): Promise { + return defaultClient.call(agentType, prompt, options); +} + +export async function callOpenCodeCustom( + agentName: string, + prompt: string, + systemPrompt: string, + options: OpenCodeCallOptions, +): Promise { + return defaultClient.callCustom(agentName, prompt, systemPrompt, options); +} diff --git a/src/infra/opencode/index.ts b/src/infra/opencode/index.ts new file mode 100644 index 0000000..1d36e84 --- /dev/null +++ b/src/infra/opencode/index.ts @@ -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'; diff --git a/src/infra/opencode/types.ts b/src/infra/opencode/types.ts new file mode 100644 index 0000000..c9ca828 --- /dev/null +++ b/src/infra/opencode/types.ts @@ -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 = { + 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; +} diff --git a/src/infra/providers/index.ts b/src/infra/providers/index.ts index 576744b..88659c8 100644 --- a/src/infra/providers/index.ts +++ b/src/infra/providers/index.ts @@ -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(), }; } diff --git a/src/infra/providers/opencode.ts b/src/infra/providers/opencode.ts new file mode 100644 index 0000000..5f83a11 --- /dev/null +++ b/src/infra/providers/opencode.ts @@ -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 => { + return callOpenCodeCustom(name, prompt, systemPrompt, toOpenCodeOptions(options)); + }, + }; + } + + return { + call: async (prompt: string, options: ProviderCallOptions): Promise => { + return callOpenCode(name, prompt, toOpenCodeOptions(options)); + }, + }; + } +} diff --git a/src/infra/providers/types.ts b/src/infra/providers/types.ts index df688d9..d2bc48d 100644 --- a/src/infra/providers/types.ts +++ b/src/infra/providers/types.ts @@ -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';