Skip to content

Implement OpenCodeCliRuntime concrete class #16

@alexey-pelykh

Description

@alexey-pelykh

Context

Fourth concrete CLIRuntimeBase subclass — spawns opencode run --format json (repo: anomalyco/opencode) and maps its per-line envelope NDJSON output to AgentEvent instances.

src/middleware/
├── types.ts                       ← AgentRuntime, AgentEvent, etc. (PR #4)
├── cli-runtime-base.ts            ← CLIRuntimeBase abstract class (PR #6)
└── runtimes/
    ├── claude.ts + claude.test.ts ← ClaudeCliRuntime (#8, PR #9)
    ├── gemini.ts + gemini.test.ts ← GeminiCliRuntime (#10, PR #11)
    ├── codex.ts  + codex.test.ts  ← CodexCliRuntime  (#12, PR #13)
    └── opencode.ts + opencode.test.ts ← THIS ISSUE

Dependencies: types module (PR #4, merged), CLIRuntimeBase (PR #6, merged).
Reference implementations: runtimes/claude.ts, runtimes/gemini.ts, runtimes/codex.ts for established patterns.


Specification

Constructor

constructor() {
    super("opencode");
}

supportsStdinPrompt (no override needed)

OpenCode supports stdin prompt delivery for >10KB prompts (same threshold as Claude). The default true from CLIRuntimeBase is correct — do not override.

buildArgs(params: AgentExecuteParams): string[]

Scenario Args
New session ["run", "--format", "json", params.prompt]
Session resume ["run", "--format", "json", "--session", params.sessionId, params.prompt]

Key behaviors:

  • Subcommand is run (unlike Codex's exec)
  • --format json enables NDJSON output (not --quiet — flag does not exist, confirmed OpenCode CLI source analysis)
  • Prompt is always the last positional argument
  • --session <id> flag for session resume (prompt is still included on resume)
  • No --mcp-config flag — MCP handled via config file (see MCP section below)

execute() override

Override with the standard pattern plus pending events buffer drain:

async *execute(params: AgentExecuteParams): AsyncIterable<AgentEvent> {
    this.resetState();

    const mcpConfigManager = /* ... setup if mcpServers present ... */;

    try {
        await mcpConfigManager?.setup();

        for await (const event of super.execute(params)) {
            if (event.type === "done") {
                this.enrichDoneEvent(event);
            }
            yield event;

            // Drain buffered events (from tool_use → tool_result pairs)
            while (this.pendingEvents.length > 0) {
                yield this.pendingEvents.shift()!;
            }
        }
    } finally {
        await mcpConfigManager?.teardown();
    }
}

The pending events buffer is needed because OpenCode emits tool_use events only when the tool has completed/errored, with the result included in the same event. A single NDJSON line must produce both AgentToolUseEvent and AgentToolResultEvent, but extractEvent() returns a single event. The tool_result is buffered and drained after yielding the tool_use.

extractEvent(line: string): AgentEvent | null

Every NDJSON line from OpenCode has a per-line envelope:

{ "type": "<event_type>", "timestamp": "...", "sessionID": "sess-abc", ...data }

Envelope processing (before type dispatch):

  1. Parse JSON, validate object with type string field
  2. Extract sessionID from envelope → store in currentSessionId (first non-undefined wins, or always update)

Event type mapping (5+1 types):

OpenCode type Maps to Details
"text" AgentTextEvent { type: "text", text: content } — complete text parts, no delta tracking needed
"tool_use" AgentToolUseEvent + buffered AgentToolResultEvent See tool_use handling below
"step_start" null Lifecycle boundary — skip
"step_finish" null Capture tokens and cost for usage; capture reason for stop reason
"reasoning" null Skip — requires --thinking flag, not emitted by default
"error" AgentErrorEvent { type: "error", message: parsed.message }

text event handling

OpenCode text events carry complete text parts (not cumulative snapshots like Codex). Each event is a standalone chunk that can be directly emitted:

// Accumulate for done event enrichment
this.accumulatedText += content;
return { type: "text", text: content } satisfies AgentTextEvent;

No delta tracking or lastEmittedTextLength needed (simpler than Codex).

tool_use event handling

OpenCode only emits tool_use when the tool has completed or errored. The event contains both input and result:

{
    "type": "tool_use",
    "timestamp": "...",
    "sessionID": "sess-abc",
    "name": "read_file",
    "callID": "call-123",
    "input": { "path": "/tmp/test.txt" },
    "state": {
        "output": "file contents...",
        "error": ""
    }
}

Mapping:

  1. Return AgentToolUseEvent:

    {
        type: "tool_use",
        toolName: parsed.name ?? "",
        toolId: parsed.callID ?? `opencode-tool-${this.toolCounter++}`,
        input: parsed.input ?? {},
    }
  2. Push AgentToolResultEvent to this.pendingEvents:

    {
        type: "tool_result",
        toolId: /* same toolId as above */,
        output: parsed.state?.output ?? "",
        isError: typeof parsed.state?.error === "string" && parsed.state.error.length > 0,
    }

The execute() override drains pendingEvents after yielding each event from super.execute().

step_finish event handling

Captures usage and cost data for done event enrichment:

{
    "type": "step_finish",
    "timestamp": "...",
    "sessionID": "sess-abc",
    "tokens": {
        "input": 500,
        "output": 200,
        "reasoning": 50,
        "total": 750,
        "cache": { "read": 100, "write": 25 }
    },
    "cost": 0.0042,
    "reason": "end_turn"
}

Store for done event enrichment:

  • tokens.inputusage.inputTokens
  • tokens.outputusage.outputTokens
  • tokens.cache.readusage.cacheReadTokens (if > 0)
  • tokens.cache.writeusage.cacheWriteTokens (if > 0)
  • costresult.totalCostUsd
  • reasonresult.stopReason

buildEnv(params: AgentExecuteParams): Record<string, string>

Returns empty record {}. No provider-specific env var injection needed (auth handled via params.env in base class).

MCP configuration — OpenCodeMcpConfigManager

Exported class managing the OpenCode config file lifecycle for MCP server configuration.

OpenCode reads MCP config from its settings file. Uses the merge-restore pattern (same as Gemini and Codex):

  • Config location: .opencode/config.json in the working directory (project-level settings)
  • Format: JSON with mcpServers key matching Record<string, McpServerConfig>
{
    "mcpServers": {
        "remoteclaw": {
            "command": "node",
            "args": ["/path/to/server.js"],
            "env": { "KEY": "value" }
        }
    }
}

Lifecycle:

  • setup():
    1. Ensure .opencode/ directory exists
    2. Read existing config (if any), save original content
    3. Deep-merge mcpServers into existing config
    4. Write merged config
  • teardown():
    1. Restore original file content, OR
    2. Remove created file (and directory if we created it)

Constructor: constructor(workingDirectory: string | undefined, mcpServers: Record<string, McpServerConfig>) — same signature as GeminiMcpConfigManager.

Done event enrichment

private enrichDoneEvent(event: AgentDoneEvent): void {
    const { result } = event;

    result.text = this.accumulatedText;
    result.sessionId = this.currentSessionId;

    if (this.lastStepFinish) {
        const { tokens, cost, reason } = this.lastStepFinish;

        if (tokens) {
            const usage: AgentUsage = {
                inputTokens: tokens.input ?? 0,
                outputTokens: tokens.output ?? 0,
                ...(tokens.cache?.read > 0 ? { cacheReadTokens: tokens.cache.read } : {}),
                ...(tokens.cache?.write > 0 ? { cacheWriteTokens: tokens.cache.write } : {}),
            };
            result.usage = usage;
        }

        if (cost !== undefined) {
            result.totalCostUsd = cost;
        }
        if (reason !== undefined) {
            result.stopReason = reason;
        }
    }
}

State management

Per-execution state (reset before each run):

Field Type Purpose
currentSessionId string | undefined Extracted from envelope sessionID
accumulatedText string Concatenated text event content
lastStepFinish StepFinishData | undefined Usage/cost/reason from last step_finish
pendingEvents AgentEvent[] Buffer for tool_result events from tool_use pairs
toolCounter number Fallback tool ID generator (opencode-tool-N)

Test file specification

Create src/middleware/runtimes/opencode.test.ts following the established pattern from codex.test.ts and gemini.test.ts.

Test helper

class TestableOpenCodeCliRuntime extends OpenCodeCliRuntime {
    public testBuildArgs(params: AgentExecuteParams): string[] {
        return this.buildArgs(params);
    }
    public testExtractEvent(line: string): AgentEvent | null {
        return this.extractEvent(line);
    }
    public testBuildEnv(params: AgentExecuteParams): Record<string, string> {
        return this.buildEnv(params);
    }
    public get testSupportsStdinPrompt(): boolean {
        return this.supportsStdinPrompt;
    }
    public get testPendingEvents(): AgentEvent[] {
        return (this as any).pendingEvents;
    }
}

Test categories

supportsStdinPrompt (1 test):

  • Returns true (default from base class)

buildArgs (~6 tests):

  • Produces run --format json <prompt> for new session
  • Produces run --format json --session <id> <prompt> for session resume
  • Includes prompt on session resume (unlike Codex which excludes it)
  • Always starts with run
  • Always includes --format json
  • Does not include --mcp-config flag

extractEvent (~16 tests):

  • Extracts sessionID from envelope and returns appropriate event
  • Maps text event to AgentTextEvent with complete text chunk
  • Accumulates text across multiple text events
  • Maps tool_use event to AgentToolUseEvent and buffers AgentToolResultEvent
  • Uses callID as toolId when present
  • Generates fallback opencode-tool-N IDs when callID is missing
  • Marks tool_result as error when state.error is non-empty
  • Stores usage from step_finish and returns null
  • Stores cost from step_finish and returns null
  • Stores stop reason from step_finish and returns null
  • Skips step_start events
  • Skips reasoning events
  • Maps error event to AgentErrorEvent
  • Skips unknown event types
  • Handles missing/malformed fields gracefully

buildEnv (2 tests):

  • Returns empty record
  • Does not inject auth vars regardless of params

Done event enrichment (~5 tests):

  • Enriches done event with accumulated text, session ID, and usage
  • Includes cacheReadTokens and cacheWriteTokens when present
  • Omits cache tokens when zero
  • Sets totalCostUsd and stopReason from step_finish
  • Handles missing step_finish gracefully (no usage, no cost)

MCP config file management (~4 tests):

  • Creates JSON config file when mcpServers has entries
  • Cleans up created file on teardown
  • Preserves existing config and restores on teardown
  • Writes correct JSON structure with mcpServers key

Total: ~34 tests


Key differences from other runtimes

Aspect Claude Gemini Codex OpenCode
Subcommand none (-p) none (-p) exec run
Prompt delivery positional + stdin -p flag positional only positional + stdin
Session resume --resume <id> -r <id> exec resume <id> --session <id>
Prompt on resume yes yes no yes
Event envelope stream_event wrapper bare JSON bare JSON per-line { type, timestamp, sessionID }
Session ID source envelope session_id init event thread.started envelope sessionID (every event)
Text events deltas (text_delta) deltas (delta: true) cumulative (needs delta) complete parts (no delta tracking)
Tool events separate start/delta/stop separate use/result separate start/completed single event (use + result combined)
Usage source result line / message_delta result.stats turn.completed step_finish.tokens
Cost tracking yes (cost_usd) no no yes (step_finish.cost)
Cache write tokens yes no no yes (tokens.cache.write)
Done event result line result event turn.completed process exit (no explicit done)
MCP config inline JSON (--mcp-config) .gemini/settings.json ~/.codex/config.toml .opencode/config.json
supportsStdinPrompt true (default) false false true (default)

Acceptance criteria

  • src/middleware/runtimes/opencode.ts exports OpenCodeCliRuntime extending CLIRuntimeBase
  • src/middleware/runtimes/opencode.ts exports OpenCodeMcpConfigManager
  • Constructor passes "opencode" to super()
  • supportsStdinPrompt is NOT overridden (defaults to true)
  • buildArgs() produces correct args for new session and resume (with --session flag)
  • buildArgs() includes prompt on resume (unlike Codex)
  • extractEvent() parses per-line envelope and extracts sessionID
  • extractEvent() maps all 6 event types correctly (text, tool_use, step_start, step_finish, reasoning, error)
  • text events emit complete text parts (no delta tracking)
  • tool_use events emit AgentToolUseEvent and buffer AgentToolResultEvent in pendingEvents
  • execute() override drains pendingEvents after each yielded event
  • step_finish usage maps to AgentUsage including cacheWriteTokens
  • step_finish.cost maps to result.totalCostUsd
  • step_finish.reason maps to result.stopReason
  • buildEnv() returns empty record
  • OpenCodeMcpConfigManager uses merge-restore pattern on .opencode/config.json
  • Done event enrichment sets text, sessionId, usage, totalCostUsd, stopReason
  • src/middleware/runtimes/opencode.test.ts covers all specified categories (~34 tests)
  • All tests pass via npx vitest run src/middleware/runtimes/opencode.test.ts

References

  • src/middleware/types.tsAgentRuntime, AgentEvent, AgentUsage, McpServerConfig
  • src/middleware/cli-runtime-base.tsCLIRuntimeBase abstract class
  • src/middleware/runtimes/codex.ts — closest reference (NDJSON, config file MCP, similar structure)
  • src/middleware/runtimes/gemini.ts — reference for JSON-based MCP config manager
  • OpenCode source: anomalyco/opencode (packages/opencode/src/cli/cmd/run.ts)
  • Known bug: --command flag suppresses JSON output (JSON output missing when using --command flag with opencode run --format json anomalyco/opencode#2923) — do not use
  • Permissions are auto-rejected in non-interactive mode (no permission denial tracking needed)
  • reasoning events require --thinking flag — skipped by default

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions