use anyhow::Result; use codex_core::ThreadConfigSnapshot; use codex_core::config::AgentRoleConfig; use codex_features::Feature; use codex_protocol::ThreadId; use codex_protocol::openai_models::ReasoningEffort; use core_test_support::responses::ResponsesRequest; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_function_call_with_namespace; use core_test_support::responses::ev_response_created; use core_test_support::responses::ev_tool_search_call; use core_test_support::responses::mount_response_once_match; use core_test_support::responses::mount_sse_once_match; use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::namespace_child_tool; 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::skip_if_no_network; use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::test_codex; use pretty_assertions::assert_eq; use serde_json::Value; use serde_json::json; use std::fs; use std::path::Path; use std::time::Duration; use tokio::time::Instant; use tokio::time::sleep; use wiremock::MockServer; const SPAWN_CALL_ID: &str = "spawn-call-1"; const MULTI_AGENT_V1_NAMESPACE: &str = "multi_agent_v1"; const TURN_0_FORK_PROMPT: &str = "seed fork context"; 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.3-codex"; const INHERITED_REASONING_EFFORT: ReasoningEffort = ReasoningEffort::XHigh; const REQUESTED_MODEL: &str = "gpt-5.4"; const 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"; fn body_contains(req: &wiremock::Request, text: &str) -> bool { let is_zstd = req .headers .get("content-encoding") .and_then(|value| value.to_str().ok()) .is_some_and(|value| { value .split(',') .any(|entry| entry.trim().eq_ignore_ascii_case("zstd")) }); let bytes = if is_zstd { zstd::stream::decode_all(std::io::Cursor::new(&req.body)).ok() } else { Some(req.body.clone()) }; bytes .and_then(|body| String::from_utf8(body).ok()) .is_some_and(|body| body.contains(text)) } fn has_subagent_notification(req: &ResponsesRequest) -> bool { req.message_input_texts("user") .iter() .any(|text| text.contains("")) } fn tool_parameter_description(tool: &Value, parameter_name: &str) -> Option { tool.get("parameters") .and_then(|parameters| parameters.get("properties")) .and_then(|properties| properties.get(parameter_name)) .and_then(|parameter| parameter.get("description")) .and_then(Value::as_str) .map(str::to_owned) } fn role_block(description: &str, role_name: &str) -> Option { let role_header = format!("{role_name}: {{"); let mut lines = description.lines().skip_while(|line| *line != role_header); let first_line = lines.next()?; let mut block = vec![first_line]; for line in lines { if line.ends_with(": {") { break; } block.push(line); } Some(block.join("\n")) } fn write_home_skill(codex_home: &Path, dir: &str, name: &str, description: &str) -> Result<()> { let skill_dir = codex_home.join("skills").join(dir); fs::create_dir_all(&skill_dir)?; let contents = format!("---\nname: {name}\ndescription: {description}\n---\n\n# Body\n"); fs::write(skill_dir.join("SKILL.md"), contents)?; Ok(()) } fn write_subagent_start_hooks(home: &Path) -> Result<()> { let session_start_script_path = home.join("session_start_hook.py"); let session_start_log_path = home.join("session_start_hook_log.jsonl"); let session_start_script = format!( r#"import json from pathlib import Path import sys log_path = Path(r"{session_start_log_path}") payload = json.load(sys.stdin) with log_path.open("a", encoding="utf-8") as handle: handle.write(json.dumps(payload) + "\n") "#, session_start_log_path = session_start_log_path.display(), ); let start_script_path = home.join("subagent_start_hook.py"); let start_log_path = home.join("subagent_start_hook_log.jsonl"); let start_script = format!( r#"import json from pathlib import Path import sys log_path = Path(r"{start_log_path}") payload = json.load(sys.stdin) with log_path.open("a", encoding="utf-8") as handle: handle.write(json.dumps(payload) + "\n") print(json.dumps({{"hookSpecificOutput": {{"hookEventName": "SubagentStart", "additionalContext": {SUBAGENT_START_CONTEXT:?}}}}})) "#, start_log_path = start_log_path.display(), ); let hooks = serde_json::json!({ "hooks": { "SessionStart": [{ "matcher": "startup", "hooks": [{ "type": "command", "command": format!("python3 {}", session_start_script_path.display()), }] }], "SubagentStart": [{ "matcher": "worker", "hooks": [{ "type": "command", "command": format!("python3 {}", start_script_path.display()), }] }] } }); fs::write(&session_start_script_path, session_start_script)?; fs::write(&start_script_path, start_script)?; fs::write(home.join("hooks.json"), hooks.to_string())?; Ok(()) } fn read_hook_log(home: &Path, filename: &str) -> Result> { let path = home.join(filename); if !path.exists() { return Ok(Vec::new()); } fs::read_to_string(path)? .lines() .filter(|line| !line.trim().is_empty()) .map(|line| serde_json::from_str(line).map_err(Into::into)) .collect() } async fn wait_for_hook_log( home: &Path, filename: &str, expected_len: usize, ) -> Result> { let deadline = Instant::now() + Duration::from_secs(2); loop { let inputs = read_hook_log(home, filename)?; if inputs.len() >= expected_len { return Ok(inputs); } if Instant::now() >= deadline { anyhow::bail!( "expected at least {expected_len} entries in {filename}, got {}", inputs.len() ); } sleep(Duration::from_millis(10)).await; } } async fn wait_for_spawned_thread_id(test: &TestCodex) -> Result { let deadline = Instant::now() + Duration::from_secs(2); loop { let ids = test.thread_manager.list_thread_ids().await; if let Some(spawned_id) = ids .iter() .find(|id| **id != test.session_configured.thread_id) { return Ok(spawned_id.to_string()); } if Instant::now() >= deadline { anyhow::bail!("timed out waiting for spawned thread id"); } sleep(Duration::from_millis(10)).await; } } async fn wait_for_requests( mock: &core_test_support::responses::ResponseMock, ) -> Result> { let deadline = Instant::now() + Duration::from_secs(2); loop { let requests = mock.requests(); if !requests.is_empty() { return Ok(requests); } if Instant::now() >= deadline { anyhow::bail!("expected at least 1 request, got {}", requests.len()); } sleep(Duration::from_millis(10)).await; } } async fn setup_turn_one_with_spawned_child( server: &MockServer, child_response_delay: Option, ) -> Result<(TestCodex, String)> { let (test, spawned_id, _child_request_log) = setup_turn_one_with_custom_spawned_child( server, json!({ "message": CHILD_PROMPT, }), child_response_delay, /*wait_for_parent_notification*/ true, |builder| builder, ) .await?; Ok((test, spawned_id)) } async fn setup_turn_one_with_custom_spawned_child( server: &MockServer, spawn_args: serde_json::Value, child_response_delay: Option, wait_for_parent_notification: bool, configure_test: impl FnOnce( core_test_support::test_codex::TestCodexBuilder, ) -> core_test_support::test_codex::TestCodexBuilder, ) -> Result<( TestCodex, String, core_test_support::responses::ResponseMock, )> { let spawn_args = serde_json::to_string(&spawn_args)?; 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_V1_NAMESPACE, "spawn_agent", &spawn_args, ), ev_completed("resp-turn1-1"), ]), ) .await; let child_sse = sse(vec![ ev_response_created("resp-child-1"), ev_assistant_message("msg-child-1", "child done"), ev_completed("resp-child-1"), ]); let child_request_log = if let Some(delay) = child_response_delay { mount_response_once_match( server, |req: &wiremock::Request| { body_contains(req, CHILD_PROMPT) && !body_contains(req, SPAWN_CALL_ID) }, sse_response(child_sse).set_delay(delay), ) .await } else { mount_sse_once_match( server, |req: &wiremock::Request| { body_contains(req, CHILD_PROMPT) && !body_contains(req, SPAWN_CALL_ID) }, child_sse, ) .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; #[allow(clippy::expect_used)] let mut builder = configure_test(test_codex().with_config(|config| { config .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); })); let test = builder.build(server).await?; 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 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("")); if has_notification { break; } if Instant::now() >= deadline { anyhow::bail!( "timed out waiting for parent rollout to include subagent notification" ); } sleep(Duration::from_millis(10)).await; } } let spawned_id = wait_for_spawned_thread_id(&test).await?; Ok((test, spawned_id, child_request_log)) } async fn spawn_child_and_capture_snapshot( server: &MockServer, spawn_args: serde_json::Value, configure_test: impl FnOnce( core_test_support::test_codex::TestCodexBuilder, ) -> core_test_support::test_codex::TestCodexBuilder, ) -> Result { let (test, spawned_id, _child_request_log) = setup_turn_one_with_custom_spawned_child( server, spawn_args, /*child_response_delay*/ None, /*wait_for_parent_notification*/ false, configure_test, ) .await?; let thread_id = ThreadId::from_string(&spawned_id)?; Ok(test .thread_manager .get_thread(thread_id) .await? .config_snapshot() .await) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn subagent_start_replaces_session_start_and_injects_context() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; let spawn_args = serde_json::to_string(&json!({ "message": CHILD_PROMPT, "task_name": "child", "agent_type": "worker", }))?; 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_V1_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, SUBAGENT_START_CONTEXT) && !body_contains(req, "") && !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 test = test_codex() .with_pre_build_hook(|home| { if let Err(error) = write_subagent_start_hooks(home) { panic!("failed to write subagent hook fixture: {error}"); } }) .with_config(|config| { core_test_support::hooks::trust_discovered_hooks(config); config .features .enable(Feature::Collab) .expect("test config should allow feature update"); }) .build(&server) .await?; test.submit_turn(TURN_1_PROMPT).await?; let child_requests = wait_for_requests(&child_request_log).await?; assert_eq!(child_requests.len(), 1); let start_inputs = wait_for_hook_log( test.codex_home_path(), "subagent_start_hook_log.jsonl", /*expected_len*/ 1, ) .await?; assert_eq!(start_inputs.len(), 1); assert_eq!(start_inputs[0]["agent_type"].as_str(), Some("worker")); let spawned_id = wait_for_spawned_thread_id(&test).await?; assert_eq!( start_inputs[0]["agent_id"].as_str(), Some(spawned_id.as_str()) ); let session_start_inputs = wait_for_hook_log( test.codex_home_path(), "session_start_hook_log.jsonl", /*expected_len*/ 1, ) .await?; assert_eq!(session_start_inputs.len(), 1); assert_eq!(session_start_inputs[0]["source"].as_str(), Some("startup")); assert_ne!( session_start_inputs[0]["session_id"].as_str(), Some(spawned_id.as_str()) ); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn subagent_notification_is_included_without_wait() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; let (test, _spawned_id) = setup_turn_one_with_spawned_child(&server, /*child_response_delay*/ None).await?; let turn2 = mount_sse_once_match( &server, |req: &wiremock::Request| body_contains(req, TURN_2_NO_WAIT_PROMPT), sse(vec![ ev_response_created("resp-turn2-1"), ev_assistant_message("msg-turn2-1", "no wait path"), ev_completed("resp-turn2-1"), ]), ) .await; test.submit_turn(TURN_2_NO_WAIT_PROMPT).await?; let turn2_requests = wait_for_requests(&turn2).await?; assert!(turn2_requests.iter().any(has_subagent_notification)); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn spawned_child_receives_forked_parent_context() -> 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 = serde_json::to_string(&json!({ "message": CHILD_PROMPT, "fork_context": true, }))?; 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_V1_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), 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"); }); 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 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; }; assert!(body_contains(&child_request, TURN_0_FORK_PROMPT)); assert!(!body_contains(&child_request, SPAWN_CALL_ID)); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn spawn_agent_requested_model_and_reasoning_override_inherited_settings_without_role() -> 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, "model": REQUESTED_MODEL, "reasoning_effort": REQUESTED_REASONING_EFFORT, }), |builder| builder, ) .await?; assert_eq!(child_snapshot.model, REQUESTED_MODEL); assert_eq!( child_snapshot.reasoning_effort, Some(REQUESTED_REASONING_EFFORT) ); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn spawned_multi_agent_v2_child_inherits_parent_developer_context() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; let spawn_args = serde_json::to_string(&json!({ "message": CHILD_PROMPT, "task_name": "worker", }))?; 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_V1_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_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.developer_instructions = Some("Parent developer instructions.".to_string()); }); let test = builder.build(&server).await?; test.submit_turn(TURN_1_PROMPT).await?; let child_requests = wait_for_requests(&child_request_log).await?; let child_request = child_requests .last() .expect("child request log should capture at least one request"); assert!(child_request.body_contains_text("Parent developer instructions.")); assert!(child_request.body_contains_text(CHILD_PROMPT)); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn skills_toggle_skips_instructions_for_parent_and_spawned_child() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; let spawn_args = serde_json::to_string(&json!({ "message": CHILD_PROMPT, "task_name": "worker", }))?; 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_V1_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_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_pre_build_hook(|home| { if let Err(err) = write_home_skill(home, "demo", "demo-skill", "demo skill") { panic!("write home skill: {err}"); } }) .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.include_skill_instructions = false; }); let test = builder.build(&server).await?; test.submit_turn(TURN_1_PROMPT).await?; let parent_request = spawn_turn.single_request(); assert!(!parent_request.body_contains_text("")); assert!(!parent_request.body_contains_text("demo-skill")); let child_requests = wait_for_requests(&child_request_log).await?; let child_request = child_requests .last() .expect("child request log should capture at least one request"); assert!(!child_request.body_contains_text("")); assert!(!child_request.body_contains_text("demo-skill")); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn spawn_agent_role_overrides_requested_model_and_reasoning_settings() -> 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, "agent_type": "custom", "model": REQUESTED_MODEL, "reasoning_effort": REQUESTED_REASONING_EFFORT, }), |builder| { builder.with_config(|config| { let role_path = config.codex_home.join("custom-role.toml"); std::fs::write( &role_path, format!( "model = \"{ROLE_MODEL}\"\nmodel_reasoning_effort = \"{ROLE_REASONING_EFFORT}\"\n", ), ) .expect("write role config"); config.agent_roles.insert( "custom".to_string(), AgentRoleConfig { description: Some("Custom role".to_string()), config_file: Some(role_path.to_path_buf()), nickname_candidates: None, }, ); }) }, ) .await?; assert_eq!(child_snapshot.model, ROLE_MODEL); assert_eq!(child_snapshot.reasoning_effort, Some(ROLE_REASONING_EFFORT)); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn spawn_agent_tool_description_mentions_role_locked_settings() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; let call_id = "tool-search-spawn-agent"; let resp_mock = mount_sse_sequence( &server, vec![ sse(vec![ ev_response_created("resp-turn1-1"), ev_tool_search_call( call_id, &json!({ "query": "spawn agent custom role", "limit": 1, }), ), ev_completed("resp-turn1-1"), ]), sse(vec![ ev_response_created("resp-turn1-2"), ev_assistant_message("msg-turn1-2", "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"); let role_path = config.codex_home.join("custom-role.toml"); std::fs::write( &role_path, format!( "developer_instructions = \"Stay focused\"\nmodel = \"{ROLE_MODEL}\"\nmodel_reasoning_effort = \"{ROLE_REASONING_EFFORT}\"\n", ), ) .expect("write role config"); config.agent_roles.insert( "custom".to_string(), AgentRoleConfig { description: Some("Custom role".to_string()), config_file: Some(role_path.to_path_buf()), nickname_candidates: None, }, ); }); let test = builder.build(&server).await?; test.submit_turn(TURN_1_PROMPT).await?; let requests = resp_mock.requests(); assert_eq!(requests.len(), 2); let output = requests[1].tool_search_output(call_id); let spawn_agent = namespace_child_tool(&output, "multi_agent_v1", "spawn_agent") .unwrap_or_else(|| { panic!("expected tool_search to return multi_agent_v1.spawn_agent: {output:?}") }); let agent_type_description = tool_parameter_description(spawn_agent, "agent_type") .expect("spawn_agent agent_type description"); let custom_role_description = role_block(&agent_type_description, "custom").expect("custom role description"); assert_eq!( custom_role_description, "custom: {\nCustom role\n- This role's model is set to `gpt-5.4` and its reasoning effort is set to `high`. These settings cannot be changed.\n}" ); Ok(()) }