diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index cd43041ab0e..81b795b0a18 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -26,6 +26,8 @@ use supports_color::Stream; mod mcp_cmd; use crate::mcp_cmd::McpCli; +use codex_core::config::Config; +use codex_core::config::ConfigOverrides; /// Codex CLI /// @@ -45,6 +47,9 @@ struct MultitoolCli { #[clap(flatten)] pub config_overrides: CliConfigOverrides, + #[clap(flatten)] + pub feature_toggles: FeatureToggles, + #[clap(flatten)] interactive: TuiCli, @@ -97,6 +102,9 @@ enum Subcommand { /// Internal: run the responses API proxy. #[clap(hide = true)] ResponsesApiProxy(ResponsesApiProxyArgs), + + /// Inspect feature flags. + Features(FeaturesCli), } #[derive(Debug, Parser)] @@ -231,6 +239,53 @@ fn print_exit_messages(exit_info: AppExitInfo) { } } +#[derive(Debug, Default, Parser, Clone)] +struct FeatureToggles { + /// Enable a feature (repeatable). Equivalent to `-c features.=true`. + #[arg(long = "enable", value_name = "FEATURE", action = clap::ArgAction::Append, global = true)] + enable: Vec, + + /// Disable a feature (repeatable). Equivalent to `-c features.=false`. + #[arg(long = "disable", value_name = "FEATURE", action = clap::ArgAction::Append, global = true)] + disable: Vec, +} + +impl FeatureToggles { + fn to_overrides(&self) -> Vec { + let mut v = Vec::new(); + for k in &self.enable { + v.push(format!("features.{k}=true")); + } + for k in &self.disable { + v.push(format!("features.{k}=false")); + } + v + } +} + +#[derive(Debug, Parser)] +struct FeaturesCli { + #[command(subcommand)] + sub: FeaturesSubcommand, +} + +#[derive(Debug, Parser)] +enum FeaturesSubcommand { + /// List known features with their stage and effective state. + List, +} + +fn stage_str(stage: codex_core::features::Stage) -> &'static str { + use codex_core::features::Stage; + match stage { + Stage::Experimental => "experimental", + Stage::Beta => "beta", + Stage::Stable => "stable", + Stage::Deprecated => "deprecated", + Stage::Removed => "removed", + } +} + /// As early as possible in the process lifecycle, apply hardening measures. We /// skip this in debug builds to avoid interfering with debugging. #[ctor::ctor] @@ -248,11 +303,17 @@ fn main() -> anyhow::Result<()> { async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<()> { let MultitoolCli { - config_overrides: root_config_overrides, + config_overrides: mut root_config_overrides, + feature_toggles, mut interactive, subcommand, } = MultitoolCli::parse(); + // Fold --enable/--disable into config overrides so they flow to all subcommands. + root_config_overrides + .raw_overrides + .extend(feature_toggles.to_overrides()); + match subcommand { None => { prepend_config_flags( @@ -381,6 +442,30 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() Some(Subcommand::GenerateTs(gen_cli)) => { codex_protocol_ts::generate_ts(&gen_cli.out_dir, gen_cli.prettier.as_deref())?; } + Some(Subcommand::Features(FeaturesCli { sub })) => match sub { + FeaturesSubcommand::List => { + // Respect root-level `-c` overrides plus top-level flags like `--profile`. + let cli_kv_overrides = root_config_overrides + .parse_overrides() + .map_err(|e| anyhow::anyhow!(e))?; + + // Thread through relevant top-level flags (at minimum, `--profile`). + // Also honor `--search` since it maps to a feature toggle. + let overrides = ConfigOverrides { + config_profile: interactive.config_profile.clone(), + tools_web_search_request: interactive.web_search.then_some(true), + ..Default::default() + }; + + let config = Config::load_with_cli_overrides(cli_kv_overrides, overrides).await?; + for def in codex_core::features::FEATURES.iter() { + let name = def.key; + let stage = stage_str(def.stage); + let enabled = config.features.enabled(def.id); + println!("{name}\t{stage}\t{enabled}"); + } + } + }, } Ok(()) @@ -484,6 +569,7 @@ mod tests { interactive, config_overrides: root_overrides, subcommand, + feature_toggles: _, } = cli; let Subcommand::Resume(ResumeCommand { diff --git a/codex-rs/cli/src/mcp_cmd.rs b/codex-rs/cli/src/mcp_cmd.rs index 0a5be0dc23a..d1b0ffc835b 100644 --- a/codex-rs/cli/src/mcp_cmd.rs +++ b/codex-rs/cli/src/mcp_cmd.rs @@ -13,6 +13,7 @@ use codex_core::config::load_global_mcp_servers; use codex_core::config::write_global_mcp_servers; use codex_core::config_types::McpServerConfig; use codex_core::config_types::McpServerTransportConfig; +use codex_core::features::Feature; use codex_core::mcp::auth::compute_auth_statuses; use codex_core::protocol::McpAuthStatus; use codex_rmcp_client::delete_oauth_tokens; @@ -285,7 +286,7 @@ async fn run_login(config_overrides: &CliConfigOverrides, login_args: LoginArgs) .await .context("failed to load configuration")?; - if !config.use_experimental_use_rmcp_client { + if !config.features.enabled(Feature::RmcpClient) { bail!( "OAuth login is only supported when experimental_use_rmcp_client is true in config.toml." ); diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index fe352f0103f..5d8974ea2e8 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -364,7 +364,9 @@ impl Session { let mcp_fut = McpConnectionManager::new( config.mcp_servers.clone(), - config.use_experimental_use_rmcp_client, + config + .features + .enabled(crate::features::Feature::RmcpClient), config.mcp_oauth_credentials_store_mode, ); let default_shell_fut = shell::default_user_shell(); @@ -446,12 +448,7 @@ impl Session { client, tools_config: ToolsConfig::new(&ToolsConfigParams { model_family: &config.model_family, - include_plan_tool: config.include_plan_tool, - include_apply_patch_tool: config.include_apply_patch_tool, - include_web_search_request: config.tools_web_search_request, - use_streamable_shell_tool: config.use_experimental_streamable_shell_tool, - include_view_image_tool: config.include_view_image_tool, - experimental_unified_exec_tool: config.use_experimental_unified_exec_tool, + features: &config.features, }), user_instructions, base_instructions, @@ -1195,12 +1192,7 @@ async fn submission_loop( let tools_config = ToolsConfig::new(&ToolsConfigParams { model_family: &effective_family, - include_plan_tool: config.include_plan_tool, - include_apply_patch_tool: config.include_apply_patch_tool, - include_web_search_request: config.tools_web_search_request, - use_streamable_shell_tool: config.use_experimental_streamable_shell_tool, - include_view_image_tool: config.include_view_image_tool, - experimental_unified_exec_tool: config.use_experimental_unified_exec_tool, + features: &config.features, }); let new_turn_context = TurnContext { @@ -1297,14 +1289,7 @@ async fn submission_loop( client, tools_config: ToolsConfig::new(&ToolsConfigParams { model_family: &model_family, - include_plan_tool: config.include_plan_tool, - include_apply_patch_tool: config.include_apply_patch_tool, - include_web_search_request: config.tools_web_search_request, - use_streamable_shell_tool: config - .use_experimental_streamable_shell_tool, - include_view_image_tool: config.include_view_image_tool, - experimental_unified_exec_tool: config - .use_experimental_unified_exec_tool, + features: &config.features, }), user_instructions: turn_context.user_instructions.clone(), base_instructions: turn_context.base_instructions.clone(), @@ -1536,14 +1521,15 @@ async fn spawn_review_thread( let model = config.review_model.clone(); let review_model_family = find_family_for_model(&model) .unwrap_or_else(|| parent_turn_context.client.get_model_family()); + // For reviews, disable plan, web_search, view_image regardless of global settings. + let mut review_features = config.features.clone(); + review_features.disable(crate::features::Feature::PlanTool); + review_features.disable(crate::features::Feature::WebSearchRequest); + review_features.disable(crate::features::Feature::ViewImageTool); + review_features.disable(crate::features::Feature::StreamableShell); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_family: &review_model_family, - include_plan_tool: false, - include_apply_patch_tool: config.include_apply_patch_tool, - include_web_search_request: false, - use_streamable_shell_tool: false, - include_view_image_tool: false, - experimental_unified_exec_tool: config.use_experimental_unified_exec_tool, + features: &review_features, }); let base_instructions = REVIEW_PROMPT.to_string(); @@ -2748,12 +2734,7 @@ mod tests { ); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_family: &config.model_family, - include_plan_tool: config.include_plan_tool, - include_apply_patch_tool: config.include_apply_patch_tool, - include_web_search_request: config.tools_web_search_request, - use_streamable_shell_tool: config.use_experimental_streamable_shell_tool, - include_view_image_tool: config.include_view_image_tool, - experimental_unified_exec_tool: config.use_experimental_unified_exec_tool, + features: &config.features, }); let turn_context = TurnContext { client, @@ -2821,12 +2802,7 @@ mod tests { ); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_family: &config.model_family, - include_plan_tool: config.include_plan_tool, - include_apply_patch_tool: config.include_apply_patch_tool, - include_web_search_request: config.tools_web_search_request, - use_streamable_shell_tool: config.use_experimental_streamable_shell_tool, - include_view_image_tool: config.include_view_image_tool, - experimental_unified_exec_tool: config.use_experimental_unified_exec_tool, + features: &config.features, }); let turn_context = Arc::new(TurnContext { client, diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index c715651851e..47db1a88bf9 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -17,6 +17,10 @@ use crate::config_types::ShellEnvironmentPolicy; use crate::config_types::ShellEnvironmentPolicyToml; use crate::config_types::Tui; use crate::config_types::UriBasedFileOpener; +use crate::features::Feature; +use crate::features::FeatureOverrides; +use crate::features::Features; +use crate::features::FeaturesToml; use crate::git_info::resolve_root_git_project_for_trust; use crate::model_family::ModelFamily; use crate::model_family::derive_default_model_family; @@ -218,6 +222,9 @@ pub struct Config { /// Include the `view_image` tool that lets the agent attach a local image path to context. pub include_view_image_tool: bool, + /// Centralized feature flags; source of truth for feature gating. + pub features: Features, + /// The active profile name used to derive this `Config` (if any). pub active_profile: Option, @@ -794,19 +801,15 @@ pub struct ConfigToml { /// Base URL for requests to ChatGPT (as opposed to the OpenAI API). pub chatgpt_base_url: Option, - /// Experimental path to a file whose contents replace the built-in BASE_INSTRUCTIONS. - pub experimental_instructions_file: Option, - - pub experimental_use_exec_command_tool: Option, - pub experimental_use_unified_exec_tool: Option, - pub experimental_use_rmcp_client: Option, - pub experimental_use_freeform_apply_patch: Option, - pub projects: Option>, /// Nested tools section for feature toggles pub tools: Option, + /// Centralized feature flags (new). Prefer this over individual toggles. + #[serde(default)] + pub features: Option, + /// When true, disables burst-paste detection for typed input entirely. /// All characters are inserted as they are received, and no buffering /// or placeholder replacement will occur for fast keypress bursts. @@ -817,6 +820,13 @@ pub struct ConfigToml { /// Tracks whether the Windows onboarding screen has been acknowledged. pub windows_wsl_setup_acknowledged: Option, + + /// Legacy, now use features + pub experimental_instructions_file: Option, + pub experimental_use_exec_command_tool: Option, + pub experimental_use_unified_exec_tool: Option, + pub experimental_use_rmcp_client: Option, + pub experimental_use_freeform_apply_patch: Option, } impl From for UserSavedConfig { @@ -980,9 +990,9 @@ impl Config { config_profile: config_profile_key, codex_linux_sandbox_exe, base_instructions, - include_plan_tool, - include_apply_patch_tool, - include_view_image_tool, + include_plan_tool: include_plan_tool_override, + include_apply_patch_tool: include_apply_patch_tool_override, + include_view_image_tool: include_view_image_tool_override, show_raw_agent_reasoning, tools_web_search_request: override_tools_web_search_request, } = overrides; @@ -1005,6 +1015,15 @@ impl Config { None => ConfigProfile::default(), }; + let feature_overrides = FeatureOverrides { + include_plan_tool: include_plan_tool_override, + include_apply_patch_tool: include_apply_patch_tool_override, + include_view_image_tool: include_view_image_tool_override, + web_search_request: override_tools_web_search_request, + }; + + let features = Features::from_config(&cfg, &config_profile, feature_overrides); + let sandbox_policy = cfg.derive_sandbox_policy(sandbox_mode); let mut model_providers = built_in_model_providers(); @@ -1050,13 +1069,13 @@ impl Config { let history = cfg.history.unwrap_or_default(); - let tools_web_search_request = override_tools_web_search_request - .or(cfg.tools.as_ref().and_then(|t| t.web_search)) - .unwrap_or(false); - - let include_view_image_tool = include_view_image_tool - .or(cfg.tools.as_ref().and_then(|t| t.view_image)) - .unwrap_or(true); + let include_plan_tool_flag = features.enabled(Feature::PlanTool); + let include_apply_patch_tool_flag = features.enabled(Feature::ApplyPatchFreeform); + let include_view_image_tool_flag = features.enabled(Feature::ViewImageTool); + let tools_web_search_request = features.enabled(Feature::WebSearchRequest); + let use_experimental_streamable_shell_tool = features.enabled(Feature::StreamableShell); + let use_experimental_unified_exec_tool = features.enabled(Feature::UnifiedExec); + let use_experimental_use_rmcp_client = features.enabled(Feature::RmcpClient); let model = model .or(config_profile.model) @@ -1164,19 +1183,14 @@ impl Config { .chatgpt_base_url .or(cfg.chatgpt_base_url) .unwrap_or("https://chatgpt.com/backend-api/".to_string()), - include_plan_tool: include_plan_tool.unwrap_or(false), - include_apply_patch_tool: include_apply_patch_tool - .or(cfg.experimental_use_freeform_apply_patch) - .unwrap_or(false), + include_plan_tool: include_plan_tool_flag, + include_apply_patch_tool: include_apply_patch_tool_flag, tools_web_search_request, - use_experimental_streamable_shell_tool: cfg - .experimental_use_exec_command_tool - .unwrap_or(false), - use_experimental_unified_exec_tool: cfg - .experimental_use_unified_exec_tool - .unwrap_or(false), - use_experimental_use_rmcp_client: cfg.experimental_use_rmcp_client.unwrap_or(false), - include_view_image_tool, + use_experimental_streamable_shell_tool, + use_experimental_unified_exec_tool, + use_experimental_use_rmcp_client, + include_view_image_tool: include_view_image_tool_flag, + features, active_profile: active_profile_name, windows_wsl_setup_acknowledged: cfg.windows_wsl_setup_acknowledged.unwrap_or(false), disable_paste_burst: cfg.disable_paste_burst.unwrap_or(false), @@ -1309,6 +1323,7 @@ pub fn log_dir(cfg: &Config) -> std::io::Result { mod tests { use crate::config_types::HistoryPersistence; use crate::config_types::Notifications; + use crate::features::Feature; use super::*; use pretty_assertions::assert_eq; @@ -1436,6 +1451,93 @@ exclude_slash_tmp = true Ok(()) } + #[test] + fn profile_legacy_toggles_override_base() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let mut profiles = HashMap::new(); + profiles.insert( + "work".to_string(), + ConfigProfile { + include_plan_tool: Some(true), + include_view_image_tool: Some(false), + ..Default::default() + }, + ); + let cfg = ConfigToml { + profiles, + profile: Some("work".to_string()), + ..Default::default() + }; + + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + )?; + + assert!(config.features.enabled(Feature::PlanTool)); + assert!(!config.features.enabled(Feature::ViewImageTool)); + assert!(config.include_plan_tool); + assert!(!config.include_view_image_tool); + + Ok(()) + } + + #[test] + fn feature_table_overrides_legacy_flags() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let mut entries = BTreeMap::new(); + entries.insert("plan_tool".to_string(), false); + entries.insert("apply_patch_freeform".to_string(), false); + let cfg = ConfigToml { + features: Some(crate::features::FeaturesToml { entries }), + ..Default::default() + }; + + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + )?; + + assert!(!config.features.enabled(Feature::PlanTool)); + assert!(!config.features.enabled(Feature::ApplyPatchFreeform)); + assert!(!config.include_plan_tool); + assert!(!config.include_apply_patch_tool); + + Ok(()) + } + + #[test] + fn legacy_toggles_map_to_features() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cfg = ConfigToml { + experimental_use_exec_command_tool: Some(true), + experimental_use_unified_exec_tool: Some(true), + experimental_use_rmcp_client: Some(true), + experimental_use_freeform_apply_patch: Some(true), + ..Default::default() + }; + + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + )?; + + assert!(config.features.enabled(Feature::ApplyPatchFreeform)); + assert!(config.features.enabled(Feature::StreamableShell)); + assert!(config.features.enabled(Feature::UnifiedExec)); + assert!(config.features.enabled(Feature::RmcpClient)); + + assert!(config.include_apply_patch_tool); + assert!(config.use_experimental_streamable_shell_tool); + assert!(config.use_experimental_unified_exec_tool); + assert!(config.use_experimental_use_rmcp_client); + + Ok(()) + } + #[test] fn config_honors_explicit_file_oauth_store_mode() -> std::io::Result<()> { let codex_home = TempDir::new()?; @@ -2120,6 +2222,7 @@ model_verbosity = "high" use_experimental_unified_exec_tool: false, use_experimental_use_rmcp_client: false, include_view_image_tool: true, + features: Features::with_defaults(), active_profile: Some("o3".to_string()), windows_wsl_setup_acknowledged: false, disable_paste_burst: false, @@ -2183,6 +2286,7 @@ model_verbosity = "high" use_experimental_unified_exec_tool: false, use_experimental_use_rmcp_client: false, include_view_image_tool: true, + features: Features::with_defaults(), active_profile: Some("gpt3".to_string()), windows_wsl_setup_acknowledged: false, disable_paste_burst: false, @@ -2261,6 +2365,7 @@ model_verbosity = "high" use_experimental_unified_exec_tool: false, use_experimental_use_rmcp_client: false, include_view_image_tool: true, + features: Features::with_defaults(), active_profile: Some("zdr".to_string()), windows_wsl_setup_acknowledged: false, disable_paste_burst: false, @@ -2325,6 +2430,7 @@ model_verbosity = "high" use_experimental_unified_exec_tool: false, use_experimental_use_rmcp_client: false, include_view_image_tool: true, + features: Features::with_defaults(), active_profile: Some("gpt5".to_string()), windows_wsl_setup_acknowledged: false, disable_paste_burst: false, diff --git a/codex-rs/core/src/config_profile.rs b/codex-rs/core/src/config_profile.rs index da521760681..ba2201ed9ca 100644 --- a/codex-rs/core/src/config_profile.rs +++ b/codex-rs/core/src/config_profile.rs @@ -20,6 +20,18 @@ pub struct ConfigProfile { pub model_verbosity: Option, pub chatgpt_base_url: Option, pub experimental_instructions_file: Option, + pub include_plan_tool: Option, + pub include_apply_patch_tool: Option, + pub include_view_image_tool: Option, + pub experimental_use_unified_exec_tool: Option, + pub experimental_use_exec_command_tool: Option, + pub experimental_use_rmcp_client: Option, + pub experimental_use_freeform_apply_patch: Option, + pub tools_web_search: Option, + pub tools_view_image: Option, + /// Optional feature toggles scoped to this profile. + #[serde(default)] + pub features: Option, } impl From for codex_app_server_protocol::Profile { diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs new file mode 100644 index 00000000000..b8314b0c14b --- /dev/null +++ b/codex-rs/core/src/features.rs @@ -0,0 +1,250 @@ +//! Centralized feature flags and metadata. +//! +//! This module defines a small set of toggles that gate experimental and +//! optional behavior across the codebase. Instead of wiring individual +//! booleans through multiple types, call sites consult a single `Features` +//! container attached to `Config`. + +use crate::config::ConfigToml; +use crate::config_profile::ConfigProfile; +use serde::Deserialize; +use std::collections::BTreeMap; +use std::collections::BTreeSet; + +mod legacy; +pub(crate) use legacy::LegacyFeatureToggles; + +/// High-level lifecycle stage for a feature. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Stage { + Experimental, + Beta, + Stable, + Deprecated, + Removed, +} + +/// Unique features toggled via configuration. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Feature { + /// Use the single unified PTY-backed exec tool. + UnifiedExec, + /// Use the streamable exec-command/write-stdin tool pair. + StreamableShell, + /// Use the official Rust MCP client (rmcp). + RmcpClient, + /// Include the plan tool. + PlanTool, + /// Include the freeform apply_patch tool. + ApplyPatchFreeform, + /// Include the view_image tool. + ViewImageTool, + /// Allow the model to request web searches. + WebSearchRequest, +} + +impl Feature { + pub fn key(self) -> &'static str { + self.info().key + } + + pub fn stage(self) -> Stage { + self.info().stage + } + + pub fn default_enabled(self) -> bool { + self.info().default_enabled + } + + fn info(self) -> &'static FeatureSpec { + FEATURES + .iter() + .find(|spec| spec.id == self) + .unwrap_or_else(|| unreachable!("missing FeatureSpec for {:?}", self)) + } +} + +/// Holds the effective set of enabled features. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct Features { + enabled: BTreeSet, +} + +#[derive(Debug, Clone, Default)] +pub struct FeatureOverrides { + pub include_plan_tool: Option, + pub include_apply_patch_tool: Option, + pub include_view_image_tool: Option, + pub web_search_request: Option, +} + +impl FeatureOverrides { + fn apply(self, features: &mut Features) { + LegacyFeatureToggles { + include_plan_tool: self.include_plan_tool, + include_apply_patch_tool: self.include_apply_patch_tool, + include_view_image_tool: self.include_view_image_tool, + tools_web_search: self.web_search_request, + ..Default::default() + } + .apply(features); + } +} + +impl Features { + /// Starts with built-in defaults. + pub fn with_defaults() -> Self { + let mut set = BTreeSet::new(); + for spec in FEATURES { + if spec.default_enabled { + set.insert(spec.id); + } + } + Self { enabled: set } + } + + pub fn enabled(&self, f: Feature) -> bool { + self.enabled.contains(&f) + } + + pub fn enable(&mut self, f: Feature) { + self.enabled.insert(f); + } + + pub fn disable(&mut self, f: Feature) { + self.enabled.remove(&f); + } + + /// Apply a table of key -> bool toggles (e.g. from TOML). + pub fn apply_map(&mut self, m: &BTreeMap) { + for (k, v) in m { + match feature_for_key(k) { + Some(feat) => { + if *v { + self.enable(feat); + } else { + self.disable(feat); + } + } + None => { + tracing::warn!("unknown feature key in config: {k}"); + } + } + } + } + + pub fn from_config( + cfg: &ConfigToml, + config_profile: &ConfigProfile, + overrides: FeatureOverrides, + ) -> Self { + let mut features = Features::with_defaults(); + + let base_legacy = LegacyFeatureToggles { + experimental_use_freeform_apply_patch: cfg.experimental_use_freeform_apply_patch, + experimental_use_exec_command_tool: cfg.experimental_use_exec_command_tool, + experimental_use_unified_exec_tool: cfg.experimental_use_unified_exec_tool, + experimental_use_rmcp_client: cfg.experimental_use_rmcp_client, + tools_web_search: cfg.tools.as_ref().and_then(|t| t.web_search), + tools_view_image: cfg.tools.as_ref().and_then(|t| t.view_image), + ..Default::default() + }; + base_legacy.apply(&mut features); + + if let Some(base_features) = cfg.features.as_ref() { + features.apply_map(&base_features.entries); + } + + let profile_legacy = LegacyFeatureToggles { + include_plan_tool: config_profile.include_plan_tool, + include_apply_patch_tool: config_profile.include_apply_patch_tool, + include_view_image_tool: config_profile.include_view_image_tool, + experimental_use_freeform_apply_patch: config_profile + .experimental_use_freeform_apply_patch, + experimental_use_exec_command_tool: config_profile.experimental_use_exec_command_tool, + experimental_use_unified_exec_tool: config_profile.experimental_use_unified_exec_tool, + experimental_use_rmcp_client: config_profile.experimental_use_rmcp_client, + tools_web_search: config_profile.tools_web_search, + tools_view_image: config_profile.tools_view_image, + }; + profile_legacy.apply(&mut features); + if let Some(profile_features) = config_profile.features.as_ref() { + features.apply_map(&profile_features.entries); + } + + overrides.apply(&mut features); + + features + } +} + +/// Keys accepted in `[features]` tables. +fn feature_for_key(key: &str) -> Option { + for spec in FEATURES { + if spec.key == key { + return Some(spec.id); + } + } + legacy::feature_for_key(key) +} + +/// Deserializable features table for TOML. +#[derive(Deserialize, Debug, Clone, Default, PartialEq)] +pub struct FeaturesToml { + #[serde(flatten)] + pub entries: BTreeMap, +} + +/// Single, easy-to-read registry of all feature definitions. +#[derive(Debug, Clone, Copy)] +pub struct FeatureSpec { + pub id: Feature, + pub key: &'static str, + pub stage: Stage, + pub default_enabled: bool, +} + +pub const FEATURES: &[FeatureSpec] = &[ + FeatureSpec { + id: Feature::UnifiedExec, + key: "unified_exec", + stage: Stage::Experimental, + default_enabled: false, + }, + FeatureSpec { + id: Feature::StreamableShell, + key: "streamable_shell", + stage: Stage::Experimental, + default_enabled: false, + }, + FeatureSpec { + id: Feature::RmcpClient, + key: "rmcp_client", + stage: Stage::Experimental, + default_enabled: false, + }, + FeatureSpec { + id: Feature::PlanTool, + key: "plan_tool", + stage: Stage::Stable, + default_enabled: false, + }, + FeatureSpec { + id: Feature::ApplyPatchFreeform, + key: "apply_patch_freeform", + stage: Stage::Beta, + default_enabled: false, + }, + FeatureSpec { + id: Feature::ViewImageTool, + key: "view_image_tool", + stage: Stage::Stable, + default_enabled: true, + }, + FeatureSpec { + id: Feature::WebSearchRequest, + key: "web_search_request", + stage: Stage::Stable, + default_enabled: false, + }, +]; diff --git a/codex-rs/core/src/features/legacy.rs b/codex-rs/core/src/features/legacy.rs new file mode 100644 index 00000000000..3becb07e7cf --- /dev/null +++ b/codex-rs/core/src/features/legacy.rs @@ -0,0 +1,158 @@ +use super::Feature; +use super::Features; +use tracing::info; + +#[derive(Clone, Copy)] +struct Alias { + legacy_key: &'static str, + feature: Feature, +} + +const ALIASES: &[Alias] = &[ + Alias { + legacy_key: "experimental_use_unified_exec_tool", + feature: Feature::UnifiedExec, + }, + Alias { + legacy_key: "experimental_use_exec_command_tool", + feature: Feature::StreamableShell, + }, + Alias { + legacy_key: "experimental_use_rmcp_client", + feature: Feature::RmcpClient, + }, + Alias { + legacy_key: "experimental_use_freeform_apply_patch", + feature: Feature::ApplyPatchFreeform, + }, + Alias { + legacy_key: "include_apply_patch_tool", + feature: Feature::ApplyPatchFreeform, + }, + Alias { + legacy_key: "include_plan_tool", + feature: Feature::PlanTool, + }, + Alias { + legacy_key: "include_view_image_tool", + feature: Feature::ViewImageTool, + }, + Alias { + legacy_key: "web_search", + feature: Feature::WebSearchRequest, + }, +]; + +pub(crate) fn feature_for_key(key: &str) -> Option { + ALIASES + .iter() + .find(|alias| alias.legacy_key == key) + .map(|alias| { + log_alias(alias.legacy_key, alias.feature); + alias.feature + }) +} + +#[derive(Debug, Default)] +pub struct LegacyFeatureToggles { + pub include_plan_tool: Option, + pub include_apply_patch_tool: Option, + pub include_view_image_tool: Option, + pub experimental_use_freeform_apply_patch: Option, + pub experimental_use_exec_command_tool: Option, + pub experimental_use_unified_exec_tool: Option, + pub experimental_use_rmcp_client: Option, + pub tools_web_search: Option, + pub tools_view_image: Option, +} + +impl LegacyFeatureToggles { + pub fn apply(self, features: &mut Features) { + set_if_some( + features, + Feature::PlanTool, + self.include_plan_tool, + "include_plan_tool", + ); + set_if_some( + features, + Feature::ApplyPatchFreeform, + self.include_apply_patch_tool, + "include_apply_patch_tool", + ); + set_if_some( + features, + Feature::ApplyPatchFreeform, + self.experimental_use_freeform_apply_patch, + "experimental_use_freeform_apply_patch", + ); + set_if_some( + features, + Feature::StreamableShell, + self.experimental_use_exec_command_tool, + "experimental_use_exec_command_tool", + ); + set_if_some( + features, + Feature::UnifiedExec, + self.experimental_use_unified_exec_tool, + "experimental_use_unified_exec_tool", + ); + set_if_some( + features, + Feature::RmcpClient, + self.experimental_use_rmcp_client, + "experimental_use_rmcp_client", + ); + set_if_some( + features, + Feature::WebSearchRequest, + self.tools_web_search, + "tools.web_search", + ); + set_if_some( + features, + Feature::ViewImageTool, + self.include_view_image_tool, + "include_view_image_tool", + ); + set_if_some( + features, + Feature::ViewImageTool, + self.tools_view_image, + "tools.view_image", + ); + } +} + +fn set_if_some( + features: &mut Features, + feature: Feature, + maybe_value: Option, + alias_key: &'static str, +) { + if let Some(enabled) = maybe_value { + set_feature(features, feature, enabled); + log_alias(alias_key, feature); + } +} + +fn set_feature(features: &mut Features, feature: Feature, enabled: bool) { + if enabled { + features.enable(feature); + } else { + features.disable(feature); + } +} + +fn log_alias(alias: &str, feature: Feature) { + let canonical = feature.key(); + if alias == canonical { + return; + } + info!( + %alias, + canonical, + "legacy feature toggle detected; prefer `[features].{canonical}`" + ); +} diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 201d8feb4d9..22e1d4cdf0f 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -29,6 +29,7 @@ pub mod exec; mod exec_command; pub mod exec_env; pub mod executor; +pub mod features; mod flags; pub mod git_info; pub mod landlock; diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index c10f8e22f9a..bb1df187c2d 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -1,5 +1,7 @@ use crate::client_common::tools::ResponsesApiTool; use crate::client_common::tools::ToolSpec; +use crate::features::Feature; +use crate::features::Features; use crate::model_family::ModelFamily; use crate::tools::handlers::PLAN_TOOL; use crate::tools::handlers::apply_patch::ApplyPatchToolType; @@ -33,26 +35,23 @@ pub(crate) struct ToolsConfig { pub(crate) struct ToolsConfigParams<'a> { pub(crate) model_family: &'a ModelFamily, - pub(crate) include_plan_tool: bool, - pub(crate) include_apply_patch_tool: bool, - pub(crate) include_web_search_request: bool, - pub(crate) use_streamable_shell_tool: bool, - pub(crate) include_view_image_tool: bool, - pub(crate) experimental_unified_exec_tool: bool, + pub(crate) features: &'a Features, } impl ToolsConfig { pub fn new(params: &ToolsConfigParams) -> Self { let ToolsConfigParams { model_family, - include_plan_tool, - include_apply_patch_tool, - include_web_search_request, - use_streamable_shell_tool, - include_view_image_tool, - experimental_unified_exec_tool, + features, } = params; - let shell_type = if *use_streamable_shell_tool { + let use_streamable_shell_tool = features.enabled(Feature::StreamableShell); + let experimental_unified_exec_tool = features.enabled(Feature::UnifiedExec); + let include_plan_tool = features.enabled(Feature::PlanTool); + let include_apply_patch_tool = features.enabled(Feature::ApplyPatchFreeform); + let include_web_search_request = features.enabled(Feature::WebSearchRequest); + let include_view_image_tool = features.enabled(Feature::ViewImageTool); + + let shell_type = if use_streamable_shell_tool { ConfigShellToolType::Streamable } else if model_family.uses_local_shell_tool { ConfigShellToolType::Local @@ -64,7 +63,7 @@ impl ToolsConfig { Some(ApplyPatchToolType::Freeform) => Some(ApplyPatchToolType::Freeform), Some(ApplyPatchToolType::Function) => Some(ApplyPatchToolType::Function), None => { - if *include_apply_patch_tool { + if include_apply_patch_tool { Some(ApplyPatchToolType::Freeform) } else { None @@ -74,11 +73,11 @@ impl ToolsConfig { Self { shell_type, - plan_tool: *include_plan_tool, + plan_tool: include_plan_tool, apply_patch_tool_type, - web_search_request: *include_web_search_request, - include_view_image_tool: *include_view_image_tool, - experimental_unified_exec_tool: *experimental_unified_exec_tool, + web_search_request: include_web_search_request, + include_view_image_tool, + experimental_unified_exec_tool, experimental_supported_tools: model_family.experimental_supported_tools.clone(), } } @@ -906,14 +905,13 @@ mod tests { fn test_build_specs() { let model_family = find_family_for_model("codex-mini-latest") .expect("codex-mini-latest should be a valid model family"); + let mut features = Features::with_defaults(); + features.enable(Feature::PlanTool); + features.enable(Feature::WebSearchRequest); + features.enable(Feature::UnifiedExec); let config = ToolsConfig::new(&ToolsConfigParams { model_family: &model_family, - include_plan_tool: true, - include_apply_patch_tool: false, - include_web_search_request: true, - use_streamable_shell_tool: false, - include_view_image_tool: true, - experimental_unified_exec_tool: true, + features: &features, }); let (tools, _) = build_specs(&config, Some(HashMap::new())).build(); @@ -926,14 +924,13 @@ mod tests { #[test] fn test_build_specs_default_shell() { let model_family = find_family_for_model("o3").expect("o3 should be a valid model family"); + let mut features = Features::with_defaults(); + features.enable(Feature::PlanTool); + features.enable(Feature::WebSearchRequest); + features.enable(Feature::UnifiedExec); let config = ToolsConfig::new(&ToolsConfigParams { model_family: &model_family, - include_plan_tool: true, - include_apply_patch_tool: false, - include_web_search_request: true, - use_streamable_shell_tool: false, - include_view_image_tool: true, - experimental_unified_exec_tool: true, + features: &features, }); let (tools, _) = build_specs(&config, Some(HashMap::new())).build(); @@ -948,14 +945,12 @@ mod tests { fn test_parallel_support_flags() { let model_family = find_family_for_model("gpt-5-codex") .expect("codex-mini-latest should be a valid model family"); + let mut features = Features::with_defaults(); + features.disable(Feature::ViewImageTool); + features.enable(Feature::UnifiedExec); let config = ToolsConfig::new(&ToolsConfigParams { model_family: &model_family, - include_plan_tool: false, - include_apply_patch_tool: false, - include_web_search_request: false, - use_streamable_shell_tool: false, - include_view_image_tool: false, - experimental_unified_exec_tool: true, + features: &features, }); let (tools, _) = build_specs(&config, None).build(); @@ -969,14 +964,11 @@ mod tests { fn test_test_model_family_includes_sync_tool() { let model_family = find_family_for_model("test-gpt-5-codex") .expect("test-gpt-5-codex should be a valid model family"); + let mut features = Features::with_defaults(); + features.disable(Feature::ViewImageTool); let config = ToolsConfig::new(&ToolsConfigParams { model_family: &model_family, - include_plan_tool: false, - include_apply_patch_tool: false, - include_web_search_request: false, - use_streamable_shell_tool: false, - include_view_image_tool: false, - experimental_unified_exec_tool: false, + features: &features, }); let (tools, _) = build_specs(&config, None).build(); @@ -1001,14 +993,12 @@ mod tests { #[test] fn test_build_specs_mcp_tools() { let model_family = find_family_for_model("o3").expect("o3 should be a valid model family"); + let mut features = Features::with_defaults(); + features.enable(Feature::UnifiedExec); + features.enable(Feature::WebSearchRequest); let config = ToolsConfig::new(&ToolsConfigParams { model_family: &model_family, - include_plan_tool: false, - include_apply_patch_tool: false, - include_web_search_request: true, - use_streamable_shell_tool: false, - include_view_image_tool: true, - experimental_unified_exec_tool: true, + features: &features, }); let (tools, _) = build_specs( &config, @@ -1106,14 +1096,11 @@ mod tests { #[test] fn test_build_specs_mcp_tools_sorted_by_name() { let model_family = find_family_for_model("o3").expect("o3 should be a valid model family"); + let mut features = Features::with_defaults(); + features.enable(Feature::UnifiedExec); let config = ToolsConfig::new(&ToolsConfigParams { model_family: &model_family, - include_plan_tool: false, - include_apply_patch_tool: false, - include_web_search_request: false, - use_streamable_shell_tool: false, - include_view_image_tool: true, - experimental_unified_exec_tool: true, + features: &features, }); // Intentionally construct a map with keys that would sort alphabetically. @@ -1183,14 +1170,12 @@ mod tests { fn test_mcp_tool_property_missing_type_defaults_to_string() { let model_family = find_family_for_model("gpt-5-codex") .expect("gpt-5-codex should be a valid model family"); + let mut features = Features::with_defaults(); + features.enable(Feature::UnifiedExec); + features.enable(Feature::WebSearchRequest); let config = ToolsConfig::new(&ToolsConfigParams { model_family: &model_family, - include_plan_tool: false, - include_apply_patch_tool: false, - include_web_search_request: true, - use_streamable_shell_tool: false, - include_view_image_tool: true, - experimental_unified_exec_tool: true, + features: &features, }); let (tools, _) = build_specs( @@ -1252,14 +1237,12 @@ mod tests { fn test_mcp_tool_integer_normalized_to_number() { let model_family = find_family_for_model("gpt-5-codex") .expect("gpt-5-codex should be a valid model family"); + let mut features = Features::with_defaults(); + features.enable(Feature::UnifiedExec); + features.enable(Feature::WebSearchRequest); let config = ToolsConfig::new(&ToolsConfigParams { model_family: &model_family, - include_plan_tool: false, - include_apply_patch_tool: false, - include_web_search_request: true, - use_streamable_shell_tool: false, - include_view_image_tool: true, - experimental_unified_exec_tool: true, + features: &features, }); let (tools, _) = build_specs( @@ -1316,14 +1299,13 @@ mod tests { fn test_mcp_tool_array_without_items_gets_default_string_items() { let model_family = find_family_for_model("gpt-5-codex") .expect("gpt-5-codex should be a valid model family"); + let mut features = Features::with_defaults(); + features.enable(Feature::UnifiedExec); + features.enable(Feature::WebSearchRequest); + features.enable(Feature::ApplyPatchFreeform); let config = ToolsConfig::new(&ToolsConfigParams { model_family: &model_family, - include_plan_tool: false, - include_apply_patch_tool: true, - include_web_search_request: true, - use_streamable_shell_tool: false, - include_view_image_tool: true, - experimental_unified_exec_tool: true, + features: &features, }); let (tools, _) = build_specs( @@ -1383,14 +1365,12 @@ mod tests { fn test_mcp_tool_anyof_defaults_to_string() { let model_family = find_family_for_model("gpt-5-codex") .expect("gpt-5-codex should be a valid model family"); + let mut features = Features::with_defaults(); + features.enable(Feature::UnifiedExec); + features.enable(Feature::WebSearchRequest); let config = ToolsConfig::new(&ToolsConfigParams { model_family: &model_family, - include_plan_tool: false, - include_apply_patch_tool: false, - include_web_search_request: true, - use_streamable_shell_tool: false, - include_view_image_tool: true, - experimental_unified_exec_tool: true, + features: &features, }); let (tools, _) = build_specs( @@ -1462,14 +1442,12 @@ mod tests { fn test_get_openai_tools_mcp_tools_with_additional_properties_schema() { let model_family = find_family_for_model("gpt-5-codex") .expect("gpt-5-codex should be a valid model family"); + let mut features = Features::with_defaults(); + features.enable(Feature::UnifiedExec); + features.enable(Feature::WebSearchRequest); let config = ToolsConfig::new(&ToolsConfigParams { model_family: &model_family, - include_plan_tool: false, - include_apply_patch_tool: false, - include_web_search_request: true, - use_streamable_shell_tool: false, - include_view_image_tool: true, - experimental_unified_exec_tool: true, + features: &features, }); let (tools, _) = build_specs( &config, diff --git a/codex-rs/core/tests/suite/model_tools.rs b/codex-rs/core/tests/suite/model_tools.rs index ee7b44d4b0d..f26cfcf67c6 100644 --- a/codex-rs/core/tests/suite/model_tools.rs +++ b/codex-rs/core/tests/suite/model_tools.rs @@ -4,6 +4,7 @@ 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::InputItem; @@ -56,12 +57,12 @@ async fn collect_tool_identifiers_for_model(model: &str) -> Vec { config.model = model.to_string(); config.model_family = find_family_for_model(model).unwrap_or_else(|| panic!("unknown model family for {model}")); - config.include_plan_tool = false; - config.include_apply_patch_tool = false; - config.include_view_image_tool = false; - config.tools_web_search_request = false; - config.use_experimental_streamable_shell_tool = false; - config.use_experimental_unified_exec_tool = false; + config.features.disable(Feature::PlanTool); + config.features.disable(Feature::ApplyPatchFreeform); + config.features.disable(Feature::ViewImageTool); + config.features.disable(Feature::WebSearchRequest); + config.features.disable(Feature::StreamableShell); + config.features.disable(Feature::UnifiedExec); let conversation_manager = ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 9ca0cc9369a..dc000af4493 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -5,6 +5,7 @@ use codex_core::ConversationManager; use codex_core::ModelProviderInfo; use codex_core::built_in_model_providers; use codex_core::config::OPENAI_DEFAULT_MODEL; +use codex_core::features::Feature; use codex_core::model_family::find_family_for_model; use codex_core::protocol::AskForApproval; use codex_core::protocol::EventMsg; @@ -99,10 +100,10 @@ async fn codex_mini_latest_tools() { config.cwd = cwd.path().to_path_buf(); config.model_provider = model_provider; config.user_instructions = Some("be consistent and helpful".to_string()); + config.features.disable(Feature::ApplyPatchFreeform); let conversation_manager = ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); - config.include_apply_patch_tool = false; config.model = "codex-mini-latest".to_string(); config.model_family = find_family_for_model("codex-mini-latest").unwrap(); @@ -185,7 +186,7 @@ async fn prompt_tools_are_consistent_across_requests() { config.cwd = cwd.path().to_path_buf(); config.model_provider = model_provider; config.user_instructions = Some("be consistent and helpful".to_string()); - config.include_plan_tool = true; + config.features.enable(Feature::PlanTool); let conversation_manager = ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index e111cebcb7f..9dd921b91f0 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -9,6 +9,7 @@ use std::time::UNIX_EPOCH; use codex_core::config_types::McpServerConfig; use codex_core::config_types::McpServerTransportConfig; +use codex_core::features::Feature; use codex_core::protocol::AskForApproval; use codex_core::protocol::EventMsg; @@ -74,7 +75,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { let fixture = test_codex() .with_config(move |config| { - config.use_experimental_use_rmcp_client = true; + config.features.enable(Feature::RmcpClient); config.mcp_servers.insert( server_name.to_string(), McpServerConfig { @@ -227,7 +228,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { let fixture = test_codex() .with_config(move |config| { - config.use_experimental_use_rmcp_client = true; + config.features.enable(Feature::RmcpClient); config.mcp_servers.insert( server_name.to_string(), McpServerConfig { @@ -408,7 +409,7 @@ async fn streamable_http_with_oauth_round_trip() -> anyhow::Result<()> { let fixture = test_codex() .with_config(move |config| { - config.use_experimental_use_rmcp_client = true; + config.features.enable(Feature::RmcpClient); config.mcp_servers.insert( server_name.to_string(), McpServerConfig { diff --git a/codex-rs/core/tests/suite/shell_serialization.rs b/codex-rs/core/tests/suite/shell_serialization.rs index fd9c26d8824..ba509e28256 100644 --- a/codex-rs/core/tests/suite/shell_serialization.rs +++ b/codex-rs/core/tests/suite/shell_serialization.rs @@ -1,6 +1,7 @@ #![cfg(not(target_os = "windows"))] use anyhow::Result; +use codex_core::features::Feature; use codex_core::model_family::find_family_for_model; use codex_core::protocol::AskForApproval; use codex_core::protocol::EventMsg; @@ -77,7 +78,7 @@ async fn shell_output_stays_json_without_freeform_apply_patch() -> Result<()> { let server = start_mock_server().await; let mut builder = test_codex().with_config(|config| { - config.include_apply_patch_tool = false; + config.features.disable(Feature::ApplyPatchFreeform); config.model = "gpt-5".to_string(); config.model_family = find_family_for_model("gpt-5").expect("gpt-5 is a model family"); }); @@ -143,7 +144,7 @@ async fn shell_output_is_structured_with_freeform_apply_patch() -> Result<()> { let server = start_mock_server().await; let mut builder = test_codex().with_config(|config| { - config.include_apply_patch_tool = true; + config.features.enable(Feature::ApplyPatchFreeform); }); let test = builder.build(&server).await?; diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index eaefe7d9dc9..a89317b7bab 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -1,6 +1,7 @@ #![cfg(not(target_os = "windows"))] use assert_matches::assert_matches; +use codex_core::features::Feature; use codex_core::model_family::find_family_for_model; use codex_core::protocol::AskForApproval; use codex_core::protocol::EventMsg; @@ -104,7 +105,7 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> { let server = start_mock_server().await; let mut builder = test_codex().with_config(|config| { - config.include_plan_tool = true; + config.features.enable(Feature::PlanTool); }); let TestCodex { codex, @@ -191,7 +192,7 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> { let server = start_mock_server().await; let mut builder = test_codex().with_config(|config| { - config.include_plan_tool = true; + config.features.enable(Feature::PlanTool); }); let TestCodex { codex, @@ -285,7 +286,7 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() let server = start_mock_server().await; let mut builder = test_codex().with_config(|config| { - config.include_apply_patch_tool = true; + config.features.enable(Feature::ApplyPatchFreeform); }); let TestCodex { codex, @@ -403,7 +404,7 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> { let server = start_mock_server().await; let mut builder = test_codex().with_config(|config| { - config.include_apply_patch_tool = true; + config.features.enable(Feature::ApplyPatchFreeform); }); let TestCodex { codex, diff --git a/codex-rs/core/tests/suite/tools.rs b/codex-rs/core/tests/suite/tools.rs index ba87cb333de..ec07b0cdbdf 100644 --- a/codex-rs/core/tests/suite/tools.rs +++ b/codex-rs/core/tests/suite/tools.rs @@ -2,6 +2,7 @@ #![allow(clippy::unwrap_used, clippy::expect_used)] use anyhow::Result; +use codex_core::features::Feature; use codex_core::model_family::find_family_for_model; use codex_core::protocol::AskForApproval; use codex_core::protocol::EventMsg; @@ -293,7 +294,11 @@ async fn collect_tools(use_unified_exec: bool) -> Result> { let mock = mount_sse_sequence(&server, responses).await; let mut builder = test_codex().with_config(move |config| { - config.use_experimental_unified_exec_tool = use_unified_exec; + if use_unified_exec { + config.features.enable(Feature::UnifiedExec); + } else { + config.features.disable(Feature::UnifiedExec); + } }); let test = builder.build(&server).await?; diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index cfa96dd75c8..6298ab06de1 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -3,6 +3,7 @@ use std::collections::HashMap; use anyhow::Result; +use codex_core::features::Feature; use codex_core::protocol::AskForApproval; use codex_core::protocol::EventMsg; use codex_core::protocol::InputItem; @@ -42,7 +43,13 @@ fn collect_tool_outputs(bodies: &[Value]) -> Result> { if let Some(call_id) = item.get("call_id").and_then(Value::as_str) { let content = extract_output_text(item) .ok_or_else(|| anyhow::anyhow!("missing tool output content"))?; - let parsed: Value = serde_json::from_str(content)?; + let trimmed = content.trim(); + if trimmed.is_empty() { + continue; + } + let parsed: Value = serde_json::from_str(trimmed).map_err(|err| { + anyhow::anyhow!("failed to parse tool output content {trimmed:?}: {err}") + })?; outputs.insert(call_id.to_string(), parsed); } } @@ -59,7 +66,7 @@ async fn unified_exec_reuses_session_via_stdin() -> Result<()> { let server = start_mock_server().await; let mut builder = test_codex().with_config(|config| { - config.use_experimental_unified_exec_tool = true; + config.features.enable(Feature::UnifiedExec); }); let TestCodex { codex, @@ -176,6 +183,7 @@ async fn unified_exec_streams_after_lagged_output() -> Result<()> { let mut builder = test_codex().with_config(|config| { config.use_experimental_unified_exec_tool = true; + config.features.enable(Feature::UnifiedExec); }); let TestCodex { codex, @@ -300,7 +308,7 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> { let server = start_mock_server().await; let mut builder = test_codex().with_config(|config| { - config.use_experimental_unified_exec_tool = true; + config.features.enable(Feature::UnifiedExec); }); let TestCodex { codex,