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 | SandboxLevel::Os => {
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 | SandboxLevel::Os => "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_in_the_os_sandbox_turns_its_own_off() {
635        let perms = PermissionRules {
636            autonomous: true,
637            sandbox: SandboxLevel::Os,
638            disallowed_tools: vec![],
639            allowed_tools: vec![],
640            allowed_edit_paths: vec![],
641            deny_read_only: false,
642            extra_writable_dirs: vec![],
643        };
644        let cmd = runner().build_command(None, "", "/tmp/sys.txt", "/tmp/msg.txt", &perms);
645        assert!(
646            cmd.contains("--dangerously-bypass-approvals-and-sandbox"),
647            "codex cannot nest its Seatbelt in the OS sandbox, got: {cmd}"
648        );
649    }
650
651    #[test]
652    fn build_command_codex_extra_writable_dirs() {
653        // When git metadata dirs are outside the worktree sandbox root, the
654        // runner adds them to extra_writable_dirs and build_command must emit
655        // --add-dir flags so Codex can create index.lock there.
656        let perms = PermissionRules {
657            autonomous: true,
658            sandbox: SandboxLevel::WorkspaceWrite,
659            disallowed_tools: vec![],
660            allowed_tools: vec![],
661            allowed_edit_paths: vec![],
662            deny_read_only: false,
663            extra_writable_dirs: vec![
664                std::path::PathBuf::from("/Users/gb/Projects/bean/.git"),
665                std::path::PathBuf::from("/Users/gb/Projects/bean/.git/worktrees/task-123"),
666            ],
667        };
668        let cmd = runner().build_command(None, "", "/tmp/sys.txt", "/tmp/msg.txt", &perms);
669        assert!(
670            cmd.contains("--add-dir"),
671            "should include --add-dir flag, got: {cmd}"
672        );
673        assert!(
674            cmd.contains("/Users/gb/Projects/bean/.git'"),
675            "should include git common dir path, got: {cmd}"
676        );
677        assert!(
678            cmd.contains("/Users/gb/Projects/bean/.git/worktrees/task-123"),
679            "should include git dir path, got: {cmd}"
680        );
681        assert_eq!(
682            cmd.matches("--add-dir ").count(),
683            2,
684            "should include two --add-dir flags, got: {cmd}"
685        );
686        assert!(
687            cmd.contains("--sandbox workspace-write"),
688            "autonomous codex with extra dirs should use --sandbox workspace-write, got: {cmd}"
689        );
690        assert!(
691            cmd.contains(r#"-c 'approval_policy="never"'"#),
692            "autonomous codex with extra dirs should use -c approval_policy=never, got: {cmd}"
693        );
694        assert!(
695            !cmd.contains("--ask-for-approval"),
696            "--ask-for-approval was removed in codex 0.133.0 and must not appear, got: {cmd}"
697        );
698        assert!(
699            !cmd.contains("--full-auto"),
700            "--full-auto is deprecated and must not appear, got: {cmd}"
701        );
702        assert!(
703            cmd.contains("exec") && cmd.contains("--json -"),
704            "should include exec and --json -, got: {cmd}"
705        );
706        let exec_pos = cmd.find("exec").expect("exec must appear in command");
707        let network_pos = cmd
708            .find("sandbox_workspace_write.network_access=true")
709            .expect("network_access override must appear in command");
710        assert!(
711            network_pos > exec_pos,
712            "sandbox_workspace_write.network_access=true must appear AFTER exec, got: {cmd}"
713        );
714    }
715
716    #[test]
717    fn build_command_codex_no_extra_writable_dirs() {
718        // With no extra_writable_dirs, --add-dir must not appear in the command.
719        let perms = PermissionRules::default();
720        let cmd = runner().build_command(None, "", "/tmp/sys.txt", "/tmp/msg.txt", &perms);
721        assert!(
722            !cmd.contains("--add-dir"),
723            "should not include --add-dir when no extra dirs, got: {cmd}"
724        );
725    }
726
727    // ── Fixture-based tests ─────────────────────────────────────
728
729    #[test]
730    fn fixture_codex_success() {
731        let raw = include_str!("../../../../tests/fixtures/codex_success.jsonl");
732        let parsed = runner().parse_response(raw).unwrap();
733        assert_eq!(parsed.response.status, "done");
734        assert!(parsed.response.summary.contains("Implemented"));
735        assert_eq!(parsed.response.accomplished.len(), 2);
736        assert_eq!(parsed.response.files, vec!["src/handler.rs"]);
737    }
738
739    #[test]
740    fn fixture_codex_rate_limit() {
741        let raw = include_str!("../../../../tests/fixtures/codex_rate_limit.jsonl");
742        let err = runner().parse_response(raw).unwrap_err();
743        assert!(matches!(err, AgentError::RateLimit { .. }), "got: {err:?}");
744    }
745
746    // ── Connection / transient error tests ────────────────────────
747
748    #[test]
749    fn classify_codex_stream_disconnected() {
750        let err = runner().classify_message("Reconnecting 3/5... stream disconnected");
751        assert!(
752            matches!(err, AgentError::NetworkError { .. }),
753            "expected NetworkError so fallback retries, got: {err:?}"
754        );
755    }
756
757    #[test]
758    fn classify_codex_websocket_error() {
759        let err = runner().classify_message("WebSocket connection closed unexpectedly");
760        assert!(
761            matches!(err, AgentError::NetworkError { .. }),
762            "expected NetworkError so fallback retries, got: {err:?}"
763        );
764    }
765
766    #[test]
767    fn classify_codex_event_stream_lagged() {
768        let err = runner()
769            .classify_message("in-process app-server event stream lagged; dropped 1 events");
770        assert!(
771            matches!(err, AgentError::NetworkError { .. }),
772            "expected NetworkError so fallback retries, got: {err:?}"
773        );
774    }
775
776    #[test]
777    fn classify_codex_econnreset() {
778        let err = runner().classify_message("ECONNRESET: connection reset by peer");
779        assert!(
780            matches!(err, AgentError::NetworkError { .. }),
781            "expected NetworkError so fallback retries, got: {err:?}"
782        );
783    }
784
785    // ── Production error patterns ─────────────────────────────────
786
787    #[test]
788    fn parse_codex_chatgpt_model_unsupported() {
789        // Real error: "The 'gpt-4.1' model is not supported when using Codex with a ChatGPT account."
790        let raw = r#"{"type":"error","message":"The 'gpt-4.1' model is not supported when using Codex with a ChatGPT account."}
791{"type":"turn.failed","error":{"message":"Model not supported"}}"#;
792
793        let err = runner().parse_response(raw).unwrap_err();
794        assert!(
795            matches!(err, AgentError::ModelUnavailable { .. }),
796            "got: {err:?}"
797        );
798        if let AgentError::ModelUnavailable { model, .. } = &err {
799            assert_eq!(model, "gpt-4.1");
800        }
801    }
802
803    #[test]
804    fn parse_codex_usage_limit_after_reconnect() {
805        // Real failure: stream disconnects, then usage limit hit.
806        // detect_error scans ALL events and prefers RateLimit over AgentFailed.
807        let raw = r#"{"type":"error","message":"Reconnecting 2/5"}
808{"type":"error","message":"Falling back from WebSockets to HTTPS transport"}
809{"type":"error","message":"You've hit your usage limit. Upgrade to Pro at https://..."}
810{"type":"turn.failed","error":{"message":"You've hit your usage limit"}}"#;
811
812        let err = runner().parse_response(raw).unwrap_err();
813        assert!(
814            matches!(err, AgentError::RateLimit { .. }),
815            "expected RateLimit (most specific), got: {err:?}"
816        );
817    }
818
819    #[test]
820    fn classify_error_chatgpt_model_unsupported() {
821        let stdout = r#"{"type":"error","message":"The 'gpt-4.1' model is not supported when using Codex with a ChatGPT account."}
822{"type":"turn.failed","error":{"message":"Model not supported"}}"#;
823
824        let err = runner().classify_error(1, stdout, "");
825        assert!(
826            matches!(err, AgentError::ModelUnavailable { .. }),
827            "got: {err:?}"
828        );
829    }
830
831    // ── Real output.json fixture tests ───────────────────────────
832
833    /// Real failure: websocket disconnections followed by usage limit.
834    /// RateLimit must take priority over AgentFailed (reconnect errors).
835    #[test]
836    fn fixture_codex_usage_limit() {
837        let raw = include_str!("../../../../tests/fixtures/codex_usage_limit.jsonl");
838        let err = runner().parse_response(raw).unwrap_err();
839        assert!(
840            matches!(err, AgentError::RateLimit { .. }),
841            "expected RateLimit (priority over reconnect errors), got: {err:?}"
842        );
843    }
844
845    /// Real failure: unsupported model with ChatGPT account.
846    #[test]
847    fn fixture_codex_model_unsupported() {
848        let raw = include_str!("../../../../tests/fixtures/codex_model_unsupported.jsonl");
849        let err = runner().parse_response(raw).unwrap_err();
850        assert!(
851            matches!(err, AgentError::ModelUnavailable { .. }),
852            "expected ModelUnavailable, got: {err:?}"
853        );
854        if let AgentError::ModelUnavailable { model, .. } = &err {
855            assert_eq!(model, "gpt-4.1");
856        }
857    }
858
859    // ── extract_text ─────────────────────────────────────────────
860
861    /// Codex: extract_text returns the agent_message text for review parsing.
862    #[test]
863    fn extract_text_returns_agent_message() {
864        let raw = concat!(
865            r#"{"type":"thread.started","thread_id":"t1"}"#,
866            "\n",
867            r#"{"type":"turn.started"}"#,
868            "\n",
869            r#"{"type":"item.completed","item":{"type":"reasoning","text":"Thinking..."}}"#,
870            "\n",
871            r#"{"type":"item.completed","item":{"type":"agent_message","text":"{\"decision\":\"approve\",\"notes\":\"LGTM\",\"test_results\":\"pass\",\"issues\":[]}"}}"#,
872            "\n",
873            r#"{"type":"turn.completed"}"#,
874        );
875        let text = runner().extract_text(raw).unwrap();
876        assert_eq!(
877            text,
878            r#"{"decision":"approve","notes":"LGTM","test_results":"pass","issues":[]}"#
879        );
880    }
881
882    /// Codex: extract_text propagates RateLimit for terminal errors.
883    #[test]
884    fn extract_text_rate_limit_propagates() {
885        let raw = concat!(
886            r#"{"type":"thread.started","thread_id":"t1"}"#,
887            "\n",
888            r#"{"type":"error","message":"You've hit your usage limit. Upgrade to continue."}"#,
889            "\n",
890            r#"{"type":"turn.failed","error":{"message":"usage limit exceeded"}}"#,
891        );
892        let err = runner().extract_text(raw).unwrap_err();
893        assert!(
894            matches!(err, AgentError::RateLimit { .. }),
895            "expected RateLimit, got: {err:?}"
896        );
897    }
898
899    /// Codex: extract_text propagates Auth error.
900    #[test]
901    fn extract_text_auth_error_propagates() {
902        let raw = concat!(
903            r#"{"type":"thread.started","thread_id":"t1"}"#,
904            "\n",
905            r#"{"type":"error","message":"401 Unauthorized: invalid api key"}"#,
906            "\n",
907            r#"{"type":"turn.failed","error":{"message":"auth failed"}}"#,
908        );
909        let err = runner().extract_text(raw).unwrap_err();
910        assert!(
911            matches!(err, AgentError::Auth { .. }),
912            "expected Auth, got: {err:?}"
913        );
914    }
915
916    /// Codex: extract_text on empty input returns empty string (not an error).
917    #[test]
918    fn extract_text_empty_input_returns_empty_ok() {
919        let text = runner().extract_text("").unwrap();
920        assert!(text.is_empty());
921    }
922
923    // ── find_codex_result ───────────────────────────────────────
924
925    #[test]
926    fn find_codex_result_success() {
927        let ndjson = concat!(
928            r#"{"type":"thread.started","thread_id":"t1"}"#,
929            "\n",
930            r#"{"type":"turn.started"}"#,
931            "\n",
932            r#"{"type":"item.completed","item":{"type":"agent_message","text":"{\"decision\":\"approve\",\"notes\":\"LGTM\"}"}}"#,
933            "\n",
934            r#"{"type":"turn.completed"}"#,
935        );
936        let result = find_codex_result(ndjson).expect("should find result");
937        assert!(!result.is_error);
938        assert!(result.result_text.contains("approve"));
939    }
940
941    #[test]
942    fn find_codex_result_error() {
943        let ndjson = concat!(
944            r#"{"type":"error","message":"You've hit your usage limit"}"#,
945            "\n",
946            r#"{"type":"turn.failed","error":{"message":"usage limit"}}"#,
947        );
948        let result = find_codex_result(ndjson).expect("should find error result");
949        assert!(result.is_error);
950        assert!(result.result_text.contains("rate limit"));
951    }
952
953    #[test]
954    fn find_codex_result_empty_returns_none() {
955        assert!(find_codex_result("").is_none());
956        assert!(find_codex_result("   ").is_none());
957    }
958
959    #[test]
960    fn find_codex_result_plain_text_returns_none() {
961        assert!(find_codex_result("just some plain text").is_none());
962    }
963
964    #[test]
965    fn find_codex_result_skips_reasoning_and_commands() {
966        let ndjson = concat!(
967            r#"{"type":"item.completed","item":{"type":"reasoning","text":"Thinking..."}}"#,
968            "\n",
969            r#"{"type":"item.completed","item":{"type":"command_execution","command":"cargo test"}}"#,
970            "\n",
971            r#"{"type":"item.completed","item":{"type":"agent_message","text":"All tests passed"}}"#,
972        );
973        let result = find_codex_result(ndjson).expect("should find result");
974        assert!(!result.is_error);
975        assert_eq!(result.result_text, "All tests passed");
976    }
977}
978
979// Codex wraps every command as `/bin/zsh -lc '<command>'` or `-lc "<command>"`
980static SHELL_WRAP: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
981    regex::Regex::new(r#"(?s)^\S*/(?:ba|z)?sh -l?c (?:'(.*)'|"(.*)")$"#)
982        .expect("shell wrap regex is valid")
983});
984
985/// Codex `--json`: each finished step is an `item.completed` event
986fn stream_events(v: &serde_json::Value) -> Vec<StreamEvent> {
987    match json_str(v, "/type") {
988        "item.completed" => item_events(&v["item"]),
989        "turn.completed" => vec![StreamEvent::Done(String::new())],
990        "turn.failed" | "error" => vec![StreamEvent::Error(json_error_message(v))],
991        _ => vec![],
992    }
993}
994
995fn item_events(item: &serde_json::Value) -> Vec<StreamEvent> {
996    let id = json_str(item, "/id").to_string();
997    let call = |name: &str, input: serde_json::Value| StreamEvent::ToolCall {
998        id: id.clone(),
999        name: name.to_string(),
1000        input,
1001    };
1002    match json_str(item, "/type") {
1003        "agent_message" => vec![StreamEvent::Text(json_str(item, "/text").to_string())],
1004        "reasoning" => vec![StreamEvent::Thinking(json_str(item, "/text").to_string())],
1005        "command_execution" => {
1006            let command = json_str(item, "/command");
1007            let command = SHELL_WRAP
1008                .captures(command)
1009                .and_then(|c| c.get(1).or(c.get(2)))
1010                .map_or(command, |m| m.as_str());
1011            vec![
1012                call("Bash", serde_json::json!({ "command": command })),
1013                StreamEvent::ToolResult {
1014                    id: id.clone(),
1015                    name: "Bash".to_string(),
1016                    output: json_str(item, "/aggregated_output").to_string(),
1017                    is_error: item["exit_code"].as_i64().is_some_and(|c| c != 0),
1018                },
1019            ]
1020        }
1021        "file_change" => item["changes"]
1022            .as_array()
1023            .into_iter()
1024            .flatten()
1025            .map(|c| call("Edit", serde_json::json!({ "path": c["path"] })))
1026            .collect(),
1027        "mcp_tool_call" => {
1028            let name = format!("{}.{}", json_str(item, "/server"), json_str(item, "/tool"));
1029            vec![call(&name, item["arguments"].clone())]
1030        }
1031        "web_search" => vec![call(
1032            "Search",
1033            serde_json::json!({ "query": json_str(item, "/query") }),
1034        )],
1035        "error" => vec![StreamEvent::Error(json_str(item, "/message").to_string())],
1036        _ => vec![],
1037    }
1038}
1039
1040#[cfg(test)]
1041mod stream_tests {
1042    use super::*;
1043    use serde_json::json;
1044
1045    fn events(line: &str) -> Vec<StreamEvent> {
1046        CodexRunner.stream_events(&serde_json::from_str(line).unwrap())
1047    }
1048
1049    #[test]
1050    fn message_and_reasoning() {
1051        let msg = r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"Fixing."}}"#;
1052        assert_eq!(events(msg), vec![StreamEvent::Text("Fixing.".into())]);
1053        let think =
1054            r#"{"type":"item.completed","item":{"id":"i1","type":"reasoning","text":"**Plan**"}}"#;
1055        assert_eq!(
1056            events(think),
1057            vec![StreamEvent::Thinking("**Plan**".into())]
1058        );
1059    }
1060
1061    #[test]
1062    fn command_unwraps_shell_and_carries_result() {
1063        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}}"#;
1064        assert_eq!(
1065            events(line),
1066            vec![
1067                StreamEvent::ToolCall {
1068                    id: "i2".into(),
1069                    name: "Bash".into(),
1070                    input: json!({"command": "git status --short"}),
1071                },
1072                StreamEvent::ToolResult {
1073                    id: "i2".into(),
1074                    name: "Bash".into(),
1075                    output: "M a.rs\n".into(),
1076                    is_error: true,
1077                },
1078            ]
1079        );
1080    }
1081
1082    #[test]
1083    fn command_unwraps_double_quoted_shell() {
1084        let line = r#"{"type":"item.completed","item":{"id":"i4","type":"command_execution","command":"/bin/zsh -lc \"rg --files .github\"","aggregated_output":"","exit_code":0}}"#;
1085        assert_eq!(
1086            events(line)[0],
1087            StreamEvent::ToolCall {
1088                id: "i4".into(),
1089                name: "Bash".into(),
1090                input: json!({"command": "rg --files .github"}),
1091            }
1092        );
1093    }
1094
1095    #[test]
1096    fn file_change_is_an_edit_per_path() {
1097        let line = r#"{"type":"item.completed","item":{"id":"i3","type":"file_change","changes":[{"path":"a.rs","kind":"update"},{"path":"b.rs","kind":"add"}]}}"#;
1098        let calls: Vec<String> = events(line)
1099            .into_iter()
1100            .map(|e| match e {
1101                StreamEvent::ToolCall { name, input, .. } => format!("{name} {}", input["path"]),
1102                other => format!("{other:?}"),
1103            })
1104            .collect();
1105        assert_eq!(calls, vec![r#"Edit "a.rs""#, r#"Edit "b.rs""#]);
1106    }
1107
1108    #[test]
1109    fn errors_and_turn_end() {
1110        assert_eq!(
1111            events(r#"{"type":"error","message":"Reconnecting... 2/5"}"#),
1112            vec![StreamEvent::Error("Reconnecting... 2/5".into())]
1113        );
1114        assert_eq!(
1115            events(r#"{"type":"turn.failed","error":{"message":"quota exceeded"}}"#),
1116            vec![StreamEvent::Error("quota exceeded".into())]
1117        );
1118        assert_eq!(
1119            events(r#"{"type":"turn.completed","usage":{}}"#),
1120            vec![StreamEvent::Done(String::new())]
1121        );
1122    }
1123
1124    #[test]
1125    fn started_items_are_dropped() {
1126        let started =
1127            r#"{"type":"item.started","item":{"type":"command_execution","command":"ls"}}"#;
1128        assert!(events(started).is_empty());
1129        assert!(events(r#"{"type":"thread.started","thread_id":"t"}"#).is_empty());
1130    }
1131}