diff --git a/codex-rs/app-server/tests/common/models_cache.rs b/codex-rs/app-server/tests/common/models_cache.rs index cc69f9951a..1bd6f316d7 100644 --- a/codex-rs/app-server/tests/common/models_cache.rs +++ b/codex-rs/app-server/tests/common/models_cache.rs @@ -68,6 +68,7 @@ fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo { input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + supports_experimental_context: false, use_responses_lite: false, guardian: None, node_repl_auto_review_required: false, diff --git a/codex-rs/codex-api/tests/models_integration.rs b/codex-rs/codex-api/tests/models_integration.rs index 288dd123e4..eb3ae763e7 100644 --- a/codex-rs/codex-api/tests/models_integration.rs +++ b/codex-rs/codex-api/tests/models_integration.rs @@ -101,6 +101,7 @@ async fn models_client_hits_models_endpoint() { input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + supports_experimental_context: false, use_responses_lite: false, guardian: None, node_repl_auto_review_required: true, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index eadfc56cea..ebda9778b1 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -172,6 +172,7 @@ mod requirements; mod resolved_permission_profile; #[cfg(test)] mod schema; +mod token_budget_startup; pub use auth_keyring::bootstrap_auth_config; pub use auth_keyring::resolve_bootstrap_auth_keyring_backend_kind; pub use codex_agent_roles::AgentRoleConfig; @@ -197,6 +198,7 @@ pub use permissions::compile_permission_profile; pub(crate) use permissions::is_builtin_permission_profile_name; pub use permissions::resolve_permission_profile; pub(crate) use resolved_permission_profile::PermissionProfileState; +pub use token_budget_startup::TokenBudgetStartupConfig; const DEFAULT_IGNORE_LARGE_UNTRACKED_DIRS: i64 = 200; const DEFAULT_IGNORE_LARGE_UNTRACKED_FILES: i64 = 10 * 1024 * 1024; @@ -1053,6 +1055,8 @@ pub struct Config { /// Context-window token budget configuration, when enabled. pub token_budget: Option, + /// Runtime snapshot of configured token-budget preferences before startup activation. + pub token_budget_startup_config: Option, /// Shared token budget for the root thread and its sub-agents. pub rollout_budget: Option, /// Current-time reminder and clock tool configuration, when enabled. @@ -4318,6 +4322,7 @@ impl Config { ghost_snapshot, multi_agent_v2, token_budget, + token_budget_startup_config: None, rollout_budget, current_time_reminder, sleep_tool_mode, diff --git a/codex-rs/core/src/config/token_budget_startup.rs b/codex-rs/core/src/config/token_budget_startup.rs new file mode 100644 index 0000000000..02c9567cb4 --- /dev/null +++ b/codex-rs/core/src/config/token_budget_startup.rs @@ -0,0 +1,31 @@ +//! Keeps configured token-budget preferences separate from session startup activation. +//! Fresh child sessions restore this snapshot before applying their starting model's defaults; +//! history forks retain their parent's effective activation and the original snapshot. + +use super::Config; +use super::ConstraintResult; +use super::TokenBudgetConfig; +use codex_features::Feature; + +/// Token-budget preferences before a session applies experimental or model-owned activation. +#[derive(Debug, Clone, PartialEq)] +pub struct TokenBudgetStartupConfig { + enabled: bool, + token_budget: Option, +} + +impl Config { + /// Captures configured preferences, removing any inherited startup activation first. + pub(crate) fn prepare_token_budget_for_startup(&mut self) -> ConstraintResult<()> { + if let Some(snapshot) = self.token_budget_startup_config.as_ref() { + self.features + .set_enabled(Feature::TokenBudget, snapshot.enabled)?; + self.token_budget = snapshot.token_budget.clone(); + } + self.token_budget_startup_config = Some(TokenBudgetStartupConfig { + enabled: self.features.enabled(Feature::TokenBudget), + token_budget: self.token_budget.clone(), + }); + Ok(()) + } +} diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 712fe63e23..24fd9cf629 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -687,11 +687,22 @@ impl Session { .get_model_info(model.as_str(), &config.to_models_manager_config()) .await; let auth = auth_manager.auth_cached(); - token_budget::apply_experimental_context(Arc::make_mut(&mut config), auth.as_ref())?; - // Intentionally resolve `enabled` and `use_history_notes_extension` only at - // thread startup. Both activation flags stay fixed for this thread runtime, - // even if the selected model changes later. - token_budget::apply_model_defaults(Arc::make_mut(&mut config), &model_info); + // Forked subagents keep their parent's activation with the copied history. + // Fresh children restore configured preferences before applying startup defaults. + let inherits_token_budget = matches!(&conversation_history, InitialHistory::Forked(_)) + && config.token_budget_startup_config.is_some(); + if !inherits_token_budget { + Arc::make_mut(&mut config) + .prepare_token_budget_for_startup() + .map_err(|err| CodexErr::InvalidRequest(err.to_string()))?; + // Resolve activation for this runtime, including when resuming saved history. + token_budget::apply_experimental_context( + Arc::make_mut(&mut config), + auth.as_ref(), + &model_info, + )?; + token_budget::apply_model_defaults(Arc::make_mut(&mut config), &model_info); + } let configured_config = Arc::clone(&config); let multi_agent_version = config.multi_agent_version_override().or_else(|| { resolve_multi_agent_version(&conversation_history, inherited_multi_agent_version) diff --git a/codex-rs/core/src/session/token_budget.rs b/codex-rs/core/src/session/token_budget.rs index 0aeb66e4f5..d20a17544e 100644 --- a/codex-rs/core/src/session/token_budget.rs +++ b/codex-rs/core/src/session/token_budget.rs @@ -21,9 +21,11 @@ fn experimental_context_is_eligible(auth_mode: AuthMode, plan_type: Option, + starting_model: &ModelInfo, ) -> std::io::Result<()> { let provider = &config.model_provider; if !config.features.enabled(Feature::ContextManagement) + || !starting_model.supports_experimental_context || !provider.supports_codex_backend_routes() || !provider.requires_openai_auth || provider.env_key.is_some() diff --git a/codex-rs/core/src/tools/handlers/multi_agents_common.rs b/codex-rs/core/src/tools/handlers/multi_agents_common.rs index 77442ad43b..9cbe72df88 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_common.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_common.rs @@ -195,6 +195,9 @@ pub(crate) fn build_agent_resume_config(turn: &TurnContext) -> Result Result { let base_config = turn.config.clone(); let mut config = (*base_config).clone(); + // Preserve activation for history forks without freezing the parent's model-owned prompts. + // Fresh child startup restores configured preferences from the retained snapshot. + config.token_budget = turn.configured_token_budget.clone(); config.model = Some(turn.model_info().slug.clone()); config.model_provider = turn.provider.info().clone(); config.model_reasoning_effort = turn diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 45d4b316d7..f9c7b48924 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -4371,8 +4371,10 @@ async fn tool_handlers_cascade_close_and_resume_and_keep_explicitly_closed_subtr assert_eq!(shutdown_report.timed_out, Vec::::new()); } +#[test_case::test_case(false; "inactive_parent")] +#[test_case::test_case(true; "active_parent")] #[tokio::test] -async fn build_agent_spawn_config_uses_turn_context_values() { +async fn build_agent_spawn_config_uses_turn_context_values(parent_enabled: bool) { fn pick_allowed_sandbox_policy( permissions: &crate::config::Permissions, base: SandboxPolicy, @@ -4443,8 +4445,26 @@ async fn build_agent_spawn_config_uses_turn_context_values() { .set(AskForApproval::OnRequest) .expect("approval policy set"); + let parent_config = Arc::make_mut(&mut turn.config); + parent_config + .prepare_token_budget_for_startup() + .expect("capture configured token budget"); + parent_config + .features + .set_enabled(Feature::TokenBudget, parent_enabled) + .expect("set parent experimental context"); + parent_config + .token_budget + .get_or_insert_default() + .use_history_notes_extension = parent_enabled; + let mut expected = parent_config.clone(); + turn.configured_token_budget = expected.token_budget.clone(); + Arc::make_mut(&mut turn.config) + .token_budget + .get_or_insert_default() + .guidance_message = Some("Parent model's resolved guidance.".to_string()); + let config = build_agent_spawn_config(&base_instructions, &turn).expect("spawn config"); - let mut expected = (*turn.config).clone(); expected.base_instructions_provenance = base_instructions.provenance.clone(); expected.base_instructions = Some(base_instructions.text); expected.model = Some(turn.model_info().slug.clone()); diff --git a/codex-rs/core/tests/suite/auto_review.rs b/codex-rs/core/tests/suite/auto_review.rs index b6fae525e5..75575a61bb 100644 --- a/codex-rs/core/tests/suite/auto_review.rs +++ b/codex-rs/core/tests/suite/auto_review.rs @@ -622,6 +622,7 @@ fn remote_model_with_auto_review_override(slug: &str, review_model: &str) -> Mod input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + supports_experimental_context: false, use_responses_lite: false, node_repl_auto_review_required: false, node_repl_disabled: false, diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index 025d93ded9..8990d0496a 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -120,6 +120,7 @@ fn test_model_info( input_modalities, used_fallback_model_metadata: false, supports_search_tool: false, + supports_experimental_context: false, use_responses_lite: false, guardian: None, node_repl_auto_review_required: false, @@ -1358,6 +1359,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + supports_experimental_context: false, use_responses_lite: false, guardian: None, node_repl_auto_review_required: false, diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 7dea480f5b..701ada2f8c 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -538,6 +538,7 @@ fn test_remote_model(slug: &str, priority: i32) -> ModelInfo { input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + supports_experimental_context: false, use_responses_lite: false, guardian: None, node_repl_auto_review_required: false, diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 3de3c34eec..eba1485915 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -672,6 +672,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + supports_experimental_context: false, use_responses_lite: false, guardian: None, node_repl_auto_review_required: false, @@ -803,6 +804,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + supports_experimental_context: false, use_responses_lite: false, guardian: None, node_repl_auto_review_required: false, diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 545ca6c7d0..ba6d217c6a 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -609,6 +609,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + supports_experimental_context: false, use_responses_lite: false, guardian: None, node_repl_auto_review_required: false, @@ -879,6 +880,7 @@ async fn remote_models_apply_legacy_instructions() -> Result<()> { input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + supports_experimental_context: false, use_responses_lite: false, guardian: None, node_repl_auto_review_required: false, @@ -1467,6 +1469,7 @@ fn test_remote_model_with_policy( input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + supports_experimental_context: false, use_responses_lite: false, guardian: None, node_repl_auto_review_required: false, diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 1c2412902d..ce4c1c66b7 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -2770,6 +2770,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re input_modalities: vec![InputModality::Text], used_fallback_model_metadata: false, supports_search_tool: false, + supports_experimental_context: false, use_responses_lite: false, guardian: None, node_repl_auto_review_required: false, diff --git a/codex-rs/core/tests/suite/spawn_agent_description.rs b/codex-rs/core/tests/suite/spawn_agent_description.rs index 712e35bb36..5d451683b7 100644 --- a/codex-rs/core/tests/suite/spawn_agent_description.rs +++ b/codex-rs/core/tests/suite/spawn_agent_description.rs @@ -90,6 +90,7 @@ fn test_model_info( input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + supports_experimental_context: false, use_responses_lite: false, guardian: None, node_repl_auto_review_required: false, diff --git a/codex-rs/core/tests/suite/token_budget.rs b/codex-rs/core/tests/suite/token_budget.rs index 0e259ed21c..3828adf59a 100644 --- a/codex-rs/core/tests/suite/token_budget.rs +++ b/codex-rs/core/tests/suite/token_budget.rs @@ -256,6 +256,9 @@ async fn token_budget_guidance_precedes_standalone_context_window( let guidance_message = "Preserve important state before compaction."; let backend_url = format!("{}/backend-api/codex", server.uri()); let test = test_codex() + .with_model_info_override("gpt-5.2", |model| { + model.supports_experimental_context = true; + }) .with_auth(CodexAuth::from_external_chatgpt_tokens( "header.e30.signature", "account-123", @@ -304,15 +307,17 @@ async fn token_budget_guidance_precedes_standalone_context_window( Ok(()) } -#[test_case("OpenAI", "/backend-api/codex", None, true; "codex_backend")] -#[test_case("Custom", "/backend-api/codex", None, false; "custom_provider")] -#[test_case("OpenAI", "/v1", None, false; "non_codex_endpoint")] -#[test_case("OpenAI", "/backend-api/codex", Some("test-provider-token"), false; "provider_credentials")] +#[test_case("OpenAI", "/backend-api/codex", None, true, true; "codex_backend")] +#[test_case("OpenAI", "/backend-api/codex", None, false, false; "unsupported_model")] +#[test_case("Custom", "/backend-api/codex", None, true, false; "custom_provider")] +#[test_case("OpenAI", "/v1", None, true, false; "non_codex_endpoint")] +#[test_case("OpenAI", "/backend-api/codex", Some("test-provider-token"), true, false; "provider_credentials")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn experimental_context_requires_codex_backend( +async fn experimental_context_requires_capable_model_and_codex_backend( provider_name: &'static str, base_path: &'static str, bearer_token: Option<&'static str>, + supports_context: bool, expected_enabled: bool, ) -> Result<()> { skip_if_no_network!(Ok(())); @@ -321,6 +326,9 @@ async fn experimental_context_requires_codex_backend( let response = mount_sse_once(&server, sse_completed("resp-1")).await; let base_url = format!("{}{base_path}", server.uri()); let test = test_codex() + .with_model_info_override("gpt-5.2", move |model| { + model.supports_experimental_context = supports_context; + }) .with_auth(CodexAuth::from_external_chatgpt_tokens( "header.e30.signature", "account-123", @@ -446,8 +454,10 @@ async fn token_budget_explicit_default_template_overrides_model_defaults() -> Re Ok(()) } +#[test_case(Feature::TokenBudget; "explicit_activation")] +#[test_case(Feature::ContextManagement; "experimental_activation")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn token_budget_defaults_follow_the_active_model() -> Result<()> { +async fn token_budget_defaults_follow_the_active_model(activation: Feature) -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; @@ -456,8 +466,15 @@ async fn token_budget_defaults_follow_the_active_model() -> Result<()> { vec![sse_completed("resp-1"), sse_completed("resp-2")], ) .await; + let backend_url = format!("{}/backend-api/codex", server.uri()); let test = test_codex() + .with_auth(CodexAuth::from_external_chatgpt_tokens( + "header.e30.signature", + "account-123", + Some("plus"), + )?) .with_model_info_override("gpt-5.2", |model_info| { + model_info.supports_experimental_context = true; let mut defaults = model_token_budget_config(); defaults.guidance_message = "Use first-model context-window guidance.".to_string(); model_info @@ -476,11 +493,12 @@ async fn token_budget_defaults_follow_the_active_model() -> Result<()> { .token_budget = Some(defaults); }) .with_model("gpt-5.2") - .with_config(|config| { + .with_config(move |config| { + config.model_provider.base_url = Some(backend_url); config.model_context_window = Some(CONFIGURED_CONTEXT_WINDOW); config .features - .enable(Feature::TokenBudget) + .enable(activation) .expect("test config should allow token budget"); }) .build_with_auto_env(&server) @@ -508,6 +526,7 @@ async fn token_budget_defaults_follow_the_active_model() -> Result<()> { let requests = responses.requests(); assert_eq!(requests.len(), 2); + assert_eq!(requests[1].body_json()["model"], "gpt-5.4"); assert!(requests[0].body_contains_text("Use first-model context-window guidance.")); assert!( requests[1].body_contains_text("Use first-model context-window guidance."), diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index a919b19159..0256da4ec2 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -1622,6 +1622,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an input_modalities: vec![InputModality::Text], used_fallback_model_metadata: false, supports_search_tool: false, + supports_experimental_context: false, use_responses_lite: false, guardian: None, node_repl_auto_review_required: false, diff --git a/codex-rs/models-manager/models.json b/codex-rs/models-manager/models.json index 698da6fb7a..c9b4d6ce6e 100644 --- a/codex-rs/models-manager/models.json +++ b/codex-rs/models-manager/models.json @@ -2,6 +2,7 @@ "models": [ { "slug": "gpt-6-astra", + "supports_experimental_context": true, "prefer_websockets": true, "support_verbosity": true, "default_verbosity": "low", diff --git a/codex-rs/models-manager/src/model_info.rs b/codex-rs/models-manager/src/model_info.rs index fd9e2615c1..ca10069931 100644 --- a/codex-rs/models-manager/src/model_info.rs +++ b/codex-rs/models-manager/src/model_info.rs @@ -178,6 +178,7 @@ pub fn model_info_from_slug(slug: &str) -> ModelInfo { input_modalities: default_input_modalities(), used_fallback_model_metadata: true, // this is the fallback model metadata supports_search_tool: false, + supports_experimental_context: false, use_responses_lite: false, guardian: None, node_repl_auto_review_required: false, diff --git a/codex-rs/protocol/src/openai_models.rs b/codex-rs/protocol/src/openai_models.rs index f5336ddc5a..8c021b17c2 100644 --- a/codex-rs/protocol/src/openai_models.rs +++ b/codex-rs/protocol/src/openai_models.rs @@ -470,6 +470,9 @@ pub struct ModelInfo { pub used_fallback_model_metadata: bool, #[serde(default)] pub supports_search_tool: bool, + /// Whether experimental context management may be activated at session startup. + #[serde(default)] + pub supports_experimental_context: bool, #[serde(default)] pub use_responses_lite: bool, #[serde(default)] @@ -1011,6 +1014,7 @@ mod tests { input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + supports_experimental_context: false, use_responses_lite: false, guardian: None, node_repl_auto_review_required: false, @@ -1772,6 +1776,7 @@ mod tests { assert!(!model.supports_image_detail_original); assert_eq!(model.web_search_tool_type, WebSearchToolType::Text); assert!(!model.supports_search_tool); + assert!(!model.supports_experimental_context); assert!(!model.use_responses_lite); assert!(!model.node_repl_auto_review_required); assert!(!model.node_repl_disabled); diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index d13b3379d5..e99c62c933 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -308,6 +308,7 @@ fn new_config(model: Option, arg0_paths: Arg0DispatchPaths) -> anyhow::R multi_agent_v2: MultiAgentV2Config::default(), max_goal_token_budget: None, token_budget: None, + token_budget_startup_config: None, rollout_budget: None, current_time_reminder: None, sleep_tool_mode: Default::default(),