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 launch_command(
63        &self,
64        prompt: &str,
65        idle: bool,
66        resume: bool,
67        session_id: Option<&str>,
68    ) -> anyhow::Result<String> {
69        let escaped = prompt.replace('\'', "'\\''");
70        let prefix = format!(
71            "{} --dangerously-bypass-approvals-and-sandbox",
72            self.program
73        );
74        if resume {
75            let sid = session_id.context("missing Codex session ID for resume")?;
76            let fallback = if idle {
77                format!("exec {prefix}")
78            } else {
79                format!("exec {prefix} '{escaped}'")
80            };
81            Ok(format!(
82                "{program} resume '{sid}' --dangerously-bypass-approvals-and-sandbox || {fallback}",
83                program = self.program,
84            ))
85        } else if idle {
86            Ok(format!("exec {prefix}"))
87        } else {
88            Ok(format!("{prefix} '{escaped}'"))
89        }
90    }
91
92    fn supports_resume(&self) -> bool {
93        true
94    }
95
96    fn health_check(&self) -> super::BackendHealth {
97        super::check_binary_available(&self.program)
98    }
99}
100
101impl CodexCliAdapter {
102    /// Build the launch command for SDK (JSONL) mode.
103    ///
104    /// In Codex SDK mode, each message spawns a new `codex exec --json`
105    /// subprocess. The initial prompt is the system/role context; actual
106    /// task messages are sent per-turn by the runtime.
107    ///
108    /// `system_prompt`: role context passed as the initial exec prompt.
109    pub fn sdk_launch_command(&self, _system_prompt: Option<&str>) -> String {
110        // In Codex SDK mode, the shim runtime handles spawning per-message.
111        // The launch script just needs to set up the environment (PATH, CWD).
112        // We use a simple sleep loop as a placeholder process — the actual
113        // codex exec calls are made by the runtime_codex module.
114        //
115        // Codex SDK uses spawn-per-message — the runtime handles subprocess
116        // spawning. The launch script just needs a sentinel process that stays
117        // alive so the shim doesn't exit.
118        // Use `sleep 2147483647` (max 32-bit seconds ≈ 68 years) instead of
119        // `sleep infinity` — macOS sleep(1) doesn't support "infinity".
120        "exec sleep 2147483647".to_string()
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    #[test]
129    fn default_program_is_codex() {
130        let adapter = CodexCliAdapter::new(None);
131        let config = adapter.spawn_config("test", Path::new("/tmp"));
132        assert_eq!(config.program, "codex");
133    }
134
135    #[test]
136    fn custom_program_path() {
137        let adapter = CodexCliAdapter::new(Some("/usr/local/bin/codex".to_string()));
138        let config = adapter.spawn_config("test", Path::new("/tmp"));
139        assert_eq!(config.program, "/usr/local/bin/codex");
140    }
141
142    #[test]
143    fn spawn_sets_work_dir() {
144        let adapter = CodexCliAdapter::new(None);
145        let config = adapter.spawn_config("task", Path::new("/my/worktree"));
146        assert_eq!(config.work_dir, "/my/worktree");
147    }
148
149    #[test]
150    fn prompt_patterns_detect_permission() {
151        let adapter = CodexCliAdapter::new(None);
152        let patterns = adapter.prompt_patterns();
153        let d = patterns.detect("Would you like to run the following command?");
154        assert!(d.is_some());
155        assert!(matches!(
156            d.unwrap().kind,
157            crate::prompt::PromptKind::Permission { .. }
158        ));
159    }
160
161    #[test]
162    fn format_input_appends_newline() {
163        let adapter = CodexCliAdapter::new(None);
164        assert_eq!(adapter.format_input("y"), "y\n");
165        assert_eq!(adapter.format_input("yes"), "yes\n");
166    }
167
168    #[test]
169    fn sdk_launch_command_uses_portable_sleep() {
170        // Regression: macOS sleep(1) doesn't support "infinity".
171        // The sentinel must use a numeric argument.
172        let adapter = CodexCliAdapter::new(None);
173        let cmd = adapter.sdk_launch_command(None);
174        assert!(
175            !cmd.contains("infinity"),
176            "sleep infinity is not portable to macOS — use a numeric value"
177        );
178        assert!(cmd.contains("sleep"), "sentinel must use sleep");
179    }
180
181    #[test]
182    fn name_is_codex_cli() {
183        let adapter = CodexCliAdapter::new(None);
184        assert_eq!(adapter.name(), "codex-cli");
185    }
186
187    #[test]
188    fn codex_prefers_agents_md_instruction_order() {
189        let adapter = CodexCliAdapter::new(None);
190        assert_eq!(
191            adapter.instruction_candidates(),
192            &["AGENTS.md", "CLAUDE.md"]
193        );
194    }
195
196    #[test]
197    fn codex_wraps_launch_prompt() {
198        let adapter = CodexCliAdapter::new(None);
199        let wrapped = adapter.wrap_launch_prompt("Launch body");
200        assert!(wrapped.contains("Codex under Batty supervision"));
201        assert!(wrapped.contains("Launch body"));
202    }
203
204    // --- Backend trait method tests ---
205
206    #[test]
207    fn launch_command_active_includes_prompt() {
208        let adapter = CodexCliAdapter::new(None);
209        let cmd = adapter
210            .launch_command("do the thing", false, false, None)
211            .unwrap();
212        assert!(cmd.contains("codex --dangerously-bypass-approvals-and-sandbox"));
213        assert!(cmd.contains("'do the thing'"));
214        // Active (non-resume) should NOT use exec so the shim can detect exit
215        assert!(!cmd.starts_with("exec "));
216    }
217
218    #[test]
219    fn launch_command_idle_omits_prompt() {
220        let adapter = CodexCliAdapter::new(None);
221        let cmd = adapter
222            .launch_command("ignored", true, false, None)
223            .unwrap();
224        assert_eq!(cmd, "exec codex --dangerously-bypass-approvals-and-sandbox");
225    }
226
227    #[test]
228    fn launch_command_resume_uses_session_id() {
229        let adapter = CodexCliAdapter::new(None);
230        let cmd = adapter
231            .launch_command("ignored", false, true, Some("codex-sess-1"))
232            .unwrap();
233        assert!(cmd.contains("codex resume 'codex-sess-1'"));
234        assert!(cmd.contains("--dangerously-bypass-approvals-and-sandbox"));
235        assert!(cmd.contains("|| exec codex --dangerously-bypass-approvals-and-sandbox 'ignored'"));
236    }
237
238    #[test]
239    fn launch_command_resume_idle_falls_back_to_fresh_idle_start() {
240        let adapter = CodexCliAdapter::new(None);
241        let cmd = adapter
242            .launch_command("ignored", true, true, Some("codex-sess-1"))
243            .unwrap();
244        assert!(cmd.contains("codex resume 'codex-sess-1'"));
245        assert!(cmd.contains("|| exec codex --dangerously-bypass-approvals-and-sandbox"));
246        assert!(!cmd.contains("'ignored'"));
247    }
248
249    #[test]
250    fn launch_command_resume_without_session_id_errors() {
251        let adapter = CodexCliAdapter::new(None);
252        let result = adapter.launch_command("ignored", false, true, None);
253        assert!(result.is_err());
254    }
255
256    #[test]
257    fn new_session_id_returns_none() {
258        let adapter = CodexCliAdapter::new(None);
259        assert!(adapter.new_session_id().is_none());
260    }
261
262    #[test]
263    fn supports_resume_is_true() {
264        let adapter = CodexCliAdapter::new(None);
265        assert!(adapter.supports_resume());
266    }
267}