Skip to main content

harness/agents/
opencode.rs

1use std::path::PathBuf;
2
3use async_trait::async_trait;
4
5use crate::config::{PermissionMode, TaskConfig};
6use crate::error::Result;
7use crate::event::*;
8use crate::process::{spawn_and_stream, StreamHandle};
9use crate::runner::AgentRunner;
10
11/// Adapter for OpenCode CLI (`opencode` binary).
12///
13/// Headless invocation:
14///   opencode run --format json "<prompt>"
15///
16/// The `run` subcommand is non-interactive: all permissions are auto-approved,
17/// and the process exits after the task completes.
18///
19/// With `--format json`, output is NDJSON with event types:
20///   - { type: "step_start", sessionID, part: { type: "step-start", ... } }
21///   - { type: "text", sessionID, part: { type: "text", text, ... } }
22///   - { type: "tool_use", sessionID, part: { type: "tool", callID, tool, state: { status, input, output, ... } } }
23///   - { type: "step_finish", sessionID, part: { type: "step-finish", reason, cost, tokens: { input, output, cache: { read, write } } } }
24pub struct OpenCodeRunner;
25
26#[async_trait]
27impl AgentRunner for OpenCodeRunner {
28    fn name(&self) -> &str {
29        "opencode"
30    }
31
32    fn is_available(&self) -> bool {
33        crate::runner::is_any_binary_available(crate::config::AgentKind::OpenCode)
34    }
35
36    fn binary_path(&self, config: &TaskConfig) -> Result<PathBuf> {
37        crate::runner::resolve_binary(crate::config::AgentKind::OpenCode, config)
38    }
39
40    fn build_args(&self, config: &TaskConfig) -> Vec<String> {
41        let mut args = vec![
42            "run".to_string(),
43            "--format".to_string(),
44            "json".to_string(),
45        ];
46
47        if let Some(ref model) = config.model {
48            args.push("--model".to_string());
49            args.push(model.clone());
50        }
51
52        // OpenCode `run` auto-approves all permissions by default.
53        // For read-only, use a plan agent.
54        match config.permission_mode {
55            PermissionMode::FullAccess => {}
56            PermissionMode::ReadOnly => {
57                args.push("--agent".to_string());
58                args.push("plan".to_string());
59            }
60        }
61
62        args.extend(config.extra_args.iter().cloned());
63
64        // Prompt is the final positional argument(s).
65        args.push(config.prompt.clone());
66        args
67    }
68
69    fn build_env(&self, _config: &TaskConfig) -> Vec<(String, String)> {
70        // OpenCode reads provider API keys from environment (ANTHROPIC_API_KEY,
71        // OPENAI_API_KEY, etc.) or from its config files.
72        vec![]
73    }
74
75    async fn run(
76        &self,
77        config: &TaskConfig,
78        cancel_token: Option<tokio_util::sync::CancellationToken>,
79    ) -> Result<StreamHandle> {
80        spawn_and_stream(self, config, parse_opencode_line, cancel_token).await
81    }
82
83    fn capabilities(&self) -> crate::runner::AgentCapabilities {
84        crate::runner::AgentCapabilities {
85            supports_system_prompt: false,
86            supports_budget: false,
87            supports_model: true,
88            supports_max_turns: false,
89            supports_append_system_prompt: false,
90        }
91    }
92}
93
94fn parse_opencode_line(line: &str) -> Vec<Result<Event>> {
95    let value: serde_json::Value = match serde_json::from_str(line) {
96        Ok(v) => v,
97        Err(_) => {
98            // OpenCode may emit non-JSON progress text. Treat it as a text delta.
99            return vec![Ok(Event::TextDelta(TextDeltaEvent {
100                text: line.to_string(),
101                timestamp_ms: 0,
102            }))];
103        }
104    };
105
106    if let Some(event_type) = value.get("type").and_then(|v| v.as_str()) {
107        return parse_typed_event(event_type, &value);
108    }
109
110    vec![]
111}
112
113/// Extract usage data from OpenCode's `part.tokens` object.
114fn extract_opencode_usage(part: &serde_json::Value) -> Option<UsageData> {
115    let tokens = part.get("tokens")?;
116    let input = tokens.get("input").and_then(|v| v.as_u64());
117    let output = tokens.get("output").and_then(|v| v.as_u64());
118    let cache_read = tokens
119        .get("cache")
120        .and_then(|c| c.get("read"))
121        .and_then(|v| v.as_u64());
122    let cache_write = tokens
123        .get("cache")
124        .and_then(|c| c.get("write"))
125        .and_then(|v| v.as_u64());
126    Some(UsageData {
127        input_tokens: input,
128        output_tokens: output,
129        cache_read_tokens: cache_read,
130        cache_creation_tokens: cache_write,
131        cost_usd: part.get("cost").and_then(|v| v.as_f64()),
132    })
133}
134
135fn parse_typed_event(event_type: &str, value: &serde_json::Value) -> Vec<Result<Event>> {
136    match event_type {
137        // ── Current format (2025+) ──────────────────────────────────
138
139        "step_start" => {
140            // First step_start is treated as session init.
141            let session_id = value
142                .get("sessionID")
143                .and_then(|v| v.as_str())
144                .unwrap_or("")
145                .to_string();
146            vec![Ok(Event::SessionStart(SessionStartEvent {
147                session_id,
148                agent: "opencode".to_string(),
149                model: None,
150                cwd: None,
151                timestamp_ms: 0,
152            }))]
153        }
154
155        "text" => {
156            // { type: "text", part: { text: "..." } }
157            let text = value
158                .pointer("/part/text")
159                .and_then(|v| v.as_str())
160                .unwrap_or("")
161                .to_string();
162            if text.is_empty() {
163                return vec![];
164            }
165            vec![Ok(Event::Message(MessageEvent {
166                role: Role::Assistant,
167                text,
168                usage: None,
169                timestamp_ms: 0,
170            }))]
171        }
172
173        "tool_use" => {
174            // { type: "tool_use", part: { callID, tool, state: { status, input: { command, ... }, output, ... } } }
175            let part = match value.get("part") {
176                Some(p) => p,
177                None => return vec![],
178            };
179            let call_id = part
180                .get("callID")
181                .and_then(|v| v.as_str())
182                .unwrap_or("")
183                .to_string();
184            let tool_name = part
185                .get("tool")
186                .and_then(|v| v.as_str())
187                .unwrap_or("unknown")
188                .to_string();
189            let state = part.get("state");
190            let input = state.and_then(|s| s.get("input")).cloned();
191            let output = state
192                .and_then(|s| s.get("output"))
193                .and_then(|v| v.as_str())
194                .map(|s| s.to_string());
195            let status = state
196                .and_then(|s| s.get("status"))
197                .and_then(|v| v.as_str())
198                .unwrap_or("completed");
199            let success = status == "completed";
200
201            // OpenCode emits tool_use with status=completed, so emit both
202            // ToolStart and ToolEnd in one go.
203            vec![
204                Ok(Event::ToolStart(ToolStartEvent {
205                    call_id: call_id.clone(),
206                    tool_name: tool_name.clone(),
207                    input,
208                    timestamp_ms: 0,
209                })),
210                Ok(Event::ToolEnd(ToolEndEvent {
211                    call_id,
212                    tool_name,
213                    success,
214                    output,
215                    usage: None,
216                    timestamp_ms: 0,
217                })),
218            ]
219        }
220
221        "step_finish" => {
222            // { type: "step_finish", part: { reason: "stop"|"tool-calls", cost, tokens: { ... } } }
223            let part = match value.get("part") {
224                Some(p) => p,
225                None => return vec![],
226            };
227            let reason = part
228                .get("reason")
229                .and_then(|v| v.as_str())
230                .unwrap_or("");
231            let session_id = value
232                .get("sessionID")
233                .and_then(|v| v.as_str())
234                .unwrap_or("")
235                .to_string();
236
237            let mut events = Vec::new();
238
239            // Always emit usage data if available.
240            if let Some(usage) = extract_opencode_usage(part) {
241                events.push(Ok(Event::UsageDelta(UsageDeltaEvent {
242                    usage,
243                    timestamp_ms: 0,
244                })));
245            }
246
247            // reason=stop means the agent is done (final step).
248            if reason == "stop" {
249                events.push(Ok(Event::Result(ResultEvent {
250                    success: true,
251                    text: String::new(),
252                    session_id,
253                    duration_ms: None,
254                    total_cost_usd: part.get("cost").and_then(|v| v.as_f64()),
255                    usage: extract_opencode_usage(part),
256                    timestamp_ms: 0,
257                })));
258            }
259            // reason=tool-calls means more steps will follow — no Result yet.
260
261            events
262        }
263
264        // ── Legacy format (kept for backward compat) ────────────────
265
266        "session.start" | "session.init" | "init" => {
267            let session_id = value
268                .get("session_id")
269                .or_else(|| value.get("id"))
270                .and_then(|v| v.as_str())
271                .unwrap_or("")
272                .to_string();
273            vec![Ok(Event::SessionStart(SessionStartEvent {
274                session_id,
275                agent: "opencode".to_string(),
276                model: value
277                    .get("model")
278                    .and_then(|v| v.as_str())
279                    .map(|s| s.to_string()),
280                cwd: value
281                    .get("cwd")
282                    .and_then(|v| v.as_str())
283                    .map(|s| s.to_string()),
284                timestamp_ms: 0,
285            }))]
286        }
287
288        "message" | "assistant" => {
289            let text = value
290                .get("content")
291                .or_else(|| value.get("text"))
292                .and_then(|v| v.as_str())
293                .unwrap_or("")
294                .to_string();
295            if text.is_empty() {
296                return vec![];
297            }
298            vec![Ok(Event::Message(MessageEvent {
299                role: Role::Assistant,
300                text,
301                usage: None,
302                timestamp_ms: 0,
303            }))]
304        }
305
306        "error" => {
307            let msg = value
308                .get("message")
309                .or_else(|| value.get("error"))
310                .and_then(|v| v.as_str())
311                .unwrap_or("unknown error")
312                .to_string();
313            vec![Ok(Event::Error(ErrorEvent {
314                message: msg,
315                code: value
316                    .get("code")
317                    .and_then(|v| v.as_str())
318                    .map(|s| s.to_string()),
319                timestamp_ms: 0,
320            }))]
321        }
322
323        "result" | "done" | "complete" => {
324            let text = value
325                .get("result")
326                .or_else(|| value.get("content"))
327                .or_else(|| value.get("text"))
328                .and_then(|v| v.as_str())
329                .unwrap_or("")
330                .to_string();
331            let session_id = value
332                .get("session_id")
333                .and_then(|v| v.as_str())
334                .unwrap_or("")
335                .to_string();
336            let success = value
337                .get("success")
338                .and_then(|v| v.as_bool())
339                .unwrap_or(true);
340            vec![Ok(Event::Result(ResultEvent {
341                success,
342                text,
343                session_id,
344                duration_ms: value.get("duration_ms").and_then(|v| v.as_u64()),
345                total_cost_usd: None,
346                usage: None,
347                timestamp_ms: 0,
348            }))]
349        }
350
351        _ => vec![],
352    }
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358
359    // ── Current format tests ────────────────────────────────────
360
361    #[test]
362    fn parse_step_start() {
363        let line = r#"{"type":"step_start","timestamp":1770612126829,"sessionID":"ses_abc123","part":{"type":"step-start","snapshot":"abc"}}"#;
364        let events = parse_opencode_line(line);
365        assert_eq!(events.len(), 1);
366        match &events[0] {
367            Ok(Event::SessionStart(s)) => {
368                assert_eq!(s.session_id, "ses_abc123");
369                assert_eq!(s.agent, "opencode");
370            }
371            other => panic!("expected SessionStart, got {other:?}"),
372        }
373    }
374
375    #[test]
376    fn parse_text_event() {
377        let line = r#"{"type":"text","sessionID":"ses_abc","part":{"type":"text","text":"Hello world","time":{"start":1,"end":2}}}"#;
378        let events = parse_opencode_line(line);
379        assert_eq!(events.len(), 1);
380        match &events[0] {
381            Ok(Event::Message(m)) => {
382                assert_eq!(m.role, Role::Assistant);
383                assert_eq!(m.text, "Hello world");
384            }
385            other => panic!("expected Message, got {other:?}"),
386        }
387    }
388
389    #[test]
390    fn parse_tool_use_event() {
391        let line = r#"{"type":"tool_use","sessionID":"ses_abc","part":{"type":"tool","callID":"toolu_01","tool":"bash","state":{"status":"completed","input":{"command":"ls"},"output":"file.txt\n"}}}"#;
392        let events = parse_opencode_line(line);
393        assert_eq!(events.len(), 2, "expected ToolStart + ToolEnd");
394        assert!(matches!(&events[0], Ok(Event::ToolStart(t)) if t.tool_name == "bash" && t.call_id == "toolu_01"));
395        assert!(matches!(&events[1], Ok(Event::ToolEnd(t)) if t.tool_name == "bash" && t.success && t.output == Some("file.txt\n".into())));
396    }
397
398    #[test]
399    fn parse_step_finish_stop() {
400        let line = r#"{"type":"step_finish","sessionID":"ses_abc","part":{"type":"step-finish","reason":"stop","cost":0.05,"tokens":{"input":100,"output":50,"reasoning":0,"cache":{"read":500,"write":100}}}}"#;
401        let events = parse_opencode_line(line);
402        // Should emit UsageDelta + Result.
403        assert!(events.len() >= 2);
404        assert!(events.iter().any(|e| matches!(e, Ok(Event::UsageDelta(_)))));
405        assert!(events.iter().any(|e| matches!(e, Ok(Event::Result(r)) if r.success)));
406    }
407
408    #[test]
409    fn parse_step_finish_tool_calls() {
410        let line = r#"{"type":"step_finish","sessionID":"ses_abc","part":{"type":"step-finish","reason":"tool-calls","cost":0,"tokens":{"input":1,"output":98,"reasoning":0,"cache":{"read":100,"write":50}}}}"#;
411        let events = parse_opencode_line(line);
412        // reason=tool-calls should emit UsageDelta but NOT Result.
413        assert!(events.iter().any(|e| matches!(e, Ok(Event::UsageDelta(_)))));
414        assert!(!events.iter().any(|e| matches!(e, Ok(Event::Result(_)))));
415    }
416
417    // ── Legacy format tests (backward compat) ───────────────────
418
419    #[test]
420    fn parse_legacy_session_init() {
421        let line = r#"{"type":"init","session_id":"oc-1","model":"claude-sonnet-4-5","cwd":"/project"}"#;
422        let events = parse_opencode_line(line);
423        assert_eq!(events.len(), 1);
424        match &events[0] {
425            Ok(Event::SessionStart(s)) => {
426                assert_eq!(s.session_id, "oc-1");
427                assert_eq!(s.model, Some("claude-sonnet-4-5".into()));
428            }
429            other => panic!("expected SessionStart, got {other:?}"),
430        }
431    }
432
433    #[test]
434    fn parse_legacy_message() {
435        let line = r#"{"type":"message","content":"Here is the answer"}"#;
436        let events = parse_opencode_line(line);
437        assert_eq!(events.len(), 1);
438        match &events[0] {
439            Ok(Event::Message(m)) => {
440                assert_eq!(m.text, "Here is the answer");
441            }
442            other => panic!("expected Message, got {other:?}"),
443        }
444    }
445
446    #[test]
447    fn parse_non_json_as_text_delta() {
448        let line = "Processing your request...";
449        let events = parse_opencode_line(line);
450        assert_eq!(events.len(), 1);
451        match &events[0] {
452            Ok(Event::TextDelta(d)) => assert_eq!(d.text, "Processing your request..."),
453            other => panic!("expected TextDelta, got {other:?}"),
454        }
455    }
456
457    #[test]
458    fn parse_error() {
459        let line = r#"{"type":"error","message":"API key invalid","code":"auth_error"}"#;
460        let events = parse_opencode_line(line);
461        assert_eq!(events.len(), 1);
462        match &events[0] {
463            Ok(Event::Error(e)) => {
464                assert_eq!(e.message, "API key invalid");
465                assert_eq!(e.code, Some("auth_error".into()));
466            }
467            other => panic!("expected Error, got {other:?}"),
468        }
469    }
470
471    #[test]
472    fn build_args_default() {
473        let config = TaskConfig::new("explain this", crate::config::AgentKind::OpenCode);
474        let runner = OpenCodeRunner;
475        let args = runner.build_args(&config);
476        assert_eq!(args[0], "run");
477        assert!(args.contains(&"--format".to_string()));
478        assert!(args.contains(&"json".to_string()));
479        assert_eq!(args.last().unwrap(), "explain this");
480    }
481
482    #[test]
483    fn build_args_read_only() {
484        let mut config = TaskConfig::new("analyze", crate::config::AgentKind::OpenCode);
485        config.permission_mode = PermissionMode::ReadOnly;
486        let runner = OpenCodeRunner;
487        let args = runner.build_args(&config);
488        assert!(args.contains(&"--agent".to_string()));
489        assert!(args.contains(&"plan".to_string()));
490    }
491
492    #[test]
493    fn build_args_full_access_no_agent_flag() {
494        let config = TaskConfig::new("task", crate::config::AgentKind::OpenCode);
495        let runner = OpenCodeRunner;
496        let args = runner.build_args(&config);
497        assert!(!args.contains(&"--agent".to_string()));
498    }
499}