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 duration_ms: None,
320 });
321 }
322 return Err(AgentError::InvalidResponse {
323 raw: trimmed.to_string(),
324 });
325 }
326
327 if let Some(err) = Self::detect_error(&events) {
329 return Err(err);
330 }
331
332 let agent_text =
334 Self::extract_agent_text(&events).ok_or_else(|| AgentError::InvalidResponse {
335 raw: trimmed.to_string(),
336 })?;
337
338 let response = match parser::parse(&agent_text) {
340 Ok(r) => r,
341 Err(_) => {
342 super::synthesize_response_from_text(&agent_text).ok_or_else(|| {
345 AgentError::InvalidResponse {
346 raw: agent_text.clone(),
347 }
348 })?
349 }
350 };
351
352 Ok(ParsedResponse {
353 response,
354 input_tokens: None,
355 output_tokens: None,
356 duration_ms: None,
357 })
358 }
359
360 fn classify_error(&self, exit_code: i32, stdout: &str, stderr: &str) -> AgentError {
361 let events = super::parse_ndjson(stdout);
363 if let Some(err) = Self::detect_error(&events) {
364 return err;
365 }
366
367 let combined = format!("{stdout}\n{stderr}");
369 super::patterns::classify_from_text(exit_code, &combined)
370 }
371
372 fn router_command(&self, prompt: &str, model: Option<&str>) -> anyhow::Result<Command> {
373 let mut cmd = Command::new("codex");
374 cmd.arg("exec").arg("--json");
375 if let Some(m) = model {
376 cmd.arg("--model").arg(m);
377 }
378 cmd.arg(prompt);
379 Ok(cmd)
380 }
381}
382
383fn extract_model_name(message: &str) -> Option<String> {
389 extract_quoted(message, '`').or_else(|| extract_quoted(message, '\''))
390}
391
392fn extract_quoted(text: &str, quote: char) -> Option<String> {
394 let start = text.find(quote)?;
395 let rest = &text[start + quote.len_utf8()..];
396 let end = rest.find(quote)?;
397 Some(rest[..end].to_string())
398}
399
400pub fn find_codex_result(ndjson: &str) -> Option<super::AgentResult> {
407 let events = super::parse_ndjson(ndjson.trim());
408 if events.is_empty() {
409 return None;
410 }
411
412 let is_error;
414 let error_text;
415 if let Some(err) = CodexRunner::detect_error(&events) {
416 is_error = true;
417 error_text = Some(err.to_string());
418 } else {
419 is_error = false;
420 error_text = None;
421 }
422
423 let result_text = if is_error {
424 error_text.unwrap_or_default()
425 } else {
426 CodexRunner::extract_agent_text(&events).unwrap_or_default()
427 };
428
429 if result_text.is_empty() && !is_error {
430 return None;
431 }
432
433 Some(super::AgentResult {
434 is_error,
435 result_text,
436 input_tokens: None,
437 output_tokens: None,
438 cost_usd: None,
439 duration_ms: None,
440 truncated_by_length: false,
441 })
442}
443
444#[cfg(test)]
445mod tests {
446 use std::path::PathBuf;
447
448 use super::*;
449
450 fn runner() -> CodexRunner {
451 CodexRunner
452 }
453
454 #[test]
455 fn parse_codex_ndjson_success() {
456 let raw = r#"{"type":"thread.started","thread_id":"t1"}
457{"type":"turn.started"}
458{"type":"item.completed","item":{"type":"agent_message","text":"{\"status\":\"done\",\"summary\":\"hello\",\"accomplished\":[],\"remaining\":[],\"files\":[]}"}}
459{"type":"turn.completed"}"#;
460
461 let parsed = runner().parse_response(raw).unwrap();
462 assert_eq!(parsed.response.status, "done");
463 assert_eq!(parsed.response.summary, "hello");
464 }
465
466 #[test]
467 fn parse_codex_ndjson_rate_limit() {
468 let raw = r#"{"type":"error","message":"You've hit your usage limit for this billing period"}
469{"type":"turn.failed","error":{"message":"You've hit your usage limit"}}"#;
470
471 let err = runner().parse_response(raw).unwrap_err();
472 assert!(matches!(err, AgentError::RateLimit { .. }));
473 }
474
475 #[test]
476 fn parse_codex_model_not_found() {
477 let raw = r#"{"type":"item.completed","item":{"type":"error","message":"Model metadata for `o3-mini` not found"}}
478{"type":"error","message":"{\"detail\":\"The 'o3-mini' model is not supported\"}"}
479{"type":"turn.failed","error":{"message":"Model not supported"}}"#;
480
481 let err = runner().parse_response(raw).unwrap_err();
482 assert!(matches!(err, AgentError::ModelUnavailable { .. }));
483 }
484
485 #[test]
490 fn parse_codex_plain_prose_completion_synthesizes_response() {
491 let raw = r#"{"type":"thread.started","thread_id":"t1"}
492{"type":"turn.started"}
493{"type":"item.completed","item":{"type":"agent_message","text":"Fixed the typo and corrected the deadline reference in the review."}}
494{"type":"turn.completed"}"#;
495
496 let parsed = runner()
497 .parse_response(raw)
498 .expect("plain-prose completion text must synthesize successfully");
499 assert_eq!(parsed.response.status, "done");
500 }
501
502 #[test]
503 fn parse_codex_empty_response() {
504 let err = runner().parse_response("").unwrap_err();
505 assert!(matches!(err, AgentError::InvalidResponse { .. }));
506 }
507
508 #[test]
509 fn extract_model_name_backtick() {
510 assert_eq!(
511 extract_model_name("Model metadata for `o3-mini` not found"),
512 Some("o3-mini".to_string())
513 );
514 }
515
516 #[test]
517 fn extract_model_name_single_quote() {
518 assert_eq!(
519 extract_model_name("The 'o3-mini' model is not supported"),
520 Some("o3-mini".to_string())
521 );
522 }
523
524 #[test]
525 fn parse_codex_multiple_agent_messages() {
526 let raw = r#"{"type":"thread.started","thread_id":"t1"}
527{"type":"turn.started"}
528{"type":"item.completed","item":{"type":"agent_message","text":"Working on it..."}}
529{"type":"item.completed","item":{"type":"command_execution","command":"echo hello"}}
530{"type":"item.completed","item":{"type":"agent_message","text":"{\"status\":\"done\",\"summary\":\"finished\",\"accomplished\":[\"did it\"],\"remaining\":[],\"files\":[\"a.rs\"]}"}}
531{"type":"turn.completed"}"#;
532
533 let parsed = runner().parse_response(raw).unwrap();
534 assert_eq!(parsed.response.status, "done");
537 }
538
539 #[test]
540 fn classify_error_codex_ndjson() {
541 let stdout = r#"{"type":"error","message":"You've hit your usage limit"}"#;
542 let err = runner().classify_error(1, stdout, "");
543 assert!(matches!(err, AgentError::RateLimit { .. }));
544 }
545
546 #[test]
547 fn build_command_codex() {
548 let perms = PermissionRules::default();
549 let cmd = runner().build_command(
550 Some("gpt-4o"),
551 "timeout 1800",
552 "/tmp/sys.txt",
553 "/tmp/msg.txt",
554 &perms,
555 );
556 assert!(cmd.contains("codex"));
557 assert!(cmd.contains("--model 'gpt-4o'"));
558 assert!(cmd.contains("exec"));
559 assert!(cmd.contains("--json -"));
560 assert!(
561 cmd.contains("--sandbox workspace-write"),
562 "default autonomous codex should use --sandbox workspace-write, got: {cmd}"
563 );
564 assert!(
565 cmd.contains(r#"-c 'approval_policy="never"'"#),
566 "default autonomous codex should use -c approval_policy=never, got: {cmd}"
567 );
568 assert!(
569 !cmd.contains("--ask-for-approval"),
570 "--ask-for-approval was removed in codex 0.133.0 and must not appear, got: {cmd}"
571 );
572 assert!(
573 !cmd.contains("--full-auto"),
574 "--full-auto is deprecated and must not appear in the command, got: {cmd}"
575 );
576 assert!(
581 cmd.contains("sandbox_workspace_write.network_access=true"),
582 "network_access override must be present, got: {cmd}"
583 );
584 let exec_pos = cmd.find("exec").expect("exec must appear in command");
585 let network_pos = cmd
586 .find("sandbox_workspace_write.network_access=true")
587 .expect("network_access override must appear in command");
588 assert!(
589 network_pos > exec_pos,
590 "sandbox_workspace_write.network_access=true must appear AFTER exec, got: {cmd}"
591 );
592 assert!(
599 !cmd.trim_start().starts_with("cat "),
600 "codex command must not start with a `cat |` pipe stage, got: {cmd}"
601 );
602 assert!(
603 cmd.contains("< <(cat \"/tmp/sys.txt\" \"/tmp/msg.txt\")"),
604 "sys/msg files must be fed via process substitution, got: {cmd}"
605 );
606 }
607
608 #[test]
609 fn build_command_codex_full_access() {
610 let perms = PermissionRules {
611 autonomous: true,
612 sandbox: SandboxLevel::FullAccess,
613 disallowed_tools: vec![],
614 allowed_tools: vec![],
615 allowed_edit_paths: vec![],
616 deny_read_only: false,
617 extra_writable_dirs: vec![],
618 };
619 let cmd = runner().build_command(None, "", "/tmp/sys.txt", "/tmp/msg.txt", &perms);
620 assert!(
621 cmd.contains("--dangerously-bypass-approvals-and-sandbox"),
622 "full access codex should use dangerously-bypass, got: {cmd}"
623 );
624 }
625
626 #[test]
627 fn build_command_codex_in_the_os_sandbox_turns_its_own_off() {
628 let perms = PermissionRules {
629 autonomous: true,
630 sandbox: SandboxLevel::Os,
631 disallowed_tools: vec![],
632 allowed_tools: vec![],
633 allowed_edit_paths: vec![],
634 deny_read_only: false,
635 extra_writable_dirs: vec![],
636 };
637 let cmd = runner().build_command(None, "", "/tmp/sys.txt", "/tmp/msg.txt", &perms);
638 assert!(
639 cmd.contains("--dangerously-bypass-approvals-and-sandbox"),
640 "codex cannot nest its Seatbelt in the OS sandbox, got: {cmd}"
641 );
642 }
643
644 #[test]
645 fn build_command_codex_extra_writable_dirs() {
646 let perms = PermissionRules {
650 autonomous: true,
651 sandbox: SandboxLevel::WorkspaceWrite,
652 disallowed_tools: vec![],
653 allowed_tools: vec![],
654 allowed_edit_paths: vec![],
655 deny_read_only: false,
656 extra_writable_dirs: vec![
657 PathBuf::from("/Users/gb/Projects/bean/.git"),
658 PathBuf::from("/Users/gb/Projects/bean/.git/worktrees/task-123"),
659 ],
660 };
661 let cmd = runner().build_command(None, "", "/tmp/sys.txt", "/tmp/msg.txt", &perms);
662 assert!(
663 cmd.contains("--add-dir"),
664 "should include --add-dir flag, got: {cmd}"
665 );
666 assert!(
667 cmd.contains("/Users/gb/Projects/bean/.git'"),
668 "should include git common dir path, got: {cmd}"
669 );
670 assert!(
671 cmd.contains("/Users/gb/Projects/bean/.git/worktrees/task-123"),
672 "should include git dir path, got: {cmd}"
673 );
674 assert_eq!(
675 cmd.matches("--add-dir ").count(),
676 2,
677 "should include two --add-dir flags, got: {cmd}"
678 );
679 assert!(
680 cmd.contains("--sandbox workspace-write"),
681 "autonomous codex with extra dirs should use --sandbox workspace-write, got: {cmd}"
682 );
683 assert!(
684 cmd.contains(r#"-c 'approval_policy="never"'"#),
685 "autonomous codex with extra dirs should use -c approval_policy=never, got: {cmd}"
686 );
687 assert!(
688 !cmd.contains("--ask-for-approval"),
689 "--ask-for-approval was removed in codex 0.133.0 and must not appear, got: {cmd}"
690 );
691 assert!(
692 !cmd.contains("--full-auto"),
693 "--full-auto is deprecated and must not appear, got: {cmd}"
694 );
695 assert!(
696 cmd.contains("exec") && cmd.contains("--json -"),
697 "should include exec and --json -, got: {cmd}"
698 );
699 let exec_pos = cmd.find("exec").expect("exec must appear in command");
700 let network_pos = cmd
701 .find("sandbox_workspace_write.network_access=true")
702 .expect("network_access override must appear in command");
703 assert!(
704 network_pos > exec_pos,
705 "sandbox_workspace_write.network_access=true must appear AFTER exec, got: {cmd}"
706 );
707 }
708
709 #[test]
710 fn build_command_codex_no_extra_writable_dirs() {
711 let perms = PermissionRules::default();
713 let cmd = runner().build_command(None, "", "/tmp/sys.txt", "/tmp/msg.txt", &perms);
714 assert!(
715 !cmd.contains("--add-dir"),
716 "should not include --add-dir when no extra dirs, got: {cmd}"
717 );
718 }
719
720 #[test]
723 fn fixture_codex_success() {
724 let raw = include_str!("../../../../tests/fixtures/codex_success.jsonl");
725 let parsed = runner().parse_response(raw).unwrap();
726 assert_eq!(parsed.response.status, "done");
727 assert!(parsed.response.summary.contains("Implemented"));
728 assert_eq!(parsed.response.accomplished.len(), 2);
729 assert_eq!(parsed.response.files, vec!["src/handler.rs"]);
730 }
731
732 #[test]
733 fn fixture_codex_rate_limit() {
734 let raw = include_str!("../../../../tests/fixtures/codex_rate_limit.jsonl");
735 let err = runner().parse_response(raw).unwrap_err();
736 assert!(matches!(err, AgentError::RateLimit { .. }), "got: {err:?}");
737 }
738
739 #[test]
742 fn classify_codex_stream_disconnected() {
743 let err = CodexRunner::classify_message("Reconnecting 3/5... stream disconnected");
744 assert!(
745 matches!(err, AgentError::NetworkError { .. }),
746 "expected NetworkError so fallback retries, got: {err:?}"
747 );
748 }
749
750 #[test]
751 fn classify_codex_websocket_error() {
752 let err = CodexRunner::classify_message("WebSocket connection closed unexpectedly");
753 assert!(
754 matches!(err, AgentError::NetworkError { .. }),
755 "expected NetworkError so fallback retries, got: {err:?}"
756 );
757 }
758
759 #[test]
760 fn classify_codex_event_stream_lagged() {
761 let err = CodexRunner::classify_message(
762 "in-process app-server event stream lagged; dropped 1 events",
763 );
764 assert!(
765 matches!(err, AgentError::NetworkError { .. }),
766 "expected NetworkError so fallback retries, got: {err:?}"
767 );
768 }
769
770 #[test]
771 fn classify_codex_econnreset() {
772 let err = CodexRunner::classify_message("ECONNRESET: connection reset by peer");
773 assert!(
774 matches!(err, AgentError::NetworkError { .. }),
775 "expected NetworkError so fallback retries, got: {err:?}"
776 );
777 }
778
779 #[test]
782 fn parse_codex_chatgpt_model_unsupported() {
783 let raw = r#"{"type":"error","message":"The 'gpt-4.1' model is not supported when using Codex with a ChatGPT account."}
785{"type":"turn.failed","error":{"message":"Model not supported"}}"#;
786
787 let err = runner().parse_response(raw).unwrap_err();
788 assert!(
789 matches!(err, AgentError::ModelUnavailable { .. }),
790 "got: {err:?}"
791 );
792 if let AgentError::ModelUnavailable { model, .. } = &err {
793 assert_eq!(model, "gpt-4.1");
794 }
795 }
796
797 #[test]
798 fn parse_codex_usage_limit_after_reconnect() {
799 let raw = r#"{"type":"error","message":"Reconnecting 2/5"}
802{"type":"error","message":"Falling back from WebSockets to HTTPS transport"}
803{"type":"error","message":"You've hit your usage limit. Upgrade to Pro at https://..."}
804{"type":"turn.failed","error":{"message":"You've hit your usage limit"}}"#;
805
806 let err = runner().parse_response(raw).unwrap_err();
807 assert!(
808 matches!(err, AgentError::RateLimit { .. }),
809 "expected RateLimit (most specific), got: {err:?}"
810 );
811 }
812
813 #[test]
814 fn classify_error_chatgpt_model_unsupported() {
815 let stdout = r#"{"type":"error","message":"The 'gpt-4.1' model is not supported when using Codex with a ChatGPT account."}
816{"type":"turn.failed","error":{"message":"Model not supported"}}"#;
817
818 let err = runner().classify_error(1, stdout, "");
819 assert!(
820 matches!(err, AgentError::ModelUnavailable { .. }),
821 "got: {err:?}"
822 );
823 }
824
825 #[test]
830 fn fixture_codex_usage_limit() {
831 let raw = include_str!("../../../../tests/fixtures/codex_usage_limit.jsonl");
832 let err = runner().parse_response(raw).unwrap_err();
833 assert!(
834 matches!(err, AgentError::RateLimit { .. }),
835 "expected RateLimit (priority over reconnect errors), got: {err:?}"
836 );
837 }
838
839 #[test]
841 fn fixture_codex_model_unsupported() {
842 let raw = include_str!("../../../../tests/fixtures/codex_model_unsupported.jsonl");
843 let err = runner().parse_response(raw).unwrap_err();
844 assert!(
845 matches!(err, AgentError::ModelUnavailable { .. }),
846 "expected ModelUnavailable, got: {err:?}"
847 );
848 if let AgentError::ModelUnavailable { model, .. } = &err {
849 assert_eq!(model, "gpt-4.1");
850 }
851 }
852
853 #[test]
857 fn extract_text_returns_agent_message() {
858 let raw = concat!(
859 r#"{"type":"thread.started","thread_id":"t1"}"#,
860 "\n",
861 r#"{"type":"turn.started"}"#,
862 "\n",
863 r#"{"type":"item.completed","item":{"type":"reasoning","text":"Thinking..."}}"#,
864 "\n",
865 r#"{"type":"item.completed","item":{"type":"agent_message","text":"{\"decision\":\"approve\",\"notes\":\"LGTM\",\"test_results\":\"pass\",\"issues\":[]}"}}"#,
866 "\n",
867 r#"{"type":"turn.completed"}"#,
868 );
869 let text = runner().extract_text(raw).unwrap();
870 assert_eq!(
871 text,
872 r#"{"decision":"approve","notes":"LGTM","test_results":"pass","issues":[]}"#
873 );
874 }
875
876 #[test]
878 fn extract_text_rate_limit_propagates() {
879 let raw = concat!(
880 r#"{"type":"thread.started","thread_id":"t1"}"#,
881 "\n",
882 r#"{"type":"error","message":"You've hit your usage limit. Upgrade to continue."}"#,
883 "\n",
884 r#"{"type":"turn.failed","error":{"message":"usage limit exceeded"}}"#,
885 );
886 let err = runner().extract_text(raw).unwrap_err();
887 assert!(
888 matches!(err, AgentError::RateLimit { .. }),
889 "expected RateLimit, got: {err:?}"
890 );
891 }
892
893 #[test]
895 fn extract_text_auth_error_propagates() {
896 let raw = concat!(
897 r#"{"type":"thread.started","thread_id":"t1"}"#,
898 "\n",
899 r#"{"type":"error","message":"401 Unauthorized: invalid api key"}"#,
900 "\n",
901 r#"{"type":"turn.failed","error":{"message":"auth failed"}}"#,
902 );
903 let err = runner().extract_text(raw).unwrap_err();
904 assert!(
905 matches!(err, AgentError::Auth { .. }),
906 "expected Auth, got: {err:?}"
907 );
908 }
909
910 #[test]
912 fn extract_text_empty_input_returns_empty_ok() {
913 let text = runner().extract_text("").unwrap();
914 assert_eq!(text, "");
915 }
916
917 #[test]
920 fn find_codex_result_success() {
921 let ndjson = concat!(
922 r#"{"type":"thread.started","thread_id":"t1"}"#,
923 "\n",
924 r#"{"type":"turn.started"}"#,
925 "\n",
926 r#"{"type":"item.completed","item":{"type":"agent_message","text":"{\"decision\":\"approve\",\"notes\":\"LGTM\"}"}}"#,
927 "\n",
928 r#"{"type":"turn.completed"}"#,
929 );
930 let result = find_codex_result(ndjson).expect("should find result");
931 assert!(!result.is_error);
932 assert!(result.result_text.contains("approve"));
933 }
934
935 #[test]
936 fn find_codex_result_error() {
937 let ndjson = concat!(
938 r#"{"type":"error","message":"You've hit your usage limit"}"#,
939 "\n",
940 r#"{"type":"turn.failed","error":{"message":"usage limit"}}"#,
941 );
942 let result = find_codex_result(ndjson).expect("should find error result");
943 assert!(result.is_error);
944 assert!(result.result_text.contains("rate limit"));
945 }
946
947 #[test]
948 fn find_codex_result_empty_returns_none() {
949 assert!(find_codex_result("").is_none());
950 assert!(find_codex_result(" ").is_none());
951 }
952
953 #[test]
954 fn find_codex_result_plain_text_returns_none() {
955 assert!(find_codex_result("just some plain text").is_none());
956 }
957
958 #[test]
959 fn find_codex_result_skips_reasoning_and_commands() {
960 let ndjson = concat!(
961 r#"{"type":"item.completed","item":{"type":"reasoning","text":"Thinking..."}}"#,
962 "\n",
963 r#"{"type":"item.completed","item":{"type":"command_execution","command":"cargo test"}}"#,
964 "\n",
965 r#"{"type":"item.completed","item":{"type":"agent_message","text":"All tests passed"}}"#,
966 );
967 let result = find_codex_result(ndjson).expect("should find result");
968 assert!(!result.is_error);
969 assert_eq!(result.result_text, "All tests passed");
970 }
971}
972
973static SHELL_WRAP: LazyLock<regex::Regex> = LazyLock::new(|| {
975 regex::Regex::new(r#"(?s)^\S*/(?:ba|z)?sh -l?c (?:'(.*)'|"(.*)")$"#)
976 .expect("shell wrap regex is valid")
977});
978
979fn stream_events(v: &serde_json::Value) -> Vec<StreamEvent> {
981 match json_str(v, "/type") {
982 "item.completed" => item_events(&v["item"]),
983 "turn.completed" => vec![StreamEvent::Done(String::new())],
984 "turn.failed" | "error" => vec![StreamEvent::Error(json_error_message(v))],
985 _ => vec![],
986 }
987}
988
989fn item_events(item: &serde_json::Value) -> Vec<StreamEvent> {
990 let id = json_str(item, "/id").to_string();
991 let call = |name: &str, input: serde_json::Value| StreamEvent::ToolCall {
992 id: id.clone(),
993 name: name.to_string(),
994 input,
995 };
996 match json_str(item, "/type") {
997 "agent_message" => vec![StreamEvent::Text(json_str(item, "/text").to_string())],
998 "reasoning" => vec![StreamEvent::Thinking(json_str(item, "/text").to_string())],
999 "command_execution" => {
1000 let command = json_str(item, "/command");
1001 let command = SHELL_WRAP
1002 .captures(command)
1003 .and_then(|c| c.get(1).or(c.get(2)))
1004 .map_or(command, |m| m.as_str());
1005 vec![
1006 call("Bash", serde_json::json!({ "command": command })),
1007 StreamEvent::ToolResult {
1008 id: id.clone(),
1009 name: "Bash".to_string(),
1010 output: json_str(item, "/aggregated_output").to_string(),
1011 is_error: item["exit_code"].as_i64().is_some_and(|c| c != 0),
1012 },
1013 ]
1014 }
1015 "file_change" => item["changes"]
1016 .as_array()
1017 .into_iter()
1018 .flatten()
1019 .map(|c| call("Edit", serde_json::json!({ "path": c["path"] })))
1020 .collect(),
1021 "mcp_tool_call" => {
1022 let name = format!("{}.{}", json_str(item, "/server"), json_str(item, "/tool"));
1023 vec![call(&name, item["arguments"].clone())]
1024 }
1025 "web_search" => vec![call(
1026 "Search",
1027 serde_json::json!({ "query": json_str(item, "/query") }),
1028 )],
1029 "error" => vec![StreamEvent::Error(json_str(item, "/message").to_string())],
1030 _ => vec![],
1031 }
1032}
1033
1034#[cfg(test)]
1035mod stream_tests {
1036 use super::*;
1037 use serde_json::json;
1038
1039 fn events(line: &str) -> Vec<StreamEvent> {
1040 CodexRunner.stream_events(&serde_json::from_str(line).unwrap())
1041 }
1042
1043 #[test]
1044 fn message_and_reasoning() {
1045 let msg = r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"Fixing."}}"#;
1046 assert_eq!(events(msg), vec![StreamEvent::Text("Fixing.".into())]);
1047 let think =
1048 r#"{"type":"item.completed","item":{"id":"i1","type":"reasoning","text":"**Plan**"}}"#;
1049 assert_eq!(
1050 events(think),
1051 vec![StreamEvent::Thinking("**Plan**".into())]
1052 );
1053 }
1054
1055 #[test]
1056 fn command_unwraps_shell_and_carries_result() {
1057 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}}"#;
1058 assert_eq!(
1059 events(line),
1060 vec![
1061 StreamEvent::ToolCall {
1062 id: "i2".into(),
1063 name: "Bash".into(),
1064 input: json!({"command": "git status --short"}),
1065 },
1066 StreamEvent::ToolResult {
1067 id: "i2".into(),
1068 name: "Bash".into(),
1069 output: "M a.rs\n".into(),
1070 is_error: true,
1071 },
1072 ]
1073 );
1074 }
1075
1076 #[test]
1077 fn command_unwraps_double_quoted_shell() {
1078 let line = r#"{"type":"item.completed","item":{"id":"i4","type":"command_execution","command":"/bin/zsh -lc \"rg --files .github\"","aggregated_output":"","exit_code":0}}"#;
1079 assert_eq!(
1080 events(line)[0],
1081 StreamEvent::ToolCall {
1082 id: "i4".into(),
1083 name: "Bash".into(),
1084 input: json!({"command": "rg --files .github"}),
1085 }
1086 );
1087 }
1088
1089 #[test]
1090 fn file_change_is_an_edit_per_path() {
1091 let line = r#"{"type":"item.completed","item":{"id":"i3","type":"file_change","changes":[{"path":"a.rs","kind":"update"},{"path":"b.rs","kind":"add"}]}}"#;
1092 let calls: Vec<String> = events(line)
1093 .into_iter()
1094 .map(|e| match e {
1095 StreamEvent::ToolCall { name, input, .. } => format!("{name} {}", input["path"]),
1096 other => format!("{other:?}"),
1097 })
1098 .collect();
1099 assert_eq!(calls, vec![r#"Edit "a.rs""#, r#"Edit "b.rs""#]);
1100 }
1101
1102 #[test]
1103 fn errors_and_turn_end() {
1104 assert_eq!(
1105 events(r#"{"type":"error","message":"Reconnecting... 2/5"}"#),
1106 vec![StreamEvent::Error("Reconnecting... 2/5".into())]
1107 );
1108 assert_eq!(
1109 events(r#"{"type":"turn.failed","error":{"message":"quota exceeded"}}"#),
1110 vec![StreamEvent::Error("quota exceeded".into())]
1111 );
1112 assert_eq!(
1113 events(r#"{"type":"turn.completed","usage":{}}"#),
1114 vec![StreamEvent::Done(String::new())]
1115 );
1116 }
1117
1118 #[test]
1119 fn started_items_are_dropped() {
1120 let started =
1121 r#"{"type":"item.started","item":{"type":"command_execution","command":"ls"}}"#;
1122 let dropped = events(started);
1123 assert!(dropped.is_empty(), "{dropped:?}");
1124 let dropped = events(r#"{"type":"thread.started","thread_id":"t"}"#);
1125 assert!(dropped.is_empty(), "{dropped:?}");
1126 }
1127}