diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 9c83c6a8f..b77ed5719 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -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" diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index f15ec4dd5..707566eb1 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -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( diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 697f50d7c..41eaeabb9 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -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, + /// 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, /// When set, restricts ChatGPT login to a specific workspace identifier. pub forced_chatgpt_workspace_id: Option, @@ -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, + /// 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, pub projects: Option>, /// 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, diff --git a/codex-rs/core/src/context_manager/updates.rs b/codex-rs/core/src/context_manager/updates.rs index 63deb5c80..0d26c551e 100644 --- a/codex-rs/core/src/context_manager/updates.rs +++ b/codex-rs/core/src/context_manager/updates.rs @@ -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) diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index 9bea95363..b0f28fecc 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -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(""), + "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(())); diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 0d50370eb..fd353b12c 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -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}" )) } diff --git a/docs/config.md b/docs/config.md index fc9d62b8e..a810262a4 100644 --- a/docs/config.md +++ b/docs/config.md @@ -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`).