Skip to main content

batty_cli/agent/
codex.rs

1//! Codex CLI adapter.
2//!
3//! Runs Codex in interactive mode by default, passing the composed task prompt
4//! as the initial user prompt argument.
5#![cfg_attr(not(test), allow(dead_code))]
6
7use std::path::Path;
8
9use anyhow::Context;
10
11use crate::agent::{AgentAdapter, SpawnConfig};
12use crate::prompt::PromptPatterns;
13
14/// Adapter for Codex CLI.
15pub struct CodexCliAdapter {
16    /// Override the codex binary name/path (default: "codex").
17    program: String,
18}
19
20impl CodexCliAdapter {
21    pub fn new(program: Option<String>) -> Self {
22        Self {
23            program: program.unwrap_or_else(|| "codex".to_string()),
24        }
25    }
26}
27
28impl AgentAdapter for CodexCliAdapter {
29    fn name(&self) -> &str {
30        "codex-cli"
31    }
32
33    fn spawn_config(&self, task_description: &str, work_dir: &Path) -> SpawnConfig {
34        SpawnConfig {
35            program: self.program.clone(),
36            args: vec![task_description.to_string()],
37            work_dir: work_dir.to_string_lossy().to_string(),
38            env: vec![],
39        }
40    }
41
42    fn prompt_patterns(&self) -> PromptPatterns {
43        PromptPatterns::codex_cli()
44    }
45
46    fn instruction_candidates(&self) -> &'static [&'static str] {
47        &["AGENTS.md", "CLAUDE.md"]
48    }
49
50    fn wrap_launch_prompt(&self, prompt: &str) -> String {
51        format!(
52            "You are running as Codex under Batty supervision.\n\
53             Treat the launch context below as authoritative session context.\n\n\
54             {prompt}"
55        )
56    }
57
58    fn format_input(&self, response: &str) -> String {
59        format!("{response}\n")
60    }
61
62    fn reset_context_keys(&self) -> Vec<(String, bool)> {
63        // Codex: send Ctrl-C to kill, then relaunch
64        vec![("C-c".to_string(), false)]
65    }
66
67    fn launch_command(
68        &self,
69        prompt: &str,
70        idle: bool,
71        resume: bool,
72        session_id: Option<&str>,
73    ) -> anyhow::Result<String> {
74        let escaped = prompt.replace('\'', "'\\''");
75        if resume {
76            let sid = session_id.context("missing Codex session ID for resume")?;
77            Ok(format!(
78                "exec codex resume '{sid}' --dangerously-bypass-approvals-and-sandbox"
79            ))
80        } else {
81            let prefix = "exec codex --dangerously-bypass-approvals-and-sandbox";
82            if idle {
83                Ok(prefix.to_string())
84            } else {
85                Ok(format!("{prefix} '{escaped}'"))
86            }
87        }
88    }
89
90    fn supports_resume(&self) -> bool {
91        true
92    }
93
94    fn health_check(&self) -> super::BackendHealth {
95        super::check_binary_available(&self.program)
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    #[test]
104    fn default_program_is_codex() {
105        let adapter = CodexCliAdapter::new(None);
106        let config = adapter.spawn_config("test", Path::new("/tmp"));
107        assert_eq!(config.program, "codex");
108    }
109
110    #[test]
111    fn custom_program_path() {
112        let adapter = CodexCliAdapter::new(Some("/usr/local/bin/codex".to_string()));
113        let config = adapter.spawn_config("test", Path::new("/tmp"));
114        assert_eq!(config.program, "/usr/local/bin/codex");
115    }
116
117    #[test]
118    fn spawn_sets_work_dir() {
119        let adapter = CodexCliAdapter::new(None);
120        let config = adapter.spawn_config("task", Path::new("/my/worktree"));
121        assert_eq!(config.work_dir, "/my/worktree");
122    }
123
124    #[test]
125    fn prompt_patterns_detect_permission() {
126        let adapter = CodexCliAdapter::new(None);
127        let patterns = adapter.prompt_patterns();
128        let d = patterns.detect("Would you like to run the following command?");
129        assert!(d.is_some());
130        assert!(matches!(
131            d.unwrap().kind,
132            crate::prompt::PromptKind::Permission { .. }
133        ));
134    }
135
136    #[test]
137    fn format_input_appends_newline() {
138        let adapter = CodexCliAdapter::new(None);
139        assert_eq!(adapter.format_input("y"), "y\n");
140        assert_eq!(adapter.format_input("yes"), "yes\n");
141    }
142
143    #[test]
144    fn name_is_codex_cli() {
145        let adapter = CodexCliAdapter::new(None);
146        assert_eq!(adapter.name(), "codex-cli");
147    }
148
149    #[test]
150    fn codex_prefers_agents_md_instruction_order() {
151        let adapter = CodexCliAdapter::new(None);
152        assert_eq!(
153            adapter.instruction_candidates(),
154            &["AGENTS.md", "CLAUDE.md"]
155        );
156    }
157
158    #[test]
159    fn codex_wraps_launch_prompt() {
160        let adapter = CodexCliAdapter::new(None);
161        let wrapped = adapter.wrap_launch_prompt("Launch body");
162        assert!(wrapped.contains("Codex under Batty supervision"));
163        assert!(wrapped.contains("Launch body"));
164    }
165
166    // --- Backend trait method tests ---
167
168    #[test]
169    fn launch_command_active_includes_prompt() {
170        let adapter = CodexCliAdapter::new(None);
171        let cmd = adapter
172            .launch_command("do the thing", false, false, None)
173            .unwrap();
174        assert!(cmd.contains("exec codex --dangerously-bypass-approvals-and-sandbox"));
175        assert!(cmd.contains("'do the thing'"));
176    }
177
178    #[test]
179    fn launch_command_idle_omits_prompt() {
180        let adapter = CodexCliAdapter::new(None);
181        let cmd = adapter
182            .launch_command("ignored", true, false, None)
183            .unwrap();
184        assert_eq!(cmd, "exec codex --dangerously-bypass-approvals-and-sandbox");
185    }
186
187    #[test]
188    fn launch_command_resume_uses_session_id() {
189        let adapter = CodexCliAdapter::new(None);
190        let cmd = adapter
191            .launch_command("ignored", false, true, Some("codex-sess-1"))
192            .unwrap();
193        assert!(cmd.contains("exec codex resume 'codex-sess-1'"));
194        assert!(cmd.contains("--dangerously-bypass-approvals-and-sandbox"));
195    }
196
197    #[test]
198    fn launch_command_resume_without_session_id_errors() {
199        let adapter = CodexCliAdapter::new(None);
200        let result = adapter.launch_command("ignored", false, true, None);
201        assert!(result.is_err());
202    }
203
204    #[test]
205    fn new_session_id_returns_none() {
206        let adapter = CodexCliAdapter::new(None);
207        assert!(adapter.new_session_id().is_none());
208    }
209
210    #[test]
211    fn supports_resume_is_true() {
212        let adapter = CodexCliAdapter::new(None);
213        assert!(adapter.supports_resume());
214    }
215}