Skip to main content

orch/engine/runner/agents/
codex.rs

1//! Codex agent runner.
2//!
3//! ## CLI invocation
4//!
5//! ```bash
6//! codex --model {model} \
7//!   exec \
8//!   -c 'sandbox_workspace_write.network_access=true' \
9//!   -c 'shell_environment_policy.inherit=all' \
10//!   --sandbox workspace-write \
11//!   -c 'approval_policy="never"' \
12//!   --json - < <(cat "{sys_file}" "{msg_file}")
13//! ```
14//!
15//! Note: all `-c` overrides must appear **after** the `exec` subcommand.
16//! Global `-c` flags before `exec` are parsed by the top-level codex CLI and do
17//! NOT propagate into the exec subcommand's sandbox — placing
18//! `sandbox_workspace_write.network_access=true` before `exec` silently fails,
19//! leaving the sandbox network-blocked.
20//!
21//! ## Output format (`exec --json`)
22//!
23//! NDJSON stream (one JSON object per line):
24//! ```jsonl
25//! {"type":"thread.started","thread_id":"..."}
26//! {"type":"turn.started"}
27//! {"type":"item.completed","item":{"type":"agent_message","text":"..."}}
28//! {"type":"item.completed","item":{"type":"command_execution","command":"..."}}
29//! {"type":"turn.completed"}
30//! ```
31//!
32//! ## Error events
33//!
34//! Rate limit:
35//! ```jsonl
36//! {"type":"error","message":"You've hit your usage limit..."}
37//! {"type":"turn.failed","error":{"message":"..."}}
38//! ```
39//!
40//! Model not found:
41//! ```jsonl
42//! {"type":"item.completed","item":{"type":"error","message":"Model metadata for `o3-mini` not found..."}}
43//! {"type":"error","message":"{\"detail\":\"The 'o3-mini' model is not supported...\"}"}
44//! {"type":"turn.failed","error":{"message":"..."}}
45//! ```
46
47use std::sync::LazyLock;
48
49use tokio::process::Command;
50
51use super::{
52    AgentError, AgentRunner, ParsedResponse, PermissionRules, SandboxLevel, StreamEvent,
53    json_error_message, json_str,
54};
55use crate::parser;
56
57/// Runner for Codex agent.
58#[derive(Debug)]
59pub struct CodexRunner;
60
61impl CodexRunner {
62    /// Extract the agent's response text from NDJSON events.
63    ///
64    /// Looks for `item.completed` events with `item.type == "agent_message"`.
65    /// Uses the **last** agent message that contains valid-looking JSON,
66    /// since earlier messages are often progress updates.
67    fn extract_agent_text(events: &[serde_json::Value]) -> Option<String> {
68        let mut texts = Vec::new();
69
70        for event in events {
71            let event_type = event.get("type").and_then(|v| v.as_str()).unwrap_or("");
72
73            if event_type == "item.completed"
74                && let Some(item) = event.get("item")
75            {
76                let item_type = item.get("type").and_then(|v| v.as_str()).unwrap_or("");
77
78                if item_type == "agent_message"
79                    && let Some(text) = item.get("text").and_then(|v| v.as_str())
80                {
81                    texts.push(text.to_string());
82                }
83            }
84        }
85
86        if texts.is_empty() {
87            return None;
88        }
89
90        // Prefer the last message that looks like JSON (contains `{` and `status`)
91        for text in texts.iter().rev() {
92            let trimmed = text.trim();
93            if trimmed.contains('{') && trimmed.contains("status") {
94                return Some(text.clone());
95            }
96        }
97
98        // Fall back to the last message
99        texts.pop()
100    }
101
102    /// Check for error events in the NDJSON stream.
103    ///
104    /// Scans ALL error events and returns the most specific one.
105    /// Priority: RateLimit > ModelUnavailable > Auth > ContextOverflow > AgentFailed
106    fn detect_error(events: &[serde_json::Value]) -> Option<AgentError> {
107        super::patterns::detect_ndjson_error(
108            events,
109            |event| {
110                let event_type = event.get("type").and_then(|v| v.as_str()).unwrap_or("");
111                match event_type {
112                    "turn.failed" => Some(
113                        event
114                            .get("error")
115                            .and_then(|e| e.get("message"))
116                            .and_then(|v| v.as_str())
117                            .unwrap_or("turn failed")
118                            .to_string(),
119                    ),
120                    "error" => Some(
121                        event
122                            .get("message")
123                            .and_then(|v| v.as_str())
124                            .unwrap_or("unknown error")
125                            .to_string(),
126                    ),
127                    "item.completed" => {
128                        let item = event.get("item")?;
129                        let item_type = item.get("type").and_then(|v| v.as_str()).unwrap_or("");
130                        if item_type == "error" {
131                            Some(
132                                item.get("message")
133                                    .and_then(|v| v.as_str())
134                                    .unwrap_or("item error")
135                                    .to_string(),
136                            )
137                        } else {
138                            None
139                        }
140                    }
141                    _ => None,
142                }
143            },
144            Self::classify_message,
145        )
146    }
147
148    /// Classify an error message into an AgentError variant.
149    fn classify_message(message: &str) -> AgentError {
150        let lower = message.to_lowercase();
151
152        // Rate limit — delegate to shared helper (covers "you've hit your", 429, 529, etc.)
153        if let Some(e) = super::patterns::detect_rate_limit(message) {
154            return e;
155        }
156
157        // Model not supported / not found (codex-specific patterns)
158        if lower.contains("model metadata")
159            || lower.contains("model is not supported")
160            || (lower.contains("not found")
161                && (lower.contains("model") || lower.contains("metadata")))
162        {
163            let model = extract_model_name(message).unwrap_or_default();
164            return AgentError::ModelUnavailable {
165                message: message.to_string(),
166                model,
167            };
168        }
169
170        // Auth errors — delegate to shared helper (includes HTTP 401/403, billing, etc.)
171        if let Some(e) = super::patterns::detect_auth_error(message) {
172            return e;
173        }
174
175        // Context overflow — delegate to shared helper
176        if let Some(e) = super::patterns::detect_context_overflow(message) {
177            return e;
178        }
179
180        // Connection/network errors — codex-specific transient patterns.
181        // Return NetworkError (not AgentFailed) so fallback.rs can route these
182        // into the dedicated network-retry loop with backoff and escalation.
183        if lower.contains("reconnecting")
184            || lower.contains("stream disconnected")
185            || lower.contains("event stream lagged")
186            || lower.contains("connection closed")
187            || lower.contains("websocket")
188            || lower.contains("econnreset")
189        {
190            return AgentError::NetworkError {
191                message: format!("codex network error: {message}"),
192            };
193        }
194        if let Some(e) = super::patterns::detect_network_error(message) {
195            return e;
196        }
197
198        AgentError::AgentFailed {
199            message: message.to_string(),
200        }
201    }
202}
203
204impl AgentRunner for CodexRunner {
205    #[cfg(test)]
206    fn name(&self) -> &'static str {
207        "codex"
208    }
209
210    fn stream_events(&self, event: &serde_json::Value) -> Vec<StreamEvent> {
211        stream_events(event)
212    }
213
214    fn extract_text(&self, raw: &str) -> Result<String, AgentError> {
215        let trimmed = raw.trim();
216        if trimmed.is_empty() {
217            return Ok(String::new());
218        }
219
220        let events = super::parse_ndjson(trimmed);
221        if events.is_empty() {
222            return Ok(trimmed.to_string());
223        }
224
225        // Propagate terminal errors (rate limit, auth) so the review pipeline
226        // can record cooldowns and abort cleanly.
227        if let Some(err) = Self::detect_error(&events) {
228            return Err(err);
229        }
230
231        // Return agent_message text, or fall back to the raw output.
232        Ok(Self::extract_agent_text(&events).unwrap_or_else(|| trimmed.to_string()))
233    }
234
235    fn build_command(
236        &self,
237        model: Option<&str>,
238        timeout_cmd: &str,
239        sys_file: &str,
240        msg_file: &str,
241        permissions: &PermissionRules,
242    ) -> String {
243        let model_flag = model
244            .map(|m| format!("--model {}", super::shell_single_quote(m)))
245            .unwrap_or_default();
246
247        // Codex permission mode (codex-cli 0.133.0+):
248        // - autonomous → --sandbox workspace-write -c 'approval_policy="never"'
249        // - supervised → -c 'approval_policy="on-request"' --sandbox {sandbox}
250        // - full access → --dangerously-bypass-approvals-and-sandbox
251        //
252        // Note: --ask-for-approval was removed in codex-cli 0.133.0.
253        // Use -c 'approval_policy="..."' instead.
254        // Note: --full-auto is deprecated as of codex 0.128.0 and does not
255        // reliably honour --add-dir entries under the new seatbelt policy.
256        let permission_flags = if permissions.autonomous {
257            match permissions.sandbox {
258                SandboxLevel::FullAccess | SandboxLevel::Os => {
259                    "--dangerously-bypass-approvals-and-sandbox".to_string()
260                }
261                _ => r#"--sandbox workspace-write -c 'approval_policy="never"'"#.to_string(),
262            }
263        } else {
264            let sandbox = match permissions.sandbox {
265                SandboxLevel::WorkspaceWrite | SandboxLevel::None => "workspace-write",
266                SandboxLevel::FullAccess | SandboxLevel::Os => "danger-full-access",
267            };
268            format!(r#"-c 'approval_policy="on-request"' --sandbox {sandbox}"#)
269        };
270
271        // Extra writable dirs (e.g. the git common dir when running inside a
272        // worktree whose .git metadata lives outside the sandbox root).
273        let add_dir_flags: String = permissions
274            .extra_writable_dirs
275            .iter()
276            .map(|p| {
277                format!(
278                    "\\\n  --add-dir {}",
279                    super::shell_single_quote(&p.to_string_lossy())
280                )
281            })
282            .collect::<Vec<_>>()
283            .join(" ");
284
285        // All -c config overrides and permission flags must be placed AFTER the
286        // `exec` subcommand. Global -c flags before `exec` are parsed by the
287        // top-level CLI and do NOT propagate into the exec subcommand's sandbox
288        // initialisation — in particular, `sandbox_workspace_write.network_access=true`
289        // has no effect when placed before `exec`, causing outbound network to be
290        // blocked despite the documented contract. Place everything after `exec`.
291        // Feed both files via process substitution rather than a leading `cat |`
292        // pipe stage — a leading pipe would make `cat`, not codex, the first
293        // stage in the runner script's pipeline, so PIPESTATUS[0] there would
294        // always report cat's (always-zero) exit code instead of codex's.
295        format!(
296            r#"{timeout_cmd} codex {model_flag} \
297  exec \
298  -c 'sandbox_workspace_write.network_access=true' \
299  -c 'shell_environment_policy.inherit=all' \
300  {permission_flags} {add_dir_flags} --json - < <(cat "{sys_file}" "{msg_file}")"#,
301        )
302    }
303
304    fn parse_response(&self, raw: &str) -> Result<ParsedResponse, AgentError> {
305        let trimmed = raw.trim();
306        if trimmed.is_empty() {
307            return Err(AgentError::InvalidResponse { raw: String::new() });
308        }
309
310        let events = super::parse_ndjson(trimmed);
311
312        if events.is_empty() {
313            // Maybe it's direct JSON, not NDJSON
314            if let Ok(resp) = parser::parse(trimmed) {
315                return Ok(ParsedResponse {
316                    response: resp,
317                    input_tokens: None,
318                    output_tokens: None,
319                    cost_usd: None,
320                    duration_ms: None,
321                });
322            }
323            return Err(AgentError::InvalidResponse {
324                raw: trimmed.to_string(),
325            });
326        }
327
328        // Check for errors first
329        if let Some(err) = Self::detect_error(&events) {
330            return Err(err);
331        }
332
333        // Extract agent response text
334        let agent_text =
335            Self::extract_agent_text(&events).ok_or_else(|| AgentError::InvalidResponse {
336                raw: trimmed.to_string(),
337            })?;
338
339        // Parse the agent text through our standard parser
340        let response = match parser::parse(&agent_text) {
341            Ok(r) => r,
342            Err(_) => {
343                // Structured parse failed — try plain-text synthesis before
344                // giving up, same rescue path claude.rs uses (issue #3467).
345                super::synthesize_response_from_text(&agent_text).ok_or_else(|| {
346                    AgentError::InvalidResponse {
347                        raw: agent_text.clone(),
348                    }
349                })?
350            }
351        };
352
353        Ok(ParsedResponse {
354            response,
355            input_tokens: None,
356            output_tokens: None,
357            cost_usd: None,
358            duration_ms: None,
359        })
360    }
361
362    fn classify_error(&self, exit_code: i32, stdout: &str, stderr: &str) -> AgentError {
363        // Try parsing NDJSON events from stdout for structured errors
364        let events = super::parse_ndjson(stdout);
365        if let Some(err) = Self::detect_error(&events) {
366            return err;
367        }
368
369        // Fall back to pattern matching
370        let combined = format!("{stdout}\n{stderr}");
371        super::patterns::classify_from_text(exit_code, &combined)
372    }
373
374    fn router_command(&self, prompt: &str, model: Option<&str>) -> anyhow::Result<Command> {
375        let mut cmd = Command::new("codex");
376        cmd.arg("exec").arg("--json");
377        if let Some(m) = model {
378            cmd.arg("--model").arg(m);
379        }
380        cmd.arg(prompt);
381        Ok(cmd)
382    }
383}
384
385/// Try to extract a model name from an error message.
386///
387/// Looks for text quoted with backticks or single quotes:
388/// - "Model metadata for `o3-mini` not found"
389/// - "The 'o3-mini' model is not supported"
390fn extract_model_name(message: &str) -> Option<String> {
391    extract_quoted(message, '`').or_else(|| extract_quoted(message, '\''))
392}
393
394/// Extract text between matching `quote` characters.
395fn extract_quoted(text: &str, quote: char) -> Option<String> {
396    let start = text.find(quote)?;
397    let rest = &text[start + quote.len_utf8()..];
398    let end = rest.find(quote)?;
399    Some(rest[..end].to_string())
400}
401
402/// Extract a structured `AgentResult` from Codex NDJSON output.
403///
404/// Finds `item.completed` events with `type:agent_message` for the result text.
405/// Checks for `turn.failed`, `error`, and `item.completed` error events.
406///
407/// Returns `None` only if no parseable NDJSON events are found.
408pub fn find_codex_result(ndjson: &str) -> Option<super::AgentResult> {
409    let events = super::parse_ndjson(ndjson.trim());
410    if events.is_empty() {
411        return None;
412    }
413
414    // Check for errors first
415    let is_error;
416    let error_text;
417    if let Some(err) = CodexRunner::detect_error(&events) {
418        is_error = true;
419        error_text = Some(err.to_string());
420    } else {
421        is_error = false;
422        error_text = None;
423    }
424
425    let result_text = if is_error {
426        error_text.unwrap_or_default()
427    } else {
428        CodexRunner::extract_agent_text(&events).unwrap_or_default()
429    };
430
431    if result_text.is_empty() && !is_error {
432        return None;
433    }
434
435    Some(super::AgentResult {
436        is_error,
437        result_text,
438        input_tokens: None,
439        output_tokens: None,
440        cost_usd: None,
441        duration_ms: None,
442        truncated_by_length: false,
443    })
444}
445
446#[cfg(test)]
447mod tests {
448    use std::path::PathBuf;
449
450    use super::*;
451
452    fn runner() -> CodexRunner {
453        CodexRunner
454    }
455
456    #[test]
457    fn parse_codex_ndjson_success() {
458        let raw = r#"{"type":"thread.started","thread_id":"t1"}
459{"type":"turn.started"}
460{"type":"item.completed","item":{"type":"agent_message","text":"{\"status\":\"done\",\"summary\":\"hello\",\"accomplished\":[],\"remaining\":[],\"files\":[]}"}}
461{"type":"turn.completed"}"#;
462
463        let parsed = runner().parse_response(raw).unwrap();
464        assert_eq!(parsed.response.status, "done");
465        assert_eq!(parsed.response.summary, "hello");
466    }
467
468    #[test]
469    fn parse_codex_ndjson_rate_limit() {
470        let raw = r#"{"type":"error","message":"You've hit your usage limit for this billing period"}
471{"type":"turn.failed","error":{"message":"You've hit your usage limit"}}"#;
472
473        let err = runner().parse_response(raw).unwrap_err();
474        assert!(matches!(err, AgentError::RateLimit { .. }));
475    }
476
477    #[test]
478    fn parse_codex_model_not_found() {
479        let raw = r#"{"type":"item.completed","item":{"type":"error","message":"Model metadata for `o3-mini` not found"}}
480{"type":"error","message":"{\"detail\":\"The 'o3-mini' model is not supported\"}"}
481{"type":"turn.failed","error":{"message":"Model not supported"}}"#;
482
483        let err = runner().parse_response(raw).unwrap_err();
484        assert!(matches!(err, AgentError::ModelUnavailable { .. }));
485    }
486
487    /// Regression test for issue #3467: when codex's agent_message text is a
488    /// plain-prose completion summary (not JSON), parser::parse fails but
489    /// synthesize_response_from_text must rescue it instead of returning
490    /// InvalidResponse, mirroring the fallback claude.rs already has.
491    #[test]
492    fn parse_codex_plain_prose_completion_synthesizes_response() {
493        let raw = r#"{"type":"thread.started","thread_id":"t1"}
494{"type":"turn.started"}
495{"type":"item.completed","item":{"type":"agent_message","text":"Fixed the typo and corrected the deadline reference in the review."}}
496{"type":"turn.completed"}"#;
497
498        let parsed = runner()
499            .parse_response(raw)
500            .expect("plain-prose completion text must synthesize successfully");
501        assert_eq!(parsed.response.status, "done");
502    }
503
504    #[test]
505    fn parse_codex_empty_response() {
506        let err = runner().parse_response("").unwrap_err();
507        assert!(matches!(err, AgentError::InvalidResponse { .. }));
508    }
509
510    #[test]
511    fn extract_model_name_backtick() {
512        assert_eq!(
513            extract_model_name("Model metadata for `o3-mini` not found"),
514            Some("o3-mini".to_string())
515        );
516    }
517
518    #[test]
519    fn extract_model_name_single_quote() {
520        assert_eq!(
521            extract_model_name("The 'o3-mini' model is not supported"),
522            Some("o3-mini".to_string())
523        );
524    }
525
526    #[test]
527    fn parse_codex_multiple_agent_messages() {
528        let raw = r#"{"type":"thread.started","thread_id":"t1"}
529{"type":"turn.started"}
530{"type":"item.completed","item":{"type":"agent_message","text":"Working on it..."}}
531{"type":"item.completed","item":{"type":"command_execution","command":"echo hello"}}
532{"type":"item.completed","item":{"type":"agent_message","text":"{\"status\":\"done\",\"summary\":\"finished\",\"accomplished\":[\"did it\"],\"remaining\":[],\"files\":[\"a.rs\"]}"}}
533{"type":"turn.completed"}"#;
534
535        let parsed = runner().parse_response(raw).unwrap();
536        // The parser concatenates all agent messages; the last valid JSON wins
537        // Actually, parser::parse will try the concatenated text and find the JSON
538        assert_eq!(parsed.response.status, "done");
539    }
540
541    #[test]
542    fn classify_error_codex_ndjson() {
543        let stdout = r#"{"type":"error","message":"You've hit your usage limit"}"#;
544        let err = runner().classify_error(1, stdout, "");
545        assert!(matches!(err, AgentError::RateLimit { .. }));
546    }
547
548    #[test]
549    fn build_command_codex() {
550        let perms = PermissionRules::default();
551        let cmd = runner().build_command(
552            Some("gpt-4o"),
553            "timeout 1800",
554            "/tmp/sys.txt",
555            "/tmp/msg.txt",
556            &perms,
557        );
558        assert!(cmd.contains("codex"));
559        assert!(cmd.contains("--model 'gpt-4o'"));
560        assert!(cmd.contains("exec"));
561        assert!(cmd.contains("--json -"));
562        assert!(
563            cmd.contains("--sandbox workspace-write"),
564            "default autonomous codex should use --sandbox workspace-write, got: {cmd}"
565        );
566        assert!(
567            cmd.contains(r#"-c 'approval_policy="never"'"#),
568            "default autonomous codex should use -c approval_policy=never, got: {cmd}"
569        );
570        assert!(
571            !cmd.contains("--ask-for-approval"),
572            "--ask-for-approval was removed in codex 0.133.0 and must not appear, got: {cmd}"
573        );
574        assert!(
575            !cmd.contains("--full-auto"),
576            "--full-auto is deprecated and must not appear in the command, got: {cmd}"
577        );
578        // Regression: network_access and shell_environment_policy overrides must appear
579        // AFTER `exec`, not before it.  Global -c flags placed before `exec` are parsed
580        // by the top-level codex CLI and do NOT propagate into the exec subcommand's
581        // sandbox initialisation — the network would be blocked silently if misplaced.
582        assert!(
583            cmd.contains("sandbox_workspace_write.network_access=true"),
584            "network_access override must be present, got: {cmd}"
585        );
586        let exec_pos = cmd.find("exec").expect("exec must appear in command");
587        let network_pos = cmd
588            .find("sandbox_workspace_write.network_access=true")
589            .expect("network_access override must appear in command");
590        assert!(
591            network_pos > exec_pos,
592            "sandbox_workspace_write.network_access=true must appear AFTER exec, got: {cmd}"
593        );
594        // Regression: the command must not start with a `cat sys msg |` pipe
595        // stage. `build_runner_script` captures the agent's exit status via
596        // `PIPESTATUS[0]` of `{agent_cmd} | tee ...` — a leading `cat |` would
597        // make PIPESTATUS[0] always report cat's (always-zero) exit code
598        // instead of codex's real exit code. sys/msg files must be fed via
599        // input redirection instead.
600        assert!(
601            !cmd.trim_start().starts_with("cat "),
602            "codex command must not start with a `cat |` pipe stage, got: {cmd}"
603        );
604        assert!(
605            cmd.contains("< <(cat \"/tmp/sys.txt\" \"/tmp/msg.txt\")"),
606            "sys/msg files must be fed via process substitution, got: {cmd}"
607        );
608    }
609
610    #[test]
611    fn build_command_codex_full_access() {
612        let perms = PermissionRules {
613            autonomous: true,
614            sandbox: SandboxLevel::FullAccess,
615            disallowed_tools: vec![],
616            allowed_tools: vec![],
617            allowed_edit_paths: vec![],
618            deny_read_only: false,
619            extra_writable_dirs: vec![],
620        };
621        let cmd = runner().build_command(None, "", "/tmp/sys.txt", "/tmp/msg.txt", &perms);
622        assert!(
623            cmd.contains("--dangerously-bypass-approvals-and-sandbox"),
624            "full access codex should use dangerously-bypass, got: {cmd}"
625        );
626    }
627
628    #[test]
629    fn build_command_codex_in_the_os_sandbox_turns_its_own_off() {
630        let perms = PermissionRules {
631            autonomous: true,
632            sandbox: SandboxLevel::Os,
633            disallowed_tools: vec![],
634            allowed_tools: vec![],
635            allowed_edit_paths: vec![],
636            deny_read_only: false,
637            extra_writable_dirs: vec![],
638        };
639        let cmd = runner().build_command(None, "", "/tmp/sys.txt", "/tmp/msg.txt", &perms);
640        assert!(
641            cmd.contains("--dangerously-bypass-approvals-and-sandbox"),
642            "codex cannot nest its Seatbelt in the OS sandbox, got: {cmd}"
643        );
644    }
645
646    #[test]
647    fn build_command_codex_extra_writable_dirs() {
648        // When git metadata dirs are outside the worktree sandbox root, the
649        // runner adds them to extra_writable_dirs and build_command must emit
650        // --add-dir flags so Codex can create index.lock there.
651        let perms = PermissionRules {
652            autonomous: true,
653            sandbox: SandboxLevel::WorkspaceWrite,
654            disallowed_tools: vec![],
655            allowed_tools: vec![],
656            allowed_edit_paths: vec![],
657            deny_read_only: false,
658            extra_writable_dirs: vec![
659                PathBuf::from("/Users/gb/Projects/bean/.git"),
660                PathBuf::from("/Users/gb/Projects/bean/.git/worktrees/task-123"),
661            ],
662        };
663        let cmd = runner().build_command(None, "", "/tmp/sys.txt", "/tmp/msg.txt", &perms);
664        assert!(
665            cmd.contains("--add-dir"),
666            "should include --add-dir flag, got: {cmd}"
667        );
668        assert!(
669            cmd.contains("/Users/gb/Projects/bean/.git'"),
670            "should include git common dir path, got: {cmd}"
671        );
672        assert!(
673            cmd.contains("/Users/gb/Projects/bean/.git/worktrees/task-123"),
674            "should include git dir path, got: {cmd}"
675        );
676        assert_eq!(
677            cmd.matches("--add-dir ").count(),
678            2,
679            "should include two --add-dir flags, got: {cmd}"
680        );
681        assert!(
682            cmd.contains("--sandbox workspace-write"),
683            "autonomous codex with extra dirs should use --sandbox workspace-write, got: {cmd}"
684        );
685        assert!(
686            cmd.contains(r#"-c 'approval_policy="never"'"#),
687            "autonomous codex with extra dirs should use -c approval_policy=never, got: {cmd}"
688        );
689        assert!(
690            !cmd.contains("--ask-for-approval"),
691            "--ask-for-approval was removed in codex 0.133.0 and must not appear, got: {cmd}"
692        );
693        assert!(
694            !cmd.contains("--full-auto"),
695            "--full-auto is deprecated and must not appear, got: {cmd}"
696        );
697        assert!(
698            cmd.contains("exec") && cmd.contains("--json -"),
699            "should include exec and --json -, got: {cmd}"
700        );
701        let exec_pos = cmd.find("exec").expect("exec must appear in command");
702        let network_pos = cmd
703            .find("sandbox_workspace_write.network_access=true")
704            .expect("network_access override must appear in command");
705        assert!(
706            network_pos > exec_pos,
707            "sandbox_workspace_write.network_access=true must appear AFTER exec, got: {cmd}"
708        );
709    }
710
711    #[test]
712    fn build_command_codex_no_extra_writable_dirs() {
713        // With no extra_writable_dirs, --add-dir must not appear in the command.
714        let perms = PermissionRules::default();
715        let cmd = runner().build_command(None, "", "/tmp/sys.txt", "/tmp/msg.txt", &perms);
716        assert!(
717            !cmd.contains("--add-dir"),
718            "should not include --add-dir when no extra dirs, got: {cmd}"
719        );
720    }
721
722    // ── Fixture-based tests ─────────────────────────────────────
723
724    #[test]
725    fn fixture_codex_success() {
726        let raw = include_str!("../../../../tests/fixtures/codex_success.jsonl");
727        let parsed = runner().parse_response(raw).unwrap();
728        assert_eq!(parsed.response.status, "done");
729        assert!(parsed.response.summary.contains("Implemented"));
730        assert_eq!(parsed.response.accomplished.len(), 2);
731        assert_eq!(parsed.response.files, vec!["src/handler.rs"]);
732    }
733
734    #[test]
735    fn fixture_codex_rate_limit() {
736        let raw = include_str!("../../../../tests/fixtures/codex_rate_limit.jsonl");
737        let err = runner().parse_response(raw).unwrap_err();
738        assert!(matches!(err, AgentError::RateLimit { .. }), "got: {err:?}");
739    }
740
741    // ── Connection / transient error tests ────────────────────────
742
743    #[test]
744    fn classify_codex_stream_disconnected() {
745        let err = CodexRunner::classify_message("Reconnecting 3/5... stream disconnected");
746        assert!(
747            matches!(err, AgentError::NetworkError { .. }),
748            "expected NetworkError so fallback retries, got: {err:?}"
749        );
750    }
751
752    #[test]
753    fn classify_codex_websocket_error() {
754        let err = CodexRunner::classify_message("WebSocket connection closed unexpectedly");
755        assert!(
756            matches!(err, AgentError::NetworkError { .. }),
757            "expected NetworkError so fallback retries, got: {err:?}"
758        );
759    }
760
761    #[test]
762    fn classify_codex_event_stream_lagged() {
763        let err = CodexRunner::classify_message(
764            "in-process app-server event stream lagged; dropped 1 events",
765        );
766        assert!(
767            matches!(err, AgentError::NetworkError { .. }),
768            "expected NetworkError so fallback retries, got: {err:?}"
769        );
770    }
771
772    #[test]
773    fn classify_codex_econnreset() {
774        let err = CodexRunner::classify_message("ECONNRESET: connection reset by peer");
775        assert!(
776            matches!(err, AgentError::NetworkError { .. }),
777            "expected NetworkError so fallback retries, got: {err:?}"
778        );
779    }
780
781    // ── Production error patterns ─────────────────────────────────
782
783    #[test]
784    fn parse_codex_chatgpt_model_unsupported() {
785        // Real error: "The 'gpt-4.1' model is not supported when using Codex with a ChatGPT account."
786        let raw = r#"{"type":"error","message":"The 'gpt-4.1' model is not supported when using Codex with a ChatGPT account."}
787{"type":"turn.failed","error":{"message":"Model not supported"}}"#;
788
789        let err = runner().parse_response(raw).unwrap_err();
790        assert!(
791            matches!(err, AgentError::ModelUnavailable { .. }),
792            "got: {err:?}"
793        );
794        if let AgentError::ModelUnavailable { model, .. } = &err {
795            assert_eq!(model, "gpt-4.1");
796        }
797    }
798
799    #[test]
800    fn parse_codex_usage_limit_after_reconnect() {
801        // Real failure: stream disconnects, then usage limit hit.
802        // detect_error scans ALL events and prefers RateLimit over AgentFailed.
803        let raw = r#"{"type":"error","message":"Reconnecting 2/5"}
804{"type":"error","message":"Falling back from WebSockets to HTTPS transport"}
805{"type":"error","message":"You've hit your usage limit. Upgrade to Pro at https://..."}
806{"type":"turn.failed","error":{"message":"You've hit your usage limit"}}"#;
807
808        let err = runner().parse_response(raw).unwrap_err();
809        assert!(
810            matches!(err, AgentError::RateLimit { .. }),
811            "expected RateLimit (most specific), got: {err:?}"
812        );
813    }
814
815    #[test]
816    fn classify_error_chatgpt_model_unsupported() {
817        let stdout = r#"{"type":"error","message":"The 'gpt-4.1' model is not supported when using Codex with a ChatGPT account."}
818{"type":"turn.failed","error":{"message":"Model not supported"}}"#;
819
820        let err = runner().classify_error(1, stdout, "");
821        assert!(
822            matches!(err, AgentError::ModelUnavailable { .. }),
823            "got: {err:?}"
824        );
825    }
826
827    // ── Real output.json fixture tests ───────────────────────────
828
829    /// Real failure: websocket disconnections followed by usage limit.
830    /// RateLimit must take priority over AgentFailed (reconnect errors).
831    #[test]
832    fn fixture_codex_usage_limit() {
833        let raw = include_str!("../../../../tests/fixtures/codex_usage_limit.jsonl");
834        let err = runner().parse_response(raw).unwrap_err();
835        assert!(
836            matches!(err, AgentError::RateLimit { .. }),
837            "expected RateLimit (priority over reconnect errors), got: {err:?}"
838        );
839    }
840
841    /// Real failure: unsupported model with ChatGPT account.
842    #[test]
843    fn fixture_codex_model_unsupported() {
844        let raw = include_str!("../../../../tests/fixtures/codex_model_unsupported.jsonl");
845        let err = runner().parse_response(raw).unwrap_err();
846        assert!(
847            matches!(err, AgentError::ModelUnavailable { .. }),
848            "expected ModelUnavailable, got: {err:?}"
849        );
850        if let AgentError::ModelUnavailable { model, .. } = &err {
851            assert_eq!(model, "gpt-4.1");
852        }
853    }
854
855    // ── extract_text ─────────────────────────────────────────────
856
857    /// Codex: extract_text returns the agent_message text for review parsing.
858    #[test]
859    fn extract_text_returns_agent_message() {
860        let raw = concat!(
861            r#"{"type":"thread.started","thread_id":"t1"}"#,
862            "\n",
863            r#"{"type":"turn.started"}"#,
864            "\n",
865            r#"{"type":"item.completed","item":{"type":"reasoning","text":"Thinking..."}}"#,
866            "\n",
867            r#"{"type":"item.completed","item":{"type":"agent_message","text":"{\"decision\":\"approve\",\"notes\":\"LGTM\",\"test_results\":\"pass\",\"issues\":[]}"}}"#,
868            "\n",
869            r#"{"type":"turn.completed"}"#,
870        );
871        let text = runner().extract_text(raw).unwrap();
872        assert_eq!(
873            text,
874            r#"{"decision":"approve","notes":"LGTM","test_results":"pass","issues":[]}"#
875        );
876    }
877
878    /// Codex: extract_text propagates RateLimit for terminal errors.
879    #[test]
880    fn extract_text_rate_limit_propagates() {
881        let raw = concat!(
882            r#"{"type":"thread.started","thread_id":"t1"}"#,
883            "\n",
884            r#"{"type":"error","message":"You've hit your usage limit. Upgrade to continue."}"#,
885            "\n",
886            r#"{"type":"turn.failed","error":{"message":"usage limit exceeded"}}"#,
887        );
888        let err = runner().extract_text(raw).unwrap_err();
889        assert!(
890            matches!(err, AgentError::RateLimit { .. }),
891            "expected RateLimit, got: {err:?}"
892        );
893    }
894
895    /// Codex: extract_text propagates Auth error.
896    #[test]
897    fn extract_text_auth_error_propagates() {
898        let raw = concat!(
899            r#"{"type":"thread.started","thread_id":"t1"}"#,
900            "\n",
901            r#"{"type":"error","message":"401 Unauthorized: invalid api key"}"#,
902            "\n",
903            r#"{"type":"turn.failed","error":{"message":"auth failed"}}"#,
904        );
905        let err = runner().extract_text(raw).unwrap_err();
906        assert!(
907            matches!(err, AgentError::Auth { .. }),
908            "expected Auth, got: {err:?}"
909        );
910    }
911
912    /// Codex: extract_text on empty input returns empty string (not an error).
913    #[test]
914    fn extract_text_empty_input_returns_empty_ok() {
915        let text = runner().extract_text("").unwrap();
916        assert_eq!(text, "");
917    }
918
919    // ── find_codex_result ───────────────────────────────────────
920
921    #[test]
922    fn find_codex_result_success() {
923        let ndjson = concat!(
924            r#"{"type":"thread.started","thread_id":"t1"}"#,
925            "\n",
926            r#"{"type":"turn.started"}"#,
927            "\n",
928            r#"{"type":"item.completed","item":{"type":"agent_message","text":"{\"decision\":\"approve\",\"notes\":\"LGTM\"}"}}"#,
929            "\n",
930            r#"{"type":"turn.completed"}"#,
931        );
932        let result = find_codex_result(ndjson).expect("should find result");
933        assert!(!result.is_error);
934        assert!(result.result_text.contains("approve"));
935    }
936
937    #[test]
938    fn find_codex_result_error() {
939        let ndjson = concat!(
940            r#"{"type":"error","message":"You've hit your usage limit"}"#,
941            "\n",
942            r#"{"type":"turn.failed","error":{"message":"usage limit"}}"#,
943        );
944        let result = find_codex_result(ndjson).expect("should find error result");
945        assert!(result.is_error);
946        assert!(result.result_text.contains("rate limit"));
947    }
948
949    #[test]
950    fn find_codex_result_empty_returns_none() {
951        assert!(find_codex_result("").is_none());
952        assert!(find_codex_result("   ").is_none());
953    }
954
955    #[test]
956    fn find_codex_result_plain_text_returns_none() {
957        assert!(find_codex_result("just some plain text").is_none());
958    }
959
960    #[test]
961    fn find_codex_result_skips_reasoning_and_commands() {
962        let ndjson = concat!(
963            r#"{"type":"item.completed","item":{"type":"reasoning","text":"Thinking..."}}"#,
964            "\n",
965            r#"{"type":"item.completed","item":{"type":"command_execution","command":"cargo test"}}"#,
966            "\n",
967            r#"{"type":"item.completed","item":{"type":"agent_message","text":"All tests passed"}}"#,
968        );
969        let result = find_codex_result(ndjson).expect("should find result");
970        assert!(!result.is_error);
971        assert_eq!(result.result_text, "All tests passed");
972    }
973}
974
975// Codex wraps every command as `/bin/zsh -lc '<command>'` or `-lc "<command>"`
976static SHELL_WRAP: LazyLock<regex::Regex> = LazyLock::new(|| {
977    regex::Regex::new(r#"(?s)^\S*/(?:ba|z)?sh -l?c (?:'(.*)'|"(.*)")$"#)
978        .expect("shell wrap regex is valid")
979});
980
981/// Codex `--json`: each finished step is an `item.completed` event
982fn stream_events(v: &serde_json::Value) -> Vec<StreamEvent> {
983    match json_str(v, "/type") {
984        "item.completed" => item_events(&v["item"]),
985        "turn.completed" => vec![StreamEvent::Done(String::new())],
986        "turn.failed" | "error" => vec![StreamEvent::Error(json_error_message(v))],
987        _ => vec![],
988    }
989}
990
991fn item_events(item: &serde_json::Value) -> Vec<StreamEvent> {
992    let id = json_str(item, "/id").to_string();
993    let call = |name: &str, input: serde_json::Value| StreamEvent::ToolCall {
994        id: id.clone(),
995        name: name.to_string(),
996        input,
997    };
998    match json_str(item, "/type") {
999        "agent_message" => vec![StreamEvent::Text(json_str(item, "/text").to_string())],
1000        "reasoning" => vec![StreamEvent::Thinking(json_str(item, "/text").to_string())],
1001        "command_execution" => {
1002            let command = json_str(item, "/command");
1003            let command = SHELL_WRAP
1004                .captures(command)
1005                .and_then(|c| c.get(1).or(c.get(2)))
1006                .map_or(command, |m| m.as_str());
1007            vec![
1008                call("Bash", serde_json::json!({ "command": command })),
1009                StreamEvent::ToolResult {
1010                    id: id.clone(),
1011                    name: "Bash".to_string(),
1012                    output: json_str(item, "/aggregated_output").to_string(),
1013                    is_error: item["exit_code"].as_i64().is_some_and(|c| c != 0),
1014                },
1015            ]
1016        }
1017        "file_change" => item["changes"]
1018            .as_array()
1019            .into_iter()
1020            .flatten()
1021            .map(|c| call("Edit", serde_json::json!({ "path": c["path"] })))
1022            .collect(),
1023        "mcp_tool_call" => {
1024            let name = format!("{}.{}", json_str(item, "/server"), json_str(item, "/tool"));
1025            vec![call(&name, item["arguments"].clone())]
1026        }
1027        "web_search" => vec![call(
1028            "Search",
1029            serde_json::json!({ "query": json_str(item, "/query") }),
1030        )],
1031        "error" => vec![StreamEvent::Error(json_str(item, "/message").to_string())],
1032        _ => vec![],
1033    }
1034}
1035
1036#[cfg(test)]
1037mod stream_tests {
1038    use super::*;
1039    use serde_json::json;
1040
1041    fn events(line: &str) -> Vec<StreamEvent> {
1042        CodexRunner.stream_events(&serde_json::from_str(line).unwrap())
1043    }
1044
1045    #[test]
1046    fn message_and_reasoning() {
1047        let msg = r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"Fixing."}}"#;
1048        assert_eq!(events(msg), vec![StreamEvent::Text("Fixing.".into())]);
1049        let think =
1050            r#"{"type":"item.completed","item":{"id":"i1","type":"reasoning","text":"**Plan**"}}"#;
1051        assert_eq!(
1052            events(think),
1053            vec![StreamEvent::Thinking("**Plan**".into())]
1054        );
1055    }
1056
1057    #[test]
1058    fn command_unwraps_shell_and_carries_result() {
1059        let line = r#"{"type":"item.completed","item":{"id":"i2","type":"command_execution","command":"/bin/zsh -lc 'git status --short'","aggregated_output":"M a.rs\n","exit_code":1}}"#;
1060        assert_eq!(
1061            events(line),
1062            vec![
1063                StreamEvent::ToolCall {
1064                    id: "i2".into(),
1065                    name: "Bash".into(),
1066                    input: json!({"command": "git status --short"}),
1067                },
1068                StreamEvent::ToolResult {
1069                    id: "i2".into(),
1070                    name: "Bash".into(),
1071                    output: "M a.rs\n".into(),
1072                    is_error: true,
1073                },
1074            ]
1075        );
1076    }
1077
1078    #[test]
1079    fn command_unwraps_double_quoted_shell() {
1080        let line = r#"{"type":"item.completed","item":{"id":"i4","type":"command_execution","command":"/bin/zsh -lc \"rg --files .github\"","aggregated_output":"","exit_code":0}}"#;
1081        assert_eq!(
1082            events(line)[0],
1083            StreamEvent::ToolCall {
1084                id: "i4".into(),
1085                name: "Bash".into(),
1086                input: json!({"command": "rg --files .github"}),
1087            }
1088        );
1089    }
1090
1091    #[test]
1092    fn file_change_is_an_edit_per_path() {
1093        let line = r#"{"type":"item.completed","item":{"id":"i3","type":"file_change","changes":[{"path":"a.rs","kind":"update"},{"path":"b.rs","kind":"add"}]}}"#;
1094        let calls: Vec<String> = events(line)
1095            .into_iter()
1096            .map(|e| match e {
1097                StreamEvent::ToolCall { name, input, .. } => format!("{name} {}", input["path"]),
1098                other => format!("{other:?}"),
1099            })
1100            .collect();
1101        assert_eq!(calls, vec![r#"Edit "a.rs""#, r#"Edit "b.rs""#]);
1102    }
1103
1104    #[test]
1105    fn errors_and_turn_end() {
1106        assert_eq!(
1107            events(r#"{"type":"error","message":"Reconnecting... 2/5"}"#),
1108            vec![StreamEvent::Error("Reconnecting... 2/5".into())]
1109        );
1110        assert_eq!(
1111            events(r#"{"type":"turn.failed","error":{"message":"quota exceeded"}}"#),
1112            vec![StreamEvent::Error("quota exceeded".into())]
1113        );
1114        assert_eq!(
1115            events(r#"{"type":"turn.completed","usage":{}}"#),
1116            vec![StreamEvent::Done(String::new())]
1117        );
1118    }
1119
1120    #[test]
1121    fn started_items_are_dropped() {
1122        let started =
1123            r#"{"type":"item.started","item":{"type":"command_execution","command":"ls"}}"#;
1124        let dropped = events(started);
1125        assert!(dropped.is_empty(), "{dropped:?}");
1126        let dropped = events(r#"{"type":"thread.started","thread_id":"t"}"#);
1127        assert!(dropped.is_empty(), "{dropped:?}");
1128    }
1129}