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