diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 34239c0c9..2edde6eb9 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1717,6 +1717,10 @@ "description": "Experimental / do not use. Selects the realtime websocket model/snapshot used for the `Op::RealtimeConversation` connection.", "type": "string" }, + "experimental_realtime_ws_startup_context": { + "description": "Experimental / do not use. Replaces the synthesized realtime startup context appended to websocket session instructions. An empty string disables startup context injection entirely.", + "type": "string" + }, "experimental_use_freeform_apply_patch": { "type": "boolean" }, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index e70f7c2b2..87f345b22 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -3091,6 +3091,7 @@ fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { experimental_realtime_ws_base_url: None, experimental_realtime_ws_model: None, experimental_realtime_ws_backend_prompt: None, + experimental_realtime_ws_startup_context: None, base_instructions: None, developer_instructions: None, compact_prompt: None, @@ -3224,6 +3225,7 @@ fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { experimental_realtime_ws_base_url: None, experimental_realtime_ws_model: None, experimental_realtime_ws_backend_prompt: None, + experimental_realtime_ws_startup_context: None, base_instructions: None, developer_instructions: None, compact_prompt: None, @@ -3355,6 +3357,7 @@ fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { experimental_realtime_ws_base_url: None, experimental_realtime_ws_model: None, experimental_realtime_ws_backend_prompt: None, + experimental_realtime_ws_startup_context: None, base_instructions: None, developer_instructions: None, compact_prompt: None, @@ -3472,6 +3475,7 @@ fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { experimental_realtime_ws_base_url: None, experimental_realtime_ws_model: None, experimental_realtime_ws_backend_prompt: None, + experimental_realtime_ws_startup_context: None, base_instructions: None, developer_instructions: None, compact_prompt: None, @@ -4432,6 +4436,34 @@ experimental_realtime_ws_backend_prompt = "prompt from config" Ok(()) } +#[test] +fn experimental_realtime_ws_startup_context_loads_from_config_toml() -> std::io::Result<()> { + let cfg: ConfigToml = toml::from_str( + r#" +experimental_realtime_ws_startup_context = "startup context from config" +"#, + ) + .expect("TOML deserialization should succeed"); + + assert_eq!( + cfg.experimental_realtime_ws_startup_context.as_deref(), + Some("startup context 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_ws_startup_context.as_deref(), + Some("startup context from config") + ); + Ok(()) +} + #[test] fn experimental_realtime_ws_model_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 f4a3063e9..3a250c09e 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -464,6 +464,10 @@ pub struct Config { /// websocket transport instructions (the `Op::RealtimeConversation` /// `/ws` session.update instructions) without changing normal prompts. pub experimental_realtime_ws_backend_prompt: Option, + /// Experimental / do not use. Replaces the synthesized realtime startup + /// context appended to websocket session instructions. An empty string + /// disables startup context injection entirely. + pub experimental_realtime_ws_startup_context: Option, /// When set, restricts ChatGPT login to a specific workspace identifier. pub forced_chatgpt_workspace_id: Option, @@ -1228,6 +1232,10 @@ pub struct ConfigToml { /// websocket transport instructions (the `Op::RealtimeConversation` /// `/ws` session.update instructions) without changing normal prompts. pub experimental_realtime_ws_backend_prompt: Option, + /// Experimental / do not use. Replaces the synthesized realtime startup + /// context appended to websocket session instructions. An empty string + /// disables startup context injection entirely. + pub experimental_realtime_ws_startup_context: Option, pub projects: Option>, /// Controls the web search tool mode: disabled, cached, or live. @@ -2405,6 +2413,7 @@ impl Config { experimental_realtime_ws_base_url: cfg.experimental_realtime_ws_base_url, 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, forced_chatgpt_workspace_id, forced_login_method, include_apply_patch_tool: include_apply_patch_tool_flag, diff --git a/codex-rs/core/src/realtime_conversation.rs b/codex-rs/core/src/realtime_conversation.rs index 4d8d6127d..8d69b6b9e 100644 --- a/codex-rs/core/src/realtime_conversation.rs +++ b/codex-rs/core/src/realtime_conversation.rs @@ -284,13 +284,19 @@ pub(crate) async fn handle_start( .experimental_realtime_ws_backend_prompt .clone() .unwrap_or(params.prompt); - let prompt = - match build_realtime_startup_context(sess.as_ref(), REALTIME_STARTUP_CONTEXT_TOKEN_BUDGET) - .await - { - Some(context) => format!("{prompt}\n\n{context}"), - None => prompt, - }; + let startup_context = match config.experimental_realtime_ws_startup_context.clone() { + Some(startup_context) => startup_context, + None => { + build_realtime_startup_context(sess.as_ref(), REALTIME_STARTUP_CONTEXT_TOKEN_BUDGET) + .await + .unwrap_or_default() + } + }; + let prompt = if startup_context.is_empty() { + prompt + } else { + format!("{prompt}\n\n{startup_context}") + }; let model = config.experimental_realtime_ws_model.clone(); let requested_session_id = params diff --git a/codex-rs/core/tests/suite/realtime_conversation.rs b/codex-rs/core/tests/suite/realtime_conversation.rs index 71976e00c..3a9aa2045 100644 --- a/codex-rs/core/tests/suite/realtime_conversation.rs +++ b/codex-rs/core/tests/suite/realtime_conversation.rs @@ -628,6 +628,123 @@ async fn conversation_uses_experimental_realtime_ws_backend_prompt_override() -> Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn conversation_uses_experimental_realtime_ws_startup_context_override() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_websocket_server(vec![ + vec![], + vec![vec![json!({ + "type": "session.updated", + "session": { "id": "sess_custom_context", "instructions": "prompt from config" } + })]], + ]) + .await; + + let mut builder = test_codex().with_config(|config| { + config.experimental_realtime_ws_backend_prompt = Some("prompt from config".to_string()); + config.experimental_realtime_ws_startup_context = + Some("custom startup context".to_string()); + }); + let test = builder.build_with_websocket_server(&server).await?; + seed_recent_thread( + &test, + "Recent work: cleaned up startup flows and reviewed websocket routing.", + "Investigate realtime startup context", + "custom-context", + ) + .await?; + fs::create_dir_all(test.workspace_path("docs"))?; + fs::write(test.workspace_path("README.md"), "workspace marker")?; + assert!(server.wait_for_handshakes(1, Duration::from_secs(2)).await); + + test.codex + .submit(Op::RealtimeConversationStart(ConversationStartParams { + prompt: "prompt from op".to_string(), + session_id: None, + })) + .await?; + + wait_for_event_match(&test.codex, |msg| match msg { + EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { + payload: RealtimeEvent::SessionUpdated { session_id, .. }, + }) if session_id == "sess_custom_context" => Some(Ok(())), + EventMsg::Error(err) => Some(Err(err.clone())), + _ => None, + }) + .await + .unwrap_or_else(|err: ErrorEvent| panic!("conversation start failed: {err:?}")); + + let startup_context_request = server.wait_for_request(1, 0).await; + let instructions = websocket_request_instructions(&startup_context_request) + .expect("custom startup context request should contain instructions"); + + assert_eq!(instructions, "prompt from config\n\ncustom startup context"); + assert!(!instructions.contains(STARTUP_CONTEXT_HEADER)); + assert!(!instructions.contains("## Machine / Workspace Map")); + + server.shutdown().await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn conversation_disables_realtime_startup_context_with_empty_override() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_websocket_server(vec![ + vec![], + vec![vec![json!({ + "type": "session.updated", + "session": { "id": "sess_no_context", "instructions": "prompt from config" } + })]], + ]) + .await; + + let mut builder = test_codex().with_config(|config| { + config.experimental_realtime_ws_backend_prompt = Some("prompt from config".to_string()); + config.experimental_realtime_ws_startup_context = Some(String::new()); + }); + let test = builder.build_with_websocket_server(&server).await?; + seed_recent_thread( + &test, + "Recent work: cleaned up startup flows and reviewed websocket routing.", + "Investigate realtime startup context", + "no-context", + ) + .await?; + fs::create_dir_all(test.workspace_path("docs"))?; + fs::write(test.workspace_path("README.md"), "workspace marker")?; + assert!(server.wait_for_handshakes(1, Duration::from_secs(2)).await); + + test.codex + .submit(Op::RealtimeConversationStart(ConversationStartParams { + prompt: "prompt from op".to_string(), + session_id: None, + })) + .await?; + + wait_for_event_match(&test.codex, |msg| match msg { + EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { + payload: RealtimeEvent::SessionUpdated { session_id, .. }, + }) if session_id == "sess_no_context" => Some(Ok(())), + EventMsg::Error(err) => Some(Err(err.clone())), + _ => None, + }) + .await + .unwrap_or_else(|err: ErrorEvent| panic!("conversation start failed: {err:?}")); + + let startup_context_request = server.wait_for_request(1, 0).await; + let instructions = websocket_request_instructions(&startup_context_request) + .expect("startup context disable request should contain instructions"); + + assert_eq!(instructions, "prompt from config"); + assert!(!instructions.contains(STARTUP_CONTEXT_HEADER)); + assert!(!instructions.contains("## Machine / Workspace Map")); + + server.shutdown().await; + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn conversation_start_injects_startup_context_from_thread_history() -> Result<()> { skip_if_no_network!(Ok(()));