Preserve Apps usage instructions in model catalog messages (#58441)

## What changed

Add optional `ModelMessages.apps` and `AppsMessages.usage_instructions` fields to carry model catalog wording without runtime interpretation. Missing or null values become `None`, and strings retain their exact contents, including empty strings, whitespace, and template-like text.

## Testing

Add serialization coverage for nullable and literal Apps values, and extend existing tests to verify Apps messages survive model response round trips and base instruction overrides.

GitOrigin-RevId: 49b516656255e07b91f8f4679e2acb8e1b03f9e1
This commit is contained in:
goodman-oai
2026-10-30 22:02:26 +00:00
committed by copyberry
parent 0ffae14a43
commit 22fbf8de8e
6 changed files with 49 additions and 0 deletions
@@ -31,6 +31,7 @@ fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo {
available_access_programs: preset.available_access_programs.clone(),
upgrade: preset.upgrade.as_ref().map(Into::into),
model_messages: Some(ModelMessages {
apps: None,
content_filter_guidance: None,
persistent_instructions: None,
tools: None,
@@ -40,6 +40,7 @@ async fn catalog_permission_message_loaded_from_remote_models_is_sent(
let model_slug = "remote-catalog-permissions-model";
let mut model = model_info_from_slug(model_slug);
model.model_messages = Some(ModelMessages {
apps: None,
content_filter_guidance: None,
persistent_instructions: None,
tools: None,
@@ -74,6 +74,7 @@ fn model_with_collaboration_messages(
) -> codex_protocol::openai_models::ModelInfo {
let mut model = model_info_from_slug(slug);
let model_messages = model.model_messages.get_or_insert(ModelMessages {
apps: None,
content_filter_guidance: None,
persistent_instructions: None,
tools: None,
@@ -643,6 +643,7 @@ fn test_remote_model(slug: &str, priority: i32) -> ModelInfo {
available_access_programs: None,
upgrade: None,
model_messages: Some(ModelMessages {
apps: None,
content_filter_guidance: None,
persistent_instructions: None,
tools: None,
@@ -3,6 +3,7 @@ use crate::ModelsManagerConfig;
use codex_prompts::render_model_instructions;
use codex_protocol::config_types::Personality;
use codex_protocol::openai_models::ApprovalMessages;
use codex_protocol::openai_models::AppsMessages;
use codex_protocol::openai_models::AutoReviewMessages;
use codex_protocol::openai_models::CollaborationModeMessages;
use codex_protocol::openai_models::ConfirmationPolicies;
@@ -90,6 +91,9 @@ fn base_instruction_override_is_literal_and_preserves_catalog_messages() {
computer_use: Some(" # Native policy\r\n\n${native_markdown}\n".to_string()),
};
let mut messages = ModelMessages {
apps: Some(AppsMessages {
usage_instructions: Some("Catalog Apps instructions.".to_string()),
}),
content_filter_guidance: None,
persistent_instructions: Some(persistent_instructions.to_string()),
tools: Some(ToolMessages {
+41
View File
@@ -580,6 +580,9 @@ impl ModelInfo {
/// retained for model catalog compatibility.
#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)]
pub struct ModelMessages {
/// Optional model-owned Apps wording, transported without runtime interpretation.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub apps: Option<AppsMessages>,
/// Developer guidance after a content-filter block. Missing, null, blank, or values over
/// 512 UTF-8 bytes use the bundled guidance.
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -605,6 +608,15 @@ pub struct ModelMessages {
pub confirmation_policies: Option<ConfirmationPolicies>,
}
/// Optional Apps wording from the model catalog, preserved for future runtime consumption.
#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)]
pub struct AppsMessages {
/// Missing and null deserialize as `None`; all strings preserve their exact bytes.
/// Rendering, size limits, and empty-string behavior are defined by consumers.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub usage_instructions: Option<String>,
}
/// Model-owned confirmation-policy Markdown, forwarded unchanged to actor tools.
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)]
pub struct ConfirmationPolicies {
@@ -1225,6 +1237,32 @@ mod tests {
assert_eq!(messages, ModelMessages::default());
}
#[test]
fn apps_messages_preserve_nullable_and_literal_wire_values() {
for apps in [
serde_json::Value::Null,
serde_json::json!({}),
serde_json::json!({"usage_instructions": null}),
serde_json::json!({"usage_instructions": ""}),
serde_json::json!({"usage_instructions": " \r\n\t"}),
serde_json::json!({"usage_instructions": " {{literal}}\r\n"}),
serde_json::json!({"usage_instructions": "é".repeat(501)}),
] {
let messages: ModelMessages =
serde_json::from_value(serde_json::json!({"apps": apps})).unwrap();
let serialized = serde_json::to_value(messages).unwrap();
let mut expected = serde_json::to_value(ModelMessages::default()).unwrap();
if !apps.is_null() {
expected["apps"] = if apps["usage_instructions"].is_null() {
serde_json::json!({})
} else {
apps
};
}
assert_eq!(serialized, expected);
}
}
#[test]
fn send_user_message_async_description_preserves_missing_null_and_empty_values() {
for (value, expected) in [
@@ -1723,6 +1761,9 @@ mod tests {
#[test]
fn models_response_prefers_template_and_preserves_message_siblings() {
let messages = ModelMessages {
apps: Some(AppsMessages {
usage_instructions: Some("Literal Apps {{guidance}}".to_string()),
}),
content_filter_guidance: Some("Offer a permitted alternative.".to_string()),
persistent_instructions: Some("Persistent catalog instructions".to_string()),
tools: Some(ToolMessages {