mirror of
https://github.com/openai/codex.git
synced 2026-02-01 22:47:52 +00:00
Compare commits
1 Commits
dev/cc/dyn
...
easong/tog
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e302d33c4e |
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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 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<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() {
|
||||
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user