chore(config) tools.request_user_input

This commit is contained in:
Dylan Hurd
2026-05-12 20:55:11 -07:00
parent 913aad4d3c
commit 65c9d24d19
12 changed files with 304 additions and 4 deletions
+17
View File
@@ -38,6 +38,7 @@ use codex_model_provider_info::OLLAMA_CHAT_PROVIDER_REMOVED_ERROR;
use codex_model_provider_info::OLLAMA_OSS_PROVIDER_ID;
use codex_model_provider_info::OPENAI_PROVIDER_ID;
use codex_protocol::config_types::ForcedLoginMethod;
use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::Personality;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::config_types::SandboxMode;
@@ -610,6 +611,22 @@ pub struct ToolsToml {
/// Enable the `view_image` tool that lets the agent attach local images.
#[serde(default)]
pub view_image: Option<bool>,
/// Configure registration and collaboration-mode availability for `request_user_input`.
#[serde(default)]
pub request_user_input: Option<RequestUserInputToolConfigToml>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct RequestUserInputToolConfigToml {
/// Whether to register the built-in `request_user_input` tool.
#[serde(default)]
pub enabled: Option<bool>,
/// Collaboration modes where `request_user_input` may be invoked.
#[serde(default)]
pub allowed_modes: Option<Vec<ModeKind>>,
}
#[derive(Deserialize)]
+36
View File
@@ -1290,6 +1290,14 @@
},
"type": "object"
},
"ModeKind": {
"description": "Initial collaboration mode to use when the TUI starts.",
"enum": [
"plan",
"default"
],
"type": "string"
},
"ModelAvailabilityNuxConfig": {
"additionalProperties": {
"format": "uint32",
@@ -2276,6 +2284,25 @@
}
]
},
"RequestUserInputToolConfigToml": {
"additionalProperties": false,
"properties": {
"allowed_modes": {
"default": null,
"description": "Collaboration modes where `request_user_input` may be invoked.",
"items": {
"$ref": "#/definitions/ModeKind"
},
"type": "array"
},
"enabled": {
"default": null,
"description": "Whether to register the built-in `request_user_input` tool.",
"type": "boolean"
}
},
"type": "object"
},
"SandboxMode": {
"enum": [
"read-only",
@@ -2501,6 +2528,15 @@
"ToolsToml": {
"additionalProperties": false,
"properties": {
"request_user_input": {
"allOf": [
{
"$ref": "#/definitions/RequestUserInputToolConfigToml"
}
],
"default": null,
"description": "Configure registration and collaboration-mode availability for `request_user_input`."
},
"view_image": {
"default": null,
"description": "Enable the `view_image` tool that lets the agent attach local images.",
+52
View File
@@ -61,11 +61,13 @@ use codex_config::types::WindowsToml;
use codex_core_plugins::PluginsManager;
use codex_exec_server::LOCAL_FS;
use codex_features::Feature;
use codex_features::Features;
use codex_features::FeaturesToml;
use codex_model_provider_info::LMSTUDIO_OSS_PROVIDER_ID;
use codex_model_provider_info::OLLAMA_OSS_PROVIDER_ID;
use codex_model_provider_info::WireApi;
use codex_models_manager::bundled_models_response;
use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::models::ActivePermissionProfile;
use codex_protocol::models::ActivePermissionProfileModification;
@@ -381,6 +383,7 @@ web_search = true
Some(ToolsToml {
web_search: None,
view_image: None,
request_user_input: None,
})
);
}
@@ -400,10 +403,51 @@ web_search = false
Some(ToolsToml {
web_search: None,
view_image: None,
request_user_input: None,
})
);
}
#[test]
fn tools_request_user_input_deserializes_registration_and_modes() {
let cfg: ConfigToml = toml::from_str(
r#"
[tools.request_user_input]
enabled = false
allowed_modes = ["plan", "default"]
"#,
)
.expect("TOML deserialization should succeed");
let request_user_input = cfg
.tools
.and_then(|tools| tools.request_user_input)
.expect("request_user_input tool config should deserialize");
assert_eq!(request_user_input.enabled, Some(false));
assert_eq!(
request_user_input.allowed_modes,
Some(vec![ModeKind::Plan, ModeKind::Default])
);
}
#[test]
fn request_user_input_tool_config_defaults_to_plan_and_legacy_feature_adds_default() {
let cfg = ConfigToml::default();
let profile = ConfigProfile::default();
assert_eq!(
resolve_request_user_input_tool_config(&cfg, &profile, &Features::with_defaults()),
(true, vec![ModeKind::Plan])
);
let mut features = Features::with_defaults();
features.enable(Feature::DefaultModeRequestUserInput);
assert_eq!(
resolve_request_user_input_tool_config(&cfg, &profile, &features),
(true, vec![ModeKind::Plan, ModeKind::Default])
);
}
#[test]
fn rejects_provider_auth_with_env_key() {
let err = toml::from_str::<ConfigToml>(
@@ -7363,6 +7407,8 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> {
include_apply_patch_tool: true,
web_search_mode: Constrained::allow_any(WebSearchMode::Cached),
web_search_config: None,
request_user_input_tool_enabled: true,
request_user_input_allowed_modes: vec![ModeKind::Plan],
use_experimental_unified_exec_tool: !cfg!(windows),
background_terminal_max_timeout: DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS,
ghost_snapshot: GhostSnapshotConfig::default(),
@@ -7810,6 +7856,8 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> {
include_apply_patch_tool: true,
web_search_mode: Constrained::allow_any(WebSearchMode::Cached),
web_search_config: None,
request_user_input_tool_enabled: true,
request_user_input_allowed_modes: vec![ModeKind::Plan],
use_experimental_unified_exec_tool: !cfg!(windows),
background_terminal_max_timeout: DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS,
ghost_snapshot: GhostSnapshotConfig::default(),
@@ -7971,6 +8019,8 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> {
include_apply_patch_tool: true,
web_search_mode: Constrained::allow_any(WebSearchMode::Cached),
web_search_config: None,
request_user_input_tool_enabled: true,
request_user_input_allowed_modes: vec![ModeKind::Plan],
use_experimental_unified_exec_tool: !cfg!(windows),
background_terminal_max_timeout: DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS,
ghost_snapshot: GhostSnapshotConfig::default(),
@@ -8117,6 +8167,8 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> {
include_apply_patch_tool: true,
web_search_mode: Constrained::allow_any(WebSearchMode::Cached),
web_search_config: None,
request_user_input_tool_enabled: true,
request_user_input_allowed_modes: vec![ModeKind::Plan],
use_experimental_unified_exec_tool: !cfg!(windows),
background_terminal_max_timeout: DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS,
ghost_snapshot: GhostSnapshotConfig::default(),
+43
View File
@@ -78,6 +78,7 @@ use codex_model_provider_info::merge_configured_model_providers;
use codex_models_manager::ModelsManagerConfig;
use codex_protocol::config_types::AltScreenMode;
use codex_protocol::config_types::ForcedLoginMethod;
use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::Personality;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::config_types::SandboxMode;
@@ -773,6 +774,12 @@ pub struct Config {
/// Additional parameters for the web search tool when it is enabled.
pub web_search_config: Option<WebSearchConfig>,
/// Whether to register the built-in request_user_input tool.
pub request_user_input_tool_enabled: bool,
/// Collaboration modes where request_user_input may be invoked.
pub request_user_input_allowed_modes: Vec<ModeKind>,
/// If set to `true`, used only the experimental unified exec tool.
pub use_experimental_unified_exec_tool: bool,
@@ -1957,6 +1964,38 @@ fn resolve_web_search_config(
}
}
fn resolve_request_user_input_tool_config(
config_toml: &ConfigToml,
config_profile: &ConfigProfile,
features: &Features,
) -> (bool, Vec<ModeKind>) {
let base = config_toml
.tools
.as_ref()
.and_then(|tools| tools.request_user_input.as_ref());
let profile = config_profile
.tools
.as_ref()
.and_then(|tools| tools.request_user_input.as_ref());
let enabled = profile
.and_then(|config| config.enabled)
.or_else(|| base.and_then(|config| config.enabled))
.unwrap_or(true);
let mut allowed_modes = profile
.and_then(|config| config.allowed_modes.clone())
.or_else(|| base.and_then(|config| config.allowed_modes.clone()))
.unwrap_or_else(|| vec![ModeKind::Plan]);
if features.enabled(Feature::DefaultModeRequestUserInput)
&& !allowed_modes.contains(&ModeKind::Default)
{
allowed_modes.push(ModeKind::Default);
}
(enabled, allowed_modes)
}
fn resolve_multi_agent_v2_config(
config_toml: &ConfigToml,
config_profile: &ConfigProfile,
@@ -2592,6 +2631,8 @@ impl Config {
let web_search_mode = resolve_web_search_mode(&cfg, &config_profile, &features)
.unwrap_or(WebSearchMode::Cached);
let web_search_config = resolve_web_search_config(&cfg, &config_profile);
let (request_user_input_tool_enabled, request_user_input_allowed_modes) =
resolve_request_user_input_tool_config(&cfg, &config_profile, &features);
let multi_agent_v2 = resolve_multi_agent_v2_config(&cfg, &config_profile);
let apps_mcp_path_override = if features.enabled(Feature::AppsMcpPathOverride) {
let base = apps_mcp_path_override_toml_config(cfg.features.as_ref());
@@ -3161,6 +3202,8 @@ impl Config {
include_apply_patch_tool: include_apply_patch_tool_flag,
web_search_mode: constrained_web_search_mode.value,
web_search_config,
request_user_input_tool_enabled,
request_user_input_allowed_modes,
use_experimental_unified_exec_tool,
background_terminal_max_timeout,
ghost_snapshot,
+4
View File
@@ -51,6 +51,10 @@ pub(super) async fn spawn_review_thread(
sess.services.main_execve_wrapper_exe.as_ref(),
)
.with_web_search_config(/*web_search_config*/ None)
.with_request_user_input_config(
config.request_user_input_tool_enabled,
config.request_user_input_allowed_modes.clone(),
)
.with_allow_login_shell(config.permissions.allow_login_shell)
.with_environment_mode(parent_turn_context.tools_config.environment_mode)
.with_spawn_agent_usage_hint(config.multi_agent_v2.usage_hint_enabled)
@@ -212,6 +212,10 @@ impl TurnContext {
.with_web_search_capability(provider_capabilities.web_search)
.with_unified_exec_shell_mode(self.tools_config.unified_exec_shell_mode.clone())
.with_web_search_config(self.tools_config.web_search_config.clone())
.with_request_user_input_config(
self.tools_config.request_user_input_tool_enabled,
self.tools_config.request_user_input_available_modes.clone(),
)
.with_allow_login_shell(self.tools_config.allow_login_shell)
.with_environment_mode(self.tools_config.environment_mode)
.with_spawn_agent_usage_hint(config.multi_agent_v2.usage_hint_enabled)
@@ -491,6 +495,10 @@ impl Session {
main_execve_wrapper_exe,
)
.with_web_search_config(per_turn_config.web_search_config.clone())
.with_request_user_input_config(
per_turn_config.request_user_input_tool_enabled,
per_turn_config.request_user_input_allowed_modes.clone(),
)
.with_allow_login_shell(per_turn_config.permissions.allow_login_shell)
.with_environment_mode(ToolEnvironmentMode::from_count(
environments.turn_environments.len(),
+5 -3
View File
@@ -332,9 +332,11 @@ fn collect_handler_tools(
handlers.push(Arc::new(UpdateGoalHandler));
}
handlers.push(Arc::new(RequestUserInputHandler {
available_modes: config.request_user_input_available_modes.clone(),
}));
if config.request_user_input_tool_enabled {
handlers.push(Arc::new(RequestUserInputHandler {
available_modes: config.request_user_input_available_modes.clone(),
}));
}
if config.request_permissions_tool_enabled {
handlers.push(Arc::new(RequestPermissionsHandler));
@@ -868,6 +868,32 @@ fn request_user_input_description_reflects_default_mode_feature_flag() {
);
}
#[test]
fn request_user_input_registration_respects_tool_config() {
let model_info = model_info();
let features = Features::with_defaults();
let available_models = Vec::new();
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
available_models: &available_models,
features: &features,
image_generation_tool_auth_allowed: true,
web_search_mode: Some(WebSearchMode::Cached),
session_source: SessionSource::Cli,
permission_profile: &PermissionProfile::Disabled,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
})
.with_request_user_input_config(/*enabled*/ false, vec![ModeKind::Plan]);
let (tools, _) = build_specs(
&tools_config,
/*mcp_tools*/ None,
/*deferred_mcp_tools*/ None,
&[],
);
assert_lacks_tool_name(&tools, REQUEST_USER_INPUT_TOOL_NAME);
}
#[test]
fn request_permissions_requires_feature_flag() {
let model_info = model_info();
@@ -203,3 +203,50 @@ async fn emits_deprecation_notice_for_use_legacy_landlock() -> anyhow::Result<()
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn emits_deprecation_notice_for_default_mode_request_user_input() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let mut builder = test_codex().with_config(|config| {
let mut entries = BTreeMap::new();
entries.insert("default_mode_request_user_input".to_string(), true);
let mut features = config.features.get().clone();
features.apply_map(&entries);
config
.features
.set(features)
.expect("test config should allow managed feature map updates");
});
let TestCodex { codex, .. } = builder.build(&server).await?;
let notice = wait_for_event_match(&codex, |event| match event {
EventMsg::DeprecationNotice(ev)
if ev
.summary
.contains("[features].default_mode_request_user_input") =>
{
Some(ev.clone())
}
_ => None,
})
.await;
let DeprecationNoticeEvent { summary, details } = notice;
assert_eq!(
summary,
"`[features].default_mode_request_user_input` is deprecated and will be removed soon."
.to_string(),
);
assert_eq!(
details.as_deref(),
Some(
"Use `[tools.request_user_input].allowed_modes = [\"plan\", \"default\"]` in config.toml instead."
),
);
Ok(())
}
+18 -1
View File
@@ -437,6 +437,12 @@ impl Features {
Feature::UseLegacyLandlock,
);
}
"default_mode_request_user_input" => {
self.record_legacy_usage_force(
"features.default_mode_request_user_input",
Feature::DefaultModeRequestUserInput,
);
}
_ => {}
}
match feature_for_key(k) {
@@ -531,6 +537,17 @@ fn legacy_usage_notice(alias: &str, feature: Feature) -> (String, Option<String>
.to_string();
(summary, Some(details))
}
Feature::DefaultModeRequestUserInput => {
let label = match alias {
"features.default_mode_request_user_input" | "default_mode_request_user_input" => {
"[features].default_mode_request_user_input"
}
_ => alias,
};
let summary = format!("`{label}` is deprecated and will be removed soon.");
let details = "Use `[tools.request_user_input].allowed_modes = [\"plan\", \"default\"]` in config.toml instead.".to_string();
(summary, Some(details))
}
_ => {
let label = if alias.contains('.') || alias.starts_with('[') {
alias.to_string()
@@ -1062,7 +1079,7 @@ pub const FEATURES: &[FeatureSpec] = &[
FeatureSpec {
id: Feature::DefaultModeRequestUserInput,
key: "default_mode_request_user_input",
stage: Stage::UnderDevelopment,
stage: Stage::Deprecated,
default_enabled: false,
},
FeatureSpec {
+36
View File
@@ -119,6 +119,18 @@ fn request_permissions_tool_is_under_development() {
assert_eq!(Feature::RequestPermissionsTool.default_enabled(), false);
}
#[test]
fn default_mode_request_user_input_is_deprecated_and_disabled_by_default() {
assert_eq!(
Feature::DefaultModeRequestUserInput.stage(),
Stage::Deprecated
);
assert_eq!(
Feature::DefaultModeRequestUserInput.default_enabled(),
false
);
}
#[test]
fn remote_compaction_v2_is_under_development() {
assert_eq!(Feature::RemoteCompactionV2.stage(), Stage::UnderDevelopment);
@@ -248,6 +260,30 @@ fn use_legacy_landlock_config_records_deprecation_notice() {
);
}
#[test]
fn default_mode_request_user_input_config_records_deprecation_notice() {
let mut entries = BTreeMap::new();
entries.insert("default_mode_request_user_input".to_string(), true);
let mut features = Features::with_defaults();
features.apply_map(&entries);
let usages = features.legacy_feature_usages().collect::<Vec<_>>();
assert_eq!(usages.len(), 1);
assert_eq!(usages[0].alias, "features.default_mode_request_user_input");
assert_eq!(usages[0].feature, Feature::DefaultModeRequestUserInput);
assert_eq!(
usages[0].summary,
"`[features].default_mode_request_user_input` is deprecated and will be removed soon."
);
assert_eq!(
usages[0].details.as_deref(),
Some(
"Use `[tools.request_user_input].allowed_modes = [\"plan\", \"default\"]` in config.toml instead."
)
);
}
#[test]
fn image_detail_original_is_a_removed_feature_key() {
assert_eq!(
+12
View File
@@ -122,6 +122,7 @@ pub struct ToolsConfig {
pub spawn_agent_usage_hint_text: Option<String>,
pub max_concurrent_threads_per_session: Option<usize>,
pub wait_agent_min_timeout_ms: Option<i64>,
pub request_user_input_tool_enabled: bool,
pub request_user_input_available_modes: Vec<ModeKind>,
pub experimental_supported_tools: Vec<String>,
pub agent_jobs_tools: bool,
@@ -259,6 +260,7 @@ impl ToolsConfig {
spawn_agent_usage_hint_text: None,
max_concurrent_threads_per_session: None,
wait_agent_min_timeout_ms: None,
request_user_input_tool_enabled: true,
request_user_input_available_modes: request_user_input_available_modes(features),
experimental_supported_tools: model_info.experimental_supported_tools.clone(),
agent_jobs_tools: include_agent_jobs,
@@ -272,6 +274,16 @@ impl ToolsConfig {
self
}
pub fn with_request_user_input_config(
mut self,
enabled: bool,
available_modes: Vec<ModeKind>,
) -> Self {
self.request_user_input_tool_enabled = enabled;
self.request_user_input_available_modes = available_modes;
self
}
pub fn with_namespace_tools_capability(mut self, namespace_tools: bool) -> Self {
if !namespace_tools {
self.namespace_tools = false;