From e302d33c4ec0754a4c5e37c4383b568a8cd0ca83 Mon Sep 17 00:00:00 2001 From: easong-openai Date: Thu, 30 Oct 2025 00:59:25 -0700 Subject: [PATCH] initial --- codex-rs/common/src/config_summary.rs | 8 + codex-rs/core/src/client.rs | 25 ++- codex-rs/core/src/client_common.rs | 133 +++++++++++- codex-rs/core/src/codex.rs | 35 ++- codex-rs/core/src/config.rs | 42 ++++ codex-rs/core/src/config_edit.rs | 10 +- codex-rs/core/src/conversation_history.rs | 2 + codex-rs/core/src/sandboxing/assessment.rs | 1 + codex-rs/core/src/tools/router.rs | 19 ++ codex-rs/core/src/tools/spec.rs | 93 +++++++- codex-rs/core/tests/suite/model_overrides.rs | 204 ++++++++++++++++++ codex-rs/core/tests/suite/prompt_caching.rs | 1 + codex-rs/protocol/src/protocol.rs | 4 + codex-rs/tui/src/app.rs | 28 +++ codex-rs/tui/src/app_event.rs | 8 + codex-rs/tui/src/chatwidget.rs | 75 +++++++ ...twidget__tests__search_popup_disabled.snap | 12 ++ ...atwidget__tests__search_popup_enabled.snap | 13 ++ codex-rs/tui/src/chatwidget/tests.rs | 113 ++++++++++ codex-rs/tui/src/slash_command.rs | 3 + 20 files changed, 818 insertions(+), 11 deletions(-) create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__search_popup_disabled.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__search_popup_enabled.snap diff --git a/codex-rs/common/src/config_summary.rs b/codex-rs/common/src/config_summary.rs index dabc606ce1..1209e08f49 100644 --- a/codex-rs/common/src/config_summary.rs +++ b/codex-rs/common/src/config_summary.rs @@ -11,6 +11,14 @@ pub fn create_config_summary_entries(config: &Config) -> Vec<(&'static str, Stri ("provider", config.model_provider_id.clone()), ("approval", config.approval_policy.to_string()), ("sandbox", summarize_sandbox_policy(&config.sandbox_policy)), + ( + "web search", + if config.tools_web_search_request { + "enabled".to_string() + } else { + "disabled".to_string() + }, + ), ]; if config.model_provider.wire_api == WireApi::Responses && config.model_family.supports_reasoning_summaries diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index a947e342ad..92fad3655a 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -36,6 +36,8 @@ use crate::client_common::Prompt; use crate::client_common::ResponseEvent; use crate::client_common::ResponseStream; use crate::client_common::ResponsesApiRequest; +use crate::client_common::ToolChoiceMode; +use crate::client_common::ToolChoicePayload; use crate::client_common::create_reasoning_param_for_request; use crate::client_common::create_text_param_for_request; use crate::config::Config; @@ -245,12 +247,33 @@ impl ModelClient { // For Azure, we send `store: true` and preserve reasoning item IDs. let azure_workaround = self.provider.is_azure_responses_endpoint(); + let allowed_tool_values = prompt.allowed_tools.as_ref().map(|allowed_names| { + use std::collections::HashSet; + let allowed: HashSet<&str> = allowed_names.iter().map(String::as_str).collect(); + prompt + .tools + .iter() + .filter(|spec| allowed.contains(spec.name())) + .map(|spec| spec.to_allowed_tool_entry()) + .collect::>() + }); + + let tool_choice = match allowed_tool_values { + Some(tools) if !tools.is_empty() => Some(ToolChoicePayload::AllowedTools { + mode: ToolChoiceMode::Auto, + tools, + }), + _ => Some(ToolChoicePayload::Auto), + }; + + trace!(tool_choice = ?tool_choice, "resolved tool choice for request"); + let payload = ResponsesApiRequest { model: &self.config.model, instructions: &full_instructions, input: &input_with_instructions, tools: &tools_json, - tool_choice: "auto", + tool_choice, parallel_tool_calls: prompt.parallel_tool_calls, reasoning, store: azure_workaround, diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index 397b09dd7c..17590fdb0d 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -11,6 +11,7 @@ use codex_protocol::models::ResponseItem; use futures::Stream; use serde::Deserialize; use serde::Serialize; +use serde::ser::Serializer; use serde_json::Value; use std::borrow::Cow; use std::collections::HashSet; @@ -41,6 +42,9 @@ pub struct Prompt { /// Optional the output schema for the model's response. pub output_schema: Option, + + /// Optional list of tool identifiers that should remain enabled for this turn. + pub allowed_tools: Option>, } impl Prompt { @@ -268,7 +272,8 @@ pub(crate) struct ResponsesApiRequest<'a> { // separate enum for serialization. pub(crate) input: &'a Vec, pub(crate) tools: &'a [serde_json::Value], - pub(crate) tool_choice: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) tool_choice: Option, pub(crate) parallel_tool_calls: bool, pub(crate) reasoning: Option, pub(crate) store: bool, @@ -280,10 +285,54 @@ pub(crate) struct ResponsesApiRequest<'a> { pub(crate) text: Option, } +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum ToolChoiceMode { + Auto, +} + +#[derive(Debug, Clone)] +pub enum ToolChoicePayload { + Auto, + AllowedTools { + mode: ToolChoiceMode, + tools: Vec, + }, +} + +impl Serialize for ToolChoicePayload { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: Serializer, + { + match self { + ToolChoicePayload::Auto => serializer.serialize_str("auto"), + ToolChoicePayload::AllowedTools { mode, tools } => { + #[derive(Serialize)] + struct AllowedToolsPayload<'a> { + #[serde(rename = "type")] + r#type: &'static str, + mode: ToolChoiceMode, + tools: &'a [serde_json::Value], + } + + let payload = AllowedToolsPayload { + r#type: "allowed_tools", + mode: *mode, + tools: tools.as_slice(), + }; + payload.serialize(serializer) + } + } + } +} + pub(crate) mod tools { use crate::tools::spec::JsonSchema; use serde::Deserialize; use serde::Serialize; + use serde_json::Value; + use serde_json::json; /// When serialized as JSON, this produces a valid "Tool" in the OpenAI /// Responses API. @@ -311,6 +360,21 @@ pub(crate) mod tools { ToolSpec::Freeform(tool) => tool.name.as_str(), } } + + pub(crate) fn to_allowed_tool_entry(&self) -> Value { + match self { + ToolSpec::Function(tool) => json!({ + "type": "function", + "name": tool.name, + }), + ToolSpec::LocalShell {} => json!({ "type": "local_shell" }), + ToolSpec::WebSearch {} => json!({ "type": "web_search" }), + ToolSpec::Freeform(tool) => json!({ + "type": "custom", + "name": tool.name, + }), + } + } } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -457,7 +521,7 @@ mod tests { instructions: "i", input: &input, tools: &tools, - tool_choice: "auto", + tool_choice: Some(ToolChoicePayload::Auto), parallel_tool_calls: true, reasoning: None, store: false, @@ -498,7 +562,7 @@ mod tests { instructions: "i", input: &input, tools: &tools, - tool_choice: "auto", + tool_choice: Some(ToolChoicePayload::Auto), parallel_tool_calls: true, reasoning: None, store: false, @@ -534,7 +598,7 @@ mod tests { instructions: "i", input: &input, tools: &tools, - tool_choice: "auto", + tool_choice: Some(ToolChoicePayload::Auto), parallel_tool_calls: true, reasoning: None, store: false, @@ -547,4 +611,65 @@ mod tests { let v = serde_json::to_value(&req).expect("json"); assert!(v.get("text").is_none()); } + + #[test] + fn serializes_auto_tool_choice_as_string() { + let input: Vec = vec![]; + let tools: Vec = vec![]; + let req = ResponsesApiRequest { + model: "gpt-5", + instructions: "i", + input: &input, + tools: &tools, + tool_choice: Some(ToolChoicePayload::Auto), + parallel_tool_calls: true, + reasoning: None, + store: false, + stream: true, + include: vec![], + prompt_cache_key: None, + text: None, + }; + + let v = serde_json::to_value(&req).expect("json"); + assert_eq!( + v.get("tool_choice"), + Some(&serde_json::Value::String("auto".to_string())) + ); + } + + #[test] + fn serializes_allowed_tools_choice() { + let input: Vec = vec![]; + let tools: Vec = vec![]; + let allowed_tools = vec![ + serde_json::json!({"type": "function", "name": "shell"}), + serde_json::json!({"type": "local_shell"}), + ]; + let req = ResponsesApiRequest { + model: "gpt-5", + instructions: "i", + input: &input, + tools: &tools, + tool_choice: Some(ToolChoicePayload::AllowedTools { + mode: ToolChoiceMode::Auto, + tools: allowed_tools.clone(), + }), + parallel_tool_calls: true, + reasoning: None, + store: false, + stream: true, + include: vec![], + prompt_cache_key: None, + text: None, + }; + + let v = serde_json::to_value(&req).expect("json"); + let expected = serde_json::json!({ + "type": "allowed_tools", + "mode": "auto", + "tools": allowed_tools, + }); + assert_eq!(v.get("tool_choice"), Some(&expected)); + } } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 1e33335f4b..5099f740e8 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -348,6 +348,17 @@ impl SessionConfiguration { if let Some(cwd) = updates.cwd.clone() { next_configuration.cwd = cwd; } + if let Some(web_search_request) = updates.web_search_request { + if web_search_request { + next_configuration + .features + .enable(crate::features::Feature::WebSearchRequest); + } else { + next_configuration + .features + .disable(crate::features::Feature::WebSearchRequest); + } + } next_configuration } } @@ -361,6 +372,7 @@ pub(crate) struct SessionSettingsUpdate { pub(crate) reasoning_effort: Option>, pub(crate) reasoning_summary: Option, pub(crate) final_output_json_schema: Option>, + pub(crate) web_search_request: Option, } impl Session { @@ -376,6 +388,7 @@ impl Session { let model_family = find_family_for_model(&session_configuration.model) .unwrap_or_else(|| config.model_family.clone()); let mut per_turn_config = (*config).clone(); + let features = session_configuration.features.clone(); per_turn_config.model = session_configuration.model.clone(); per_turn_config.model_family = model_family.clone(); per_turn_config.model_reasoning_effort = session_configuration.model_reasoning_effort; @@ -384,6 +397,16 @@ impl Session { per_turn_config.model_context_window = Some(model_info.context_window); } + per_turn_config.features = features.clone(); + per_turn_config.include_apply_patch_tool = features.enabled(Feature::ApplyPatchFreeform); + per_turn_config.include_view_image_tool = features.enabled(Feature::ViewImageTool); + per_turn_config.tools_web_search_request = features.enabled(Feature::WebSearchRequest); + per_turn_config.experimental_sandbox_command_assessment = + features.enabled(Feature::SandboxCommandAssessment); + per_turn_config.use_experimental_streamable_shell_tool = + features.enabled(Feature::StreamableShell); + per_turn_config.use_experimental_unified_exec_tool = features.enabled(Feature::UnifiedExec); + let otel_event_manager = otel_event_manager.clone().with_model( session_configuration.model.as_str(), session_configuration.model.as_str(), @@ -401,7 +424,7 @@ impl Session { let tools_config = ToolsConfig::new(&ToolsConfigParams { model_family: &model_family, - features: &config.features, + features: &features, }); TurnContext { @@ -1254,6 +1277,7 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv model, effort, summary, + include_web_search_request, } => { let updates = SessionSettingsUpdate { cwd, @@ -1262,6 +1286,7 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv model, reasoning_effort: effort, reasoning_summary: summary, + web_search_request: include_web_search_request, ..Default::default() }; sess.update_settings(updates).await; @@ -1288,6 +1313,7 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv reasoning_effort: Some(effort), reasoning_summary: Some(summary), final_output_json_schema: Some(final_output_json_schema), + web_search_request: None, }, ), Op::UserInput { items } => (items, SessionSettingsUpdate::default()), @@ -1848,12 +1874,17 @@ async fn run_turn( .get_model_family() .supports_parallel_tool_calls; let parallel_tool_calls = model_supports_parallel; + let tool_specs = router.specs(); + let allowed_tools = turn_context + .tools_config + .allowed_tool_names(tool_specs.as_slice()); let prompt = Prompt { input: filter_model_visible_history(input), - tools: router.specs(), + tools: tool_specs, parallel_tool_calls, base_instructions_override: turn_context.base_instructions.clone(), output_schema: turn_context.final_output_json_schema.clone(), + allowed_tools, }; let mut retries = 0; diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index c5406b1d27..bfdffb8ecc 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -1780,6 +1780,48 @@ trust_level = "trusted" Ok(()) } + #[test] + fn web_search_override_keeps_config_and_features_in_sync() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cfg = ConfigToml::default(); + + let enabled = Config::load_from_base_config_with_overrides( + cfg.clone(), + ConfigOverrides { + tools_web_search_request: Some(true), + ..Default::default() + }, + codex_home.path().to_path_buf(), + )?; + assert!( + enabled.features.enabled(Feature::WebSearchRequest), + "feature flag should be enabled when override is true" + ); + assert!( + enabled.tools_web_search_request, + "config mirror flag should reflect enabled state" + ); + + let disabled = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides { + tools_web_search_request: Some(false), + ..Default::default() + }, + codex_home.path().to_path_buf(), + )?; + assert!( + !disabled.features.enabled(Feature::WebSearchRequest), + "feature flag should be disabled when override is false" + ); + assert!( + !disabled.tools_web_search_request, + "config mirror flag should reflect disabled state" + ); + + Ok(()) + } + #[test] fn profile_sandbox_mode_overrides_base() -> std::io::Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/core/src/config_edit.rs b/codex-rs/core/src/config_edit.rs index 6e68c08ca0..356e6d5bda 100644 --- a/codex-rs/core/src/config_edit.rs +++ b/codex-rs/core/src/config_edit.rs @@ -93,6 +93,14 @@ fn apply_toml_edit_override_segments( current[last] = value; } +fn scalar_from_str(value: &str) -> toml_edit::Item { + match value { + "true" => toml_edit::value(true), + "false" => toml_edit::value(false), + _ => toml_edit::value(value), + } +} + async fn persist_overrides_with_behavior( codex_home: &Path, profile: Option<&str>, @@ -161,7 +169,7 @@ async fn persist_overrides_with_behavior( match value { Some(v) => { - let item_value = toml_edit::value(v); + let item_value = scalar_from_str(v); apply_toml_edit_override_segments(&mut doc, segments_to_apply, item_value); mutated = true; } diff --git a/codex-rs/core/src/conversation_history.rs b/codex-rs/core/src/conversation_history.rs index e9583ff0fb..d968e9a68c 100644 --- a/codex-rs/core/src/conversation_history.rs +++ b/codex-rs/core/src/conversation_history.rs @@ -356,6 +356,7 @@ impl ConversationHistory { call_id: call_id.clone(), output: FunctionCallOutputPayload { content: truncated, + content_items: output.content_items.clone(), success: output.success, }, } @@ -653,6 +654,7 @@ mod tests { call_id: "call-100".to_string(), output: FunctionCallOutputPayload { content: long_output.clone(), + content_items: None, success: Some(true), }, }; diff --git a/codex-rs/core/src/sandboxing/assessment.rs b/codex-rs/core/src/sandboxing/assessment.rs index f02a90b46e..0ac3918486 100644 --- a/codex-rs/core/src/sandboxing/assessment.rs +++ b/codex-rs/core/src/sandboxing/assessment.rs @@ -128,6 +128,7 @@ pub(crate) async fn assess_command( parallel_tool_calls: false, base_instructions_override: Some(system_prompt), output_schema: Some(sandbox_assessment_schema()), + allowed_tools: None, }; let child_otel = diff --git a/codex-rs/core/src/tools/router.rs b/codex-rs/core/src/tools/router.rs index 19098aa80d..7e20a0dc91 100644 --- a/codex-rs/core/src/tools/router.rs +++ b/codex-rs/core/src/tools/router.rs @@ -1,5 +1,6 @@ use std::collections::HashMap; use std::sync::Arc; +use std::time::Duration; use crate::client_common::tools::ToolSpec; use crate::codex::Session; @@ -153,6 +154,24 @@ impl ToolRouter { payload, }; + if !invocation + .turn + .tools_config + .is_tool_allowed(invocation.tool_name.as_str()) + { + let message = format!("tool {} is disabled for this session", invocation.tool_name); + let otel = invocation.turn.client.get_otel_event_manager(); + otel.tool_result( + invocation.tool_name.as_str(), + &invocation.call_id, + invocation.payload.log_payload().as_ref(), + Duration::ZERO, + false, + &message, + ); + return Err(FunctionCallError::RespondToModel(message)); + } + match self.registry.dispatch(invocation).await { Ok(response) => Ok(response), Err(FunctionCallError::Fatal(message)) => Err(FunctionCallError::Fatal(message)), diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index eba9fd517c..5de59d83f7 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -78,6 +78,37 @@ impl ToolsConfig { experimental_supported_tools: model_family.experimental_supported_tools.clone(), } } + + pub fn allowed_tool_names(&self, tool_specs: &[ToolSpec]) -> Option> { + if tool_specs.is_empty() { + return None; + } + + let mut removed_web_search = false; + let mut allowed: Vec = Vec::with_capacity(tool_specs.len()); + + for spec in tool_specs { + let name = spec.name(); + if name == "web_search" && !self.web_search_request { + removed_web_search = true; + continue; + } + allowed.push(name.to_string()); + } + + if removed_web_search { + Some(allowed) + } else { + None + } + } + + pub fn is_tool_allowed(&self, name: &str) -> bool { + if name == "web_search" { + return self.web_search_request; + } + true + } } /// Generic JSON‑Schema subset needed for our tool definitions @@ -971,9 +1002,7 @@ pub(crate) fn build_specs( builder.register_handler("test_sync_tool", test_sync_handler); } - if config.web_search_request { - builder.push_spec(ToolSpec::WebSearch {}); - } + builder.push_spec(ToolSpec::WebSearch {}); if config.include_view_image_tool { builder.push_spec_with_parallel_support(create_view_image_tool(), true); @@ -1205,6 +1234,64 @@ mod tests { assert_contains_tool_names(&tools, &subset); } + #[test] + fn test_web_search_spec_present_even_when_disabled() { + let model_family = find_family_for_model("o3").expect("o3 should be a valid model family"); + let mut features = Features::with_defaults(); + features.disable(Feature::WebSearchRequest); + let config = ToolsConfig::new(&ToolsConfigParams { + model_family: &model_family, + features: &features, + }); + let (tools, _) = build_specs(&config, None).build(); + assert!( + tools + .iter() + .any(|tool| tool_name(&tool.spec) == "web_search"), + "web_search spec should be present even when feature disabled" + ); + } + + #[test] + fn test_allowed_tool_names_filters_disabled_web_search() { + let model_family = find_family_for_model("o3").expect("o3 should be a valid model family"); + let mut features = Features::with_defaults(); + features.disable(Feature::WebSearchRequest); + let config = ToolsConfig::new(&ToolsConfigParams { + model_family: &model_family, + features: &features, + }); + let (tools, _) = build_specs(&config, None).build(); + let specs: Vec = tools.iter().map(|t| t.spec.clone()).collect(); + let allowed = config + .allowed_tool_names(specs.as_slice()) + .expect("expected allow list when web_search disabled"); + assert!( + !allowed.iter().any(|name| name == "web_search"), + "allowed tool list should exclude web_search" + ); + assert!( + !allowed.is_empty(), + "allowed tool list should retain other tool names" + ); + } + + #[test] + fn test_allowed_tool_names_none_when_enabled() { + let model_family = find_family_for_model("o3").expect("o3 should be a valid model family"); + let features = Features::with_defaults(); + let config = ToolsConfig::new(&ToolsConfigParams { + model_family: &model_family, + features: &features, + }); + let (tools, _) = build_specs(&config, None).build(); + let specs: Vec = tools.iter().map(|t| t.spec.clone()).collect(); + assert!( + config.allowed_tool_names(specs.as_slice()).is_none(), + "allowed tool list should be None when web_search enabled" + ); + } + #[test] #[ignore] fn test_parallel_support_flags() { diff --git a/codex-rs/core/tests/suite/model_overrides.rs b/codex-rs/core/tests/suite/model_overrides.rs index a186c13ef3..86ce9cb603 100644 --- a/codex-rs/core/tests/suite/model_overrides.rs +++ b/codex-rs/core/tests/suite/model_overrides.rs @@ -1,15 +1,79 @@ use codex_core::CodexAuth; use codex_core::ConversationManager; +use codex_core::ModelProviderInfo; +use codex_core::built_in_model_providers; +use codex_core::features::Feature; +use codex_core::model_family::find_family_for_model; use codex_core::protocol::EventMsg; use codex_core::protocol::Op; use codex_core::protocol_config_types::ReasoningEffort; +use codex_protocol::user_input::UserInput; use core_test_support::load_default_config_for_test; +use core_test_support::load_sse_fixture_with_id; use core_test_support::wait_for_event; use pretty_assertions::assert_eq; +use serde_json::Value; use tempfile::TempDir; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; const CONFIG_TOML: &str = "config.toml"; +fn sse_completed(id: &str) -> String { + load_sse_fixture_with_id("tests/fixtures/completed_template.json", id) +} + +fn tool_identifiers(body: &Value) -> Vec { + let tools = match body["tools"].as_array() { + Some(array) => array, + None => panic!("tool list missing in Responses payload: {body:?}"), + }; + + tools + .iter() + .map(|tool| { + if let Some(name) = tool.get("name").and_then(Value::as_str) { + name.to_string() + } else if let Some(kind) = tool.get("type").and_then(Value::as_str) { + kind.to_string() + } else { + panic!("tool entry missing identifiers: {tool:?}"); + } + }) + .collect() +} + +fn allowed_tool_specs(body: &Value) -> Option> { + match body.get("tool_choice") { + None => panic!("tool_choice missing in Responses payload: {body:?}"), + Some(Value::String(name)) => { + assert_eq!(name, "auto", "unexpected tool_choice string: {name}"); + None + } + Some(Value::Object(obj)) => { + let ty = obj + .get("type") + .and_then(Value::as_str) + .expect("tool_choice.type field"); + assert_eq!(ty, "allowed_tools", "unexpected tool_choice type: {ty}"); + let mode = obj + .get("mode") + .and_then(Value::as_str) + .expect("tool_choice.mode field"); + assert_eq!(mode, "auto", "unexpected tool_choice mode: {mode}"); + let tools = obj + .get("tools") + .and_then(Value::as_array) + .expect("tool_choice.tools field"); + Some(tools.to_vec()) + } + Some(other) => panic!("unexpected tool_choice payload: {other:?}"), + } +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn override_turn_context_does_not_persist_when_config_exists() { let codex_home = TempDir::new().unwrap(); @@ -38,6 +102,7 @@ async fn override_turn_context_does_not_persist_when_config_exists() { model: Some("o3".to_string()), effort: Some(Some(ReasoningEffort::High)), summary: None, + include_web_search_request: None, }) .await .expect("submit override"); @@ -78,6 +143,7 @@ async fn override_turn_context_does_not_create_config_file() { model: Some("o3".to_string()), effort: Some(Some(ReasoningEffort::Medium)), summary: None, + include_web_search_request: None, }) .await .expect("submit override"); @@ -90,3 +156,141 @@ async fn override_turn_context_does_not_create_config_file() { "override should not create config.toml" ); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn override_turn_context_toggles_web_search_tool() { + let server = MockServer::start().await; + + let sse = sse_completed("toggle-web-search"); + let template = ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_raw(sse, "text/event-stream"); + + Mock::given(method("POST")) + .and(path("/v1/responses")) + .respond_with(template.clone()) + .expect(2) + .mount(&server) + .await; + + let model_provider = ModelProviderInfo { + base_url: Some(format!("{}/v1", server.uri())), + ..built_in_model_providers()["openai"].clone() + }; + + let cwd = TempDir::new().unwrap(); + let codex_home = TempDir::new().unwrap(); + let mut config = load_default_config_for_test(&codex_home); + config.cwd = cwd.path().to_path_buf(); + config.model = "gpt-5-codex".to_string(); + config.model_family = find_family_for_model("gpt-5-codex").expect("model family"); + config.model_provider = model_provider; + config.model_provider_id = "openai".to_string(); + config.features.disable(Feature::WebSearchRequest); + config.features.disable(Feature::ViewImageTool); + config.features.disable(Feature::ApplyPatchFreeform); + config.features.disable(Feature::StreamableShell); + config.features.disable(Feature::UnifiedExec); + config.features.disable(Feature::SandboxCommandAssessment); + config.tools_web_search_request = config.features.enabled(Feature::WebSearchRequest); + + let conversation_manager = + ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); + let codex = conversation_manager + .new_conversation(config) + .await + .expect("create conversation") + .conversation; + + codex + .submit(Op::OverrideTurnContext { + cwd: None, + approval_policy: None, + sandbox_policy: None, + model: None, + effort: None, + summary: None, + include_web_search_request: Some(true), + }) + .await + .expect("enable web search"); + + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "first turn".to_string(), + }], + }) + .await + .expect("submit first input"); + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; + + codex + .submit(Op::OverrideTurnContext { + cwd: None, + approval_policy: None, + sandbox_policy: None, + model: None, + effort: None, + summary: None, + include_web_search_request: Some(false), + }) + .await + .expect("disable web search"); + + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "second turn".to_string(), + }], + }) + .await + .expect("submit second input"); + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; + + let requests = server + .received_requests() + .await + .unwrap_or_else(|| Vec::new()); + assert_eq!(requests.len(), 2, "expected two requests for two turns"); + + let first = match requests[0].body_json::() { + Ok(json) => json, + Err(err) => panic!("first request body should be JSON: {err}"), + }; + let first_tools = tool_identifiers(&first); + assert!( + first_tools.iter().any(|tool| tool == "web_search"), + "expected web_search tool after enabling; got {first_tools:?}" + ); + assert!( + allowed_tool_specs(&first).is_none(), + "expected auto tool choice when web_search enabled" + ); + + let second = match requests[1].body_json::() { + Ok(json) => json, + Err(err) => panic!("second request body should be JSON: {err}"), + }; + let second_tools = tool_identifiers(&second); + assert!( + second_tools.iter().any(|tool| tool == "web_search"), + "expected web_search tool spec to remain present; got {second_tools:?}" + ); + let second_allowed = allowed_tool_specs(&second).expect("expected allowed tools payload"); + assert!( + second_allowed.iter().all(|tool| tool + .get("type") + .and_then(Value::as_str) + .map(|ty| ty != "web_search") + .unwrap_or(true)), + "expected web_search not to be allowed after disabling; got {second_allowed:?}" + ); + assert!( + second_allowed.iter().any(|tool| { + tool.get("type") == Some(&Value::String("function".to_string())) + && tool.get("name") == Some(&Value::String("shell".to_string())) + }), + "expected shell function to remain allowed; got {second_allowed:?}" + ); +} diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 1bf12eb954..2cc451ef67 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -460,6 +460,7 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() { model: Some("o3".to_string()), effort: Some(Some(ReasoningEffort::High)), summary: Some(ReasoningSummary::Detailed), + include_web_search_request: None, }) .await .unwrap(); diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index a7f5241bb3..93d9c4cd51 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -136,6 +136,10 @@ pub enum Op { /// Updated reasoning summary preference (honored only for reasoning-capable models). #[serde(skip_serializing_if = "Option::is_none")] summary: Option, + + /// Toggle the availability of the Responses `web_search` tool for subsequent turns. + #[serde(skip_serializing_if = "Option::is_none")] + include_web_search_request: Option, }, /// Approve a command execution diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index c8eab72969..1aeaf59c88 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -19,6 +19,8 @@ use codex_core::ConversationManager; use codex_core::config::Config; use codex_core::config::persist_model_selection; use codex_core::config::set_hide_full_access_warning; +use codex_core::config_edit::persist_overrides; +use codex_core::features::Feature; use codex_core::model_family::find_family_for_model; use codex_core::protocol::SessionSource; use codex_core::protocol::TokenUsage; @@ -414,6 +416,32 @@ impl App { AppEvent::UpdateSandboxPolicy(policy) => { self.chat_widget.set_sandbox_policy(policy); } + AppEvent::UpdateWebSearch(enabled) => { + if enabled { + self.config.features.enable(Feature::WebSearchRequest); + } else { + self.config.features.disable(Feature::WebSearchRequest); + } + self.config.tools_web_search_request = enabled; + self.chat_widget.set_web_search_enabled(enabled); + } + AppEvent::PersistWebSearch { enabled } => { + let value = if enabled { "true" } else { "false" }; + if let Err(err) = persist_overrides( + &self.config.codex_home, + self.active_profile.as_deref(), + &[(&["features", "web_search_request"], value)], + ) + .await + { + tracing::error!( + error = %err, + "failed to persist web search toggle" + ); + self.chat_widget + .add_error_message(format!("Failed to save web search preference: {err}")); + } + } AppEvent::UpdateFullAccessWarningAcknowledged(ack) => { self.chat_widget.set_full_access_warning_acknowledged(ack); } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 8b14b0be6f..33f7c6ba3f 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -78,6 +78,14 @@ pub(crate) enum AppEvent { /// Update the current sandbox policy in the running app and widget. UpdateSandboxPolicy(SandboxPolicy), + /// Update whether the web_search tool is available. + UpdateWebSearch(bool), + + /// Persist the web_search availability toggle to config. + PersistWebSearch { + enabled: bool, + }, + /// Update whether the full access warning prompt has been acknowledged. UpdateFullAccessWarningAcknowledged(bool), diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 13411df404..31eccc0cf6 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1213,6 +1213,9 @@ impl ChatWidget { SlashCommand::Approvals => { self.open_approvals_popup(); } + SlashCommand::Search => { + self.open_search_popup(); + } SlashCommand::Quit => { self.app_event_tx.send(AppEvent::ExitRequest); } @@ -1741,6 +1744,7 @@ impl ChatWidget { model: Some(model_for_action.clone()), effort: Some(effort_for_action), summary: None, + include_web_search_request: None, })); tx.send(AppEvent::UpdateModel(model_for_action.clone())); tx.send(AppEvent::UpdateReasoningEffort(effort_for_action)); @@ -1826,6 +1830,72 @@ impl ChatWidget { }); } + pub(crate) fn open_search_popup(&mut self) { + let current_enabled = self.config.tools_web_search_request; + + let make_item = |label: &str, + enabled: bool, + description: &str, + info_message: &str, + hint: Option<&str>, + search_value: &str| { + let message_owned = info_message.to_string(); + let hint_owned = hint.map(std::string::ToString::to_string); + let actions: Vec = vec![Box::new(move |tx| { + tx.send(AppEvent::CodexOp(Op::OverrideTurnContext { + cwd: None, + approval_policy: None, + sandbox_policy: None, + model: None, + effort: None, + summary: None, + include_web_search_request: Some(enabled), + })); + tx.send(AppEvent::UpdateWebSearch(enabled)); + tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_info_event(message_owned.clone(), hint_owned.clone()), + ))); + tx.send(AppEvent::PersistWebSearch { enabled }); + })]; + + SelectionItem { + name: label.to_string(), + description: Some(description.to_string()), + is_current: current_enabled == enabled, + actions, + dismiss_on_select: true, + search_value: Some(search_value.to_string()), + ..Default::default() + } + }; + + let mut items: Vec = Vec::with_capacity(2); + items.push(make_item( + "Enable Web Search", + true, + "Allow Codex to make web searches when it needs external information", + "Web search enabled", + Some("Disable later if you prefer offline-only behavior."), + "enable web search", + )); + items.push(make_item( + "Disable Web Search", + false, + "Do not allow codex to perform web searches", + "Web search disabled", + Some("Enable when you want Codex to pull fresh information."), + "disable web search", + )); + + self.bottom_pane.show_selection_view(SelectionViewParams { + title: Some("Toggle Web Search".to_string()), + subtitle: Some("Control whether Codex can access external search.".to_string()), + footer_hint: Some(standard_popup_hint_line()), + items, + ..Default::default() + }); + } + fn approval_preset_actions( approval: AskForApproval, sandbox: SandboxPolicy, @@ -1839,6 +1909,7 @@ impl ChatWidget { model: None, effort: None, summary: None, + include_web_search_request: None, })); tx.send(AppEvent::UpdateAskForApprovalPolicy(approval)); tx.send(AppEvent::UpdateSandboxPolicy(sandbox_clone)); @@ -1934,6 +2005,10 @@ impl ChatWidget { self.config.model = model.to_string(); } + pub(crate) fn set_web_search_enabled(&mut self, enabled: bool) { + self.config.tools_web_search_request = enabled; + } + pub(crate) fn add_info_message(&mut self, message: String, hint: Option) { self.add_to_history(history_cell::new_info_event(message, hint)); self.request_redraw(); diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__search_popup_disabled.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__search_popup_disabled.snap new file mode 100644 index 0000000000..3e015748b7 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__search_popup_disabled.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: popup +--- + Toggle Web Search + Control whether Codex can access external search. + + 1. Enable Web Search Allow Codex to make web searches when it + needs external information +› 2. Disable Web Search (current) Do not allow codex to perform web searches + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__search_popup_enabled.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__search_popup_enabled.snap new file mode 100644 index 0000000000..8571d182f8 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__search_popup_enabled.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/chatwidget/tests.rs +assertion_line: 1289 +expression: popup +--- + Toggle Web Search + Control whether Codex can access external search. + +› 1. Enable Web Search (current) Allow Codex to make web searches when it + needs external information + 2. Disable Web Search Do not allow codex to perform web searches + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index a9c1d58c67..01f9093ee5 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -1,6 +1,7 @@ use super::*; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; +use crate::slash_command::SlashCommand; use crate::test_backend::VT100Backend; use crate::tui::FrameRequester; use assert_matches::assert_matches; @@ -50,6 +51,7 @@ use std::fs::File; use std::io::BufRead; use std::io::BufReader; use std::path::PathBuf; +use std::str::FromStr; use tempfile::NamedTempFile; use tempfile::tempdir; use tokio::sync::mpsc::error::TryRecvError; @@ -1257,6 +1259,117 @@ fn render_bottom_popup(chat: &ChatWidget, width: u16) -> String { lines.join("\n") } +#[test] +fn slash_command_search_parses() { + assert_eq!( + SlashCommand::from_str("search").unwrap(), + SlashCommand::Search + ); +} + +#[test] +fn search_popup_snapshot_disabled() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + + chat.config.tools_web_search_request = false; + chat.open_search_popup(); + + let popup = render_bottom_popup(&chat, 80); + assert_snapshot!("search_popup_disabled", popup); +} + +#[test] +fn search_popup_snapshot_enabled() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + + chat.config.tools_web_search_request = true; + chat.open_search_popup(); + + let popup = render_bottom_popup(&chat, 80); + assert_snapshot!("search_popup_enabled", popup); +} + +#[test] +fn search_popup_highlight_non_current_shows_action_description() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + + chat.config.tools_web_search_request = true; + chat.open_search_popup(); + chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + + let popup = render_bottom_popup(&chat, 80); + assert!( + popup.contains("Do not allow codex to perform web searches"), + "expected non-current item to show its action description" + ); + assert!( + !popup.contains("Currently enabled"), + "expected current-state tooltip to disappear when another option is highlighted" + ); +} + +#[test] +fn search_popup_highlight_from_disabled_shows_enable_description() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + + chat.config.tools_web_search_request = false; + chat.open_search_popup(); + chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + + let popup = render_bottom_popup(&chat, 80); + assert!( + popup.contains("Allow Codex to make web searches"), + "expected enable option to show its action description when selected" + ); + assert!( + !popup.contains("Currently disabled"), + "expected current-state tooltip to disappear when another option is highlighted" + ); +} + +#[test] +fn search_popup_selection_persists_toggle() { + let (mut chat, _tx, mut app_rx, _op_rx) = make_chatwidget_manual_with_sender(); + + chat.config.tools_web_search_request = false; + chat.open_search_popup(); + chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + let mut saw_override = false; + let mut saw_update = false; + let mut saw_persist = false; + + while let Ok(event) = app_rx.try_recv() { + match event { + AppEvent::CodexOp(Op::OverrideTurnContext { + include_web_search_request, + .. + }) => { + assert_eq!( + include_web_search_request, + Some(true), + "expected override to enable web search" + ); + saw_override = true; + } + AppEvent::UpdateWebSearch(enabled) => { + assert!(enabled, "expected UpdateWebSearch to enable the toggle"); + saw_update = true; + } + AppEvent::PersistWebSearch { enabled } => { + assert!(enabled, "expected persistence to record enabled state"); + saw_persist = true; + } + _ => {} + } + } + + assert!(saw_override, "expected to send override op"); + assert!(saw_update, "expected to send UpdateWebSearch"); + assert!(saw_persist, "expected to persist web search toggle"); +} + #[test] fn model_selection_popup_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index bb3be33099..d45dae4963 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -14,6 +14,7 @@ pub enum SlashCommand { // more frequently used commands should be listed first. Model, Approvals, + Search, Review, New, Init, @@ -46,6 +47,7 @@ impl SlashCommand { SlashCommand::Status => "show current session configuration and token usage", SlashCommand::Model => "choose what model and reasoning effort to use", SlashCommand::Approvals => "choose what Codex can do without approval", + SlashCommand::Search => "toggle web search availability", SlashCommand::Mcp => "list configured MCP tools", SlashCommand::Logout => "log out of Codex", #[cfg(debug_assertions)] @@ -68,6 +70,7 @@ impl SlashCommand { | SlashCommand::Undo | SlashCommand::Model | SlashCommand::Approvals + | SlashCommand::Search | SlashCommand::Review | SlashCommand::Logout => false, SlashCommand::Diff