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