use anyhow::Result; use app_test_support::McpProcess; use app_test_support::to_response; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::UserInput as V2UserInput; use core_test_support::responses; use core_test_support::skip_if_no_network; use pretty_assertions::assert_eq; use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] async fn turn_start_accepts_output_schema_v2() -> Result<()> { skip_if_no_network!(Ok(())); let server = responses::start_mock_server().await; let body = responses::sse(vec![ responses::ev_response_created("resp-1"), responses::ev_assistant_message("msg-1", "Done"), responses::ev_completed("resp-1"), ]); let response_mock = responses::mount_sse_once(&server, body).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_req = mcp .send_thread_start_request(ThreadStartParams { ..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)?; let output_schema = serde_json::json!({ "type": "object", "properties": { "answer": { "type": "string" } }, "required": ["answer"], "additionalProperties": false }); let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "Hello".to_string(), text_elements: Vec::new(), }], output_schema: Some(output_schema.clone()), ..Default::default() }) .await?; let turn_resp: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), ) .await??; let _turn: TurnStartResponse = to_response::(turn_resp)?; timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_notification_message("turn/completed"), ) .await??; let request = response_mock.single_request(); let payload = request.body_json(); let text = payload.get("text").expect("request missing text field"); let format = text .get("format") .expect("request missing text.format field"); assert_eq!( format, &serde_json::json!({ "name": "codex_output_schema", "type": "json_schema", "strict": true, "schema": output_schema, }) ); Ok(()) } #[tokio::test] async fn turn_start_output_schema_is_per_turn_v2() -> Result<()> { skip_if_no_network!(Ok(())); let server = responses::start_mock_server().await; let body1 = responses::sse(vec![ responses::ev_response_created("resp-1"), responses::ev_assistant_message("msg-1", "Done"), responses::ev_completed("resp-1"), ]); let response_mock1 = responses::mount_sse_once(&server, body1).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_req = mcp .send_thread_start_request(ThreadStartParams { ..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)?; let output_schema = serde_json::json!({ "type": "object", "properties": { "answer": { "type": "string" } }, "required": ["answer"], "additionalProperties": false }); let turn_req_1 = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "Hello".to_string(), text_elements: Vec::new(), }], output_schema: Some(output_schema.clone()), ..Default::default() }) .await?; let turn_resp_1: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(turn_req_1)), ) .await??; let _turn: TurnStartResponse = to_response::(turn_resp_1)?; timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_notification_message("turn/completed"), ) .await??; let payload1 = response_mock1.single_request().body_json(); assert_eq!( payload1.pointer("/text/format"), Some(&serde_json::json!({ "name": "codex_output_schema", "type": "json_schema", "strict": true, "schema": output_schema, })) ); let body2 = responses::sse(vec![ responses::ev_response_created("resp-2"), responses::ev_assistant_message("msg-2", "Done"), responses::ev_completed("resp-2"), ]); let response_mock2 = responses::mount_sse_once(&server, body2).await; let turn_req_2 = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "Hello again".to_string(), text_elements: Vec::new(), }], output_schema: None, ..Default::default() }) .await?; let turn_resp_2: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(turn_req_2)), ) .await??; let _turn: TurnStartResponse = to_response::(turn_resp_2)?; timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_notification_message("turn/completed"), ) .await??; let payload2 = response_mock2.single_request().body_json(); assert_eq!(payload2.pointer("/text/format"), None); Ok(()) } fn create_config_toml(codex_home: &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 for test" base_url = "{server_uri}/v1" wire_api = "responses" request_max_retries = 0 stream_max_retries = 0 "# ), ) }