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 reset_context_keys(&self) -> Vec<(String, bool)> {
63 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 #[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}