Compare commits

...

1 Commits

Author SHA1 Message Date
easong-openai
e302d33c4e initial 2025-10-30 00:59:25 -07:00
20 changed files with 818 additions and 11 deletions

View File

@@ -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

View File

@@ -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::<Vec<serde_json::Value>>()
});
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,

View File

@@ -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<Value>,
/// Optional list of tool identifiers that should remain enabled for this turn.
pub allowed_tools: Option<Vec<String>>,
}
impl Prompt {
@@ -268,7 +272,8 @@ pub(crate) struct ResponsesApiRequest<'a> {
// separate enum for serialization.
pub(crate) input: &'a Vec<ResponseItem>,
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<ToolChoicePayload>,
pub(crate) parallel_tool_calls: bool,
pub(crate) reasoning: Option<Reasoning>,
pub(crate) store: bool,
@@ -280,10 +285,54 @@ pub(crate) struct ResponsesApiRequest<'a> {
pub(crate) text: Option<TextControls>,
}
#[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<serde_json::Value>,
},
}
impl Serialize for ToolChoicePayload {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
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<ResponseItem> = vec![];
let tools: Vec<serde_json::Value> = 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<ResponseItem> = vec![];
let tools: Vec<serde_json::Value> = 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));
}
}

View File

@@ -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<Option<ReasoningEffortConfig>>,
pub(crate) reasoning_summary: Option<ReasoningSummaryConfig>,
pub(crate) final_output_json_schema: Option<Option<Value>>,
pub(crate) web_search_request: Option<bool>,
}
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<Session>, config: Arc<Config>, rx_sub: Receiv
model,
effort,
summary,
include_web_search_request,
} => {
let updates = SessionSettingsUpdate {
cwd,
@@ -1262,6 +1286,7 @@ async fn submission_loop(sess: Arc<Session>, config: Arc<Config>, 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<Session>, config: Arc<Config>, 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;

View File

@@ -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()?;

View File

@@ -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;
}

View File

@@ -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),
},
};

View File

@@ -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 =

View File

@@ -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)),

View File

@@ -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<Vec<String>> {
if tool_specs.is_empty() {
return None;
}
let mut removed_web_search = false;
let mut allowed: Vec<String> = 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 JSONSchema 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<ToolSpec> = 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<ToolSpec> = 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() {

View File

@@ -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<String> {
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<Vec<Value>> {
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::<Value>() {
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::<Value>() {
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:?}"
);
}

View File

@@ -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();

View File

@@ -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<ReasoningSummaryConfig>,
/// Toggle the availability of the Responses `web_search` tool for subsequent turns.
#[serde(skip_serializing_if = "Option::is_none")]
include_web_search_request: Option<bool>,
},
/// Approve a command execution

View File

@@ -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);
}

View File

@@ -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),

View File

@@ -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<SelectionAction> = 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<SelectionItem> = 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<String>) {
self.add_to_history(history_cell::new_info_event(message, hint));
self.request_redraw();

View File

@@ -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

View File

@@ -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

View File

@@ -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();

View File

@@ -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