1use 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#[derive(Debug, Clone)]
65pub struct OpenCodeConfig {
66 pub base: AdapterConfig,
68 pub model: Option<String>,
70 pub verbose: bool,
72}
73
74impl OpenCodeConfig {
75 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)), model,
82 verbose: false,
83 }
84 }
85
86 #[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 #[must_use]
95 pub fn with_verbose(mut self, verbose: bool) -> Self {
96 self.verbose = verbose;
97 self
98 }
99
100 #[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
114pub struct OpenCodeAdapter {
116 config: OpenCodeConfig,
117 worktree: Option<PathBuf>,
118 task_id: Option<Uuid>,
119 process: Option<Child>,
120}
121
122impl OpenCodeAdapter {
123 pub fn new(config: OpenCodeConfig) -> Self {
125 Self {
126 config,
127 worktree: None,
128 task_id: None,
129 process: None,
130 }
131 }
132
133 pub fn with_defaults() -> Self {
135 Self::new(OpenCodeConfig::default())
136 }
137
138 #[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 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 let binary = &self.config.base.binary_path;
537
538 let result = status_with_retry(binary, "--version");
540
541 match result {
542 Ok(status) if status.success() => Ok(()),
543 Ok(_) => {
544 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 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 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 for (key, value) in &self.config.base.env {
646 cmd.env(key, value);
647 }
648
649 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 if let Some(ref mut stdin) = child.stdin {
660 if let Err(e) = stdin.write_all(formatted_input.as_bytes()) {
661 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 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 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 }