1use super::{
48 json_error_message, json_str, AgentError, AgentRunner, ParsedResponse, PermissionRules,
49 SandboxLevel, StreamEvent,
50};
51use crate::parser;
52
53pub struct CodexRunner;
55
56impl CodexRunner {
57 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 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 texts.pop()
95 }
96
97 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 fn classify_message(&self, message: &str) -> AgentError {
145 let lower = message.to_lowercase();
146
147 if let Some(e) = super::patterns::detect_rate_limit(message) {
149 return e;
150 }
151
152 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 if let Some(e) = super::patterns::detect_auth_error(message) {
167 return e;
168 }
169
170 if let Some(e) = super::patterns::detect_context_overflow(message) {
172 return e;
173 }
174
175 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 if let Some(err) = self.detect_error(&events) {
223 return Err(err);
224 }
225
226 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 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 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 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 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 if let Some(err) = self.detect_error(&events) {
332 return Err(err);
333 }
334
335 let agent_text =
337 self.extract_agent_text(&events)
338 .ok_or_else(|| AgentError::InvalidResponse {
339 raw: trimmed.to_string(),
340 })?;
341
342 let response = match parser::parse(&agent_text) {
344 Ok(r) => r,
345 Err(_) => {
346 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 let events = super::parse_ndjson(stdout);
367 if let Some(err) = self.detect_error(&events) {
368 return err;
369 }
370
371 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
391fn extract_model_name(message: &str) -> Option<String> {
397 extract_quoted(message, '`').or_else(|| extract_quoted(message, '\''))
398}
399
400fn 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
408pub 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 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 #[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 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 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 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 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 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 #[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 #[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 #[test]
770 fn parse_codex_chatgpt_model_unsupported() {
771 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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
961static 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
967fn 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}