Use captured step settings when building model context (#44200)

## Why

Model changes during an active turn could leave context using the turn's initial model and settings, even after requests switched to the new model.

## What changed

- Build model, personality, approval, collaboration, app, plugin, and multi-agent instructions from captured step settings.
- Resolve multi-agent policy from the step's effective reasoning effort.
- Use the captured step's context window for full-context metadata and retain that step when rebuilding initial context for compaction.

## Testing

Add regression coverage for model switches updating context without duplicating updates, delegation policy following selected or model-default reasoning effort, and compaction preserving retained context after a model change.

GitOrigin-RevId: 3615c7a3f13f15fdcce9ad13d23311340e0aea88
This commit is contained in:
Abhinav
2026-09-09 16:48:03 +00:00
committed by copyberry
parent ccf470c060
commit b4507997e0
11 changed files with 373 additions and 69 deletions
+1 -4
View File
@@ -103,10 +103,7 @@ pub(crate) async fn build_compaction_initial_context(
step_context,
} => {
let items = sess
.build_initial_context_with_world_state(
step_context.turn.as_ref(),
world_state.as_ref(),
)
.build_initial_context_with_world_state(step_context, world_state.as_ref())
.await;
(
items.into_iter().map(ResponseItemEnvelope::new).collect(),
+1 -1
View File
@@ -26,7 +26,7 @@ async fn process_compacted_history_with_test_session(
.expect("world state should build"),
);
let initial_context = session
.build_initial_context_with_world_state(&turn_context, world_state.as_ref())
.build_initial_context_with_world_state(&step_context, world_state.as_ref())
.await;
let initial_context_injection = InitialContextInjection::BeforeLastUserMessage {
world_state,
+12 -4
View File
@@ -3977,11 +3977,15 @@ impl Session {
.collect()
}
/// `step_context` and `world_state` must come from the same captured step.
/// If more callers need this pair, bundle them into a captured-context struct
/// so callers cannot mix settings and WorldState from different steps.
pub(crate) async fn build_initial_context_with_world_state(
&self,
turn_context: &TurnContext,
step_context: &StepContext,
world_state: &WorldState,
) -> Vec<ResponseItem> {
let turn_context = step_context.turn.as_ref();
let mut developer_sections = Vec::<RenderedFragment>::with_capacity(8);
let mut contextual_user_sections = Vec::<RenderedFragment>::with_capacity(2);
let mut separate_developer_sections = Vec::<RenderedFragment>::new();
@@ -4071,7 +4075,11 @@ impl Session {
}
// This is full-context metadata. Steady-state context diffs should not re-emit it.
if turn_context.config.features.enabled(Feature::TokenBudget)
&& turn_context.model_context_window().is_some()
&& step_context
.settings
.model_info
.resolved_context_window()
.is_some()
{
// Keep the legacy bridge hint when native Notes is disabled. A failed
// native request must not fall back to the bridge.
@@ -4291,7 +4299,7 @@ impl Session {
};
let (window_number, window_ids) = window;
let context_items = self
.build_initial_context_with_world_state(turn_context, world_state.as_ref())
.build_initial_context_with_world_state(step_context, world_state.as_ref())
.await
.into_iter()
.map(ResponseItemEnvelope::new)
@@ -4350,7 +4358,7 @@ impl Session {
// Full initial context resets the baseline; later turns persist only its changes.
let (mut context_items, world_state_item) = if should_inject_full_context {
let context_items = self
.build_initial_context_with_world_state(turn_context, world_state.as_ref())
.build_initial_context_with_world_state(step_context, world_state.as_ref())
.await;
let snapshot = world_state.snapshot();
self.state
+12 -8
View File
@@ -1,6 +1,6 @@
use crate::config::MultiAgentV2Config;
use crate::context::MultiAgentRoleInstructions;
use crate::session::turn_context::TurnContext;
use crate::session::step_context::StepContext;
use codex_protocol::config_types::MultiAgentMode;
use codex_protocol::openai_models::MultiAgentRoleMessages;
use codex_protocol::openai_models::ReasoningEffort;
@@ -65,15 +65,17 @@ pub(crate) struct ResolvedMultiAgentV2UsageHints {
}
pub(super) fn usage_hint_text(
turn_context: &TurnContext,
step_context: &StepContext,
session_source: &SessionSource,
) -> Option<MultiAgentRoleInstructions> {
let turn_context = step_context.turn.as_ref();
if turn_context.multi_agent_version != MultiAgentVersion::V2 {
return None;
}
let catalog = turn_context
.model_info()
let catalog = step_context
.settings
.model_info
.model_messages
.as_ref()
.and_then(|messages| messages.multi_agent.as_ref())
@@ -152,13 +154,15 @@ pub(crate) fn resolve_usage_hints(
}
}
pub(crate) fn effective_multi_agent_mode(turn_context: &TurnContext) -> Option<MultiAgentMode> {
pub(crate) fn effective_multi_agent_mode(step_context: &StepContext) -> Option<MultiAgentMode> {
let turn_context = step_context.turn.as_ref();
let settings = &step_context.settings;
if turn_context.multi_agent_version != MultiAgentVersion::V2 {
return None;
}
let catalog_mode = turn_context
.model_info()
let catalog_mode = settings
.model_info
.model_messages
.as_ref()
.and_then(|messages| messages.multi_agent.as_ref())
@@ -174,7 +178,7 @@ pub(crate) fn effective_multi_agent_mode(turn_context: &TurnContext) -> Option<M
// of an effort-derived built-in policy.
let multi_agent_mode = match mode_hint_text {
Some(hint_text) => MultiAgentMode::Custom(hint_text.to_string()),
None => match turn_context.effective_reasoning_effort() {
None => match settings.effective_reasoning_effort() {
Some(ReasoningEffort::Ultra) => catalog_mode
.and_then(|messages| messages.proactive.clone())
.map(MultiAgentMode::Custom)
@@ -90,6 +90,12 @@ impl ResolvedStepSettings {
.cloned()
}
pub(crate) fn effective_collaboration_mode(&self) -> CollaborationMode {
let mut collaboration_mode = self.selected.collaboration_mode.clone();
collaboration_mode.settings.model = self.model_info.slug.clone();
collaboration_mode
}
pub(crate) fn approval_policy(&self) -> AskForApproval {
self.selected.approval_policy.value()
}
+106 -3
View File
@@ -74,6 +74,7 @@ use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::models::ImageDetail;
use codex_protocol::models::PermissionProfile;
use codex_protocol::models::SandboxEnforcement;
use codex_protocol::openai_models::ModelInstructionsVariables;
use codex_protocol::openai_models::ModelServiceTier;
use codex_protocol::openai_models::ToolMode;
use codex_protocol::permissions::FileSystemAccessMode;
@@ -6416,9 +6417,13 @@ async fn build_initial_context(
session: &Session,
turn_context: &Arc<TurnContext>,
) -> Vec<ResponseItem> {
let world_state = build_world_state_from_turn_context(session, turn_context).await;
let step_context = StepContext::for_test(Arc::clone(turn_context));
let world_state = session
.build_world_state_for_step(&step_context)
.await
.expect("world state should build");
session
.build_initial_context_with_world_state(turn_context.as_ref(), &world_state)
.build_initial_context_with_world_state(&step_context, &world_state)
.await
}
@@ -10123,8 +10128,9 @@ async fn build_initial_context_reuses_in_flight_recommendation_prewarm() {
// Keep the OnceCell initializer unpolled while first-thread context joins its in-flight fetch.
// This does not depend on how quickly the HTTP server returns its response.
let world_state = WorldState::default();
let step_context = StepContext::for_test(Arc::clone(&turn_context));
let initial_context =
session.build_initial_context_with_world_state(&turn_context, &world_state);
session.build_initial_context_with_world_state(&step_context, &world_state);
tokio::pin!(initial_context);
assert!(futures::poll!(initial_context.as_mut()).is_pending());
@@ -10802,6 +10808,103 @@ async fn record_context_updates_and_set_reference_context_item_persists_split_fi
);
}
#[tokio::test]
async fn build_initial_context_uses_retained_step_after_model_change() {
let (session, mut turn_context, _rx_event) =
make_session_and_context_with_auth_and_config_and_rx(
CodexAuth::from_api_key("Test API Key"),
Vec::new(),
|config| {
config.features.enable(Feature::Personality).unwrap();
config.features.enable(Feature::TokenBudget).unwrap();
config.personality = Some(Personality::Friendly);
},
)
.await;
update_turn_settings_for_test(Arc::get_mut(&mut turn_context).unwrap(), |settings| {
let model_info = Arc::make_mut(&mut settings.model_info);
model_info.slug = "model-a".to_string();
model_info.context_window = None;
model_info.max_context_window = None;
let messages = model_info.model_messages.as_mut().unwrap();
messages.instructions_template = Some("A instructions: {{ personality }}".to_string());
messages.instructions_variables = Some(ModelInstructionsVariables {
personality_default: Some("default".to_string()),
personality_friendly: Some("friendly".to_string()),
personality_pragmatic: Some("pragmatic".to_string()),
});
});
session
.set_previous_turn_settings(Some(PreviousTurnSettings {
model: "base-model".to_string(),
comp_hash: None,
realtime_active: None,
}))
.await;
let step_a = session
.capture_step_context(Arc::clone(&turn_context), &CancellationToken::new())
.await
.unwrap();
let world_a = Arc::new(session.build_world_state_for_step(&step_a).await.unwrap());
let retained = crate::compact::InitialContextInjection::BeforeLastUserMessage {
world_state: Arc::clone(&world_a),
step_context: Arc::clone(&step_a),
};
let (initial_a, _) =
crate::compact::build_compaction_initial_context(&session, &retained).await;
let mut selected_b = step_a.settings.selected().clone();
selected_b.collaboration_mode.settings.model = "model-b".to_string();
selected_b.personality = Some(Personality::Pragmatic);
let mut model_b = step_a.settings.model_info.as_ref().clone();
model_b.slug = "model-b".to_string();
model_b.context_window = Some(128_000);
model_b
.model_messages
.as_mut()
.unwrap()
.instructions_template = Some("B instructions: {{ personality }}".to_string());
turn_context
.current_settings
.store(Arc::new(ResolvedStepSettings::new(
Arc::new(selected_b),
Arc::new(model_b),
/*fast_mode_enabled*/ false,
)));
let step_b = session
.capture_step_context(Arc::clone(&turn_context), &CancellationToken::new())
.await
.unwrap();
let world_b = session.build_world_state_for_step(&step_b).await.unwrap();
let initial_b = session
.build_initial_context_with_world_state(&step_b, &world_b)
.await;
let (restored_a, restored_world) =
crate::compact::build_compaction_initial_context(&session, &retained).await;
assert_eq!(restored_a, initial_a);
assert!(Arc::ptr_eq(restored_world.as_ref().unwrap(), &world_a));
let initial_a = initial_a
.into_iter()
.map(ResponseItemEnvelope::into_item)
.collect::<Vec<_>>();
let a_text = developer_input_texts(&initial_a).join("\n");
let b_text = developer_input_texts(&initial_b).join("\n");
assert!(a_text.contains("A instructions: friendly"));
assert!(!a_text.contains("<context_window>"));
assert!(b_text.contains("B instructions: pragmatic"));
assert!(!b_text.contains("A instructions:"));
assert!(
b_text.contains("<context_window>"),
"full-context metadata must use B's window even though the turn started without one"
);
assert_eq!(
step_b.environments.to_selections(),
step_a.environments.to_selections()
);
assert!(Arc::ptr_eq(&step_b.turn.config, &step_a.turn.config));
}
#[tokio::test]
async fn build_initial_context_prepends_model_switch_message() {
let (session, turn_context) = make_session_and_context().await;
+1 -18
View File
@@ -369,16 +369,6 @@ impl TurnContext {
self.initial_settings.personality()
}
/// Legacy: returns the frozen initial-turn collaboration-mode developer instructions.
/// Step-scoped consumers should use their captured `StepContext::settings`.
pub(crate) fn collaboration_mode_developer_instructions(&self) -> &Option<String> {
&self
.initial_settings
.selected_collaboration_mode()
.settings
.developer_instructions
}
pub(crate) fn skills_snapshot(&self) -> Arc<HostSkillsSnapshot> {
let Some(snapshot) = self.extension_data.get::<HostSkillsSnapshot>() else {
unreachable!("every turn has a host skills snapshot");
@@ -389,14 +379,7 @@ impl TurnContext {
/// Legacy: returns the frozen initial-turn collaboration mode with the resolved model slug.
/// Step-scoped consumers should use their captured `StepContext::settings`.
pub(crate) fn collaboration_mode(&self) -> CollaborationMode {
CollaborationMode {
mode: self.mode(),
settings: Settings {
model: self.model_info().slug.clone(),
reasoning_effort: self.reasoning_effort().cloned(),
developer_instructions: self.collaboration_mode_developer_instructions().clone(),
},
}
self.initial_settings.effective_collaboration_mode()
}
pub(crate) fn plugin_attribution_for_command(
+21 -22
View File
@@ -37,13 +37,14 @@ impl Session {
step_context: &StepContext,
) -> CodexResult<WorldState> {
let turn_context = step_context.turn.as_ref();
let settings = &step_context.settings;
let model_info = settings.model_info.as_ref();
let personality = settings.personality();
tracing::trace!(
selected_capability_root_count = step_context.selected_capability_roots.len(),
"building step world state"
);
let model_instructions = turn_context
.model_info()
.get_model_instructions(turn_context.personality());
let model_instructions = model_info.get_model_instructions(personality);
let model_instructions = if !turn_context.config.update_plan_enabled
&& turn_context.config.model_catalog.is_none()
&& (turn_context.config.base_instructions.is_none()
@@ -77,8 +78,8 @@ impl Session {
base_instructions,
)
};
let personality_is_baked = turn_context.model_info().supports_personality()
&& base_instructions == model_instructions;
let personality_is_baked =
model_info.supports_personality() && base_instructions == model_instructions;
let environment_subagents = if turn_context.config.include_environment_context {
self.services
.agent_control
@@ -92,22 +93,21 @@ impl Session {
};
let mut world_state = WorldState::default();
world_state.add_section(ModelInstructionsState::new(
&turn_context.model_info().slug,
&model_info.slug,
previous_model.as_deref(),
model_instructions,
));
if self.features.enabled(Feature::Personality) {
let personality_instructions = turn_context.personality().and_then(|personality| {
turn_context
.model_info()
let personality_instructions = personality.and_then(|personality| {
model_info
.model_messages
.as_ref()
.and_then(|messages| messages.get_personality_message(Some(personality)))
.filter(|message| !message.is_empty())
});
world_state.add_section(PersonalityState::new(
&turn_context.model_info().slug,
turn_context.personality(),
&model_info.slug,
personality,
previous_context
.as_ref()
.map(|previous| previous.model.as_str())
@@ -172,12 +172,12 @@ impl Session {
let cwd = environment
.and_then(|environment| environment.cwd().to_abs_path().ok())
.unwrap_or_else(|| turn_context.cwd.clone());
let model_messages = turn_context.model_info().model_messages.as_ref();
let model_messages = model_info.model_messages.as_ref();
world_state.add_section(PermissionsState::new(
&permission_profile,
step_context.settings.approval_policy(),
settings.approval_policy(),
ApprovalPromptContext::new(
step_context.settings.approvals_reviewer(),
settings.approvals_reviewer(),
model_messages.and_then(|messages| messages.approvals.as_ref()),
model_messages.and_then(|messages| messages.permissions.as_ref()),
),
@@ -197,9 +197,8 @@ impl Session {
}
if turn_context.config.include_collaboration_mode_instructions {
world_state.add_section(CollaborationModeState::from_collaboration_mode(
&turn_context.collaboration_mode(),
turn_context
.model_info()
&settings.effective_collaboration_mode(),
model_info
.model_messages
.as_ref()
.and_then(|messages| messages.collaboration_modes.as_ref()),
@@ -266,12 +265,12 @@ impl Session {
false
};
let apps_usage_instructions_available =
apps_available && turn_context.model_info().include_apps_usage_instructions;
apps_available && model_info.include_apps_usage_instructions;
world_state.add_section(AppsInstructionsState::new(
apps_usage_instructions_available,
));
let plugins_usage_instructions_available = step_context.mcp.plugins_available()
&& turn_context.model_info().include_plugin_usage_instructions;
let plugins_usage_instructions_available =
step_context.mcp.plugins_available() && model_info.include_plugin_usage_instructions;
world_state.add_section(PluginsInstructionsState::new(
plugins_usage_instructions_available,
));
@@ -314,10 +313,10 @@ impl Session {
}
}
let mut multi_agent_mode = MultiAgentModeState::new(
super::multi_agents::effective_multi_agent_mode(turn_context),
super::multi_agents::effective_multi_agent_mode(step_context),
);
if let Some(usage_hint_text) =
super::multi_agents::usage_hint_text(turn_context, &turn_context.session_source)
super::multi_agents::usage_hint_text(step_context, &turn_context.session_source)
{
let usage_hint = MultiAgentUsageHintState::new(usage_hint_text);
multi_agent_mode = multi_agent_mode.with_usage_hint(&usage_hint);
+5 -2
View File
@@ -7,6 +7,7 @@ use crate::mcp::McpEnvironmentScope;
use crate::mcp::McpThreadIdentity;
use crate::rollout::RolloutRecorder;
use crate::session::session::SessionSettingsUpdate;
use crate::session::step_context::StepContext;
use crate::session::tests::build_world_state_from_turn_context;
use crate::session::tests::make_session_and_context;
use crate::tasks::InterruptedTurnHistoryMarker;
@@ -598,8 +599,9 @@ async fn ignores_session_prefix_messages_when_truncating() {
let (session, turn_context) = make_session_and_context().await;
let turn_context = Arc::new(turn_context);
let world_state = build_world_state_from_turn_context(&session, &turn_context).await;
let step_context = StepContext::for_test(turn_context);
let mut items = session
.build_initial_context_with_world_state(&turn_context, &world_state)
.build_initial_context_with_world_state(&step_context, &world_state)
.await;
items.push(user_msg("feature request"));
items.push(assistant_msg("ack"));
@@ -1201,10 +1203,11 @@ async fn spawn_internal_session_preserves_parent_lineage_without_forking_history
let reviewer_turn = reviewer.thread.session.new_default_turn().await;
let reviewer_world_state =
build_world_state_from_turn_context(&reviewer.thread.session, &reviewer_turn).await;
let reviewer_step = StepContext::for_test(Arc::clone(&reviewer_turn));
let reviewer_context = reviewer
.thread
.session
.build_initial_context_with_world_state(&reviewer_turn, &reviewer_world_state)
.build_initial_context_with_world_state(&reviewer_step, &reviewer_world_state)
.await;
assert!(
!serde_json::to_string(&reviewer_context)
@@ -1,4 +1,5 @@
use super::*;
use crate::session::step_context::StepContext;
use crate::session::tests::build_world_state_from_turn_context;
use crate::session::tests::make_session_and_context;
use codex_protocol::AgentPath;
@@ -364,8 +365,9 @@ async fn ignores_session_prefix_messages_when_truncating_rollout_from_start() {
let (session, turn_context) = make_session_and_context().await;
let turn_context = Arc::new(turn_context);
let world_state = build_world_state_from_turn_context(&session, &turn_context).await;
let step_context = StepContext::for_test(turn_context);
let mut items = session
.build_initial_context_with_world_state(&turn_context, &world_state)
.build_initial_context_with_world_state(&step_context, &world_state)
.await;
items.push(user_msg("feature request"));
items.push(assistant_msg("ack"));
+205 -6
View File
@@ -9,12 +9,19 @@ use codex_login::CodexAuth;
use codex_models_manager::bundled_models_response;
use codex_models_manager::manager::RefreshStrategy;
use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::config_types::Personality;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::openai_models::ApprovalMessages;
use codex_protocol::openai_models::CollaborationModeMessages;
use codex_protocol::openai_models::ConfirmationPolicies;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::openai_models::ModelInstructionsVariables;
use codex_protocol::openai_models::ModelTokenBudgetConfig;
use codex_protocol::openai_models::ModelsResponse;
use codex_protocol::openai_models::MultiAgentMessages;
use codex_protocol::openai_models::MultiAgentModeMessages;
use codex_protocol::openai_models::MultiAgentRoleMessages;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::openai_models::ReasoningEffortPreset;
use codex_protocol::openai_models::ToolMessage;
@@ -344,6 +351,21 @@ async fn settings_updates_preserve_turn_identity_and_target(target: SettingsTarg
Some("original-turn-state".to_string())
);
assert_eq!(requests[2].header(TURN_STATE_HEADER), None);
let expected_switch_counts = match target {
SettingsTarget::Thread => vec![0, 0, 1],
SettingsTarget::Turn => vec![0, 1, 2],
};
assert_eq!(
requests
.iter()
.map(|request| request
.message_input_texts("developer")
.iter()
.filter(|text| text.contains("<model_switch>"))
.count())
.collect::<Vec<_>>(),
expected_switch_counts
);
Ok(())
}
@@ -365,7 +387,7 @@ enum TokenBudgetScenario {
#[test_case(TokenBudgetScenario::InitialWindowOnly)]
#[test_case(TokenBudgetScenario::DestinationWithoutGuidance)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn active_model_switch_resolves_token_budget_from_original_preferences(
async fn active_model_switch_updates_core_context_from_captured_settings(
scenario: TokenBudgetScenario,
) -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -411,6 +433,15 @@ async fn active_model_switch_resolves_token_budget_from_original_preferences(
.features
.enable(Feature::TokenBudget)
.expect("enable token-budget feature");
config
.features
.enable(Feature::Personality)
.expect("enable personality");
config
.features
.enable(Feature::MultiAgentV2)
.expect("enable multi-agent V2");
config.personality = Some(Personality::Pragmatic);
if context_window_model.is_some() {
config.model_context_window = None;
}
@@ -426,11 +457,38 @@ async fn active_model_switch_resolves_token_budget_from_original_preferences(
model.context_window = (slug == context_window_model).then_some(128_000);
model.max_context_window = None;
}
model
.model_messages
.as_mut()
.expect("model messages")
.token_budget = (initial_model || destination_has_guidance).then(|| ModelTokenBudgetConfig {
let messages = model.model_messages.as_mut().expect("model messages");
messages.instructions_template = Some(format!(
"Instructions for {slug}. {{{{ personality }}}}"
));
messages.instructions_variables = Some(ModelInstructionsVariables {
personality_default: Some(format!("Default {slug} personality.")),
personality_friendly: Some(format!("Friendly {slug} personality.")),
personality_pragmatic: Some(format!("Pragmatic {slug} personality.")),
});
messages.collaboration_modes = Some(CollaborationModeMessages {
default: Some(format!("Default collaboration for {slug}.")),
plan: None,
});
messages.multi_agent = Some(MultiAgentMessages {
role: Some(MultiAgentRoleMessages {
root: Some(format!("Root role for {slug}.")),
subagent: None,
}),
mode: Some(MultiAgentModeMessages {
explicit: Some(format!("Delegation policy for {slug}.")),
proactive: None,
hint_text: None,
}),
});
messages.approvals = Some(ApprovalMessages {
on_request: Some(format!("Approval instructions for {slug}.")),
on_request_auto_review: None,
never: None,
unless_trusted: None,
});
messages.token_budget =
(initial_model || destination_has_guidance).then(|| ModelTokenBudgetConfig {
enabled: false,
use_history_notes_extension: false,
reminder_threshold_tokens: if initial_model { 8_000 } else { 2_000 },
@@ -487,6 +545,64 @@ async fn active_model_switch_resolves_token_budget_from_original_preferences(
let requests = response_mock.requests();
assert_eq!(requests.len(), 3);
assert_eq!(
requests
.iter()
.map(|request| request.body_json()["model"].clone())
.collect::<Vec<_>>(),
vec![json!(MODEL_A), json!(MODEL_B), json!(MODEL_B)]
);
let initial_instructions =
format!("Instructions for {MODEL_A}. Pragmatic {MODEL_A} personality.");
assert_eq!(requests[0].instructions_text(), initial_instructions);
assert!(!requests[0].body_contains_text("<model_switch>"));
for text in [
format!("Root role for {MODEL_A}."),
format!("Delegation policy for {MODEL_A}."),
] {
assert!(requests[0].body_contains_text(&text));
}
for pair in requests.windows(/*size*/ 2) {
assert!(
pair[1].input().starts_with(&pair[0].input()),
"context updates must be append-only"
);
assert_eq!(pair[1].instructions_text(), initial_instructions);
assert_eq!(pair[1].body_json()["tools"], pair[0].body_json()["tools"]);
assert_eq!(
pair[1].message_input_texts("user"),
pair[0].message_input_texts("user")
);
}
for request in &requests[1..] {
let developer_texts = request.message_input_texts("developer");
let switches = developer_texts
.iter()
.filter(|text| text.contains("<model_switch>"))
.collect::<Vec<_>>();
assert_eq!(switches.len(), 1);
assert!(switches[0].contains(&format!(
"Instructions for {MODEL_B}. Pragmatic {MODEL_B} personality."
)));
assert!(
!request.body_contains_text("<personality_spec>"),
"personality is included in the model-switch instructions"
);
for text in [
format!("Default collaboration for {MODEL_B}."),
format!("Approval instructions for {MODEL_B}."),
format!("Root role for {MODEL_B}."),
format!("Delegation policy for {MODEL_B}."),
] {
assert_eq!(
developer_texts
.iter()
.filter(|message| message.contains(&text))
.count(),
1
);
}
}
let initial_guidance = format!("Use {MODEL_A} token-budget guidance.");
let initial_guidance_expected =
!explicit_default_template && context_window_model != Some(MODEL_B);
@@ -532,6 +648,89 @@ async fn active_model_switch_resolves_token_budget_from_original_preferences(
Ok(())
}
#[test_case(Some(ReasoningEffort::Ultra); "selected effort")]
#[test_case(None; "captured model default effort")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn active_model_switch_updates_multi_agent_policy_from_captured_effort(
effort: Option<ReasoningEffort>,
) -> Result<()> {
skip_if_no_network!(Ok(()));
let use_model_default = effort.is_none();
let server = start_mock_server().await;
let response_mock = mount_sse_sequence(
&server,
vec![
paused_response("resp-1", "pause-before-effort-switch"),
sse_completed("resp-2"),
],
)
.await;
let test = step_settings_test()
.with_config(move |config| {
config
.features
.enable(Feature::MultiAgentV2)
.expect("enable multi-agent V2");
for model in &mut config
.model_catalog
.as_mut()
.expect("controlled model catalog")
.models
{
model
.model_messages
.as_mut()
.expect("model messages")
.multi_agent = None;
model
.supported_reasoning_levels
.push(ReasoningEffortPreset {
effort: ReasoningEffort::Ultra,
description: "Ultra".to_string(),
});
model.default_reasoning_level =
Some(if model.slug == MODEL_B && use_model_default {
ReasoningEffort::Ultra
} else {
ReasoningEffort::Low
});
}
})
.build_with_auto_env(&server)
.await?;
let request = start_paused_turn(&test.codex).await?;
assert_eq!(
submit_turn_settings(
&test.codex,
&request.turn_id,
TurnSettingsUpdate {
model: Some(MODEL_B.to_string()),
effort: Some(effort),
..Default::default()
},
)
.await?,
TurnSettingsUpdateOutcome::Applied
);
answer_paused_turn(&test.codex, &request.turn_id).await?;
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
let requests = response_mock.requests();
assert_eq!(requests.len(), 2);
assert_eq!(requests[0].body_json()["model"], MODEL_A);
assert_eq!(requests[1].body_json()["model"], MODEL_B);
assert_eq!(requests[1].body_json()["reasoning"]["effort"], "xhigh");
let proactive_text = "Proactive multi-agent delegation is active.";
assert!(!requests[0].body_contains_text(proactive_text));
assert!(requests[1].body_contains_text(proactive_text));
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn mcp_confirmation_policy_follows_step_model_changes() -> Result<()> {
skip_if_no_network!(Ok(()));