diff --git a/codex-rs/exec/src/cli.rs b/codex-rs/exec/src/cli.rs index 2b5932dd5..9e37769f1 100644 --- a/codex-rs/exec/src/cli.rs +++ b/codex-rs/exec/src/cli.rs @@ -256,60 +256,5 @@ pub enum Color { } #[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - - #[test] - fn resume_parses_prompt_after_global_flags() { - const PROMPT: &str = "echo resume-with-global-flags-after-subcommand"; - let cli = Cli::parse_from([ - "codex-exec", - "resume", - "--last", - "--json", - "--model", - "gpt-5.2-codex", - "--dangerously-bypass-approvals-and-sandbox", - "--skip-git-repo-check", - "--ephemeral", - PROMPT, - ]); - - assert!(cli.ephemeral); - let Some(Command::Resume(args)) = cli.command else { - panic!("expected resume command"); - }; - let effective_prompt = args.prompt.clone().or_else(|| { - if args.last { - args.session_id.clone() - } else { - None - } - }); - assert_eq!(effective_prompt.as_deref(), Some(PROMPT)); - } - - #[test] - fn resume_accepts_output_last_message_flag_after_subcommand() { - const PROMPT: &str = "echo resume-with-output-file"; - let cli = Cli::parse_from([ - "codex-exec", - "resume", - "session-123", - "-o", - "/tmp/resume-output.md", - PROMPT, - ]); - - assert_eq!( - cli.last_message_file, - Some(PathBuf::from("/tmp/resume-output.md")) - ); - let Some(Command::Resume(args)) = cli.command else { - panic!("expected resume command"); - }; - assert_eq!(args.session_id.as_deref(), Some("session-123")); - assert_eq!(args.prompt.as_deref(), Some(PROMPT)); - } -} +#[path = "cli_tests.rs"] +mod tests; diff --git a/codex-rs/exec/src/cli_tests.rs b/codex-rs/exec/src/cli_tests.rs new file mode 100644 index 000000000..bf4884f24 --- /dev/null +++ b/codex-rs/exec/src/cli_tests.rs @@ -0,0 +1,55 @@ +use super::*; +use pretty_assertions::assert_eq; + +#[test] +fn resume_parses_prompt_after_global_flags() { + const PROMPT: &str = "echo resume-with-global-flags-after-subcommand"; + let cli = Cli::parse_from([ + "codex-exec", + "resume", + "--last", + "--json", + "--model", + "gpt-5.2-codex", + "--dangerously-bypass-approvals-and-sandbox", + "--skip-git-repo-check", + "--ephemeral", + PROMPT, + ]); + + assert!(cli.ephemeral); + let Some(Command::Resume(args)) = cli.command else { + panic!("expected resume command"); + }; + let effective_prompt = args.prompt.clone().or_else(|| { + if args.last { + args.session_id.clone() + } else { + None + } + }); + assert_eq!(effective_prompt.as_deref(), Some(PROMPT)); +} + +#[test] +fn resume_accepts_output_last_message_flag_after_subcommand() { + const PROMPT: &str = "echo resume-with-output-file"; + let cli = Cli::parse_from([ + "codex-exec", + "resume", + "session-123", + "-o", + "/tmp/resume-output.md", + PROMPT, + ]); + + assert_eq!( + cli.last_message_file, + Some(PathBuf::from("/tmp/resume-output.md")) + ); + let Some(Command::Resume(args)) = cli.command else { + panic!("expected resume command"); + }; + assert_eq!(args.session_id.as_deref(), Some("session-123")); + assert_eq!(args.prompt.as_deref(), Some(PROMPT)); +} diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index df1cb31a4..2c390b215 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -564,350 +564,5 @@ fn should_print_final_message_to_tty( } #[cfg(test)] -mod tests { - use codex_app_server_protocol::ThreadItem; - use codex_app_server_protocol::Turn; - use codex_app_server_protocol::TurnStatus; - use owo_colors::Style; - - use super::EventProcessorWithHumanOutput; - use super::final_message_from_turn_items; - use super::reasoning_text; - use super::should_print_final_message_to_stdout; - use super::should_print_final_message_to_tty; - use crate::event_processor::EventProcessor; - use codex_app_server_protocol::ServerNotification; - - #[test] - fn suppresses_final_stdout_message_when_both_streams_are_terminals() { - assert!(!should_print_final_message_to_stdout( - Some("hello"), - /*stdout_is_terminal*/ true, - /*stderr_is_terminal*/ true - )); - } - - #[test] - fn prints_final_stdout_message_when_stdout_is_not_terminal() { - assert!(should_print_final_message_to_stdout( - Some("hello"), - /*stdout_is_terminal*/ false, - /*stderr_is_terminal*/ true - )); - } - - #[test] - fn prints_final_stdout_message_when_stderr_is_not_terminal() { - assert!(should_print_final_message_to_stdout( - Some("hello"), - /*stdout_is_terminal*/ true, - /*stderr_is_terminal*/ false - )); - } - - #[test] - fn suppresses_final_stdout_message_when_missing() { - assert!(!should_print_final_message_to_stdout( - /*final_message*/ None, /*stdout_is_terminal*/ false, - /*stderr_is_terminal*/ false - )); - } - - #[test] - fn prints_final_tty_message_when_not_yet_rendered() { - assert!(should_print_final_message_to_tty( - Some("hello"), - /*final_message_rendered*/ false, - /*stdout_is_terminal*/ true, - /*stderr_is_terminal*/ true - )); - } - - #[test] - fn suppresses_final_tty_message_when_already_rendered() { - assert!(!should_print_final_message_to_tty( - Some("hello"), - /*final_message_rendered*/ true, - /*stdout_is_terminal*/ true, - /*stderr_is_terminal*/ true - )); - } - - #[test] - fn reasoning_text_prefers_summary_when_raw_reasoning_is_hidden() { - let text = reasoning_text( - &["summary".to_string()], - &["raw".to_string()], - /*show_raw_agent_reasoning*/ false, - ); - - assert_eq!(text.as_deref(), Some("summary")); - } - - #[test] - fn reasoning_text_uses_raw_content_when_enabled() { - let text = reasoning_text( - &["summary".to_string()], - &["raw".to_string()], - /*show_raw_agent_reasoning*/ true, - ); - - assert_eq!(text.as_deref(), Some("raw")); - } - - #[test] - fn final_message_from_turn_items_uses_latest_agent_message() { - let message = final_message_from_turn_items(&[ - ThreadItem::AgentMessage { - id: "msg-1".to_string(), - text: "first".to_string(), - phase: None, - memory_citation: None, - }, - ThreadItem::Plan { - id: "plan-1".to_string(), - text: "plan".to_string(), - }, - ThreadItem::AgentMessage { - id: "msg-2".to_string(), - text: "second".to_string(), - phase: None, - memory_citation: None, - }, - ]); - - assert_eq!(message.as_deref(), Some("second")); - } - - #[test] - fn final_message_from_turn_items_falls_back_to_latest_plan() { - let message = final_message_from_turn_items(&[ - ThreadItem::Reasoning { - id: "reasoning-1".to_string(), - summary: vec!["inspect".to_string()], - content: Vec::new(), - }, - ThreadItem::Plan { - id: "plan-1".to_string(), - text: "first plan".to_string(), - }, - ThreadItem::Plan { - id: "plan-2".to_string(), - text: "final plan".to_string(), - }, - ]); - - assert_eq!(message.as_deref(), Some("final plan")); - } - - #[test] - fn turn_completed_recovers_final_message_from_turn_items() { - let mut processor = EventProcessorWithHumanOutput { - bold: Style::new(), - cyan: Style::new(), - dimmed: Style::new(), - green: Style::new(), - italic: Style::new(), - magenta: Style::new(), - red: Style::new(), - yellow: Style::new(), - show_agent_reasoning: true, - show_raw_agent_reasoning: false, - last_message_path: None, - final_message: None, - final_message_rendered: false, - emit_final_message_on_shutdown: false, - last_total_token_usage: None, - }; - - let status = processor.process_server_notification(ServerNotification::TurnCompleted( - codex_app_server_protocol::TurnCompletedNotification { - thread_id: "thread-1".to_string(), - turn: Turn { - id: "turn-1".to_string(), - items: vec![ThreadItem::AgentMessage { - id: "msg-1".to_string(), - text: "final answer".to_string(), - phase: None, - memory_citation: None, - }], - status: TurnStatus::Completed, - error: None, - }, - }, - )); - - assert_eq!( - status, - crate::event_processor::CodexStatus::InitiateShutdown - ); - assert_eq!(processor.final_message.as_deref(), Some("final answer")); - } - - #[test] - fn turn_completed_overwrites_stale_final_message_from_turn_items() { - let mut processor = EventProcessorWithHumanOutput { - bold: Style::new(), - cyan: Style::new(), - dimmed: Style::new(), - green: Style::new(), - italic: Style::new(), - magenta: Style::new(), - red: Style::new(), - yellow: Style::new(), - show_agent_reasoning: true, - show_raw_agent_reasoning: false, - last_message_path: None, - final_message: Some("stale answer".to_string()), - final_message_rendered: true, - emit_final_message_on_shutdown: false, - last_total_token_usage: None, - }; - - let status = processor.process_server_notification(ServerNotification::TurnCompleted( - codex_app_server_protocol::TurnCompletedNotification { - thread_id: "thread-1".to_string(), - turn: Turn { - id: "turn-1".to_string(), - items: vec![ThreadItem::AgentMessage { - id: "msg-1".to_string(), - text: "final answer".to_string(), - phase: None, - memory_citation: None, - }], - status: TurnStatus::Completed, - error: None, - }, - }, - )); - - assert_eq!( - status, - crate::event_processor::CodexStatus::InitiateShutdown - ); - assert_eq!(processor.final_message.as_deref(), Some("final answer")); - assert!(!processor.final_message_rendered); - } - - #[test] - fn turn_completed_preserves_streamed_final_message_when_turn_items_are_empty() { - let mut processor = EventProcessorWithHumanOutput { - bold: Style::new(), - cyan: Style::new(), - dimmed: Style::new(), - green: Style::new(), - italic: Style::new(), - magenta: Style::new(), - red: Style::new(), - yellow: Style::new(), - show_agent_reasoning: true, - show_raw_agent_reasoning: false, - last_message_path: None, - final_message: Some("streamed answer".to_string()), - final_message_rendered: false, - emit_final_message_on_shutdown: false, - last_total_token_usage: None, - }; - - let status = processor.process_server_notification(ServerNotification::TurnCompleted( - codex_app_server_protocol::TurnCompletedNotification { - thread_id: "thread-1".to_string(), - turn: Turn { - id: "turn-1".to_string(), - items: Vec::new(), - status: TurnStatus::Completed, - error: None, - }, - }, - )); - - assert_eq!( - status, - crate::event_processor::CodexStatus::InitiateShutdown - ); - assert_eq!(processor.final_message.as_deref(), Some("streamed answer")); - assert!(processor.emit_final_message_on_shutdown); - } - - #[test] - fn turn_failed_clears_stale_final_message() { - let mut processor = EventProcessorWithHumanOutput { - bold: Style::new(), - cyan: Style::new(), - dimmed: Style::new(), - green: Style::new(), - italic: Style::new(), - magenta: Style::new(), - red: Style::new(), - yellow: Style::new(), - show_agent_reasoning: true, - show_raw_agent_reasoning: false, - last_message_path: None, - final_message: Some("partial answer".to_string()), - final_message_rendered: true, - emit_final_message_on_shutdown: true, - last_total_token_usage: None, - }; - - let status = processor.process_server_notification(ServerNotification::TurnCompleted( - codex_app_server_protocol::TurnCompletedNotification { - thread_id: "thread-1".to_string(), - turn: Turn { - id: "turn-1".to_string(), - items: Vec::new(), - status: TurnStatus::Failed, - error: None, - }, - }, - )); - - assert_eq!( - status, - crate::event_processor::CodexStatus::InitiateShutdown - ); - assert_eq!(processor.final_message, None); - assert!(!processor.final_message_rendered); - assert!(!processor.emit_final_message_on_shutdown); - } - - #[test] - fn turn_interrupted_clears_stale_final_message() { - let mut processor = EventProcessorWithHumanOutput { - bold: Style::new(), - cyan: Style::new(), - dimmed: Style::new(), - green: Style::new(), - italic: Style::new(), - magenta: Style::new(), - red: Style::new(), - yellow: Style::new(), - show_agent_reasoning: true, - show_raw_agent_reasoning: false, - last_message_path: None, - final_message: Some("partial answer".to_string()), - final_message_rendered: true, - emit_final_message_on_shutdown: true, - last_total_token_usage: None, - }; - - let status = processor.process_server_notification(ServerNotification::TurnCompleted( - codex_app_server_protocol::TurnCompletedNotification { - thread_id: "thread-1".to_string(), - turn: Turn { - id: "turn-1".to_string(), - items: Vec::new(), - status: TurnStatus::Interrupted, - error: None, - }, - }, - )); - - assert_eq!( - status, - crate::event_processor::CodexStatus::InitiateShutdown - ); - assert_eq!(processor.final_message, None); - assert!(!processor.final_message_rendered); - assert!(!processor.emit_final_message_on_shutdown); - } -} +#[path = "event_processor_with_human_output_tests.rs"] +mod tests; diff --git a/codex-rs/exec/src/event_processor_with_human_output_tests.rs b/codex-rs/exec/src/event_processor_with_human_output_tests.rs new file mode 100644 index 000000000..2b625dd56 --- /dev/null +++ b/codex-rs/exec/src/event_processor_with_human_output_tests.rs @@ -0,0 +1,346 @@ +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnStatus; +use owo_colors::Style; +use pretty_assertions::assert_eq; + +use super::EventProcessorWithHumanOutput; +use super::final_message_from_turn_items; +use super::reasoning_text; +use super::should_print_final_message_to_stdout; +use super::should_print_final_message_to_tty; +use crate::event_processor::EventProcessor; + +#[test] +fn suppresses_final_stdout_message_when_both_streams_are_terminals() { + assert!(!should_print_final_message_to_stdout( + Some("hello"), + /*stdout_is_terminal*/ true, + /*stderr_is_terminal*/ true + )); +} + +#[test] +fn prints_final_stdout_message_when_stdout_is_not_terminal() { + assert!(should_print_final_message_to_stdout( + Some("hello"), + /*stdout_is_terminal*/ false, + /*stderr_is_terminal*/ true + )); +} + +#[test] +fn prints_final_stdout_message_when_stderr_is_not_terminal() { + assert!(should_print_final_message_to_stdout( + Some("hello"), + /*stdout_is_terminal*/ true, + /*stderr_is_terminal*/ false + )); +} + +#[test] +fn suppresses_final_stdout_message_when_missing() { + assert!(!should_print_final_message_to_stdout( + /*final_message*/ None, /*stdout_is_terminal*/ false, + /*stderr_is_terminal*/ false + )); +} + +#[test] +fn prints_final_tty_message_when_not_yet_rendered() { + assert!(should_print_final_message_to_tty( + Some("hello"), + /*final_message_rendered*/ false, + /*stdout_is_terminal*/ true, + /*stderr_is_terminal*/ true + )); +} + +#[test] +fn suppresses_final_tty_message_when_already_rendered() { + assert!(!should_print_final_message_to_tty( + Some("hello"), + /*final_message_rendered*/ true, + /*stdout_is_terminal*/ true, + /*stderr_is_terminal*/ true + )); +} + +#[test] +fn reasoning_text_prefers_summary_when_raw_reasoning_is_hidden() { + let text = reasoning_text( + &["summary".to_string()], + &["raw".to_string()], + /*show_raw_agent_reasoning*/ false, + ); + + assert_eq!(text.as_deref(), Some("summary")); +} + +#[test] +fn reasoning_text_uses_raw_content_when_enabled() { + let text = reasoning_text( + &["summary".to_string()], + &["raw".to_string()], + /*show_raw_agent_reasoning*/ true, + ); + + assert_eq!(text.as_deref(), Some("raw")); +} + +#[test] +fn final_message_from_turn_items_uses_latest_agent_message() { + let message = final_message_from_turn_items(&[ + ThreadItem::AgentMessage { + id: "msg-1".to_string(), + text: "first".to_string(), + phase: None, + memory_citation: None, + }, + ThreadItem::Plan { + id: "plan-1".to_string(), + text: "plan".to_string(), + }, + ThreadItem::AgentMessage { + id: "msg-2".to_string(), + text: "second".to_string(), + phase: None, + memory_citation: None, + }, + ]); + + assert_eq!(message.as_deref(), Some("second")); +} + +#[test] +fn final_message_from_turn_items_falls_back_to_latest_plan() { + let message = final_message_from_turn_items(&[ + ThreadItem::Reasoning { + id: "reasoning-1".to_string(), + summary: vec!["inspect".to_string()], + content: Vec::new(), + }, + ThreadItem::Plan { + id: "plan-1".to_string(), + text: "first plan".to_string(), + }, + ThreadItem::Plan { + id: "plan-2".to_string(), + text: "final plan".to_string(), + }, + ]); + + assert_eq!(message.as_deref(), Some("final plan")); +} + +#[test] +fn turn_completed_recovers_final_message_from_turn_items() { + let mut processor = EventProcessorWithHumanOutput { + bold: Style::new(), + cyan: Style::new(), + dimmed: Style::new(), + green: Style::new(), + italic: Style::new(), + magenta: Style::new(), + red: Style::new(), + yellow: Style::new(), + show_agent_reasoning: true, + show_raw_agent_reasoning: false, + last_message_path: None, + final_message: None, + final_message_rendered: false, + emit_final_message_on_shutdown: false, + last_total_token_usage: None, + }; + + let status = processor.process_server_notification(ServerNotification::TurnCompleted( + codex_app_server_protocol::TurnCompletedNotification { + thread_id: "thread-1".to_string(), + turn: Turn { + id: "turn-1".to_string(), + items: vec![ThreadItem::AgentMessage { + id: "msg-1".to_string(), + text: "final answer".to_string(), + phase: None, + memory_citation: None, + }], + status: TurnStatus::Completed, + error: None, + }, + }, + )); + + assert_eq!( + status, + crate::event_processor::CodexStatus::InitiateShutdown + ); + assert_eq!(processor.final_message.as_deref(), Some("final answer")); +} + +#[test] +fn turn_completed_overwrites_stale_final_message_from_turn_items() { + let mut processor = EventProcessorWithHumanOutput { + bold: Style::new(), + cyan: Style::new(), + dimmed: Style::new(), + green: Style::new(), + italic: Style::new(), + magenta: Style::new(), + red: Style::new(), + yellow: Style::new(), + show_agent_reasoning: true, + show_raw_agent_reasoning: false, + last_message_path: None, + final_message: Some("stale answer".to_string()), + final_message_rendered: true, + emit_final_message_on_shutdown: false, + last_total_token_usage: None, + }; + + let status = processor.process_server_notification(ServerNotification::TurnCompleted( + codex_app_server_protocol::TurnCompletedNotification { + thread_id: "thread-1".to_string(), + turn: Turn { + id: "turn-1".to_string(), + items: vec![ThreadItem::AgentMessage { + id: "msg-1".to_string(), + text: "final answer".to_string(), + phase: None, + memory_citation: None, + }], + status: TurnStatus::Completed, + error: None, + }, + }, + )); + + assert_eq!( + status, + crate::event_processor::CodexStatus::InitiateShutdown + ); + assert_eq!(processor.final_message.as_deref(), Some("final answer")); + assert!(!processor.final_message_rendered); +} + +#[test] +fn turn_completed_preserves_streamed_final_message_when_turn_items_are_empty() { + let mut processor = EventProcessorWithHumanOutput { + bold: Style::new(), + cyan: Style::new(), + dimmed: Style::new(), + green: Style::new(), + italic: Style::new(), + magenta: Style::new(), + red: Style::new(), + yellow: Style::new(), + show_agent_reasoning: true, + show_raw_agent_reasoning: false, + last_message_path: None, + final_message: Some("streamed answer".to_string()), + final_message_rendered: false, + emit_final_message_on_shutdown: false, + last_total_token_usage: None, + }; + + let status = processor.process_server_notification(ServerNotification::TurnCompleted( + codex_app_server_protocol::TurnCompletedNotification { + thread_id: "thread-1".to_string(), + turn: Turn { + id: "turn-1".to_string(), + items: Vec::new(), + status: TurnStatus::Completed, + error: None, + }, + }, + )); + + assert_eq!( + status, + crate::event_processor::CodexStatus::InitiateShutdown + ); + assert_eq!(processor.final_message.as_deref(), Some("streamed answer")); + assert!(processor.emit_final_message_on_shutdown); +} + +#[test] +fn turn_failed_clears_stale_final_message() { + let mut processor = EventProcessorWithHumanOutput { + bold: Style::new(), + cyan: Style::new(), + dimmed: Style::new(), + green: Style::new(), + italic: Style::new(), + magenta: Style::new(), + red: Style::new(), + yellow: Style::new(), + show_agent_reasoning: true, + show_raw_agent_reasoning: false, + last_message_path: None, + final_message: Some("partial answer".to_string()), + final_message_rendered: true, + emit_final_message_on_shutdown: true, + last_total_token_usage: None, + }; + + let status = processor.process_server_notification(ServerNotification::TurnCompleted( + codex_app_server_protocol::TurnCompletedNotification { + thread_id: "thread-1".to_string(), + turn: Turn { + id: "turn-1".to_string(), + items: Vec::new(), + status: TurnStatus::Failed, + error: None, + }, + }, + )); + + assert_eq!( + status, + crate::event_processor::CodexStatus::InitiateShutdown + ); + assert_eq!(processor.final_message, None); + assert!(!processor.final_message_rendered); + assert!(!processor.emit_final_message_on_shutdown); +} + +#[test] +fn turn_interrupted_clears_stale_final_message() { + let mut processor = EventProcessorWithHumanOutput { + bold: Style::new(), + cyan: Style::new(), + dimmed: Style::new(), + green: Style::new(), + italic: Style::new(), + magenta: Style::new(), + red: Style::new(), + yellow: Style::new(), + show_agent_reasoning: true, + show_raw_agent_reasoning: false, + last_message_path: None, + final_message: Some("partial answer".to_string()), + final_message_rendered: true, + emit_final_message_on_shutdown: true, + last_total_token_usage: None, + }; + + let status = processor.process_server_notification(ServerNotification::TurnCompleted( + codex_app_server_protocol::TurnCompletedNotification { + thread_id: "thread-1".to_string(), + turn: Turn { + id: "turn-1".to_string(), + items: Vec::new(), + status: TurnStatus::Interrupted, + error: None, + }, + }, + )); + + assert_eq!( + status, + crate::event_processor::CodexStatus::InitiateShutdown + ); + assert_eq!(processor.final_message, None); + assert!(!processor.final_message_rendered); + assert!(!processor.emit_final_message_on_shutdown); +} diff --git a/codex-rs/exec/src/event_processor_with_jsonl_output.rs b/codex-rs/exec/src/event_processor_with_jsonl_output.rs index 1a085d93d..bf9453188 100644 --- a/codex-rs/exec/src/event_processor_with_jsonl_output.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output.rs @@ -621,59 +621,5 @@ impl EventProcessor for EventProcessorWithJsonOutput { } #[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - use tempfile::tempdir; - - #[test] - fn failed_turn_does_not_overwrite_output_last_message_file() { - let tempdir = tempdir().expect("create tempdir"); - let output_path = tempdir.path().join("last-message.txt"); - std::fs::write(&output_path, "keep existing contents").expect("seed output file"); - - let mut processor = EventProcessorWithJsonOutput::new(Some(output_path.clone())); - - let collected = processor.collect_thread_events(ServerNotification::ItemCompleted( - codex_app_server_protocol::ItemCompletedNotification { - item: ThreadItem::AgentMessage { - id: "msg-1".to_string(), - text: "partial answer".to_string(), - phase: None, - memory_citation: None, - }, - thread_id: "thread-1".to_string(), - turn_id: "turn-1".to_string(), - }, - )); - - assert_eq!(collected.status, CodexStatus::Running); - assert_eq!(processor.final_message(), Some("partial answer")); - - let status = processor.process_server_notification(ServerNotification::TurnCompleted( - codex_app_server_protocol::TurnCompletedNotification { - thread_id: "thread-1".to_string(), - turn: codex_app_server_protocol::Turn { - id: "turn-1".to_string(), - items: Vec::new(), - status: TurnStatus::Failed, - error: Some(codex_app_server_protocol::TurnError { - message: "turn failed".to_string(), - additional_details: None, - codex_error_info: None, - }), - }, - }, - )); - - assert_eq!(status, CodexStatus::InitiateShutdown); - assert_eq!(processor.final_message(), None); - - EventProcessor::print_final_output(&mut processor); - - assert_eq!( - std::fs::read_to_string(&output_path).expect("read output file"), - "keep existing contents" - ); - } -} +#[path = "event_processor_with_jsonl_output_tests.rs"] +mod tests; diff --git a/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs b/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs new file mode 100644 index 000000000..ffb4d1ed0 --- /dev/null +++ b/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs @@ -0,0 +1,54 @@ +use super::*; +use pretty_assertions::assert_eq; +use tempfile::tempdir; + +#[test] +fn failed_turn_does_not_overwrite_output_last_message_file() { + let tempdir = tempdir().expect("create tempdir"); + let output_path = tempdir.path().join("last-message.txt"); + std::fs::write(&output_path, "keep existing contents").expect("seed output file"); + + let mut processor = EventProcessorWithJsonOutput::new(Some(output_path.clone())); + + let collected = processor.collect_thread_events(ServerNotification::ItemCompleted( + codex_app_server_protocol::ItemCompletedNotification { + item: ThreadItem::AgentMessage { + id: "msg-1".to_string(), + text: "partial answer".to_string(), + phase: None, + memory_citation: None, + }, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }, + )); + + assert_eq!(collected.status, CodexStatus::Running); + assert_eq!(processor.final_message(), Some("partial answer")); + + let status = processor.process_server_notification(ServerNotification::TurnCompleted( + codex_app_server_protocol::TurnCompletedNotification { + thread_id: "thread-1".to_string(), + turn: codex_app_server_protocol::Turn { + id: "turn-1".to_string(), + items: Vec::new(), + status: TurnStatus::Failed, + error: Some(codex_app_server_protocol::TurnError { + message: "turn failed".to_string(), + additional_details: None, + codex_error_info: None, + }), + }, + }, + )); + + assert_eq!(status, CodexStatus::InitiateShutdown); + assert_eq!(processor.final_message(), None); + + EventProcessor::print_final_output(&mut processor); + + assert_eq!( + std::fs::read_to_string(&output_path).expect("read output file"), + "keep existing contents" + ); +} diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 6ad3b2047..5a396a981 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -1657,407 +1657,5 @@ fn build_review_request(args: &ReviewArgs) -> anyhow::Result { } #[cfg(test)] -mod tests { - use super::*; - use codex_otel::set_parent_from_w3c_trace_context; - use codex_protocol::config_types::ApprovalsReviewer; - use opentelemetry::trace::TraceContextExt; - use opentelemetry::trace::TraceId; - use opentelemetry::trace::TracerProvider as _; - use opentelemetry_sdk::trace::SdkTracerProvider; - use pretty_assertions::assert_eq; - use tempfile::tempdir; - use tracing_opentelemetry::OpenTelemetrySpanExt; - - fn test_tracing_subscriber() -> impl tracing::Subscriber + Send + Sync { - let provider = SdkTracerProvider::builder().build(); - let tracer = provider.tracer("codex-exec-tests"); - tracing_subscriber::registry().with(tracing_opentelemetry::layer().with_tracer(tracer)) - } - - #[test] - fn exec_defaults_analytics_to_enabled() { - assert_eq!(DEFAULT_ANALYTICS_ENABLED, true); - } - - #[test] - fn exec_root_span_can_be_parented_from_trace_context() { - let subscriber = test_tracing_subscriber(); - let _guard = tracing::subscriber::set_default(subscriber); - - let parent = codex_protocol::protocol::W3cTraceContext { - traceparent: Some("00-00000000000000000000000000000077-0000000000000088-01".into()), - tracestate: Some("vendor=value".into()), - }; - let exec_span = exec_root_span(); - assert!(set_parent_from_w3c_trace_context(&exec_span, &parent)); - - let trace_id = exec_span.context().span().span_context().trace_id(); - assert_eq!( - trace_id, - TraceId::from_hex("00000000000000000000000000000077").expect("trace id") - ); - } - - #[test] - fn builds_uncommitted_review_request() { - let args = ReviewArgs { - uncommitted: true, - base: None, - commit: None, - commit_title: None, - prompt: None, - }; - let request = build_review_request(&args).expect("builds uncommitted review request"); - - let expected = ReviewRequest { - target: ReviewTarget::UncommittedChanges, - user_facing_hint: None, - }; - - assert_eq!(request, expected); - } - - #[test] - fn builds_commit_review_request_with_title() { - let args = ReviewArgs { - uncommitted: false, - base: None, - commit: Some("123456789".to_string()), - commit_title: Some("Add review command".to_string()), - prompt: None, - }; - let request = build_review_request(&args).expect("builds commit review request"); - - let expected = ReviewRequest { - target: ReviewTarget::Commit { - sha: "123456789".to_string(), - title: Some("Add review command".to_string()), - }, - user_facing_hint: None, - }; - - assert_eq!(request, expected); - } - - #[test] - fn builds_custom_review_request_trims_prompt() { - let args = ReviewArgs { - uncommitted: false, - base: None, - commit: None, - commit_title: None, - prompt: Some(" custom review instructions ".to_string()), - }; - let request = build_review_request(&args).expect("builds custom review request"); - - let expected = ReviewRequest { - target: ReviewTarget::Custom { - instructions: "custom review instructions".to_string(), - }, - user_facing_hint: None, - }; - - assert_eq!(request, expected); - } - - #[test] - fn decode_prompt_bytes_strips_utf8_bom() { - let input = [0xEF, 0xBB, 0xBF, b'h', b'i', b'\n']; - - let out = decode_prompt_bytes(&input).expect("decode utf-8 with BOM"); - - assert_eq!(out, "hi\n"); - } - - #[test] - fn decode_prompt_bytes_decodes_utf16le_bom() { - // UTF-16LE BOM + "hi\n" - let input = [0xFF, 0xFE, b'h', 0x00, b'i', 0x00, b'\n', 0x00]; - - let out = decode_prompt_bytes(&input).expect("decode utf-16le with BOM"); - - assert_eq!(out, "hi\n"); - } - - #[test] - fn decode_prompt_bytes_decodes_utf16be_bom() { - // UTF-16BE BOM + "hi\n" - let input = [0xFE, 0xFF, 0x00, b'h', 0x00, b'i', 0x00, b'\n']; - - let out = decode_prompt_bytes(&input).expect("decode utf-16be with BOM"); - - assert_eq!(out, "hi\n"); - } - - #[test] - fn decode_prompt_bytes_rejects_utf32le_bom() { - // UTF-32LE BOM + "hi\n" - let input = [ - 0xFF, 0xFE, 0x00, 0x00, b'h', 0x00, 0x00, 0x00, b'i', 0x00, 0x00, 0x00, b'\n', 0x00, - 0x00, 0x00, - ]; - - let err = decode_prompt_bytes(&input).expect_err("utf-32le should be rejected"); - - assert_eq!( - err, - PromptDecodeError::UnsupportedBom { - encoding: "UTF-32LE" - } - ); - } - - #[test] - fn decode_prompt_bytes_rejects_utf32be_bom() { - // UTF-32BE BOM + "hi\n" - let input = [ - 0x00, 0x00, 0xFE, 0xFF, 0x00, 0x00, 0x00, b'h', 0x00, 0x00, 0x00, b'i', 0x00, 0x00, - 0x00, b'\n', - ]; - - let err = decode_prompt_bytes(&input).expect_err("utf-32be should be rejected"); - - assert_eq!( - err, - PromptDecodeError::UnsupportedBom { - encoding: "UTF-32BE" - } - ); - } - - #[test] - fn decode_prompt_bytes_rejects_invalid_utf8() { - // Invalid UTF-8 sequence: 0xC3 0x28 - let input = [0xC3, 0x28]; - - let err = decode_prompt_bytes(&input).expect_err("invalid utf-8 should fail"); - - assert_eq!(err, PromptDecodeError::InvalidUtf8 { valid_up_to: 0 }); - } - - #[test] - fn prompt_with_stdin_context_wraps_stdin_block() { - let combined = prompt_with_stdin_context("Summarize this concisely", "my output"); - - assert_eq!( - combined, - "Summarize this concisely\n\n\nmy output\n" - ); - } - - #[test] - fn prompt_with_stdin_context_preserves_trailing_newline() { - let combined = prompt_with_stdin_context("Summarize this concisely", "my output\n"); - - assert_eq!( - combined, - "Summarize this concisely\n\n\nmy output\n" - ); - } - - #[test] - fn lagged_event_warning_message_is_explicit() { - assert_eq!( - lagged_event_warning_message(/*skipped*/ 7), - "in-process app-server event stream lagged; dropped 7 events".to_string() - ); - } - - #[tokio::test] - async fn resume_lookup_model_providers_filters_only_last_lookup() { - let codex_home = tempdir().expect("create temp codex home"); - let cwd = tempdir().expect("create temp cwd"); - let mut config = ConfigBuilder::default() - .codex_home(codex_home.path().to_path_buf()) - .fallback_cwd(Some(cwd.path().to_path_buf())) - .build() - .await - .expect("build default config"); - config.model_provider_id = "test-provider".to_string(); - - let last_args = crate::cli::ResumeArgs { - session_id: None, - last: true, - all: false, - images: vec![], - prompt: None, - }; - let named_args = crate::cli::ResumeArgs { - session_id: Some("named-session".to_string()), - last: false, - all: false, - images: vec![], - prompt: None, - }; - - assert_eq!( - resume_lookup_model_providers(&config, &last_args), - Some(vec!["test-provider".to_string()]) - ); - assert_eq!(resume_lookup_model_providers(&config, &named_args), None); - } - - #[test] - fn turn_items_for_thread_returns_matching_turn_items() { - let thread = AppServerThread { - id: "thread-1".to_string(), - preview: String::new(), - ephemeral: false, - model_provider: "openai".to_string(), - created_at: 0, - updated_at: 0, - status: codex_app_server_protocol::ThreadStatus::Idle, - path: None, - cwd: PathBuf::from("/tmp/project"), - cli_version: "0.0.0-test".to_string(), - source: codex_app_server_protocol::SessionSource::Exec, - agent_nickname: None, - agent_role: None, - git_info: None, - name: None, - turns: vec![ - codex_app_server_protocol::Turn { - id: "turn-1".to_string(), - items: vec![AppServerThreadItem::AgentMessage { - id: "msg-1".to_string(), - text: "hello".to_string(), - phase: None, - memory_citation: None, - }], - status: codex_app_server_protocol::TurnStatus::Completed, - error: None, - }, - codex_app_server_protocol::Turn { - id: "turn-2".to_string(), - items: vec![AppServerThreadItem::Plan { - id: "plan-1".to_string(), - text: "ship it".to_string(), - }], - status: codex_app_server_protocol::TurnStatus::Completed, - error: None, - }, - ], - }; - - assert_eq!( - turn_items_for_thread(&thread, "turn-1"), - Some(vec![AppServerThreadItem::AgentMessage { - id: "msg-1".to_string(), - text: "hello".to_string(), - phase: None, - memory_citation: None, - }]) - ); - assert_eq!(turn_items_for_thread(&thread, "missing-turn"), None); - } - - #[test] - fn canceled_mcp_server_elicitation_response_uses_cancel_action() { - let value = canceled_mcp_server_elicitation_response() - .expect("mcp elicitation cancel response should serialize"); - let response: McpServerElicitationRequestResponse = - serde_json::from_value(value).expect("cancel response should deserialize"); - - assert_eq!( - response, - McpServerElicitationRequestResponse { - action: McpServerElicitationAction::Cancel, - content: None, - meta: None, - } - ); - } - - #[tokio::test] - async fn thread_start_params_include_review_policy_when_review_policy_is_manual_only() { - let codex_home = tempdir().expect("create temp codex home"); - let cwd = tempdir().expect("create temp cwd"); - let config = ConfigBuilder::default() - .codex_home(codex_home.path().to_path_buf()) - .harness_overrides(ConfigOverrides { - approvals_reviewer: Some(ApprovalsReviewer::User), - ..Default::default() - }) - .fallback_cwd(Some(cwd.path().to_path_buf())) - .build() - .await - .expect("build config with manual-only review policy"); - - let params = thread_start_params_from_config(&config); - - assert_eq!( - params.approvals_reviewer, - Some(codex_app_server_protocol::ApprovalsReviewer::User) - ); - } - - #[tokio::test] - async fn thread_start_params_include_review_policy_when_auto_review_is_enabled() { - let codex_home = tempdir().expect("create temp codex home"); - let cwd = tempdir().expect("create temp cwd"); - let config = ConfigBuilder::default() - .codex_home(codex_home.path().to_path_buf()) - .harness_overrides(ConfigOverrides { - approvals_reviewer: Some(ApprovalsReviewer::GuardianSubagent), - ..Default::default() - }) - .fallback_cwd(Some(cwd.path().to_path_buf())) - .build() - .await - .expect("build config with guardian review policy"); - - let params = thread_start_params_from_config(&config); - - assert_eq!( - params.approvals_reviewer, - Some(codex_app_server_protocol::ApprovalsReviewer::GuardianSubagent) - ); - } - - #[test] - fn session_configured_from_thread_response_uses_review_policy_from_response() { - let response = ThreadStartResponse { - thread: codex_app_server_protocol::Thread { - id: "67e55044-10b1-426f-9247-bb680e5fe0c8".to_string(), - preview: String::new(), - ephemeral: false, - model_provider: "openai".to_string(), - created_at: 0, - updated_at: 0, - status: codex_app_server_protocol::ThreadStatus::Idle, - path: Some(PathBuf::from("/tmp/rollout.jsonl")), - cwd: PathBuf::from("/tmp"), - cli_version: "0.0.0".to_string(), - source: codex_app_server_protocol::SessionSource::Cli, - agent_nickname: None, - agent_role: None, - git_info: None, - name: Some("thread".to_string()), - turns: vec![], - }, - model: "gpt-5.4".to_string(), - model_provider: "openai".to_string(), - service_tier: None, - cwd: PathBuf::from("/tmp"), - approval_policy: codex_app_server_protocol::AskForApproval::OnRequest, - approvals_reviewer: codex_app_server_protocol::ApprovalsReviewer::GuardianSubagent, - sandbox: codex_app_server_protocol::SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], - read_only_access: codex_app_server_protocol::ReadOnlyAccess::FullAccess, - network_access: false, - exclude_tmpdir_env_var: false, - exclude_slash_tmp: false, - }, - reasoning_effort: None, - }; - - let event = session_configured_from_thread_start_response(&response) - .expect("build bootstrap session configured event"); - - assert_eq!( - event.approvals_reviewer, - ApprovalsReviewer::GuardianSubagent - ); - } -} +#[path = "lib_tests.rs"] +mod tests; diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs new file mode 100644 index 000000000..b223ba13c --- /dev/null +++ b/codex-rs/exec/src/lib_tests.rs @@ -0,0 +1,402 @@ +use super::*; +use codex_otel::set_parent_from_w3c_trace_context; +use codex_protocol::config_types::ApprovalsReviewer; +use opentelemetry::trace::TraceContextExt; +use opentelemetry::trace::TraceId; +use opentelemetry::trace::TracerProvider as _; +use opentelemetry_sdk::trace::SdkTracerProvider; +use pretty_assertions::assert_eq; +use tempfile::tempdir; +use tracing_opentelemetry::OpenTelemetrySpanExt; + +fn test_tracing_subscriber() -> impl tracing::Subscriber + Send + Sync { + let provider = SdkTracerProvider::builder().build(); + let tracer = provider.tracer("codex-exec-tests"); + tracing_subscriber::registry().with(tracing_opentelemetry::layer().with_tracer(tracer)) +} + +#[test] +fn exec_defaults_analytics_to_enabled() { + assert_eq!(DEFAULT_ANALYTICS_ENABLED, true); +} + +#[test] +fn exec_root_span_can_be_parented_from_trace_context() { + let subscriber = test_tracing_subscriber(); + let _guard = tracing::subscriber::set_default(subscriber); + + let parent = codex_protocol::protocol::W3cTraceContext { + traceparent: Some("00-00000000000000000000000000000077-0000000000000088-01".into()), + tracestate: Some("vendor=value".into()), + }; + let exec_span = exec_root_span(); + assert!(set_parent_from_w3c_trace_context(&exec_span, &parent)); + + let trace_id = exec_span.context().span().span_context().trace_id(); + assert_eq!( + trace_id, + TraceId::from_hex("00000000000000000000000000000077").expect("trace id") + ); +} + +#[test] +fn builds_uncommitted_review_request() { + let args = ReviewArgs { + uncommitted: true, + base: None, + commit: None, + commit_title: None, + prompt: None, + }; + let request = build_review_request(&args).expect("builds uncommitted review request"); + + let expected = ReviewRequest { + target: ReviewTarget::UncommittedChanges, + user_facing_hint: None, + }; + + assert_eq!(request, expected); +} + +#[test] +fn builds_commit_review_request_with_title() { + let args = ReviewArgs { + uncommitted: false, + base: None, + commit: Some("123456789".to_string()), + commit_title: Some("Add review command".to_string()), + prompt: None, + }; + let request = build_review_request(&args).expect("builds commit review request"); + + let expected = ReviewRequest { + target: ReviewTarget::Commit { + sha: "123456789".to_string(), + title: Some("Add review command".to_string()), + }, + user_facing_hint: None, + }; + + assert_eq!(request, expected); +} + +#[test] +fn builds_custom_review_request_trims_prompt() { + let args = ReviewArgs { + uncommitted: false, + base: None, + commit: None, + commit_title: None, + prompt: Some(" custom review instructions ".to_string()), + }; + let request = build_review_request(&args).expect("builds custom review request"); + + let expected = ReviewRequest { + target: ReviewTarget::Custom { + instructions: "custom review instructions".to_string(), + }, + user_facing_hint: None, + }; + + assert_eq!(request, expected); +} + +#[test] +fn decode_prompt_bytes_strips_utf8_bom() { + let input = [0xEF, 0xBB, 0xBF, b'h', b'i', b'\n']; + + let out = decode_prompt_bytes(&input).expect("decode utf-8 with BOM"); + + assert_eq!(out, "hi\n"); +} + +#[test] +fn decode_prompt_bytes_decodes_utf16le_bom() { + // UTF-16LE BOM + "hi\n" + let input = [0xFF, 0xFE, b'h', 0x00, b'i', 0x00, b'\n', 0x00]; + + let out = decode_prompt_bytes(&input).expect("decode utf-16le with BOM"); + + assert_eq!(out, "hi\n"); +} + +#[test] +fn decode_prompt_bytes_decodes_utf16be_bom() { + // UTF-16BE BOM + "hi\n" + let input = [0xFE, 0xFF, 0x00, b'h', 0x00, b'i', 0x00, b'\n']; + + let out = decode_prompt_bytes(&input).expect("decode utf-16be with BOM"); + + assert_eq!(out, "hi\n"); +} + +#[test] +fn decode_prompt_bytes_rejects_utf32le_bom() { + // UTF-32LE BOM + "hi\n" + let input = [ + 0xFF, 0xFE, 0x00, 0x00, b'h', 0x00, 0x00, 0x00, b'i', 0x00, 0x00, 0x00, b'\n', 0x00, 0x00, + 0x00, + ]; + + let err = decode_prompt_bytes(&input).expect_err("utf-32le should be rejected"); + + assert_eq!( + err, + PromptDecodeError::UnsupportedBom { + encoding: "UTF-32LE" + } + ); +} + +#[test] +fn decode_prompt_bytes_rejects_utf32be_bom() { + // UTF-32BE BOM + "hi\n" + let input = [ + 0x00, 0x00, 0xFE, 0xFF, 0x00, 0x00, 0x00, b'h', 0x00, 0x00, 0x00, b'i', 0x00, 0x00, 0x00, + b'\n', + ]; + + let err = decode_prompt_bytes(&input).expect_err("utf-32be should be rejected"); + + assert_eq!( + err, + PromptDecodeError::UnsupportedBom { + encoding: "UTF-32BE" + } + ); +} + +#[test] +fn decode_prompt_bytes_rejects_invalid_utf8() { + // Invalid UTF-8 sequence: 0xC3 0x28 + let input = [0xC3, 0x28]; + + let err = decode_prompt_bytes(&input).expect_err("invalid utf-8 should fail"); + + assert_eq!(err, PromptDecodeError::InvalidUtf8 { valid_up_to: 0 }); +} + +#[test] +fn prompt_with_stdin_context_wraps_stdin_block() { + let combined = prompt_with_stdin_context("Summarize this concisely", "my output"); + + assert_eq!( + combined, + "Summarize this concisely\n\n\nmy output\n" + ); +} + +#[test] +fn prompt_with_stdin_context_preserves_trailing_newline() { + let combined = prompt_with_stdin_context("Summarize this concisely", "my output\n"); + + assert_eq!( + combined, + "Summarize this concisely\n\n\nmy output\n" + ); +} + +#[test] +fn lagged_event_warning_message_is_explicit() { + assert_eq!( + lagged_event_warning_message(/*skipped*/ 7), + "in-process app-server event stream lagged; dropped 7 events".to_string() + ); +} + +#[tokio::test] +async fn resume_lookup_model_providers_filters_only_last_lookup() { + let codex_home = tempdir().expect("create temp codex home"); + let cwd = tempdir().expect("create temp cwd"); + let mut config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(cwd.path().to_path_buf())) + .build() + .await + .expect("build default config"); + config.model_provider_id = "test-provider".to_string(); + + let last_args = crate::cli::ResumeArgs { + session_id: None, + last: true, + all: false, + images: vec![], + prompt: None, + }; + let named_args = crate::cli::ResumeArgs { + session_id: Some("named-session".to_string()), + last: false, + all: false, + images: vec![], + prompt: None, + }; + + assert_eq!( + resume_lookup_model_providers(&config, &last_args), + Some(vec!["test-provider".to_string()]) + ); + assert_eq!(resume_lookup_model_providers(&config, &named_args), None); +} + +#[test] +fn turn_items_for_thread_returns_matching_turn_items() { + let thread = AppServerThread { + id: "thread-1".to_string(), + preview: String::new(), + ephemeral: false, + model_provider: "openai".to_string(), + created_at: 0, + updated_at: 0, + status: codex_app_server_protocol::ThreadStatus::Idle, + path: None, + cwd: PathBuf::from("/tmp/project"), + cli_version: "0.0.0-test".to_string(), + source: codex_app_server_protocol::SessionSource::Exec, + agent_nickname: None, + agent_role: None, + git_info: None, + name: None, + turns: vec![ + codex_app_server_protocol::Turn { + id: "turn-1".to_string(), + items: vec![AppServerThreadItem::AgentMessage { + id: "msg-1".to_string(), + text: "hello".to_string(), + phase: None, + memory_citation: None, + }], + status: codex_app_server_protocol::TurnStatus::Completed, + error: None, + }, + codex_app_server_protocol::Turn { + id: "turn-2".to_string(), + items: vec![AppServerThreadItem::Plan { + id: "plan-1".to_string(), + text: "ship it".to_string(), + }], + status: codex_app_server_protocol::TurnStatus::Completed, + error: None, + }, + ], + }; + + assert_eq!( + turn_items_for_thread(&thread, "turn-1"), + Some(vec![AppServerThreadItem::AgentMessage { + id: "msg-1".to_string(), + text: "hello".to_string(), + phase: None, + memory_citation: None, + }]) + ); + assert_eq!(turn_items_for_thread(&thread, "missing-turn"), None); +} + +#[test] +fn canceled_mcp_server_elicitation_response_uses_cancel_action() { + let value = canceled_mcp_server_elicitation_response() + .expect("mcp elicitation cancel response should serialize"); + let response: McpServerElicitationRequestResponse = + serde_json::from_value(value).expect("cancel response should deserialize"); + + assert_eq!( + response, + McpServerElicitationRequestResponse { + action: McpServerElicitationAction::Cancel, + content: None, + meta: None, + } + ); +} + +#[tokio::test] +async fn thread_start_params_include_review_policy_when_review_policy_is_manual_only() { + let codex_home = tempdir().expect("create temp codex home"); + let cwd = tempdir().expect("create temp cwd"); + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .harness_overrides(ConfigOverrides { + approvals_reviewer: Some(ApprovalsReviewer::User), + ..Default::default() + }) + .fallback_cwd(Some(cwd.path().to_path_buf())) + .build() + .await + .expect("build config with manual-only review policy"); + + let params = thread_start_params_from_config(&config); + + assert_eq!( + params.approvals_reviewer, + Some(codex_app_server_protocol::ApprovalsReviewer::User) + ); +} + +#[tokio::test] +async fn thread_start_params_include_review_policy_when_auto_review_is_enabled() { + let codex_home = tempdir().expect("create temp codex home"); + let cwd = tempdir().expect("create temp cwd"); + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .harness_overrides(ConfigOverrides { + approvals_reviewer: Some(ApprovalsReviewer::GuardianSubagent), + ..Default::default() + }) + .fallback_cwd(Some(cwd.path().to_path_buf())) + .build() + .await + .expect("build config with guardian review policy"); + + let params = thread_start_params_from_config(&config); + + assert_eq!( + params.approvals_reviewer, + Some(codex_app_server_protocol::ApprovalsReviewer::GuardianSubagent) + ); +} + +#[test] +fn session_configured_from_thread_response_uses_review_policy_from_response() { + let response = ThreadStartResponse { + thread: codex_app_server_protocol::Thread { + id: "67e55044-10b1-426f-9247-bb680e5fe0c8".to_string(), + preview: String::new(), + ephemeral: false, + model_provider: "openai".to_string(), + created_at: 0, + updated_at: 0, + status: codex_app_server_protocol::ThreadStatus::Idle, + path: Some(PathBuf::from("/tmp/rollout.jsonl")), + cwd: PathBuf::from("/tmp"), + cli_version: "0.0.0".to_string(), + source: codex_app_server_protocol::SessionSource::Cli, + agent_nickname: None, + agent_role: None, + git_info: None, + name: Some("thread".to_string()), + turns: vec![], + }, + model: "gpt-5.4".to_string(), + model_provider: "openai".to_string(), + service_tier: None, + cwd: PathBuf::from("/tmp"), + approval_policy: codex_app_server_protocol::AskForApproval::OnRequest, + approvals_reviewer: codex_app_server_protocol::ApprovalsReviewer::GuardianSubagent, + sandbox: codex_app_server_protocol::SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + read_only_access: codex_app_server_protocol::ReadOnlyAccess::FullAccess, + network_access: false, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }, + reasoning_effort: None, + }; + + let event = session_configured_from_thread_start_response(&response) + .expect("build bootstrap session configured event"); + + assert_eq!( + event.approvals_reviewer, + ApprovalsReviewer::GuardianSubagent + ); +} diff --git a/codex-rs/exec/src/main.rs b/codex-rs/exec/src/main.rs index e4cbd25ad..79a681b14 100644 --- a/codex-rs/exec/src/main.rs +++ b/codex-rs/exec/src/main.rs @@ -41,42 +41,5 @@ fn main() -> anyhow::Result<()> { } #[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - - #[test] - fn top_cli_parses_resume_prompt_after_config_flag() { - const PROMPT: &str = "echo resume-with-global-flags-after-subcommand"; - let cli = TopCli::parse_from([ - "codex-exec", - "resume", - "--last", - "--json", - "--model", - "gpt-5.2-codex", - "--config", - "reasoning_level=xhigh", - "--dangerously-bypass-approvals-and-sandbox", - "--skip-git-repo-check", - PROMPT, - ]); - - let Some(codex_exec::Command::Resume(args)) = cli.inner.command else { - panic!("expected resume command"); - }; - let effective_prompt = args.prompt.clone().or_else(|| { - if args.last { - args.session_id.clone() - } else { - None - } - }); - assert_eq!(effective_prompt.as_deref(), Some(PROMPT)); - assert_eq!(cli.config_overrides.raw_overrides.len(), 1); - assert_eq!( - cli.config_overrides.raw_overrides[0], - "reasoning_level=xhigh" - ); - } -} +#[path = "main_tests.rs"] +mod tests; diff --git a/codex-rs/exec/src/main_tests.rs b/codex-rs/exec/src/main_tests.rs new file mode 100644 index 000000000..a9cb0ec63 --- /dev/null +++ b/codex-rs/exec/src/main_tests.rs @@ -0,0 +1,37 @@ +use super::*; +use pretty_assertions::assert_eq; + +#[test] +fn top_cli_parses_resume_prompt_after_config_flag() { + const PROMPT: &str = "echo resume-with-global-flags-after-subcommand"; + let cli = TopCli::parse_from([ + "codex-exec", + "resume", + "--last", + "--json", + "--model", + "gpt-5.2-codex", + "--config", + "reasoning_level=xhigh", + "--dangerously-bypass-approvals-and-sandbox", + "--skip-git-repo-check", + PROMPT, + ]); + + let Some(codex_exec::Command::Resume(args)) = cli.inner.command else { + panic!("expected resume command"); + }; + let effective_prompt = args.prompt.clone().or_else(|| { + if args.last { + args.session_id.clone() + } else { + None + } + }); + assert_eq!(effective_prompt.as_deref(), Some(PROMPT)); + assert_eq!(cli.config_overrides.raw_overrides.len(), 1); + assert_eq!( + cli.config_overrides.raw_overrides[0], + "reasoning_level=xhigh" + ); +}