use codex_core::CodexThread;use codex_core::REVIEW_PROMPT;use codex_core::config::Config;use codex_core::review_format::render_review_output_text;use codex_protocol::models::ContentItem;use codex_protocol::models::ResponseItem;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::RolloutItem;use codex_protocol::protocol::RolloutLine;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 tempfile::TempDir;use tokio::io::AsyncWriteExt as _;use uuid::Uuid;use wiremock::MockServer;/// Verify that submitting `Op::Review` spawns a child task and emits/// EnteredReviewMode -> ExitedReviewMode(None) -> TurnComplete/// in that order when the model returns a structured review JSON 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(); // Verify lifecycle: Entered -> Exited(Some(review)) -> TurnComplete. 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:?}"), }; // 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: RolloutLine = serde_json::from_str(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()) ); // 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: RolloutLine = serde_json::from_value(v).expect("rollout line"); if let RolloutItem::ResponseItem(ResponseItem::Message { role, content, .. }) = rl.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("<user_action>") { 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;}/// 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;}/// 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()); // Choose a review model different from the main model; ensure it is used. let codex = new_conversation_for_server(&server, codex_home.clone(), |cfg| { cfg.model = Some("gpt-4.1".to_string()); cfg.review_model = Some("gpt-5.4".to_string()); }) .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(), "gpt-5.4"); 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.#[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 codex = new_conversation_for_server(&server, codex_home.clone(), |cfg| { cfg.model = Some("gpt-4.1".to_string()); cfg.review_model = None; }) .await; 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-4.1"); 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": { "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, metadata: 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, metadata: 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("<cwd>"), "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: RolloutLine = serde_json::from_value(v).expect("rollout line"); if let RolloutItem::ResponseItem(ResponseItem::Message { role, content, .. }) = rl.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 .submit(Op::UserInput { items: vec![UserInput::Text { text: followup.clone(), text_elements: Vec::new(), }], final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: Default::default(), }) .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, codex_protocol::protocol::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<serde_json::Value> { vec![ responses::ev_assistant_message("msg-1", text), responses::ev_completed("resp-1"), ]}fn completed_sse() -> Vec<serde_json::Value> { 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<serde_json::Value>, 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<F>( server: &MockServer, codex_home: Arc<TempDir>, mutator: F,) -> Arc<CodexThread>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<F>( server: &MockServer, codex_home: Arc<TempDir>, resume_path: std::path::PathBuf, mutator: F,) -> Arc<CodexThread>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}