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                    duration_ms: None,
320                });
321            }
322            return Err(AgentError::InvalidResponse {
323                raw: trimmed.to_string(),
324            });
325        }
326
327        // Check for errors first
328        if let Some(err) = Self::detect_error(&events) {
329            return Err(err);
330        }
331
332        // Extract agent response text
333        let agent_text =
334            Self::extract_agent_text(&events).ok_or_else(|| AgentError::InvalidResponse {
335                raw: trimmed.to_string(),
336            })?;
337
338        // Parse the agent text through our standard parser
339        let response = match parser::parse(&agent_text) {
340            Ok(r) => r,
341            Err(_) => {
342                // Structured parse failed — try plain-text synthesis before
343                // giving up, same rescue path claude.rs uses (issue #3467).
344                super::synthesize_response_from_text(&agent_text).ok_or_else(|| {
345                    AgentError::InvalidResponse {
346                        raw: agent_text.clone(),
347                    }
348                })?
349            }
350        };
351
352        Ok(ParsedResponse {
353            response,
354            input_tokens: None,
355            output_tokens: None,
356            duration_ms: None,
357        })
358    }
359
360    fn classify_error(&self, exit_code: i32, stdout: &str, stderr: &str) -> AgentError {
361        // Try parsing NDJSON events from stdout for structured errors
362        let events = super::parse_ndjson(stdout);
363        if let Some(err) = Self::detect_error(&events) {
364            return err;
365        }
366
367        // Fall back to pattern matching
368        let combined = format!("{stdout}\n{stderr}");
369        super::patterns::classify_from_text(exit_code, &combined)
370    }
371
372    fn router_command(&self, prompt: &str, model: Option<&str>) -> anyhow::Result<Command> {
373        let mut cmd = Command::new("codex");
374        cmd.arg("exec").arg("--json");
375        if let Some(m) = model {
376            cmd.arg("--model").arg(m);
377        }
378        cmd.arg(prompt);
379        Ok(cmd)
380    }
381}
382
383/// Try to extract a model name from an error message.
384///
385/// Looks for text quoted with backticks or single quotes:
386/// - "Model metadata for `o3-mini` not found"
387/// - "The 'o3-mini' model is not supported"
388fn extract_model_name(message: &str) -> Option<String> {
389    extract_quoted(message, '`').or_else(|| extract_quoted(message, '\''))
390}
391
392/// Extract text between matching `quote` characters.
393fn extract_quoted(text: &str, quote: char) -> Option<String> {
394    let start = text.find(quote)?;
395    let rest = &text[start + quote.len_utf8()..];
396    let end = rest.find(quote)?;
397    Some(rest[..end].to_string())
398}
399
400/// Extract a structured `AgentResult` from Codex NDJSON output.
401///
402/// Finds `item.completed` events with `type:agent_message` for the result text.
403/// Checks for `turn.failed`, `error`, and `item.completed` error events.
404///
405/// Returns `None` only if no parseable NDJSON events are found.
406pub fn find_codex_result(ndjson: &str) -> Option<super::AgentResult> {
407    let events = super::parse_ndjson(ndjson.trim());
408    if events.is_empty() {
409        return None;
410    }
411
412    // Check for errors first
413    let is_error;
414    let error_text;
415    if let Some(err) = CodexRunner::detect_error(&events) {
416        is_error = true;
417        error_text = Some(err.to_string());
418    } else {
419        is_error = false;
420        error_text = None;
421    }
422
423    let result_text = if is_error {
424        error_text.unwrap_or_default()
425    } else {
426        CodexRunner::extract_agent_text(&events).unwrap_or_default()
427    };
428
429    if result_text.is_empty() && !is_error {
430        return None;
431    }
432
433    Some(super::AgentResult {
434        is_error,
435        result_text,
436        input_tokens: None,
437        output_tokens: None,
438        cost_usd: None,
439        duration_ms: None,
440        truncated_by_length: false,
441    })
442}
443
444#[cfg(test)]
445mod tests {
446    use std::path::PathBuf;
447
448    use super::*;
449
450    fn runner() -> CodexRunner {
451        CodexRunner
452    }
453
454    #[test]
455    fn parse_codex_ndjson_success() {
456        let raw = r#"{"type":"thread.started","thread_id":"t1"}
457{"type":"turn.started"}
458{"type":"item.completed","item":{"type":"agent_message","text":"{\"status\":\"done\",\"summary\":\"hello\",\"accomplished\":[],\"remaining\":[],\"files\":[]}"}}
459{"type":"turn.completed"}"#;
460
461        let parsed = runner().parse_response(raw).unwrap();
462        assert_eq!(parsed.response.status, "done");
463        assert_eq!(parsed.response.summary, "hello");
464    }
465
466    #[test]
467    fn parse_codex_ndjson_rate_limit() {
468        let raw = r#"{"type":"error","message":"You've hit your usage limit for this billing period"}
469{"type":"turn.failed","error":{"message":"You've hit your usage limit"}}"#;
470
471        let err = runner().parse_response(raw).unwrap_err();
472        assert!(matches!(err, AgentError::RateLimit { .. }));
473    }
474
475    #[test]
476    fn parse_codex_model_not_found() {
477        let raw = r#"{"type":"item.completed","item":{"type":"error","message":"Model metadata for `o3-mini` not found"}}
478{"type":"error","message":"{\"detail\":\"The 'o3-mini' model is not supported\"}"}
479{"type":"turn.failed","error":{"message":"Model not supported"}}"#;
480
481        let err = runner().parse_response(raw).unwrap_err();
482        assert!(matches!(err, AgentError::ModelUnavailable { .. }));
483    }
484
485    /// Regression test for issue #3467: when codex's agent_message text is a
486    /// plain-prose completion summary (not JSON), parser::parse fails but
487    /// synthesize_response_from_text must rescue it instead of returning
488    /// InvalidResponse, mirroring the fallback claude.rs already has.
489    #[test]
490    fn parse_codex_plain_prose_completion_synthesizes_response() {
491        let raw = r#"{"type":"thread.started","thread_id":"t1"}
492{"type":"turn.started"}
493{"type":"item.completed","item":{"type":"agent_message","text":"Fixed the typo and corrected the deadline reference in the review."}}
494{"type":"turn.completed"}"#;
495
496        let parsed = runner()
497            .parse_response(raw)
498            .expect("plain-prose completion text must synthesize successfully");
499        assert_eq!(parsed.response.status, "done");
500    }
501
502    #[test]
503    fn parse_codex_empty_response() {
504        let err = runner().parse_response("").unwrap_err();
505        assert!(matches!(err, AgentError::InvalidResponse { .. }));
506    }
507
508    #[test]
509    fn extract_model_name_backtick() {
510        assert_eq!(
511            extract_model_name("Model metadata for `o3-mini` not found"),
512            Some("o3-mini".to_string())
513        );
514    }
515
516    #[test]
517    fn extract_model_name_single_quote() {
518        assert_eq!(
519            extract_model_name("The 'o3-mini' model is not supported"),
520            Some("o3-mini".to_string())
521        );
522    }
523
524    #[test]
525    fn parse_codex_multiple_agent_messages() {
526        let raw = r#"{"type":"thread.started","thread_id":"t1"}
527{"type":"turn.started"}
528{"type":"item.completed","item":{"type":"agent_message","text":"Working on it..."}}
529{"type":"item.completed","item":{"type":"command_execution","command":"echo hello"}}
530{"type":"item.completed","item":{"type":"agent_message","text":"{\"status\":\"done\",\"summary\":\"finished\",\"accomplished\":[\"did it\"],\"remaining\":[],\"files\":[\"a.rs\"]}"}}
531{"type":"turn.completed"}"#;
532
533        let parsed = runner().parse_response(raw).unwrap();
534        // The parser concatenates all agent messages; the last valid JSON wins
535        // Actually, parser::parse will try the concatenated text and find the JSON
536        assert_eq!(parsed.response.status, "done");
537    }
538
539    #[test]
540    fn classify_error_codex_ndjson() {
541        let stdout = r#"{"type":"error","message":"You've hit your usage limit"}"#;
542        let err = runner().classify_error(1, stdout, "");
543        assert!(matches!(err, AgentError::RateLimit { .. }));
544    }
545
546    #[test]
547    fn build_command_codex() {
548        let perms = PermissionRules::default();
549        let cmd = runner().build_command(
550            Some("gpt-4o"),
551            "timeout 1800",
552            "/tmp/sys.txt",
553            "/tmp/msg.txt",
554            &perms,
555        );
556        assert!(cmd.contains("codex"));
557        assert!(cmd.contains("--model 'gpt-4o'"));
558        assert!(cmd.contains("exec"));
559        assert!(cmd.contains("--json -"));
560        assert!(
561            cmd.contains("--sandbox workspace-write"),
562            "default autonomous codex should use --sandbox workspace-write, got: {cmd}"
563        );
564        assert!(
565            cmd.contains(r#"-c 'approval_policy="never"'"#),
566            "default autonomous codex should use -c approval_policy=never, got: {cmd}"
567        );
568        assert!(
569            !cmd.contains("--ask-for-approval"),
570            "--ask-for-approval was removed in codex 0.133.0 and must not appear, got: {cmd}"
571        );
572        assert!(
573            !cmd.contains("--full-auto"),
574            "--full-auto is deprecated and must not appear in the command, got: {cmd}"
575        );
576        // Regression: network_access and shell_environment_policy overrides must appear
577        // AFTER `exec`, not before it.  Global -c flags placed before `exec` are parsed
578        // by the top-level codex CLI and do NOT propagate into the exec subcommand's
579        // sandbox initialisation — the network would be blocked silently if misplaced.
580        assert!(
581            cmd.contains("sandbox_workspace_write.network_access=true"),
582            "network_access override must be present, got: {cmd}"
583        );
584        let exec_pos = cmd.find("exec").expect("exec must appear in command");
585        let network_pos = cmd
586            .find("sandbox_workspace_write.network_access=true")
587            .expect("network_access override must appear in command");
588        assert!(
589            network_pos > exec_pos,
590            "sandbox_workspace_write.network_access=true must appear AFTER exec, got: {cmd}"
591        );
592        // Regression: the command must not start with a `cat sys msg |` pipe
593        // stage. `build_runner_script` captures the agent's exit status via
594        // `PIPESTATUS[0]` of `{agent_cmd} | tee ...` — a leading `cat |` would
595        // make PIPESTATUS[0] always report cat's (always-zero) exit code
596        // instead of codex's real exit code. sys/msg files must be fed via
597        // input redirection instead.
598        assert!(
599            !cmd.trim_start().starts_with("cat "),
600            "codex command must not start with a `cat |` pipe stage, got: {cmd}"
601        );
602        assert!(
603            cmd.contains("< <(cat \"/tmp/sys.txt\" \"/tmp/msg.txt\")"),
604            "sys/msg files must be fed via process substitution, got: {cmd}"
605        );
606    }
607
608    #[test]
609    fn build_command_codex_full_access() {
610        let perms = PermissionRules {
611            autonomous: true,
612            sandbox: SandboxLevel::FullAccess,
613            disallowed_tools: vec![],
614            allowed_tools: vec![],
615            allowed_edit_paths: vec![],
616            deny_read_only: false,
617            extra_writable_dirs: vec![],
618        };
619        let cmd = runner().build_command(None, "", "/tmp/sys.txt", "/tmp/msg.txt", &perms);
620        assert!(
621            cmd.contains("--dangerously-bypass-approvals-and-sandbox"),
622            "full access codex should use dangerously-bypass, got: {cmd}"
623        );
624    }
625
626    #[test]
627    fn build_command_codex_in_the_os_sandbox_turns_its_own_off() {
628        let perms = PermissionRules {
629            autonomous: true,
630            sandbox: SandboxLevel::Os,
631            disallowed_tools: vec![],
632            allowed_tools: vec![],
633            allowed_edit_paths: vec![],
634            deny_read_only: false,
635            extra_writable_dirs: vec![],
636        };
637        let cmd = runner().build_command(None, "", "/tmp/sys.txt", "/tmp/msg.txt", &perms);
638        assert!(
639            cmd.contains("--dangerously-bypass-approvals-and-sandbox"),
640            "codex cannot nest its Seatbelt in the OS sandbox, got: {cmd}"
641        );
642    }
643
644    #[test]
645    fn build_command_codex_extra_writable_dirs() {
646        // When git metadata dirs are outside the worktree sandbox root, the
647        // runner adds them to extra_writable_dirs and build_command must emit
648        // --add-dir flags so Codex can create index.lock there.
649        let perms = PermissionRules {
650            autonomous: true,
651            sandbox: SandboxLevel::WorkspaceWrite,
652            disallowed_tools: vec![],
653            allowed_tools: vec![],
654            allowed_edit_paths: vec![],
655            deny_read_only: false,
656            extra_writable_dirs: vec![
657                PathBuf::from("/Users/gb/Projects/bean/.git"),
658                PathBuf::from("/Users/gb/Projects/bean/.git/worktrees/task-123"),
659            ],
660        };
661        let cmd = runner().build_command(None, "", "/tmp/sys.txt", "/tmp/msg.txt", &perms);
662        assert!(
663            cmd.contains("--add-dir"),
664            "should include --add-dir flag, got: {cmd}"
665        );
666        assert!(
667            cmd.contains("/Users/gb/Projects/bean/.git'"),
668            "should include git common dir path, got: {cmd}"
669        );
670        assert!(
671            cmd.contains("/Users/gb/Projects/bean/.git/worktrees/task-123"),
672            "should include git dir path, got: {cmd}"
673        );
674        assert_eq!(
675            cmd.matches("--add-dir ").count(),
676            2,
677            "should include two --add-dir flags, got: {cmd}"
678        );
679        assert!(
680            cmd.contains("--sandbox workspace-write"),
681            "autonomous codex with extra dirs should use --sandbox workspace-write, got: {cmd}"
682        );
683        assert!(
684            cmd.contains(r#"-c 'approval_policy="never"'"#),
685            "autonomous codex with extra dirs should use -c approval_policy=never, got: {cmd}"
686        );
687        assert!(
688            !cmd.contains("--ask-for-approval"),
689            "--ask-for-approval was removed in codex 0.133.0 and must not appear, got: {cmd}"
690        );
691        assert!(
692            !cmd.contains("--full-auto"),
693            "--full-auto is deprecated and must not appear, got: {cmd}"
694        );
695        assert!(
696            cmd.contains("exec") && cmd.contains("--json -"),
697            "should include exec and --json -, got: {cmd}"
698        );
699        let exec_pos = cmd.find("exec").expect("exec must appear in command");
700        let network_pos = cmd
701            .find("sandbox_workspace_write.network_access=true")
702            .expect("network_access override must appear in command");
703        assert!(
704            network_pos > exec_pos,
705            "sandbox_workspace_write.network_access=true must appear AFTER exec, got: {cmd}"
706        );
707    }
708
709    #[test]
710    fn build_command_codex_no_extra_writable_dirs() {
711        // With no extra_writable_dirs, --add-dir must not appear in the command.
712        let perms = PermissionRules::default();
713        let cmd = runner().build_command(None, "", "/tmp/sys.txt", "/tmp/msg.txt", &perms);
714        assert!(
715            !cmd.contains("--add-dir"),
716            "should not include --add-dir when no extra dirs, got: {cmd}"
717        );
718    }
719
720    // ── Fixture-based tests ─────────────────────────────────────
721
722    #[test]
723    fn fixture_codex_success() {
724        let raw = include_str!("../../../../tests/fixtures/codex_success.jsonl");
725        let parsed = runner().parse_response(raw).unwrap();
726        assert_eq!(parsed.response.status, "done");
727        assert!(parsed.response.summary.contains("Implemented"));
728        assert_eq!(parsed.response.accomplished.len(), 2);
729        assert_eq!(parsed.response.files, vec!["src/handler.rs"]);
730    }
731
732    #[test]
733    fn fixture_codex_rate_limit() {
734        let raw = include_str!("../../../../tests/fixtures/codex_rate_limit.jsonl");
735        let err = runner().parse_response(raw).unwrap_err();
736        assert!(matches!(err, AgentError::RateLimit { .. }), "got: {err:?}");
737    }
738
739    // ── Connection / transient error tests ────────────────────────
740
741    #[test]
742    fn classify_codex_stream_disconnected() {
743        let err = CodexRunner::classify_message("Reconnecting 3/5... stream disconnected");
744        assert!(
745            matches!(err, AgentError::NetworkError { .. }),
746            "expected NetworkError so fallback retries, got: {err:?}"
747        );
748    }
749
750    #[test]
751    fn classify_codex_websocket_error() {
752        let err = CodexRunner::classify_message("WebSocket connection closed unexpectedly");
753        assert!(
754            matches!(err, AgentError::NetworkError { .. }),
755            "expected NetworkError so fallback retries, got: {err:?}"
756        );
757    }
758
759    #[test]
760    fn classify_codex_event_stream_lagged() {
761        let err = CodexRunner::classify_message(
762            "in-process app-server event stream lagged; dropped 1 events",
763        );
764        assert!(
765            matches!(err, AgentError::NetworkError { .. }),
766            "expected NetworkError so fallback retries, got: {err:?}"
767        );
768    }
769
770    #[test]
771    fn classify_codex_econnreset() {
772        let err = CodexRunner::classify_message("ECONNRESET: connection reset by peer");
773        assert!(
774            matches!(err, AgentError::NetworkError { .. }),
775            "expected NetworkError so fallback retries, got: {err:?}"
776        );
777    }
778
779    // ── Production error patterns ─────────────────────────────────
780
781    #[test]
782    fn parse_codex_chatgpt_model_unsupported() {
783        // Real error: "The 'gpt-4.1' model is not supported when using Codex with a ChatGPT account."
784        let raw = r#"{"type":"error","message":"The 'gpt-4.1' model is not supported when using Codex with a ChatGPT account."}
785{"type":"turn.failed","error":{"message":"Model not supported"}}"#;
786
787        let err = runner().parse_response(raw).unwrap_err();
788        assert!(
789            matches!(err, AgentError::ModelUnavailable { .. }),
790            "got: {err:?}"
791        );
792        if let AgentError::ModelUnavailable { model, .. } = &err {
793            assert_eq!(model, "gpt-4.1");
794        }
795    }
796
797    #[test]
798    fn parse_codex_usage_limit_after_reconnect() {
799        // Real failure: stream disconnects, then usage limit hit.
800        // detect_error scans ALL events and prefers RateLimit over AgentFailed.
801        let raw = r#"{"type":"error","message":"Reconnecting 2/5"}
802{"type":"error","message":"Falling back from WebSockets to HTTPS transport"}
803{"type":"error","message":"You've hit your usage limit. Upgrade to Pro at https://..."}
804{"type":"turn.failed","error":{"message":"You've hit your usage limit"}}"#;
805
806        let err = runner().parse_response(raw).unwrap_err();
807        assert!(
808            matches!(err, AgentError::RateLimit { .. }),
809            "expected RateLimit (most specific), got: {err:?}"
810        );
811    }
812
813    #[test]
814    fn classify_error_chatgpt_model_unsupported() {
815        let stdout = r#"{"type":"error","message":"The 'gpt-4.1' model is not supported when using Codex with a ChatGPT account."}
816{"type":"turn.failed","error":{"message":"Model not supported"}}"#;
817
818        let err = runner().classify_error(1, stdout, "");
819        assert!(
820            matches!(err, AgentError::ModelUnavailable { .. }),
821            "got: {err:?}"
822        );
823    }
824
825    // ── Real output.json fixture tests ───────────────────────────
826
827    /// Real failure: websocket disconnections followed by usage limit.
828    /// RateLimit must take priority over AgentFailed (reconnect errors).
829    #[test]
830    fn fixture_codex_usage_limit() {
831        let raw = include_str!("../../../../tests/fixtures/codex_usage_limit.jsonl");
832        let err = runner().parse_response(raw).unwrap_err();
833        assert!(
834            matches!(err, AgentError::RateLimit { .. }),
835            "expected RateLimit (priority over reconnect errors), got: {err:?}"
836        );
837    }
838
839    /// Real failure: unsupported model with ChatGPT account.
840    #[test]
841    fn fixture_codex_model_unsupported() {
842        let raw = include_str!("../../../../tests/fixtures/codex_model_unsupported.jsonl");
843        let err = runner().parse_response(raw).unwrap_err();
844        assert!(
845            matches!(err, AgentError::ModelUnavailable { .. }),
846            "expected ModelUnavailable, got: {err:?}"
847        );
848        if let AgentError::ModelUnavailable { model, .. } = &err {
849            assert_eq!(model, "gpt-4.1");
850        }
851    }
852
853    // ── extract_text ─────────────────────────────────────────────
854
855    /// Codex: extract_text returns the agent_message text for review parsing.
856    #[test]
857    fn extract_text_returns_agent_message() {
858        let raw = concat!(
859            r#"{"type":"thread.started","thread_id":"t1"}"#,
860            "\n",
861            r#"{"type":"turn.started"}"#,
862            "\n",
863            r#"{"type":"item.completed","item":{"type":"reasoning","text":"Thinking..."}}"#,
864            "\n",
865            r#"{"type":"item.completed","item":{"type":"agent_message","text":"{\"decision\":\"approve\",\"notes\":\"LGTM\",\"test_results\":\"pass\",\"issues\":[]}"}}"#,
866            "\n",
867            r#"{"type":"turn.completed"}"#,
868        );
869        let text = runner().extract_text(raw).unwrap();
870        assert_eq!(
871            text,
872            r#"{"decision":"approve","notes":"LGTM","test_results":"pass","issues":[]}"#
873        );
874    }
875
876    /// Codex: extract_text propagates RateLimit for terminal errors.
877    #[test]
878    fn extract_text_rate_limit_propagates() {
879        let raw = concat!(
880            r#"{"type":"thread.started","thread_id":"t1"}"#,
881            "\n",
882            r#"{"type":"error","message":"You've hit your usage limit. Upgrade to continue."}"#,
883            "\n",
884            r#"{"type":"turn.failed","error":{"message":"usage limit exceeded"}}"#,
885        );
886        let err = runner().extract_text(raw).unwrap_err();
887        assert!(
888            matches!(err, AgentError::RateLimit { .. }),
889            "expected RateLimit, got: {err:?}"
890        );
891    }
892
893    /// Codex: extract_text propagates Auth error.
894    #[test]
895    fn extract_text_auth_error_propagates() {
896        let raw = concat!(
897            r#"{"type":"thread.started","thread_id":"t1"}"#,
898            "\n",
899            r#"{"type":"error","message":"401 Unauthorized: invalid api key"}"#,
900            "\n",
901            r#"{"type":"turn.failed","error":{"message":"auth failed"}}"#,
902        );
903        let err = runner().extract_text(raw).unwrap_err();
904        assert!(
905            matches!(err, AgentError::Auth { .. }),
906            "expected Auth, got: {err:?}"
907        );
908    }
909
910    /// Codex: extract_text on empty input returns empty string (not an error).
911    #[test]
912    fn extract_text_empty_input_returns_empty_ok() {
913        let text = runner().extract_text("").unwrap();
914        assert_eq!(text, "");
915    }
916
917    // ── find_codex_result ───────────────────────────────────────
918
919    #[test]
920    fn find_codex_result_success() {
921        let ndjson = concat!(
922            r#"{"type":"thread.started","thread_id":"t1"}"#,
923            "\n",
924            r#"{"type":"turn.started"}"#,
925            "\n",
926            r#"{"type":"item.completed","item":{"type":"agent_message","text":"{\"decision\":\"approve\",\"notes\":\"LGTM\"}"}}"#,
927            "\n",
928            r#"{"type":"turn.completed"}"#,
929        );
930        let result = find_codex_result(ndjson).expect("should find result");
931        assert!(!result.is_error);
932        assert!(result.result_text.contains("approve"));
933    }
934
935    #[test]
936    fn find_codex_result_error() {
937        let ndjson = concat!(
938            r#"{"type":"error","message":"You've hit your usage limit"}"#,
939            "\n",
940            r#"{"type":"turn.failed","error":{"message":"usage limit"}}"#,
941        );
942        let result = find_codex_result(ndjson).expect("should find error result");
943        assert!(result.is_error);
944        assert!(result.result_text.contains("rate limit"));
945    }
946
947    #[test]
948    fn find_codex_result_empty_returns_none() {
949        assert!(find_codex_result("").is_none());
950        assert!(find_codex_result("   ").is_none());
951    }
952
953    #[test]
954    fn find_codex_result_plain_text_returns_none() {
955        assert!(find_codex_result("just some plain text").is_none());
956    }
957
958    #[test]
959    fn find_codex_result_skips_reasoning_and_commands() {
960        let ndjson = concat!(
961            r#"{"type":"item.completed","item":{"type":"reasoning","text":"Thinking..."}}"#,
962            "\n",
963            r#"{"type":"item.completed","item":{"type":"command_execution","command":"cargo test"}}"#,
964            "\n",
965            r#"{"type":"item.completed","item":{"type":"agent_message","text":"All tests passed"}}"#,
966        );
967        let result = find_codex_result(ndjson).expect("should find result");
968        assert!(!result.is_error);
969        assert_eq!(result.result_text, "All tests passed");
970    }
971}
972
973// Codex wraps every command as `/bin/zsh -lc '<command>'` or `-lc "<command>"`
974static SHELL_WRAP: LazyLock<regex::Regex> = LazyLock::new(|| {
975    regex::Regex::new(r#"(?s)^\S*/(?:ba|z)?sh -l?c (?:'(.*)'|"(.*)")$"#)
976        .expect("shell wrap regex is valid")
977});
978
979/// Codex `--json`: each finished step is an `item.completed` event
980fn stream_events(v: &serde_json::Value) -> Vec<StreamEvent> {
981    match json_str(v, "/type") {
982        "item.completed" => item_events(&v["item"]),
983        "turn.completed" => vec![StreamEvent::Done(String::new())],
984        "turn.failed" | "error" => vec![StreamEvent::Error(json_error_message(v))],
985        _ => vec![],
986    }
987}
988
989fn item_events(item: &serde_json::Value) -> Vec<StreamEvent> {
990    let id = json_str(item, "/id").to_string();
991    let call = |name: &str, input: serde_json::Value| StreamEvent::ToolCall {
992        id: id.clone(),
993        name: name.to_string(),
994        input,
995    };
996    match json_str(item, "/type") {
997        "agent_message" => vec![StreamEvent::Text(json_str(item, "/text").to_string())],
998        "reasoning" => vec![StreamEvent::Thinking(json_str(item, "/text").to_string())],
999        "command_execution" => {
1000            let command = json_str(item, "/command");
1001            let command = SHELL_WRAP
1002                .captures(command)
1003                .and_then(|c| c.get(1).or(c.get(2)))
1004                .map_or(command, |m| m.as_str());
1005            vec![
1006                call("Bash", serde_json::json!({ "command": command })),
1007                StreamEvent::ToolResult {
1008                    id: id.clone(),
1009                    name: "Bash".to_string(),
1010                    output: json_str(item, "/aggregated_output").to_string(),
1011                    is_error: item["exit_code"].as_i64().is_some_and(|c| c != 0),
1012                },
1013            ]
1014        }
1015        "file_change" => item["changes"]
1016            .as_array()
1017            .into_iter()
1018            .flatten()
1019            .map(|c| call("Edit", serde_json::json!({ "path": c["path"] })))
1020            .collect(),
1021        "mcp_tool_call" => {
1022            let name = format!("{}.{}", json_str(item, "/server"), json_str(item, "/tool"));
1023            vec![call(&name, item["arguments"].clone())]
1024        }
1025        "web_search" => vec![call(
1026            "Search",
1027            serde_json::json!({ "query": json_str(item, "/query") }),
1028        )],
1029        "error" => vec![StreamEvent::Error(json_str(item, "/message").to_string())],
1030        _ => vec![],
1031    }
1032}
1033
1034#[cfg(test)]
1035mod stream_tests {
1036    use super::*;
1037    use serde_json::json;
1038
1039    fn events(line: &str) -> Vec<StreamEvent> {
1040        CodexRunner.stream_events(&serde_json::from_str(line).unwrap())
1041    }
1042
1043    #[test]
1044    fn message_and_reasoning() {
1045        let msg = r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"Fixing."}}"#;
1046        assert_eq!(events(msg), vec![StreamEvent::Text("Fixing.".into())]);
1047        let think =
1048            r#"{"type":"item.completed","item":{"id":"i1","type":"reasoning","text":"**Plan**"}}"#;
1049        assert_eq!(
1050            events(think),
1051            vec![StreamEvent::Thinking("**Plan**".into())]
1052        );
1053    }
1054
1055    #[test]
1056    fn command_unwraps_shell_and_carries_result() {
1057        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}}"#;
1058        assert_eq!(
1059            events(line),
1060            vec![
1061                StreamEvent::ToolCall {
1062                    id: "i2".into(),
1063                    name: "Bash".into(),
1064                    input: json!({"command": "git status --short"}),
1065                },
1066                StreamEvent::ToolResult {
1067                    id: "i2".into(),
1068                    name: "Bash".into(),
1069                    output: "M a.rs\n".into(),
1070                    is_error: true,
1071                },
1072            ]
1073        );
1074    }
1075
1076    #[test]
1077    fn command_unwraps_double_quoted_shell() {
1078        let line = r#"{"type":"item.completed","item":{"id":"i4","type":"command_execution","command":"/bin/zsh -lc \"rg --files .github\"","aggregated_output":"","exit_code":0}}"#;
1079        assert_eq!(
1080            events(line)[0],
1081            StreamEvent::ToolCall {
1082                id: "i4".into(),
1083                name: "Bash".into(),
1084                input: json!({"command": "rg --files .github"}),
1085            }
1086        );
1087    }
1088
1089    #[test]
1090    fn file_change_is_an_edit_per_path() {
1091        let line = r#"{"type":"item.completed","item":{"id":"i3","type":"file_change","changes":[{"path":"a.rs","kind":"update"},{"path":"b.rs","kind":"add"}]}}"#;
1092        let calls: Vec<String> = events(line)
1093            .into_iter()
1094            .map(|e| match e {
1095                StreamEvent::ToolCall { name, input, .. } => format!("{name} {}", input["path"]),
1096                other => format!("{other:?}"),
1097            })
1098            .collect();
1099        assert_eq!(calls, vec![r#"Edit "a.rs""#, r#"Edit "b.rs""#]);
1100    }
1101
1102    #[test]
1103    fn errors_and_turn_end() {
1104        assert_eq!(
1105            events(r#"{"type":"error","message":"Reconnecting... 2/5"}"#),
1106            vec![StreamEvent::Error("Reconnecting... 2/5".into())]
1107        );
1108        assert_eq!(
1109            events(r#"{"type":"turn.failed","error":{"message":"quota exceeded"}}"#),
1110            vec![StreamEvent::Error("quota exceeded".into())]
1111        );
1112        assert_eq!(
1113            events(r#"{"type":"turn.completed","usage":{}}"#),
1114            vec![StreamEvent::Done(String::new())]
1115        );
1116    }
1117
1118    #[test]
1119    fn started_items_are_dropped() {
1120        let started =
1121            r#"{"type":"item.started","item":{"type":"command_execution","command":"ls"}}"#;
1122        let dropped = events(started);
1123        assert!(dropped.is_empty(), "{dropped:?}");
1124        let dropped = events(r#"{"type":"thread.started","thread_id":"t"}"#);
1125        assert!(dropped.is_empty(), "{dropped:?}");
1126    }
1127}