1#![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
14pub struct CodexCliAdapter {
16 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 pub fn sdk_launch_command(&self, _system_prompt: Option<&str>) -> String {
110 "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 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 #[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 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}