From a1f11226302e189106e9cd0d262741916852658a Mon Sep 17 00:00:00 2001 From: Friel Date: Fri, 27 Mar 2026 16:29:37 -0700 Subject: [PATCH 01/14] feat(core): force forked agents to inherit parent model Co-authored-by: Codex --- .../src/tools/handlers/multi_agents/spawn.rs | 18 ++-- .../src/tools/handlers/multi_agents_tests.rs | 92 +++++++++++++++++++ .../tools/handlers/multi_agents_v2/spawn.rs | 18 ++-- 3 files changed, 112 insertions(+), 16 deletions(-) diff --git a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs index 308ec49d85..879883e6e7 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs @@ -62,14 +62,16 @@ impl ToolHandler for Handler { .await; let mut config = build_agent_spawn_config(&session.get_base_instructions().await, turn.as_ref())?; - apply_requested_spawn_agent_model_overrides( - &session, - turn.as_ref(), - &mut config, - args.model.as_deref(), - args.reasoning_effort, - ) - .await?; + if !args.fork_context { + apply_requested_spawn_agent_model_overrides( + &session, + turn.as_ref(), + &mut config, + args.model.as_deref(), + args.reasoning_effort, + ) + .await?; + } apply_role_to_config(&mut config, role_name) .await .map_err(FunctionCallError::RespondToModel)?; diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index c971346d75..26f0b53b9c 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -298,6 +298,98 @@ async fn spawn_agent_uses_explorer_role_and_preserves_approval_policy() { assert_eq!(snapshot.model_provider_id, "ollama"); } +#[tokio::test] +async fn spawn_agent_fork_context_ignores_child_model_overrides() { + let (mut session, turn) = make_session_and_context().await; + let manager = thread_manager(); + let root = manager + .start_thread((*turn.config).clone()) + .await + .expect("root thread should start"); + session.services.agent_control = manager.agent_control(); + session.conversation_id = root.thread_id; + let expected_model = turn.model_info.slug.clone(); + let expected_reasoning_effort = turn.reasoning_effort; + + let output = SpawnAgentHandler + .handle(invocation( + Arc::new(session), + Arc::new(turn), + "spawn_agent", + function_payload(json!({ + "message": "inspect this repo", + "model": "not-a-real-model", + "reasoning_effort": "low", + "fork_context": true + })), + )) + .await + .expect("spawn_agent should succeed"); + let (content, _) = expect_text_output(output); + let result: serde_json::Value = + serde_json::from_str(&content).expect("spawn_agent result should be json"); + let agent_id = parse_agent_id( + result["agent_id"] + .as_str() + .expect("spawn_agent result should include agent_id"), + ); + let snapshot = manager + .get_thread(agent_id) + .await + .expect("spawned agent thread should exist") + .config_snapshot() + .await; + + assert_eq!(snapshot.model, expected_model); + assert_eq!(snapshot.reasoning_effort, expected_reasoning_effort); +} + +#[tokio::test] +async fn multi_agent_v2_spawn_fork_context_ignores_child_model_overrides() { + let (mut session, turn) = make_session_and_context().await; + let manager = thread_manager(); + let root = manager + .start_thread((*turn.config).clone()) + .await + .expect("root thread should start"); + session.services.agent_control = manager.agent_control(); + session.conversation_id = root.thread_id; + let expected_model = turn.model_info.slug.clone(); + let expected_reasoning_effort = turn.reasoning_effort; + + let output = SpawnAgentHandlerV2 + .handle(invocation( + Arc::new(session), + Arc::new(turn), + "spawn_agent", + function_payload(json!({ + "message": "inspect this repo", + "model": "not-a-real-model", + "reasoning_effort": "low", + "fork_context": true + })), + )) + .await + .expect("spawn_agent should succeed"); + let (content, _) = expect_text_output(output); + let result: serde_json::Value = + serde_json::from_str(&content).expect("spawn_agent result should be json"); + let agent_id = parse_agent_id( + result["agent_id"] + .as_str() + .expect("spawn_agent result should include agent_id"), + ); + let snapshot = manager + .get_thread(agent_id) + .await + .expect("spawned agent thread should exist") + .config_snapshot() + .await; + + assert_eq!(snapshot.model, expected_model); + assert_eq!(snapshot.reasoning_effort, expected_reasoning_effort); +} + #[tokio::test] async fn spawn_agent_returns_agent_id_without_task_name() { let (mut session, turn) = make_session_and_context().await; diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs index 3dc8d72024..58f6cf53b1 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs @@ -66,14 +66,16 @@ impl ToolHandler for Handler { .await; let mut config = build_agent_spawn_config(&session.get_base_instructions().await, turn.as_ref())?; - apply_requested_spawn_agent_model_overrides( - &session, - turn.as_ref(), - &mut config, - args.model.as_deref(), - args.reasoning_effort, - ) - .await?; + if !args.fork_context { + apply_requested_spawn_agent_model_overrides( + &session, + turn.as_ref(), + &mut config, + args.model.as_deref(), + args.reasoning_effort, + ) + .await?; + } apply_role_to_config(&mut config, role_name) .await .map_err(FunctionCallError::RespondToModel)?; From 920edd3658bb89a9f6466de9807758556904f8bb Mon Sep 17 00:00:00 2001 From: Friel Date: Fri, 27 Mar 2026 16:57:22 -0700 Subject: [PATCH 02/14] test(core): fix multi-agent v2 fork-context regression Co-authored-by: Codex --- .../src/tools/handlers/multi_agents_tests.rs | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 26f0b53b9c..fe07e0018d 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -354,6 +354,15 @@ async fn multi_agent_v2_spawn_fork_context_ignores_child_model_overrides() { .expect("root thread should start"); session.services.agent_control = manager.agent_control(); session.conversation_id = root.thread_id; + let mut config = (*turn.config).clone(); + config + .features + .enable(Feature::MultiAgentV2) + .expect("test config should allow feature update"); + let turn = TurnContext { + config: Arc::new(config), + ..turn + }; let expected_model = turn.model_info.slug.clone(); let expected_reasoning_effort = turn.reasoning_effort; @@ -366,7 +375,8 @@ async fn multi_agent_v2_spawn_fork_context_ignores_child_model_overrides() { "message": "inspect this repo", "model": "not-a-real-model", "reasoning_effort": "low", - "fork_context": true + "fork_context": true, + "task_name": "fork_context_v2" })), )) .await @@ -374,11 +384,13 @@ async fn multi_agent_v2_spawn_fork_context_ignores_child_model_overrides() { let (content, _) = expect_text_output(output); let result: serde_json::Value = serde_json::from_str(&content).expect("spawn_agent result should be json"); - let agent_id = parse_agent_id( - result["agent_id"] - .as_str() - .expect("spawn_agent result should include agent_id"), - ); + assert_eq!(result["task_name"], "/root/fork_context_v2"); + let agent_id = manager + .captured_ops() + .into_iter() + .map(|(thread_id, _)| thread_id) + .find(|thread_id| *thread_id != root.thread_id) + .expect("spawned agent should receive an op"); let snapshot = manager .get_thread(agent_id) .await From 4186f55515434dfa2972a3930dfe1b8f4614caff Mon Sep 17 00:00:00 2001 From: Friel Date: Sun, 29 Mar 2026 17:56:12 +0000 Subject: [PATCH 03/14] feat(core): add role-level fork_context defaults --- codex-rs/core/config.schema.json | 4 ++++ codex-rs/core/src/agent/control_tests.rs | 1 + codex-rs/core/src/agent/role.rs | 10 ++++++++++ codex-rs/core/src/agent/role_tests.rs | 16 ++++++++++++++++ codex-rs/core/src/codex_tests.rs | 1 + codex-rs/core/src/config/agent_roles.rs | 3 +++ codex-rs/core/src/config/config_tests.rs | 5 +++++ codex-rs/core/src/config/mod.rs | 5 +++++ .../src/tools/handlers/multi_agents/spawn.rs | 13 ++++++++----- .../src/tools/handlers/multi_agents_tests.rs | 4 ++-- .../src/tools/handlers/multi_agents_v2/spawn.rs | 13 +++++++++---- .../core/tests/suite/subagent_notifications.rs | 2 ++ 12 files changed, 66 insertions(+), 11 deletions(-) diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 5876d463ce..626b29710e 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -21,6 +21,10 @@ "description": "Human-facing role documentation used in spawn tool guidance. Required unless supplied by the referenced agent role file.", "type": "string" }, + "fork_context": { + "description": "Default fork-context behavior for this role.", + "type": "boolean" + }, "nickname_candidates": { "description": "Candidate nicknames for agents spawned with this role.", "items": { diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 834ab2037b..85c8be3c30 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -1493,6 +1493,7 @@ async fn spawn_thread_subagent_uses_role_specific_nickname_candidates() { description: Some("Research role".to_string()), config_file: None, nickname_candidates: Some(vec!["Atlas".to_string()]), + fork_context: None, }, ); let (parent_thread_id, _parent_thread) = harness.start_thread().await; diff --git a/codex-rs/core/src/agent/role.rs b/codex-rs/core/src/agent/role.rs index b7d7b55ab1..cb738de15a 100644 --- a/codex-rs/core/src/agent/role.rs +++ b/codex-rs/core/src/agent/role.rs @@ -119,6 +119,13 @@ pub(crate) fn resolve_role_config<'a>( .or_else(|| built_in::configs().get(role_name)) } +pub(crate) fn default_fork_context_for_role(config: &Config, role_name: Option<&str>) -> bool { + let role_name = role_name.unwrap_or(DEFAULT_ROLE_NAME); + resolve_role_config(config, role_name) + .and_then(|role| role.fork_context) + .unwrap_or(false) +} + fn preservation_policy(config: &Config, role_layer_toml: &TomlValue) -> (bool, bool) { let role_selects_provider = role_layer_toml.get("model_provider").is_some(); let role_selects_profile = role_layer_toml.get("profile").is_some(); @@ -352,6 +359,7 @@ mod built_in { description: Some("Default agent.".to_string()), config_file: None, nickname_candidates: None, + fork_context: Some(true), } ), ( @@ -366,6 +374,7 @@ Rules: - Reuse existing explorers for related questions."#.to_string()), config_file: Some("explorer.toml".to_string().parse().unwrap_or_default()), nickname_candidates: None, + fork_context: Some(true), } ), ( @@ -381,6 +390,7 @@ Rules: - Always tell workers they are **not alone in the codebase**, and they should not revert the edits made by others, and they should adjust their implementation to accommodate the changes made by others. This is important because there may be multiple workers making changes in parallel, and they need to be aware of each other's work to avoid conflicts and ensure a cohesive final product."#.to_string()), config_file: None, nickname_candidates: None, + fork_context: Some(true), } ), // Awaiter is temp removed diff --git a/codex-rs/core/src/agent/role_tests.rs b/codex-rs/core/src/agent/role_tests.rs index 5b3941ebda..a25aa9750c 100644 --- a/codex-rs/core/src/agent/role_tests.rs +++ b/codex-rs/core/src/agent/role_tests.rs @@ -96,6 +96,7 @@ async fn apply_role_returns_unavailable_for_missing_user_role_file() { description: None, config_file: Some(PathBuf::from("/path/does/not/exist.toml")), nickname_candidates: None, + fork_context: None, }, ); @@ -116,6 +117,7 @@ async fn apply_role_returns_unavailable_for_invalid_user_role_toml() { description: None, config_file: Some(role_path), nickname_candidates: None, + fork_context: None, }, ); @@ -147,6 +149,7 @@ model = "role-model" description: None, config_file: Some(role_path), nickname_candidates: None, + fork_context: None, }, ); @@ -178,6 +181,7 @@ async fn apply_role_preserves_unspecified_keys() { description: None, config_file: Some(role_path), nickname_candidates: None, + fork_context: None, }, ); @@ -237,6 +241,7 @@ model_provider = "test-provider" description: None, config_file: Some(role_path), nickname_candidates: None, + fork_context: None, }, ); @@ -291,6 +296,7 @@ model_verbosity = "high" description: None, config_file: Some(role_path), nickname_candidates: None, + fork_context: None, }, ); @@ -357,6 +363,7 @@ model_provider = "role-provider" description: None, config_file: Some(role_path), nickname_candidates: None, + fork_context: None, }, ); @@ -415,6 +422,7 @@ model_provider = "base-provider" description: None, config_file: Some(role_path), nickname_candidates: None, + fork_context: None, }, ); @@ -479,6 +487,7 @@ model_reasoning_effort = "high" description: None, config_file: Some(role_path), nickname_candidates: None, + fork_context: None, }, ); @@ -523,6 +532,7 @@ writable_roots = ["./sandbox-root"] description: None, config_file: Some(role_path), nickname_candidates: None, + fork_context: None, }, ); @@ -585,6 +595,7 @@ async fn apply_role_takes_precedence_over_existing_session_flags_for_same_key() description: None, config_file: Some(role_path), nickname_candidates: None, + fork_context: None, }, ); @@ -628,6 +639,7 @@ enabled = false description: None, config_file: Some(role_path), nickname_candidates: None, + fork_context: None, }, ); @@ -662,6 +674,7 @@ fn spawn_tool_spec_build_deduplicates_user_defined_built_in_roles() { description: Some("user override".to_string()), config_file: None, nickname_candidates: None, + fork_context: None, }, ), ("researcher".to_string(), AgentRoleConfig::default()), @@ -683,6 +696,7 @@ fn spawn_tool_spec_lists_user_defined_roles_before_built_ins() { description: Some("first".to_string()), config_file: None, nickname_candidates: None, + fork_context: None, }, )]); @@ -710,6 +724,7 @@ fn spawn_tool_spec_marks_role_locked_model_and_reasoning_effort() { description: Some("Research carefully.".to_string()), config_file: Some(role_path), nickname_candidates: None, + fork_context: None, }, )]); @@ -735,6 +750,7 @@ fn spawn_tool_spec_marks_role_locked_reasoning_effort_only() { description: Some("Review carefully.".to_string()), config_file: Some(role_path), nickname_candidates: None, + fork_context: None, }, )]); diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index f5e5d4463e..dcd13c210d 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2369,6 +2369,7 @@ enabled = false description: None, config_file: Some(role_path), nickname_candidates: None, + fork_context: None, }, ); crate::agent::role::apply_role_to_config(&mut child_config, Some("custom")) diff --git a/codex-rs/core/src/config/agent_roles.rs b/codex-rs/core/src/config/agent_roles.rs index c527435e92..1b5bc03864 100644 --- a/codex-rs/core/src/config/agent_roles.rs +++ b/codex-rs/core/src/config/agent_roles.rs @@ -157,6 +157,7 @@ fn merge_missing_role_fields(role: &mut AgentRoleConfig, fallback: &AgentRoleCon .nickname_candidates .clone() .or(fallback.nickname_candidates.clone()); + role.fork_context = role.fork_context.or(fallback.fork_context); } fn agents_toml_from_layer(layer_toml: &TomlValue) -> std::io::Result> { @@ -190,6 +191,7 @@ fn agent_role_config_from_toml( description, config_file, nickname_candidates, + fork_context: role.fork_context, }) } @@ -480,6 +482,7 @@ fn discover_agent_roles_in_dir( description: parsed_file.description, config_file: Some(agent_file), nickname_candidates: parsed_file.nickname_candidates, + fork_context: None, }, ); } diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index da0936f27e..923b41b16f 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -3217,6 +3217,7 @@ fn load_config_rejects_missing_agent_role_config_file() -> std::io::Result<()> { description: Some("Research role".to_string()), config_file: Some(missing_path.abs()), nickname_candidates: None, + fork_context: None, }, )]), }), @@ -4085,6 +4086,7 @@ fn load_config_normalizes_agent_role_nickname_candidates() -> std::io::Result<() " Hypatia ".to_string(), "Noether".to_string(), ]), + fork_context: None, }, )]), }), @@ -4123,6 +4125,7 @@ fn load_config_rejects_empty_agent_role_nickname_candidates() -> std::io::Result description: Some("Research role".to_string()), config_file: None, nickname_candidates: Some(Vec::new()), + fork_context: None, }, )]), }), @@ -4158,6 +4161,7 @@ fn load_config_rejects_duplicate_agent_role_nickname_candidates() -> std::io::Re description: Some("Research role".to_string()), config_file: None, nickname_candidates: Some(vec!["Hypatia".to_string(), " Hypatia ".to_string()]), + fork_context: None, }, )]), }), @@ -4193,6 +4197,7 @@ fn load_config_rejects_unsafe_agent_role_nickname_candidates() -> std::io::Resul description: Some("Research role".to_string()), config_file: None, nickname_candidates: Some(vec!["Agent ".to_string()]), + fork_context: None, }, )]), }), diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 58cffc2659..9c7470470c 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -1580,6 +1580,8 @@ pub struct AgentRoleConfig { pub config_file: Option, /// Candidate nicknames for agents spawned with this role. pub nickname_candidates: Option>, + /// Default fork-context behavior for this role. + pub fork_context: Option, } #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] @@ -1595,6 +1597,9 @@ pub struct AgentRoleToml { /// Candidate nicknames for agents spawned with this role. pub nickname_candidates: Option>, + + /// Default fork-context behavior for this role. + pub fork_context: Option, } impl From for Tools { diff --git a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs index 879883e6e7..3b55b76a91 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs @@ -4,6 +4,7 @@ use crate::agent::control::SpawnAgentOptions; use crate::agent::control::render_input_preview; use crate::agent::role::DEFAULT_ROLE_NAME; use crate::agent::role::apply_role_to_config; +use crate::agent::role::default_fork_context_for_role; use crate::agent::exceeds_thread_spawn_depth_limit; use crate::agent::next_thread_spawn_depth; @@ -62,7 +63,10 @@ impl ToolHandler for Handler { .await; let mut config = build_agent_spawn_config(&session.get_base_instructions().await, turn.as_ref())?; - if !args.fork_context { + let fork_context = args + .fork_context + .unwrap_or_else(|| default_fork_context_for_role(&turn.config, role_name)); + if !fork_context { apply_requested_spawn_agent_model_overrides( &session, turn.as_ref(), @@ -92,8 +96,8 @@ impl ToolHandler for Handler { /*task_name*/ None, )?), SpawnAgentOptions { - fork_parent_spawn_call_id: args.fork_context.then(|| call_id.clone()), - fork_mode: args.fork_context.then_some(SpawnAgentForkMode::FullHistory), + fork_parent_spawn_call_id: fork_context.then(|| call_id.clone()), + fork_mode: fork_context.then_some(SpawnAgentForkMode::FullHistory), }, ) .await @@ -178,8 +182,7 @@ struct SpawnAgentArgs { agent_type: Option, model: Option, reasoning_effort: Option, - #[serde(default)] - fork_context: bool, + fork_context: Option, } #[derive(Debug, Serialize)] diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index fe07e0018d..e2a58653a2 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -345,7 +345,7 @@ async fn spawn_agent_fork_context_ignores_child_model_overrides() { } #[tokio::test] -async fn multi_agent_v2_spawn_fork_context_ignores_child_model_overrides() { +async fn multi_agent_v2_spawn_fork_turns_ignores_child_model_overrides() { let (mut session, turn) = make_session_and_context().await; let manager = thread_manager(); let root = manager @@ -375,7 +375,7 @@ async fn multi_agent_v2_spawn_fork_context_ignores_child_model_overrides() { "message": "inspect this repo", "model": "not-a-real-model", "reasoning_effort": "low", - "fork_context": true, + "fork_turns": "all", "task_name": "fork_context_v2" })), )) diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs index 58f6cf53b1..b8a1f5443e 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs @@ -5,6 +5,7 @@ use crate::agent::control::render_input_preview; use crate::agent::next_thread_spawn_depth; use crate::agent::role::DEFAULT_ROLE_NAME; use crate::agent::role::apply_role_to_config; +use crate::agent::role::default_fork_context_for_role; use codex_protocol::AgentPath; use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::Op; @@ -33,12 +34,13 @@ impl ToolHandler for Handler { } = invocation; let arguments = function_arguments(payload)?; let args: SpawnAgentArgs = parse_arguments(&arguments)?; - let fork_mode = args.fork_mode()?; let role_name = args .agent_type .as_deref() .map(str::trim) .filter(|role| !role.is_empty()); + let fork_context = default_fork_context_for_role(&turn.config, role_name); + let fork_mode = args.fork_mode(fork_context)?; let initial_operation = parse_collab_input(Some(args.message), /*items*/ None)?; let prompt = render_input_preview(&initial_operation); @@ -66,7 +68,7 @@ impl ToolHandler for Handler { .await; let mut config = build_agent_spawn_config(&session.get_base_instructions().await, turn.as_ref())?; - if !args.fork_context { + if fork_mode.is_none() { apply_requested_spawn_agent_model_overrides( &session, turn.as_ref(), @@ -214,7 +216,10 @@ struct SpawnAgentArgs { } impl SpawnAgentArgs { - fn fork_mode(&self) -> Result, FunctionCallError> { + fn fork_mode( + &self, + default_fork_context: bool, + ) -> Result, FunctionCallError> { if self.fork_context.is_some() { return Err(FunctionCallError::RespondToModel( "fork_context is not supported in MultiAgentV2; use fork_turns instead".to_string(), @@ -227,7 +232,7 @@ impl SpawnAgentArgs { .map(str::trim) .filter(|fork_turns| !fork_turns.is_empty()) else { - return Ok(None); + return Ok(default_fork_context.then_some(SpawnAgentForkMode::FullHistory)); }; if fork_turns.eq_ignore_ascii_case("none") { diff --git a/codex-rs/core/tests/suite/subagent_notifications.rs b/codex-rs/core/tests/suite/subagent_notifications.rs index a1a8c29b9c..694637405a 100644 --- a/codex-rs/core/tests/suite/subagent_notifications.rs +++ b/codex-rs/core/tests/suite/subagent_notifications.rs @@ -467,6 +467,7 @@ async fn spawn_agent_role_overrides_requested_model_and_reasoning_settings() -> description: Some("Custom role".to_string()), config_file: Some(role_path), nickname_candidates: None, + fork_context: None, }, ); }) @@ -515,6 +516,7 @@ async fn spawn_agent_tool_description_mentions_role_locked_settings() -> Result< description: Some("Custom role".to_string()), config_file: Some(role_path), nickname_candidates: None, + fork_context: None, }, ); }); From 5a89bb1c4eefbd8f4897a2cb1c9f1dde06ae98ab Mon Sep 17 00:00:00 2001 From: Friel Date: Mon, 30 Mar 2026 03:07:31 +0000 Subject: [PATCH 04/14] test: refresh fork-context expectations --- .../app-server/tests/suite/v2/turn_start.rs | 11 ++++++---- .../src/tools/handlers/multi_agents_tests.rs | 22 ++++++++++++++++++- .../tests/suite/subagent_notifications.rs | 7 +++--- 3 files changed, 31 insertions(+), 9 deletions(-) diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 3155a4be9c..bc3c4750bd 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1665,12 +1665,15 @@ async fn turn_start_file_change_approval_v2() -> Result<()> { } #[tokio::test] -async fn turn_start_emits_spawn_agent_item_with_model_metadata_v2() -> Result<()> { +async fn turn_start_emits_spawn_agent_item_with_effective_inherited_model_metadata_v2() -> Result<()> +{ skip_if_no_network!(Ok(())); const CHILD_PROMPT: &str = "child: do work"; const PARENT_PROMPT: &str = "spawn a child and continue"; const SPAWN_CALL_ID: &str = "spawn-call-1"; + const INHERITED_MODEL: &str = "gpt-5.2-codex"; + const INHERITED_REASONING_EFFORT: ReasoningEffort = ReasoningEffort::XHigh; const REQUESTED_MODEL: &str = "gpt-5.1"; const REQUESTED_REASONING_EFFORT: ReasoningEffort = ReasoningEffort::Low; @@ -1726,7 +1729,7 @@ async fn turn_start_emits_spawn_agent_item_with_model_metadata_v2() -> Result<() let thread_req = mcp .send_thread_start_request(ThreadStartParams { - model: Some("gpt-5.2-codex".to_string()), + model: Some(INHERITED_MODEL.to_string()), ..Default::default() }) .await?; @@ -1823,8 +1826,8 @@ async fn turn_start_emits_spawn_agent_item_with_model_metadata_v2() -> Result<() assert_eq!(sender_thread_id, thread.id); assert_eq!(receiver_thread_ids, vec![receiver_thread_id.clone()]); assert_eq!(prompt, Some(CHILD_PROMPT.to_string())); - assert_eq!(model, Some(REQUESTED_MODEL.to_string())); - assert_eq!(reasoning_effort, Some(REQUESTED_REASONING_EFFORT)); + assert_eq!(model, Some(INHERITED_MODEL.to_string())); + assert_eq!(reasoning_effort, Some(INHERITED_REASONING_EFFORT)); let agent_state = agents_states .get(&receiver_thread_id) .expect("spawn completion should include child agent state"); diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index e2a58653a2..48def14a5e 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -248,7 +248,12 @@ async fn spawn_agent_uses_explorer_role_and_preserves_approval_policy() { let (mut session, mut turn) = make_session_and_context().await; let manager = thread_manager(); + let root = manager + .start_thread((*turn.config).clone()) + .await + .expect("root thread should start"); session.services.agent_control = manager.agent_control(); + session.conversation_id = root.thread_id; let mut config = (*turn.config).clone(); let provider = built_in_model_providers(/* openai_base_url */ /*openai_base_url*/ None)["ollama"].clone(); @@ -406,7 +411,12 @@ async fn multi_agent_v2_spawn_fork_turns_ignores_child_model_overrides() { async fn spawn_agent_returns_agent_id_without_task_name() { let (mut session, turn) = make_session_and_context().await; let manager = thread_manager(); + let root = manager + .start_thread((*turn.config).clone()) + .await + .expect("root thread should start"); session.services.agent_control = manager.agent_control(); + session.conversation_id = root.thread_id; let output = SpawnAgentHandler .handle(invocation( @@ -1751,7 +1761,12 @@ async fn spawn_agent_reapplies_runtime_sandbox_after_role_config() { let (mut session, mut turn) = make_session_and_context().await; let manager = thread_manager(); + let root = manager + .start_thread((*turn.config).clone()) + .await + .expect("root thread should start"); session.services.agent_control = manager.agent_control(); + session.conversation_id = root.thread_id; let expected_sandbox = pick_allowed_sandbox_policy( &turn.config.permissions.sandbox_policy, turn.config.permissions.sandbox_policy.get().clone(), @@ -1862,13 +1877,18 @@ async fn spawn_agent_allows_depth_up_to_configured_max_depth() { let (mut session, mut turn) = make_session_and_context().await; let manager = thread_manager(); + let root = manager + .start_thread((*turn.config).clone()) + .await + .expect("root thread should start"); session.services.agent_control = manager.agent_control(); + session.conversation_id = root.thread_id; let mut config = (*turn.config).clone(); config.agent_max_depth = DEFAULT_AGENT_MAX_DEPTH + 1; turn.config = Arc::new(config); turn.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { - parent_thread_id: session.conversation_id, + parent_thread_id: root.thread_id, depth: DEFAULT_AGENT_MAX_DEPTH, agent_path: None, agent_nickname: None, diff --git a/codex-rs/core/tests/suite/subagent_notifications.rs b/codex-rs/core/tests/suite/subagent_notifications.rs index 694637405a..899522ee18 100644 --- a/codex-rs/core/tests/suite/subagent_notifications.rs +++ b/codex-rs/core/tests/suite/subagent_notifications.rs @@ -413,8 +413,7 @@ async fn spawned_child_receives_forked_parent_context() -> Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn spawn_agent_requested_model_and_reasoning_override_inherited_settings_without_role() --> Result<()> { +async fn spawn_agent_inherits_parent_model_and_reasoning_without_role() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; @@ -429,10 +428,10 @@ async fn spawn_agent_requested_model_and_reasoning_override_inherited_settings_w ) .await?; - assert_eq!(child_snapshot.model, REQUESTED_MODEL); + assert_eq!(child_snapshot.model, INHERITED_MODEL); assert_eq!( child_snapshot.reasoning_effort, - Some(REQUESTED_REASONING_EFFORT) + Some(INHERITED_REASONING_EFFORT) ); Ok(()) From b9c17a0a687444aa77ac9ed1cd4d6cef8bd6525b Mon Sep 17 00:00:00 2001 From: Friel Date: Mon, 30 Mar 2026 04:22:19 +0000 Subject: [PATCH 05/14] fix(core): preserve effective effort for fork-context children --- codex-rs/app-server/tests/suite/v2/turn_start.rs | 5 ++++- codex-rs/core/src/tools/handlers/multi_agents_common.rs | 6 +++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index bc3c4750bd..e50ee65f76 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1673,7 +1673,10 @@ async fn turn_start_emits_spawn_agent_item_with_effective_inherited_model_metada const PARENT_PROMPT: &str = "spawn a child and continue"; const SPAWN_CALL_ID: &str = "spawn-call-1"; const INHERITED_MODEL: &str = "gpt-5.2-codex"; - const INHERITED_REASONING_EFFORT: ReasoningEffort = ReasoningEffort::XHigh; + // thread/start only sets the model here; the session keeps the resolved collaboration-mode + // effort for that thread, so fork-context children should inherit `Medium`, not the model + // catalog default. + const INHERITED_REASONING_EFFORT: ReasoningEffort = ReasoningEffort::Medium; const REQUESTED_MODEL: &str = "gpt-5.1"; const REQUESTED_REASONING_EFFORT: ReasoningEffort = ReasoningEffort::Low; diff --git a/codex-rs/core/src/tools/handlers/multi_agents_common.rs b/codex-rs/core/src/tools/handlers/multi_agents_common.rs index dd68465a7f..4778f6b9f8 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_common.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_common.rs @@ -225,7 +225,11 @@ fn build_agent_shared_config(turn: &TurnContext) -> Result Date: Mon, 30 Mar 2026 05:48:14 +0000 Subject: [PATCH 06/14] test(core): fix subagent notification validator --- .../core/tests/suite/subagent_notifications.rs | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/codex-rs/core/tests/suite/subagent_notifications.rs b/codex-rs/core/tests/suite/subagent_notifications.rs index 899522ee18..04ebde856d 100644 --- a/codex-rs/core/tests/suite/subagent_notifications.rs +++ b/codex-rs/core/tests/suite/subagent_notifications.rs @@ -31,7 +31,9 @@ const TURN_1_PROMPT: &str = "spawn a child and continue"; const TURN_2_NO_WAIT_PROMPT: &str = "follow up without wait"; const CHILD_PROMPT: &str = "child: do work"; const INHERITED_MODEL: &str = "gpt-5.2-codex"; -const INHERITED_REASONING_EFFORT: ReasoningEffort = ReasoningEffort::XHigh; +// Fork-context children inherit the spawning turn's effective effort, which resolves to the +// model's default Medium effort in this test harness. +const INHERITED_REASONING_EFFORT: ReasoningEffort = ReasoningEffort::Medium; const REQUESTED_MODEL: &str = "gpt-5.1"; const REQUESTED_REASONING_EFFORT: ReasoningEffort = ReasoningEffort::Low; const ROLE_MODEL: &str = "gpt-5.1-codex-max"; @@ -221,15 +223,14 @@ 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() - .ok_or_else(|| anyhow::anyhow!("expected parent rollout path"))?; + let rollout_path = test.codex.rollout_path().expect("rollout path"); let deadline = Instant::now() + Duration::from_secs(6); loop { - let has_notification = tokio::fs::read_to_string(&rollout_path) - .await - .is_ok_and(|rollout| rollout.contains("")); + test.codex.ensure_rollout_materialized().await; + test.codex.flush_rollout().await; + let has_notification = std::fs::read_to_string(&rollout_path) + .ok() + .is_some_and(|rollout| rollout.contains("")); if has_notification { break; } From 4b1cdb044f84daa9d2e877c7b2821c87a42ce730 Mon Sep 17 00:00:00 2001 From: Friel Date: Mon, 30 Mar 2026 06:09:12 +0000 Subject: [PATCH 07/14] test(core): keep notification test non-forked by default --- codex-rs/core/tests/suite/subagent_notifications.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/codex-rs/core/tests/suite/subagent_notifications.rs b/codex-rs/core/tests/suite/subagent_notifications.rs index 04ebde856d..85c2347175 100644 --- a/codex-rs/core/tests/suite/subagent_notifications.rs +++ b/codex-rs/core/tests/suite/subagent_notifications.rs @@ -144,6 +144,7 @@ async fn setup_turn_one_with_spawned_child( server, json!({ "message": CHILD_PROMPT, + "fork_context": false, }), child_response_delay, /*wait_for_parent_notification*/ true, From 129882ef6101a110de3d7370097277f3e1067051 Mon Sep 17 00:00:00 2001 From: Friel Date: Wed, 1 Apr 2026 00:07:05 +0000 Subject: [PATCH 08/14] test(core): fix v2 fork_turn payload --- codex-rs/core/src/tools/handlers/multi_agents_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 48def14a5e..9be4f56a57 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -377,7 +377,7 @@ async fn multi_agent_v2_spawn_fork_turns_ignores_child_model_overrides() { Arc::new(turn), "spawn_agent", function_payload(json!({ - "message": "inspect this repo", + "items": [{"type": "text", "text": "inspect this repo"}], "model": "not-a-real-model", "reasoning_effort": "low", "fork_turns": "all", From 2db7fc10347232c7b2a724e98608a823c57c9bc1 Mon Sep 17 00:00:00 2001 From: Friel Date: Wed, 1 Apr 2026 23:18:43 +0000 Subject: [PATCH 09/14] test(core): cover fork_context defaults for discovered roles --- codex-rs/core/src/agent/role.rs | 2 +- codex-rs/core/src/agent/role_tests.rs | 80 ++++++++++++++++++++++++++ codex-rs/tools/src/agent_tool.rs | 8 +-- codex-rs/tools/src/agent_tool_tests.rs | 52 +++++++++++++++++ 4 files changed, 137 insertions(+), 5 deletions(-) diff --git a/codex-rs/core/src/agent/role.rs b/codex-rs/core/src/agent/role.rs index cb738de15a..d12accf62b 100644 --- a/codex-rs/core/src/agent/role.rs +++ b/codex-rs/core/src/agent/role.rs @@ -123,7 +123,7 @@ pub(crate) fn default_fork_context_for_role(config: &Config, role_name: Option<& let role_name = role_name.unwrap_or(DEFAULT_ROLE_NAME); resolve_role_config(config, role_name) .and_then(|role| role.fork_context) - .unwrap_or(false) + .unwrap_or(true) } fn preservation_policy(config: &Config, role_layer_toml: &TomlValue) -> (bool, bool) { diff --git a/codex-rs/core/src/agent/role_tests.rs b/codex-rs/core/src/agent/role_tests.rs index a25aa9750c..77511ed16f 100644 --- a/codex-rs/core/src/agent/role_tests.rs +++ b/codex-rs/core/src/agent/role_tests.rs @@ -72,6 +72,86 @@ async fn apply_role_returns_error_for_unknown_role() { assert_eq!(err, "unknown agent_type 'missing-role'"); } +#[tokio::test] +async fn default_fork_context_for_role_defaults_unspecified_custom_roles_to_true() { + let (_home, mut config) = test_config_with_cli_overrides(Vec::new()).await; + config.agent_roles.insert( + "custom".to_string(), + AgentRoleConfig { + description: Some("Custom role".to_string()), + config_file: None, + nickname_candidates: None, + fork_context: None, + }, + ); + + assert!(default_fork_context_for_role(&config, Some("custom"))); +} + +#[tokio::test] +async fn default_fork_context_for_role_defaults_discovered_role_files_to_true() { + let codex_home = TempDir::new().expect("create temp dir"); + let repo_root = TempDir::new().expect("create temp dir"); + let nested_cwd = repo_root.path().join("packages").join("app"); + fs::create_dir_all(repo_root.path().join(".git")).expect("create git dir"); + fs::create_dir_all(&nested_cwd).expect("create nested cwd"); + + let workspace_key = repo_root.path().to_string_lossy().replace('\\', "\\\\"); + tokio::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + format!( + r#"[projects."{workspace_key}"] +trust_level = "trusted" +"# + ), + ) + .await + .expect("write config"); + + let agents_dir = repo_root.path().join(".codex").join("agents"); + tokio::fs::create_dir_all(&agents_dir) + .await + .expect("create agents dir"); + tokio::fs::write( + agents_dir.join("custom.toml"), + r#" +name = "custom" +description = "Custom role" +developer_instructions = "Stay focused" +"#, + ) + .await + .expect("write role file"); + + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .harness_overrides(ConfigOverrides { + cwd: Some(nested_cwd), + ..Default::default() + }) + .build() + .await + .expect("load config"); + + assert!(default_fork_context_for_role(&config, Some("custom"))); +} + +#[tokio::test] +async fn default_fork_context_for_role_uses_explicit_custom_role_override() { + let (_home, mut config) = test_config_with_cli_overrides(Vec::new()).await; + config.agent_roles.insert( + "custom".to_string(), + AgentRoleConfig { + description: Some("Custom role".to_string()), + config_file: None, + nickname_candidates: None, + fork_context: Some(false), + }, + ); + + assert!(!default_fork_context_for_role(&config, Some("custom"))); +} + #[tokio::test] #[ignore = "No role requiring it for now"] async fn apply_explorer_role_sets_model_and_adds_session_flags_layer() { diff --git a/codex-rs/tools/src/agent_tool.rs b/codex-rs/tools/src/agent_tool.rs index b82084d51d..accfc9ef8f 100644 --- a/codex-rs/tools/src/agent_tool.rs +++ b/codex-rs/tools/src/agent_tool.rs @@ -576,7 +576,7 @@ fn spawn_agent_common_properties_v1(agent_type_description: &str) -> BTreeMap BTreeMap BTreeMap BTreeMap Date: Wed, 1 Apr 2026 23:46:01 +0000 Subject: [PATCH 10/14] test(core): cover TOML fork_context override --- codex-rs/core/src/agent/role_tests.rs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/codex-rs/core/src/agent/role_tests.rs b/codex-rs/core/src/agent/role_tests.rs index 77511ed16f..7562bf68fe 100644 --- a/codex-rs/core/src/agent/role_tests.rs +++ b/codex-rs/core/src/agent/role_tests.rs @@ -152,6 +152,29 @@ async fn default_fork_context_for_role_uses_explicit_custom_role_override() { assert!(!default_fork_context_for_role(&config, Some("custom"))); } +#[tokio::test] +async fn default_fork_context_for_role_uses_explicit_custom_role_override_from_config_toml() { + let home = TempDir::new().expect("create temp dir"); + tokio::fs::write( + home.path().join(CONFIG_TOML_FILE), + r#"[agents.custom] +description = "Custom role" +fork_context = false +"#, + ) + .await + .expect("write config"); + + let config = ConfigBuilder::default() + .codex_home(home.path().to_path_buf()) + .fallback_cwd(Some(home.path().to_path_buf())) + .build() + .await + .expect("load test config"); + + assert!(!default_fork_context_for_role(&config, Some("custom"))); +} + #[tokio::test] #[ignore = "No role requiring it for now"] async fn apply_explorer_role_sets_model_and_adds_session_flags_layer() { From 73c4e6d25577c7db9511ef799716ed44c6bf9bef Mon Sep 17 00:00:00 2001 From: Friel Date: Thu, 2 Apr 2026 00:57:14 +0000 Subject: [PATCH 11/14] fix(core): restore parent fork spawn config --- codex-rs/core/src/codex.rs | 7 ++ codex-rs/core/src/codex_thread.rs | 6 + .../src/tools/handlers/multi_agents/spawn.rs | 3 + .../src/tools/handlers/multi_agents_common.rs | 18 +++ .../src/tools/handlers/multi_agents_tests.rs | 105 +++++++++++++++++- .../tools/handlers/multi_agents_v2/spawn.rs | 3 + .../tests/suite/subagent_notifications.rs | 4 +- 7 files changed, 142 insertions(+), 4 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index f1f008530c..e70aed2aa6 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -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(), } } diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 8b467fc10b..d6af167125 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -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, + pub plan_mode_reasoning_effort: Option, + pub model_verbosity: Option, + pub model_context_window: Option, + pub model_auto_compact_token_limit: Option, 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, pub personality: Option, + pub active_profile: Option, pub session_source: SessionSource, } diff --git a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs index 3b55b76a91..2e66f77856 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs @@ -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); diff --git a/codex-rs/core/src/tools/handlers/multi_agents_common.rs b/codex-rs/core/src/tools/handlers/multi_agents_common.rs index 4778f6b9f8..3f987578a2 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_common.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_common.rs @@ -238,6 +238,24 @@ fn build_agent_shared_config(turn: &TurnContext) -> Result 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] diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs index b8a1f5443e..057ad537ec 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs @@ -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); diff --git a/codex-rs/core/tests/suite/subagent_notifications.rs b/codex-rs/core/tests/suite/subagent_notifications.rs index 85c2347175..72bf00ed23 100644 --- a/codex-rs/core/tests/suite/subagent_notifications.rs +++ b/codex-rs/core/tests/suite/subagent_notifications.rs @@ -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; From f255b673a7968b9554d413d39f4d9aa58b85808b Mon Sep 17 00:00:00 2001 From: Friel Date: Thu, 2 Apr 2026 01:28:36 +0000 Subject: [PATCH 12/14] fix(core): align fork spawn snapshot tests --- codex-rs/app-server/src/codex_message_processor.rs | 5 +++++ codex-rs/core/src/tools/handlers/multi_agents_tests.rs | 4 ---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index c2854e663b..00be9ab52f 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -8831,6 +8831,10 @@ mod tests { model: "gpt-5".to_string(), model_provider_id: "openai".to_string(), service_tier: Some(codex_protocol::config_types::ServiceTier::Flex), + plan_mode_reasoning_effort: None, + model_verbosity: None, + model_context_window: None, + model_auto_compact_token_limit: None, approval_policy: codex_protocol::protocol::AskForApproval::OnRequest, approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer::User, sandbox_policy: codex_protocol::protocol::SandboxPolicy::DangerFullAccess, @@ -8838,6 +8842,7 @@ mod tests { ephemeral: false, reasoning_effort: None, personality: None, + active_profile: None, session_source: SessionSource::Cli, }; diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 2e00f024e1..e70c895473 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -367,7 +367,6 @@ async fn spawn_agent_fork_context_ignores_child_model_overrides() { 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; @@ -408,7 +407,6 @@ async fn spawn_agent_fork_context_ignores_child_model_overrides() { 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); @@ -446,7 +444,6 @@ async fn multi_agent_v2_spawn_fork_turns_ignores_child_model_overrides() { }; 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; @@ -490,7 +487,6 @@ async fn multi_agent_v2_spawn_fork_turns_ignores_child_model_overrides() { 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); From 2f6068c2799a7af922f0f3f59bf0df8f9bf7abb4 Mon Sep 17 00:00:00 2001 From: Friel Date: Thu, 2 Apr 2026 03:28:35 +0000 Subject: [PATCH 13/14] test(core): stabilize spawn_agent fork_context fixtures --- .../core/src/tools/handlers/multi_agents_tests.rs | 15 +++++++-------- .../core/tests/suite/subagent_notifications.rs | 4 +++- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index e70c895473..30c1f5e8e0 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -58,6 +58,7 @@ use std::time::Duration; use tokio::sync::Mutex; use tokio::time::timeout; use tokio_util::sync::CancellationToken; +use tracing_test::traced_test; fn invocation( session: Arc, @@ -95,11 +96,14 @@ 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(); + tokio::fs::create_dir_all(&turn.config.codex_home) + .await + .expect("codex home should be created"); 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_provider = "openai" model_context_window = 12345 model_auto_compact_token_limit = 1234 model_verbosity = "low" @@ -108,19 +112,13 @@ profile = "role-profile" service_tier = "fast" [profiles.role-profile] -model_provider = "role-provider" +model_provider = "openai" "#, ) .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); @@ -355,6 +353,7 @@ async fn spawn_agent_uses_explorer_role_and_preserves_approval_policy() { } #[tokio::test] +#[traced_test] async fn spawn_agent_fork_context_ignores_child_model_overrides() { let (mut session, mut turn) = make_session_and_context().await; let role_name = install_role_with_model_provider_and_profile_override(&mut turn).await; diff --git a/codex-rs/core/tests/suite/subagent_notifications.rs b/codex-rs/core/tests/suite/subagent_notifications.rs index 72bf00ed23..919eda2e48 100644 --- a/codex-rs/core/tests/suite/subagent_notifications.rs +++ b/codex-rs/core/tests/suite/subagent_notifications.rs @@ -442,7 +442,8 @@ async fn spawn_agent_inherits_parent_model_and_reasoning_without_role() -> Resul } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn spawn_agent_role_overrides_requested_model_and_reasoning_settings() -> Result<()> { +async fn spawn_agent_role_overrides_requested_model_and_reasoning_settings_without_fork_context() +-> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; @@ -453,6 +454,7 @@ async fn spawn_agent_role_overrides_requested_model_and_reasoning_settings() -> "agent_type": "custom", "model": REQUESTED_MODEL, "reasoning_effort": REQUESTED_REASONING_EFFORT, + "fork_context": false, }), |builder| { builder.with_config(|config| { From 2880de19033bd139945c0d29d271a5336b40699d Mon Sep 17 00:00:00 2001 From: Friel Date: Thu, 2 Apr 2026 04:17:36 +0000 Subject: [PATCH 14/14] test(app-server): expect fork-context spawn to inherit parent model --- codex-rs/app-server/tests/suite/v2/turn_start.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index e50ee65f76..eee866dcdf 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1871,6 +1871,11 @@ async fn turn_start_emits_spawn_agent_item_with_effective_role_model_metadata_v2 const CHILD_PROMPT: &str = "child: do work"; const PARENT_PROMPT: &str = "spawn a child and continue"; const SPAWN_CALL_ID: &str = "spawn-call-1"; + const INHERITED_MODEL: &str = "gpt-5.2-codex"; + // thread/start only sets the model here; the session keeps the resolved collaboration-mode + // effort for that thread, so fork-context children should inherit `Medium`, not the model + // catalog default. + const INHERITED_REASONING_EFFORT: ReasoningEffort = ReasoningEffort::Medium; const REQUESTED_MODEL: &str = "gpt-5.1"; const REQUESTED_REASONING_EFFORT: ReasoningEffort = ReasoningEffort::Low; const ROLE_MODEL: &str = "gpt-5.1-codex-max"; @@ -2013,8 +2018,8 @@ config_file = "./custom-role.toml" assert_eq!(sender_thread_id, thread.id); assert_eq!(receiver_thread_ids, vec![receiver_thread_id.clone()]); assert_eq!(prompt, Some(CHILD_PROMPT.to_string())); - assert_eq!(model, Some(ROLE_MODEL.to_string())); - assert_eq!(reasoning_effort, Some(ROLE_REASONING_EFFORT)); + assert_eq!(model, Some(INHERITED_MODEL.to_string())); + assert_eq!(reasoning_effort, Some(INHERITED_REASONING_EFFORT)); let agent_state = agents_states .get(&receiver_thread_id) .expect("spawn completion should include child agent state");