Consolidate model instructions in ModelMessages (#36787)

## What changed

- Remove `ModelInfo.base_instructions` as an in-memory instruction source and use `model_messages.instructions_template` consistently for bundled, remote, fallback, and overridden model metadata.
- Preserve compatibility by promoting legacy `base_instructions` values when reading model responses and caches, and by including rendered legacy instructions when serializing `ModelsResponse` for older clients.
- Treat templates without instruction variables as literal text and retain the other model-message fields when applying instruction overrides.

This completes the consolidation proposed in https://github.com/openai/codex/pull/31302.

## Testing

- Cover legacy response and cache migration, canonical-template precedence, fallback instructions, personality rendering, overrides, and model switching.

GitOrigin-RevId: 089d986ca5e30da67db2c77a1b6a046d2cff52dc
This commit is contained in:
rhan-oai authored and copyberry committed 2026-08-03 19:30:40 +00:00
1 parent bbcf5e10fb
commit df72fdb415
27 files changed
+655 -311

No files matched your search

-1
View File
@@ -268,7 +268,6 @@ fn remote_model_with_auto_review_override(slug: &str, review_model: &str) -> Mod
service_tiers: Vec::new(),
default_service_tier: None,
upgrade: None,
base_instructions: "base instructions".to_string(),
model_messages: None,
include_skills_usage_instructions: false,
supports_reasoning_summary_parameter: true,
@@ -97,7 +97,6 @@ fn test_model_info(
service_tiers: Vec::new(),
default_service_tier: None,
upgrade: None,
base_instructions: "base instructions".to_string(),
model_messages: None,
include_skills_usage_instructions: false,
supports_reasoning_summary_parameter: true,
@@ -961,7 +960,6 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result<
service_tiers: Vec::new(),
default_service_tier: None,
upgrade: None,
base_instructions: "base instructions".to_string(),
model_messages: None,
include_skills_usage_instructions: false,
supports_reasoning_summary_parameter: true,
+36 -7
View File
@@ -13,6 +13,7 @@ use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::models::PermissionProfile;
use codex_protocol::openai_models::ConfigShellToolType;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::openai_models::ModelMessages;
use codex_protocol::openai_models::ModelVisibility;
use codex_protocol::openai_models::ModelsResponse;
use codex_protocol::openai_models::ReasoningEffort;
@@ -34,6 +35,7 @@ use core_test_support::wait_for_event;
use pretty_assertions::assert_eq;
use serde::Deserialize;
use serde::Serialize;
use serde_json::json;
use wiremock::MockServer;
const ETAG: &str = "\"models-etag-ttl\"";
@@ -272,6 +274,11 @@ async fn matching_models_etag_renews_cache_after_half_its_lifetime() -> Result<(
async fn uses_cache_when_version_matches() -> Result<()> {
let server = MockServer::start().await;
let cached_model = test_remote_model(VERSIONED_MODEL, /*priority*/ 1);
let response = responses::mount_sse_once(
&server,
sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]),
)
.await;
let models_mock = responses::mount_models_once(
&server,
ModelsResponse {
@@ -283,20 +290,30 @@ async fn uses_cache_when_version_matches() -> Result<()> {
let mut builder = test_codex().with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing());
builder = builder
.with_pre_build_hook(move |home| {
let cache = ModelsCache {
let mut cache = serde_json::to_value(ModelsCache {
fetched_at: Utc::now(),
etag: None,
client_version: Some(client_version_to_whole()),
models: vec![cached_model],
};
let cache_path = home.join(CACHE_FILE);
write_cache_sync(&cache_path, &cache).expect("write cache");
})
.expect("serialize cache");
let cached_model = cache["models"][0]
.as_object_mut()
.expect("cached model should serialize as an object");
cached_model.remove("model_messages");
cached_model.insert("base_instructions".to_string(), json!("base instructions"));
std::fs::write(
home.join(CACHE_FILE),
serde_json::to_vec_pretty(&cache).expect("serialize legacy cache"),
)
.expect("write legacy cache");
})
.with_config(|config| {
config.model = Some(VERSIONED_MODEL.to_string());
config.model_provider.request_max_retries = Some(0);
});
let test = builder.build(&server).await?;
let test = builder.build_with_auto_env(&server).await?;
let models_manager = test.thread_manager.get_models_manager();
let models = models_manager
.list_models(
@@ -314,6 +331,11 @@ async fn uses_cache_when_version_matches() -> Result<()> {
0,
"/models should not be called when cache version matches"
);
test.submit_turn("use cached model").await?;
assert_eq!(
response.single_request().instructions_text(),
"base instructions"
);
Ok(())
}
@@ -480,8 +502,15 @@ fn test_remote_model(slug: &str, priority: i32) -> ModelInfo {
service_tiers: Vec::new(),
default_service_tier: None,
upgrade: None,
base_instructions: "base instructions".to_string(),
model_messages: None,
model_messages: Some(ModelMessages {
instructions_template: Some("base instructions".to_string()),
instructions_variables: None,
approvals: None,
collaboration_modes: None,
auto_review: None,
permissions: None,
token_budget: None,
}),
include_skills_usage_instructions: false,
supports_reasoning_summary_parameter: true,
default_reasoning_summary: ReasoningSummary::Auto,
@@ -110,6 +110,7 @@ async fn catalog_approval_message_is_sent_in_initial_permissions() -> Result<()>
)
.await;
let model_slug = "catalog-approvals-model";
let override_instructions = "literal {{ personality }} override";
let model = model_with_approval_messages(
model_slug,
"catalog user approval instructions",
@@ -118,16 +119,19 @@ async fn catalog_approval_message_is_sent_in_initial_permissions() -> Result<()>
let mut builder = test_codex()
.with_model(model_slug)
.with_config(move |config| {
config.base_instructions = Some(override_instructions.to_string());
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
config.model_catalog = Some(ModelsResponse {
models: vec![model],
});
});
let test = builder.build(&server).await?;
let test = builder.build_with_auto_env(&server).await?;
submit_text_turn(&test, "hello").await?;
let permissions = permissions_texts(&req.single_request());
let request = req.single_request();
assert_eq!(request.instructions_text(), override_instructions);
let permissions = permissions_texts(&request);
assert_eq!(permissions.len(), 1);
assert!(permissions[0].contains("catalog user approval instructions"));
assert!(permissions[0].contains("Filesystem sandboxing defines"));
+55 -55
View File
@@ -1,7 +1,9 @@
use codex_config::types::Personality;
use codex_features::Feature;
use codex_models_manager::bundled_models_response;
use codex_models_manager::manager::RefreshStrategy;
use codex_models_manager::manager::SharedModelsManager;
use codex_models_manager::model_info::BASE_INSTRUCTIONS;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::models::PermissionProfile;
use codex_protocol::openai_models::ConfigShellToolType;
@@ -18,7 +20,6 @@ use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::Op;
use codex_protocol::user_input::UserInput;
use core_test_support::load_default_config_for_test;
use core_test_support::responses::mount_models_once;
use core_test_support::responses::mount_sse_once;
use core_test_support::responses::mount_sse_sequence;
@@ -31,7 +32,6 @@ use core_test_support::test_codex::test_codex;
use core_test_support::test_codex::turn_permission_fields;
use core_test_support::wait_for_event;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use tokio::time::Duration;
use tokio::time::Instant;
use tokio::time::sleep;
@@ -88,43 +88,6 @@ fn read_only_text_turn_with_personality(
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn personality_does_not_mutate_base_instructions_without_template() {
let codex_home = TempDir::new().expect("create temp dir");
let mut config = load_default_config_for_test(&codex_home).await;
config
.features
.enable(Feature::Personality)
.expect("test config should allow feature update");
config.personality = Some(Personality::Friendly);
let model_info = codex_core::test_support::construct_model_info_offline("gpt-5.4", &config);
assert_eq!(
model_info.get_model_instructions(config.personality),
model_info.base_instructions
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn base_instructions_override_disables_personality_template() {
let codex_home = TempDir::new().expect("create temp dir");
let mut config = load_default_config_for_test(&codex_home).await;
config
.features
.enable(Feature::Personality)
.expect("test config should allow feature update");
config.personality = Some(Personality::Friendly);
config.base_instructions = Some("override instructions".to_string());
let model_info = codex_core::test_support::construct_model_info_offline("gpt-5.4", &config);
assert_eq!(model_info.base_instructions, "override instructions");
assert_eq!(
model_info.get_model_instructions(config.personality),
"override instructions"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn user_turn_personality_none_does_not_add_update_message() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
@@ -267,8 +230,10 @@ async fn config_personality_none_strips_baked_personality_section() -> anyhow::R
let resp_mock = mount_sse_once(&server, sse_completed("resp-1")).await;
let mut builder = test_codex()
.with_model_info_override("gpt-5.4", |model_info| {
model_info.base_instructions = "Base instructions\n# Personality\nBaked personality\n## Writing Style\nNested writing style\n# General\nGeneral instructions".to_string();
model_info.model_messages = None;
if let Some(model_messages) = model_info.model_messages.as_mut() {
model_messages.instructions_template = Some("Base instructions\n# Personality\nBaked personality\n## Writing Style\nNested writing style\n# General\nGeneral instructions".to_string());
model_messages.instructions_variables = None;
}
})
.with_config(|config| {
config
@@ -516,21 +481,53 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn instructions_uses_base_if_feature_disabled() -> anyhow::Result<()> {
let codex_home = TempDir::new().expect("create temp dir");
let mut config = load_default_config_for_test(&codex_home).await;
config
.features
.disable(Feature::Personality)
.expect("test config should allow feature update");
config.personality = Some(Personality::Friendly);
async fn disabled_personality_sends_remote_default_instructions() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let server = MockServer::start().await;
let remote_slug = "remote-disabled-personality";
let default_personality = "default remote personality";
let friendly_personality = "friendly remote personality";
let mut remote_model = bundled_models_response()?
.models
.into_iter()
.find(|model| model.slug == "gpt-5.4")
.expect("bundled gpt-5.4 model");
remote_model.slug = remote_slug.to_string();
if let Some(model_messages) = remote_model.model_messages.as_mut() {
model_messages.instructions_template = Some("remote base\n{{ personality }}".to_string());
model_messages.instructions_variables = Some(ModelInstructionsVariables {
personality_default: Some(default_personality.to_string()),
personality_friendly: Some(friendly_personality.to_string()),
personality_pragmatic: Some("pragmatic remote personality".to_string()),
});
}
mount_models_once(
&server,
ModelsResponse {
models: vec![remote_model],
},
)
.await;
let response = mount_sse_once(&server, sse_completed("resp-1")).await;
let mut builder = test_codex()
.with_auth(codex_login::CodexAuth::create_dummy_chatgpt_auth_for_testing())
.with_config(move |config| {
config
.features
.disable(Feature::Personality)
.expect("test config should allow feature update");
config.model = Some(remote_slug.to_string());
config.personality = Some(Personality::Friendly);
});
let test = builder.build_with_auto_env(&server).await?;
test.submit_turn("use the default personality").await?;
let model_info = codex_core::test_support::construct_model_info_offline("gpt-5.4", &config);
assert_eq!(
model_info.get_model_instructions(config.personality),
model_info.base_instructions
response.single_request().instructions_text(),
format!("remote base\n{default_personality}")
);
Ok(())
}
@@ -587,6 +584,11 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()>
let requests = resp_mock.requests();
assert_eq!(requests.len(), 2, "expected two requests");
assert!(
requests
.iter()
.all(|request| request.instructions_text() == BASE_INSTRUCTIONS)
);
let request = requests
.last()
.expect("expected personality update request");
@@ -631,7 +633,6 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow
service_tiers: Vec::new(),
default_service_tier: None,
upgrade: None,
base_instructions: "base instructions".to_string(),
model_messages: Some(ModelMessages {
instructions_template: Some("Base instructions\n{{ personality }}\n".to_string()),
instructions_variables: Some(ModelInstructionsVariables {
@@ -752,7 +753,6 @@ async fn user_turn_personality_remote_model_template_includes_update_message() -
service_tiers: Vec::new(),
default_service_tier: None,
upgrade: None,
base_instructions: "base instructions".to_string(),
model_messages: Some(ModelMessages {
instructions_template: Some("Base instructions\n{{ personality }}\n".to_string()),
instructions_variables: Some(ModelInstructionsVariables {
+3 -3
View File
@@ -154,7 +154,7 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> {
})
.build(&server)
.await?;
let base_instructions = thread_manager
let model_info = thread_manager
.get_models_manager()
.get_model_info(
config
@@ -163,8 +163,8 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> {
.expect("test config should have a model"),
&config.to_models_manager_config(),
)
.await
.base_instructions;
.await;
let base_instructions = model_info.get_model_instructions(config.personality);
codex
.submit(Op::UserInput {
+127 -26
View File
@@ -6,10 +6,12 @@ use codex_model_provider_info::built_in_model_providers;
use codex_models_manager::bundled_models_response;
use codex_models_manager::manager::RefreshStrategy;
use codex_models_manager::manager::SharedModelsManager;
use codex_models_manager::model_info::BASE_INSTRUCTIONS;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::models::PermissionProfile;
use codex_protocol::openai_models::ConfigShellToolType;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::openai_models::ModelMessages;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::openai_models::ModelVisibility;
use codex_protocol::openai_models::ModelsResponse;
@@ -33,6 +35,7 @@ use core_test_support::responses::mount_models_once_with_delay;
use core_test_support::responses::mount_sse_once;
use core_test_support::responses::mount_sse_sequence;
use core_test_support::responses::sse;
use core_test_support::responses::start_mock_server;
use core_test_support::skip_if_no_network;
use core_test_support::skip_if_sandbox;
use core_test_support::test_codex::TestCodex;
@@ -49,10 +52,36 @@ use tokio::time::Instant;
use tokio::time::sleep;
use tokio::time::timeout;
use wiremock::BodyPrintLimit;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::method;
use wiremock::matchers::path;
const REMOTE_MODEL_SLUG: &str = "codex-test";
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn unknown_model_sends_builtin_instructions() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let response = mount_sse_once(
&server,
sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]),
)
.await;
let mut builder = test_codex().with_model("future-custom-model");
let test = builder.build_with_auto_env(&server).await?;
test.submit_turn("use fallback model metadata").await?;
assert_eq!(
response.single_request().instructions_text(),
BASE_INSTRUCTIONS
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn remote_models_get_model_info_uses_longest_matching_prefix() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -73,12 +102,28 @@ async fn remote_models_get_model_info_uses_longest_matching_prefix() -> Result<(
);
let specific = ModelInfo {
display_name: "GPT 5.3 Codex".to_string(),
base_instructions: "use specific prefix".to_string(),
model_messages: Some(ModelMessages {
instructions_template: Some("use specific prefix".to_string()),
instructions_variables: None,
approvals: None,
collaboration_modes: None,
auto_review: None,
permissions: None,
token_budget: None,
}),
..specific
};
let generic = ModelInfo {
display_name: "GPT 5.3".to_string(),
base_instructions: "use generic prefix".to_string(),
model_messages: Some(ModelMessages {
instructions_template: Some("use generic prefix".to_string()),
instructions_variables: None,
approvals: None,
collaboration_modes: None,
auto_review: None,
permissions: None,
token_budget: None,
}),
..generic
};
mount_models_once(
@@ -115,7 +160,10 @@ async fn remote_models_get_model_info_uses_longest_matching_prefix() -> Result<(
.await;
assert_eq!(model_info.slug, "gpt-5.3-codex-test");
assert_eq!(model_info.base_instructions, specific.base_instructions);
assert_eq!(
model_info.get_model_instructions(config.personality),
specific.get_model_instructions(config.personality)
);
Ok(())
}
@@ -487,7 +535,6 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> {
service_tiers: Vec::new(),
default_service_tier: None,
upgrade: None,
base_instructions: "base instructions".to_string(),
model_messages: None,
include_skills_usage_instructions: false,
supports_reasoning_summary_parameter: true,
@@ -705,7 +752,7 @@ async fn remote_models_truncation_policy_with_tool_output_override() -> Result<(
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn remote_models_apply_remote_base_instructions() -> Result<()> {
async fn remote_models_apply_legacy_instructions() -> Result<()> {
skip_if_no_network!(Ok(()));
skip_if_sandbox!(Ok(()));
@@ -716,7 +763,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> {
let model = "test-gpt-5-remote";
let remote_base = "Use the remote base instructions only.";
let remote_instructions = "Use the remote instructions template only.";
let remote_model = ModelInfo {
slug: model.to_string(),
display_name: "Parallel Remote".to_string(),
@@ -741,8 +788,15 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> {
service_tiers: Vec::new(),
default_service_tier: None,
upgrade: None,
base_instructions: remote_base.to_string(),
model_messages: None,
model_messages: Some(ModelMessages {
instructions_template: Some(remote_instructions.to_string()),
instructions_variables: None,
approvals: None,
collaboration_modes: None,
auto_review: None,
permissions: None,
token_budget: None,
}),
include_skills_usage_instructions: false,
supports_reasoning_summary_parameter: true,
default_reasoning_summary: ReasoningSummary::Auto,
@@ -761,21 +815,34 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> {
effective_context_window_percent: 95,
experimental_supported_tools: Vec::new(),
};
mount_models_once(
&server,
ModelsResponse {
models: vec![remote_model],
},
)
.await;
let mut models_response = serde_json::to_value(ModelsResponse {
models: vec![remote_model],
})?;
models_response["models"][0]
.as_object_mut()
.expect("model should serialize as an object")
.remove("model_messages");
Mock::given(method("GET"))
.and(path("/v1/models"))
.respond_with(ResponseTemplate::new(200).set_body_json(models_response))
.up_to_n_times(1)
.mount(&server)
.await;
let response_mock = mount_sse_once(
let response_mock = mount_sse_sequence(
&server,
sse(vec![
ev_response_created("resp-1"),
ev_assistant_message("msg-1", "done"),
ev_completed("resp-1"),
]),
vec![
sse(vec![
ev_response_created("resp-1"),
ev_assistant_message("msg-1", "done"),
ev_completed("resp-1"),
]),
sse(vec![
ev_response_created("resp-2"),
ev_assistant_message("msg-2", "done"),
ev_completed("resp-2"),
]),
],
)
.await;
@@ -795,6 +862,31 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> {
let models_manager = thread_manager.get_models_manager();
wait_for_model_available(&models_manager, model).await;
let cwd_path = cwd.abs();
let (sandbox_policy, permission_profile) =
turn_permission_fields(PermissionProfile::Disabled, cwd_path.as_path());
codex
.submit(Op::UserInput {
items: vec![UserInput::Text {
text: "hello base".into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
responsesapi_client_metadata: None,
additional_context: Default::default(),
thread_settings: codex_protocol::protocol::ThreadSettingsOverrides {
environments: Some(local_selections(cwd_path.clone())),
approval_policy: Some(AskForApproval::Never),
sandbox_policy: Some(sandbox_policy),
permission_profile,
summary: Some(ReasoningSummary::Auto),
..Default::default()
},
})
.await?;
wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await;
core_test_support::submit_thread_settings(
&codex,
codex_protocol::protocol::ThreadSettingsOverrides {
@@ -804,7 +896,6 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> {
)
.await?;
let cwd_path = cwd.abs();
let (sandbox_policy, permission_profile) =
turn_permission_fields(PermissionProfile::Disabled, cwd_path.as_path());
codex
@@ -832,9 +923,20 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> {
let base_model_info = models_manager
.get_model_info("gpt-5.2", &config.to_models_manager_config())
.await;
let body = response_mock.single_request().body_json();
let instructions = body["instructions"].as_str().unwrap();
assert_eq!(instructions, base_model_info.base_instructions);
let requests = response_mock.requests();
assert_eq!(requests.len(), 2, "expected two model requests");
let request = requests.last().expect("expected second model request");
assert_eq!(
request.instructions_text(),
base_model_info.get_model_instructions(config.personality)
);
assert!(
request
.message_input_texts("developer")
.iter()
.any(|text| text.contains(remote_instructions)),
"expected the model switch message to contain the remote instructions template"
);
Ok(())
}
@@ -1268,7 +1370,6 @@ fn test_remote_model_with_policy(
service_tiers: Vec::new(),
default_service_tier: None,
upgrade: None,
base_instructions: "base instructions".to_string(),
model_messages: None,
include_skills_usage_instructions: false,
supports_reasoning_summary_parameter: true,
-1
View File
@@ -2259,7 +2259,6 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re
service_tiers: Vec::new(),
default_service_tier: None,
upgrade: None,
base_instructions: "base instructions".to_string(),
model_messages: None,
include_skills_usage_instructions: false,
supports_reasoning_summary_parameter: true,
@@ -78,7 +78,6 @@ fn test_model_info(
service_tiers,
default_service_tier: None,
upgrade: None,
base_instructions: "base instructions".to_string(),
model_messages: None,
include_skills_usage_instructions: false,
supports_reasoning_summary_parameter: true,
-1
View File
@@ -1370,7 +1370,6 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an
service_tiers: Vec::new(),
default_service_tier: None,
upgrade: None,
base_instructions: "base instructions".to_string(),
model_messages: None,
include_skills_usage_instructions: false,
supports_reasoning_summary_parameter: true,