use anyhow::Result; use app_test_support::McpProcess; use app_test_support::create_final_assistant_message_sse_response; use app_test_support::create_mock_chat_completions_server_unchecked; use app_test_support::to_response; use codex_app_server_protocol::ItemCompletedNotification; use codex_app_server_protocol::ItemStartedNotification; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ReviewStartParams; use codex_app_server_protocol::ReviewTarget; use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnStatus; use serde_json::json; use tempfile::TempDir; use tokio::time::timeout; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); const INVALID_REQUEST_ERROR_CODE: i64 = -32600; #[tokio::test] async fn review_start_runs_review_turn_and_emits_code_review_item() -> Result<()> { let review_payload = json!({ "findings": [ { "title": "Prefer Stylize helpers", "body": "Use .dim()/.bold() chaining instead of manual Style.", "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": "Looks solid overall with minor polish suggested.", "overall_confidence_score": 0.75 }) .to_string(); let responses = vec![create_final_assistant_message_sse_response( &review_payload, )?]; let server = create_mock_chat_completions_server_unchecked(responses).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_id = start_default_thread(&mut mcp).await?; let review_req = mcp .send_review_start_request(ReviewStartParams { thread_id: thread_id.clone(), append_to_original_thread: true, target: ReviewTarget::Commit { sha: "1234567deadbeef".to_string(), title: Some("Tidy UI colors".to_string()), }, }) .await?; let review_resp: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(review_req)), ) .await??; let TurnStartResponse { turn } = to_response::(review_resp)?; let turn_id = turn.id.clone(); assert_eq!(turn.status, TurnStatus::InProgress); assert_eq!(turn.items.len(), 1); match &turn.items[0] { ThreadItem::UserMessage { content, .. } => { assert_eq!(content.len(), 1); assert!(matches!( &content[0], codex_app_server_protocol::UserInput::Text { .. } )); } other => panic!("expected user message, got {other:?}"), } let _started: JSONRPCNotification = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_notification_message("turn/started"), ) .await??; let item_started: JSONRPCNotification = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_notification_message("item/started"), ) .await??; let started: ItemStartedNotification = serde_json::from_value(item_started.params.expect("params must be present"))?; match started.item { ThreadItem::CodeReview { id, review } => { assert_eq!(id, turn_id); assert_eq!(review, "commit 1234567"); } other => panic!("expected code review item, got {other:?}"), } let mut review_body: Option = None; for _ in 0..5 { let review_notif: JSONRPCNotification = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_notification_message("item/completed"), ) .await??; let completed: ItemCompletedNotification = serde_json::from_value(review_notif.params.expect("params must be present"))?; match completed.item { ThreadItem::CodeReview { id, review } => { assert_eq!(id, turn_id); review_body = Some(review); break; } ThreadItem::UserMessage { .. } => continue, other => panic!("unexpected item/completed payload: {other:?}"), } } let review = review_body.expect("did not observe a code review item"); assert!(review.contains("Prefer Stylize helpers")); assert!(review.contains("/tmp/file.rs:10-20")); Ok(()) } #[tokio::test] async fn review_start_rejects_empty_base_branch() -> Result<()> { let server = create_mock_chat_completions_server_unchecked(vec![]).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_id = start_default_thread(&mut mcp).await?; let request_id = mcp .send_review_start_request(ReviewStartParams { thread_id, append_to_original_thread: true, target: ReviewTarget::BaseBranch { branch: " ".to_string(), }, }) .await?; let error: JSONRPCError = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_error_message(RequestId::Integer(request_id)), ) .await??; assert_eq!(error.error.code, INVALID_REQUEST_ERROR_CODE); assert!( error.error.message.contains("branch must not be empty"), "unexpected message: {}", error.error.message ); Ok(()) } #[tokio::test] async fn review_start_rejects_empty_commit_sha() -> Result<()> { let server = create_mock_chat_completions_server_unchecked(vec![]).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_id = start_default_thread(&mut mcp).await?; let request_id = mcp .send_review_start_request(ReviewStartParams { thread_id, append_to_original_thread: true, target: ReviewTarget::Commit { sha: "\t".to_string(), title: None, }, }) .await?; let error: JSONRPCError = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_error_message(RequestId::Integer(request_id)), ) .await??; assert_eq!(error.error.code, INVALID_REQUEST_ERROR_CODE); assert!( error.error.message.contains("sha must not be empty"), "unexpected message: {}", error.error.message ); Ok(()) } #[tokio::test] async fn review_start_rejects_empty_custom_instructions() -> Result<()> { let server = create_mock_chat_completions_server_unchecked(vec![]).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_id = start_default_thread(&mut mcp).await?; let request_id = mcp .send_review_start_request(ReviewStartParams { thread_id, append_to_original_thread: true, target: ReviewTarget::Custom { instructions: "\n\n".to_string(), }, }) .await?; let error: JSONRPCError = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_error_message(RequestId::Integer(request_id)), ) .await??; assert_eq!(error.error.code, INVALID_REQUEST_ERROR_CODE); assert!( error .error .message .contains("instructions must not be empty"), "unexpected message: {}", error.error.message ); Ok(()) } async fn start_default_thread(mcp: &mut McpProcess) -> Result { let thread_req = mcp .send_thread_start_request(ThreadStartParams { model: Some("mock-model".to_string()), ..Default::default() }) .await?; let thread_resp: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), ) .await??; let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; Ok(thread.id) } fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { let config_toml = codex_home.join("config.toml"); std::fs::write( config_toml, format!( r#" model = "mock-model" approval_policy = "never" sandbox_mode = "read-only" model_provider = "mock_provider" [model_providers.mock_provider] name = "Mock provider" base_url = "{server_uri}/v1" wire_api = "chat" request_max_retries = 0 stream_max_retries = 0 "# ), ) }