diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index afba0247a0..8dbd4d329a 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -25,6 +25,7 @@ use crate::thread_manager::ThreadManagerState; use crate::thread_manager::default_thread_id_generator; use crate::thread_rollout_truncation::truncate_rollout_to_last_n_fork_turns; use crate::turn_timing::now_unix_timestamp_ms; +use arc_swap::ArcSwapOption; use codex_history::InitialHistory; use codex_history::ResumedHistory; use codex_history::RolloutItem; @@ -72,6 +73,7 @@ use self::residency::V2Residency; mod execution; mod legacy; mod residency; +mod service_tier; mod spawn; mod user_authorization; @@ -128,6 +130,8 @@ pub(crate) struct AgentControl { agent_execution_limiter: Arc, /// Session-scoped state shared by the root thread and every cloned sub-agent control handle. rollout_budget: Arc, + /// The user-selected root routing tier, shared by the entire agent tree. + root_service_tier: Arc>, } impl Default for AgentControl { @@ -155,6 +159,7 @@ impl AgentControl { v2_residency: Arc::default(), agent_execution_limiter: Arc::default(), rollout_budget: Arc::default(), + root_service_tier: Arc::new(ArcSwapOption::from(None)), }; if let Some(rollout_budget) = rollout_budget { control.rollout_budget.configure(rollout_budget); diff --git a/codex-rs/core/src/agent/control/service_tier.rs b/codex-rs/core/src/agent/control/service_tier.rs new file mode 100644 index 0000000000..88c3010015 --- /dev/null +++ b/codex-rs/core/src/agent/control/service_tier.rs @@ -0,0 +1,18 @@ +//! Shares the root user's selected routing tier across the entire agent tree. + +use super::AgentControl; +use std::sync::Arc; + +impl AgentControl { + /// Returns the latest user-selected tier for this root and all its descendants. + pub(crate) fn root_service_tier(&self) -> Option { + self.root_service_tier + .load_full() + .map(|service_tier| (*service_tier).clone()) + } + + /// Publishes a root-owned tier without mutating individual child sessions. + pub(crate) fn set_root_service_tier(&self, service_tier: Option) { + self.root_service_tier.store(service_tier.map(Arc::new)); + } +} diff --git a/codex-rs/core/src/agent/control/spawn.rs b/codex-rs/core/src/agent/control/spawn.rs index 24a9e2692c..a7e460f517 100644 --- a/codex-rs/core/src/agent/control/spawn.rs +++ b/codex-rs/core/src/agent/control/spawn.rs @@ -415,6 +415,7 @@ impl AgentControl { CodexErr::InvalidRequest(format!("permission_profile is invalid: {err}")) })?; } + config.service_tier = self.root_service_tier(); if let Some(model) = stored_model { config.model = Some(model); } diff --git a/codex-rs/core/src/agent/role.rs b/codex-rs/core/src/agent/role.rs index baa5aa89c8..1eb771bd54 100644 --- a/codex-rs/core/src/agent/role.rs +++ b/codex-rs/core/src/agent/role.rs @@ -311,11 +311,7 @@ pub(crate) mod spawn_tool_spec { let reasoning_effort = role_toml .get("model_reasoning_effort") .and_then(TomlValue::as_str); - let service_tier = role_toml - .get("service_tier") - .and_then(TomlValue::as_str); - - let model_and_reasoning_note = match (model, reasoning_effort) { + match (model, reasoning_effort) { (Some(model), Some(reasoning_effort)) => format!( "\n- This role's model is set to `{model}` and its reasoning effort is set to `{reasoning_effort}`. These settings cannot be changed." ), @@ -330,15 +326,7 @@ pub(crate) mod spawn_tool_spec { ) } (None, None) => String::new(), - }; - let service_tier_note = service_tier - .map(|service_tier| { - format!( - "\n- This role's service tier is set to `{service_tier}`. If it is supported by the resolved model, it takes precedence over a valid spawn request service tier." - ) - }) - .unwrap_or_default(); - format!("{model_and_reasoning_note}{service_tier_note}") + } }) .unwrap_or_default(); format!("{name}: {{\n{description}{locked_settings_note}\n}}") diff --git a/codex-rs/core/src/agent/role_tests.rs b/codex-rs/core/src/agent/role_tests.rs index 164851e112..6abe3610d8 100644 --- a/codex-rs/core/src/agent/role_tests.rs +++ b/codex-rs/core/src/agent/role_tests.rs @@ -711,7 +711,7 @@ fn spawn_tool_spec_marks_role_locked_reasoning_effort_only() { } #[test] -fn spawn_tool_spec_marks_role_locked_service_tier() { +fn spawn_tool_spec_omits_role_service_tier() { let tempdir = TempDir::new().expect("create temp dir"); let role_path = tempdir.path().join("tiered.toml"); fs::write( @@ -730,9 +730,7 @@ fn spawn_tool_spec_marks_role_locked_service_tier() { let spec = spawn_tool_spec::build(&user_defined_roles); - assert!(spec.contains( - "Stay fast.\n- This role's service tier is set to `priority`. If it is supported by the resolved model, it takes precedence over a valid spawn request service tier." - )); + assert!(spec.contains("tiered: {\nStay fast.\n}")); } #[test] diff --git a/codex-rs/core/src/compact_remote_request.rs b/codex-rs/core/src/compact_remote_request.rs index 2f4b489f96..149be4b3d9 100644 --- a/codex-rs/core/src/compact_remote_request.rs +++ b/codex-rs/core/src/compact_remote_request.rs @@ -88,7 +88,7 @@ pub(super) async fn run_remote_compact_attempt( service_tier: if sess.services.auth_manager.auth_mode() == Some(AuthMode::ApiKey) { None } else { - turn_context.config.service_tier.clone() + step_context.settings.service_tier.clone() }, }, &turn_context.session_telemetry, diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs index ee82bbcb7a..331751c716 100644 --- a/codex-rs/core/src/compact_remote_v2.rs +++ b/codex-rs/core/src/compact_remote_v2.rs @@ -367,11 +367,12 @@ struct RemoteCompactionV2Output { async fn run_remote_compaction_request_v2( sess: &Session, - turn_context: &TurnContext, + step_context: &StepContext, client_session: &mut ModelClientSession, prompt: &Prompt, responses_metadata: &CodexResponsesMetadata, ) -> CodexResult { + let turn_context = &step_context.turn; let max_retries = turn_context .provider .info() @@ -386,7 +387,7 @@ async fn run_remote_compaction_request_v2( &turn_context.session_telemetry, turn_context.reasoning_effort().cloned(), turn_context.reasoning_summary(), - turn_context.config.service_tier.clone(), + step_context.settings.service_tier.clone(), responses_metadata, &InferenceTraceContext::disabled(), ) diff --git a/codex-rs/core/src/compact_remote_v2_attempt.rs b/codex-rs/core/src/compact_remote_v2_attempt.rs index 7fe14113a6..81861af114 100644 --- a/codex-rs/core/src/compact_remote_v2_attempt.rs +++ b/codex-rs/core/src/compact_remote_v2_attempt.rs @@ -104,7 +104,7 @@ pub(super) async fn run_remote_compact_v2_attempt( }; let compaction_output_result = run_remote_compaction_request_v2( sess, - turn_context.as_ref(), + step_context, client_session, &prompt, &responses_metadata, diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 363eda5446..70b04c3f65 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -1649,6 +1649,9 @@ impl Session { previous_permission_profile != updated_permission_profile; let mcp_inputs_changed = self.mcp_inputs_differ(&state.session_configuration, &updated, &updates); + let root_service_tier_changed = updated.parent_thread_id.is_none() + && state.session_configuration.step_settings.service_tier + != updated.step_settings.service_tier; if mcp_inputs_changed { self.mark_mcp_runtime_dirty(); } @@ -1665,6 +1668,15 @@ impl Session { .update_thread_config(&environment_config); } state.session_configuration = updated; + if root_service_tier_changed { + self.services.agent_control.set_root_service_tier( + state + .session_configuration + .step_settings + .service_tier + .clone(), + ); + } let new_config = notify_config_contributors .then(|| self.build_effective_session_config(&state.session_configuration)); let commit = SessionSettingsCommit { @@ -3320,7 +3332,22 @@ impl Session { ) -> CodexResult> { // Capture once before asynchronous planning; all request consumers // retain this immutable settings version even if the turn is updated. - let settings = turn_context.current_settings.load_full(); + let mut settings = turn_context.current_settings.load_full(); + if matches!( + turn_context.session_source, + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { .. }) + ) { + let root_service_tier = self.services.agent_control.root_service_tier(); + if settings.selected().service_tier != root_service_tier { + let mut selected = settings.selected().clone(); + selected.service_tier = root_service_tier; + settings = Arc::new(ResolvedStepSettings::new( + Arc::new(selected), + Arc::clone(&settings.model_info), + self.features.enabled(Feature::FastMode), + )); + } + } let token_budget = token_budget::resolve_token_budget( turn_context.configured_token_budget.as_ref(), turn_context.use_model_token_budget_defaults, diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 6da32bc1aa..0e3daef637 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -715,6 +715,15 @@ impl Session { .parent_thread_id .or_else(|| initial_history.get_resumed_parent_thread_id()); session_configuration.parent_thread_id = parent_thread_id; + if parent_thread_id.is_none() { + agent_control.set_root_service_tier( + session_configuration + .step_settings + .service_tier + .clone() + .or_else(|| config.service_tier.clone()), + ); + } let is_paginated_subagent = matches!( session_configuration.history_mode, ThreadHistoryMode::Paginated diff --git a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs index 9dbf2adda0..bd9fe9f74a 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs @@ -91,9 +91,6 @@ async fn handle_spawn_agent( .await; let mut config = build_agent_spawn_config(&session.get_base_instructions().await, turn.as_ref())?; - if let Some(service_tier) = args.service_tier.as_ref() { - config.service_tier = Some(service_tier.clone()); - } if args.fork_context { reject_full_fork_agent_type_override(role_name)?; } @@ -108,13 +105,7 @@ async fn handle_spawn_agent( if !args.fork_context { apply_spawn_agent_role(&session, &mut config, role_name).await?; } - apply_spawn_agent_service_tier( - &session, - &mut config, - turn.config.service_tier.as_deref(), - args.service_tier.as_deref(), - ) - .await?; + apply_spawn_agent_service_tier(&session, &mut config).await?; apply_spawn_agent_runtime_overrides(&mut config, turn.as_ref())?; let result = Box::pin(session.services.agent_control.spawn_agent_with_metadata( @@ -237,7 +228,6 @@ struct SpawnAgentArgs { agent_type: Option, model: Option, reasoning_effort: Option, - service_tier: Option, #[serde(default)] fork_context: bool, } 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 9fdfd48962..77442ad43b 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_common.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_common.rs @@ -11,6 +11,7 @@ use crate::tools::context::ToolPayload; use codex_models_manager::manager::RefreshStrategy; use codex_protocol::AgentPath; use codex_protocol::ThreadId; +use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE; use codex_protocol::error::CodexErr; use codex_protocol::error::CodexErrorDetails; use codex_protocol::models::BaseInstructions; @@ -321,17 +322,14 @@ pub(crate) async fn apply_requested_spawn_agent_model_overrides( pub(crate) async fn apply_spawn_agent_service_tier( session: &Session, config: &mut Config, - parent_service_tier: Option<&str>, - requested_service_tier: Option<&str>, ) -> Result<(), FunctionCallError> { - let candidate_service_tiers = [ - config.service_tier.clone(), - requested_service_tier.map(str::to_string), - parent_service_tier.map(str::to_string), - ]; - if candidate_service_tiers.iter().all(Option::is_none) { + let Some(service_tier) = session.services.agent_control.root_service_tier() else { config.service_tier = None; return Ok(()); + }; + if service_tier == SERVICE_TIER_DEFAULT_REQUEST_VALUE { + config.service_tier = Some(service_tier); + return Ok(()); } let model = config.model.clone().ok_or_else(|| { @@ -345,31 +343,9 @@ pub(crate) async fn apply_spawn_agent_service_tier( .get_model_info(model.as_str(), &config.to_models_manager_config()) .await; - if let Some(requested_service_tier) = requested_service_tier - && !model_info.supports_service_tier(requested_service_tier) - { - let supported_service_tiers = if model_info.service_tiers.is_empty() { - "none".to_string() - } else { - model_info - .service_tiers - .iter() - .map(|tier| tier.id.as_str()) - .collect::>() - .join(", ") - }; - return Err(FunctionCallError::RespondToModel(format!( - "Service tier `{requested_service_tier}` is not supported for model `{model}`. Supported service tiers: {supported_service_tiers}" - ))); - } - - config.service_tier = - candidate_service_tiers - .into_iter() - .flatten() - .find(|candidate_service_tier| { - model_info.supports_service_tier(candidate_service_tier.as_str()) - }); + config.service_tier = model_info + .supports_service_tier(service_tier.as_str()) + .then_some(service_tier); Ok(()) } diff --git a/codex-rs/core/src/tools/handlers/multi_agents_spec.rs b/codex-rs/core/src/tools/handlers/multi_agents_spec.rs index eea209eb02..8d9d48ef3d 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_spec.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_spec.rs @@ -18,8 +18,6 @@ const SPAWN_AGENT_INHERITED_MODEL_GUIDANCE: &str = "Spawned agents inherit your const SPAWN_AGENT_TYPE_OVERRIDE_DESCRIPTION_V1: &str = "Agent type override for the new agent. Omit to inherit the parent agent type with a full-history fork; otherwise, `default` is used."; const SPAWN_AGENT_MODEL_OVERRIDE_DESCRIPTION: &str = "Model override for the new agent. Omit unless an explicit override is needed."; -const SPAWN_AGENT_SERVICE_TIER_OVERRIDE_DESCRIPTION: &str = - "Service tier override for the new agent. Omit unless explicitly requested."; const MAX_REASONING_EFFORT_CHARS_IN_SPAWN_AGENT_DESCRIPTION: usize = 64; #[derive(Debug, Clone)] @@ -110,9 +108,6 @@ pub fn create_spawn_agent_tool_v2(options: SpawnAgentToolOptions) -> ToolSpec { if !options.expose_agent_type { properties.remove("agent_type"); } - if options.hide_agent_type_model_reasoning { - properties.remove("service_tier"); - } if !options.expose_spawn_agent_model_overrides { properties.remove("model"); properties.remove("reasoning_effort"); @@ -619,12 +614,6 @@ fn spawn_agent_common_properties_v1(agent_type_description: &str) -> BTreeMap BTreeMap, model: Option, reasoning_effort: Option, - service_tier: Option, fork_turns: Option, fork_context: Option, } diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index 76cda520ad..6a9f0d8d4a 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -2658,7 +2658,7 @@ async fn multi_agent_feature_selects_one_agent_tool_family() { .properties .as_ref() .expect("spawn_agent should use object params"); - for property in ["model", "reasoning_effort", "service_tier"] { + for property in ["model", "reasoning_effort"] { assert!( properties.contains_key(property), "expected v1 spawn_agent to expose `{property}`" diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 28cb2c6b46..cd6cffb5ca 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -161,6 +161,7 @@ mod step_settings_snapshots; mod stream_error_allows_next_turn; mod stream_no_completed; mod subagent_notifications; +mod subagent_service_tier; mod token_budget; mod tool_harness; mod tool_lifecycle; diff --git a/codex-rs/core/tests/suite/subagent_service_tier.rs b/codex-rs/core/tests/suite/subagent_service_tier.rs new file mode 100644 index 0000000000..74d5592540 --- /dev/null +++ b/codex-rs/core/tests/suite/subagent_service_tier.rs @@ -0,0 +1,397 @@ +//! Verifies that root service-tier changes reach existing child turns and future work. + +use anyhow::Result; +use codex_core::TurnInputRequest; +use codex_core::config::AgentRoleConfig; +use codex_core::config::Config; +use codex_features::Feature; +use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::ThreadSettingsOverrides; +use codex_protocol::user_input::UserInput; +use core_test_support::responses::ResponseMock; +use core_test_support::responses::ev_assistant_message; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_function_call_with_namespace; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_sse_once_match; +use core_test_support::responses::sse; +use core_test_support::responses::start_mock_server; +use core_test_support::submit_thread_settings; +use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event; +use core_test_support::wait_for_event_match; +use pretty_assertions::assert_eq; +use serde_json::json; +use test_case::test_case; + +const ROOT_PROMPT: &str = "spawn the service-tier worker"; +const CHILD_PROMPT: &str = "pause the service-tier worker"; +const FOLLOWUP_PROMPT: &str = "continue the service-tier worker later"; +const FRESH_ROOT_PROMPT: &str = "spawn another service-tier worker"; +const FRESH_CHILD_PROMPT: &str = "finish the new service-tier worker"; +const SPAWN_CALL_ID: &str = "spawn-service-tier-worker"; +const FRESH_SPAWN_CALL_ID: &str = "spawn-fresh-service-tier-worker"; +const PAUSE_CALL_ID: &str = "pause-service-tier-worker"; +const PRIORITY_ROLE: &str = "priority-worker"; + +fn body_contains(request: &wiremock::Request, text: &str) -> bool { + let body = match request + .headers + .get("content-encoding") + .and_then(|encoding| encoding.to_str().ok()) + { + Some(encoding) if encoding.eq_ignore_ascii_case("zstd") => { + zstd::stream::decode_all(std::io::Cursor::new(&request.body)).ok() + } + _ => Some(request.body.clone()), + }; + body.and_then(|body| String::from_utf8(body).ok()) + .is_some_and(|body| body.contains(text)) +} + +fn assert_request_service_tier(request: &ResponseMock, expected: Option<&str>) { + assert_eq!( + request + .single_request() + .body_json() + .get("service_tier") + .and_then(serde_json::Value::as_str), + expected + ); +} + +fn configure_priority_role(config: &mut Config) { + for feature in [Feature::Collab, Feature::MultiAgentV2] { + config + .features + .enable(feature) + .expect("test config should allow feature update"); + } + config.model_provider.request_max_retries = Some(0); + config.model_provider.stream_max_retries = Some(0); + let role_path = config.codex_home.join("priority-worker.toml"); + std::fs::write(&role_path, "service_tier = \"priority\"\n") + .expect("priority role should be written"); + config.agent_roles.insert( + PRIORITY_ROLE.to_string(), + AgentRoleConfig { + description: Some("Role with a configured priority tier".to_string()), + config_file: Some(role_path.to_path_buf()), + nickname_candidates: None, + }, + ); +} + +async fn wait_for_turn_complete(thread: &codex_core::CodexThread) { + wait_for_event(thread, |event| matches!(event, EventMsg::TurnComplete(_))).await; +} + +async fn mount_root_collaboration_call( + server: &wiremock::MockServer, + prompt: &'static str, + call_id: &'static str, + arguments: serde_json::Value, +) { + let response_id = format!("root-{call_id}"); + mount_sse_once_match( + server, + move |request: &wiremock::Request| { + body_contains(request, prompt) && !body_contains(request, call_id) + }, + sse(vec![ + ev_response_created(&response_id), + ev_function_call_with_namespace( + call_id, + "collaboration", + "spawn_agent", + &arguments.to_string(), + ), + ev_completed(&response_id), + ]), + ) + .await; + + let completion_id = format!("root-{call_id}-finished"); + mount_sse_once_match( + server, + move |request: &wiremock::Request| { + body_contains(request, prompt) && body_contains(request, call_id) + }, + sse(vec![ + ev_response_created(&completion_id), + ev_assistant_message(&format!("{completion_id}-message"), "worker started"), + ev_completed(&completion_id), + ]), + ) + .await; +} + +async fn mount_completed_child( + server: &wiremock::MockServer, + prompt: &'static str, + root_prompt: &'static str, +) -> ResponseMock { + mount_sse_once_match( + server, + move |request: &wiremock::Request| { + body_contains(request, prompt) && !body_contains(request, root_prompt) + }, + sse(vec![ + ev_response_created(prompt), + ev_assistant_message(&format!("{prompt}-message"), "worker completed"), + ev_completed(prompt), + ]), + ) + .await +} + +#[test_case(Some("priority"), None; "disabling fast mode updates active and idle child work")] +#[test_case(Some("priority"), Some("default"); "explicit default updates active and idle child work")] +#[test_case(None, Some("priority"); "enabling fast mode updates active and idle child work")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn root_service_tier_change_updates_existing_subagent( + initial_service_tier: Option<&str>, + updated_service_tier: Option<&str>, +) -> Result<()> { + let server = start_mock_server().await; + let initial_service_tier_owned = initial_service_tier.map(str::to_string); + let updated_request_service_tier = updated_service_tier + .filter(|service_tier| *service_tier != SERVICE_TIER_DEFAULT_REQUEST_VALUE); + let mut builder = test_codex() + .with_model("gpt-5.6-sol") + .with_config(move |config| { + config.service_tier = initial_service_tier_owned; + configure_priority_role(config); + }); + let test = builder.build_with_auto_env(&server).await?; + assert!(!test.config.features.enabled(Feature::StepModelSwitching)); + let mut created_threads = test.thread_manager.subscribe_thread_created(); + + mount_root_collaboration_call( + &server, + ROOT_PROMPT, + SPAWN_CALL_ID, + json!({ + "message": CHILD_PROMPT, + "task_name": "worker", + "agent_type": PRIORITY_ROLE, + "fork_turns": "none", + }), + ) + .await; + + let initial_child_request = mount_sse_once_match( + &server, + |request: &wiremock::Request| { + body_contains(request, CHILD_PROMPT) + && !body_contains(request, ROOT_PROMPT) + && !body_contains(request, PAUSE_CALL_ID) + }, + sse(vec![ + ev_response_created("child-paused"), + ev_function_call_with_namespace( + PAUSE_CALL_ID, + "collaboration", + "wait_agent", + &json!({ "timeout_ms": 30_000 }).to_string(), + ), + ev_completed("child-paused"), + ]), + ) + .await; + let continued_child_request = mount_sse_once_match( + &server, + |request: &wiremock::Request| { + body_contains(request, PAUSE_CALL_ID) && !body_contains(request, ROOT_PROMPT) + }, + sse(vec![ + ev_response_created("child-continued"), + ev_assistant_message("child-continued-message", "child continued"), + ev_completed("child-continued"), + ]), + ) + .await; + test.submit_text_turn(ROOT_PROMPT).await?; + let child_thread_id = created_threads.recv().await?; + let child = test.thread_manager.get_thread(child_thread_id).await?; + let original_child_service_tier = child.config_snapshot().await.service_tier; + assert_eq!(original_child_service_tier.as_deref(), initial_service_tier); + wait_for_event_match(child.as_ref(), |event| match event { + EventMsg::CollabWaitingBegin(request) if request.call_id == PAUSE_CALL_ID => Some(()), + EventMsg::Error(error) => panic!("child failed before pausing: {}", error.message), + EventMsg::TurnComplete(completed) => { + panic!("child completed before pausing: {completed:?}") + } + _ => None, + }) + .await; + assert_request_service_tier(&initial_child_request, initial_service_tier); + + submit_thread_settings( + &test.codex, + ThreadSettingsOverrides { + service_tier: Some(updated_service_tier.map(str::to_string)), + ..Default::default() + }, + ) + .await?; + assert_eq!( + child.config_snapshot().await.service_tier, + original_child_service_tier, + "the root routing policy must not rewrite child-owned settings" + ); + + child + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "continue after the service-tier update".to_string(), + text_elements: Vec::new(), + }])) + .await?; + wait_for_turn_complete(&child).await; + assert_request_service_tier(&continued_child_request, updated_request_service_tier); + + let child_compaction_request = mount_sse_once_match( + &server, + |request: &wiremock::Request| body_contains(request, "compaction_trigger"), + sse(vec![ + ev_response_created("child-remote-compaction"), + json!({ + "type": "response.output_item.done", + "item": { + "type": "compaction", + "encrypted_content": "compacted child history", + }, + }), + ev_completed("child-remote-compaction"), + ]), + ) + .await; + child.submit(Op::Compact).await?; + wait_for_turn_complete(&child).await; + assert_request_service_tier(&child_compaction_request, updated_request_service_tier); + + let future_child_request = mount_completed_child(&server, FOLLOWUP_PROMPT, ROOT_PROMPT).await; + child + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: FOLLOWUP_PROMPT.to_string(), + text_elements: Vec::new(), + }])) + .await?; + wait_for_turn_complete(&child).await; + assert_request_service_tier(&future_child_request, updated_request_service_tier); + + mount_root_collaboration_call( + &server, + FRESH_ROOT_PROMPT, + FRESH_SPAWN_CALL_ID, + json!({ + "message": FRESH_CHILD_PROMPT, + "task_name": "fresh", + "agent_type": PRIORITY_ROLE, + "fork_turns": "none", + }), + ) + .await; + let fresh_child_request = + mount_completed_child(&server, FRESH_CHILD_PROMPT, FRESH_ROOT_PROMPT).await; + test.submit_text_turn(FRESH_ROOT_PROMPT).await?; + let fresh_thread_id = created_threads.recv().await?; + let fresh_thread = test.thread_manager.get_thread(fresh_thread_id).await?; + wait_for_turn_complete(&fresh_thread).await; + assert_request_service_tier(&fresh_child_request, updated_request_service_tier); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn evicted_role_subagent_uses_root_service_tier_after_reload() -> Result<()> { + let server = start_mock_server().await; + let mut builder = test_codex() + .with_model("gpt-5.6-sol") + .with_config(|config| { + config.service_tier = Some("priority".to_string()); + config.multi_agent_v2.max_concurrent_threads_per_session = 2; + configure_priority_role(config); + }); + let test = builder.build_with_auto_env(&server).await?; + let mut created_threads = test.thread_manager.subscribe_thread_created(); + + mount_root_collaboration_call( + &server, + ROOT_PROMPT, + SPAWN_CALL_ID, + json!({ + "message": CHILD_PROMPT, + "task_name": "original", + "agent_type": PRIORITY_ROLE, + "fork_turns": "none", + }), + ) + .await; + let original_request = mount_completed_child(&server, CHILD_PROMPT, ROOT_PROMPT).await; + test.submit_text_turn(ROOT_PROMPT).await?; + let original_thread_id = created_threads.recv().await?; + let original_thread = test.thread_manager.get_thread(original_thread_id).await?; + wait_for_turn_complete(&original_thread).await; + assert_request_service_tier(&original_request, Some("priority")); + drop(original_thread); + + mount_root_collaboration_call( + &server, + FRESH_ROOT_PROMPT, + FRESH_SPAWN_CALL_ID, + json!({ + "message": FRESH_CHILD_PROMPT, + "task_name": "replacement", + "fork_turns": "none", + }), + ) + .await; + mount_completed_child(&server, FRESH_CHILD_PROMPT, FRESH_ROOT_PROMPT).await; + test.submit_text_turn(FRESH_ROOT_PROMPT).await?; + let replacement_thread_id = created_threads.recv().await?; + let replacement_thread = test + .thread_manager + .get_thread(replacement_thread_id) + .await?; + wait_for_turn_complete(&replacement_thread).await; + assert!( + test.thread_manager + .get_thread(original_thread_id) + .await + .is_err() + ); + + submit_thread_settings( + &test.codex, + ThreadSettingsOverrides { + service_tier: Some(None), + ..Default::default() + }, + ) + .await?; + test.thread_manager + .ensure_multi_agent_v2_child_loaded(original_thread_id) + .await?; + let reloaded_thread = test.thread_manager.get_thread(original_thread_id).await?; + assert_eq!( + reloaded_thread.config_snapshot().await.service_tier, + test.codex.config_snapshot().await.service_tier, + "reload ignores the role tier and preserves the root-owned preference" + ); + + let reloaded_request = mount_completed_child(&server, FOLLOWUP_PROMPT, ROOT_PROMPT).await; + reloaded_thread + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: FOLLOWUP_PROMPT.to_string(), + text_elements: Vec::new(), + }])) + .await?; + wait_for_turn_complete(&reloaded_thread).await; + assert_request_service_tier(&reloaded_request, /*expected*/ None); + reloaded_thread.shutdown_and_wait().await?; + test.codex.shutdown_and_wait().await?; + + Ok(()) +}