mirror of
https://github.com/openai/codex.git
synced 2026-10-27 18:48:37 +00:00
Make subagents follow the root service tier (#41308)
## What changed - Share the root thread's current service tier across its agent tree. - Apply that tier to each subagent request when the selected child model supports it, including remote compaction requests. - Propagate root service-tier updates to existing and newly spawned subagents without rewriting child-owned settings. - Remove per-spawn and role-level service-tier overrides so routing remains controlled by the root thread. ## Testing - Cover supported and unsupported child models, full-history forks, live tier changes, remote compaction, new subagents, and reloaded subagents. GitOrigin-RevId: 6c299e224e90b41a558788fb7aa4d5f336fa383a
This commit is contained in:
@@ -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<AgentExecutionLimiter>,
|
||||
/// Session-scoped state shared by the root thread and every cloned sub-agent control handle.
|
||||
rollout_budget: Arc<RolloutBudget>,
|
||||
/// The user-selected root routing tier, shared by the entire agent tree.
|
||||
root_service_tier: Arc<ArcSwapOption<String>>,
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -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<String> {
|
||||
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<String>) {
|
||||
self.root_service_tier.store(service_tier.map(Arc::new));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}}")
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<RemoteCompactionV2Output> {
|
||||
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(),
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Arc<StepContext>> {
|
||||
// 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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<String>,
|
||||
model: Option<String>,
|
||||
reasoning_effort: Option<ReasoningEffort>,
|
||||
service_tier: Option<String>,
|
||||
#[serde(default)]
|
||||
fork_context: bool,
|
||||
}
|
||||
|
||||
@@ -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::<Vec<_>>()
|
||||
.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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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<St
|
||||
.to_string(),
|
||||
)),
|
||||
),
|
||||
(
|
||||
"service_tier".to_string(),
|
||||
JsonSchema::string(Some(
|
||||
SPAWN_AGENT_SERVICE_TIER_OVERRIDE_DESCRIPTION.to_string(),
|
||||
)),
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -663,12 +652,6 @@ fn spawn_agent_common_properties_v2(agent_type_description: &str) -> BTreeMap<St
|
||||
.to_string(),
|
||||
)),
|
||||
),
|
||||
(
|
||||
"service_tier".to_string(),
|
||||
JsonSchema::string(Some(
|
||||
SPAWN_AGENT_SERVICE_TIER_OVERRIDE_DESCRIPTION.to_string(),
|
||||
)),
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -676,7 +659,6 @@ fn hide_spawn_agent_metadata_options(properties: &mut BTreeMap<String, JsonSchem
|
||||
properties.remove("agent_type");
|
||||
properties.remove("model");
|
||||
properties.remove("reasoning_effort");
|
||||
properties.remove("service_tier");
|
||||
}
|
||||
|
||||
fn spawn_agent_tool_description(
|
||||
|
||||
@@ -115,12 +115,7 @@ fn spawn_agent_tool_v2_requires_task_name_and_lists_visible_models() {
|
||||
.and_then(|schema| schema.description.as_deref()),
|
||||
Some("Reasoning effort override for the new agent. Omit to inherit the parent effort.")
|
||||
);
|
||||
assert_eq!(
|
||||
properties
|
||||
.get("service_tier")
|
||||
.and_then(|schema| schema.description.as_deref()),
|
||||
Some(SPAWN_AGENT_SERVICE_TIER_OVERRIDE_DESCRIPTION)
|
||||
);
|
||||
assert!(!properties.contains_key("service_tier"));
|
||||
assert_eq!(
|
||||
parameters.required.as_ref(),
|
||||
Some(&vec!["task_name".to_string(), "message".to_string()])
|
||||
@@ -181,12 +176,7 @@ fn spawn_agent_tool_v1_keeps_legacy_fork_context_field() {
|
||||
.and_then(|schema| schema.description.as_deref()),
|
||||
Some(SPAWN_AGENT_MODEL_OVERRIDE_DESCRIPTION)
|
||||
);
|
||||
assert_eq!(
|
||||
properties
|
||||
.get("service_tier")
|
||||
.and_then(|schema| schema.description.as_deref()),
|
||||
Some(SPAWN_AGENT_SERVICE_TIER_OVERRIDE_DESCRIPTION)
|
||||
);
|
||||
assert!(!properties.contains_key("service_tier"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -407,106 +407,31 @@ async fn multi_agent_v2_spawn_fork_turns_all_applies_agent_type_override() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_agent_service_tier_override_validates_the_effective_child_model() {
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SpawnAgentResult {
|
||||
agent_id: String,
|
||||
}
|
||||
async fn spawn_agent_service_tier_uses_root_preference_when_root_model_cannot_support_it() {
|
||||
let (_session, turn) = make_session_and_context().await;
|
||||
let mut config = (*turn.config).clone();
|
||||
config.model = Some("gpt-5.4-mini".to_string());
|
||||
config.service_tier = Some(ServiceTier::Fast.request_value().to_string());
|
||||
let manager = thread_manager();
|
||||
let root = manager
|
||||
.start_thread(StartThreadOptions::new(config.clone()))
|
||||
.await
|
||||
.expect("root thread should start");
|
||||
assert_eq!(root.thread.config_snapshot().await.service_tier, None);
|
||||
|
||||
{
|
||||
let (mut session, turn) = make_session_and_context().await;
|
||||
let manager = thread_manager();
|
||||
let root = manager
|
||||
.start_thread(StartThreadOptions::new((*turn.config).clone()))
|
||||
.await
|
||||
.expect("root thread should start");
|
||||
session.services.agent_control = manager.agent_control();
|
||||
session.thread_id = root.thread_id;
|
||||
config.model = Some("gpt-5.4".to_string());
|
||||
apply_spawn_agent_service_tier(root.thread.session.as_ref(), &mut config)
|
||||
.await
|
||||
.expect("root preference should be resolved against the child model");
|
||||
|
||||
let output = SpawnAgentHandler::default()
|
||||
.handle(invocation(
|
||||
Arc::new(session),
|
||||
Arc::new(turn),
|
||||
"spawn_agent",
|
||||
function_payload(json!({
|
||||
"message": "inspect this repo",
|
||||
"model": "gpt-5.4",
|
||||
"service_tier": ServiceTier::Fast.request_value()
|
||||
})),
|
||||
))
|
||||
.await
|
||||
.expect("spawn_agent should accept a supported explicit service tier");
|
||||
let (content, _) = expect_text_output(output);
|
||||
let result: SpawnAgentResult =
|
||||
serde_json::from_str(&content).expect("spawn_agent result should be json");
|
||||
let snapshot = manager
|
||||
.get_thread(parse_agent_id(&result.agent_id))
|
||||
.await
|
||||
.expect("spawned agent thread should exist")
|
||||
.config_snapshot()
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
snapshot.service_tier,
|
||||
Some(ServiceTier::Fast.request_value().to_string())
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
let (session, turn) = make_session_and_context().await;
|
||||
let err = SpawnAgentHandler::default()
|
||||
.handle(invocation(
|
||||
Arc::new(session),
|
||||
Arc::new(turn),
|
||||
"spawn_agent",
|
||||
function_payload(json!({
|
||||
"message": "inspect this repo",
|
||||
"model": "gpt-5.4",
|
||||
"service_tier": "turbo"
|
||||
})),
|
||||
))
|
||||
.await
|
||||
.err()
|
||||
.expect("unknown service tier should be rejected");
|
||||
|
||||
assert_eq!(
|
||||
err,
|
||||
FunctionCallError::RespondToModel(
|
||||
"Service tier `turbo` is not supported for model `gpt-5.4`. Supported service tiers: priority"
|
||||
.to_string()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
let (session, turn) = make_session_and_context().await;
|
||||
let err = SpawnAgentHandler::default()
|
||||
.handle(invocation(
|
||||
Arc::new(session),
|
||||
Arc::new(turn),
|
||||
"spawn_agent",
|
||||
function_payload(json!({
|
||||
"message": "inspect this repo",
|
||||
"model": "gpt-5.4-mini",
|
||||
"service_tier": ServiceTier::Fast.request_value()
|
||||
})),
|
||||
))
|
||||
.await
|
||||
.err()
|
||||
.expect("tier unsupported by the final child model should be rejected");
|
||||
|
||||
assert_eq!(
|
||||
err,
|
||||
FunctionCallError::RespondToModel(
|
||||
"Service tier `priority` is not supported for model `gpt-5.4-mini`. Supported service tiers: none"
|
||||
.to_string()
|
||||
)
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
config.service_tier,
|
||||
Some(ServiceTier::Fast.request_value().to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_agent_service_tier_inheritance_preserves_supported_or_configured_tiers() {
|
||||
async fn spawn_agent_service_tier_inheritance_uses_root_preference_and_child_model() {
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SpawnAgentResult {
|
||||
agent_id: String,
|
||||
@@ -525,7 +450,7 @@ async fn spawn_agent_service_tier_inheritance_preserves_supported_or_configured_
|
||||
.start_thread(StartThreadOptions::new((*turn.config).clone()))
|
||||
.await
|
||||
.expect("root thread should start");
|
||||
session.services.agent_control = manager.agent_control();
|
||||
session.services.agent_control = root.thread.session.services.agent_control.clone();
|
||||
session.thread_id = root.thread_id;
|
||||
|
||||
let output = SpawnAgentHandler::default()
|
||||
@@ -566,7 +491,7 @@ async fn spawn_agent_service_tier_inheritance_preserves_supported_or_configured_
|
||||
.start_thread(StartThreadOptions::new((*turn.config).clone()))
|
||||
.await
|
||||
.expect("root thread should start");
|
||||
session.services.agent_control = manager.agent_control();
|
||||
session.services.agent_control = root.thread.session.services.agent_control.clone();
|
||||
session.thread_id = root.thread_id;
|
||||
|
||||
let output = SpawnAgentHandler::default()
|
||||
@@ -643,7 +568,7 @@ service_tier = "priority"
|
||||
})),
|
||||
))
|
||||
.await
|
||||
.expect("spawn_agent should preserve the child role service tier");
|
||||
.expect("spawn_agent should ignore the child role service tier");
|
||||
let (content, _) = expect_text_output(output);
|
||||
let result: SpawnAgentResult =
|
||||
serde_json::from_str(&content).expect("spawn_agent result should be json");
|
||||
@@ -654,15 +579,12 @@ service_tier = "priority"
|
||||
.config_snapshot()
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
snapshot.service_tier,
|
||||
Some(ServiceTier::Fast.request_value().to_string())
|
||||
);
|
||||
assert_eq!(snapshot.service_tier, None);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_agent_role_service_tier_falls_back_to_supported_parent_tier() {
|
||||
async fn spawn_agent_role_service_tier_cannot_override_root_preference() {
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SpawnAgentResult {
|
||||
agent_id: String,
|
||||
@@ -702,7 +624,7 @@ service_tier = "turbo"
|
||||
.start_thread(StartThreadOptions::new((*turn.config).clone()))
|
||||
.await
|
||||
.expect("root thread should start");
|
||||
session.services.agent_control = manager.agent_control();
|
||||
session.services.agent_control = root.thread.session.services.agent_control.clone();
|
||||
session.thread_id = root.thread_id;
|
||||
|
||||
let output = SpawnAgentHandler::default()
|
||||
@@ -734,72 +656,25 @@ service_tier = "turbo"
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_agent_role_service_tier_does_not_hide_invalid_spawn_request() {
|
||||
let (session, mut turn) = make_session_and_context().await;
|
||||
tokio::fs::create_dir_all(&turn.config.codex_home)
|
||||
.await
|
||||
.expect("codex home should be created");
|
||||
let role_config_path = turn.config.codex_home.as_path().join("tiered-role.toml");
|
||||
tokio::fs::write(
|
||||
&role_config_path,
|
||||
r#"model = "gpt-5.4"
|
||||
service_tier = "priority"
|
||||
"#,
|
||||
)
|
||||
.await
|
||||
.expect("role config should be written");
|
||||
|
||||
let role_name = "tiered-role".to_string();
|
||||
let mut config = (*turn.config).clone();
|
||||
config.agent_roles.insert(
|
||||
role_name.clone(),
|
||||
AgentRoleConfig {
|
||||
description: Some("Role with a supported child tier".to_string()),
|
||||
config_file: Some(role_config_path),
|
||||
nickname_candidates: None,
|
||||
},
|
||||
);
|
||||
turn.config = Arc::new(config);
|
||||
|
||||
let result = SpawnAgentHandler::default()
|
||||
.handle(invocation(
|
||||
Arc::new(session),
|
||||
Arc::new(turn),
|
||||
"spawn_agent",
|
||||
function_payload(json!({
|
||||
"message": "inspect this repo",
|
||||
"agent_type": role_name,
|
||||
"service_tier": "turbo"
|
||||
})),
|
||||
))
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
result.err(),
|
||||
Some(FunctionCallError::RespondToModel(
|
||||
"Service tier `turbo` is not supported for model `gpt-5.4`. Supported service tiers: priority"
|
||||
.to_string()
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_agent_full_history_fork_accepts_explicit_service_tier() {
|
||||
async fn spawn_agent_full_history_fork_inherits_root_service_tier() {
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SpawnAgentResult {
|
||||
agent_id: String,
|
||||
}
|
||||
|
||||
let (mut session, turn) = make_session_and_context().await;
|
||||
let turn = turn
|
||||
let mut turn = turn
|
||||
.with_model("gpt-5.4".to_string(), &session.services.models_manager)
|
||||
.await;
|
||||
let mut config = (*turn.config).clone();
|
||||
config.service_tier = Some(ServiceTier::Fast.request_value().to_string());
|
||||
turn.config = Arc::new(config);
|
||||
let manager = thread_manager();
|
||||
let root = manager
|
||||
.start_thread(StartThreadOptions::new((*turn.config).clone()))
|
||||
.await
|
||||
.expect("root thread should start");
|
||||
session.services.agent_control = manager.agent_control();
|
||||
session.services.agent_control = root.thread.session.services.agent_control.clone();
|
||||
session.thread_id = root.thread_id;
|
||||
|
||||
let output = SpawnAgentHandler::default()
|
||||
@@ -809,12 +684,11 @@ async fn spawn_agent_full_history_fork_accepts_explicit_service_tier() {
|
||||
"spawn_agent",
|
||||
function_payload(json!({
|
||||
"message": "inspect this repo",
|
||||
"fork_context": true,
|
||||
"service_tier": ServiceTier::Fast.request_value()
|
||||
"fork_context": true
|
||||
})),
|
||||
))
|
||||
.await
|
||||
.expect("full-history fork should accept explicit service tier");
|
||||
.expect("full-history fork should inherit the root service tier");
|
||||
let (content, _) = expect_text_output(output);
|
||||
let result: SpawnAgentResult =
|
||||
serde_json::from_str(&content).expect("spawn_agent result should be json");
|
||||
@@ -832,7 +706,7 @@ async fn spawn_agent_full_history_fork_accepts_explicit_service_tier() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multi_agent_v2_full_history_fork_accepts_explicit_service_tier() {
|
||||
async fn multi_agent_v2_full_history_fork_inherits_root_service_tier() {
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SpawnAgentResult {
|
||||
task_name: String,
|
||||
@@ -843,6 +717,7 @@ async fn multi_agent_v2_full_history_fork_accepts_explicit_service_tier() {
|
||||
.with_model("gpt-5.4".to_string(), &session.services.models_manager)
|
||||
.await;
|
||||
let mut config = (*turn.config).clone();
|
||||
config.service_tier = Some(ServiceTier::Fast.request_value().to_string());
|
||||
config
|
||||
.features
|
||||
.enable(Feature::MultiAgentV2)
|
||||
@@ -853,7 +728,7 @@ async fn multi_agent_v2_full_history_fork_accepts_explicit_service_tier() {
|
||||
.start_thread(StartThreadOptions::new((*turn.config).clone()))
|
||||
.await
|
||||
.expect("root thread should start");
|
||||
session.services.agent_control = manager.agent_control();
|
||||
session.services.agent_control = root.thread.session.services.agent_control.clone();
|
||||
session.thread_id = root.thread_id;
|
||||
let session = Arc::new(session);
|
||||
let turn = Arc::new(turn);
|
||||
@@ -865,12 +740,11 @@ async fn multi_agent_v2_full_history_fork_accepts_explicit_service_tier() {
|
||||
"spawn_agent",
|
||||
function_payload(json!({
|
||||
"message": "inspect this repo",
|
||||
"task_name": "fork_with_tier",
|
||||
"service_tier": ServiceTier::Fast.request_value()
|
||||
"task_name": "fork_with_tier"
|
||||
})),
|
||||
))
|
||||
.await
|
||||
.expect("multi-agent v2 full-history fork should accept explicit service tier");
|
||||
.expect("multi-agent v2 full-history fork should inherit the root service tier");
|
||||
let (content, _) = expect_text_output(output);
|
||||
let result: SpawnAgentResult =
|
||||
serde_json::from_str(&content).expect("spawn_agent result should be json");
|
||||
|
||||
@@ -124,9 +124,6 @@ async fn handle_spawn_agent(
|
||||
let child_depth = next_thread_spawn_depth(&session_source);
|
||||
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());
|
||||
}
|
||||
let is_full_history_fork = matches!(fork_mode, Some(SpawnAgentForkMode::FullHistory));
|
||||
apply_requested_spawn_agent_model_overrides(
|
||||
&session,
|
||||
@@ -144,13 +141,7 @@ async fn handle_spawn_agent(
|
||||
.clone_from(&turn.developer_instructions);
|
||||
}
|
||||
}
|
||||
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())?;
|
||||
|
||||
// Remember an applied configured default so cold reload reapplies its restrictions.
|
||||
@@ -288,7 +279,6 @@ struct SpawnAgentArgs {
|
||||
agent_type: Option<String>,
|
||||
model: Option<String>,
|
||||
reasoning_effort: Option<ReasoningEffort>,
|
||||
service_tier: Option<String>,
|
||||
fork_turns: Option<String>,
|
||||
fork_context: Option<bool>,
|
||||
}
|
||||
|
||||
@@ -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}`"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
Reference in New Issue
Block a user