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