diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 36d8f676c..6b48dcb4a 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1465,6 +1465,10 @@ ], "description": "User-level skill config entries keyed by SKILL.md path." }, + "suppress_unstable_features_warning": { + "description": "Suppress warnings about unstable (under development) features.", + "type": "boolean" + }, "tool_output_token_limit": { "description": "Token budget applied when storing tool/function outputs in the context manager.", "format": "uint", diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 6401605ee..0f696ea84 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -22,6 +22,7 @@ use crate::connectors; use crate::exec_policy::ExecPolicyManager; use crate::features::Feature; use crate::features::Features; +use crate::features::maybe_push_unstable_features_warning; use crate::models_manager::manager::ModelsManager; use crate::parse_command::parse_command; use crate::parse_turn_item; @@ -754,6 +755,7 @@ impl Session { }); } maybe_push_chat_wire_api_deprecation(&config, &mut post_session_configured_events); + maybe_push_unstable_features_warning(&config, &mut post_session_configured_events); let auth = auth.as_ref(); let otel_manager = OtelManager::new( diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 031f3c6f3..dbccb2bef 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -316,6 +316,9 @@ pub struct Config { /// Centralized feature flags; source of truth for feature gating. pub features: Features, + /// When `true`, suppress warnings about unstable (under development) features. + pub suppress_unstable_features_warning: bool, + /// The active profile name used to derive this `Config` (if any). pub active_profile: Option, @@ -906,6 +909,9 @@ pub struct ConfigToml { #[schemars(schema_with = "crate::config::schema::features_schema")] pub features: Option, + /// Suppress warnings about unstable (under development) features. + pub suppress_unstable_features_warning: Option, + /// Settings for ghost snapshots (used for undo). #[serde(default)] pub ghost_snapshot: Option, @@ -1564,6 +1570,9 @@ impl Config { use_experimental_unified_exec_tool, ghost_snapshot, features, + suppress_unstable_features_warning: cfg + .suppress_unstable_features_warning + .unwrap_or(false), active_profile: active_profile_name, active_project, windows_wsl_setup_acknowledged: cfg.windows_wsl_setup_acknowledged.unwrap_or(false), @@ -3732,6 +3741,7 @@ model_verbosity = "high" use_experimental_unified_exec_tool: false, ghost_snapshot: GhostSnapshotConfig::default(), features: Features::with_defaults(), + suppress_unstable_features_warning: false, active_profile: Some("o3".to_string()), active_project: ProjectConfig { trust_level: None }, windows_wsl_setup_acknowledged: false, @@ -3814,6 +3824,7 @@ model_verbosity = "high" use_experimental_unified_exec_tool: false, ghost_snapshot: GhostSnapshotConfig::default(), features: Features::with_defaults(), + suppress_unstable_features_warning: false, active_profile: Some("gpt3".to_string()), active_project: ProjectConfig { trust_level: None }, windows_wsl_setup_acknowledged: false, @@ -3911,6 +3922,7 @@ model_verbosity = "high" use_experimental_unified_exec_tool: false, ghost_snapshot: GhostSnapshotConfig::default(), features: Features::with_defaults(), + suppress_unstable_features_warning: false, active_profile: Some("zdr".to_string()), active_project: ProjectConfig { trust_level: None }, windows_wsl_setup_acknowledged: false, @@ -3994,6 +4006,7 @@ model_verbosity = "high" use_experimental_unified_exec_tool: false, ghost_snapshot: GhostSnapshotConfig::default(), features: Features::with_defaults(), + suppress_unstable_features_warning: false, active_profile: Some("gpt5".to_string()), active_project: ProjectConfig { trust_level: None }, windows_wsl_setup_acknowledged: false, diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index 07cc098be..7fde52687 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -5,14 +5,20 @@ //! booleans through multiple types, call sites consult a single `Features` //! container attached to `Config`. +use crate::config::CONFIG_TOML_FILE; +use crate::config::Config; use crate::config::ConfigToml; use crate::config::profile::ConfigProfile; +use crate::protocol::Event; +use crate::protocol::EventMsg; +use crate::protocol::WarningEvent; use codex_otel::OtelManager; use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; use std::collections::BTreeMap; use std::collections::BTreeSet; +use toml::Value as TomlValue; mod legacy; pub(crate) use legacy::LegacyFeatureToggles; @@ -466,3 +472,54 @@ pub const FEATURES: &[FeatureSpec] = &[ default_enabled: false, }, ]; + +/// Push a warning event if any under-development features are enabled. +pub fn maybe_push_unstable_features_warning( + config: &Config, + post_session_configured_events: &mut Vec, +) { + if config.suppress_unstable_features_warning { + return; + } + + let mut under_development_feature_keys = Vec::new(); + if let Some(table) = config + .config_layer_stack + .effective_config() + .get("features") + .and_then(TomlValue::as_table) + { + for (key, value) in table { + if value.as_bool() != Some(true) { + continue; + } + let Some(spec) = FEATURES.iter().find(|spec| spec.key == key.as_str()) else { + continue; + }; + if !config.features.enabled(spec.id) { + continue; + } + if matches!(spec.stage, Stage::UnderDevelopment) { + under_development_feature_keys.push(spec.key.to_string()); + } + } + } + + if under_development_feature_keys.is_empty() { + return; + } + + let under_development_feature_keys = under_development_feature_keys.join(", "); + let config_path = config + .codex_home + .join(CONFIG_TOML_FILE) + .display() + .to_string(); + let message = format!( + "Under-development features enabled: {under_development_feature_keys}. Under-development features are incomplete and may behave unpredictably. To suppress this warning, set `suppress_unstable_features_warning = true` in {config_path}." + ); + post_session_configured_events.push(Event { + id: "".to_owned(), + msg: EventMsg::Warning(WarningEvent { message }), + }); +} diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index e0da93a69..9bf4ef8ef 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -74,6 +74,7 @@ mod tools; mod truncation; mod undo; mod unified_exec; +mod unstable_features_warning; mod user_notification; mod user_shell_cmd; mod view_image; diff --git a/codex-rs/core/tests/suite/unstable_features_warning.rs b/codex-rs/core/tests/suite/unstable_features_warning.rs new file mode 100644 index 000000000..f148edc49 --- /dev/null +++ b/codex-rs/core/tests/suite/unstable_features_warning.rs @@ -0,0 +1,90 @@ +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use codex_core::AuthManager; +use codex_core::CodexAuth; +use codex_core::NewThread; +use codex_core::ThreadManager; +use codex_core::config::CONFIG_TOML_FILE; +use codex_core::features::Feature; +use codex_core::protocol::EventMsg; +use codex_core::protocol::InitialHistory; +use codex_core::protocol::WarningEvent; +use codex_utils_absolute_path::AbsolutePathBuf; +use core::time::Duration; +use core_test_support::load_default_config_for_test; +use core_test_support::wait_for_event; +use tempfile::TempDir; +use tokio::time::timeout; +use toml::toml; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn emits_warning_when_unstable_features_enabled_via_config() { + let home = TempDir::new().expect("tempdir"); + let mut config = load_default_config_for_test(&home).await; + config.features.enable(Feature::ChildAgentsMd); + let user_config_path = + AbsolutePathBuf::from_absolute_path(config.codex_home.join(CONFIG_TOML_FILE)) + .expect("absolute user config path"); + config.config_layer_stack = config.config_layer_stack.with_user_config( + &user_config_path, + toml! { features = { child_agents_md = true } }.into(), + ); + + let thread_manager = ThreadManager::with_models_provider( + CodexAuth::from_api_key("test"), + config.model_provider.clone(), + ); + let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test")); + + let NewThread { + thread: conversation, + .. + } = thread_manager + .resume_thread_with_history(config, InitialHistory::New, auth_manager) + .await + .expect("spawn conversation"); + + let warning = wait_for_event(&conversation, |ev| matches!(ev, EventMsg::Warning(_))).await; + let EventMsg::Warning(WarningEvent { message }) = warning else { + panic!("expected warning event"); + }; + assert!(message.contains("child_agents_md")); + assert!(message.contains("Under-development features enabled")); + assert!(message.contains("suppress_unstable_features_warning = true")); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn suppresses_warning_when_configured() { + let home = TempDir::new().expect("tempdir"); + let mut config = load_default_config_for_test(&home).await; + config.features.enable(Feature::ChildAgentsMd); + config.suppress_unstable_features_warning = true; + let user_config_path = + AbsolutePathBuf::from_absolute_path(config.codex_home.join(CONFIG_TOML_FILE)) + .expect("absolute user config path"); + config.config_layer_stack = config.config_layer_stack.with_user_config( + &user_config_path, + toml! { features = { child_agents_md = true } }.into(), + ); + + let thread_manager = ThreadManager::with_models_provider( + CodexAuth::from_api_key("test"), + config.model_provider.clone(), + ); + let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test")); + + let NewThread { + thread: conversation, + .. + } = thread_manager + .resume_thread_with_history(config, InitialHistory::New, auth_manager) + .await + .expect("spawn conversation"); + + let warning = timeout( + Duration::from_millis(150), + wait_for_event(&conversation, |ev| matches!(ev, EventMsg::Warning(_))), + ) + .await; + assert!(warning.is_err()); +}