-
Notifications
You must be signed in to change notification settings - Fork 0
Implement OpenCodeCliRuntime concrete class #16
Description
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'sexec) --format jsonenables 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-configflag — 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):
- Parse JSON, validate object with
typestring field - Extract
sessionIDfrom envelope → store incurrentSessionId(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:
-
Return
AgentToolUseEvent:{ type: "tool_use", toolName: parsed.name ?? "", toolId: parsed.callID ?? `opencode-tool-${this.toolCounter++}`, input: parsed.input ?? {}, }
-
Push
AgentToolResultEventtothis.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.input→usage.inputTokenstokens.output→usage.outputTokenstokens.cache.read→usage.cacheReadTokens(if > 0)tokens.cache.write→usage.cacheWriteTokens(if > 0)cost→result.totalCostUsdreason→result.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.jsonin the working directory (project-level settings) - Format: JSON with
mcpServerskey matchingRecord<string, McpServerConfig>
{
"mcpServers": {
"remoteclaw": {
"command": "node",
"args": ["/path/to/server.js"],
"env": { "KEY": "value" }
}
}
}Lifecycle:
setup():- Ensure
.opencode/directory exists - Read existing config (if any), save original content
- Deep-merge
mcpServersinto existing config - Write merged config
- Ensure
teardown():- Restore original file content, OR
- 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-configflag
extractEvent (~16 tests):
- Extracts
sessionIDfrom envelope and returns appropriate event - Maps
textevent toAgentTextEventwith complete text chunk - Accumulates text across multiple
textevents - Maps
tool_useevent toAgentToolUseEventand buffersAgentToolResultEvent - Uses
callIDastoolIdwhen present - Generates fallback
opencode-tool-NIDs whencallIDis missing - Marks
tool_resultas error whenstate.erroris non-empty - Stores usage from
step_finishand returnsnull - Stores cost from
step_finishand returnsnull - Stores stop reason from
step_finishand returnsnull - Skips
step_startevents - Skips
reasoningevents - Maps
errorevent toAgentErrorEvent - 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
cacheReadTokensandcacheWriteTokenswhen present - Omits cache tokens when zero
- Sets
totalCostUsdandstopReasonfromstep_finish - Handles missing
step_finishgracefully (no usage, no cost)
MCP config file management (~4 tests):
- Creates JSON config file when
mcpServershas entries - Cleans up created file on teardown
- Preserves existing config and restores on teardown
- Writes correct JSON structure with
mcpServerskey
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.tsexportsOpenCodeCliRuntimeextendingCLIRuntimeBase -
src/middleware/runtimes/opencode.tsexportsOpenCodeMcpConfigManager - Constructor passes
"opencode"tosuper() -
supportsStdinPromptis NOT overridden (defaults totrue) -
buildArgs()produces correct args for new session and resume (with--sessionflag) -
buildArgs()includes prompt on resume (unlike Codex) -
extractEvent()parses per-line envelope and extractssessionID -
extractEvent()maps all 6 event types correctly (text, tool_use, step_start, step_finish, reasoning, error) -
textevents emit complete text parts (no delta tracking) -
tool_useevents emitAgentToolUseEventand bufferAgentToolResultEventinpendingEvents -
execute()override drainspendingEventsafter each yielded event -
step_finishusage maps toAgentUsageincludingcacheWriteTokens -
step_finish.costmaps toresult.totalCostUsd -
step_finish.reasonmaps toresult.stopReason -
buildEnv()returns empty record -
OpenCodeMcpConfigManageruses merge-restore pattern on.opencode/config.json - Done event enrichment sets
text,sessionId,usage,totalCostUsd,stopReason -
src/middleware/runtimes/opencode.test.tscovers all specified categories (~34 tests) - All tests pass via
npx vitest run src/middleware/runtimes/opencode.test.ts
References
src/middleware/types.ts—AgentRuntime,AgentEvent,AgentUsage,McpServerConfigsrc/middleware/cli-runtime-base.ts—CLIRuntimeBaseabstract classsrc/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:
--commandflag suppresses JSON output (JSON output missing when using--commandflag withopencode run --format jsonanomalyco/opencode#2923) — do not use - Permissions are auto-rejected in non-interactive mode (no permission denial tracking needed)
reasoningevents require--thinkingflag — skipped by default