Skip to main content

hivemind/adapters/
opencode.rs

1//! `OpenCode` adapter - wrapper for `OpenCode` CLI runtime.
2//!
3//! This adapter wraps the `OpenCode` CLI to provide task execution
4//! capabilities within Hivemind's orchestration framework.
5
6use super::runtime::{
7    AdapterConfig, ExecutionInput, ExecutionReport, InteractiveAdapterEvent,
8    InteractiveExecutionResult, RuntimeAdapter, RuntimeError,
9};
10use crossterm::event::{self, Event as CrosstermEvent, KeyCode, KeyEvent, KeyModifiers};
11use crossterm::terminal;
12use portable_pty::{native_pty_system, CommandBuilder, PtySize};
13use signal_hook::consts::SIGINT;
14use signal_hook::iterator::Signals;
15use std::env;
16use std::fmt::Write as FmtWrite;
17use std::io::{BufReader, Read, Write};
18use std::path::PathBuf;
19use std::process::{Child, Command, Stdio};
20use std::sync::mpsc;
21use std::time::{Duration, Instant};
22use uuid::Uuid;
23
24struct RawModeGuard;
25
26impl RawModeGuard {
27    fn new() -> std::io::Result<Self> {
28        terminal::enable_raw_mode()?;
29        Ok(Self)
30    }
31}
32
33impl Drop for RawModeGuard {
34    fn drop(&mut self) {
35        let _ = terminal::disable_raw_mode();
36    }
37}
38
39fn status_with_retry(
40    binary: &std::path::Path,
41    arg: &str,
42) -> std::io::Result<std::process::ExitStatus> {
43    let mut last_err: Option<std::io::Error> = None;
44    for _ in 0..50 {
45        match Command::new(binary)
46            .arg(arg)
47            .stdout(Stdio::null())
48            .stderr(Stdio::null())
49            .status()
50        {
51            Ok(status) => return Ok(status),
52            Err(e) if e.raw_os_error() == Some(26) => {
53                last_err = Some(e);
54                std::thread::sleep(Duration::from_millis(5));
55            }
56            Err(e) => return Err(e),
57        }
58    }
59
60    Err(last_err.unwrap_or_else(|| std::io::Error::other("Executable remained busy")))
61}
62
63/// `OpenCode` adapter configuration.
64#[derive(Debug, Clone)]
65pub struct OpenCodeConfig {
66    /// Base adapter config.
67    pub base: AdapterConfig,
68    /// Model to use (if configurable).
69    pub model: Option<String>,
70    /// Whether to enable verbose output.
71    pub verbose: bool,
72}
73
74impl OpenCodeConfig {
75    /// Creates a new `OpenCode` config with default settings.
76    pub fn new(binary_path: PathBuf) -> Self {
77        let model = env::var("HIVEMIND_OPENCODE_MODEL").ok();
78        Self {
79            base: AdapterConfig::new("opencode", binary_path)
80                .with_timeout(Duration::from_secs(600)), // 10 minutes
81            model,
82            verbose: false,
83        }
84    }
85
86    /// Sets the model.
87    #[must_use]
88    pub fn with_model(mut self, model: impl Into<String>) -> Self {
89        self.model = Some(model.into());
90        self
91    }
92
93    /// Enables verbose mode.
94    #[must_use]
95    pub fn with_verbose(mut self, verbose: bool) -> Self {
96        self.verbose = verbose;
97        self
98    }
99
100    /// Sets the timeout.
101    #[must_use]
102    pub fn with_timeout(mut self, timeout: Duration) -> Self {
103        self.base.timeout = timeout;
104        self
105    }
106}
107
108impl Default for OpenCodeConfig {
109    fn default() -> Self {
110        Self::new(PathBuf::from("opencode"))
111    }
112}
113
114/// `OpenCode` runtime adapter.
115pub struct OpenCodeAdapter {
116    config: OpenCodeConfig,
117    worktree: Option<PathBuf>,
118    task_id: Option<Uuid>,
119    process: Option<Child>,
120}
121
122impl OpenCodeAdapter {
123    /// Creates a new `OpenCode` adapter.
124    pub fn new(config: OpenCodeConfig) -> Self {
125        Self {
126            config,
127            worktree: None,
128            task_id: None,
129            process: None,
130        }
131    }
132
133    /// Creates an adapter with default configuration.
134    pub fn with_defaults() -> Self {
135        Self::new(OpenCodeConfig::default())
136    }
137
138    /// Formats the input for the runtime.
139    #[allow(clippy::unused_self)]
140    fn format_input(&self, input: &ExecutionInput) -> String {
141        let task_description = &input.task_description;
142        let success_criteria = &input.success_criteria;
143        let mut prompt = format!("Task: {task_description}\n\n");
144        let _ = write!(prompt, "Success Criteria: {success_criteria}\n\n");
145
146        if let Some(ref context) = input.context {
147            let _ = write!(prompt, "Context:\n{context}\n\n");
148        }
149
150        if !input.prior_attempts.is_empty() {
151            prompt.push_str("Prior Attempts:\n");
152            for attempt in &input.prior_attempts {
153                let attempt_number = attempt.attempt_number;
154                let summary = &attempt.summary;
155                let _ = writeln!(prompt, "- Attempt {attempt_number}: {summary}",);
156                if let Some(ref reason) = attempt.failure_reason {
157                    let _ = writeln!(prompt, "  Failure: {reason}");
158                }
159            }
160            prompt.push('\n');
161        }
162
163        if let Some(ref feedback) = input.verifier_feedback {
164            let _ = write!(prompt, "Verifier Feedback:\n{feedback}\n\n");
165        }
166
167        prompt
168    }
169
170    #[allow(clippy::too_many_lines)]
171    pub fn execute_interactive<F>(
172        &mut self,
173        input: &ExecutionInput,
174        mut on_event: F,
175    ) -> Result<InteractiveExecutionResult, RuntimeError>
176    where
177        F: FnMut(InteractiveAdapterEvent) -> std::result::Result<(), String>,
178    {
179        enum Msg {
180            Output(String),
181            Interrupt,
182            Exit(portable_pty::ExitStatus),
183            OutputDone,
184        }
185
186        let worktree = self
187            .worktree
188            .as_ref()
189            .ok_or_else(|| RuntimeError::new("not_prepared", "Adapter not prepared", false))?;
190
191        let start = Instant::now();
192        let timeout = self.config.base.timeout;
193        let formatted_input = self.format_input(input);
194
195        let pty_system = native_pty_system();
196        let pair = pty_system
197            .openpty(PtySize {
198                rows: 24,
199                cols: 80,
200                pixel_width: 0,
201                pixel_height: 0,
202            })
203            .map_err(|e| {
204                RuntimeError::new("pty_open_failed", format!("Failed to open PTY: {e}"), false)
205            })?;
206
207        let mut cmd = CommandBuilder::new(&self.config.base.binary_path);
208        cmd.cwd(worktree);
209
210        for (key, value) in &self.config.base.env {
211            cmd.env(key, value);
212        }
213
214        let is_opencode_binary = self
215            .config
216            .base
217            .binary_path
218            .file_name()
219            .and_then(|s| s.to_str())
220            .is_some_and(|s| {
221                let lower = s.to_ascii_lowercase();
222                lower.contains("opencode") || lower.contains("kilo")
223            });
224
225        if is_opencode_binary {
226            cmd.arg("run");
227
228            let has_model_flag = self
229                .config
230                .base
231                .args
232                .iter()
233                .any(|a| a == "--model" || a == "-m" || a.starts_with("--model="));
234            if !has_model_flag {
235                if let Some(model) = &self.config.model {
236                    cmd.arg("--model");
237                    cmd.arg(model);
238                }
239            }
240
241            if self.config.verbose {
242                let has_print_logs = self.config.base.args.iter().any(|a| a == "--print-logs");
243                if !has_print_logs {
244                    cmd.arg("--print-logs");
245                }
246            }
247
248            cmd.args(&self.config.base.args);
249            cmd.arg(formatted_input.clone());
250        } else if self.config.base.args.is_empty() {
251            return Err(RuntimeError::new(
252                "missing_args",
253                "No runtime args configured; either point to the opencode binary or provide args",
254                true,
255            ));
256        } else {
257            cmd.args(&self.config.base.args);
258        }
259
260        let portable_pty::PtyPair { master, slave } = pair;
261
262        let child = slave.spawn_command(cmd).map_err(|e| {
263            RuntimeError::new(
264                "spawn_failed",
265                format!("Failed to spawn process: {e}"),
266                false,
267            )
268        })?;
269
270        // IMPORTANT: drop the parent's handle to the PTY slave.
271        // If we keep it open, the master reader may never observe EOF/hangup after the child exits,
272        // causing interactive sessions to hang indefinitely.
273        drop(slave);
274
275        let mut writer = master.take_writer().map_err(|e| {
276            RuntimeError::new(
277                "pty_writer_failed",
278                format!("Failed to open PTY writer: {e}"),
279                false,
280            )
281        })?;
282        let mut reader = master.try_clone_reader().map_err(|e| {
283            RuntimeError::new(
284                "pty_reader_failed",
285                format!("Failed to open PTY reader: {e}"),
286                false,
287            )
288        })?;
289
290        if !is_opencode_binary {
291            let _ = writer.write_all(formatted_input.as_bytes());
292            let _ = writer.write_all(b"\n");
293            let _ = writer.flush();
294        }
295
296        let (tx, rx) = mpsc::channel::<Msg>();
297
298        let output_tx = tx.clone();
299        let output_handle = std::thread::spawn(move || {
300            let mut buf = [0u8; 1024];
301            loop {
302                let Ok(n) = reader.read(&mut buf) else {
303                    break;
304                };
305                if n == 0 {
306                    break;
307                }
308                let chunk = String::from_utf8_lossy(&buf[..n]).to_string();
309                let _ = output_tx.send(Msg::Output(chunk));
310            }
311            let _ = output_tx.send(Msg::OutputDone);
312        });
313
314        let mut killer = child.clone_killer();
315        let wait_tx = tx.clone();
316        let mut wait_child = child;
317        let wait_handle = std::thread::spawn(move || {
318            if let Ok(status) = wait_child.wait() {
319                let _ = wait_tx.send(Msg::Exit(status));
320            }
321        });
322
323        let mut signals = Signals::new([SIGINT]).map_err(|e| {
324            RuntimeError::new(
325                "signal_register_failed",
326                format!("Failed to register SIGINT handler: {e}"),
327                false,
328            )
329        })?;
330
331        let _raw = RawModeGuard::new().map_err(|e| {
332            RuntimeError::new(
333                "interactive_tty_failed",
334                format!("Failed to enable raw terminal mode: {e}"),
335                false,
336            )
337        })?;
338
339        let mut terminated_reason: Option<String> = None;
340        let mut stdout = String::new();
341        let mut exit_status: Option<portable_pty::ExitStatus> = None;
342        let mut output_done = false;
343
344        let mut input_line = String::new();
345        let mut grace_deadline: Option<Instant> = None;
346
347        loop {
348            if terminated_reason.is_none() && start.elapsed() > timeout {
349                terminated_reason = Some("timeout".to_string());
350                grace_deadline = Some(Instant::now() + Duration::from_millis(200));
351                let _ = writer.write_all(b"\x03");
352                let _ = writer.flush();
353            }
354
355            for _sig in signals.pending() {
356                if terminated_reason.is_none() {
357                    let _ = tx.send(Msg::Interrupt);
358                }
359            }
360
361            while let Ok(msg) = rx.try_recv() {
362                match msg {
363                    Msg::Output(chunk) => {
364                        stdout.push_str(&chunk);
365                        on_event(InteractiveAdapterEvent::Output { content: chunk }).map_err(
366                            |e| RuntimeError::new("interactive_callback_failed", e, false),
367                        )?;
368                    }
369                    Msg::Interrupt => {
370                        if terminated_reason.is_none() {
371                            terminated_reason = Some("interrupted".to_string());
372                            grace_deadline = Some(Instant::now() + Duration::from_millis(200));
373                            on_event(InteractiveAdapterEvent::Interrupted).map_err(|e| {
374                                RuntimeError::new("interactive_callback_failed", e, false)
375                            })?;
376                            let _ = writer.write_all(b"\x03");
377                            let _ = writer.flush();
378                        }
379                    }
380                    Msg::Exit(status) => {
381                        exit_status = Some(status);
382                    }
383                    Msg::OutputDone => {
384                        output_done = true;
385                    }
386                }
387            }
388
389            if let Some(deadline) = grace_deadline {
390                if Instant::now() >= deadline {
391                    let _ = killer.kill();
392                    grace_deadline = None;
393                }
394            }
395
396            if output_done && exit_status.is_some() {
397                break;
398            }
399
400            if event::poll(Duration::from_millis(20)).map_err(|e| {
401                RuntimeError::new(
402                    "interactive_input_failed",
403                    format!("Failed to poll input: {e}"),
404                    false,
405                )
406            })? {
407                let ev = event::read().map_err(|e| {
408                    RuntimeError::new(
409                        "interactive_input_failed",
410                        format!("Failed to read input: {e}"),
411                        false,
412                    )
413                })?;
414
415                match ev {
416                    CrosstermEvent::Key(KeyEvent {
417                        code: KeyCode::Char('c'),
418                        modifiers,
419                        ..
420                    }) if modifiers.contains(KeyModifiers::CONTROL) => {
421                        let _ = tx.send(Msg::Interrupt);
422                    }
423                    CrosstermEvent::Key(KeyEvent {
424                        code: KeyCode::Enter,
425                        ..
426                    }) => {
427                        let line = std::mem::take(&mut input_line);
428                        on_event(InteractiveAdapterEvent::Input {
429                            content: line.clone(),
430                        })
431                        .map_err(|e| RuntimeError::new("interactive_callback_failed", e, false))?;
432                        let _ = writer.write_all(b"\r");
433                        let _ = writer.flush();
434                    }
435                    CrosstermEvent::Key(KeyEvent {
436                        code: KeyCode::Backspace,
437                        ..
438                    }) => {
439                        input_line.pop();
440                        let _ = writer.write_all(&[0x7f]);
441                        let _ = writer.flush();
442                    }
443                    CrosstermEvent::Key(KeyEvent {
444                        code: KeyCode::Tab, ..
445                    }) => {
446                        input_line.push('\t');
447                        let _ = writer.write_all(b"\t");
448                        let _ = writer.flush();
449                    }
450                    CrosstermEvent::Key(KeyEvent {
451                        code: KeyCode::Char(ch),
452                        modifiers,
453                        ..
454                    }) if !modifiers.contains(KeyModifiers::CONTROL)
455                        && !modifiers.contains(KeyModifiers::ALT) =>
456                    {
457                        input_line.push(ch);
458                        let mut buf = [0u8; 4];
459                        let s = ch.encode_utf8(&mut buf);
460                        let _ = writer.write_all(s.as_bytes());
461                        let _ = writer.flush();
462                    }
463                    CrosstermEvent::Key(KeyEvent {
464                        code: KeyCode::Left,
465                        ..
466                    }) => {
467                        let _ = writer.write_all(b"\x1b[D");
468                        let _ = writer.flush();
469                    }
470                    CrosstermEvent::Key(KeyEvent {
471                        code: KeyCode::Right,
472                        ..
473                    }) => {
474                        let _ = writer.write_all(b"\x1b[C");
475                        let _ = writer.flush();
476                    }
477                    CrosstermEvent::Key(KeyEvent {
478                        code: KeyCode::Up, ..
479                    }) => {
480                        let _ = writer.write_all(b"\x1b[A");
481                        let _ = writer.flush();
482                    }
483                    CrosstermEvent::Key(KeyEvent {
484                        code: KeyCode::Down,
485                        ..
486                    }) => {
487                        let _ = writer.write_all(b"\x1b[B");
488                        let _ = writer.flush();
489                    }
490                    _ => {}
491                }
492            }
493        }
494
495        let _ = output_handle.join();
496        let _ = wait_handle.join();
497
498        let exit_code = exit_status
499            .as_ref()
500            .map_or(-1, |s| i32::try_from(s.exit_code()).unwrap_or(-1));
501        let duration = start.elapsed();
502
503        let report = if exit_code == 0 {
504            ExecutionReport::success(duration, stdout, String::new())
505        } else {
506            ExecutionReport {
507                exit_code,
508                duration,
509                stdout,
510                stderr: String::new(),
511                files_created: Vec::new(),
512                files_modified: Vec::new(),
513                files_deleted: Vec::new(),
514                errors: vec![RuntimeError::new(
515                    "nonzero_exit",
516                    format!("Process exited with code {exit_code}"),
517                    true,
518                )],
519            }
520        };
521
522        Ok(InteractiveExecutionResult {
523            report,
524            terminated_reason,
525        })
526    }
527}
528
529impl RuntimeAdapter for OpenCodeAdapter {
530    fn name(&self) -> &str {
531        &self.config.base.name
532    }
533
534    fn initialize(&mut self) -> Result<(), RuntimeError> {
535        // Check if binary exists and is executable
536        let binary = &self.config.base.binary_path;
537
538        // Try to run with --version or --help
539        let result = status_with_retry(binary, "--version");
540
541        match result {
542            Ok(status) if status.success() => Ok(()),
543            Ok(_) => {
544                // Try --help as fallback
545                let help_result = status_with_retry(binary, "--help");
546
547                match help_result {
548                    Ok(status) if status.success() => Ok(()),
549                    _ => Err(RuntimeError::new(
550                        "health_check_failed",
551                        format!("Binary {} is not responding correctly", binary.display()),
552                        false,
553                    )),
554                }
555            }
556            Err(e) => Err(RuntimeError::new(
557                "binary_not_found",
558                format!("Cannot execute {}: {e}", binary.display()),
559                false,
560            )),
561        }
562    }
563
564    fn prepare(&mut self, task_id: Uuid, worktree: &std::path::Path) -> Result<(), RuntimeError> {
565        // Verify worktree exists
566        if !worktree.exists() {
567            return Err(RuntimeError::new(
568                "worktree_not_found",
569                format!("Worktree does not exist: {}", worktree.display()),
570                false,
571            ));
572        }
573
574        self.worktree = Some(worktree.to_path_buf());
575        self.task_id = Some(task_id);
576        Ok(())
577    }
578
579    #[allow(clippy::too_many_lines)]
580    fn execute(&mut self, input: ExecutionInput) -> Result<ExecutionReport, RuntimeError> {
581        let worktree = self
582            .worktree
583            .as_ref()
584            .ok_or_else(|| RuntimeError::new("not_prepared", "Adapter not prepared", false))?;
585
586        let start = Instant::now();
587        let timeout = self.config.base.timeout;
588
589        let formatted_input = self.format_input(&input);
590
591        // Build and spawn the command
592        let mut cmd = Command::new(&self.config.base.binary_path);
593        cmd.current_dir(worktree)
594            .stdout(Stdio::piped())
595            .stderr(Stdio::piped());
596
597        let is_opencode_binary = self
598            .config
599            .base
600            .binary_path
601            .file_name()
602            .and_then(|s| s.to_str())
603            .is_some_and(|s| {
604                let lower = s.to_ascii_lowercase();
605                lower.contains("opencode") || lower.contains("kilo")
606            });
607
608        if is_opencode_binary {
609            cmd.stdin(Stdio::null());
610            cmd.arg("run");
611
612            let has_model_flag = self
613                .config
614                .base
615                .args
616                .iter()
617                .any(|a| a == "--model" || a == "-m" || a.starts_with("--model="));
618            if !has_model_flag {
619                if let Some(model) = &self.config.model {
620                    cmd.arg("--model").arg(model);
621                }
622            }
623
624            if self.config.verbose {
625                let has_print_logs = self.config.base.args.iter().any(|a| a == "--print-logs");
626                if !has_print_logs {
627                    cmd.arg("--print-logs");
628                }
629            }
630
631            cmd.args(&self.config.base.args);
632            cmd.arg(formatted_input.clone());
633        } else if self.config.base.args.is_empty() {
634            return Err(RuntimeError::new(
635                "missing_args",
636                "No runtime args configured; either point to the opencode binary or provide args",
637                true,
638            ));
639        } else {
640            cmd.stdin(Stdio::piped());
641            cmd.args(&self.config.base.args);
642        }
643
644        // Add environment variables
645        for (key, value) in &self.config.base.env {
646            cmd.env(key, value);
647        }
648
649        // Spawn process
650        let mut child = cmd.spawn().map_err(|e| {
651            RuntimeError::new(
652                "spawn_failed",
653                format!("Failed to spawn process: {e}"),
654                false,
655            )
656        })?;
657
658        // Write input to stdin
659        if let Some(ref mut stdin) = child.stdin {
660            if let Err(e) = stdin.write_all(formatted_input.as_bytes()) {
661                // Some runtimes may exit quickly (e.g. error paths); in that case stdin can be
662                // closed before we finish writing. Treat EPIPE as non-fatal and continue to
663                // collect exit status and output.
664                if e.kind() != std::io::ErrorKind::BrokenPipe {
665                    return Err(RuntimeError::new(
666                        "stdin_write_failed",
667                        format!("Failed to write to stdin: {e}"),
668                        true,
669                    ));
670                }
671            }
672        }
673        // Close stdin
674        drop(child.stdin.take());
675
676        self.process = Some(child);
677
678        let (stdout_handle, stderr_handle) = if let Some(ref mut process) = self.process {
679            let stdout = process.stdout.take().ok_or_else(|| {
680                RuntimeError::new("stdout_capture_failed", "Missing stdout pipe", false)
681            })?;
682            let stderr = process.stderr.take().ok_or_else(|| {
683                RuntimeError::new("stderr_capture_failed", "Missing stderr pipe", false)
684            })?;
685
686            let stdout_handle = std::thread::spawn(move || {
687                let mut reader = BufReader::new(stdout);
688                let mut out = String::new();
689                let _ = reader.read_to_string(&mut out);
690                out
691            });
692            let stderr_handle = std::thread::spawn(move || {
693                let mut reader = BufReader::new(stderr);
694                let mut out = String::new();
695                let _ = reader.read_to_string(&mut out);
696                out
697            });
698
699            (stdout_handle, stderr_handle)
700        } else {
701            return Err(RuntimeError::new(
702                "no_process",
703                "No process to wait on",
704                false,
705            ));
706        };
707
708        let status = loop {
709            let Some(ref mut process) = self.process else {
710                let _ = stdout_handle.join();
711                let _ = stderr_handle.join();
712                self.process = None;
713                return Err(RuntimeError::timeout(timeout));
714            };
715
716            if start.elapsed() > timeout {
717                let _ = stdout_handle.join();
718                let _ = stderr_handle.join();
719                self.process = None;
720                return Err(RuntimeError::timeout(timeout));
721            }
722
723            if let Some(status) = process.try_wait().map_err(|e| {
724                RuntimeError::new(
725                    "wait_failed",
726                    format!("Failed to wait on process: {e}"),
727                    false,
728                )
729            })? {
730                break status;
731            }
732
733            std::thread::sleep(Duration::from_millis(10));
734        };
735
736        let duration = start.elapsed();
737        let stdout_content = stdout_handle.join().unwrap_or_else(|_| String::new());
738        let stderr_content = stderr_handle.join().unwrap_or_else(|_| String::new());
739
740        self.process = None;
741
742        let exit_code = status.code().unwrap_or(-1);
743        if exit_code == 0 {
744            Ok(ExecutionReport::success(
745                duration,
746                stdout_content,
747                stderr_content,
748            ))
749        } else {
750            Ok(ExecutionReport::failure(
751                exit_code,
752                duration,
753                RuntimeError::new(
754                    "nonzero_exit",
755                    format!("Process exited with code {exit_code}"),
756                    true,
757                ),
758            ))
759        }
760    }
761
762    fn terminate(&mut self) -> Result<(), RuntimeError> {
763        if let Some(ref mut process) = self.process {
764            process.kill().map_err(|e| {
765                RuntimeError::new("kill_failed", format!("Failed to kill process: {e}"), false)
766            })?;
767        }
768
769        self.process = None;
770        self.worktree = None;
771        self.task_id = None;
772        Ok(())
773    }
774
775    fn config(&self) -> &AdapterConfig {
776        &self.config.base
777    }
778}
779
780#[cfg(test)]
781mod tests {
782    use super::*;
783    use std::os::unix::fs::PermissionsExt;
784
785    #[test]
786    fn opencode_config_creation() {
787        let config = OpenCodeConfig::new(PathBuf::from("/usr/bin/opencode"))
788            .with_model("gpt-4")
789            .with_verbose(true)
790            .with_timeout(Duration::from_secs(120));
791
792        assert_eq!(config.model, Some("gpt-4".to_string()));
793        assert!(config.verbose);
794        assert_eq!(config.base.timeout, Duration::from_secs(120));
795    }
796
797    #[test]
798    fn opencode_config_default() {
799        let config = OpenCodeConfig::default();
800        assert_eq!(config.base.name, "opencode");
801        assert!(config.model.is_none());
802        assert!(!config.verbose);
803    }
804
805    #[test]
806    fn adapter_creation() {
807        let adapter = OpenCodeAdapter::with_defaults();
808        assert_eq!(adapter.name(), "opencode");
809    }
810
811    #[test]
812    fn input_formatting() {
813        let adapter = OpenCodeAdapter::with_defaults();
814
815        let input = ExecutionInput {
816            task_description: "Write a function".to_string(),
817            success_criteria: "Function works".to_string(),
818            context: Some("This is for testing".to_string()),
819            prior_attempts: vec![],
820            verifier_feedback: None,
821        };
822
823        let formatted = adapter.format_input(&input);
824        assert!(formatted.contains("Write a function"));
825        assert!(formatted.contains("Function works"));
826        assert!(formatted.contains("This is for testing"));
827    }
828
829    #[test]
830    fn input_formatting_with_retries() {
831        let adapter = OpenCodeAdapter::with_defaults();
832
833        let input = ExecutionInput {
834            task_description: "Fix the bug".to_string(),
835            success_criteria: "Tests pass".to_string(),
836            context: None,
837            prior_attempts: vec![super::super::runtime::AttemptSummary {
838                attempt_number: 1,
839                summary: "Tried approach A".to_string(),
840                failure_reason: Some("Tests failed".to_string()),
841            }],
842            verifier_feedback: Some("Check edge cases".to_string()),
843        };
844
845        let formatted = adapter.format_input(&input);
846        assert!(formatted.contains("Attempt 1"));
847        assert!(formatted.contains("Tried approach A"));
848        assert!(formatted.contains("Check edge cases"));
849    }
850
851    #[test]
852    fn prepare_requires_existing_worktree() {
853        let mut adapter = OpenCodeAdapter::with_defaults();
854        let task_id = Uuid::new_v4();
855
856        let result = adapter.prepare(task_id, &PathBuf::from("/nonexistent/path"));
857        assert!(result.is_err());
858    }
859
860    #[test]
861    fn prepare_with_valid_worktree() {
862        let mut adapter = OpenCodeAdapter::with_defaults();
863        let task_id = Uuid::new_v4();
864
865        // Use /tmp which should exist
866        let result = adapter.prepare(task_id, &PathBuf::from("/tmp"));
867        assert!(result.is_ok());
868        assert!(adapter.worktree.is_some());
869        assert!(adapter.task_id.is_some());
870    }
871
872    #[test]
873    fn terminate_clears_state() {
874        let mut adapter = OpenCodeAdapter::with_defaults();
875        adapter.worktree = Some(PathBuf::from("/tmp"));
876        adapter.task_id = Some(Uuid::new_v4());
877
878        adapter.terminate().unwrap();
879
880        assert!(adapter.worktree.is_none());
881        assert!(adapter.task_id.is_none());
882    }
883
884    #[test]
885    fn execute_enforces_timeout() {
886        let tmp = tempfile::tempdir().expect("tempdir");
887
888        let mut cfg = OpenCodeConfig::new(PathBuf::from("/usr/bin/env"));
889        cfg.base.args = vec!["sh".to_string(), "-c".to_string(), "sleep 2".to_string()];
890        cfg.base.timeout = Duration::from_millis(50);
891
892        let mut adapter = OpenCodeAdapter::new(cfg);
893        adapter.initialize().unwrap();
894        adapter.prepare(Uuid::new_v4(), tmp.path()).unwrap();
895
896        let input = ExecutionInput {
897            task_description: "Test".to_string(),
898            success_criteria: "Done".to_string(),
899            context: None,
900            prior_attempts: Vec::new(),
901            verifier_feedback: None,
902        };
903
904        let err = adapter.execute(input).unwrap_err();
905        assert_eq!(err.code, "timeout");
906    }
907
908    #[test]
909    fn initialize_falls_back_to_help_when_version_fails() {
910        let tmp = tempfile::tempdir().expect("tempdir");
911        let script_path = tmp.path().join("fake_runtime.sh");
912        std::fs::write(
913            &script_path,
914            "#!/usr/bin/env sh\nif [ \"$1\" = \"--version\" ]; then exit 1; fi\nif [ \"$1\" = \"--help\" ]; then exit 0; fi\nexit 0\n",
915        )
916        .unwrap();
917        let mut perms = std::fs::metadata(&script_path).unwrap().permissions();
918        perms.set_mode(0o755);
919        std::fs::set_permissions(&script_path, perms).unwrap();
920
921        let cfg = OpenCodeConfig::new(script_path);
922        let mut adapter = OpenCodeAdapter::new(cfg);
923        adapter.initialize().unwrap();
924    }
925
926    #[test]
927    fn execute_success_captures_stdout_and_stderr() {
928        let tmp = tempfile::tempdir().expect("tempdir");
929
930        let mut cfg = OpenCodeConfig::new(PathBuf::from("/usr/bin/env"));
931        cfg.base.args = vec![
932            "sh".to_string(),
933            "-c".to_string(),
934            "echo ok_stdout; echo ok_stderr 1>&2".to_string(),
935        ];
936        cfg.base.timeout = Duration::from_secs(1);
937
938        let mut adapter = OpenCodeAdapter::new(cfg);
939        adapter.initialize().unwrap();
940        adapter.prepare(Uuid::new_v4(), tmp.path()).unwrap();
941
942        let input = ExecutionInput {
943            task_description: "Test".to_string(),
944            success_criteria: "Done".to_string(),
945            context: None,
946            prior_attempts: Vec::new(),
947            verifier_feedback: None,
948        };
949
950        let report = adapter.execute(input).unwrap();
951        assert_eq!(report.exit_code, 0);
952        assert!(report.stdout.contains("ok_stdout"));
953        assert!(report.stderr.contains("ok_stderr"));
954    }
955
956    #[test]
957    fn execute_nonzero_exit_returns_failure_report() {
958        let tmp = tempfile::tempdir().expect("tempdir");
959
960        let mut cfg = OpenCodeConfig::new(PathBuf::from("/usr/bin/env"));
961        cfg.base.args = vec![
962            "sh".to_string(),
963            "-c".to_string(),
964            "echo bad; exit 7".to_string(),
965        ];
966        cfg.base.timeout = Duration::from_secs(1);
967
968        let mut adapter = OpenCodeAdapter::new(cfg);
969        adapter.initialize().unwrap();
970        adapter.prepare(Uuid::new_v4(), tmp.path()).unwrap();
971
972        let input = ExecutionInput {
973            task_description: "Test".to_string(),
974            success_criteria: "Done".to_string(),
975            context: None,
976            prior_attempts: Vec::new(),
977            verifier_feedback: None,
978        };
979
980        let report = adapter.execute(input).unwrap();
981        assert_eq!(report.exit_code, 7);
982        assert!(report.errors.iter().any(|e| e.code == "nonzero_exit"));
983    }
984
985    // Note: Full execution tests require the actual opencode binary
986    // and are better suited for integration tests
987}