Skip to main content

harness/agents/
codex.rs

1use std::path::PathBuf;
2
3use async_trait::async_trait;
4
5use crate::config::{PermissionMode, TaskConfig};
6use crate::error::{Error, Result};
7use crate::event::*;
8use crate::process::{spawn_and_stream, StreamHandle};
9use crate::runner::AgentRunner;
10
11/// Adapter for OpenAI Codex CLI (`codex` binary).
12///
13/// Headless invocation:
14///   codex exec --json "<prompt>"
15///
16/// Stream format: NDJSON with event types:
17///   - { type: "thread.started", thread_id }
18///   - { type: "turn.started" }
19///   - { type: "item.started", item: { id, type: "command_execution", command, status: "in_progress", ... } }
20///   - { type: "item.completed", item: { id, type: "agent_message"|"command_execution"|"reasoning", ... } }
21///   - { type: "turn.completed", usage: { input_tokens, cached_input_tokens, output_tokens } }
22///   - { type: "turn.failed", error }
23///   - { type: "error", message }
24pub struct CodexRunner;
25
26#[async_trait]
27impl AgentRunner for CodexRunner {
28    fn name(&self) -> &str {
29        "codex"
30    }
31
32    fn is_available(&self) -> bool {
33        crate::runner::is_any_binary_available(crate::config::AgentKind::Codex)
34    }
35
36    fn binary_path(&self, config: &TaskConfig) -> Result<PathBuf> {
37        crate::runner::resolve_binary(crate::config::AgentKind::Codex, config)
38    }
39
40    fn build_args(&self, config: &TaskConfig) -> Vec<String> {
41        let mut args = vec!["exec".to_string(), "--json".to_string()];
42
43        if let Some(ref model) = config.model {
44            args.push("--model".to_string());
45            args.push(model.clone());
46        }
47
48        // Map permission mode to Codex's --sandbox + approval flags.
49        match config.permission_mode {
50            PermissionMode::FullAccess => {
51                args.push("--sandbox".to_string());
52                args.push("danger-full-access".to_string());
53                args.push("--dangerously-bypass-approvals-and-sandbox".to_string());
54            }
55            PermissionMode::ReadOnly => {
56                args.push("--sandbox".to_string());
57                args.push("read-only".to_string());
58            }
59        }
60
61        args.extend(config.extra_args.iter().cloned());
62
63        // Prompt must come last.
64        args.push(config.prompt.clone());
65        args
66    }
67
68    fn build_env(&self, _config: &TaskConfig) -> Vec<(String, String)> {
69        // Codex reads CODEX_API_KEY or OPENAI_API_KEY from environment.
70        vec![]
71    }
72
73    async fn run(
74        &self,
75        config: &TaskConfig,
76        cancel_token: Option<tokio_util::sync::CancellationToken>,
77    ) -> Result<StreamHandle> {
78        spawn_and_stream(self, config, parse_codex_line, cancel_token).await
79    }
80
81    fn capabilities(&self) -> crate::runner::AgentCapabilities {
82        crate::runner::AgentCapabilities {
83            supports_system_prompt: false,
84            supports_budget: false,
85            supports_model: true,
86            supports_max_turns: false,
87            supports_append_system_prompt: false,
88        }
89    }
90}
91
92fn parse_codex_line(line: &str) -> Vec<Result<Event>> {
93    let value: serde_json::Value = match serde_json::from_str(line) {
94        Ok(v) => v,
95        Err(e) => return vec![Err(Error::ParseError(format!("invalid JSON: {e}: {line}")))],
96    };
97
98    let event_type = match value.get("type").and_then(|v| v.as_str()) {
99        Some(t) => t,
100        None => return vec![],
101    };
102
103    match event_type {
104        "thread.started" => {
105            let thread_id = value
106                .get("thread_id")
107                .and_then(|v| v.as_str())
108                .unwrap_or("unknown")
109                .to_string();
110            vec![Ok(Event::SessionStart(SessionStartEvent {
111                session_id: thread_id,
112                agent: "codex".to_string(),
113                model: value
114                    .get("model")
115                    .and_then(|v| v.as_str())
116                    .map(|s| s.to_string()),
117                cwd: None,
118                timestamp_ms: 0,
119            }))]
120        }
121
122        "item.started" => {
123            let item = match value.get("item") {
124                Some(i) => i,
125                None => return vec![],
126            };
127            let item_type = match item.get("type").and_then(|v| v.as_str()) {
128                Some(t) => t,
129                None => return vec![],
130            };
131
132            match item_type {
133                "command_execution" => {
134                    let call_id = item
135                        .get("id")
136                        .and_then(|v| v.as_str())
137                        .unwrap_or("unknown")
138                        .to_string();
139                    let command = item
140                        .get("command")
141                        .and_then(|v| v.as_str())
142                        .unwrap_or("")
143                        .to_string();
144                    vec![Ok(Event::ToolStart(ToolStartEvent {
145                        call_id,
146                        tool_name: "shell".to_string(),
147                        input: Some(serde_json::json!({ "command": command })),
148                        timestamp_ms: 0,
149                    }))]
150                }
151                _ => vec![],
152            }
153        }
154
155        "item.completed" => {
156            let item = match value.get("item") {
157                Some(i) => i,
158                None => return vec![],
159            };
160            let item_type = match item.get("type").and_then(|v| v.as_str()) {
161                Some(t) => t,
162                None => return vec![],
163            };
164
165            match item_type {
166                "agent_message" | "message" => {
167                    // { item: { type: "agent_message", text: "..." } }
168                    // Also handle legacy { item: { type: "message", content: [...] } }
169                    let text = item
170                        .get("text")
171                        .and_then(|v| v.as_str())
172                        .map(String::from)
173                        .or_else(|| {
174                            item.get("content")
175                                .and_then(|v| v.as_array())
176                                .map(|arr| {
177                                    arr.iter()
178                                        .filter_map(|c| c.get("text").and_then(|v| v.as_str()))
179                                        .collect::<Vec<_>>()
180                                        .join("")
181                                })
182                        })
183                        .or_else(|| {
184                            item.get("content")
185                                .and_then(|v| v.as_str())
186                                .map(String::from)
187                        })
188                        .unwrap_or_default();
189
190                    if text.is_empty() {
191                        return vec![];
192                    }
193
194                    let role_str = item
195                        .get("role")
196                        .and_then(|v| v.as_str())
197                        .unwrap_or("assistant");
198                    let role = match role_str {
199                        "user" => Role::User,
200                        "system" => Role::System,
201                        _ => Role::Assistant,
202                    };
203
204                    vec![Ok(Event::Message(MessageEvent {
205                        role,
206                        text,
207                        usage: None,
208                        timestamp_ms: 0,
209                    }))]
210                }
211
212                "command_execution" | "command" | "shell" => {
213                    let call_id = item
214                        .get("id")
215                        .and_then(|v| v.as_str())
216                        .unwrap_or("unknown")
217                        .to_string();
218                    let command = item
219                        .get("command")
220                        .and_then(|v| v.as_str())
221                        .unwrap_or("")
222                        .to_string();
223                    let exit_code = item.get("exit_code").and_then(|v| v.as_i64());
224                    let success = exit_code.map(|c| c == 0).unwrap_or(true);
225                    let output = item
226                        .get("aggregated_output")
227                        .or_else(|| item.get("output"))
228                        .and_then(|v| v.as_str())
229                        .map(|s| s.to_string());
230
231                    // If we already emitted ToolStart from item.started, just emit ToolEnd.
232                    // If there was no item.started (shouldn't happen, but be safe), emit both.
233                    vec![Ok(Event::ToolEnd(ToolEndEvent {
234                        call_id: call_id.clone(),
235                        tool_name: "shell".to_string(),
236                        success,
237                        output: output.or_else(|| Some(serde_json::json!({ "command": command }).to_string())),
238                        usage: None,
239                        timestamp_ms: 0,
240                    }))]
241                }
242
243                "file_change" => {
244                    let call_id = item
245                        .get("id")
246                        .and_then(|v| v.as_str())
247                        .unwrap_or("unknown")
248                        .to_string();
249                    let path = item
250                        .get("path")
251                        .and_then(|v| v.as_str())
252                        .unwrap_or("")
253                        .to_string();
254
255                    // Emit both ToolStart and ToolEnd for file_change items.
256                    vec![
257                        Ok(Event::ToolStart(ToolStartEvent {
258                            call_id: call_id.clone(),
259                            tool_name: "file_change".to_string(),
260                            input: Some(serde_json::json!({ "path": path })),
261                            timestamp_ms: 0,
262                        })),
263                        Ok(Event::ToolEnd(ToolEndEvent {
264                            call_id,
265                            tool_name: "file_change".to_string(),
266                            success: true,
267                            output: None,
268                            usage: None,
269                            timestamp_ms: 0,
270                        })),
271                    ]
272                }
273
274                // "reasoning" items — skip (internal thinking).
275                _ => vec![],
276            }
277        }
278
279        // Legacy: item.created — same as item.completed.
280        "item.created" => {
281            // Delegate to item.completed logic since structure is the same.
282            let mut patched = value.clone();
283            patched["type"] = serde_json::json!("item.completed");
284            parse_codex_line(&patched.to_string())
285        }
286
287        "turn.completed" => {
288            // { type: "turn.completed", usage: { input_tokens, cached_input_tokens, output_tokens } }
289            let usage = value.get("usage").map(|u| UsageData {
290                input_tokens: u.get("input_tokens").and_then(|v| v.as_u64()),
291                output_tokens: u.get("output_tokens").and_then(|v| v.as_u64()),
292                cache_read_tokens: u.get("cached_input_tokens").and_then(|v| v.as_u64()),
293                cache_creation_tokens: None,
294                cost_usd: None,
295            });
296
297            let mut events = Vec::new();
298
299            if let Some(ref u) = usage {
300                events.push(Ok(Event::UsageDelta(UsageDeltaEvent {
301                    usage: u.clone(),
302                    timestamp_ms: 0,
303                })));
304            }
305
306            // turn.completed is typically the last event from Codex (no thread.completed),
307            // so emit a Result event.
308            events.push(Ok(Event::Result(ResultEvent {
309                success: true,
310                text: String::new(),
311                session_id: String::new(),
312                duration_ms: None,
313                total_cost_usd: None,
314                usage,
315                timestamp_ms: 0,
316            })));
317
318            events
319        }
320
321        "turn.failed" => {
322            let error_msg = value
323                .get("error")
324                .and_then(|v| v.as_str())
325                .or_else(|| value.get("message").and_then(|v| v.as_str()))
326                .unwrap_or("turn failed")
327                .to_string();
328            vec![Ok(Event::Error(ErrorEvent {
329                message: error_msg,
330                code: Some("turn_failed".into()),
331                timestamp_ms: 0,
332            }))]
333        }
334
335        "thread.completed" => {
336            let text = value
337                .get("summary")
338                .or_else(|| value.get("result"))
339                .and_then(|v| v.as_str())
340                .unwrap_or("")
341                .to_string();
342            let thread_id = value
343                .get("thread_id")
344                .and_then(|v| v.as_str())
345                .unwrap_or("")
346                .to_string();
347
348            vec![Ok(Event::Result(ResultEvent {
349                success: true,
350                text,
351                session_id: thread_id,
352                duration_ms: value.get("duration_ms").and_then(|v| v.as_u64()),
353                total_cost_usd: None,
354                usage: None,
355                timestamp_ms: 0,
356            }))]
357        }
358
359        "error" => {
360            let msg = value
361                .get("message")
362                .and_then(|v| v.as_str())
363                .unwrap_or("unknown error")
364                .to_string();
365            let code = value
366                .get("code")
367                .and_then(|v| v.as_str())
368                .map(|s| s.to_string());
369            vec![Ok(Event::Error(ErrorEvent {
370                message: msg,
371                code,
372                timestamp_ms: 0,
373            }))]
374        }
375
376        // turn.started — skip (no useful data).
377        _ => vec![],
378    }
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384
385    // ── Current format tests ────────────────────────────────────
386
387    #[test]
388    fn parse_thread_started() {
389        let line = r#"{"type":"thread.started","thread_id":"th-123"}"#;
390        let events = parse_codex_line(line);
391        assert_eq!(events.len(), 1);
392        match &events[0] {
393            Ok(Event::SessionStart(s)) => {
394                assert_eq!(s.session_id, "th-123");
395                assert_eq!(s.agent, "codex");
396            }
397            other => panic!("expected SessionStart, got {other:?}"),
398        }
399    }
400
401    #[test]
402    fn parse_agent_message() {
403        let line = r#"{"type":"item.completed","item":{"id":"item_2","type":"agent_message","text":"Hello!"}}"#;
404        let events = parse_codex_line(line);
405        assert_eq!(events.len(), 1);
406        match &events[0] {
407            Ok(Event::Message(m)) => {
408                assert_eq!(m.role, Role::Assistant);
409                assert_eq!(m.text, "Hello!");
410            }
411            other => panic!("expected Message, got {other:?}"),
412        }
413    }
414
415    #[test]
416    fn parse_command_started() {
417        let line = r#"{"type":"item.started","item":{"id":"item_1","type":"command_execution","command":"/bin/bash -lc 'ls'","aggregated_output":"","exit_code":null,"status":"in_progress"}}"#;
418        let events = parse_codex_line(line);
419        assert_eq!(events.len(), 1);
420        assert!(matches!(&events[0], Ok(Event::ToolStart(t)) if t.tool_name == "shell" && t.call_id == "item_1"));
421    }
422
423    #[test]
424    fn parse_command_completed() {
425        let line = r#"{"type":"item.completed","item":{"id":"item_1","type":"command_execution","command":"ls","aggregated_output":"file.txt\n","exit_code":0,"status":"completed"}}"#;
426        let events = parse_codex_line(line);
427        assert_eq!(events.len(), 1);
428        match &events[0] {
429            Ok(Event::ToolEnd(t)) => {
430                assert_eq!(t.call_id, "item_1");
431                assert_eq!(t.tool_name, "shell");
432                assert!(t.success);
433                assert_eq!(t.output, Some("file.txt\n".into()));
434            }
435            other => panic!("expected ToolEnd, got {other:?}"),
436        }
437    }
438
439    #[test]
440    fn parse_command_failed() {
441        let line = r#"{"type":"item.completed","item":{"id":"item_1","type":"command_execution","command":"false","aggregated_output":"","exit_code":1,"status":"completed"}}"#;
442        let events = parse_codex_line(line);
443        assert_eq!(events.len(), 1);
444        match &events[0] {
445            Ok(Event::ToolEnd(t)) => {
446                assert!(!t.success);
447            }
448            other => panic!("expected ToolEnd, got {other:?}"),
449        }
450    }
451
452    #[test]
453    fn parse_turn_completed() {
454        let line = r#"{"type":"turn.completed","usage":{"input_tokens":8587,"cached_input_tokens":7808,"output_tokens":24}}"#;
455        let events = parse_codex_line(line);
456        assert!(events.len() >= 2);
457        assert!(events.iter().any(|e| matches!(e, Ok(Event::UsageDelta(u)) if u.usage.input_tokens == Some(8587))));
458        assert!(events.iter().any(|e| matches!(e, Ok(Event::Result(r)) if r.success)));
459    }
460
461    #[test]
462    fn parse_file_change() {
463        let line = r#"{"type":"item.completed","item":{"type":"file_change","id":"fc-1","path":"src/main.rs"}}"#;
464        let events = parse_codex_line(line);
465        assert_eq!(events.len(), 2, "expected ToolStart + ToolEnd");
466        assert!(matches!(&events[0], Ok(Event::ToolStart(t)) if t.tool_name == "file_change"));
467        assert!(matches!(&events[1], Ok(Event::ToolEnd(t)) if t.tool_name == "file_change" && t.success));
468    }
469
470    #[test]
471    fn parse_turn_failed() {
472        let line = r#"{"type":"turn.failed","error":"rate limit exceeded"}"#;
473        let events = parse_codex_line(line);
474        assert_eq!(events.len(), 1);
475        match &events[0] {
476            Ok(Event::Error(e)) => {
477                assert_eq!(e.message, "rate limit exceeded");
478            }
479            other => panic!("expected Error, got {other:?}"),
480        }
481    }
482
483    #[test]
484    fn parse_error_event() {
485        let line = r#"{"type":"error","message":"rate limit exceeded","code":"rate_limit"}"#;
486        let events = parse_codex_line(line);
487        assert_eq!(events.len(), 1);
488        match &events[0] {
489            Ok(Event::Error(e)) => {
490                assert_eq!(e.message, "rate limit exceeded");
491                assert_eq!(e.code, Some("rate_limit".into()));
492            }
493            other => panic!("expected Error, got {other:?}"),
494        }
495    }
496
497    #[test]
498    fn parse_reasoning_item_skipped() {
499        let line = r#"{"type":"item.completed","item":{"id":"item_0","type":"reasoning","text":"thinking..."}}"#;
500        let events = parse_codex_line(line);
501        assert!(events.is_empty(), "reasoning items should be skipped");
502    }
503
504    // ── Legacy format tests ─────────────────────────────────────
505
506    #[test]
507    fn parse_legacy_item_created_message() {
508        let line = r#"{"type":"item.created","item":{"type":"message","role":"assistant","content":[{"text":"Hello"}]}}"#;
509        let events = parse_codex_line(line);
510        assert_eq!(events.len(), 1);
511        match &events[0] {
512            Ok(Event::Message(m)) => {
513                assert_eq!(m.role, Role::Assistant);
514                assert_eq!(m.text, "Hello");
515            }
516            other => panic!("expected Message, got {other:?}"),
517        }
518    }
519
520    #[test]
521    fn parse_legacy_thread_completed() {
522        let line = r#"{"type":"thread.completed","thread_id":"th-123","summary":"All done","duration_ms":5000}"#;
523        let events = parse_codex_line(line);
524        assert_eq!(events.len(), 1);
525        match &events[0] {
526            Ok(Event::Result(r)) => {
527                assert!(r.success);
528                assert_eq!(r.text, "All done");
529                assert_eq!(r.session_id, "th-123");
530                assert_eq!(r.duration_ms, Some(5000));
531            }
532            other => panic!("expected Result, got {other:?}"),
533        }
534    }
535
536    // ── build_args tests ────────────────────────────────────────
537
538    #[test]
539    fn build_args_full_access() {
540        let config = TaskConfig::new("do it", crate::config::AgentKind::Codex);
541        let runner = CodexRunner;
542        let args = runner.build_args(&config);
543        assert!(args.contains(&"exec".to_string()));
544        assert!(args.contains(&"--json".to_string()));
545        assert!(args.contains(&"--sandbox".to_string()));
546        assert!(args.contains(&"danger-full-access".to_string()));
547        assert!(args.contains(&"--dangerously-bypass-approvals-and-sandbox".to_string()));
548        assert_eq!(args.last().unwrap(), "do it");
549    }
550
551    #[test]
552    fn build_args_read_only() {
553        let mut config = TaskConfig::new("analyze", crate::config::AgentKind::Codex);
554        config.permission_mode = PermissionMode::ReadOnly;
555        let runner = CodexRunner;
556        let args = runner.build_args(&config);
557        assert!(args.contains(&"--sandbox".to_string()));
558        assert!(args.contains(&"read-only".to_string()));
559        assert!(!args.contains(&"--dangerously-bypass-approvals-and-sandbox".to_string()));
560    }
561
562    #[test]
563    fn build_args_with_model() {
564        let mut config = TaskConfig::new("do it", crate::config::AgentKind::Codex);
565        config.model = Some("gpt-5-codex".into());
566        let runner = CodexRunner;
567        let args = runner.build_args(&config);
568        assert!(args.contains(&"--model".to_string()));
569        assert!(args.contains(&"gpt-5-codex".to_string()));
570    }
571}