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
11pub 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 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 args.push(config.prompt.clone());
66 args
67 }
68
69 fn build_env(&self, _config: &TaskConfig) -> Vec<(String, String)> {
70 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 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
113fn 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 "step_start" => {
140 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 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 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 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 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 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 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 events
262 }
263
264 "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 #[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 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 assert!(events.iter().any(|e| matches!(e, Ok(Event::UsageDelta(_)))));
414 assert!(!events.iter().any(|e| matches!(e, Ok(Event::Result(_)))));
415 }
416
417 #[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}