diff --git a/src/config.ts b/src/config.ts index be802df9..1adb40a4 100644 --- a/src/config.ts +++ b/src/config.ts @@ -4,7 +4,7 @@ import * as os from 'node:os'; import * as path from 'node:path'; /** Agent engine backing a bot. */ -export type EngineName = 'claude' | 'kimi' | 'codex'; +export type EngineName = 'claude' | 'kimi' | 'codex' | 'opencode'; /** Shared config fields used by MessageBridge and Executors (platform-agnostic). */ export interface BotConfigBase { @@ -40,6 +40,8 @@ export interface BotConfigBase { }; /** Codex-specific overrides. Populated only when engine === 'codex'. */ codex?: CodexBotConfig; + /** OpenCode-specific overrides. Populated only when engine === 'opencode'. */ + opencode?: OpenCodeBotConfig; /** * Stage 4 — opt-in to the persistent Claude process pool. When enabled, * each chatId is backed by a long-lived Claude Code process (managed by @@ -78,6 +80,15 @@ export interface CodexBotConfig { env?: Record; } +export interface OpenCodeBotConfig { + executable?: string; + model?: string; + contextWindow?: number; + dangerouslySkipPermissions?: boolean; + extraArgs?: string[]; + env?: Record; +} + /** Feishu bot config (extends base with Feishu credentials). */ export interface BotConfig extends BotConfigBase { feishu: { @@ -182,11 +193,22 @@ export interface CodexJsonConfig { env?: Record; } +/** OpenCode-specific overrides in bots.json. */ +export interface OpenCodeJsonConfig { + executable?: string; + model?: string; + contextWindow?: number; + dangerouslySkipPermissions?: boolean; + extraArgs?: string[]; + env?: Record; +} + /** Fields shared across all bot JSON entries (engine selection and engine overrides). */ interface EngineJsonFields { engine?: EngineName; kimi?: KimiJsonConfig; codex?: CodexJsonConfig; + opencode?: OpenCodeJsonConfig; } export interface FeishuBotJsonEntry extends EngineJsonFields { @@ -212,6 +234,7 @@ export interface FeishuBotJsonEntry extends EngineJsonFields { function feishuBotFromJson(entry: FeishuBotJsonEntry): BotConfig { const codex = buildCodexConfig(entry.codex); + const opencode = buildOpenCodeConfig(entry.opencode); return { name: entry.name, ...(entry.description ? { description: entry.description } : {}), @@ -224,6 +247,7 @@ function feishuBotFromJson(entry: FeishuBotJsonEntry): BotConfig { ...(entry.engine ? { engine: entry.engine } : {}), ...(entry.kimi ? { kimi: entry.kimi } : {}), ...(codex ? { codex } : {}), + ...(opencode ? { opencode } : {}), feishu: { appId: entry.feishuAppId, appSecret: entry.feishuAppSecret, @@ -254,6 +278,7 @@ export interface TelegramBotJsonEntry extends EngineJsonFields { function telegramBotFromJson(entry: TelegramBotJsonEntry): TelegramBotConfig { const codex = buildCodexConfig(entry.codex); + const opencode = buildOpenCodeConfig(entry.opencode); return { name: entry.name, ...(entry.description ? { description: entry.description } : {}), @@ -265,6 +290,7 @@ function telegramBotFromJson(entry: TelegramBotJsonEntry): TelegramBotConfig { ...(entry.engine ? { engine: entry.engine } : {}), ...(entry.kimi ? { kimi: entry.kimi } : {}), ...(codex ? { codex } : {}), + ...(opencode ? { opencode } : {}), telegram: { botToken: entry.telegramBotToken, }, @@ -292,6 +318,7 @@ export interface WebBotJsonEntry extends EngineJsonFields { export function webBotFromJson(entry: WebBotJsonEntry): BotConfigBase { const codex = buildCodexConfig(entry.codex); + const opencode = buildOpenCodeConfig(entry.opencode); return { name: entry.name, ...(entry.description ? { description: entry.description } : {}), @@ -303,6 +330,7 @@ export function webBotFromJson(entry: WebBotJsonEntry): BotConfigBase { ...(entry.engine ? { engine: entry.engine } : {}), ...(entry.kimi ? { kimi: entry.kimi } : {}), ...(codex ? { codex } : {}), + ...(opencode ? { opencode } : {}), claude: buildClaudeConfig(entry), }; } @@ -325,12 +353,14 @@ export interface WechatBotJsonEntry extends EngineJsonFields { function wechatBotFromJson(entry: WechatBotJsonEntry): WechatBotConfig { const codex = buildCodexConfig(entry.codex); + const opencode = buildOpenCodeConfig(entry.opencode); return { name: entry.name, ...(entry.description ? { description: entry.description } : {}), ...(entry.engine ? { engine: entry.engine } : {}), ...(entry.kimi ? { kimi: entry.kimi } : {}), ...(codex ? { codex } : {}), + ...(opencode ? { opencode } : {}), wechat: { ilinkBaseUrl: entry.ilinkBaseUrl, botToken: entry.wechatBotToken, @@ -376,14 +406,27 @@ function buildCodexConfig(entry?: CodexJsonConfig): BotConfigBase['codex'] | und return Object.keys(cfg).length > 0 ? cfg : undefined; } +function buildOpenCodeConfig(entry?: OpenCodeJsonConfig): BotConfigBase['opencode'] | undefined { + const cfg: BotConfigBase['opencode'] = { + ...(process.env.OPENCODE_EXECUTABLE_PATH ? { executable: process.env.OPENCODE_EXECUTABLE_PATH } : {}), + ...(process.env.OPENCODE_MODEL ? { model: process.env.OPENCODE_MODEL } : {}), + ...(process.env.OPENCODE_CONTEXT_WINDOW ? { contextWindow: parseInt(process.env.OPENCODE_CONTEXT_WINDOW, 10) } : {}), + ...(process.env.OPENCODE_SKIP_PERMISSIONS === 'true' ? { dangerouslySkipPermissions: true } : {}), + ...(entry ?? {}), + }; + return Object.keys(cfg).length > 0 ? cfg : undefined; +} + // --- Single-bot env var mode --- function feishuBotFromEnv(): BotConfig { const codex = buildCodexConfig(); + const opencode = buildOpenCodeConfig(); return { name: 'default', ...(process.env.METABOT_ENGINE ? { engine: process.env.METABOT_ENGINE as EngineName } : {}), ...(codex ? { codex } : {}), + ...(opencode ? { opencode } : {}), feishu: { appId: required('FEISHU_APP_ID'), appSecret: required('FEISHU_APP_SECRET'), @@ -402,10 +445,12 @@ function feishuBotFromEnv(): BotConfig { function telegramBotFromEnv(): TelegramBotConfig { const codex = buildCodexConfig(); + const opencode = buildOpenCodeConfig(); return { name: 'telegram-default', ...(process.env.METABOT_ENGINE ? { engine: process.env.METABOT_ENGINE as EngineName } : {}), ...(codex ? { codex } : {}), + ...(opencode ? { opencode } : {}), telegram: { botToken: required('TELEGRAM_BOT_TOKEN'), }, @@ -423,10 +468,12 @@ function telegramBotFromEnv(): TelegramBotConfig { function wechatBotFromEnv(): WechatBotConfig { const codex = buildCodexConfig(); + const opencode = buildOpenCodeConfig(); return { name: 'wechat-default', ...(process.env.METABOT_ENGINE ? { engine: process.env.METABOT_ENGINE as EngineName } : {}), ...(codex ? { codex } : {}), + ...(opencode ? { opencode } : {}), wechat: { botToken: process.env.WECHAT_BOT_TOKEN || undefined, }, diff --git a/src/engines/index.ts b/src/engines/index.ts index 44da0251..88c00189 100644 --- a/src/engines/index.ts +++ b/src/engines/index.ts @@ -4,6 +4,7 @@ import type { Engine, EngineName } from './types.js'; import { ClaudeEngine } from './claude/index.js'; import { KimiEngine } from './kimi/index.js'; import { CodexEngine } from './codex/index.js'; +import { OpenCodeEngine } from './opencode/index.js'; /** * Create an Engine for the given bot config. @@ -26,6 +27,8 @@ export function createEngine( return new KimiEngine(config, logger); case 'codex': return new CodexEngine(config, logger); + case 'opencode': + return new OpenCodeEngine(config, logger); default: { const _exhaustive: never = name; throw new Error(`Unknown engine: ${_exhaustive}`); @@ -38,7 +41,7 @@ export function resolveEngineName(config: BotConfigBase): EngineName { const explicit = config.engine; if (explicit) return explicit; const envDefault = process.env.METABOT_ENGINE as EngineName | undefined; - if (envDefault === 'claude' || envDefault === 'kimi' || envDefault === 'codex') return envDefault; + if (envDefault === 'claude' || envDefault === 'kimi' || envDefault === 'codex' || envDefault === 'opencode') return envDefault; return 'claude'; } @@ -46,6 +49,7 @@ export type { Engine, EngineName, Executor } from './types.js'; export { ClaudeEngine } from './claude/index.js'; export { KimiEngine } from './kimi/index.js'; export { CodexEngine } from './codex/index.js'; +export { OpenCodeEngine } from './opencode/index.js'; // Re-export shared types and classes currently used by the bridge and web/api layers. // Moving these behind the engine boundary lets consumers import from a single place. diff --git a/src/engines/opencode/executor.ts b/src/engines/opencode/executor.ts new file mode 100644 index 00000000..e6fe573f --- /dev/null +++ b/src/engines/opencode/executor.ts @@ -0,0 +1,213 @@ +import { execSync, spawn, type ChildProcess } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import type { BotConfigBase, OpenCodeBotConfig } from '../../config.js'; +import type { Logger } from '../../utils/logger.js'; +import { AsyncQueue } from '../../utils/async-queue.js'; +import type { + ApiContext, + ExecutionHandle, + ExecutorOptions, + SDKMessage, +} from '../claude/executor.js'; +import { + createOpenCodeTranslatorState, + translateOpenCodeJsonEvent, + type OpenCodeJsonEvent, +} from './jsonl-translator.js'; + +const isWindows = process.platform === 'win32'; + +function resolveOpenCodePath(): string { + if (process.env.OPENCODE_EXECUTABLE_PATH) return process.env.OPENCODE_EXECUTABLE_PATH; + try { + const cmd = isWindows ? 'where opencode' : 'which opencode'; + return execSync(cmd, { encoding: 'utf-8' }).trim().split(/\r?\n/)[0]; + } catch { + if (!isWindows) { + for (const candidate of ['/usr/local/bin/opencode', '/usr/bin/opencode', '/opt/homebrew/bin/opencode']) { + if (existsSync(candidate)) return candidate; + } + } + return 'opencode'; + } +} + +const OPENCODE_EXECUTABLE = resolveOpenCodePath(); + +export function buildOpenCodeArgs( + opencodeConfig: OpenCodeBotConfig, + prompt: string, + sessionId: string | undefined, + model: string | undefined, +): string[] { + const args: string[] = ['run', '--format', 'json']; + if (model) args.push('--model', model); + if (sessionId) args.push('--continue', '--session', sessionId); + if (opencodeConfig.dangerouslySkipPermissions) args.push('--dangerously-skip-permissions'); + for (const extraArg of opencodeConfig.extraArgs ?? []) args.push(extraArg); + args.push('--', prompt); + return args; +} + +export class OpenCodeExecutor { + constructor( + private config: BotConfigBase, + private logger: Logger, + ) {} + + startExecution(options: ExecutorOptions): ExecutionHandle { + const { prompt, cwd, sessionId, abortController, outputsDir, apiContext } = options; + const opencodeConfig = this.config.opencode ?? {}; + const model = options.model ?? opencodeConfig.model; + const fullPrompt = this.buildPromptWithContext(prompt, outputsDir, apiContext); + const queue = new AsyncQueue(); + const state = createOpenCodeTranslatorState({ + model, + contextWindow: opencodeConfig.contextWindow, + }); + const args = buildOpenCodeArgs(opencodeConfig, fullPrompt, sessionId, model); + const startTime = Date.now(); + let child: ChildProcess | undefined; + let sawResult = false; + let stderr = ''; + let stdoutBuffer = ''; + + this.logger.info({ cwd, hasSession: !!sessionId, model, engine: 'opencode' }, 'Starting OpenCode execution'); + + const finishWithError = (message: string): void => { + if (sawResult) return; + sawResult = true; + queue.enqueue({ + type: 'result', + subtype: abortController.signal.aborted ? 'error_cancelled' : 'error_during_execution', + session_id: state.sessionId ?? sessionId, + duration_ms: Date.now() - startTime, + result: state.lastAgentText, + is_error: true, + errors: [message], + }); + }; + + const emitEvent = (event: OpenCodeJsonEvent): void => { + const messages = translateOpenCodeJsonEvent(event, state); + for (const message of messages) { + if (message.type === 'result') sawResult = true; + queue.enqueue(message); + } + }; + + const processStdout = (chunk: Buffer): void => { + stdoutBuffer += chunk.toString('utf-8'); + const lines = stdoutBuffer.split(/\r?\n/); + stdoutBuffer = lines.pop() ?? ''; + for (const line of lines) { + if (!line.trim()) continue; + try { + emitEvent(JSON.parse(line) as OpenCodeJsonEvent); + } catch (err) { + this.logger.warn({ err, line }, 'Failed to parse OpenCode JSONL event'); + } + } + }; + + try { + child = spawn(opencodeConfig.executable || OPENCODE_EXECUTABLE, args, { + cwd, + env: { ...process.env, ...(opencodeConfig.env ?? {}) }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + } catch (err: unknown) { + finishWithError(err instanceof Error ? err.message : String(err)); + queue.finish(); + } + + if (child) { + if (abortController.signal.aborted) { + child.kill('SIGTERM'); + } else { + abortController.signal.addEventListener('abort', () => child?.kill('SIGTERM'), { once: true }); + } + + child.stdout?.on('data', processStdout); + child.stderr?.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf-8'); }); + child.on('error', (err) => { + finishWithError(err.message); + queue.finish(); + }); + child.on('close', (code, signal) => { + if (stdoutBuffer.trim()) { + try { + emitEvent(JSON.parse(stdoutBuffer) as OpenCodeJsonEvent); + } catch (err) { + this.logger.warn({ err, line: stdoutBuffer }, 'Failed to parse final OpenCode JSONL event'); + } + } + if (code !== 0 && !sawResult) { + const suffix = stderr.trim() ? `: ${stderr.trim()}` : ''; + finishWithError(`OpenCode exited with ${signal ? `signal ${signal}` : `code ${code}`}${suffix}`); + } + if (stderr.trim()) { + this.logger.debug({ stderr: stderr.trim() }, 'OpenCode stderr'); + } + queue.finish(); + }); + } + + return { + stream: queue[Symbol.asyncIterator]() as AsyncGenerator, + sendAnswer: (_toolUseId: string, _sid: string, _answerText: string) => { + this.logger.warn({ engine: 'opencode' }, 'sendAnswer called on OpenCode executor — not implemented'); + }, + resolveQuestion: (_toolUseId: string, _answers: Record) => { + this.logger.warn({ engine: 'opencode' }, 'resolveQuestion called on OpenCode executor — not implemented'); + }, + finish: () => { + if (child && !child.killed) child.kill('SIGTERM'); + queue.finish(); + }, + }; + } + + async *execute(options: ExecutorOptions): AsyncGenerator { + const handle = this.startExecution(options); + try { + for await (const msg of handle.stream) { + yield msg; + } + } finally { + handle.finish(); + } + } + + private buildPromptWithContext( + prompt: string, + outputsDir: string | undefined, + apiContext: ApiContext | undefined, + ): string { + const sections: string[] = []; + + if (outputsDir) { + sections.push( + `## Output Files\nWhen producing output files for the user (images, PDFs, documents, archives, code files, etc.), copy them to: ${outputsDir}\nThe bridge will automatically send files placed there to the user.`, + ); + } + + if (apiContext) { + sections.push( + `## MetaBot API\nYou are running as bot "${apiContext.botName}" in chat "${apiContext.chatId}".\nUse the /metabot skill for full API documentation (agent bus, scheduling, bot management).`, + ); + + if (apiContext.groupMembers && apiContext.groupMembers.length > 0) { + const others = apiContext.groupMembers.filter((m) => m !== apiContext.botName); + if (apiContext.groupId) { + sections.push( + `## Group Chat\nYou are in a group chat (group: ${apiContext.groupId}) with these bots: ${others.join(', ')}.\nTo talk to another bot, use: \`mb talk grouptalk-${apiContext.groupId}- "message"\``, + ); + } + } + } + + if (sections.length === 0) return prompt; + return `${prompt}\n\n---\n\n${sections.join('\n\n')}`; + } +} diff --git a/src/engines/opencode/index.ts b/src/engines/opencode/index.ts new file mode 100644 index 00000000..f1d2289f --- /dev/null +++ b/src/engines/opencode/index.ts @@ -0,0 +1,29 @@ +import type { BotConfigBase } from '../../config.js'; +import type { Logger } from '../../utils/logger.js'; +import type { Engine, Executor } from '../types.js'; +import { StreamProcessor } from '../claude/stream-processor.js'; +import { OpenCodeExecutor } from './executor.js'; + +export class OpenCodeEngine implements Engine { + readonly name = 'opencode' as const; + + constructor( + private config: BotConfigBase, + private logger: Logger, + ) {} + + createExecutor(): Executor { + return new OpenCodeExecutor(this.config, this.logger); + } + + createStreamProcessor(userPrompt: string): StreamProcessor { + return new StreamProcessor(userPrompt); + } +} + +export { OpenCodeExecutor } from './executor.js'; +export type { OpenCodeJsonEvent, OpenCodeTranslatorState } from './jsonl-translator.js'; +export { + createOpenCodeTranslatorState, + translateOpenCodeJsonEvent, +} from './jsonl-translator.js'; diff --git a/src/engines/opencode/jsonl-translator.ts b/src/engines/opencode/jsonl-translator.ts new file mode 100644 index 00000000..54854a4f --- /dev/null +++ b/src/engines/opencode/jsonl-translator.ts @@ -0,0 +1,192 @@ +import type { SDKMessage } from '../claude/executor.js'; + +export interface OpenCodeJsonEvent { + type: string; + sessionID?: string; + timestamp?: number; + messageID?: string; + part?: { + id?: string; + type?: string; + text?: string; + name?: string; + input?: Record; + state?: { + status?: string; + input?: Record; + output?: string; + title?: string; + metadata?: Record; + time?: { start?: number; end?: number }; + }; + snapshot?: string; + reason?: string; + cost?: number; + tokens?: { + input?: number; + output?: number; + reasoning?: number; + cache?: { write?: number; read?: number }; + }; + }; + tokens?: { + input?: number; + output?: number; + reasoning?: number; + cache?: { write?: number; read?: number }; + }; + cost?: number; + error?: string; + message?: string; +} + +export interface OpenCodeTranslatorState { + sessionId?: string; + lastAgentText: string; + startTime: number; + model?: string; + contextWindow?: number; +} + +export function createOpenCodeTranslatorState(options: { + model?: string; + contextWindow?: number; +} = {}): OpenCodeTranslatorState { + return { + lastAgentText: '', + startTime: Date.now(), + model: options.model, + contextWindow: options.contextWindow, + }; +} + +export function translateOpenCodeJsonEvent( + event: OpenCodeJsonEvent, + state: OpenCodeTranslatorState, +): SDKMessage[] { + if (event.sessionID && !state.sessionId) { + state.sessionId = event.sessionID; + } + + switch (event.type) { + case 'step_start': + if (event.sessionID) { + return [{ type: 'system', subtype: 'init', session_id: event.sessionID }]; + } + return []; + + case 'text': { + const text = event.part?.text ?? ''; + state.lastAgentText += text; + return [{ + type: 'stream_event', + session_id: state.sessionId, + event: { + type: 'content_block_delta', + delta: { type: 'text_delta', text }, + }, + }]; + } + + case 'tool_use': + return translateToolUse(event, state); + + case 'step_finish': { + const reason = event.part?.reason; + if (reason === 'tool-calls') return []; + const tokens = event.part?.tokens ?? event.tokens; + return [buildResultMessage(tokens, event.part?.cost ?? event.cost, state, false)]; + } + + case 'error': + return [buildResultMessage(undefined, undefined, state, true, event.error ?? event.message)]; + + default: + return []; + } +} + +function translateToolUse(event: OpenCodeJsonEvent, state: OpenCodeTranslatorState): SDKMessage[] { + const part = event.part; + if (!part) return []; + + const toolState = part.state; + const toolName = part.name ?? 'unknown'; + const toolId = part.id ?? `oc-${Date.now()}`; + const input = toolState?.input ?? {}; + + const messages: SDKMessage[] = [{ + type: 'assistant', + session_id: state.sessionId, + message: { + content: [{ + type: 'tool_use', + id: toolId, + name: mapToolName(toolName), + input, + }], + }, + }]; + + if (toolState?.status === 'completed' || toolState?.output != null) { + messages.push({ + type: 'user', + session_id: state.sessionId, + message: { + content: [{ + type: 'tool_result', + id: toolId, + text: typeof toolState?.output === 'string' ? toolState.output : '', + }], + }, + }); + } + + return messages; +} + +function mapToolName(name: string): string { + const mapping: Record = { + bash: 'Bash', + read: 'Read', + write: 'Write', + edit: 'Edit', + glob: 'Glob', + grep: 'Grep', + webfetch: 'WebFetch', + }; + return mapping[name.toLowerCase()] ?? name; +} + +function buildResultMessage( + tokens: OpenCodeJsonEvent['tokens'] | undefined, + cost: number | undefined, + state: OpenCodeTranslatorState, + isError: boolean, + errorMessage?: string, +): SDKMessage { + const inputTokens = (tokens?.input ?? 0) + (tokens?.cache?.read ?? 0) + (tokens?.cache?.write ?? 0); + const outputTokens = (tokens?.output ?? 0) + (tokens?.reasoning ?? 0); + + const modelUsage = state.model + ? { + [state.model]: { + inputTokens, + outputTokens, + contextWindow: state.contextWindow ?? 0, + costUSD: cost ?? 0, + }, + } + : undefined; + + return { + type: 'result', + subtype: isError ? 'error_during_execution' : 'success', + session_id: state.sessionId, + duration_ms: Date.now() - state.startTime, + result: state.lastAgentText, + is_error: isError, + errors: isError ? [errorMessage || 'OpenCode execution failed'] : undefined, + modelUsage, + }; +} diff --git a/src/engines/types.ts b/src/engines/types.ts index 6839ec34..113d951e 100644 --- a/src/engines/types.ts +++ b/src/engines/types.ts @@ -9,9 +9,10 @@ import type { TeamEvent, } from './claude/executor.js'; import type { CodexExecutor } from './codex/executor.js'; +import type { OpenCodeExecutor } from './opencode/executor.js'; import type { StreamProcessor } from './claude/stream-processor.js'; -export type EngineName = 'claude' | 'kimi' | 'codex'; +export type EngineName = 'claude' | 'kimi' | 'codex' | 'opencode'; /** * An Engine is a programmable agent backend (Claude Code, Kimi Code, …). @@ -46,6 +47,7 @@ export type StreamProcessorLike = StreamProcessor; export type { ClaudeExecutor, CodexExecutor, + OpenCodeExecutor, ExecutionHandle, ExecutorOptions, SDKMessage, diff --git a/tests/opencode-build-args.test.ts b/tests/opencode-build-args.test.ts new file mode 100644 index 00000000..e496b271 --- /dev/null +++ b/tests/opencode-build-args.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import { buildOpenCodeArgs } from '../src/engines/opencode/executor.js'; +import type { OpenCodeBotConfig } from '../src/config.js'; + +describe('buildOpenCodeArgs', () => { + const prompt = 'run pwd'; + + it('produces default args: run --format json -- ', () => { + const args = buildOpenCodeArgs({}, prompt, undefined, undefined); + expect(args).toEqual(['run', '--format', 'json', '--', prompt]); + }); + + it('adds --model when a model is provided', () => { + const args = buildOpenCodeArgs({}, prompt, undefined, 'anthropic/claude-sonnet-4-5'); + expect(args).toContain('--model'); + expect(args[args.indexOf('--model') + 1]).toBe('anthropic/claude-sonnet-4-5'); + }); + + it('adds --continue --session when resuming a session', () => { + const args = buildOpenCodeArgs({}, prompt, 'ses_abc123', undefined); + expect(args).toContain('--continue'); + expect(args).toContain('--session'); + expect(args[args.indexOf('--session') + 1]).toBe('ses_abc123'); + }); + + it('adds --dangerously-skip-permissions when configured', () => { + const cfg: OpenCodeBotConfig = { dangerouslySkipPermissions: true }; + const args = buildOpenCodeArgs(cfg, prompt, undefined, undefined); + expect(args).toContain('--dangerously-skip-permissions'); + }); + + it('does not add --dangerously-skip-permissions when not configured', () => { + const args = buildOpenCodeArgs({}, prompt, undefined, undefined); + expect(args).not.toContain('--dangerously-skip-permissions'); + }); + + it('appends extraArgs in order before the -- separator', () => { + const cfg: OpenCodeBotConfig = { extraArgs: ['--agent', 'plan', '--variant', 'high'] }; + const args = buildOpenCodeArgs(cfg, prompt, undefined, undefined); + const dashDashIdx = args.indexOf('--'); + const agentIdx = args.indexOf('--agent'); + expect(agentIdx).toBeGreaterThan(-1); + expect(agentIdx).toBeLessThan(dashDashIdx); + expect(args[agentIdx + 1]).toBe('plan'); + }); + + it('keeps prompt as a single argv entry even with whitespace and metacharacters', () => { + const evil = 'ignore; rm -rf /\n`whoami`'; + const args = buildOpenCodeArgs({}, evil, undefined, undefined); + expect(args[args.length - 1]).toBe(evil); + }); + + it('combines model, session, skip-permissions, and extraArgs correctly', () => { + const cfg: OpenCodeBotConfig = { + dangerouslySkipPermissions: true, + extraArgs: ['--dir', '/work'], + }; + const args = buildOpenCodeArgs(cfg, prompt, 'ses_xyz', 'google/gemini-3-pro'); + expect(args).toEqual([ + 'run', '--format', 'json', + '--model', 'google/gemini-3-pro', + '--continue', '--session', 'ses_xyz', + '--dangerously-skip-permissions', + '--dir', '/work', + '--', prompt, + ]); + }); +}); diff --git a/tests/opencode-jsonl-translator.test.ts b/tests/opencode-jsonl-translator.test.ts new file mode 100644 index 00000000..74c780ef --- /dev/null +++ b/tests/opencode-jsonl-translator.test.ts @@ -0,0 +1,239 @@ +import { describe, expect, it } from 'vitest'; +import { StreamProcessor } from '../src/engines/claude/stream-processor.js'; +import { + createOpenCodeTranslatorState, + translateOpenCodeJsonEvent, + type OpenCodeJsonEvent, +} from '../src/engines/opencode/jsonl-translator.js'; + +describe('OpenCode JSONL translator', () => { + it('maps a complete opencode run into the existing stream processor shape', () => { + const events: OpenCodeJsonEvent[] = [ + { type: 'step_start', sessionID: 'ses_abc123', part: { type: 'step-start', snapshot: 'deadbeef' } }, + { type: 'text', sessionID: 'ses_abc123', part: { type: 'text', text: 'I\'ll check the current directory.' } }, + { + type: 'tool_use', + sessionID: 'ses_abc123', + part: { + id: 'prt_001', + type: 'tool', + name: 'bash', + state: { + status: 'completed', + input: { command: 'pwd', description: 'Print working directory' }, + output: '/home/user/project\n', + title: 'Print working directory', + metadata: { exit: 0 }, + }, + }, + }, + { type: 'text', sessionID: 'ses_abc123', part: { type: 'text', text: 'The working directory is /home/user/project.' } }, + { + type: 'step_finish', + sessionID: 'ses_abc123', + part: { + type: 'step-finish', + reason: 'stop', + cost: 0.0042, + tokens: { input: 1500, output: 50, reasoning: 0, cache: { write: 500, read: 200 } }, + }, + }, + ]; + + const state = createOpenCodeTranslatorState({ model: 'anthropic/claude-sonnet-4-5', contextWindow: 200000 }); + const processor = new StreamProcessor('Run pwd'); + + let cardState = processor.processMessage({ type: 'system' }); + for (const event of events) { + for (const message of translateOpenCodeJsonEvent(event, state)) { + cardState = processor.processMessage(message); + } + } + + expect(processor.getSessionId()).toBe('ses_abc123'); + expect(cardState.status).toBe('complete'); + expect(cardState.responseText).toContain('The working directory is /home/user/project.'); + expect(cardState.toolCalls).toEqual([{ name: 'Bash', detail: '`pwd`', status: 'done' }]); + expect(cardState.model).toBe('anthropic/claude-sonnet-4-5'); + expect(cardState.contextWindow).toBe(200000); + }); + + it('captures session id from step_start', () => { + const state = createOpenCodeTranslatorState(); + const messages = translateOpenCodeJsonEvent({ type: 'step_start', sessionID: 'ses_xyz' }, state); + expect(state.sessionId).toBe('ses_xyz'); + expect(messages).toEqual([{ type: 'system', subtype: 'init', session_id: 'ses_xyz' }]); + }); + + it('returns [] for step_start without sessionID', () => { + const state = createOpenCodeTranslatorState(); + const messages = translateOpenCodeJsonEvent({ type: 'step_start' }, state); + expect(messages).toEqual([]); + }); + + it('emits stream_event with text_delta for text events', () => { + const state = createOpenCodeTranslatorState(); + state.sessionId = 'ses_1'; + const messages = translateOpenCodeJsonEvent( + { type: 'text', part: { type: 'text', text: 'Hello world' } }, + state, + ); + expect(messages).toHaveLength(1); + expect(messages[0]).toMatchObject({ + type: 'stream_event', + session_id: 'ses_1', + event: { + type: 'content_block_delta', + delta: { type: 'text_delta', text: 'Hello world' }, + }, + }); + expect(state.lastAgentText).toBe('Hello world'); + }); + + it('accumulates text across multiple text events', () => { + const state = createOpenCodeTranslatorState(); + translateOpenCodeJsonEvent({ type: 'text', part: { type: 'text', text: 'Hello ' } }, state); + translateOpenCodeJsonEvent({ type: 'text', part: { type: 'text', text: 'world' } }, state); + expect(state.lastAgentText).toBe('Hello world'); + }); + + it('handles text events with missing part.text', () => { + const state = createOpenCodeTranslatorState(); + const messages = translateOpenCodeJsonEvent({ type: 'text', part: { type: 'text' } }, state); + expect(messages).toHaveLength(1); + expect(state.lastAgentText).toBe(''); + }); + + it('translates tool_use events into tool_use + tool_result SDKMessages', () => { + const state = createOpenCodeTranslatorState(); + state.sessionId = 'ses_t'; + const messages = translateOpenCodeJsonEvent({ + type: 'tool_use', + part: { + id: 'prt_42', + name: 'read', + state: { + status: 'completed', + input: { file_path: '/tmp/test.txt' }, + output: 'file contents here', + }, + }, + }, state); + + expect(messages).toHaveLength(2); + expect(messages[0]).toMatchObject({ + type: 'assistant', + message: { content: [{ type: 'tool_use', id: 'prt_42', name: 'Read', input: { file_path: '/tmp/test.txt' } }] }, + }); + expect(messages[1]).toMatchObject({ + type: 'user', + message: { content: [{ type: 'tool_result', id: 'prt_42', text: 'file contents here' }] }, + }); + }); + + it('handles tool_use events with missing state (defensive)', () => { + const state = createOpenCodeTranslatorState(); + const messages = translateOpenCodeJsonEvent({ + type: 'tool_use', + part: { id: 'prt_x', name: 'bash' }, + }, state); + expect(messages).toHaveLength(1); + expect(messages[0]).toMatchObject({ type: 'assistant' }); + }); + + it('maps opencode tool names to MetaBot tool names', () => { + const state = createOpenCodeTranslatorState(); + const check = (name: string, expected: string) => { + const [msg] = translateOpenCodeJsonEvent({ + type: 'tool_use', + part: { id: 'x', name, state: { status: 'completed', input: {}, output: '' } }, + }, state); + expect((msg as any).message.content[0].name).toBe(expected); + }; + + check('bash', 'Bash'); + check('read', 'Read'); + check('write', 'Write'); + check('edit', 'Edit'); + check('glob', 'Glob'); + check('grep', 'Grep'); + check('CustomTool', 'CustomTool'); + }); + + it('emits result for step_finish with reason "stop"', () => { + const state = createOpenCodeTranslatorState({ model: 'openai/gpt-5' }); + state.sessionId = 'ses_r'; + state.lastAgentText = 'Done.'; + const messages = translateOpenCodeJsonEvent({ + type: 'step_finish', + part: { + reason: 'stop', + cost: 0.01, + tokens: { input: 1000, output: 100, reasoning: 50, cache: { write: 200, read: 300 } }, + }, + }, state); + + expect(messages).toHaveLength(1); + expect(messages[0]).toMatchObject({ + type: 'result', + subtype: 'success', + session_id: 'ses_r', + is_error: false, + result: 'Done.', + }); + const usage = (messages[0] as any).modelUsage['openai/gpt-5']; + expect(usage.inputTokens).toBe(1500); + expect(usage.outputTokens).toBe(150); + expect(usage.costUSD).toBe(0.01); + }); + + it('returns [] for step_finish with reason "tool-calls" (intermediate step)', () => { + const state = createOpenCodeTranslatorState(); + const messages = translateOpenCodeJsonEvent({ + type: 'step_finish', + part: { reason: 'tool-calls', tokens: { input: 100, output: 10 } }, + }, state); + expect(messages).toEqual([]); + }); + + it('handles step_finish with no tokens/cost', () => { + const state = createOpenCodeTranslatorState({ model: 'test-model' }); + state.lastAgentText = 'text'; + const [msg] = translateOpenCodeJsonEvent( + { type: 'step_finish', part: { reason: 'stop' } }, + state, + ); + const usage = (msg as any).modelUsage['test-model']; + expect(usage.inputTokens).toBe(0); + expect(usage.outputTokens).toBe(0); + expect(usage.costUSD).toBe(0); + }); + + it('maps error events to error results', () => { + const state = createOpenCodeTranslatorState(); + state.sessionId = 'ses_e'; + const [msg] = translateOpenCodeJsonEvent({ type: 'error', error: 'rate limit exceeded' }, state); + expect(msg).toMatchObject({ + type: 'result', + subtype: 'error_during_execution', + is_error: true, + errors: ['rate limit exceeded'], + }); + }); + + it('falls back to generic error message when error event has no detail', () => { + const state = createOpenCodeTranslatorState(); + const [msg] = translateOpenCodeJsonEvent({ type: 'error' }, state); + expect(msg).toMatchObject({ + type: 'result', + is_error: true, + errors: ['OpenCode execution failed'], + }); + }); + + it('returns [] for unknown event types', () => { + const state = createOpenCodeTranslatorState(); + expect(translateOpenCodeJsonEvent({ type: 'something.new' } as OpenCodeJsonEvent, state)).toEqual([]); + expect(translateOpenCodeJsonEvent({ type: 'turn.started' } as OpenCodeJsonEvent, state)).toEqual([]); + }); +}); diff --git a/tests/opencode-multi-step.test.ts b/tests/opencode-multi-step.test.ts new file mode 100644 index 00000000..563da71c --- /dev/null +++ b/tests/opencode-multi-step.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest'; +import { StreamProcessor } from '../src/engines/claude/stream-processor.js'; +import { + createOpenCodeTranslatorState, + translateOpenCodeJsonEvent, + type OpenCodeJsonEvent, +} from '../src/engines/opencode/jsonl-translator.js'; + +describe('OpenCode multi-step execution', () => { + it('handles a multi-step run: step1 (tool-calls) → step2 (stop)', () => { + const events: OpenCodeJsonEvent[] = [ + // Step 1: agent calls a tool + { type: 'step_start', sessionID: 'ses_multi' }, + { type: 'text', part: { type: 'text', text: 'Let me check.' } }, + { + type: 'tool_use', + part: { + id: 'prt_1', + name: 'bash', + state: { status: 'completed', input: { command: 'ls' }, output: 'file.txt\n' }, + }, + }, + // step_finish with reason "tool-calls" → NOT terminal + { + type: 'step_finish', + part: { + reason: 'tool-calls', + tokens: { input: 500, output: 20 }, + cost: 0.001, + }, + }, + + // Step 2: agent responds + { type: 'step_start', sessionID: 'ses_multi' }, + { type: 'text', part: { type: 'text', text: 'Found file.txt.' } }, + // step_finish with reason "stop" → terminal + { + type: 'step_finish', + part: { + reason: 'stop', + tokens: { input: 1000, output: 30, cache: { read: 200, write: 0 } }, + cost: 0.003, + }, + }, + ]; + + const state = createOpenCodeTranslatorState({ model: 'anthropic/claude-sonnet-4-5', contextWindow: 200000 }); + const processor = new StreamProcessor('List files'); + + let cardState = processor.processMessage({ type: 'system' }); + let resultCount = 0; + for (const event of events) { + for (const message of translateOpenCodeJsonEvent(event, state)) { + cardState = processor.processMessage(message); + if (message.type === 'result') resultCount++; + } + } + + // Only one result message should be emitted (from the final step_finish) + expect(resultCount).toBe(1); + expect(cardState.status).toBe('complete'); + expect(processor.getSessionId()).toBe('ses_multi'); + // Text accumulates across both steps + expect(state.lastAgentText).toContain('Found file.txt.'); + // Tool call from step 1 should appear + expect(cardState.toolCalls).toEqual([{ name: 'Bash', detail: '`ls`', status: 'done' }]); + // Cost/tokens from the final step + const usage = (cardState as any).modelUsage; + expect(usage).toBeUndefined(); + }); + + it('preserves sessionID consistency across multiple steps', () => { + const state = createOpenCodeTranslatorState(); + + translateOpenCodeJsonEvent({ type: 'step_start', sessionID: 'ses_consist' }, state); + expect(state.sessionId).toBe('ses_consist'); + + // Second step_start with same sessionID + translateOpenCodeJsonEvent({ type: 'step_start', sessionID: 'ses_consist' }, state); + expect(state.sessionId).toBe('ses_consist'); + + // Finish + const [result] = translateOpenCodeJsonEvent( + { type: 'step_finish', part: { reason: 'stop' } }, + state, + ); + expect(result.session_id).toBe('ses_consist'); + }); + + it('does not produce a result for step_finish reason=tool-calls even with tokens', () => { + const state = createOpenCodeTranslatorState({ model: 'test' }); + state.lastAgentText = 'some text'; + + const messages = translateOpenCodeJsonEvent({ + type: 'step_finish', + part: { + reason: 'tool-calls', + tokens: { input: 999, output: 111 }, + cost: 0.05, + }, + }, state); + + expect(messages).toEqual([]); + // lastAgentText should NOT be reset since this isn't terminal + expect(state.lastAgentText).toBe('some text'); + }); +});