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