use codex_core::CodexThread; use codex_core::REVIEW_PROMPT; use codex_core::TurnInputRequest; use codex_core::config::Config; use codex_core::config::Constrained; use codex_core::find_thread_path_by_id_str; use codex_exec_server::CreateDirectoryOptions; use codex_features::Feature; use codex_history::RolloutItem; use codex_login::CodexAuth; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::Settings; use codex_protocol::items::TurnItem; use codex_protocol::models::ContentItem; use codex_protocol::models::PermissionProfile; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ModelServiceTier; use codex_protocol::openai_models::ModelTokenBudgetConfig; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::openai_models::ReasoningEffortPreset; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ExitedReviewModeEvent; use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewCodeLocation; use codex_protocol::protocol::ReviewFinding; use codex_protocol::protocol::ReviewLineRange; use codex_protocol::protocol::ReviewOutputEvent; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::ReviewTarget; use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::protocol::TurnEnvironmentSelections; use codex_protocol::review_format::render_review_output_text; use codex_protocol::user_input::UserInput; use core_test_support::PathBufExt; use core_test_support::responses; use core_test_support::responses::ResponseMock; use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; use core_test_support::test_codex::local_selections; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; use pretty_assertions::assert_eq; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; use tempfile::TempDir; use tokio::io::AsyncWriteExt as _; use uuid::Uuid; use wiremock::MockServer; /// Verify that submitting `Op::Review` emits review item lifecycle, /// legacy review events, and TurnComplete when the model returns a structured review payload. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn review_op_emits_lifecycle_and_review_output() { // Skip under Codex sandbox network restrictions. skip_if_no_network!(); // Start mock Responses API server. Return a single assistant message whose // text is a JSON-encoded ReviewOutputEvent. let review_json = serde_json::json!({ "findings": [ { "title": "Prefer Stylize helpers", "body": "Use .dim()/.bold() chaining instead of manual Style where possible.", "confidence_score": 0.9, "priority": 1, "code_location": { "absolute_file_path": "/tmp/file.rs", "line_range": {"start": 10, "end": 20} } } ], "overall_correctness": "good", "overall_explanation": "All good with some improvements suggested.", "overall_confidence_score": 0.8 }) .to_string(); let (server, request_log) = start_responses_server_with_sse( assistant_message_sse(&review_json), /*expected_requests*/ 1, ) .await; let codex_home = Arc::new(TempDir::new().unwrap()); let codex = new_conversation_for_server(&server, codex_home.clone(), |_| {}).await; // Submit review request. codex .submit(Op::Review { review_request: ReviewRequest { target: ReviewTarget::Custom { instructions: "Please review my changes".to_string(), }, user_facing_hint: None, }, }) .await .unwrap(); // Item lifecycle events are emitted first, then the legacy review event is fanned out // with the same stable IDs for compatibility consumers. let entered_started = wait_for_event(&codex, |ev| { matches!( ev, EventMsg::ItemStarted(event) if matches!(event.item, TurnItem::EnteredReviewMode(_)) ) }) .await; let (review_turn_id, entered_item_id) = match entered_started { EventMsg::ItemStarted(event) => (event.turn_id, event.item.id()), other => panic!("expected entered review item start, got {other:?}"), }; let entered_completed = wait_for_event(&codex, |ev| { matches!( ev, EventMsg::ItemCompleted(event) if matches!(event.item, TurnItem::EnteredReviewMode(_)) ) }) .await; match entered_completed { EventMsg::ItemCompleted(event) => { assert_eq!(event.turn_id, review_turn_id); assert_eq!(event.item.id(), entered_item_id); } other => panic!("expected entered review item completion, got {other:?}"), } let entered = wait_for_event(&codex, |ev| matches!(ev, EventMsg::EnteredReviewMode(_))).await; match entered { EventMsg::EnteredReviewMode(event) => { assert_eq!(event.turn_id.as_deref(), Some(review_turn_id.as_str())); assert_eq!(event.item_id.as_deref(), Some(entered_item_id.as_str())); } other => panic!("expected EnteredReviewMode(..), got {other:?}"), } let exited_started = wait_for_event(&codex, |ev| { matches!( ev, EventMsg::ItemStarted(event) if matches!(event.item, TurnItem::ExitedReviewMode(_)) ) }) .await; let exited_item_id = match exited_started { EventMsg::ItemStarted(event) => { assert_eq!(event.turn_id, review_turn_id); event.item.id() } other => panic!("expected exited review item start, got {other:?}"), }; let exited_completed = wait_for_event(&codex, |ev| { matches!( ev, EventMsg::ItemCompleted(event) if matches!(event.item, TurnItem::ExitedReviewMode(_)) ) }) .await; match exited_completed { EventMsg::ItemCompleted(event) => { assert_eq!(event.turn_id, review_turn_id); assert_eq!(event.item.id(), exited_item_id); } other => panic!("expected exited review item completion, got {other:?}"), } let closed = wait_for_event(&codex, |ev| matches!(ev, EventMsg::ExitedReviewMode(_))).await; let review = match closed { EventMsg::ExitedReviewMode(ev) => { assert_eq!(ev.turn_id.as_deref(), Some(review_turn_id.as_str())); assert_eq!(ev.item_id.as_deref(), Some(exited_item_id.as_str())); ev.review_output .expect("expected ExitedReviewMode with Some(review_output)") } other => panic!("expected ExitedReviewMode(..), got {other:?}"), }; // Deep compare full structure using PartialEq (floats are f32 on both sides). let expected = ReviewOutputEvent { findings: vec![ReviewFinding { title: "Prefer Stylize helpers".to_string(), body: "Use .dim()/.bold() chaining instead of manual Style where possible.".to_string(), confidence_score: 0.9, priority: 1, code_location: ReviewCodeLocation { absolute_file_path: PathBuf::from("/tmp/file.rs"), line_range: ReviewLineRange { start: 10, end: 20 }, }, }], overall_correctness: "good".to_string(), overall_explanation: "All good with some improvements suggested.".to_string(), overall_confidence_score: 0.8, }; assert_eq!(expected, review); let _complete = wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; let path = codex.rollout_path().expect("rollout path"); let text = std::fs::read_to_string(&path).expect("read rollout file"); let parent_thread_id = text .lines() .filter(|line| !line.trim().is_empty()) .find_map(|line| { let rollout_line = codex_rollout::parse_rollout_line(line).expect("rollout line"); match rollout_line.item { RolloutItem::SessionMeta(session_meta) => Some(session_meta.meta.id.to_string()), _ => None, } }) .expect("parent session meta"); let request = request_log.single_request(); assert_eq!( request.header("x-openai-subagent").as_deref(), Some("review") ); let turn_metadata: serde_json::Value = serde_json::from_str( &request .header("x-codex-turn-metadata") .expect("review request turn metadata"), ) .expect("review request turn metadata json"); assert!(turn_metadata.get("forked_from_thread_id").is_none()); assert_eq!( turn_metadata["parent_thread_id"].as_str(), Some(parent_thread_id.as_str()) ); let request_body = request.body_json(); responses::assert_root_turn(&request_body, Some(review_turn_id.as_str())) .expect("review request root turn metadata"); responses::assert_parent_turn(&request_body, Some(review_turn_id.as_str())) .expect("review request parent turn metadata"); // Also verify that a user message with the header and a formatted finding // was recorded back in the parent session's rollout. let mut saw_header = false; let mut saw_finding_line = false; let expected_assistant_text = render_review_output_text(&expected); let mut saw_assistant_plain = false; let mut saw_assistant_xml = false; for line in text.lines() { if line.trim().is_empty() { continue; } let v: serde_json::Value = serde_json::from_str(line).expect("jsonl line"); let rl = codex_rollout::decode_rollout_line(v).expect("rollout line"); if let RolloutItem::ResponseItem(envelope) = rl.item && let ResponseItem::Message { role, content, .. } = envelope.item { if role == "user" { for c in content { if let ContentItem::InputText { text } = c { if text.contains("full review output from reviewer model") { saw_header = true; } if text.contains("- Prefer Stylize helpers — /tmp/file.rs:10-20") { saw_finding_line = true; } } } } else if role == "assistant" { for c in content { if let ContentItem::OutputText { text } = c { if text.contains("") { saw_assistant_xml = true; } if text == expected_assistant_text { saw_assistant_plain = true; } } } } } } assert!(saw_header, "user header missing from rollout"); assert!( saw_finding_line, "formatted finding line missing from rollout" ); assert!( saw_assistant_plain, "assistant review output missing from rollout" ); assert!( !saw_assistant_xml, "assistant review output contains user_action markup" ); let _codex_home_guard = codex_home; server.verify().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn cancelled_review_does_not_forward_delegate_mcp_startup() { skip_if_no_network!(); let server = start_mock_server().await; let request_log = responses::mount_response_once( &server, responses::sse_response(responses::sse(vec![responses::ev_response_created( "resp-1", )])) .set_delay(Duration::from_secs(30)), ) .await; let codex_home = Arc::new(TempDir::new().unwrap()); let codex = new_conversation_for_server(&server, codex_home.clone(), |_| {}).await; // Consume the parent session's own empty startup round before starting the review. wait_for_event(&codex, |event| { matches!(event, EventMsg::McpStartupComplete(_)) }) .await; codex .submit(Op::Review { review_request: ReviewRequest { target: ReviewTarget::Custom { instructions: "Cancel this review".to_string(), }, user_facing_hint: None, }, }) .await .unwrap(); tokio::time::timeout(Duration::from_secs(5), async { loop { match codex.next_event().await.expect("review event").msg { event @ (EventMsg::McpStartupUpdate(_) | EventMsg::McpStartupComplete(_)) => { panic!("review forwarded delegate MCP startup: {event:?}") } EventMsg::EnteredReviewMode(_) => break, _ => {} } } }) .await .expect("timed out waiting for review entry"); tokio::time::timeout(Duration::from_secs(5), async { while request_log.requests().is_empty() { tokio::task::yield_now().await; } }) .await .expect("review request did not reach the server"); codex.submit(Op::Interrupt).await.unwrap(); let mut exited_review = false; tokio::time::timeout(Duration::from_secs(5), async { loop { match codex .next_event() .await .expect("review cancellation event") .msg { event @ (EventMsg::McpStartupUpdate(_) | EventMsg::McpStartupComplete(_)) => { panic!("cancelled review forwarded delegate MCP startup: {event:?}") } EventMsg::ExitedReviewMode(ExitedReviewModeEvent { review_output, .. }) => { assert_eq!(review_output, None); exited_review = true; } EventMsg::TurnAborted(_) if exited_review => break, EventMsg::TurnAborted(_) => panic!("review turn aborted before review mode exited"), _ => {} } } }) .await .expect("timed out waiting for review cancellation"); assert_eq!(request_log.requests().len(), 1); let _codex_home_guard = codex_home; server.verify().await; } /// When the model returns plain text that is not JSON, ensure the child /// lifecycle still occurs and the plain text is surfaced via /// ExitedReviewMode(Some(..)) as the overall_explanation. // Windows CI only: bump to 4 workers to prevent SSE/event starvation and test timeouts. #[cfg_attr(windows, tokio::test(flavor = "multi_thread", worker_threads = 4))] #[cfg_attr(not(windows), tokio::test(flavor = "multi_thread", worker_threads = 2))] async fn review_op_with_plain_text_emits_review_fallback() { skip_if_no_network!(); let (server, _request_log) = start_responses_server_with_sse( assistant_message_sse("just plain text"), /*expected_requests*/ 1, ) .await; let codex_home = Arc::new(TempDir::new().unwrap()); let codex = new_conversation_for_server(&server, codex_home.clone(), |_| {}).await; codex .submit(Op::Review { review_request: ReviewRequest { target: ReviewTarget::Custom { instructions: "Plain text review".to_string(), }, user_facing_hint: None, }, }) .await .unwrap(); let _entered = wait_for_event(&codex, |ev| matches!(ev, EventMsg::EnteredReviewMode(_))).await; let closed = wait_for_event(&codex, |ev| matches!(ev, EventMsg::ExitedReviewMode(_))).await; let review = match closed { EventMsg::ExitedReviewMode(ev) => ev .review_output .expect("expected ExitedReviewMode with Some(review_output)"), other => panic!("expected ExitedReviewMode(..), got {other:?}"), }; // Expect a structured fallback carrying the plain text. let expected = ReviewOutputEvent { overall_explanation: "just plain text".to_string(), ..Default::default() }; assert_eq!(expected, review); let _complete = wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; let _codex_home_guard = codex_home; server.verify().await; } /// Ensure review flow suppresses assistant-specific streaming/completion events: /// - AgentMessageContentDelta /// - ItemCompleted for TurnItem::AgentMessage // Windows CI only: bump to 4 workers to prevent SSE/event starvation and test timeouts. #[cfg_attr(windows, tokio::test(flavor = "multi_thread", worker_threads = 4))] #[cfg_attr(not(windows), tokio::test(flavor = "multi_thread", worker_threads = 2))] async fn review_filters_agent_message_related_events() { skip_if_no_network!(); let (server, _request_log) = start_responses_server_with_sse( vec![ responses::ev_message_item_added("msg-1", ""), responses::ev_output_text_delta("Hi"), responses::ev_output_text_delta(" there"), responses::ev_assistant_message("msg-1", "Hi there"), responses::ev_completed("resp-1"), ], /*expected_requests*/ 1, ) .await; let codex_home = Arc::new(TempDir::new().unwrap()); let codex = new_conversation_for_server(&server, codex_home.clone(), |_| {}).await; codex .submit(Op::Review { review_request: ReviewRequest { target: ReviewTarget::Custom { instructions: "Filter streaming events".to_string(), }, user_facing_hint: None, }, }) .await .unwrap(); let mut saw_entered = false; let mut saw_exited = false; // Drain until TurnComplete; assert streaming-related events never surface. wait_for_event(&codex, |event| match event { EventMsg::TurnComplete(_) => true, EventMsg::EnteredReviewMode(_) => { saw_entered = true; false } EventMsg::ExitedReviewMode(_) => { saw_exited = true; false } // The following must be filtered by review flow EventMsg::AgentMessageContentDelta(_) => { panic!("unexpected AgentMessageContentDelta surfaced during review") } _ => false, }) .await; assert!(saw_entered && saw_exited, "missing review lifecycle events"); let _codex_home_guard = codex_home; server.verify().await; } /// When the model returns structured JSON in a review, ensure only a single /// non-streaming AgentMessage is emitted; the UI consumes the structured /// result via ExitedReviewMode plus a final assistant message. // Windows CI only: bump to 4 workers to prevent SSE/event starvation and test timeouts. #[cfg_attr(windows, tokio::test(flavor = "multi_thread", worker_threads = 4))] #[cfg_attr(not(windows), tokio::test(flavor = "multi_thread", worker_threads = 2))] async fn review_does_not_emit_agent_message_on_structured_output() { skip_if_no_network!(); let review_json = serde_json::json!({ "findings": [ { "title": "Example", "body": "Structured review output.", "confidence_score": 0.5, "priority": 1, "code_location": { "absolute_file_path": "/tmp/file.rs", "line_range": {"start": 1, "end": 2} } } ], "overall_correctness": "ok", "overall_explanation": "ok", "overall_confidence_score": 0.5 }) .to_string(); let (server, _request_log) = start_responses_server_with_sse( assistant_message_sse(&review_json), /*expected_requests*/ 1, ) .await; let codex_home = Arc::new(TempDir::new().unwrap()); let codex = new_conversation_for_server(&server, codex_home.clone(), |_| {}).await; codex .submit(Op::Review { review_request: ReviewRequest { target: ReviewTarget::Custom { instructions: "check structured".to_string(), }, user_facing_hint: None, }, }) .await .unwrap(); // Drain events until TurnComplete; ensure we only see a final // AgentMessage (no streaming assistant messages). let mut saw_entered = false; let mut saw_exited = false; let mut agent_messages = 0; wait_for_event(&codex, |event| match event { EventMsg::TurnComplete(_) => true, EventMsg::AgentMessage(_) => { agent_messages += 1; false } EventMsg::EnteredReviewMode(_) => { saw_entered = true; false } EventMsg::ExitedReviewMode(_) => { saw_exited = true; false } _ => false, }) .await; assert_eq!(1, agent_messages, "expected exactly one AgentMessage event"); assert!(saw_entered && saw_exited, "missing review lifecycle events"); let _codex_home_guard = codex_home; server.verify().await; } /// Reviews inherit current session settings without inheriting another model's defaults. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn review_uses_updated_turn_permissions_and_approval_policy() { skip_if_no_network!(); fn model_defaults(guidance_message: &str) -> ModelTokenBudgetConfig { ModelTokenBudgetConfig { enabled: false, use_history_notes_extension: false, reminder_threshold_tokens: 6_144, reminder_message_template: "Reminder: {n_remaining} tokens remain.".to_string(), guidance_message: guidance_message.to_string(), auto_compact_fallback_prompt: "Preserve the important context.".to_string(), auto_compact_fallback_buffer_tokens: 16_384, } } let (server, request_log) = start_responses_server_with_sse(completed_sse(), /*expected_requests*/ 1).await; let codex_home = Arc::new(TempDir::new().unwrap()); let test = test_codex() .with_home(codex_home.clone()) .with_model_info_override("gpt-5.2", |model_info| { model_info.service_tiers.clear(); model_info .model_messages .as_mut() .expect("parent model should have model messages") .token_budget = Some(model_defaults("PARENT MODEL ONLY")); }) .with_model_info_override("gpt-5.4", |model_info| { model_info.service_tiers = vec![ModelServiceTier { id: ServiceTier::Fast.request_value().to_string(), name: "Fast".to_string(), description: "Priority processing".to_string(), }]; model_info.supported_reasoning_levels = [ ReasoningEffort::Low, ReasoningEffort::Medium, ReasoningEffort::High, ] .into_iter() .map(|effort| ReasoningEffortPreset { description: effort.to_string(), effort, }) .collect(); model_info.default_reasoning_level = Some(ReasoningEffort::High); model_info .model_messages .as_mut() .expect("review model should have model messages") .token_budget = Some(model_defaults("REVIEW MODEL ONLY")); }) .with_model("gpt-5.2") .with_config(|config| { config.review_model = Some("gpt-5.4".to_string()); config.model_context_window = Some(128_000); config.service_tier = Some(ServiceTier::Fast.request_value().to_string()); config .features .enable(Feature::FastMode) .expect("enable FastMode"); config .features .enable(Feature::TokenBudget) .expect("token budget should be available"); config.approvals_reviewer = ApprovalsReviewer::AutoReview; config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest); config .permissions .set_permission_profile(PermissionProfile::read_only()) .expect("initial permission profile should be valid"); }) .build_with_auto_env(&server) .await .expect("review conversation should be created"); let codex = Arc::clone(&test.codex); let updated_cwd = test.config.cwd.join("updated-review-workspace"); let mut selection = test.executor_environment().selection().clone(); selection.cwd = selection .cwd .join("updated-review-workspace") .expect("updated execution directory should be valid"); selection.workspace_roots = vec![selection.cwd.clone()]; test.fs() .create_directory( &selection.cwd, CreateDirectoryOptions { recursive: true, follow_symlinks: true, }, /*sandbox*/ None, ) .await .expect("updated review workspace should be created"); core_test_support::submit_thread_settings( &codex, ThreadSettingsOverrides { environments: Some(TurnEnvironmentSelections::new( updated_cwd.clone(), vec![selection], )), approval_policy: Some(AskForApproval::Never), approvals_reviewer: Some(ApprovalsReviewer::User), permission_profile: Some(PermissionProfile::Disabled), personality: Some(Personality::Friendly), collaboration_mode: Some(CollaborationMode { mode: ModeKind::Plan, settings: Settings { model: "gpt-5.2".to_string(), reasoning_effort: Some(ReasoningEffort::XHigh), developer_instructions: Some("Parent planning instructions".to_string()), }, }), ..Default::default() }, ) .await .expect("updated thread permissions should be accepted"); let stored_settings = codex.thread_settings_snapshot().await; codex .submit(Op::Review { review_request: ReviewRequest { target: ReviewTarget::Custom { instructions: "review current permissions".to_string(), }, user_facing_hint: None, }, }) .await .expect("review should start"); wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; assert_eq!(codex.thread_settings_snapshot().await, stored_settings); let request = request_log.single_request(); assert_eq!(request.body_json()["reasoning"]["effort"], "medium"); assert_eq!( request.body_json()["service_tier"], ServiceTier::Fast.request_value() ); assert!( request .message_input_texts("developer") .iter() .any(|text| text.contains("Approval policy is currently never")), "review should use the updated approval policy" ); assert!( request .message_input_texts("developer") .iter() .any(|text| text.contains("REVIEW MODEL ONLY")), "review should use its own model's token-budget defaults" ); assert!( !request .message_input_texts("developer") .iter() .any(|text| text.contains("PARENT MODEL ONLY")), "review should not inherit the parent model's token-budget defaults" ); assert!( request .message_input_texts("user") .iter() .any(|text| text.contains("")), "review should use the updated permission profile" ); let review_thread_id = request.body_json()["client_metadata"]["thread_id"] .as_str() .expect("review request should include its thread ID") .to_string(); let review_rollout_path = find_thread_path_by_id_str( codex_home.path(), &review_thread_id, /*state_db_ctx*/ None, ) .await .expect("review rollout lookup should succeed") .expect("review thread should have a rollout"); let review_rollout = std::fs::read_to_string(review_rollout_path).expect("review rollout should be readable"); let review_session_cwd = review_rollout .lines() .find_map(|line| { let rollout_line = codex_rollout::parse_rollout_line(line) .expect("review rollout line should be valid"); match rollout_line.item { RolloutItem::SessionMeta(session_meta) => Some(session_meta.meta.cwd), _ => None, } }) .expect("review rollout should contain session metadata"); assert_eq!(review_session_cwd, updated_cwd.as_path()); let review_context = review_rollout .lines() .filter_map(|line| { let rollout_line = codex_rollout::parse_rollout_line(line) .expect("review rollout line should be valid"); match rollout_line.item { RolloutItem::TurnContext(turn_context) => Some(turn_context), _ => None, } }) .next_back() .expect("review rollout should contain turn context"); assert_eq!( review_context.approvals_reviewer, Some(ApprovalsReviewer::User) ); assert_eq!(review_context.personality, Some(Personality::Friendly)); // The review delegate still starts in its own default mode, not the parent's Plan mode. assert_eq!( review_context.collaboration_mode, Some(CollaborationMode { mode: ModeKind::Default, settings: Settings { model: "gpt-5.4".to_string(), reasoning_effort: Some(ReasoningEffort::Medium), developer_instructions: None, }, }) ); let _codex_home_guard = codex_home; server.verify().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn review_omits_retained_tier_when_fast_mode_disabled() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); let (server, request_log) = start_responses_server_with_sse(completed_sse(), /*expected_requests*/ 1).await; let test = test_codex() .with_model_info_override("gpt-5.4", |model| { model.service_tiers = vec![ModelServiceTier { id: ServiceTier::Flex.request_value().to_string(), name: "Flex".to_string(), description: "Flexible processing".to_string(), }]; }) .with_config(|config| { config .features .disable(Feature::FastMode) .expect("disable FastMode"); }) .build_with_auto_env(&server) .await?; core_test_support::submit_thread_settings( &test.codex, ThreadSettingsOverrides { service_tier: Some(Some(ServiceTier::Flex.request_value().to_string())), ..Default::default() }, ) .await?; test.codex .submit(Op::Review { review_request: ReviewRequest { target: ReviewTarget::Custom { instructions: "review the changes".to_string(), }, user_facing_hint: None, }, }) .await?; wait_for_event(&test.codex, |event| { matches!(event, EventMsg::TurnComplete(_)) }) .await; assert_eq!( test.codex .thread_settings_snapshot() .await .service_tier .as_deref(), Some("flex") ); assert_eq!( request_log.single_request().body_json().get("service_tier"), None ); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn review_resolves_inherited_summary_preferences() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); let (server, request_log) = start_responses_server_with_sse(completed_sse(), /*expected_requests*/ 2).await; let test = test_codex() .with_model_info_override("gpt-5.2", |model| { model.default_reasoning_summary = ReasoningSummary::Auto; }) .with_model_info_override("gpt-5.4", |model| { model.default_reasoning_summary = ReasoningSummary::Detailed; }) .with_model("gpt-5.2") .with_config(|config| { config.review_model = Some("gpt-5.4".to_string()); config.model_reasoning_summary = None; }) .build_with_auto_env(&server) .await?; // First follow the review model's default, then use a preference updated on the thread. for summary in [None, Some(ReasoningSummary::Concise)] { if let Some(summary) = summary { core_test_support::submit_thread_settings( &test.codex, ThreadSettingsOverrides { summary: Some(summary), ..Default::default() }, ) .await?; } let stored_settings = test.codex.thread_settings_snapshot().await; test.codex .submit(Op::Review { review_request: ReviewRequest { target: ReviewTarget::Custom { instructions: "review the changes".to_string(), }, user_facing_hint: None, }, }) .await?; wait_for_event(&test.codex, |event| { matches!(event, EventMsg::TurnComplete(_)) }) .await; assert_eq!(test.codex.thread_settings_snapshot().await, stored_settings); } let actual = request_log .requests() .iter() .map(|request| { let body = request.body_json(); serde_json::json!([body["model"], body["reasoning"]["summary"]]) }) .collect::>(); assert_eq!( actual, vec![ serde_json::json!(["gpt-5.4", "detailed"]), serde_json::json!(["gpt-5.4", "concise"]), ] ); Ok(()) } /// Ensure that when a custom `review_model` is set in the config, the review /// request uses that model (and not the main chat model). #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn review_uses_custom_review_model_from_config() { skip_if_no_network!(); let (server, request_log) = start_responses_server_with_sse(completed_sse(), /*expected_requests*/ 1).await; let codex_home = Arc::new(TempDir::new().unwrap()); let test = test_codex() .with_home(Arc::clone(&codex_home)) .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) .with_config(|config| { config.model = Some("gpt-4.1".to_string()); config.review_model = Some("custom-review-model".to_string()); config.model_reasoning_effort = Some(ReasoningEffort::Max); }) .build_with_auto_env(&server) .await .expect("custom review conversation should be created"); let codex = Arc::clone(&test.codex); std::fs::remove_file(codex_home.path().join("models_cache.json")) .expect("initial empty model catalog should be cached"); let mut models = codex_models_manager::bundled_models_response() .expect("bundled model catalog should parse"); let model = models .models .iter_mut() .find(|model| model.slug == "gpt-5.6-sol") .expect("bundled model should exist"); model.slug = "custom-review-model".to_string(); model.node_repl_auto_review_required = true; model.node_repl_disabled = true; let models_mock = responses::mount_models_once(&server, models).await; codex .submit(Op::Review { review_request: ReviewRequest { target: ReviewTarget::Custom { instructions: "use custom model".to_string(), }, user_facing_hint: None, }, }) .await .unwrap(); // Wait for completion let _entered = wait_for_event(&codex, |ev| matches!(ev, EventMsg::EnteredReviewMode(_))).await; let _closed = wait_for_event(&codex, |ev| { matches!( ev, EventMsg::ExitedReviewMode(ExitedReviewModeEvent { review_output: None, .. }) ) }) .await; let _complete = wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; // Assert the request body model equals the configured review model let request = request_log.single_request(); assert_eq!(request.path(), "/v1/responses"); let body = request.body_json(); assert_eq!(body["model"].as_str().unwrap(), "custom-review-model"); assert_eq!(body["reasoning"]["effort"].as_str(), Some("max")); let turn_metadata: serde_json::Value = serde_json::from_str( &request .header("x-codex-turn-metadata") .expect("review request turn metadata"), ) .expect("review request turn metadata json"); assert_eq!(turn_metadata["node_repl_auto_review_required"], true); assert_eq!(turn_metadata["node_repl_disabled"], true); assert_eq!(models_mock.requests().len(), 1); let _codex_home_guard = codex_home; server.verify().await; } /// Ensure that when `review_model` is not set in the config, the review request /// uses the session model without exposing disabled clock tools or reminders. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn review_uses_session_model_when_review_model_unset() { skip_if_no_network!(); let (server, request_log) = start_responses_server_with_sse(completed_sse(), /*expected_requests*/ 1).await; let codex_home = Arc::new(TempDir::new().unwrap()); let test = test_codex() .with_home(Arc::clone(&codex_home)) .with_config(|config| { config.model = Some("gpt-5.4".to_string()); config.review_model = None; config.model_reasoning_effort = Some(ReasoningEffort::Persistent); }) .build_with_auto_env(&server) .await .expect("same-model review conversation should be created"); let codex = Arc::clone(&test.codex); codex .submit(Op::Review { review_request: ReviewRequest { target: ReviewTarget::Custom { instructions: "use session model".to_string(), }, user_facing_hint: None, }, }) .await .unwrap(); let _entered = wait_for_event(&codex, |ev| matches!(ev, EventMsg::EnteredReviewMode(_))).await; let _closed = wait_for_event(&codex, |ev| { matches!( ev, EventMsg::ExitedReviewMode(ExitedReviewModeEvent { review_output: None, .. }) ) }) .await; let _complete = wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; let request = request_log.single_request(); assert_eq!(request.path(), "/v1/responses"); let body = request.body_json(); assert_eq!(body["model"].as_str().unwrap(), "gpt-5.4"); assert_eq!(body["reasoning"]["effort"].as_str(), Some("disabled")); assert_eq!( ["curr_time", "sleep"].map(|name| request.tool_by_name("clock", name).is_some()), [false, false] ); assert!(!request.has_content_kinds(&["current_time.reminder"])); let _codex_home_guard = codex_home; server.verify().await; } /// When a review session begins, it must not prepend prior chat history from /// the parent session. The request `input` should contain only the review /// prompt from the user. // Windows CI only: bump to 4 workers to prevent SSE/event starvation and test timeouts. #[cfg_attr(windows, tokio::test(flavor = "multi_thread", worker_threads = 4))] #[cfg_attr(not(windows), tokio::test(flavor = "multi_thread", worker_threads = 2))] async fn review_input_isolated_from_parent_history() { skip_if_no_network!(); let (server, request_log) = start_responses_server_with_sse(completed_sse(), /*expected_requests*/ 1).await; // Seed a parent session history via resume file with both user + assistant items. let codex_home = Arc::new(TempDir::new().unwrap()); let session_file = codex_home.path().join("resume.jsonl"); { let mut f = tokio::fs::File::create(&session_file).await.unwrap(); let convo_id = Uuid::new_v4(); // Proper session_meta line (enveloped) with a conversation id let meta_line = serde_json::json!({ "timestamp": "2024-01-01T00:00:00.000Z", "type": "session_meta", "payload": { "session_id": convo_id, "id": convo_id, "timestamp": "2024-01-01T00:00:00Z", "cwd": ".", "originator": "test_originator", "cli_version": "test_version", "model_provider": "test-provider" } }); f.write_all(format!("{meta_line}\n").as_bytes()) .await .unwrap(); // Prior user message (enveloped response_item) let user = codex_protocol::models::ResponseItem::Message { id: None, role: "user".to_string(), content: vec![codex_protocol::models::ContentItem::InputText { text: "parent: earlier user message".to_string(), }], phase: None, internal_chat_message_metadata_passthrough: None, }; let user_json = serde_json::to_value(&user).unwrap(); let user_line = serde_json::json!({ "timestamp": "2024-01-01T00:00:01.000Z", "type": "response_item", "payload": user_json }); f.write_all(format!("{user_line}\n").as_bytes()) .await .unwrap(); // Prior assistant message (enveloped response_item) let assistant = codex_protocol::models::ResponseItem::Message { id: None, role: "assistant".to_string(), content: vec![codex_protocol::models::ContentItem::OutputText { text: "parent: assistant reply".to_string(), }], phase: None, internal_chat_message_metadata_passthrough: None, }; let assistant_json = serde_json::to_value(&assistant).unwrap(); let assistant_line = serde_json::json!({ "timestamp": "2024-01-01T00:00:02.000Z", "type": "response_item", "payload": assistant_json }); f.write_all(format!("{assistant_line}\n").as_bytes()) .await .unwrap(); } let codex = resume_conversation_for_server(&server, codex_home.clone(), session_file.clone(), |_| {}) .await; // Submit review request; it must start fresh (no parent history in `input`). let review_prompt = "Please review only this".to_string(); codex .submit(Op::Review { review_request: ReviewRequest { target: ReviewTarget::Custom { instructions: review_prompt.clone(), }, user_facing_hint: None, }, }) .await .unwrap(); let _entered = wait_for_event(&codex, |ev| matches!(ev, EventMsg::EnteredReviewMode(_))).await; let _closed = wait_for_event(&codex, |ev| { matches!( ev, EventMsg::ExitedReviewMode(ExitedReviewModeEvent { review_output: None, .. }) ) }) .await; let _complete = wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; // Assert the request `input` contains the environment context followed by the user review prompt. let request = request_log.single_request(); assert_eq!(request.path(), "/v1/responses"); let body = request.body_json(); let input = body["input"].as_array().expect("input array"); assert!( input.len() >= 2, "expected at least environment context and review prompt" ); let env_text = input .iter() .filter_map(|msg| msg.get("content").and_then(|content| content.as_array())) .flat_map(|content| content.iter()) .filter_map(|entry| entry.get("text").and_then(|text| text.as_str())) .find(|text| text.starts_with(ENVIRONMENT_CONTEXT_OPEN_TAG)) .expect("env text"); assert!( env_text.contains(""), "environment context should include cwd" ); let review_text = input .iter() .filter_map(|msg| msg.get("content").and_then(|content| content.as_array())) .flat_map(|content| content.iter()) .filter_map(|entry| entry.get("text").and_then(|text| text.as_str())) .find(|text| *text == review_prompt) .expect("review prompt text"); assert_eq!( review_text, review_prompt, "user message should only contain the raw review prompt" ); // Ensure the REVIEW_PROMPT rubric is sent via instructions. let instructions = body["instructions"].as_str().expect("instructions string"); assert_eq!(instructions, REVIEW_PROMPT); // Also verify that a user interruption note was recorded in the rollout. let path = codex.rollout_path().expect("rollout path"); let text = std::fs::read_to_string(&path).expect("read rollout file"); let mut saw_interruption_message = false; for line in text.lines() { if line.trim().is_empty() { continue; } let v: serde_json::Value = serde_json::from_str(line).expect("jsonl line"); let rl = codex_rollout::decode_rollout_line(v).expect("rollout line"); if let RolloutItem::ResponseItem(envelope) = rl.item && let ResponseItem::Message { role, content, .. } = envelope.item && role == "user" { for c in content { if let ContentItem::InputText { text } = c && text.contains("User initiated a review task, but was interrupted.") { saw_interruption_message = true; break; } } } if saw_interruption_message { break; } } assert!( saw_interruption_message, "expected user interruption message in rollout" ); let _codex_home_guard = codex_home; server.verify().await; } /// After a review thread finishes, its conversation should be visible in the /// parent session so later turns can reference the results. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn review_history_surfaces_in_parent_session() { skip_if_no_network!(); let (server, request_log) = start_responses_server_with_sse( assistant_message_sse("review assistant output"), /*expected_requests*/ 2, ) .await; let codex_home = Arc::new(TempDir::new().unwrap()); let codex = new_conversation_for_server(&server, codex_home.clone(), |_| {}).await; // 1) Run a review turn that produces an assistant message (isolated in child). codex .submit(Op::Review { review_request: ReviewRequest { target: ReviewTarget::Custom { instructions: "Start a review".to_string(), }, user_facing_hint: None, }, }) .await .unwrap(); let _entered = wait_for_event(&codex, |ev| matches!(ev, EventMsg::EnteredReviewMode(_))).await; let _closed = wait_for_event(&codex, |ev| { matches!( ev, EventMsg::ExitedReviewMode(ExitedReviewModeEvent { review_output: Some(_), .. }) ) }) .await; let _complete = wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; // 2) Continue in the parent session; request input must not include any review items. let followup = "back to parent".to_string(); codex .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { text: followup.clone(), text_elements: Vec::new(), }])) .await .unwrap(); let _complete = wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; // Inspect the second request (parent turn) input contents. // Parent turns include session initial messages (user_instructions, environment_context). // Critically, no messages from the review thread should appear. let requests = request_log.requests(); assert_eq!(requests.len(), 2); for request in &requests { assert_eq!(request.path(), "/v1/responses"); } let body = requests[1].body_json(); let input = body["input"].as_array().expect("input array"); // Must include the followup as the last item for this turn let last = input.last().expect("at least one item in input"); assert_eq!(last["role"].as_str().unwrap(), "user"); let last_text = last["content"][0]["text"].as_str().unwrap(); assert_eq!(last_text, followup); // Ensure review-thread content is present for downstream turns. let contains_review_rollout_user = input.iter().any(|msg| { msg["content"][0]["text"] .as_str() .unwrap_or_default() .contains("User initiated a review task.") }); let contains_review_assistant = input.iter().any(|msg| { msg["content"][0]["text"] .as_str() .unwrap_or_default() .contains("review assistant output") }); assert!( contains_review_rollout_user, "review rollout user message missing from parent turn input" ); assert!( contains_review_assistant, "review assistant output missing from parent turn input" ); let _codex_home_guard = codex_home; server.verify().await; } /// `/review` should use the session's current cwd (including runtime overrides) /// when resolving base-branch review prompts (merge-base computation). #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn review_uses_overridden_cwd_for_base_branch_merge_base() { skip_if_no_network!(); let (server, request_log) = start_responses_server_with_sse(completed_sse(), /*expected_requests*/ 1).await; let initial_cwd = TempDir::new().unwrap(); let repo_dir = TempDir::new().unwrap(); let repo_path = repo_dir.path(); fn run_git(repo_path: &std::path::Path, args: &[&str]) { let output = std::process::Command::new("git") .arg("-C") .arg(repo_path) .args(args) .output() .expect("spawn git"); assert!( output.status.success(), "git {:?} failed: stdout={:?} stderr={:?}", args, String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); } run_git(repo_path, &["init", "-b", "main"]); run_git(repo_path, &["config", "user.email", "test@example.com"]); run_git(repo_path, &["config", "user.name", "Test User"]); std::fs::write(repo_path.join("file.txt"), "hello\n").unwrap(); run_git(repo_path, &["add", "."]); run_git(repo_path, &["commit", "-m", "initial"]); let head_sha = std::process::Command::new("git") .arg("-C") .arg(repo_path) .args(["rev-parse", "HEAD"]) .output() .expect("rev-parse HEAD"); assert!(head_sha.status.success()); let head_sha = String::from_utf8(head_sha.stdout) .expect("utf8 sha") .trim() .to_string(); let codex_home = Arc::new(TempDir::new().unwrap()); let initial_cwd_path = initial_cwd.path().to_path_buf(); let codex = new_conversation_for_server(&server, codex_home.clone(), move |config| { config.cwd = initial_cwd_path.abs(); }) .await; core_test_support::submit_thread_settings( &codex, ThreadSettingsOverrides { environments: Some(local_selections(repo_path.to_path_buf().abs())), ..Default::default() }, ) .await .unwrap(); codex .submit(Op::Review { review_request: ReviewRequest { target: ReviewTarget::BaseBranch { branch: "main".to_string(), }, user_facing_hint: None, }, }) .await .unwrap(); let _entered = wait_for_event(&codex, |ev| matches!(ev, EventMsg::EnteredReviewMode(_))).await; let _complete = wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; let requests = request_log.requests(); assert_eq!(requests.len(), 1); for request in &requests { assert_eq!(request.path(), "/v1/responses"); } let body = requests[0].body_json(); let input = body["input"].as_array().expect("input array"); let saw_merge_base_sha = input .iter() .filter_map(|msg| msg["content"][0]["text"].as_str()) .any(|text| text.contains(&head_sha)); assert!( saw_merge_base_sha, "expected review prompt to include merge-base sha {head_sha}" ); let _codex_home_guard = codex_home; server.verify().await; } fn assistant_message_sse(text: &str) -> Vec { vec![ responses::ev_assistant_message("msg-1", text), responses::ev_completed("resp-1"), ] } fn completed_sse() -> Vec { vec![responses::ev_completed("resp-1")] } /// Start a mock Responses API server and mount the given SSE events. async fn start_responses_server_with_sse( events: Vec, expected_requests: usize, ) -> (MockServer, ResponseMock) { let server = start_mock_server().await; let sse = responses::sse(events); let responses = vec![sse; expected_requests]; let request_log = mount_sse_sequence(&server, responses).await; (server, request_log) } /// Create a conversation configured to talk to the provided mock server. async fn new_conversation_for_server( server: &MockServer, codex_home: Arc, mutator: F, ) -> Arc where F: FnOnce(&mut Config) + Send + 'static, { let base_url = format!("{}/v1", server.uri()); let mut builder = test_codex() .with_home(codex_home) .with_config(move |config| { config.model_provider.base_url = Some(base_url.clone()); mutator(config); }); builder .build(server) .await .expect("create conversation") .codex } /// Create a conversation resuming from a rollout file, configured to talk to the provided mock server. async fn resume_conversation_for_server( server: &MockServer, codex_home: Arc, resume_path: std::path::PathBuf, mutator: F, ) -> Arc where F: FnOnce(&mut Config) + Send + 'static, { let base_url = format!("{}/v1", server.uri()); let mut builder = test_codex() .with_home(codex_home.clone()) .with_config(move |config| { config.model_provider.base_url = Some(base_url.clone()); mutator(config); }); builder .resume(server, codex_home, resume_path) .await .expect("resume conversation") .codex }