mirror of
https://github.com/openai/codex.git
synced 2026-10-27 18:48:37 +00:00
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:
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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(()));
|
||||
|
||||
Reference in New Issue
Block a user