#![cfg(not(target_os = "windows"))] #![allow(clippy::unwrap_used)] use anyhow::Context; use anyhow::Result; use codex_config::types::McpServerConfig; use codex_config::types::McpServerTransportConfig; use codex_core::TurnInputRequest; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Settings; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::protocol::TruncationPolicy; use codex_protocol::user_input::UserInput; use core_test_support::TempDirExt; use core_test_support::assert_regex_match; use core_test_support::responses; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_function_call; use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_sse_once; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; use core_test_support::skip_if_wine_exec; use core_test_support::stdio_server_bin; use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::TestCodexBuilder; use core_test_support::test_codex::local_selections; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; use core_test_support::wait_for_mcp_server; use serde_json::Value; use serde_json::json; use std::collections::HashMap; use std::time::Duration; use test_case::test_case; use wiremock::MockServer; use super::rmcp_client::remote_aware_environment_id; use super::rmcp_client::remote_aware_stdio_server_bin; fn assert_wall_time_header(output: &str) { let (wall_time, marker) = output .split_once('\n') .expect("wall-time header should contain an Output marker"); assert_regex_match(r"^Wall time: [0-9]+(?:\.[0-9]+)? seconds$", wall_time); assert_eq!(marker, "Output:"); } // Verifies that a standard tool call (exec_command) exceeding the model formatting // limits is truncated before being sent back to the model. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn tool_call_output_configured_limit_chars_type() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; // Use a model that exposes the exec_command tool. let mut builder = test_codex().with_model("gpt-5.2").with_config(|config| { config.tool_output_token_limit = Some(100_000); }); let fixture = builder.build(&server).await?; let call_id = "shell-too-large"; let command = if cfg!(windows) { "for ($i=1; $i -le 100000; $i++) { Write-Output $i }" } else { "seq 1 100000" }; let args = serde_json::json!({ "cmd": command, "yield_time_ms": 5_000, "max_output_tokens": 100_000, }); // First response: model tells us to run the tool; second: complete the turn. mount_sse_once( &server, sse(vec![ responses::ev_response_created("resp-1"), responses::ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?), responses::ev_completed("resp-1"), ]), ) .await; let mock2 = mount_sse_once( &server, sse(vec![ responses::ev_assistant_message("msg-1", "done"), responses::ev_completed("resp-2"), ]), ) .await; fixture .submit_turn_with_permission_profile( "trigger big shell output", PermissionProfile::Disabled, ) .await?; // Inspect what we sent back to the model; it should contain a truncated // function_call_output for the shell call. let output = mock2 .single_request() .function_call_output_text(call_id) .context("function_call_output present for shell call")?; let output = output.replace("\r\n", "\n"); // Expect plain text (not JSON) containing the entire shell output. assert!( serde_json::from_str::(&output).is_err(), "expected truncated shell output to be plain text" ); assert!( (400_000..=401_000).contains(&output.len()), "expected output near the configured 100k-token budget, got {} bytes", output.len() ); assert!( output.contains("chars truncated"), "unified exec should preserve the model's byte-based truncation policy" ); Ok(()) } // Verifies that a standard tool call (exec_command) exceeding the model formatting // limits is truncated before being sent back to the model. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn tool_call_output_exceeds_limit_truncated_chars_limit() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; // Use a model that exposes the exec_command tool. let mut builder = test_codex().with_model("gpt-5.2"); let fixture = builder.build(&server).await?; let call_id = "shell-too-large"; let command = if cfg!(windows) { "for ($i=1; $i -le 100000; $i++) { Write-Output $i }" } else { "seq 1 100000" }; let args = serde_json::json!({ "cmd": command, "yield_time_ms": 5_000, }); // First response: model tells us to run the tool; second: complete the turn. mount_sse_once( &server, sse(vec![ responses::ev_response_created("resp-1"), responses::ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?), responses::ev_completed("resp-1"), ]), ) .await; let mock2 = mount_sse_once( &server, sse(vec![ responses::ev_assistant_message("msg-1", "done"), responses::ev_completed("resp-2"), ]), ) .await; fixture .submit_turn_with_permission_profile( "trigger big shell output", PermissionProfile::Disabled, ) .await?; // Inspect what we sent back to the model; it should contain a truncated // function_call_output for the shell call. let output = mock2 .single_request() .function_call_output_text(call_id) .context("function_call_output present for shell call")?; let output = output.replace("\r\n", "\n"); // Expect plain text (not JSON) containing the entire shell output. assert!( serde_json::from_str::(&output).is_err(), "expected truncated shell output to be plain text" ); let truncated_pattern = r#"(?s)^Chunk ID: [^\n]+\nWall time: [0-9]+(?:\.[0-9]+)? seconds\nProcess exited with code 0\nOriginal token count: \d+\nOutput:\nWarning: truncated output \(original token count: \d+\)\nTotal output lines: 100000\n\n.*?…\d+ chars truncated….*$"#; assert_regex_match(truncated_pattern, &output); let len = output.len(); assert!( (9_900..=10_500).contains(&len), "expected ~10k chars after truncation, got {len}" ); Ok(()) } // Verifies that a standard tool call (exec_command) exceeding the model formatting // limits is truncated before being sent back to the model. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn tool_call_output_exceeds_limit_truncated_for_model() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; // Use a model that exposes the exec_command tool. let mut builder = test_codex().with_model("gpt-5.4"); let fixture = builder.build(&server).await?; let call_id = "shell-too-large"; let command = if cfg!(windows) { "for ($i=1; $i -le 100000; $i++) { Write-Output $i }" } else { "seq 1 100000" }; let args = serde_json::json!({ "cmd": command, "yield_time_ms": 5_000, }); // First response: model tells us to run the tool; second: complete the turn. mount_sse_once( &server, sse(vec![ responses::ev_response_created("resp-1"), responses::ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?), responses::ev_completed("resp-1"), ]), ) .await; let mock2 = mount_sse_once( &server, sse(vec![ responses::ev_assistant_message("msg-1", "done"), responses::ev_completed("resp-2"), ]), ) .await; fixture .submit_turn_with_permission_profile( "trigger big shell output", PermissionProfile::Disabled, ) .await?; // Inspect what we sent back to the model; it should contain a truncated // function_call_output for the shell call. let output = mock2 .single_request() .function_call_output_text(call_id) .context("function_call_output present for shell call")?; let output = output.replace("\r\n", "\n"); // Expect plain text (not JSON) containing the entire shell output. assert!( serde_json::from_str::(&output).is_err(), "expected truncated shell output to be plain text" ); let truncated_pattern = r#"(?s)^Chunk ID: [^\n]+ Wall time: [0-9]+(?:\.[0-9]+)? seconds Process exited with code 0 Original token count: \d+ Output: Warning: truncated output \(original token count: \d+\) Total output lines: 100000 1 2 3 4 5 6 .*…\d+ tokens truncated.* 99999 100000 $"#; assert_regex_match(truncated_pattern, &output); Ok(()) } // Ensures exec_command outputs that exceed the line limit are truncated only once. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn tool_call_output_truncated_only_once() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; let mut builder = test_codex().with_model("gpt-5.4"); let fixture = builder.build(&server).await?; let call_id = "shell-single-truncation"; let command = if cfg!(windows) { "for ($i=1; $i -le 10000; $i++) { Write-Output $i }" } else { "seq 1 10000" }; let args = serde_json::json!({ "cmd": command, "yield_time_ms": 5_000, }); mount_sse_once( &server, sse(vec![ responses::ev_response_created("resp-1"), responses::ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?), responses::ev_completed("resp-1"), ]), ) .await; let mock2 = mount_sse_once( &server, sse(vec![ responses::ev_assistant_message("msg-1", "done"), responses::ev_completed("resp-2"), ]), ) .await; fixture .submit_turn_with_permission_profile( "trigger big shell output", PermissionProfile::Disabled, ) .await?; let output = mock2 .single_request() .function_call_output_text(call_id) .context("function_call_output present for shell call")?; let truncation_markers = output.matches("tokens truncated").count(); assert_eq!( truncation_markers, 1, "shell output should carry only one truncation marker: {output}" ); Ok(()) } // Verifies that an MCP tool call result exceeding the model formatting limits // is truncated before being sent back to the model. #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn mcp_tool_call_output_exceeds_limit_truncated_for_model() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; let call_id = "rmcp-truncated"; let server_name = "rmcp"; let namespace = format!("mcp__{server_name}"); // Build a very large message to exceed 10KiB once serialized. let large_msg = "long-message-with-newlines-".repeat(6000); let args_json = serde_json::json!({ "message": large_msg }); mount_sse_once( &server, sse(vec![ responses::ev_response_created("resp-1"), responses::ev_function_call_with_namespace( call_id, &namespace, "echo", &args_json.to_string(), ), responses::ev_completed("resp-1"), ]), ) .await; let mock2 = mount_sse_once( &server, sse(vec![ responses::ev_assistant_message("msg-1", "rmcp echo tool completed."), responses::ev_completed("resp-2"), ]), ) .await; // Compile the rmcp stdio test server and configure it. let rmcp_test_server_bin = stdio_server_bin()?; let mut builder = test_codex().with_config(move |config| { let mut servers = config.mcp_servers.get().clone(); servers.insert( server_name.to_string(), codex_config::types::McpServerConfig { auth: Default::default(), transport: codex_config::types::McpServerTransportConfig::Stdio { command: rmcp_test_server_bin, args: Vec::new(), env: None, env_vars: Vec::new(), cwd: None, }, environment_id: "local".to_string(), enabled: true, required: false, supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: Some(std::time::Duration::from_secs(10)), tool_timeout_sec: None, default_tools_approval_mode: None, enabled_tools: None, disabled_tools: None, scopes: None, oauth: None, oauth_resource: None, tools: HashMap::new(), }, ); config .mcp_servers .set(servers) .expect("test mcp servers should accept any configuration"); config.tool_output_token_limit = Some(500); }); let fixture = builder.build(&server).await?; wait_for_mcp_server(&fixture.codex, server_name).await?; fixture .submit_turn_with_permission_profile( "call the rmcp echo tool with a very large message", PermissionProfile::read_only(), ) .await?; // The MCP tool call output is converted to a function_call_output for the model. let output = mock2 .single_request() .function_call_output_text(call_id) .context("function_call_output present for rmcp call")?; assert!( !output.contains("Total output lines:"), "MCP output should not include line-based truncation header: {output}" ); let truncated_pattern = r#"(?s)^Wall time: [0-9]+(?:\.[0-9]+)? seconds\nOutput:\n\{"echo":\s*"ECHOING: long-message-with-newlines-.*tokens truncated.*long-message-with-newlines-.*$"#; assert_regex_match(truncated_pattern, &output); assert!(output.len() < 2600, "{}", output.len()); Ok(()) } // Verifies that an MCP image tool output is serialized as content_items array with // the image preserved and no truncation summary appended (since there are no text items). #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; let call_id = "rmcp-image-no-trunc"; let server_name = "rmcp"; let namespace = format!("mcp__{server_name}"); mount_sse_once( &server, sse(vec![ ev_response_created("resp-1"), responses::ev_function_call_with_namespace(call_id, &namespace, "image", "{}"), ev_completed("resp-1"), ]), ) .await; let final_mock = mount_sse_once( &server, sse(vec![ ev_assistant_message("msg-1", "done"), ev_completed("resp-2"), ]), ) .await; // Build the stdio rmcp server and pass a tiny PNG via data URL so it can construct ImageContent. let rmcp_test_server_bin = stdio_server_bin()?; // 1x1 PNG data URL let openai_png = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="; let mut builder = test_codex().with_config(move |config| { let mut servers = config.mcp_servers.get().clone(); servers.insert( server_name.to_string(), McpServerConfig { auth: Default::default(), transport: McpServerTransportConfig::Stdio { command: rmcp_test_server_bin, args: Vec::new(), env: Some(HashMap::from([( "MCP_TEST_IMAGE_DATA_URL".to_string(), openai_png.to_string(), )])), env_vars: Vec::new(), cwd: None, }, environment_id: "local".to_string(), enabled: true, required: false, supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: Some(Duration::from_secs(10)), tool_timeout_sec: None, default_tools_approval_mode: None, enabled_tools: None, disabled_tools: None, scopes: None, oauth: None, oauth_resource: None, tools: HashMap::new(), }, ); config .mcp_servers .set(servers) .expect("test mcp servers should accept any configuration"); }); let fixture = builder.build(&server).await?; wait_for_mcp_server(&fixture.codex, server_name).await?; let session_model = fixture.session_configured.model.clone(); let permission_profile = PermissionProfile::read_only(); let sandbox_policy = permission_profile.to_legacy_sandbox_policy(fixture.cwd.path())?; fixture .codex .start_or_steer_turn( TurnInputRequest::user_input(vec![UserInput::Text { text: "call the rmcp image tool".into(), text_elements: Vec::new(), }]) .with_thread_settings(ThreadSettingsOverrides { environments: Some(local_selections(fixture.cwd.abs())), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile: Some(permission_profile), collaboration_mode: Some(CollaborationMode { mode: ModeKind::Default, settings: Settings { model: session_model, reasoning_effort: None, developer_instructions: None, }, }), ..Default::default() }), ) .await?; // Wait for completion to ensure the outbound request is captured. wait_for_event(&fixture.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; let output_item = final_mock.single_request().function_call_output(call_id); // Expect exactly the wall-time text and image item; no trailing truncation summary. let output = output_item.get("output").expect("output"); assert!(output.is_array(), "expected array output"); let arr = output.as_array().unwrap(); assert_eq!(arr.len(), 2, "no truncation summary should be appended"); assert_wall_time_header( arr[0]["text"] .as_str() .expect("first MCP image output item should be wall-time text"), ); assert_eq!( arr[1], json!({"type": "input_image", "image_url": openai_png, "detail": "high"}) ); Ok(()) } // Token-based policy should report token counts even when truncation is byte-estimated. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn token_policy_marker_reports_tokens() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; let mut builder = test_codex().with_model("gpt-5.4").with_config(|config| { config.tool_output_token_limit = Some(50); // small budget to force truncation }); let fixture = builder.build(&server).await?; let call_id = "shell-token-marker"; let args = json!({ "cmd": "seq 1 150", "yield_time_ms": 5_000, }); mount_sse_once( &server, sse(vec![ ev_response_created("resp-1"), ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?), ev_completed("resp-1"), ]), ) .await; let done_mock = mount_sse_once( &server, sse(vec![ ev_assistant_message("msg-1", "done"), ev_completed("resp-2"), ]), ) .await; fixture .submit_turn_with_permission_profile("run the shell tool", PermissionProfile::Disabled) .await?; let output = done_mock .single_request() .function_call_output_text(call_id) .context("shell output present")?; let pattern = r"(?s)^Chunk ID: [^\n]+\nWall time: [0-9]+(?:\.[0-9]+)? seconds\nProcess exited with code 0\nOriginal token count: \d+\nOutput:\nWarning: truncated output \(original token count: \d+\)\nTotal output lines: 150\n\n1\n2\n3\n.*…\d+ tokens truncated….*149\n150\n$"; assert_regex_match(pattern, &output); assert_eq!(output.matches("tokens truncated").count(), 1); assert!(output.len() <= (TruncationPolicy::Tokens(50) * 1.2).byte_budget()); Ok(()) } // Byte-based policy should report characters removed. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn byte_policy_marker_reports_bytes() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; let mut builder = test_codex().with_model("gpt-5.2").with_config(|config| { config.tool_output_token_limit = Some(50); // ~200 byte cap }); let fixture = builder.build(&server).await?; let call_id = "shell-byte-marker"; let args = json!({ "cmd": "seq 1 150", "yield_time_ms": 5_000, }); mount_sse_once( &server, sse(vec![ ev_response_created("resp-1"), ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?), ev_completed("resp-1"), ]), ) .await; let done_mock = mount_sse_once( &server, sse(vec![ ev_assistant_message("msg-1", "done"), ev_completed("resp-2"), ]), ) .await; fixture .submit_turn_with_permission_profile("run the shell tool", PermissionProfile::Disabled) .await?; let output = done_mock .single_request() .function_call_output_text(call_id) .context("shell output present")?; let pattern = r"(?s)^Chunk ID: [^\n]+\nWall time: [0-9]+(?:\.[0-9]+)? seconds\nProcess exited with code 0\nOriginal token count: \d+\nOutput:\nWarning: truncated output \(original token count: \d+\)\nTotal output lines: 150\n\n1\n2\n3\n.*…\d+ chars truncated….*149\n150\n$"; assert_regex_match(pattern, &output); assert_eq!(output.matches("chars truncated").count(), 1); assert!(output.len() <= (TruncationPolicy::Bytes(200) * 1.2).byte_budget()); Ok(()) } // exec_command output should remain intact when the config opts into a large token budget. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn exec_command_output_not_truncated_with_custom_limit() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; let mut builder = test_codex().with_model("gpt-5.4").with_config(|config| { config.tool_output_token_limit = Some(50_000); // ample budget }); let fixture = builder.build(&server).await?; let call_id = "shell-no-trunc"; let args = json!({ "cmd": "seq 1 1000", "yield_time_ms": 5_000, }); let expected_body: String = (1..=1000).map(|i| format!("{i}\n")).collect(); mount_sse_once( &server, sse(vec![ ev_response_created("resp-1"), ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?), ev_completed("resp-1"), ]), ) .await; let done_mock = mount_sse_once( &server, sse(vec![ ev_assistant_message("msg-1", "done"), ev_completed("resp-2"), ]), ) .await; fixture .submit_turn_with_permission_profile( "run big output without truncation", PermissionProfile::Disabled, ) .await?; let output = done_mock .single_request() .function_call_output_text(call_id) .context("shell output present")?; assert!( output.ends_with(&expected_body), "expected entire shell output when budget increased: {output}" ); assert!( !output.contains("truncated"), "output should remain untruncated with ample budget" ); Ok(()) } async fn call_mcp_echo( server: &MockServer, builder: TestCodexBuilder, output_token_limit: Option, message_bytes: usize, ) -> Result<(TestCodex, String)> { let call_id = "rmcp-output"; let server_name = "rmcp"; let namespace = format!("mcp__{server_name}"); let args_json = json!({ "message": "a".repeat(message_bytes) }); mount_sse_once( server, sse(vec![ responses::ev_response_created("resp-1"), responses::ev_function_call_with_namespace( call_id, &namespace, "echo", &args_json.to_string(), ), responses::ev_completed("resp-1"), ]), ) .await; let response = mount_sse_once( server, sse(vec![ responses::ev_assistant_message("msg-1", "rmcp echo tool completed."), responses::ev_completed("resp-2"), ]), ) .await; let command = remote_aware_stdio_server_bin()?; let mut builder = builder.with_config(move |config| { let mcp_server = serde_json::from_value(json!({ "command": command, "environment_id": remote_aware_environment_id(), "cwd": config.cwd, "startup_timeout_sec": 10, "tools": { "echo": { "output_token_limit": output_token_limit } }, })) .expect("test MCP server configuration"); config .mcp_servers .set(HashMap::from([(server_name.to_string(), mcp_server)])) .expect("test mcp servers should accept any configuration"); }); let fixture = builder.build_with_auto_env(server).await?; wait_for_mcp_server(&fixture.codex, server_name).await?; fixture.submit_text_turn("call the MCP echo tool").await?; let output = response .single_request() .function_call_output_text(call_id) .context("model-facing MCP output text")?; Ok((fixture, output)) } #[test_case(3_000, 13_000; "serialization allowance")] #[test_case(30_000, 116_000; "large override")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn mcp_tool_output_limit_preserves_output_that_fits( output_token_limit: usize, message_bytes: usize, ) -> Result<()> { skip_if_no_network!(Ok(())); skip_if_wine_exec!(Ok(()), "requires a Windows test_stdio_server binary"); let server = start_mock_server().await; let builder = test_codex().with_config(|config| config.tool_output_token_limit = Some(50)); let (_fixture, output) = call_mcp_echo(&server, builder, Some(output_token_limit), message_bytes).await?; assert!(output.contains(&"a".repeat(message_bytes))); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn mcp_tool_output_limit_truncates_oversized_output() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_wine_exec!(Ok(()), "requires a Windows test_stdio_server binary"); let server = start_mock_server().await; let builder = test_codex().with_config(|config| config.tool_output_token_limit = Some(50)); let (_fixture, output) = call_mcp_echo( &server, builder, Some(30_000), /*message_bytes*/ 150_000, ) .await?; assert!(output.contains("truncated")); // 30k tokens plus the serialization allowance leaves about 144k bytes. assert!((140_000..145_000).contains(&output.len())); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn mcp_tool_output_limit_applies_to_hook_feedback() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_wine_exec!(Ok(()), "requires a Windows test_stdio_server binary"); let server = start_mock_server().await; let builder = test_codex() .with_pre_build_hook(|home| { super::hooks_mcp::write_mcp_tool_hook( home, "PostToolUse", Some("^mcp__rmcp__echo$"), "rmcp", &json!({ "continue": false, "stopReason": "hook feedback ".repeat(100) }) .to_string(), ) .expect("write MCP post-tool hook"); }) .with_config(|config| { core_test_support::hooks::trust_discovered_hooks(config); config.tool_output_token_limit = Some(50); }); let (_fixture, output) = call_mcp_echo(&server, builder, Some(100), /*message_bytes*/ 0).await?; assert!(output.starts_with("hook feedback ")); assert!(output.contains("truncated")); // The tool's 120-token budget applies, not the 60-token global budget. assert!((400..600).contains(&output.len())); Ok(()) } #[test_case(None; "model default")] #[test_case(Some(30_000); "tool override")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn mcp_tool_output_limit_survives_resume(output_token_limit: Option) -> Result<()> { skip_if_no_network!(Ok(())); skip_if_wine_exec!(Ok(()), "requires a Windows test_stdio_server binary"); let server = start_mock_server().await; let builder = test_codex().with_config(|config| config.tool_output_token_limit = Some(50_000)); let (fixture, output) = call_mcp_echo( &server, builder, output_token_limit, /*message_bytes*/ 150_000, ) .await?; fixture.codex.ensure_rollout_materialized().await; fixture.codex.flush_rollout().await?; let resumed_response = mount_sse_once( &server, sse(vec![ responses::ev_assistant_message("msg-2", "resumed"), responses::ev_completed("resp-3"), ]), ) .await; let mut resume_builder = test_codex().with_config(|config| { config.tool_output_token_limit = Some(50); }); let resumed = resume_builder.restart(&server, &fixture).await?; resumed.submit_turn("continue").await?; assert_eq!( resumed_response .single_request() .function_call_output_text("rmcp-output") .context("resumed MCP output")?, output ); Ok(()) }