devboy_core/agents/
codex.rs1use std::collections::HashSet;
10use std::io::{BufRead, BufReader};
11use std::path::Path;
12
13use super::{AgentDetector, AgentSnapshot, InstallStatus};
14
15const ID: &str = "codex";
16const DISPLAY_NAME: &str = "Codex CLI";
17const MAX_LINES: usize = 200_000;
18
19pub struct CodexDetector;
20
21impl AgentDetector for CodexDetector {
22 fn id(&self) -> &'static str {
23 ID
24 }
25 fn display_name(&self) -> &'static str {
26 DISPLAY_NAME
27 }
28
29 fn detect(&self, home: &Path) -> AgentSnapshot {
30 let codex_dir = home.join(".codex");
31 let history = codex_dir.join("history.jsonl");
32 let paths_checked = vec![codex_dir.clone(), history.clone()];
33
34 let dir_present = codex_dir.is_dir();
35 let binary_present = which::which("codex").is_ok();
36
37 let status = if dir_present || binary_present {
38 InstallStatus::Yes
39 } else {
40 InstallStatus::No
41 };
42 if status == InstallStatus::No {
43 return empty(paths_checked);
44 }
45
46 let (sessions, last_used) = parse_history(&history);
47
48 AgentSnapshot {
49 id: ID,
50 display_name: DISPLAY_NAME,
51 status,
52 sessions: (sessions > 0).then_some(sessions),
53 last_used,
54 score: 0.0,
55 paths_checked,
56 }
57 }
58}
59
60fn parse_history(history: &Path) -> (u64, Option<chrono::DateTime<chrono::Utc>>) {
61 let Ok(file) = std::fs::File::open(history) else {
62 return (0, None);
63 };
64 let reader = BufReader::new(file);
65 let mut sessions: HashSet<String> = HashSet::new();
66 let mut last_ts: i64 = 0;
67 for line in reader.lines().map_while(Result::ok).take(MAX_LINES) {
68 let Ok(value) = serde_json::from_str::<serde_json::Value>(&line) else {
69 continue;
70 };
71 if let Some(sid) = value.get("session_id").and_then(|v| v.as_str()) {
72 sessions.insert(sid.to_string());
73 }
74 if let Some(ts) = value.get("ts").and_then(|v| v.as_i64())
75 && ts > last_ts
76 {
77 last_ts = ts;
78 }
79 }
80 let last_used = if last_ts > 0 {
84 chrono::DateTime::<chrono::Utc>::from_timestamp(last_ts, 0)
85 } else {
86 None
87 };
88 (sessions.len() as u64, last_used)
89}
90
91fn empty(paths_checked: Vec<std::path::PathBuf>) -> AgentSnapshot {
92 AgentSnapshot {
93 id: ID,
94 display_name: DISPLAY_NAME,
95 status: InstallStatus::No,
96 sessions: None,
97 last_used: None,
98 score: 0.0,
99 paths_checked,
100 }
101}
102
103#[cfg(test)]
104mod tests {
105 use super::*;
106 use std::fs;
107 use tempfile::tempdir;
108
109 #[test]
110 fn counts_unique_session_ids() {
111 let home = tempdir().unwrap();
112 let codex = home.path().join(".codex");
113 fs::create_dir_all(&codex).unwrap();
114 let history = codex.join("history.jsonl");
115 let body = "\
116{\"session_id\":\"AAA\",\"ts\":1700000000,\"text\":\"hi\"}
117{\"session_id\":\"AAA\",\"ts\":1700000010,\"text\":\"again\"}
118{\"session_id\":\"BBB\",\"ts\":1700000020,\"text\":\"third\"}
119{\"session_id\":\"CCC\",\"ts\":1700000030,\"text\":\"fourth\"}
120";
121 fs::write(&history, body).unwrap();
122 let snap = CodexDetector.detect(home.path());
123 assert_eq!(snap.status, InstallStatus::Yes);
124 assert_eq!(snap.sessions, Some(3));
125 assert_eq!(snap.last_used.unwrap().timestamp(), 1700000030);
126 }
127
128 #[test]
129 fn no_codex_dir_means_not_installed() {
130 let home = tempdir().unwrap();
131 let snap = CodexDetector.detect(home.path());
132 if which::which("codex").is_err() {
133 assert_eq!(snap.status, InstallStatus::No);
134 assert!(snap.sessions.is_none());
135 assert!(snap.last_used.is_none());
136 }
137 }
138
139 #[test]
140 fn empty_history_file_means_zero_sessions() {
141 let home = tempdir().unwrap();
142 let codex = home.path().join(".codex");
143 fs::create_dir_all(&codex).unwrap();
144 fs::write(codex.join("history.jsonl"), b"").unwrap();
145 let snap = CodexDetector.detect(home.path());
146 assert_eq!(snap.status, InstallStatus::Yes);
147 assert!(snap.sessions.is_none());
148 assert!(snap.last_used.is_none());
149 }
150
151 #[test]
152 fn malformed_json_lines_are_skipped() {
153 let home = tempdir().unwrap();
154 let codex = home.path().join(".codex");
155 fs::create_dir_all(&codex).unwrap();
156 let body = "\
157not json at all
158{\"session_id\":\"X\",\"ts\":1700000000,\"text\":\"valid\"}
159{this is also broken}
160";
161 fs::write(codex.join("history.jsonl"), body).unwrap();
162 let snap = CodexDetector.detect(home.path());
163 assert_eq!(snap.sessions, Some(1));
164 assert_eq!(snap.last_used.unwrap().timestamp(), 1700000000);
165 }
166
167 #[test]
168 fn missing_history_file_yields_no_session_data_but_install_yes() {
169 let home = tempdir().unwrap();
170 let codex = home.path().join(".codex");
171 fs::create_dir_all(&codex).unwrap();
172 let snap = CodexDetector.detect(home.path());
174 assert_eq!(snap.status, InstallStatus::Yes);
175 assert!(snap.sessions.is_none());
176 assert!(snap.last_used.is_none());
177 }
178}