From 6891b76417f6a4fa7e81dccca8eff83c9686f12d Mon Sep 17 00:00:00 2001 From: Friel Date: Fri, 27 Mar 2026 16:29:37 -0700 Subject: [PATCH 1/8] 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 36294c49c6..5f993f95b4 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 c710a6e219..0fb82a53db 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 76285f631fb02836f6451e73bcff04e4d731e851 Mon Sep 17 00:00:00 2001 From: Friel Date: Fri, 27 Mar 2026 16:57:22 -0700 Subject: [PATCH 2/8] 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 5f993f95b4..99dd86d198 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 ba8a6d9ae870867f6ce5fce1a7c173a099c4f678 Mon Sep 17 00:00:00 2001 From: Friel Date: Sun, 29 Mar 2026 17:56:12 +0000 Subject: [PATCH 3/8] 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 99dd86d198..ef141b30b7 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 0fb82a53db..a0294b3b25 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(/*message*/ None, Some(args.items))?; 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 ac7c881177ce34b7c26e4c1bdd455fa04967c8c9 Mon Sep 17 00:00:00 2001 From: Friel Date: Mon, 30 Mar 2026 03:07:31 +0000 Subject: [PATCH 4/8] 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 ef141b30b7..6b8f800d41 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( @@ -1620,7 +1630,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(), @@ -1731,13 +1746,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 c9c01679b59b061d2d3b5247b4ac5333dea55cba Mon Sep 17 00:00:00 2001 From: Friel Date: Mon, 30 Mar 2026 04:22:19 +0000 Subject: [PATCH 5/8] 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 6/8] 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 52d43c4b5f6a766a66425fc83cac0cf533ae07ba Mon Sep 17 00:00:00 2001 From: Friel Date: Mon, 30 Mar 2026 06:09:12 +0000 Subject: [PATCH 7/8] 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 3379d411c407c43828fdf6bc337c1ac9080bde0c Mon Sep 17 00:00:00 2001 From: Friel Date: Wed, 1 Apr 2026 00:07:05 +0000 Subject: [PATCH 8/8] 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 6b8f800d41..9f57674552 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",