Add realtime start instructions config override (#14270)

- add `realtime_start_instructions` config support
- thread it into realtime context updates, schema, docs, and tests
This commit is contained in:
Ahmed Ibrahim 2026-03-10 18:42:05 -07:00 committed by Michael Bolin
parent 31bf1dbe63
commit 39c1bc1c68
7 changed files with 134 additions and 3 deletions

View file

@ -1788,6 +1788,10 @@
"experimental_compact_prompt_file": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"experimental_realtime_start_instructions": {
"description": "Experimental / do not use. Replaces the built-in realtime start instructions inserted into developer messages when realtime becomes active.",
"type": "string"
},
"experimental_realtime_ws_backend_prompt": {
"description": "Experimental / do not use. Overrides only the realtime conversation websocket transport instructions (the `Op::RealtimeConversation` `/ws` session.update instructions) without changing normal prompts.",
"type": "string"

View file

@ -3965,6 +3965,7 @@ fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> {
personality: Some(Personality::Pragmatic),
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
realtime_audio: RealtimeAudioConfig::default(),
experimental_realtime_start_instructions: None,
experimental_realtime_ws_base_url: None,
experimental_realtime_ws_model: None,
experimental_realtime_ws_backend_prompt: None,
@ -4100,6 +4101,7 @@ fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> {
personality: Some(Personality::Pragmatic),
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
realtime_audio: RealtimeAudioConfig::default(),
experimental_realtime_start_instructions: None,
experimental_realtime_ws_base_url: None,
experimental_realtime_ws_model: None,
experimental_realtime_ws_backend_prompt: None,
@ -4233,6 +4235,7 @@ fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> {
personality: Some(Personality::Pragmatic),
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
realtime_audio: RealtimeAudioConfig::default(),
experimental_realtime_start_instructions: None,
experimental_realtime_ws_base_url: None,
experimental_realtime_ws_model: None,
experimental_realtime_ws_backend_prompt: None,
@ -4352,6 +4355,7 @@ fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> {
personality: Some(Personality::Pragmatic),
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
realtime_audio: RealtimeAudioConfig::default(),
experimental_realtime_start_instructions: None,
experimental_realtime_ws_base_url: None,
experimental_realtime_ws_model: None,
experimental_realtime_ws_backend_prompt: None,
@ -5261,6 +5265,34 @@ async fn feature_requirements_reject_legacy_aliases() {
);
}
#[test]
fn experimental_realtime_start_instructions_load_from_config_toml() -> std::io::Result<()> {
let cfg: ConfigToml = toml::from_str(
r#"
experimental_realtime_start_instructions = "start instructions from config"
"#,
)
.expect("TOML deserialization should succeed");
assert_eq!(
cfg.experimental_realtime_start_instructions.as_deref(),
Some("start instructions from config")
);
let codex_home = TempDir::new()?;
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.path().to_path_buf(),
)?;
assert_eq!(
config.experimental_realtime_start_instructions.as_deref(),
Some("start instructions from config")
);
Ok(())
}
#[test]
fn experimental_realtime_ws_base_url_loads_from_config_toml() -> std::io::Result<()> {
let cfg: ConfigToml = toml::from_str(

View file

@ -470,6 +470,10 @@ pub struct Config {
/// context appended to websocket session instructions. An empty string
/// disables startup context injection entirely.
pub experimental_realtime_ws_startup_context: Option<String>,
/// Experimental / do not use. Replaces the built-in realtime start
/// instructions inserted into developer messages when realtime becomes
/// active.
pub experimental_realtime_start_instructions: Option<String>,
/// When set, restricts ChatGPT login to a specific workspace identifier.
pub forced_chatgpt_workspace_id: Option<String>,
@ -1241,6 +1245,10 @@ pub struct ConfigToml {
/// context appended to websocket session instructions. An empty string
/// disables startup context injection entirely.
pub experimental_realtime_ws_startup_context: Option<String>,
/// Experimental / do not use. Replaces the built-in realtime start
/// instructions inserted into developer messages when realtime becomes
/// active.
pub experimental_realtime_start_instructions: Option<String>,
pub projects: Option<HashMap<String, ProjectConfig>>,
/// Controls the web search tool mode: disabled, cached, or live.
@ -2426,6 +2434,7 @@ impl Config {
experimental_realtime_ws_model: cfg.experimental_realtime_ws_model,
experimental_realtime_ws_backend_prompt: cfg.experimental_realtime_ws_backend_prompt,
experimental_realtime_ws_startup_context: cfg.experimental_realtime_ws_startup_context,
experimental_realtime_start_instructions: cfg.experimental_realtime_start_instructions,
forced_chatgpt_workspace_id,
forced_login_method,
include_apply_patch_tool: include_apply_patch_tool_flag,

View file

@ -75,7 +75,17 @@ pub(crate) fn build_realtime_update_item(
next.realtime_active,
) {
(Some(true), false) => Some(DeveloperInstructions::realtime_end_message("inactive")),
(Some(false), true) | (None, true) => Some(DeveloperInstructions::realtime_start_message()),
(Some(false), true) | (None, true) => Some(
if let Some(instructions) = next
.config
.experimental_realtime_start_instructions
.as_deref()
{
DeveloperInstructions::realtime_start_message_with_instructions(instructions)
} else {
DeveloperInstructions::realtime_start_message()
},
),
(Some(true), true) | (Some(false), false) => None,
(None, false) => previous_turn_settings
.and_then(|settings| settings.realtime_active)

View file

@ -161,6 +161,25 @@ fn assert_request_contains_realtime_start(request: &responses::ResponsesRequest)
);
}
fn assert_request_contains_custom_realtime_start(
request: &responses::ResponsesRequest,
instructions: &str,
) {
let body = request.body_json().to_string();
assert!(
body.contains("<realtime_conversation>"),
"expected request to preserve the realtime wrapper"
);
assert!(
body.contains(instructions),
"expected request to use custom realtime start instructions"
);
assert!(
!body.contains("Realtime conversation started."),
"expected request to replace the default realtime start instructions"
);
}
fn assert_request_contains_realtime_end(request: &responses::ResponsesRequest) {
let body = request.body_json().to_string();
assert!(
@ -1518,6 +1537,53 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_restates_realtime_sta
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn remote_request_uses_custom_experimental_realtime_start_instructions() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = wiremock::MockServer::start().await;
let realtime_server = start_remote_realtime_server().await;
let custom_instructions = "custom realtime start instructions";
let mut builder = remote_realtime_test_codex_builder(&realtime_server).with_config({
let custom_instructions = custom_instructions.to_string();
move |config| {
config.experimental_realtime_start_instructions = Some(custom_instructions);
}
});
let test = builder.build(&server).await?;
let responses_mock = responses::mount_sse_once(
&server,
responses::sse(vec![
responses::ev_assistant_message("m1", "REMOTE_FIRST_REPLY"),
responses::ev_completed("r1"),
]),
)
.await;
start_realtime_conversation(test.codex.as_ref()).await?;
test.codex
.submit(Op::UserInput {
items: vec![UserInput::Text {
text: "USER_ONE".to_string(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
})
.await?;
wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
assert_request_contains_custom_realtime_start(
&responses_mock.single_request(),
custom_instructions,
);
close_realtime_conversation(test.codex.as_ref()).await?;
realtime_server.shutdown().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn snapshot_request_shape_remote_pre_turn_compaction_restates_realtime_end() -> Result<()> {
skip_if_no_network!(Ok(()));

View file

@ -520,9 +520,12 @@ impl DeveloperInstructions {
}
pub fn realtime_start_message() -> Self {
Self::realtime_start_message_with_instructions(REALTIME_START_INSTRUCTIONS.trim())
}
pub fn realtime_start_message_with_instructions(instructions: &str) -> Self {
DeveloperInstructions::new(format!(
"{REALTIME_CONVERSATION_OPEN_TAG}\n{}\n{REALTIME_CONVERSATION_CLOSE_TAG}",
REALTIME_START_INSTRUCTIONS.trim()
"{REALTIME_CONVERSATION_OPEN_TAG}\n{instructions}\n{REALTIME_CONVERSATION_CLOSE_TAG}"
))
}

View file

@ -49,4 +49,11 @@ Plan preset. The string value `none` means "no reasoning" (an explicit Plan
override), not "inherit the global default". There is currently no separate
config value for "follow the global default in Plan mode".
## Realtime start instructions
`experimental_realtime_start_instructions` lets you replace the built-in
developer message Codex inserts when realtime becomes active. It only affects
the realtime start message in prompt history and does not change websocket
backend prompt settings or the realtime end/inactive message.
Ctrl+C/Ctrl+D quitting uses a ~1 second double-press hint (`ctrl + c again to quit`).