use super::Agent;
use crate::context::tsk_env::TskEnv;
use async_trait::async_trait;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::{Arc, OnceLock};
pub mod codex_log_processor;
pub use codex_log_processor::CodexLogProcessor;
pub struct CodexAgent {
tsk_env: Option<Arc<TskEnv>>,
version_cache: OnceLock<String>,
}
impl CodexAgent {
pub fn with_tsk_env(tsk_env: Arc<TskEnv>) -> Self {
Self {
tsk_env: Some(tsk_env),
version_cache: OnceLock::new(),
}
}
fn get_codex_config_dir(&self) -> PathBuf {
self.tsk_env
.as_ref()
.expect("TskEnv should always be present")
.codex_config_dir()
.to_path_buf()
}
}
impl Default for CodexAgent {
fn default() -> Self {
Self::with_tsk_env(Arc::new(TskEnv::new().expect("Failed to create TskEnv")))
}
}
#[async_trait]
impl Agent for CodexAgent {
fn build_command(&self, instruction_path: &str, is_interactive: bool) -> Vec<String> {
let filename = Path::new(instruction_path)
.file_name()
.and_then(|f| f.to_str())
.unwrap_or("instructions.md");
if is_interactive {
let normal_command = format!(
"cat /instructions/{} | codex e --dangerously-bypass-approvals-and-sandbox --json",
filename
);
vec![
"sh".to_string(),
"-c".to_string(),
format!(
r#"sleep 0.5; echo '=== Task Instructions ==='; cat /instructions/{}; echo; echo '=== Normal Command ==='; echo '{}'; echo; echo '=== Starting Interactive Codex Session ==='; echo 'You can now interact with Codex directly.'; echo 'Type "exit" or Ctrl+D to end the session.'; echo; exec /bin/bash"#,
filename, normal_command
),
]
} else {
vec![
"sh".to_string(),
"-c".to_string(),
format!(
"cat /instructions/{} | codex e --dangerously-bypass-approvals-and-sandbox --json 2>&1 | tee /output/codex-log.txt",
filename
),
]
}
}
fn volumes(&self) -> Vec<(String, String, String)> {
let codex_config_dir = self.get_codex_config_dir();
vec![
(
codex_config_dir.to_string_lossy().to_string(),
"/home/agent/.codex".to_string(),
"".to_string(),
),
]
}
fn environment(&self) -> Vec<(String, String)> {
vec![
("HOME".to_string(), "/home/agent".to_string()),
("USER".to_string(), "agent".to_string()),
]
}
fn create_log_processor(
&self,
_task: Option<&crate::task::Task>,
) -> Box<dyn super::LogProcessor> {
Box::new(CodexLogProcessor::new())
}
fn name(&self) -> &str {
"codex"
}
async fn validate(&self) -> Result<(), String> {
if cfg!(test) {
return Ok(());
}
let codex_config_dir = self.get_codex_config_dir();
if !codex_config_dir.exists() {
return Err(format!(
"Codex configuration directory not found at {}. Please run 'codex login' first.",
codex_config_dir.display()
));
}
Ok(())
}
fn version(&self) -> String {
self.version_cache
.get_or_init(|| {
if cfg!(test) {
return "test-codex-1.0.0".to_string();
}
match Command::new("codex").arg("--version").output() {
Ok(output) if output.status.success() => {
let version_str = String::from_utf8_lossy(&output.stdout);
let cleaned = version_str.trim().to_string();
if cleaned.is_empty() {
"unknown".to_string()
} else {
cleaned.replace(' ', "-").replace('\n', "")
}
}
Ok(_) => {
eprintln!("Warning: Failed to get Codex version (command failed)");
"unknown".to_string()
}
Err(e) => {
eprintln!("Warning: Failed to run codex --version: {}", e);
"unknown".to_string()
}
}
})
.clone()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::context::AppContext;
#[test]
fn test_codex_agent_properties() {
let app_context = AppContext::builder().build();
let tsk_env = app_context.tsk_env();
let agent = CodexAgent::with_tsk_env(tsk_env);
assert_eq!(agent.name(), "codex");
let volumes = agent.volumes();
assert_eq!(volumes.len(), 1);
let volume_paths: Vec<&str> = volumes
.iter()
.map(|(_, container_path, _)| container_path.as_str())
.collect();
assert!(volume_paths.contains(&"/home/agent/.codex"));
let env = agent.environment();
assert_eq!(env.len(), 2);
let env_map: std::collections::HashMap<_, _> = env.into_iter().collect();
assert_eq!(env_map.get("HOME"), Some(&"/home/agent".to_string()));
assert_eq!(env_map.get("USER"), Some(&"agent".to_string()));
}
#[test]
fn test_codex_agent_build_command() {
let app_context = AppContext::builder().build();
let tsk_env = app_context.tsk_env();
let agent = CodexAgent::with_tsk_env(tsk_env);
let command = agent.build_command("/tmp/instructions.md", false);
assert_eq!(command.len(), 3);
assert_eq!(command[0], "sh");
assert_eq!(command[1], "-c");
assert!(command[2].contains("cat /instructions/instructions.md"));
assert!(command[2].contains("codex e --dangerously-bypass-approvals-and-sandbox"));
assert!(command[2].contains("tee /output/codex-log.txt"));
let command = agent.build_command("/path/to/task/instructions.txt", false);
assert!(command[2].contains("cat /instructions/instructions.txt"));
assert!(command[2].contains("tee /output/codex-log.txt"));
let command = agent.build_command("/tmp/instructions.md", true);
assert_eq!(command.len(), 3);
assert_eq!(command[0], "sh");
assert_eq!(command[1], "-c");
assert!(command[2].starts_with("sleep 0.5;"));
assert!(command[2].contains("=== Task Instructions ==="));
assert!(command[2].contains("cat /instructions/instructions.md"));
assert!(command[2].contains("=== Normal Command ==="));
assert!(command[2].contains("codex e --dangerously-bypass-approvals-and-sandbox"));
assert!(command[2].contains("=== Starting Interactive Codex Session ==="));
assert!(command[2].contains("exec /bin/bash"));
}
#[tokio::test]
async fn test_codex_agent_validate_without_config() {
let app_context = AppContext::builder().build();
let tsk_env = app_context.tsk_env();
let agent = CodexAgent::with_tsk_env(tsk_env);
let result = agent.validate().await;
assert!(result.is_ok());
}
#[test]
fn test_codex_agent_create_log_processor() {
let app_context = AppContext::builder().build();
let tsk_env = app_context.tsk_env();
let agent = CodexAgent::with_tsk_env(tsk_env);
let _log_processor = agent.create_log_processor(None);
}
#[test]
fn test_codex_agent_version() {
let app_context = AppContext::builder().build();
let tsk_env = app_context.tsk_env();
let agent = CodexAgent::with_tsk_env(tsk_env);
let version = agent.version();
assert_eq!(version, "test-codex-1.0.0");
let version2 = agent.version();
assert_eq!(version, version2);
}
}