diff --git a/codex-rs/app-server/tests/common/models_cache.rs b/codex-rs/app-server/tests/common/models_cache.rs index 5f3227cbdd..e3de19b9d0 100644 --- a/codex-rs/app-server/tests/common/models_cache.rs +++ b/codex-rs/app-server/tests/common/models_cache.rs @@ -35,6 +35,7 @@ fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo { upgrade: preset.upgrade.as_ref().map(Into::into), model_messages: Some(ModelMessages { persistent_instructions: None, + tools: None, instructions_template: Some("base instructions".to_string()), instructions_variables: None, approvals: None, diff --git a/codex-rs/core/src/guardian/review_session_tests.rs b/codex-rs/core/src/guardian/review_session_tests.rs index ee306540e0..7399adc136 100644 --- a/codex-rs/core/src/guardian/review_session_tests.rs +++ b/codex-rs/core/src/guardian/review_session_tests.rs @@ -377,6 +377,7 @@ async fn guardian_review_session_config_prefers_managed_policy_and_uses_catalog_ parent_config.guardian_policy_config = Some(managed_policy.to_string()); let model_messages = ModelMessages { persistent_instructions: None, + tools: None, instructions_template: None, instructions_variables: None, approvals: None, @@ -417,6 +418,7 @@ async fn guardian_review_session_config_preserves_explicit_empty_catalog_policy( let parent_config = crate::config::test_config().await; let model_messages = ModelMessages { persistent_instructions: None, + tools: None, instructions_template: None, instructions_variables: None, approvals: None, @@ -465,6 +467,7 @@ async fn guardian_review_session_config_preserves_explicit_empty_catalog_templat let catalog_policy = "Use the catalog Guardian policy."; let model_messages = ModelMessages { persistent_instructions: None, + tools: None, instructions_template: None, instructions_variables: None, approvals: None, diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 996a18ab5f..a4fe4429fa 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -3467,13 +3467,14 @@ impl Session { }); extension_data.insert(selected_plugins.clone()); turn_context.extension_data.insert(selected_plugins); - // Tool planning still uses the admitted turn. Migrating it to the - // captured model is a separate step from diagnostic activation. + // Tool availability still follows the admitted turn; the async message + // description comes from the captured step model. let tool_router = turn::built_tools( self.as_ref(), turn_context.as_ref(), // TODO(CDXENT-441): use the step scoped model turn_context.model_info(), + settings.model_info.model_messages.as_ref(), &environments, &mcp, &extension_data, diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 69b67a8eba..124f27bb43 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -92,6 +92,7 @@ use codex_protocol::models::BaseInstructions; use codex_protocol::models::ContentItem; use codex_protocol::models::MessagePhase; use codex_protocol::models::ResponseItem; +use codex_protocol::openai_models::ModelMessages; use codex_protocol::protocol::AgentMessageContentDeltaEvent; use codex_protocol::protocol::AgentReasoningSectionBreakEvent; use codex_protocol::protocol::CodexErrorInfo; @@ -1504,6 +1505,7 @@ pub(crate) async fn prepare_tool_recommendations( } } +#[allow(clippy::too_many_arguments)] #[instrument(level = "trace", skip_all, fields( @@ -1516,6 +1518,7 @@ pub(crate) async fn built_tools( sess: &Session, turn_context: &TurnContext, model_info: &codex_protocol::openai_models::ModelInfo, + model_messages: Option<&ModelMessages>, environments: &TurnEnvironmentSnapshot, mcp: &Arc, step_store: &ExtensionData, @@ -1585,6 +1588,7 @@ pub(crate) async fn built_tools( sess, turn_context, model_info, + model_messages, environments, mcp, apps_enabled, diff --git a/codex-rs/core/src/tools/handlers/send_user_message_async.rs b/codex-rs/core/src/tools/handlers/send_user_message_async.rs index fa1bd08b6f..027330cc1d 100644 --- a/codex-rs/core/src/tools/handlers/send_user_message_async.rs +++ b/codex-rs/core/src/tools/handlers/send_user_message_async.rs @@ -20,7 +20,9 @@ use std::collections::BTreeMap; const TOOL_NAME: &str = "send_user_message_async"; -pub struct SendUserMessageAsyncHandler; +pub struct SendUserMessageAsyncHandler { + pub description: Option, +} #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] @@ -43,8 +45,10 @@ impl ToolExecutor for SendUserMessageAsyncHandler { ToolSpec::Function(ResponsesApiTool { name: TOOL_NAME.to_string(), - description: "Send a concise message that needs the user's attention during ongoing work. The tool returns immediately without ending the turn or waiting for a reply; any reply arrives asynchronously as a new user message.\nOnly use this tool to ask for missing information, preferences, constraints, clarification, or approval. The message should be concise, easy to read and understand, and at the right level of abstraction that is appropriate for the user and task at hand." - .to_string(), + description: self.description.clone().unwrap_or_else(|| { + "Send a concise message that needs the user's attention during ongoing work. The tool returns immediately without ending the turn or waiting for a reply; any reply arrives asynchronously as a new user message.\nOnly use this tool to ask for missing information, preferences, constraints, clarification, or approval. The message should be concise, easy to read and understand, and at the right level of abstraction that is appropriate for the user and task at hand." + .to_string() + }), strict: false, defer_loading: None, parameters: JsonSchema::object( diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index f98ad17f57..082bb8f0b2 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -75,6 +75,7 @@ use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ConfigShellToolType; use codex_protocol::openai_models::InputModality; use codex_protocol::openai_models::ModelInfo; +use codex_protocol::openai_models::ModelMessages; use codex_protocol::openai_models::ToolMode; use codex_protocol::protocol::MultiAgentVersion; use codex_tools::ResponsesApiNamespace; @@ -109,6 +110,7 @@ const IMAGEGEN_TOOL_NAME: &str = "imagegen"; struct CoreToolPlanContext<'a> { turn_context: &'a TurnContext, model_info: &'a ModelInfo, + model_messages: Option<&'a ModelMessages>, environments: &'a TurnEnvironmentSnapshot, mcp: &'a codex_mcp::McpBinding, tool_suggest_candidates: Option<&'a crate::tools::router::ToolSuggestCandidates>, @@ -123,6 +125,7 @@ pub(crate) fn build_tool_router( session: &Session, turn_context: &TurnContext, model_info: &ModelInfo, + model_messages: Option<&ModelMessages>, environments: &TurnEnvironmentSnapshot, mcp: &Arc, apps_enabled: bool, @@ -138,6 +141,7 @@ pub(crate) fn build_tool_router( let context = CoreToolPlanContext { turn_context, model_info, + model_messages, environments, mcp, tool_suggest_candidates, @@ -290,6 +294,7 @@ pub(crate) fn build_core_tool_registry( let context = CoreToolPlanContext { turn_context, model_info, + model_messages: model_info.model_messages.as_ref(), environments, mcp, tool_suggest_candidates, @@ -1172,7 +1177,16 @@ fn add_core_utility_tools(context: &CoreToolPlanContext<'_>, registry: &mut Tool .iter() .any(|tool| tool == "send_user_message_async") { - registry.add_with_exposure(SendUserMessageAsyncHandler, ToolExposure::DirectModelOnly); + registry.add_with_exposure( + SendUserMessageAsyncHandler { + description: context + .model_messages + .and_then(|messages| messages.tools.as_ref()) + .and_then(|tools| tools.send_user_message_async.as_ref()) + .and_then(|tool| tool.description.clone()), + }, + ToolExposure::DirectModelOnly, + ); } if environment_mode.has_environment() && features.enabled(Feature::RequestPermissionsTool) { diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index e8f31f508f..d3afa083a0 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -529,6 +529,7 @@ async fn internal_guardian_sessions_exclude_optional_core_tools() { &session, step_context.turn.as_ref(), step_context.turn.model_info(), + step_context.settings.model_info.model_messages.as_ref(), &step_context.environments, &step_context.mcp, /*apps_enabled*/ false, @@ -581,6 +582,7 @@ async fn internal_guardian_sessions_respect_managed_shell_restrictions() { &session, step_context.turn.as_ref(), step_context.turn.model_info(), + step_context.settings.model_info.model_messages.as_ref(), &step_context.environments, &step_context.mcp, /*apps_enabled*/ false, @@ -617,6 +619,7 @@ async fn internal_guardian_sessions_preserve_code_mode() { &session, step_context.turn.as_ref(), step_context.turn.model_info(), + step_context.settings.model_info.model_messages.as_ref(), &step_context.environments, &step_context.mcp, /*apps_enabled*/ false, @@ -687,6 +690,7 @@ async fn internal_guardian_sessions_require_managed_secondary_environments() { &session, step_context.turn.as_ref(), step_context.turn.model_info(), + step_context.settings.model_info.model_messages.as_ref(), &step_context.environments, &step_context.mcp, /*apps_enabled*/ false, diff --git a/codex-rs/core/tests/suite/catalog_permission_messages.rs b/codex-rs/core/tests/suite/catalog_permission_messages.rs index 60f606e388..892dfbc4b7 100644 --- a/codex-rs/core/tests/suite/catalog_permission_messages.rs +++ b/codex-rs/core/tests/suite/catalog_permission_messages.rs @@ -32,6 +32,7 @@ async fn catalog_permission_message_loaded_from_remote_models_is_sent() -> Resul let mut model = model_info_from_slug(model_slug); model.model_messages = Some(ModelMessages { persistent_instructions: None, + tools: None, instructions_template: None, instructions_variables: None, approvals: None, diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index 979562e90b..8d54fcd689 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -76,6 +76,7 @@ fn model_with_collaboration_messages( let mut model = model_info_from_slug(slug); let model_messages = model.model_messages.get_or_insert(ModelMessages { persistent_instructions: None, + tools: None, instructions_template: None, instructions_variables: None, approvals: None, diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index b7d046dd93..ea663b2ac0 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -505,6 +505,7 @@ fn test_remote_model(slug: &str, priority: i32) -> ModelInfo { upgrade: None, model_messages: Some(ModelMessages { persistent_instructions: None, + tools: None, instructions_template: Some("base instructions".to_string()), instructions_variables: None, approvals: None, diff --git a/codex-rs/core/tests/suite/permissions_messages.rs b/codex-rs/core/tests/suite/permissions_messages.rs index a44ae2f4bf..f58dba2c73 100644 --- a/codex-rs/core/tests/suite/permissions_messages.rs +++ b/codex-rs/core/tests/suite/permissions_messages.rs @@ -48,6 +48,7 @@ fn model_with_approval_messages( let mut model = model_info_from_slug(slug); model.model_messages = Some(ModelMessages { persistent_instructions: None, + tools: None, instructions_template: None, instructions_variables: None, approvals: Some(ApprovalMessages { @@ -74,6 +75,7 @@ fn model_with_permission_messages( let mut model = model_info_from_slug(slug); model.model_messages = Some(ModelMessages { persistent_instructions: None, + tools: None, instructions_template: None, instructions_variables: None, approvals: None, @@ -230,6 +232,7 @@ async fn catalog_non_on_request_approval_messages_are_sent_in_initial_permission let mut model = model_info_from_slug(model_slug); model.model_messages = Some(ModelMessages { persistent_instructions: None, + tools: None, instructions_template: None, instructions_variables: None, approvals: Some(approvals), diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 1ae7b90490..e139bb7373 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -634,6 +634,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow upgrade: None, model_messages: Some(ModelMessages { persistent_instructions: None, + tools: None, instructions_template: Some("Base instructions\n{{ personality }}\n".to_string()), instructions_variables: Some(ModelInstructionsVariables { personality_default: Some(default_personality_message.to_string()), @@ -763,6 +764,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - upgrade: None, model_messages: Some(ModelMessages { persistent_instructions: None, + tools: None, instructions_template: Some("Base instructions\n{{ personality }}\n".to_string()), instructions_variables: Some(ModelInstructionsVariables { personality_default: None, diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 1d2ced5f5f..01207a01db 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -116,6 +116,7 @@ async fn remote_models_get_model_info_uses_longest_matching_prefix() -> Result<( display_name: "GPT 5.3 Codex".to_string(), model_messages: Some(ModelMessages { persistent_instructions: None, + tools: None, instructions_template: Some("use specific prefix".to_string()), instructions_variables: None, approvals: None, @@ -133,6 +134,7 @@ async fn remote_models_get_model_info_uses_longest_matching_prefix() -> Result<( display_name: "GPT 5.3".to_string(), model_messages: Some(ModelMessages { persistent_instructions: None, + tools: None, instructions_template: Some("use generic prefix".to_string()), instructions_variables: None, approvals: None, @@ -408,6 +410,7 @@ async fn remote_models_long_model_slug_is_sent_with_supported_reasoning( remote_model.default_reasoning_summary = ReasoningSummary::Detailed; remote_model.model_messages = Some(ModelMessages { persistent_instructions: catalog_instructions.map(str::to_string), + tools: None, instructions_template: Some(base_instructions.to_string()), instructions_variables: None, approvals: None, @@ -851,6 +854,7 @@ async fn remote_models_apply_legacy_instructions() -> Result<()> { upgrade: None, model_messages: Some(ModelMessages { persistent_instructions: None, + tools: None, instructions_template: Some(remote_instructions.to_string()), instructions_variables: None, approvals: None, diff --git a/codex-rs/core/tests/suite/send_user_message_async.rs b/codex-rs/core/tests/suite/send_user_message_async.rs index a610fd6efb..fa0d37c13a 100644 --- a/codex-rs/core/tests/suite/send_user_message_async.rs +++ b/codex-rs/core/tests/suite/send_user_message_async.rs @@ -8,6 +8,8 @@ use codex_protocol::items::TurnItem; use codex_protocol::models::MessagePhase; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::openai_models::ReasoningEffortPreset; +use codex_protocol::openai_models::ToolMessage; +use codex_protocol::openai_models::ToolMessages; use codex_protocol::openai_models::ToolMode; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::SessionSource; @@ -63,9 +65,16 @@ async fn persistent_async_message_guidance_follows_tool_availability( effort: ReasoningEffort::Persistent, description: ReasoningEffort::Persistent.to_string(), }); - if let Some(messages) = model.model_messages.as_mut() { - messages.persistent_instructions = None; - } + let messages = model + .model_messages + .as_mut() + .expect("model instruction metadata"); + messages.persistent_instructions = None; + messages.tools = Some(ToolMessages { + send_user_message_async: Some(ToolMessage { + description: Some("Catalog async message description.".to_string()), + }), + }); }) .with_config(|config| { config.model_reasoning_effort = Some(ReasoningEffort::Persistent); @@ -110,8 +119,15 @@ async fn persistent_async_message_guidance_follows_tool_availability( Ok(()) } +#[test_case(None; "fallback_description")] +#[test_case(Some(ToolMessages { send_user_message_async: None }); "missing_tool")] +#[test_case(Some(ToolMessages { send_user_message_async: Some(ToolMessage::default()) }); "missing_description")] +#[test_case(Some(ToolMessages { send_user_message_async: Some(ToolMessage { description: Some("Catalog async message description.".to_string()) }) }); "catalog_description")] +#[test_case(Some(ToolMessages { send_user_message_async: Some(ToolMessage { description: Some(String::new()) }) }); "empty_description")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn send_user_message_async_emits_item_and_does_not_end_the_turn() -> Result<()> { +async fn send_user_message_async_emits_item_and_does_not_end_the_turn( + tool_messages: Option, +) -> Result<()> { skip_if_no_network!(Ok(())); const CALL_ID: &str = "async-message-call"; @@ -139,12 +155,25 @@ async fn send_user_message_async_emits_item_and_does_not_end_the_turn() -> Resul ], ) .await; + let expected_description = tool_messages + .as_ref() + .and_then(|tools| tools.send_user_message_async.as_ref()) + .and_then(|tool| tool.description.as_deref()) + .unwrap_or( + "Send a concise message that needs the user's attention during ongoing work. The tool returns immediately without ending the turn or waiting for a reply; any reply arrives asynchronously as a new user message.\nOnly use this tool to ask for missing information, preferences, constraints, clarification, or approval. The message should be concise, easy to read and understand, and at the right level of abstraction that is appropriate for the user and task at hand.", + ) + .to_string(); let test = test_codex() - .with_model_info_override("gpt-5.2", |model| { + .with_model_info_override("gpt-5.2", move |model| { model.tool_mode = Some(ToolMode::CodeModeOnly); model .experimental_supported_tools .push("send_user_message_async".to_string()); + model + .model_messages + .as_mut() + .expect("model instruction metadata") + .tools = tool_messages; }) .build_with_auto_env(&server) .await?; @@ -207,16 +236,16 @@ async fn send_user_message_async_emits_item_and_does_not_end_the_turn() -> Resul let requests = responses.requests(); assert_eq!(requests.len(), 2); - assert!( - requests[0].body_json()["tools"] + for request in &requests { + let body = request.body_json(); + let tool = body["tools"] .as_array() - .is_some_and(|tools| { - tools.iter().any(|tool| { - tool["type"] == "function" && tool["name"] == "send_user_message_async" - }) - }), - "the async message tool should be directly visible to the model" - ); + .expect("request tools") + .iter() + .find(|tool| tool["type"] == "function" && tool["name"] == "send_user_message_async") + .expect("the async message tool should be directly visible to the model"); + assert_eq!(tool["description"], expected_description); + } assert_eq!( requests[1].function_call_output_text(CALL_ID), Some(r#"{"accepted":true}"#.to_string()) diff --git a/codex-rs/core/tests/suite/step_settings.rs b/codex-rs/core/tests/suite/step_settings.rs index 845812f5ab..a347b6c85f 100644 --- a/codex-rs/core/tests/suite/step_settings.rs +++ b/codex-rs/core/tests/suite/step_settings.rs @@ -17,6 +17,8 @@ use codex_protocol::openai_models::ModelTokenBudgetConfig; use codex_protocol::openai_models::ModelsResponse; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::openai_models::ReasoningEffortPreset; +use codex_protocol::openai_models::ToolMessage; +use codex_protocol::openai_models::ToolMessages; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::CONTEXT_WINDOW_GUIDANCE_CLOSE_TAG; use codex_protocol::protocol::CONTEXT_WINDOW_GUIDANCE_OPEN_TAG; @@ -1128,6 +1130,90 @@ async fn model_activation_uses_destination_metadata_defaults( Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn send_user_message_async_description_follows_mid_turn_model_changes() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let response_mock = mount_sse_sequence( + &server, + vec![ + paused_response("resp-1", "pause-before-async-message-model-change"), + sse_completed("resp-2"), + ], + ) + .await; + let test = step_settings_test() + .with_config(|config| { + for model in &mut config + .model_catalog + .as_mut() + .expect("controlled model catalog") + .models + { + model + .experimental_supported_tools + .push("send_user_message_async".to_string()); + model + .model_messages + .as_mut() + .expect("model instruction metadata") + .tools = Some(ToolMessages { + send_user_message_async: Some(ToolMessage { + description: Some(format!("Async message description for {}.", model.slug)), + }), + }); + } + }) + .build_with_auto_env(&server) + .await?; + let paused = start_paused_turn(&test.codex).await?; + assert_eq!( + submit_turn_settings( + &test.codex, + &paused.turn_id, + TurnSettingsUpdate { + model: Some(MODEL_B.to_string()), + ..Default::default() + } + ) + .await?, + TurnSettingsUpdateOutcome::Applied + ); + answer_paused_turn(&test.codex, &paused.turn_id).await?; + wait_for_event(&test.codex, |event| match event { + EventMsg::Error(error) => panic!("model activation failed: {}", error.message), + EventMsg::TurnComplete(_) => true, + _ => false, + }) + .await; + + assert_eq!( + response_mock + .requests() + .iter() + .map(|request| { + let body = request.body_json(); + let tool = body["tools"] + .as_array() + .expect("request tools") + .iter() + .find(|tool| tool["name"] == "send_user_message_async") + .expect("async message tool"); + json!({"model": body["model"], "description": tool["description"]}) + }) + .collect::>(), + [MODEL_A, MODEL_B] + .map(|model| json!({ + "model": model, + "description": format!("Async message description for {model}."), + })) + .to_vec(), + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn persistent_instructions_follow_mid_turn_model_changes() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/models-manager/src/model_info.rs b/codex-rs/models-manager/src/model_info.rs index e0abb59032..b79c67b2cf 100644 --- a/codex-rs/models-manager/src/model_info.rs +++ b/codex-rs/models-manager/src/model_info.rs @@ -52,6 +52,7 @@ pub fn with_config_overrides(mut model: ModelInfo, config: &ModelsManagerConfig) if let Some(base_instructions) = &config.base_instructions { let model_messages = model.model_messages.get_or_insert(ModelMessages { persistent_instructions: None, + tools: None, instructions_template: None, instructions_variables: None, approvals: None, @@ -192,6 +193,7 @@ fn local_model_messages_for_slug(slug: &str) -> ModelMessages { match slug { "gpt-5.2-codex" | "exp-codex-personality" => ModelMessages { persistent_instructions: None, + tools: None, instructions_template: Some(format!( "{DEFAULT_PERSONALITY_HEADER}\n\n{PERSONALITY_PLACEHOLDER}\n\n{BASE_INSTRUCTIONS}" )), @@ -211,6 +213,7 @@ fn local_model_messages_for_slug(slug: &str) -> ModelMessages { }, _ => ModelMessages { persistent_instructions: None, + tools: None, instructions_template: Some(BASE_INSTRUCTIONS.to_string()), instructions_variables: None, approvals: None, diff --git a/codex-rs/models-manager/src/model_info_tests.rs b/codex-rs/models-manager/src/model_info_tests.rs index 38e090e696..d6f3a73bf4 100644 --- a/codex-rs/models-manager/src/model_info_tests.rs +++ b/codex-rs/models-manager/src/model_info_tests.rs @@ -11,6 +11,8 @@ use codex_protocol::openai_models::MultiAgentMessages; use codex_protocol::openai_models::MultiAgentModeMessages; use codex_protocol::openai_models::MultiAgentRoleMessages; use codex_protocol::openai_models::PermissionMessages; +use codex_protocol::openai_models::ToolMessage; +use codex_protocol::openai_models::ToolMessages; use pretty_assertions::assert_eq; fn config_with_personality(personality: Option) -> ModelsManagerConfig { @@ -25,6 +27,7 @@ fn config_with_personality(personality: Option) -> ModelsManagerCon fn base_instruction_override_is_literal_and_preserves_catalog_messages() { let override_instructions = "override {{ personality }}"; let persistent_instructions = "Follow up on the active task."; + let async_message_description = "Catalog async message description."; let mut model = model_info_from_slug("unknown-model"); let approvals = ApprovalMessages { on_request: Some("user approvals".to_string()), @@ -75,6 +78,11 @@ fn base_instruction_override_is_literal_and_preserves_catalog_messages() { }; model.model_messages = Some(ModelMessages { persistent_instructions: Some(persistent_instructions.to_string()), + tools: Some(ToolMessages { + send_user_message_async: Some(ToolMessage { + description: Some(async_message_description.to_string()), + }), + }), instructions_template: Some("template".to_string()), instructions_variables: Some(ModelInstructionsVariables { personality_default: Some("default".to_string()), @@ -101,6 +109,11 @@ fn base_instruction_override_is_literal_and_preserves_catalog_messages() { updated.model_messages, Some(ModelMessages { persistent_instructions: Some(persistent_instructions.to_string()), + tools: Some(ToolMessages { + send_user_message_async: Some(ToolMessage { + description: Some(async_message_description.to_string()), + }), + }), instructions_template: Some(override_instructions.to_string()), instructions_variables: None, approvals: Some(approvals), @@ -130,6 +143,11 @@ fn disabled_personality_bakes_default_and_preserves_catalog_approval_messages() }; model.model_messages = Some(ModelMessages { persistent_instructions: Some(String::new()), + tools: Some(ToolMessages { + send_user_message_async: Some(ToolMessage { + description: Some(String::new()), + }), + }), instructions_template: Some("before {{ personality }} after".to_string()), instructions_variables: Some(ModelInstructionsVariables { personality_default: Some("default".to_string()), @@ -156,6 +174,11 @@ fn disabled_personality_bakes_default_and_preserves_catalog_approval_messages() updated.model_messages, Some(ModelMessages { persistent_instructions: Some(String::new()), + tools: Some(ToolMessages { + send_user_message_async: Some(ToolMessage { + description: Some(String::new()), + }), + }), instructions_template: Some("before default after".to_string()), instructions_variables: None, approvals: Some(approvals), @@ -185,6 +208,7 @@ fn disabled_personality_uses_plain_base_instructions_for_local_personality_model updated.model_messages, Some(ModelMessages { persistent_instructions: None, + tools: None, instructions_template: Some(BASE_INSTRUCTIONS.to_string()), instructions_variables: None, approvals: None, @@ -228,6 +252,7 @@ fn personality_none_strips_catalog_instruction_sources_through_the_next_h1() { let mut model = model_info_from_slug("unknown-model"); model.model_messages = Some(ModelMessages { persistent_instructions: None, + tools: None, instructions_template: Some(instructions.to_string()), instructions_variables: None, approvals: None, diff --git a/codex-rs/protocol/src/openai_models.rs b/codex-rs/protocol/src/openai_models.rs index 5f7f78deb2..6b6d481bda 100644 --- a/codex-rs/protocol/src/openai_models.rs +++ b/codex-rs/protocol/src/openai_models.rs @@ -546,6 +546,8 @@ pub struct ModelMessages { /// instructions; an empty string disables them. #[serde(default, skip_serializing_if = "Option::is_none")] pub persistent_instructions: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tools: Option, pub instructions_template: Option, pub instructions_variables: Option, pub approvals: Option, @@ -573,6 +575,22 @@ pub struct ConfirmationPolicies { pub computer_use: Option, } +/// Model-owned messages for built-in tools. +#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)] +pub struct ToolMessages { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub send_user_message_async: Option, +} + +/// Model-owned messages for a built-in tool. +#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)] +pub struct ToolMessage { + /// Missing or null uses the built-in description; an empty string leaves the description + /// empty without disabling the tool. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, +} + /// Model-owned defaults for the context-window token-budget feature. #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)] pub struct ModelTokenBudgetConfig { @@ -793,6 +811,7 @@ where { let messages = model.model_messages.get_or_insert(ModelMessages { persistent_instructions: None, + tools: None, instructions_template: None, instructions_variables: None, approvals: None, @@ -1002,6 +1021,7 @@ mod tests { messages, ModelMessages { persistent_instructions: None, + tools: None, instructions_template: None, instructions_variables: None, approvals: None, @@ -1016,6 +1036,61 @@ mod tests { ); } + #[test] + fn send_user_message_async_description_preserves_missing_null_and_empty_values() { + for (value, expected) in [ + (serde_json::json!({}), None), + (serde_json::json!({"tools": null}), None), + (serde_json::json!({"tools": {}}), Some(None)), + ( + serde_json::json!({"tools": {"send_user_message_async": null}}), + Some(None), + ), + ( + serde_json::json!({"tools": {"send_user_message_async": {}}}), + Some(Some(ToolMessage::default())), + ), + ( + serde_json::json!({"tools": {"send_user_message_async": {"description": null}}}), + Some(Some(ToolMessage::default())), + ), + ( + serde_json::json!({"tools": {"send_user_message_async": {"description": ""}}}), + Some(Some(ToolMessage { + description: Some(String::new()), + })), + ), + ( + serde_json::json!({"tools": {"send_user_message_async": {"description": "Catalog description"}}}), + Some(Some(ToolMessage { + description: Some("Catalog description".to_string()), + })), + ), + ] { + let messages: ModelMessages = + serde_json::from_value(value).expect("model messages should deserialize"); + let serialized = + serde_json::to_value(&messages).expect("model messages should serialize"); + assert_eq!( + ( + messages.tools, + serialized + .get("tools") + .map(|tools| tools.get("send_user_message_async").cloned()), + ), + ( + expected.as_ref().map(|tool| ToolMessages { + send_user_message_async: tool.clone(), + }), + expected.map(|tool| tool.map(|tool| match tool.description { + Some(description) => serde_json::json!({"description": description}), + None => serde_json::json!({}), + })), + ) + ); + } + } + #[test] fn approval_messages_preserve_missing_and_empty_values() { let messages: ModelMessages = from_str( @@ -1150,6 +1225,7 @@ mod tests { messages, ModelMessages { persistent_instructions: None, + tools: None, instructions_template: None, instructions_variables: None, approvals: None, @@ -1243,6 +1319,7 @@ mod tests { fn get_model_instructions_uses_template_when_placeholder_present() { let model = test_model(Some(ModelMessages { persistent_instructions: None, + tools: None, instructions_template: Some("Hello {{ personality }}".to_string()), instructions_variables: Some(personality_variables()), approvals: None, @@ -1264,6 +1341,7 @@ mod tests { fn get_model_instructions_strips_placeholder_with_incomplete_variables() { let model = test_model(Some(ModelMessages { persistent_instructions: None, + tools: None, instructions_template: Some("Hello\n{{ personality }}".to_string()), instructions_variables: Some(ModelInstructionsVariables { personality_default: None, @@ -1290,6 +1368,7 @@ mod tests { let model_no_personality = test_model(Some(ModelMessages { persistent_instructions: None, + tools: None, instructions_template: Some("Hello\n{{ personality }}".to_string()), instructions_variables: Some(ModelInstructionsVariables { personality_default: None, @@ -1327,6 +1406,7 @@ mod tests { fn get_model_instructions_is_empty_when_template_is_missing() { let model = test_model(Some(ModelMessages { persistent_instructions: None, + tools: None, instructions_template: None, instructions_variables: Some(ModelInstructionsVariables { personality_default: None, @@ -1374,6 +1454,7 @@ mod tests { model.model_messages, Some(ModelMessages { persistent_instructions: None, + tools: None, instructions_template: Some("legacy instructions".to_string()), instructions_variables: None, approvals: None, @@ -1424,6 +1505,7 @@ mod tests { let response = ModelsResponse { models: vec![test_model(Some(ModelMessages { persistent_instructions: None, + tools: None, instructions_template: Some("before {{ personality }} after".to_string()), instructions_variables: Some(ModelInstructionsVariables { personality_default: Some("default".to_string()), @@ -1453,6 +1535,11 @@ mod tests { fn models_response_prefers_template_and_preserves_message_siblings() { let messages = ModelMessages { persistent_instructions: Some("Persistent catalog instructions".to_string()), + tools: Some(ToolMessages { + send_user_message_async: Some(ToolMessage { + description: Some("Catalog description".to_string()), + }), + }), instructions_template: None, instructions_variables: None, approvals: Some(ApprovalMessages { @@ -1519,6 +1606,11 @@ mod tests { let canonical_messages = ModelMessages { persistent_instructions: Some(String::new()), + tools: Some(ToolMessages { + send_user_message_async: Some(ToolMessage { + description: Some(String::new()), + }), + }), instructions_template: Some("canonical instructions".to_string()), instructions_variables: None, approvals: None,