From c5e5719c05ae19970cf0c2d13a328c9349339cf8 Mon Sep 17 00:00:00 2001 From: yyi407609-droid Date: Thu, 14 May 2026 18:37:36 +0800 Subject: [PATCH 1/2] feat(engines): add opencode engine support Add OpenCodeEngine implementing the Engine interface: - OpenCodeExecutor spawns opencode CLI with JSONL streaming - OpenCodeStreamProcessor transforms opencode events into CardState - OpenCode JSONL translator for event format conversion - Add 'opencode' to EngineName type and BotConfigBase - Add OpenCodeBotConfig for model/skipPermissions/extraArgs settings Original work by @yyi407609-droid from PR #223. --- src/config.ts | 11 +- src/engines/index.ts | 6 +- src/engines/opencode/executor.ts | 212 +++++++++++++++++++++++ src/engines/opencode/index.ts | 30 ++++ src/engines/opencode/jsonl-translator.ts | 98 +++++++++++ src/engines/opencode/stream-processor.ts | 88 ++++++++++ src/engines/types.ts | 7 +- 7 files changed, 448 insertions(+), 4 deletions(-) create mode 100644 src/engines/opencode/executor.ts create mode 100644 src/engines/opencode/index.ts create mode 100644 src/engines/opencode/jsonl-translator.ts create mode 100644 src/engines/opencode/stream-processor.ts diff --git a/src/config.ts b/src/config.ts index be802df9..f3ef6bce 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,13 @@ export interface CodexBotConfig { env?: Record; } +export interface OpenCodeBotConfig { + model?: string; + dangerouslySkipPermissions?: boolean; + extraArgs?: string[]; + env?: Record; +} + /** Feishu bot config (extends base with Feishu credentials). */ export interface BotConfig extends BotConfigBase { feishu: { 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..c2972466 --- /dev/null +++ b/src/engines/opencode/executor.ts @@ -0,0 +1,212 @@ +import { execSync, spawn, type ChildProcess } from 'node:child_process'; +import type { BotConfigBase } 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; + if (isWindows) { + const exePaths = [ + 'C:\\Users\\\\AppData\\Roaming\\npm\\node_modules\\opencode-ai\\node_modules\\opencode-windows-x64\\bin\\opencode.exe', + 'C:\\Program Files\\opencode\\opencode.exe', + 'opencode', + ]; + for (const p of exePaths) { + if (p !== 'opencode') { + try { execSync(`if exist "${p}" (echo ${p})`, { encoding: 'utf-8', shell: 'cmd.exe' }); } catch { continue; } + } + } + try { + return execSync('where opencode', { encoding: 'utf-8', shell: 'cmd.exe' }).trim().split(/\r?\n/)[0]; + } catch { + return 'opencode'; + } + } + try { + return execSync('which opencode', { encoding: 'utf-8' }).trim().split(/\r?\n/)[0]; + } catch { + return '/usr/local/bin/opencode'; + } +} + +const OPENCODE_EXECUTABLE = resolveOpenCodePath(); + +export function buildOpenCodeArgs( + opencodeConfig: { model?: string; dangerouslySkipPermissions?: boolean; extraArgs?: string[] }, + sessionId: string | undefined, + model: string, + prompt: string, +): string[] { + const args: string[] = []; + args.push('run', '--format', 'json'); + if (model) args.push('--model', model); + if (sessionId) args.push('--continue', '--session', sessionId); + if (opencodeConfig.dangerouslySkipPermissions) args.push('--dangerously-skip-permissions'); + if (opencodeConfig.extraArgs) { + 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, model } = options; + const opencodeConfig = this.config.opencode ?? {}; + const effectiveModel = model ?? opencodeConfig.model ?? 'minimax-cn-coding-plan/MiniMax-M2.5-highspeed'; + const fullPrompt = this.buildPromptWithContext(prompt, outputsDir, apiContext); + const queue = new AsyncQueue(); + const state = createOpenCodeTranslatorState({ model: effectiveModel }); + const args = buildOpenCodeArgs(opencodeConfig, sessionId, effectiveModel, fullPrompt); + const startTime = Date.now(); + let child: ChildProcess | undefined; + let sawResult = false; + let stderr = ''; + let stdoutBuffer = ''; + + this.logger.info({ cwd, hasSession: !!sessionId, model: effectiveModel, 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 => { + if (event.sessionID && !state.sessionId) state.sessionId = event.sessionID; + 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(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 { /* ignore */ } + } + 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(', ')}.`, + ); + } + } + } + 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..a380b247 --- /dev/null +++ b/src/engines/opencode/index.ts @@ -0,0 +1,30 @@ +import type { BotConfigBase } from '../../config.js'; +import type { Logger } from '../../utils/logger.js'; +import type { Engine, Executor } from '../types.js'; +import { OpenCodeExecutor } from './executor.js'; +import { OpenCodeStreamProcessor } from './stream-processor.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): OpenCodeStreamProcessor { + return new OpenCodeStreamProcessor(userPrompt); + } +} + +export { OpenCodeExecutor } from './executor.js'; +export { OpenCodeStreamProcessor } from './stream-processor.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..79779527 --- /dev/null +++ b/src/engines/opencode/jsonl-translator.ts @@ -0,0 +1,98 @@ +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?: unknown; + messageID?: string; + sessionID?: string; + }; + tokens?: { + total?: number; + 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; +} + +export function createOpenCodeTranslatorState(options: { model?: string } = {}): OpenCodeTranslatorState { + return { lastAgentText: '', startTime: Date.now(), model: options.model }; +} + +export function translateOpenCodeJsonEvent( + event: OpenCodeJsonEvent, + state: OpenCodeTranslatorState, +): SDKMessage[] { + switch (event.type) { + case 'step_start': + return []; + + case 'text': { + const text = event.part?.text ?? ''; + state.lastAgentText += text; + return [{ + type: 'assistant', + session_id: state.sessionId, + message: { content: [{ type: 'text', text }] }, + }]; + } + + case 'step_finish': { + const resultText = state.lastAgentText; + state.lastAgentText = ''; + return [{ + type: 'result', + subtype: 'success', + session_id: state.sessionId, + duration_ms: Date.now() - state.startTime, + result: resultText, + is_error: false, + errors: undefined, + modelUsage: state.model ? { + [state.model]: { + inputTokens: event.tokens?.input ?? 0, + outputTokens: event.tokens?.output ?? 0, + contextWindow: 128000, + costUSD: event.cost ?? 0, + }, + } : undefined, + }]; + } + + case 'error': + case 'result': { + if (event.sessionID) state.sessionId = event.sessionID; + const isError = event.type === 'error'; + return [{ + type: 'result', + subtype: isError ? 'error_during_execution' : 'success', + session_id: state.sessionId, + duration_ms: Date.now() - state.startTime, + result: event.error ?? event.message ?? state.lastAgentText, + is_error: isError, + errors: isError ? [event.error ?? event.message ?? 'Unknown error'] : undefined, + }]; + } + + default: + return []; + } +} diff --git a/src/engines/opencode/stream-processor.ts b/src/engines/opencode/stream-processor.ts new file mode 100644 index 00000000..059b7233 --- /dev/null +++ b/src/engines/opencode/stream-processor.ts @@ -0,0 +1,88 @@ +import type { SDKMessage } from '../claude/executor.js'; +import type { CardState } from '../../types.js'; + +export class OpenCodeStreamProcessor { + private responseText = ''; + private toolCalls: { toolUseId: string; name: string; input: string }[] = []; + private currentToolName: string | null = null; + private currentToolInput = ''; + private sessionId: string | undefined; + private costUsd: number | undefined; + private durationMs: number | undefined; + private errorMessage: string | undefined; + private _model: string | undefined; + private _imagePaths: Set = new Set(); + private _status: 'thinking' | 'running' | 'complete' | 'error' = 'thinking'; + private _userPrompt: string; + + constructor(userPrompt: string) { + this._userPrompt = userPrompt; + } + + processMessage(message: SDKMessage): CardState | undefined { + if (message.session_id) this.sessionId = message.session_id; + + switch (message.type) { + case 'assistant': { + this._status = 'running'; + if (message.message?.content) { + for (const block of message.message.content) { + if (block.type === 'text') { + this.responseText += block.text ?? ''; + } + if (block.type === 'tool_use') { + this.currentToolName = block.name ?? 'unknown'; + this.currentToolInput = JSON.stringify(block.input ?? {}); + } + if (block.type === 'content_block_end' && this.currentToolName) { + this.toolCalls.push({ + toolUseId: `oc-${Date.now()}-${Math.random().toString(36).slice(2)}`, + name: this.currentToolName, + input: this.currentToolInput, + }); + this.currentToolName = null; + this.currentToolInput = ''; + } + } + } + return undefined; + } + + case 'result': { + this._status = message.is_error ? 'error' : 'complete'; + if (message.duration_ms) this.durationMs = message.duration_ms; + if (message.modelUsage) { + const firstModel = Object.values(message.modelUsage)[0] as { costUSD?: number }; + this.costUsd = firstModel?.costUSD; + } + if (message.is_error && message.errors?.length) { + this.errorMessage = message.errors[0]; + } + if (message.result && !this.responseText) { + this.responseText = message.result; + } + return this.buildCardState(); + } + + default: + return undefined; + } + } + + extractImagePaths(): string[] { + return Array.from(this._imagePaths); + } + + private buildCardState(): CardState { + return { + status: this._status, + userPrompt: this._userPrompt, + responseText: this.responseText, + toolCalls: this.toolCalls, + costUsd: this.costUsd, + durationMs: this.durationMs, + errorMessage: this.errorMessage, + model: this._model, + }; + } +} diff --git a/src/engines/types.ts b/src/engines/types.ts index 6839ec34..969a2ecb 100644 --- a/src/engines/types.ts +++ b/src/engines/types.ts @@ -9,9 +9,11 @@ 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'; +import type { OpenCodeStreamProcessor } from './opencode/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, …). @@ -41,11 +43,12 @@ export interface Executor { execute(options: ExecutorOptions): AsyncGenerator; } -export type StreamProcessorLike = StreamProcessor; +export type StreamProcessorLike = StreamProcessor | OpenCodeStreamProcessor; export type { ClaudeExecutor, CodexExecutor, + OpenCodeExecutor, ExecutionHandle, ExecutorOptions, SDKMessage, From cddfeeda9d5fa17cb785de427725ca03db224581 Mon Sep 17 00:00:00 2001 From: Chunhao Zhang <1226577187zch@gmail.com> Date: Thu, 14 May 2026 18:45:00 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(opencode):=20rewrite=20engine=20?= =?UTF-8?q?=E2=80=94=20address=20review=20feedback=20from=20#223?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Based on reviewer feedback (floodsung), this commit fixes: 1. Delete dead-code OpenCodeStreamProcessor — reuse Claude's StreamProcessor (same pattern as Codex/Kimi engines) 2. Add tool_use event translation — tool calls now appear on Feishu cards instead of being silently dropped 3. Use stream_event+content_block_delta for real-time text streaming instead of batched assistant messages 4. Handle multi-step execution — step_finish with reason "tool-calls" is intermediate, only reason "stop" emits a terminal result 5. Remove broken Windows path hardcoding — use PATH lookup + OPENCODE_EXECUTABLE_PATH env var 6. Remove hardcoded private model — model comes from config 7. Add full config deserialization — OpenCodeJsonConfig, buildOpenCodeConfig(), EngineJsonFields.opencode, all *FromJson and *FromEnv functions wired 8. Add executable and contextWindow to OpenCodeBotConfig 9. Restore persistentExecutor field deleted by original commit 10. Remove StreamProcessorLike union — back to StreamProcessor only 11. Add comprehensive tests: JSONL translator, CLI args, multi-step Co-Authored-By: Claude Opus 4.6 (1M context) --- src/config.ts | 38 ++++ src/engines/opencode/executor.ts | 79 ++++---- src/engines/opencode/index.ts | 7 +- src/engines/opencode/jsonl-translator.ts | 174 +++++++++++++---- src/engines/opencode/stream-processor.ts | 88 --------- src/engines/types.ts | 3 +- tests/opencode-build-args.test.ts | 68 +++++++ tests/opencode-jsonl-translator.test.ts | 239 +++++++++++++++++++++++ tests/opencode-multi-step.test.ts | 107 ++++++++++ 9 files changed, 630 insertions(+), 173 deletions(-) delete mode 100644 src/engines/opencode/stream-processor.ts create mode 100644 tests/opencode-build-args.test.ts create mode 100644 tests/opencode-jsonl-translator.test.ts create mode 100644 tests/opencode-multi-step.test.ts diff --git a/src/config.ts b/src/config.ts index f3ef6bce..1adb40a4 100644 --- a/src/config.ts +++ b/src/config.ts @@ -81,7 +81,9 @@ export interface CodexBotConfig { } export interface OpenCodeBotConfig { + executable?: string; model?: string; + contextWindow?: number; dangerouslySkipPermissions?: boolean; extraArgs?: string[]; env?: Record; @@ -191,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 { @@ -221,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 } : {}), @@ -233,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, @@ -263,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 } : {}), @@ -274,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, }, @@ -301,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 } : {}), @@ -312,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), }; } @@ -334,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, @@ -385,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'), @@ -411,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'), }, @@ -432,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/opencode/executor.ts b/src/engines/opencode/executor.ts index c2972466..e6fe573f 100644 --- a/src/engines/opencode/executor.ts +++ b/src/engines/opencode/executor.ts @@ -1,5 +1,6 @@ import { execSync, spawn, type ChildProcess } from 'node:child_process'; -import type { BotConfigBase } from '../../config.js'; +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 { @@ -18,46 +19,32 @@ const isWindows = process.platform === 'win32'; function resolveOpenCodePath(): string { if (process.env.OPENCODE_EXECUTABLE_PATH) return process.env.OPENCODE_EXECUTABLE_PATH; - if (isWindows) { - const exePaths = [ - 'C:\\Users\\\\AppData\\Roaming\\npm\\node_modules\\opencode-ai\\node_modules\\opencode-windows-x64\\bin\\opencode.exe', - 'C:\\Program Files\\opencode\\opencode.exe', - 'opencode', - ]; - for (const p of exePaths) { - if (p !== 'opencode') { - try { execSync(`if exist "${p}" (echo ${p})`, { encoding: 'utf-8', shell: 'cmd.exe' }); } catch { continue; } - } - } - try { - return execSync('where opencode', { encoding: 'utf-8', shell: 'cmd.exe' }).trim().split(/\r?\n/)[0]; - } catch { - return 'opencode'; - } - } try { - return execSync('which opencode', { encoding: 'utf-8' }).trim().split(/\r?\n/)[0]; + const cmd = isWindows ? 'where opencode' : 'which opencode'; + return execSync(cmd, { encoding: 'utf-8' }).trim().split(/\r?\n/)[0]; } catch { - return '/usr/local/bin/opencode'; + 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: { model?: string; dangerouslySkipPermissions?: boolean; extraArgs?: string[] }, - sessionId: string | undefined, - model: string, + opencodeConfig: OpenCodeBotConfig, prompt: string, + sessionId: string | undefined, + model: string | undefined, ): string[] { - const args: string[] = []; - args.push('run', '--format', 'json'); + 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'); - if (opencodeConfig.extraArgs) { - for (const extraArg of opencodeConfig.extraArgs) args.push(extraArg); - } + for (const extraArg of opencodeConfig.extraArgs ?? []) args.push(extraArg); args.push('--', prompt); return args; } @@ -69,20 +56,23 @@ export class OpenCodeExecutor { ) {} startExecution(options: ExecutorOptions): ExecutionHandle { - const { prompt, cwd, sessionId, abortController, outputsDir, apiContext, model } = options; + const { prompt, cwd, sessionId, abortController, outputsDir, apiContext } = options; const opencodeConfig = this.config.opencode ?? {}; - const effectiveModel = model ?? opencodeConfig.model ?? 'minimax-cn-coding-plan/MiniMax-M2.5-highspeed'; + const model = options.model ?? opencodeConfig.model; const fullPrompt = this.buildPromptWithContext(prompt, outputsDir, apiContext); const queue = new AsyncQueue(); - const state = createOpenCodeTranslatorState({ model: effectiveModel }); - const args = buildOpenCodeArgs(opencodeConfig, sessionId, effectiveModel, fullPrompt); + 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: effectiveModel, engine: 'opencode' }, 'Starting OpenCode execution'); + this.logger.info({ cwd, hasSession: !!sessionId, model, engine: 'opencode' }, 'Starting OpenCode execution'); const finishWithError = (message: string): void => { if (sawResult) return; @@ -99,7 +89,6 @@ export class OpenCodeExecutor { }; const emitEvent = (event: OpenCodeJsonEvent): void => { - if (event.sessionID && !state.sessionId) state.sessionId = event.sessionID; const messages = translateOpenCodeJsonEvent(event, state); for (const message of messages) { if (message.type === 'result') sawResult = true; @@ -122,7 +111,7 @@ export class OpenCodeExecutor { }; try { - child = spawn(OPENCODE_EXECUTABLE, args, { + child = spawn(opencodeConfig.executable || OPENCODE_EXECUTABLE, args, { cwd, env: { ...process.env, ...(opencodeConfig.env ?? {}) }, stdio: ['ignore', 'pipe', 'pipe'], @@ -147,13 +136,19 @@ export class OpenCodeExecutor { }); child.on('close', (code, signal) => { if (stdoutBuffer.trim()) { - try { emitEvent(JSON.parse(stdoutBuffer) as OpenCodeJsonEvent); } catch { /* ignore */ } + 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'); + if (stderr.trim()) { + this.logger.debug({ stderr: stderr.trim() }, 'OpenCode stderr'); + } queue.finish(); }); } @@ -176,7 +171,9 @@ export class OpenCodeExecutor { async *execute(options: ExecutorOptions): AsyncGenerator { const handle = this.startExecution(options); try { - for await (const msg of handle.stream) yield msg; + for await (const msg of handle.stream) { + yield msg; + } } finally { handle.finish(); } @@ -188,24 +185,28 @@ export class OpenCodeExecutor { 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(', ')}.`, + `## 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 index a380b247..f1d2289f 100644 --- a/src/engines/opencode/index.ts +++ b/src/engines/opencode/index.ts @@ -1,8 +1,8 @@ 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'; -import { OpenCodeStreamProcessor } from './stream-processor.js'; export class OpenCodeEngine implements Engine { readonly name = 'opencode' as const; @@ -16,13 +16,12 @@ export class OpenCodeEngine implements Engine { return new OpenCodeExecutor(this.config, this.logger); } - createStreamProcessor(userPrompt: string): OpenCodeStreamProcessor { - return new OpenCodeStreamProcessor(userPrompt); + createStreamProcessor(userPrompt: string): StreamProcessor { + return new StreamProcessor(userPrompt); } } export { OpenCodeExecutor } from './executor.js'; -export { OpenCodeStreamProcessor } from './stream-processor.js'; export type { OpenCodeJsonEvent, OpenCodeTranslatorState } from './jsonl-translator.js'; export { createOpenCodeTranslatorState, diff --git a/src/engines/opencode/jsonl-translator.ts b/src/engines/opencode/jsonl-translator.ts index 79779527..54854a4f 100644 --- a/src/engines/opencode/jsonl-translator.ts +++ b/src/engines/opencode/jsonl-translator.ts @@ -10,12 +10,26 @@ export interface OpenCodeJsonEvent { type?: string; text?: string; name?: string; - input?: unknown; - messageID?: string; - sessionID?: 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?: { - total?: number; input?: number; output?: number; reasoning?: number; @@ -31,68 +45,148 @@ export interface OpenCodeTranslatorState { lastAgentText: string; startTime: number; model?: string; + contextWindow?: number; } -export function createOpenCodeTranslatorState(options: { model?: string } = {}): OpenCodeTranslatorState { - return { lastAgentText: '', startTime: Date.now(), model: options.model }; +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: 'assistant', + type: 'stream_event', session_id: state.sessionId, - message: { content: [{ type: 'text', text }] }, + event: { + type: 'content_block_delta', + delta: { type: 'text_delta', text }, + }, }]; } + case 'tool_use': + return translateToolUse(event, state); + case 'step_finish': { - const resultText = state.lastAgentText; - state.lastAgentText = ''; - return [{ - type: 'result', - subtype: 'success', - session_id: state.sessionId, - duration_ms: Date.now() - state.startTime, - result: resultText, - is_error: false, - errors: undefined, - modelUsage: state.model ? { - [state.model]: { - inputTokens: event.tokens?.input ?? 0, - outputTokens: event.tokens?.output ?? 0, - contextWindow: 128000, - costUSD: event.cost ?? 0, - }, - } : undefined, - }]; + 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': - case 'result': { - if (event.sessionID) state.sessionId = event.sessionID; - const isError = event.type === 'error'; - return [{ - type: 'result', - subtype: isError ? 'error_during_execution' : 'success', - session_id: state.sessionId, - duration_ms: Date.now() - state.startTime, - result: event.error ?? event.message ?? state.lastAgentText, - is_error: isError, - errors: isError ? [event.error ?? event.message ?? 'Unknown error'] : undefined, - }]; - } + 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/opencode/stream-processor.ts b/src/engines/opencode/stream-processor.ts deleted file mode 100644 index 059b7233..00000000 --- a/src/engines/opencode/stream-processor.ts +++ /dev/null @@ -1,88 +0,0 @@ -import type { SDKMessage } from '../claude/executor.js'; -import type { CardState } from '../../types.js'; - -export class OpenCodeStreamProcessor { - private responseText = ''; - private toolCalls: { toolUseId: string; name: string; input: string }[] = []; - private currentToolName: string | null = null; - private currentToolInput = ''; - private sessionId: string | undefined; - private costUsd: number | undefined; - private durationMs: number | undefined; - private errorMessage: string | undefined; - private _model: string | undefined; - private _imagePaths: Set = new Set(); - private _status: 'thinking' | 'running' | 'complete' | 'error' = 'thinking'; - private _userPrompt: string; - - constructor(userPrompt: string) { - this._userPrompt = userPrompt; - } - - processMessage(message: SDKMessage): CardState | undefined { - if (message.session_id) this.sessionId = message.session_id; - - switch (message.type) { - case 'assistant': { - this._status = 'running'; - if (message.message?.content) { - for (const block of message.message.content) { - if (block.type === 'text') { - this.responseText += block.text ?? ''; - } - if (block.type === 'tool_use') { - this.currentToolName = block.name ?? 'unknown'; - this.currentToolInput = JSON.stringify(block.input ?? {}); - } - if (block.type === 'content_block_end' && this.currentToolName) { - this.toolCalls.push({ - toolUseId: `oc-${Date.now()}-${Math.random().toString(36).slice(2)}`, - name: this.currentToolName, - input: this.currentToolInput, - }); - this.currentToolName = null; - this.currentToolInput = ''; - } - } - } - return undefined; - } - - case 'result': { - this._status = message.is_error ? 'error' : 'complete'; - if (message.duration_ms) this.durationMs = message.duration_ms; - if (message.modelUsage) { - const firstModel = Object.values(message.modelUsage)[0] as { costUSD?: number }; - this.costUsd = firstModel?.costUSD; - } - if (message.is_error && message.errors?.length) { - this.errorMessage = message.errors[0]; - } - if (message.result && !this.responseText) { - this.responseText = message.result; - } - return this.buildCardState(); - } - - default: - return undefined; - } - } - - extractImagePaths(): string[] { - return Array.from(this._imagePaths); - } - - private buildCardState(): CardState { - return { - status: this._status, - userPrompt: this._userPrompt, - responseText: this.responseText, - toolCalls: this.toolCalls, - costUsd: this.costUsd, - durationMs: this.durationMs, - errorMessage: this.errorMessage, - model: this._model, - }; - } -} diff --git a/src/engines/types.ts b/src/engines/types.ts index 969a2ecb..113d951e 100644 --- a/src/engines/types.ts +++ b/src/engines/types.ts @@ -11,7 +11,6 @@ import type { import type { CodexExecutor } from './codex/executor.js'; import type { OpenCodeExecutor } from './opencode/executor.js'; import type { StreamProcessor } from './claude/stream-processor.js'; -import type { OpenCodeStreamProcessor } from './opencode/stream-processor.js'; export type EngineName = 'claude' | 'kimi' | 'codex' | 'opencode'; @@ -43,7 +42,7 @@ export interface Executor { execute(options: ExecutorOptions): AsyncGenerator; } -export type StreamProcessorLike = StreamProcessor | OpenCodeStreamProcessor; +export type StreamProcessorLike = StreamProcessor; export type { ClaudeExecutor, 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'); + }); +});