fix(core): restore parent fork spawn config

This commit is contained in:
Friel
2026-04-02 00:57:14 +00:00
parent fc08138cf0
commit 73c4e6d255
7 changed files with 142 additions and 4 deletions

View File

@@ -1129,6 +1129,12 @@ impl SessionConfiguration {
model: self.collaboration_mode.model().to_string(),
model_provider_id: self.original_config_do_not_use.model_provider_id.clone(),
service_tier: self.service_tier,
plan_mode_reasoning_effort: self.original_config_do_not_use.plan_mode_reasoning_effort,
model_verbosity: self.original_config_do_not_use.model_verbosity,
model_context_window: self.original_config_do_not_use.model_context_window,
model_auto_compact_token_limit: self
.original_config_do_not_use
.model_auto_compact_token_limit,
approval_policy: self.approval_policy.value(),
approvals_reviewer: self.approvals_reviewer,
sandbox_policy: self.sandbox_policy.get().clone(),
@@ -1136,6 +1142,7 @@ impl SessionConfiguration {
ephemeral: self.original_config_do_not_use.ephemeral,
reasoning_effort: self.collaboration_mode.reasoning_effort(),
personality: self.personality,
active_profile: self.original_config_do_not_use.active_profile.clone(),
session_source: self.session_source.clone(),
}
}

View File

@@ -12,6 +12,7 @@ use codex_features::Feature;
use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::config_types::Personality;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::config_types::Verbosity;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::models::ResponseItem;
@@ -33,6 +34,10 @@ pub struct ThreadConfigSnapshot {
pub model: String,
pub model_provider_id: String,
pub service_tier: Option<ServiceTier>,
pub plan_mode_reasoning_effort: Option<ReasoningEffort>,
pub model_verbosity: Option<Verbosity>,
pub model_context_window: Option<i64>,
pub model_auto_compact_token_limit: Option<i64>,
pub approval_policy: AskForApproval,
pub approvals_reviewer: ApprovalsReviewer,
pub sandbox_policy: SandboxPolicy,
@@ -40,6 +45,7 @@ pub struct ThreadConfigSnapshot {
pub ephemeral: bool,
pub reasoning_effort: Option<ReasoningEffort>,
pub personality: Option<Personality>,
pub active_profile: Option<String>,
pub session_source: SessionSource,
}

View File

@@ -79,6 +79,9 @@ impl ToolHandler for Handler {
apply_role_to_config(&mut config, role_name)
.await
.map_err(FunctionCallError::RespondToModel)?;
if fork_context {
restore_forked_spawn_agent_model_config(&mut config, turn.as_ref());
}
apply_spawn_agent_runtime_overrides(&mut config, turn.as_ref())?;
apply_spawn_agent_overrides(&mut config, child_depth);

View File

@@ -238,6 +238,24 @@ fn build_agent_shared_config(turn: &TurnContext) -> Result<Config, FunctionCallE
Ok(config)
}
/// Restores parent-owned model selection after role application on forked spawns.
pub(crate) fn restore_forked_spawn_agent_model_config(config: &mut Config, turn: &TurnContext) {
config.model = Some(turn.model_info.slug.clone());
config.service_tier = turn.config.service_tier;
config.model_provider_id = turn.config.model_provider_id.clone();
config.model_provider = turn.provider.clone();
config.model_reasoning_effort = turn
.reasoning_effort
.or(turn.model_info.default_reasoning_level);
config.plan_mode_reasoning_effort = turn.config.plan_mode_reasoning_effort;
config.model_reasoning_summary = Some(turn.reasoning_summary);
config.model_verbosity = turn.config.model_verbosity;
config.model_context_window = turn.config.model_context_window;
config.model_auto_compact_token_limit = turn.config.model_auto_compact_token_limit;
config.model_supports_reasoning_summaries = turn.config.model_supports_reasoning_summaries;
config.active_profile = turn.config.active_profile.clone();
}
/// Copies runtime-only turn state onto a child config before it is handed to `AgentControl`.
///
/// These values are chosen by the live turn rather than persisted config, so leaving them stale

View File

@@ -4,6 +4,7 @@ use crate::CodexAuth;
use crate::ThreadManager;
use crate::built_in_model_providers;
use crate::codex::make_session_and_context;
use crate::config::AgentRoleConfig;
use crate::config::DEFAULT_AGENT_MAX_DEPTH;
use crate::config::types::ShellEnvironmentPolicy;
use crate::function_tool::FunctionCallError;
@@ -34,11 +35,14 @@ use crate::turn_diff_tracker::TurnDiffTracker;
use codex_features::Feature;
use codex_protocol::AgentPath;
use codex_protocol::ThreadId;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::config_types::Verbosity;
use codex_protocol::models::BaseInstructions;
use codex_protocol::models::ContentItem;
use codex_protocol::models::FunctionCallOutputBody;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::protocol::InitialHistory;
use codex_protocol::protocol::InterAgentCommunication;
use codex_protocol::protocol::RolloutItem;
@@ -89,6 +93,53 @@ fn thread_manager() -> ThreadManager {
)
}
async fn install_role_with_model_provider_and_profile_override(turn: &mut TurnContext) -> String {
let role_name = "fork-context-role".to_string();
let role_config_path = turn.config.codex_home.join("fork-context-role.toml");
tokio::fs::write(
&role_config_path,
r#"developer_instructions = "Forked children should keep the parent model config."
model_provider = "role-provider"
model_context_window = 12345
model_auto_compact_token_limit = 1234
model_verbosity = "low"
plan_mode_reasoning_effort = "minimal"
profile = "role-profile"
service_tier = "fast"
[profiles.role-profile]
model_provider = "role-provider"
"#,
)
.await
.expect("role config should be written");
let mut config = (*turn.config).clone();
let mut role_provider =
built_in_model_providers(/* openai_base_url */ /*openai_base_url*/ None)["openai"].clone();
role_provider.name = "Role Provider".to_string();
config
.model_providers
.insert("role-provider".to_string(), role_provider);
config.service_tier = Some(ServiceTier::Flex);
config.plan_mode_reasoning_effort = Some(ReasoningEffort::High);
config.model_verbosity = Some(Verbosity::High);
config.model_context_window = Some(200_000);
config.model_auto_compact_token_limit = Some(180_000);
config.agent_roles.insert(
role_name.clone(),
AgentRoleConfig {
description: Some("Role with model-provider and profile overrides".to_string()),
config_file: Some(role_config_path),
nickname_candidates: None,
fork_context: None,
},
);
turn.config = Arc::new(config);
role_name
}
fn history_contains_inter_agent_communication(
history_items: &[ResponseItem],
expected: &InterAgentCommunication,
@@ -305,7 +356,8 @@ async fn spawn_agent_uses_explorer_role_and_preserves_approval_policy() {
#[tokio::test]
async fn spawn_agent_fork_context_ignores_child_model_overrides() {
let (mut session, turn) = make_session_and_context().await;
let (mut session, mut turn) = make_session_and_context().await;
let role_name = install_role_with_model_provider_and_profile_override(&mut turn).await;
let manager = thread_manager();
let root = manager
.start_thread((*turn.config).clone())
@@ -314,7 +366,15 @@ async fn spawn_agent_fork_context_ignores_child_model_overrides() {
session.services.agent_control = manager.agent_control();
session.conversation_id = root.thread_id;
let expected_model = turn.model_info.slug.clone();
let expected_model_provider_id = turn.config.model_provider_id.clone();
let expected_model_provider_name = turn.provider.name.clone();
let expected_active_profile = turn.config.active_profile.clone();
let expected_reasoning_effort = turn.reasoning_effort;
let expected_service_tier = turn.config.service_tier;
let expected_plan_mode_reasoning_effort = turn.config.plan_mode_reasoning_effort;
let expected_model_verbosity = turn.config.model_verbosity;
let expected_model_context_window = turn.config.model_context_window;
let expected_model_auto_compact_token_limit = turn.config.model_auto_compact_token_limit;
let output = SpawnAgentHandler
.handle(invocation(
@@ -323,6 +383,7 @@ async fn spawn_agent_fork_context_ignores_child_model_overrides() {
"spawn_agent",
function_payload(json!({
"message": "inspect this repo",
"agent_type": role_name,
"model": "not-a-real-model",
"reasoning_effort": "low",
"fork_context": true
@@ -346,12 +407,27 @@ async fn spawn_agent_fork_context_ignores_child_model_overrides() {
.await;
assert_eq!(snapshot.model, expected_model);
assert_eq!(snapshot.model_provider_id, expected_model_provider_id);
assert_eq!(snapshot.model_provider.name, expected_model_provider_name);
assert_eq!(snapshot.active_profile, expected_active_profile);
assert_eq!(snapshot.reasoning_effort, expected_reasoning_effort);
assert_eq!(snapshot.service_tier, expected_service_tier);
assert_eq!(
snapshot.plan_mode_reasoning_effort,
expected_plan_mode_reasoning_effort
);
assert_eq!(snapshot.model_verbosity, expected_model_verbosity);
assert_eq!(snapshot.model_context_window, expected_model_context_window);
assert_eq!(
snapshot.model_auto_compact_token_limit,
expected_model_auto_compact_token_limit
);
}
#[tokio::test]
async fn multi_agent_v2_spawn_fork_turns_ignores_child_model_overrides() {
let (mut session, turn) = make_session_and_context().await;
let (mut session, mut turn) = make_session_and_context().await;
let role_name = install_role_with_model_provider_and_profile_override(&mut turn).await;
let manager = thread_manager();
let root = manager
.start_thread((*turn.config).clone())
@@ -369,7 +445,15 @@ async fn multi_agent_v2_spawn_fork_turns_ignores_child_model_overrides() {
..turn
};
let expected_model = turn.model_info.slug.clone();
let expected_model_provider_id = turn.config.model_provider_id.clone();
let expected_model_provider_name = turn.provider.name.clone();
let expected_active_profile = turn.config.active_profile.clone();
let expected_reasoning_effort = turn.reasoning_effort;
let expected_service_tier = turn.config.service_tier;
let expected_plan_mode_reasoning_effort = turn.config.plan_mode_reasoning_effort;
let expected_model_verbosity = turn.config.model_verbosity;
let expected_model_context_window = turn.config.model_context_window;
let expected_model_auto_compact_token_limit = turn.config.model_auto_compact_token_limit;
let output = SpawnAgentHandlerV2
.handle(invocation(
@@ -377,7 +461,8 @@ async fn multi_agent_v2_spawn_fork_turns_ignores_child_model_overrides() {
Arc::new(turn),
"spawn_agent",
function_payload(json!({
"items": [{"type": "text", "text": "inspect this repo"}],
"message": "inspect this repo",
"agent_type": role_name,
"model": "not-a-real-model",
"reasoning_effort": "low",
"fork_turns": "all",
@@ -404,7 +489,21 @@ async fn multi_agent_v2_spawn_fork_turns_ignores_child_model_overrides() {
.await;
assert_eq!(snapshot.model, expected_model);
assert_eq!(snapshot.model_provider_id, expected_model_provider_id);
assert_eq!(snapshot.model_provider.name, expected_model_provider_name);
assert_eq!(snapshot.active_profile, expected_active_profile);
assert_eq!(snapshot.reasoning_effort, expected_reasoning_effort);
assert_eq!(snapshot.service_tier, expected_service_tier);
assert_eq!(
snapshot.plan_mode_reasoning_effort,
expected_plan_mode_reasoning_effort
);
assert_eq!(snapshot.model_verbosity, expected_model_verbosity);
assert_eq!(snapshot.model_context_window, expected_model_context_window);
assert_eq!(
snapshot.model_auto_compact_token_limit,
expected_model_auto_compact_token_limit
);
}
#[tokio::test]

View File

@@ -81,6 +81,9 @@ impl ToolHandler for Handler {
apply_role_to_config(&mut config, role_name)
.await
.map_err(FunctionCallError::RespondToModel)?;
if fork_mode.is_some() {
restore_forked_spawn_agent_model_config(&mut config, turn.as_ref());
}
apply_spawn_agent_runtime_overrides(&mut config, turn.as_ref())?;
apply_spawn_agent_overrides(&mut config, child_depth);

View File

@@ -224,7 +224,9 @@ async fn setup_turn_one_with_custom_spawned_child(
test.submit_turn(TURN_1_PROMPT).await?;
if child_response_delay.is_none() && wait_for_parent_notification {
let _ = wait_for_requests(&child_request_log).await?;
let rollout_path = test.codex.rollout_path().expect("rollout path");
let Some(rollout_path) = test.codex.rollout_path() else {
anyhow::bail!("rollout path");
};
let deadline = Instant::now() + Duration::from_secs(6);
loop {
test.codex.ensure_rollout_materialized().await;