diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index 0709a267da..3ac2fa3c3a 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -688,9 +688,9 @@ pub struct AgentsToml { pub max_concurrent_threads_per_session: Option, /// Maximum nesting depth for V1 agent threads. Ignored by V2. pub max_depth: Option, - /// Reserved default model for spawned subagents. Currently ignored. + /// Default model for spawned subagents when the spawn call does not select one. pub default_subagent_model: Option, - /// Reserved default reasoning effort for spawned subagents. Currently ignored. + /// Default reasoning effort for spawned subagents when the spawn call does not select one. pub default_subagent_reasoning_effort: Option, /// Default maximum runtime in seconds for agent job workers. #[schemars(range(min = 1))] diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 7b09c84e78..0666e065c6 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -37,7 +37,7 @@ }, "properties": { "default_subagent_model": { - "description": "Reserved default model for spawned subagents. Currently ignored.", + "description": "Default model for spawned subagents when the spawn call does not select one.", "type": "string" }, "default_subagent_reasoning_effort": { @@ -46,7 +46,7 @@ "$ref": "#/definitions/ReasoningEffort" } ], - "description": "Reserved default reasoning effort for spawned subagents. Currently ignored." + "description": "Default reasoning effort for spawned subagents when the spawn call does not select one." }, "enabled": { "description": "Whether multi-agent tools are enabled. Defaults to true. An enabled `features.multi_agent_v2` setting takes precedence.", diff --git a/codex-rs/core/src/agent/role.rs b/codex-rs/core/src/agent/role.rs index 156ace1f39..3fc20a33ae 100644 --- a/codex-rs/core/src/agent/role.rs +++ b/codex-rs/core/src/agent/role.rs @@ -2,9 +2,9 @@ //! //! Roles are selected at spawn time and are loaded with the same config machinery as //! `config.toml`. This module resolves built-in and user-defined role files, inserts the role as a -//! high-precedence layer, and preserves the caller's current provider and service tier unless the -//! role layer sets them. It does not decide when to spawn a sub-agent or which role to use; the -//! multi-agent tool handler owns that orchestration. +//! high-precedence layer, and preserves the caller's current model, reasoning effort, provider, +//! and service tier unless the role layer sets them. It does not decide when to spawn a sub-agent +//! or which role to use; the multi-agent tool handler owns that orchestration. use crate::config::AgentRoleConfig; use crate::config::Config; @@ -135,14 +135,18 @@ mod reload { preserve_current_provider: bool, preserve_current_service_tier: bool, ) -> anyhow::Result { + let preserve_current_model = role_layer_toml.get("model").is_none(); + let preserve_current_reasoning_effort = + role_layer_toml.get("model_reasoning_effort").is_none(); let config_layer_stack = build_config_layer_stack(config, &role_layer_toml)?; let merged_config = deserialize_effective_config(config, &config_layer_stack)?; - let next_config = Config::load_config_with_layer_stack( + let mut next_config = Config::load_config_with_layer_stack( LOCAL_FS.as_ref(), merged_config, reload_overrides( config, + preserve_current_model, preserve_current_provider, preserve_current_service_tier, ), @@ -150,6 +154,11 @@ mod reload { config_layer_stack, ) .await?; + if preserve_current_reasoning_effort { + next_config + .model_reasoning_effort + .clone_from(&config.model_reasoning_effort); + } Ok(next_config) } @@ -200,11 +209,15 @@ mod reload { fn reload_overrides( config: &Config, + preserve_current_model: bool, preserve_current_provider: bool, preserve_current_service_tier: bool, ) -> ConfigOverrides { ConfigOverrides { cwd: Some(config.cwd.to_path_buf()), + model: preserve_current_model + .then(|| config.model.clone()) + .flatten(), model_provider: preserve_current_provider.then(|| config.model_provider_id.clone()), service_tier: preserve_current_service_tier.then(|| config.service_tier.clone()), codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(), diff --git a/codex-rs/core/src/agent/role_tests.rs b/codex-rs/core/src/agent/role_tests.rs index b56531c90c..624b8b9ba1 100644 --- a/codex-rs/core/src/agent/role_tests.rs +++ b/codex-rs/core/src/agent/role_tests.rs @@ -183,8 +183,8 @@ async fn apply_role_preserves_unspecified_keys() { config.main_execve_wrapper_exe = Some(PathBuf::from("/tmp/codex-execve-wrapper")); let role_path = write_role_config( &home, - "effort-only.toml", - "developer_instructions = \"Stay focused\"\nmodel_reasoning_effort = \"high\"", + "instructions-only.toml", + "developer_instructions = \"Stay focused\"", ) .await; config.agent_roles.insert( @@ -196,12 +196,17 @@ async fn apply_role_preserves_unspecified_keys() { }, ); + config.model = Some("spawn-model".to_string()); + config.model_reasoning_effort = Some(ReasoningEffort::Low); + apply_role_to_config(&mut config, Some("custom")) .await .expect("custom role should apply"); - assert_eq!(config.model.as_deref(), Some("base-model")); - assert_eq!(config.model_reasoning_effort, Some(ReasoningEffort::High)); + assert_eq!( + (config.model.as_deref(), config.model_reasoning_effort), + (Some("spawn-model"), Some(ReasoningEffort::Low)), + ); assert_eq!( config.codex_linux_sandbox_exe, Some(PathBuf::from("/tmp/codex-linux-sandbox")) diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 5a640aef13..8239c02c6d 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -863,10 +863,10 @@ pub struct Config { /// User-configured maximum number of spawned agent threads per session. pub agent_max_threads: Option, - /// Reserved default model for spawned subagents. + /// Default model for spawned subagents when the spawn call does not select one. pub agent_default_subagent_model: Option, - /// Reserved default reasoning effort for spawned subagents. + /// Default reasoning effort for spawned subagents when the spawn call does not select one. pub agent_default_subagent_reasoning_effort: Option, /// Maximum runtime in seconds for agent job workers before they are failed. diff --git a/codex-rs/core/src/tools/handlers/agent_jobs.rs b/codex-rs/core/src/tools/handlers/agent_jobs.rs index 6405414731..2398889691 100644 --- a/codex-rs/core/src/tools/handlers/agent_jobs.rs +++ b/codex-rs/core/src/tools/handlers/agent_jobs.rs @@ -5,6 +5,7 @@ use crate::function_tool::FunctionCallError; use crate::session::session::Session; use crate::session::turn_context::TurnContext; use crate::tools::handlers::multi_agents::build_agent_spawn_config; +use crate::tools::handlers::multi_agents_common::apply_requested_spawn_agent_model_overrides; use crate::tools::handlers::parse_arguments; use codex_protocol::ThreadId; use codex_protocol::error::CodexErr; @@ -124,7 +125,15 @@ async fn build_runner_options( } let max_concurrency = normalize_concurrency(requested_concurrency, agent_max_threads); let base_instructions = session.get_base_instructions().await; - let spawn_config = build_agent_spawn_config(&base_instructions, turn.as_ref())?; + let mut spawn_config = build_agent_spawn_config(&base_instructions, turn.as_ref())?; + apply_requested_spawn_agent_model_overrides( + session, + turn, + &mut spawn_config, + /*requested_model*/ None, + /*requested_reasoning_effort*/ None, + ) + .await?; Ok(JobRunnerOptions { max_concurrency, spawn_config, 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 e5923bab79..24983cb083 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs @@ -92,20 +92,17 @@ async fn handle_spawn_agent( config.service_tier = Some(service_tier.clone()); } if args.fork_context { - reject_full_fork_spawn_overrides( - role_name, - args.model.as_deref(), - args.reasoning_effort.clone(), - )?; - } else { - apply_requested_spawn_agent_model_overrides( - &session, - turn.as_ref(), - &mut config, - args.model.as_deref(), - args.reasoning_effort.clone(), - ) - .await?; + reject_full_fork_agent_type_override(role_name)?; + } + apply_requested_spawn_agent_model_overrides( + &session, + turn.as_ref(), + &mut config, + args.model.as_deref(), + args.reasoning_effort.clone(), + ) + .await?; + if !args.fork_context { apply_role_to_config(&mut config, role_name) .await .map_err(FunctionCallError::RespondToModel)?; 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 0d50798d07..7c6f2f349d 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_common.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_common.rs @@ -201,14 +201,12 @@ fn build_agent_shared_config(turn: &TurnContext) -> Result, - model: Option<&str>, - reasoning_effort: Option, ) -> Result<(), FunctionCallError> { - if agent_type.is_some() || model.is_some() || reasoning_effort.is_some() { + if agent_type.is_some() { return Err(FunctionCallError::RespondToModel( - "Full-history forked agents inherit the parent agent type, model, and reasoning effort; omit agent_type, model, and reasoning_effort, or spawn without a full-history fork.".to_string(), + "Full-history forked agents inherit the parent agent type; omit agent_type, or spawn without a full-history fork.".to_string(), )); } Ok(()) @@ -249,6 +247,9 @@ pub(crate) async fn apply_requested_spawn_agent_model_overrides( requested_model: Option<&str>, requested_reasoning_effort: Option, ) -> Result<(), FunctionCallError> { + let requested_model = requested_model.or(turn.config.agent_default_subagent_model.as_deref()); + let requested_reasoning_effort = requested_reasoning_effort + .or_else(|| turn.config.agent_default_subagent_reasoning_effort.clone()); if requested_model.is_none() && requested_reasoning_effort.is_none() { return Ok(()); } 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 d7215f06a8..55a24254da 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -339,42 +339,7 @@ async fn spawn_agent_fork_context_rejects_agent_type_override() { assert_eq!( err, FunctionCallError::RespondToModel( - "Full-history forked agents inherit the parent agent type, model, and reasoning effort; omit agent_type, model, and reasoning_effort, or spawn without a full-history fork.".to_string(), - ) - ); -} - -#[tokio::test] -async fn spawn_agent_fork_context_rejects_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.thread_id = root.thread_id; - - let err = SpawnAgentHandler::default() - .handle(invocation( - Arc::new(session), - Arc::new(turn), - "spawn_agent", - function_payload(json!({ - "message": "inspect this repo", - "model": "gpt-5-child-override", - "reasoning_effort": "low", - "fork_context": true - })), - )) - .await - .err() - .expect("forked spawn should reject child model overrides"); - - assert_eq!( - err, - FunctionCallError::RespondToModel( - "Full-history forked agents inherit the parent agent type, model, and reasoning effort; omit agent_type, model, and reasoning_effort, or spawn without a full-history fork.".to_string(), + "Full-history forked agents inherit the parent agent type; omit agent_type, or spawn without a full-history fork.".to_string(), ) ); } @@ -420,48 +385,7 @@ async fn multi_agent_v2_spawn_fork_turns_all_rejects_agent_type_override() { assert_eq!( err, FunctionCallError::RespondToModel( - "Full-history forked agents inherit the parent agent type, model, and reasoning effort; omit agent_type, model, and reasoning_effort, or spawn without a full-history fork.".to_string(), - ) - ); -} - -#[tokio::test] -async fn multi_agent_v2_spawn_defaults_to_full_fork_and_rejects_child_model_overrides() { - 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.thread_id = root.thread_id; - let mut config = (*turn.config).clone(); - config - .features - .enable(Feature::MultiAgentV2) - .expect("test config should allow feature update"); - set_turn_config(&mut turn, config); - - let err = SpawnAgentHandlerV2::default() - .handle(invocation( - Arc::new(session), - Arc::new(turn), - "spawn_agent", - function_payload(json!({ - "message": "inspect this repo", - "task_name": "fork_context_v2", - "model": "gpt-5-child-override", - "reasoning_effort": "low" - })), - )) - .await - .err() - .expect("default full fork should reject child model overrides"); - - assert_eq!( - err, - FunctionCallError::RespondToModel( - "Full-history forked agents inherit the parent agent type, model, and reasoning effort; omit agent_type, model, and reasoning_effort, or spawn without a full-history fork.".to_string(), + "Full-history forked agents inherit the parent agent type; omit agent_type, or spawn without a full-history fork.".to_string(), ) ); } 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 840a5d57b0..2db21f27fe 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 @@ -61,26 +61,22 @@ async fn handle_spawn_agent( let child_depth = next_thread_spawn_depth(&session_source); let mut config = build_agent_spawn_config(&session.get_base_instructions().await, turn.as_ref())?; - // TODO(jif): Apply `agents.default_subagent_model` as the default model here. - // TODO(jif): Apply `agents.default_subagent_reasoning_effort` as the default effort here. if let Some(service_tier) = args.service_tier.as_ref() { config.service_tier = Some(service_tier.clone()); } - if matches!(fork_mode, Some(SpawnAgentForkMode::FullHistory)) { - reject_full_fork_spawn_overrides( - role_name, - args.model.as_deref(), - args.reasoning_effort.clone(), - )?; - } else { - apply_requested_spawn_agent_model_overrides( - &session, - turn.as_ref(), - &mut config, - args.model.as_deref(), - args.reasoning_effort.clone(), - ) - .await?; + let is_full_history_fork = matches!(fork_mode, Some(SpawnAgentForkMode::FullHistory)); + if is_full_history_fork { + reject_full_fork_agent_type_override(role_name)?; + } + apply_requested_spawn_agent_model_overrides( + &session, + turn.as_ref(), + &mut config, + args.model.as_deref(), + args.reasoning_effort.clone(), + ) + .await?; + if !is_full_history_fork { apply_role_to_config(&mut config, role_name) .await .map_err(FunctionCallError::RespondToModel)?; diff --git a/codex-rs/core/tests/suite/agent_jobs.rs b/codex-rs/core/tests/suite/agent_jobs.rs index 252b473077..3601cd9d7f 100644 --- a/codex-rs/core/tests/suite/agent_jobs.rs +++ b/codex-rs/core/tests/suite/agent_jobs.rs @@ -1,5 +1,6 @@ use anyhow::Result; use codex_features::Feature; +use codex_protocol::openai_models::ReasoningEffort; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_function_call; use core_test_support::responses::ev_response_created; @@ -7,6 +8,7 @@ use core_test_support::responses::sse; use core_test_support::responses::sse_response; use core_test_support::responses::start_mock_server; use core_test_support::test_codex::test_codex; +use pretty_assertions::assert_eq; use regex_lite::Regex; use serde_json::Value; use serde_json::json; @@ -292,6 +294,8 @@ async fn spawn_agents_on_csv_runs_and_exports() -> Result<()> { .features .enable(Feature::Sqlite) .expect("test config should allow feature update"); + config.agent_default_subagent_model = Some("gpt-5.4".to_string()); + config.agent_default_subagent_reasoning_effort = Some(ReasoningEffort::High); }); let test = builder.build(&server).await?; @@ -319,6 +323,27 @@ async fn spawn_agents_on_csv_runs_and_exports() -> Result<()> { assert!(output.contains("result_json")); assert!(output.contains("item_id")); assert!(output.contains("\"item_id\"")); + let worker_settings = server + .received_requests() + .await + .unwrap_or_default() + .into_iter() + .filter_map(|request| { + let body = serde_json::from_slice::(&decode_body_bytes(&request)).ok()?; + if has_function_call_output(&body) { + return None; + } + extract_job_and_item(&body)?; + Some(( + body["model"].as_str().map(str::to_string), + body["reasoning"]["effort"].as_str().map(str::to_string), + )) + }) + .collect::>(); + assert_eq!( + worker_settings, + vec![(Some("gpt-5.4".to_string()), Some("high".to_string())); 2] + ); Ok(()) } diff --git a/codex-rs/core/tests/suite/subagent_notifications.rs b/codex-rs/core/tests/suite/subagent_notifications.rs index 338d67fef9..ef62ade72f 100644 --- a/codex-rs/core/tests/suite/subagent_notifications.rs +++ b/codex-rs/core/tests/suite/subagent_notifications.rs @@ -62,6 +62,10 @@ const INHERITED_MODEL: &str = "gpt-5.2"; const INHERITED_REASONING_EFFORT: ReasoningEffort = ReasoningEffort::XHigh; const REQUESTED_MODEL: &str = "gpt-5.4"; const REQUESTED_REASONING_EFFORT: ReasoningEffort = ReasoningEffort::Low; +const V2_DEFAULT_MODEL: &str = "gpt-5.6-terra"; +const V2_DEFAULT_REASONING_EFFORT: ReasoningEffort = ReasoningEffort::High; +const V2_REQUESTED_MODEL: &str = "gpt-5.6-sol"; +const V2_REQUESTED_REASONING_EFFORT: ReasoningEffort = ReasoningEffort::Low; const ROLE_MODEL: &str = "gpt-5.4"; const ROLE_REASONING_EFFORT: ReasoningEffort = ReasoningEffort::High; const SUBAGENT_START_CONTEXT: &str = "subagent start context reaches child"; @@ -355,6 +359,26 @@ async fn wait_for_requests( } } +async fn wait_for_request_with_model( + mock: &core_test_support::responses::ResponseMock, + model: &str, +) -> Result { + let deadline = Instant::now() + Duration::from_secs(2); + loop { + if let Some(request) = mock + .requests() + .into_iter() + .find(|request| request.body_json()["model"] == model) + { + return Ok(request); + } + if Instant::now() >= deadline { + anyhow::bail!("timed out waiting for request using model {model}"); + } + sleep(Duration::from_millis(10)).await; + } +} + async fn setup_turn_one_with_spawned_child( server: &MockServer, child_response_delay: Option, @@ -900,9 +924,11 @@ async fn spawned_child_receives_forked_parent_context( ) .await; - let _child_request_log = mount_sse_once_match( + let child_request_log = mount_sse_once_match( &server, - |req: &wiremock::Request| body_contains(req, CHILD_PROMPT), + |req: &wiremock::Request| { + body_contains(req, CHILD_PROMPT) && !body_contains(req, SPAWN_CALL_ID) + }, sse(vec![ ev_response_created("resp-child-1"), ev_assistant_message("msg-child-1", "child done"), @@ -929,6 +955,10 @@ async fn spawned_child_receives_forked_parent_context( .features .enable(Feature::Collab) .expect("test config should allow feature update"); + config.model = Some(INHERITED_MODEL.to_string()); + config.model_reasoning_effort = Some(INHERITED_REASONING_EFFORT); + config.agent_default_subagent_model = Some(REQUESTED_MODEL.to_string()); + config.agent_default_subagent_reasoning_effort = Some(REQUESTED_REASONING_EFFORT); }); let test = builder.build(&server).await?; @@ -938,26 +968,141 @@ async fn spawned_child_receives_forked_parent_context( test.submit_turn(TURN_1_PROMPT).await?; let _ = spawn_turn.single_request(); - let deadline = Instant::now() + Duration::from_secs(2); - let child_request = loop { - if let Some(request) = server - .received_requests() - .await - .unwrap_or_default() - .into_iter() - .find(|request| { - body_contains(request, CHILD_PROMPT) && !body_contains(request, SPAWN_CALL_ID) - }) - { - break request; - } - if Instant::now() >= deadline { - anyhow::bail!("timed out waiting for forked child request"); - } - sleep(Duration::from_millis(10)).await; + let child_request = wait_for_request_with_model(&child_request_log, REQUESTED_MODEL).await?; + assert!(child_request.body_contains_text(TURN_0_FORK_PROMPT)); + let child_body = child_request.body_json(); + assert_eq!( + ( + child_body["model"].clone(), + child_body["reasoning"]["effort"].clone(), + ), + ( + json!(REQUESTED_MODEL), + json!(REQUESTED_REASONING_EFFORT.to_string()), + ) + ); + + Ok(()) +} + +#[derive(Clone, Copy)] +enum FullHistoryV2ModelSelection { + ConfiguredDefault, + ExplicitOverride, +} + +#[test_case(FullHistoryV2ModelSelection::ConfiguredDefault; "configured default with omitted fork_turns")] +#[test_case(FullHistoryV2ModelSelection::ExplicitOverride; "explicit override with fork_turns all")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn spawned_full_history_v2_child_uses_model_precedence_without_dropping_context( + selection: FullHistoryV2ModelSelection, +) -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let seed_turn = mount_sse_once_match( + &server, + |req: &wiremock::Request| body_contains(req, TURN_0_FORK_PROMPT), + sse(vec![ + ev_response_created("resp-seed-1"), + ev_assistant_message("msg-seed-1", "seeded"), + ev_completed("resp-seed-1"), + ]), + ) + .await; + let (spawn_args, expected_model, expected_reasoning_effort) = match selection { + FullHistoryV2ModelSelection::ConfiguredDefault => ( + json!({ + "message": CHILD_PROMPT, + "task_name": "worker", + }), + V2_DEFAULT_MODEL, + V2_DEFAULT_REASONING_EFFORT, + ), + FullHistoryV2ModelSelection::ExplicitOverride => ( + json!({ + "message": CHILD_PROMPT, + "task_name": "worker", + "fork_turns": "all", + "model": V2_REQUESTED_MODEL, + "reasoning_effort": V2_REQUESTED_REASONING_EFFORT, + }), + V2_REQUESTED_MODEL, + V2_REQUESTED_REASONING_EFFORT, + ), }; - assert!(body_contains(&child_request, TURN_0_FORK_PROMPT)); - assert!(!body_contains(&child_request, SPAWN_CALL_ID)); + let spawn_args = serde_json::to_string(&spawn_args)?; + let spawn_turn = mount_sse_once_match( + &server, + |req: &wiremock::Request| body_contains(req, TURN_1_PROMPT), + sse(vec![ + ev_response_created("resp-turn1-1"), + ev_function_call_with_namespace( + SPAWN_CALL_ID, + MULTI_AGENT_V2_NAMESPACE, + "spawn_agent", + &spawn_args, + ), + ev_completed("resp-turn1-1"), + ]), + ) + .await; + let child_request_log = mount_sse_once_match( + &server, + |req: &wiremock::Request| { + body_contains(req, CHILD_PROMPT) && !body_contains(req, SPAWN_CALL_ID) + }, + sse(vec![ + ev_response_created("resp-child-1"), + ev_assistant_message("msg-child-1", "child done"), + ev_completed("resp-child-1"), + ]), + ) + .await; + let _turn1_followup = mount_sse_once_match( + &server, + |req: &wiremock::Request| body_contains(req, SPAWN_CALL_ID), + sse(vec![ + ev_response_created("resp-turn1-2"), + ev_assistant_message("msg-turn1-2", "parent done"), + ev_completed("resp-turn1-2"), + ]), + ) + .await; + let mut builder = test_codex().with_config(|config| { + config + .features + .enable(Feature::Collab) + .expect("test config should allow feature update"); + config + .features + .enable(Feature::MultiAgentV2) + .expect("test config should allow feature update"); + config.model = Some(INHERITED_MODEL.to_string()); + config.model_reasoning_effort = Some(INHERITED_REASONING_EFFORT); + config.agent_default_subagent_model = Some(V2_DEFAULT_MODEL.to_string()); + config.agent_default_subagent_reasoning_effort = Some(V2_DEFAULT_REASONING_EFFORT); + }); + let test = builder.build(&server).await?; + + test.submit_turn(TURN_0_FORK_PROMPT).await?; + let _ = seed_turn.single_request(); + test.submit_turn(TURN_1_PROMPT).await?; + let _ = spawn_turn.single_request(); + + let child_request = wait_for_request_with_model(&child_request_log, expected_model).await?; + assert!(child_request.body_contains_text(TURN_0_FORK_PROMPT)); + let child_body = child_request.body_json(); + assert_eq!( + ( + child_body["model"].clone(), + child_body["reasoning"]["effort"].clone(), + ), + ( + json!(expected_model), + json!(expected_reasoning_effort.to_string()), + ) + ); Ok(()) } @@ -975,7 +1120,12 @@ async fn spawn_agent_requested_model_and_reasoning_override_inherited_settings_w "model": REQUESTED_MODEL, "reasoning_effort": REQUESTED_REASONING_EFFORT, }), - |builder| builder, + |builder| { + builder.with_config(|config| { + config.agent_default_subagent_model = Some(INHERITED_MODEL.to_string()); + config.agent_default_subagent_reasoning_effort = Some(ReasoningEffort::High); + }) + }, ) .await?; @@ -988,6 +1138,30 @@ async fn spawn_agent_requested_model_and_reasoning_override_inherited_settings_w Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn spawn_agent_uses_configured_subagent_defaults() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let child_snapshot = + spawn_child_and_capture_snapshot(&server, json!({ "message": CHILD_PROMPT }), |builder| { + builder.with_config(|config| { + config.agent_default_subagent_model = Some(REQUESTED_MODEL.to_string()); + config.agent_default_subagent_reasoning_effort = Some(REQUESTED_REASONING_EFFORT); + }) + }) + .await?; + + assert_eq!( + (child_snapshot.model, child_snapshot.reasoning_effort), + ( + REQUESTED_MODEL.to_string(), + Some(REQUESTED_REASONING_EFFORT) + ) + ); + Ok(()) +} + #[test_case(true, false; "unsupported child")] #[test_case(false, true; "supported child")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] diff --git a/codex-rs/tui/src/debug_config.rs b/codex-rs/tui/src/debug_config.rs index a74fba53b5..800922aa70 100644 --- a/codex-rs/tui/src/debug_config.rs +++ b/codex-rs/tui/src/debug_config.rs @@ -71,12 +71,12 @@ fn render_agents_config_lines(config: &Config) -> Vec> { ) .into(), format!( - " - default_subagent_model = {} (not yet wired)", + " - default_subagent_model = {}", format_optional(config.agent_default_subagent_model.as_deref()) ) .into(), format!( - " - default_subagent_reasoning_effort = {} (not yet wired)", + " - default_subagent_reasoning_effort = {}", format_optional(config.agent_default_subagent_reasoning_effort.as_ref()) ) .into(), diff --git a/codex-rs/tui/src/snapshots/codex_tui__debug_config__tests__debug_config_output_lists_agents_fields.snap b/codex-rs/tui/src/snapshots/codex_tui__debug_config__tests__debug_config_output_lists_agents_fields.snap index 5aaff27d21..d0e2f25471 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__debug_config__tests__debug_config_output_lists_agents_fields.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__debug_config__tests__debug_config_output_lists_agents_fields.snap @@ -8,7 +8,7 @@ expression: render_to_text(&render_agents_config_lines(&config)) - enabled = false - max_concurrent_threads_per_session = 7 - max_depth = -2 (V1 only; ignored by V2) - - default_subagent_model = gpt-5.6-terra (not yet wired) - - default_subagent_reasoning_effort = high (not yet wired) + - default_subagent_model = gpt-5.6-terra + - default_subagent_reasoning_effort = high - job_max_runtime_seconds = 900 - interrupt_message = false