From 7c7ddb6734726efdf5b8b5428aa184c096fcafa9 Mon Sep 17 00:00:00 2001 From: Guinness Chen Date: Mon, 15 Jun 2026 16:16:59 -0700 Subject: [PATCH 1/2] Add realtime startup context toggle --- .../src/protocol/common.rs | 7 ++ .../src/protocol/v2/realtime.rs | 3 + codex-rs/app-server/README.md | 4 +- .../src/request_processors/turn_processor.rs | 1 + .../tests/suite/v2/experimental_api.rs | 2 + .../tests/suite/v2/realtime_conversation.rs | 79 +++++++++++++++++++ codex-rs/core/src/realtime_conversation.rs | 18 +++-- codex-rs/core/tests/suite/compact_remote.rs | 1 + .../core/tests/suite/realtime_conversation.rs | 38 +++++++++ codex-rs/protocol/src/protocol.rs | 2 + 10 files changed, 148 insertions(+), 7 deletions(-) diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 50ed39364ba7..25601de82457 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -3029,6 +3029,7 @@ mod tests { thread_id: "thr_123".to_string(), model: Some("realtime-treatment-model".to_string()), output_modality: RealtimeOutputModality::Audio, + include_startup_context: Some(false), prompt: Some(Some("You are on a call".to_string())), realtime_session_id: Some("sess_456".to_string()), transport: None, @@ -3045,6 +3046,7 @@ mod tests { "threadId": "thr_123", "model": "realtime-treatment-model", "outputModality": "audio", + "includeStartupContext": false, "prompt": "You are on a call", "realtimeSessionId": "sess_456", "transport": null, @@ -3066,6 +3068,7 @@ mod tests { thread_id: "thr_123".to_string(), model: None, output_modality: RealtimeOutputModality::Audio, + include_startup_context: None, prompt: None, realtime_session_id: None, transport: None, @@ -3082,6 +3085,7 @@ mod tests { "threadId": "thr_123", "model": null, "outputModality": "audio", + "includeStartupContext": null, "realtimeSessionId": null, "transport": null, "version": null, @@ -3098,6 +3102,7 @@ mod tests { thread_id: "thr_123".to_string(), model: None, output_modality: RealtimeOutputModality::Audio, + include_startup_context: None, prompt: Some(None), realtime_session_id: None, transport: None, @@ -3114,6 +3119,7 @@ mod tests { "threadId": "thr_123", "model": null, "outputModality": "audio", + "includeStartupContext": null, "prompt": null, "realtimeSessionId": null, "transport": null, @@ -3273,6 +3279,7 @@ mod tests { thread_id: "thr_123".to_string(), model: None, output_modality: RealtimeOutputModality::Audio, + include_startup_context: None, prompt: Some(Some("You are on a call".to_string())), realtime_session_id: None, transport: None, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/realtime.rs b/codex-rs/app-server-protocol/src/protocol/v2/realtime.rs index bde44f9a00c5..0d4784329582 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/realtime.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/realtime.rs @@ -76,6 +76,9 @@ pub struct ThreadRealtimeStartParams { /// Selects text or audio output for the realtime session. Transport and voice stay /// independent so clients can choose how they connect separately from what the model emits. pub output_modality: RealtimeOutputModality, + /// Set to false to start without Codex's startup context. Omitted or null includes it. + #[ts(optional = nullable)] + pub include_startup_context: Option, #[serde( default, deserialize_with = "crate::protocol::serde_helpers::deserialize_double_option", diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 0e8b5ba20dea..84d9452ed640 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -165,7 +165,7 @@ Example with notification opt-out: - `thread/inject_items` — append raw Responses API items to a loaded thread’s model-visible history without starting a user turn; returns `{}` on success. - `turn/steer` — add user input to an already in-flight regular turn without starting a new turn; returns the active `turnId` that accepted the input. `clientUserMessageId` is optional; when supplied, the corresponding `userMessage` item echoes it as `clientId`. Review and manual compaction turns reject `turn/steer`. - `turn/interrupt` — request cancellation of an in-flight turn by `(thread_id, turn_id)`; success is an empty `{}` response and the turn finishes with `status: "interrupted"`. -- `thread/realtime/start` — start a thread-scoped realtime session (experimental); pass `outputModality: "text"` or `outputModality: "audio"` to choose model output, and optionally pass `model` and `version` to override configured realtime selection for this session only. Returns `{}` and streams `thread/realtime/*` notifications. Omit `transport` for the websocket transport, or pass `{ "type": "webrtc", "sdp": "..." }` to create a WebRTC session from a browser-generated SDP offer; the remote answer SDP is emitted as `thread/realtime/sdp`. +- `thread/realtime/start` — start a thread-scoped realtime session (experimental); pass `outputModality: "text"` or `outputModality: "audio"` to choose model output, optionally pass `model` and `version` to override configured realtime selection for this session only, and pass `includeStartupContext: false` to omit Codex's generated startup context. Returns `{}` and streams `thread/realtime/*` notifications. Omit `transport` for the websocket transport, or pass `{ "type": "webrtc", "sdp": "..." }` to create a WebRTC session from a browser-generated SDP offer; the remote answer SDP is emitted as `thread/realtime/sdp`. - `thread/realtime/appendAudio` — append an input audio chunk to the active realtime session (experimental); returns `{}`. - `thread/realtime/appendText` — append text input to the active realtime session with a required `role` of `user` or `developer` (experimental); returns `{}`. Older clients that omit `role` default to `user`. - `thread/realtime/stop` — stop the active realtime session for the thread (experimental); returns `{}`. @@ -870,6 +870,8 @@ Omit `prompt` to use Codex's default realtime backend prompt. Send `prompt: null `prompt: ""` when the session should start without that default backend prompt. Clients may also pass `model` and `version` on `thread/realtime/start` to select a different realtime session configuration without changing thread or user config. +Pass `includeStartupContext: false` to skip Codex's startup context for this +session while still using the selected backend prompt. ```javascript await pc.setRemoteDescription({ diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index c8074b8dbb31..33334723a582 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -938,6 +938,7 @@ impl TurnRequestProcessor { architecture: params.architecture, model: params.model, output_modality: params.output_modality, + include_startup_context: params.include_startup_context.unwrap_or(true), prompt: params.prompt, realtime_session_id: params.realtime_session_id, transport: params.transport.map(|transport| match transport { diff --git a/codex-rs/app-server/tests/suite/v2/experimental_api.rs b/codex-rs/app-server/tests/suite/v2/experimental_api.rs index 9e45cf256cae..e669235710e2 100644 --- a/codex-rs/app-server/tests/suite/v2/experimental_api.rs +++ b/codex-rs/app-server/tests/suite/v2/experimental_api.rs @@ -83,6 +83,7 @@ async fn realtime_conversation_start_requires_experimental_api_capability() -> R thread_id: "thr_123".to_string(), model: None, output_modality: RealtimeOutputModality::Audio, + include_startup_context: None, prompt: Some(Some("hello".to_string())), realtime_session_id: None, transport: None, @@ -192,6 +193,7 @@ async fn realtime_webrtc_start_requires_experimental_api_capability() -> Result< thread_id: "thr_123".to_string(), model: None, output_modality: RealtimeOutputModality::Audio, + include_startup_context: None, prompt: Some(Some("hello".to_string())), realtime_session_id: None, transport: Some(ThreadRealtimeStartTransport::Webrtc { diff --git a/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs b/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs index 98549ef36c88..a94d058a72f9 100644 --- a/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs +++ b/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs @@ -318,6 +318,7 @@ impl RealtimeE2eHarness { thread_id: self.thread_id.clone(), model: None, output_modality: RealtimeOutputModality::Audio, + include_startup_context: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: Some(ThreadRealtimeStartTransport::Webrtc { @@ -567,6 +568,7 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> { thread_id: thread_start.thread.id.clone(), model: Some("realtime-treatment-model".to_string()), output_modality: RealtimeOutputModality::Audio, + include_startup_context: None, prompt: None, realtime_session_id: None, transport: None, @@ -780,6 +782,78 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> { Ok(()) } +#[tokio::test] +async fn realtime_start_can_skip_startup_context() -> Result<()> { + skip_if_no_network!(Ok(())); + + let responses_server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let realtime_server = start_websocket_server(vec![vec![vec![json!({ + "type": "session.updated", + "session": { "id": "sess_backend", "instructions": "backend prompt" } + })]]]) + .await; + + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + &responses_server.uri(), + realtime_server.uri(), + /*realtime_enabled*/ true, + StartupContextConfig::Generated, + )?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + login_with_api_key(&mut mcp, "sk-test-key").await?; + + let thread_start_request_id = mcp + .send_thread_start_request(ThreadStartParams::default()) + .await?; + let thread_start_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_start_request_id)), + ) + .await??; + let thread_start: ThreadStartResponse = to_response(thread_start_response)?; + + let start_request_id = mcp + .send_thread_realtime_start_request(ThreadRealtimeStartParams { + architecture: None, + thread_id: thread_start.thread.id.clone(), + model: None, + output_modality: RealtimeOutputModality::Audio, + include_startup_context: Some(false), + prompt: None, + realtime_session_id: None, + transport: None, + version: None, + voice: None, + }) + .await?; + let start_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_request_id)), + ) + .await??; + let _: ThreadRealtimeStartResponse = to_response(start_response)?; + + read_notification::(&mut mcp, "thread/realtime/started") + .await?; + + let startup_context_request = realtime_server + .wait_for_request(/*connection_index*/ 0, /*request_index*/ 0) + .await; + let startup_context_body = startup_context_request.body_json(); + let instructions = startup_context_body["session"]["instructions"] + .as_str() + .context("expected realtime instructions")?; + assert_eq!(instructions, "backend prompt"); + assert!(!instructions.contains(STARTUP_CONTEXT_HEADER)); + + realtime_server.shutdown().await; + Ok(()) +} + #[tokio::test] async fn realtime_text_output_modality_requests_text_output_and_final_transcript() -> Result<()> { skip_if_no_network!(Ok(())); @@ -843,6 +917,7 @@ async fn realtime_text_output_modality_requests_text_output_and_final_transcript thread_id: thread_start.thread.id.clone(), model: None, output_modality: RealtimeOutputModality::Text, + include_startup_context: None, prompt: None, realtime_session_id: None, transport: None, @@ -1020,6 +1095,7 @@ async fn realtime_conversation_stop_emits_closed_notification() -> Result<()> { thread_id: thread_start.thread.id.clone(), model: None, output_modality: RealtimeOutputModality::Audio, + include_startup_context: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: None, @@ -1120,6 +1196,7 @@ async fn realtime_webrtc_start_emits_sdp_notification() -> Result<()> { thread_id: thread_id.clone(), model: None, output_modality: RealtimeOutputModality::Audio, + include_startup_context: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: Some(ThreadRealtimeStartTransport::Webrtc { @@ -2168,6 +2245,7 @@ async fn realtime_webrtc_start_surfaces_backend_error() -> Result<()> { thread_id: thread_start.thread.id, model: None, output_modality: RealtimeOutputModality::Audio, + include_startup_context: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: Some(ThreadRealtimeStartTransport::Webrtc { @@ -2230,6 +2308,7 @@ async fn realtime_conversation_requires_feature_flag() -> Result<()> { thread_id: thread_start.thread.id.clone(), model: None, output_modality: RealtimeOutputModality::Audio, + include_startup_context: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: None, diff --git a/codex-rs/core/src/realtime_conversation.rs b/codex-rs/core/src/realtime_conversation.rs index a1a861c8782c..d41a9d9d5d3b 100644 --- a/codex-rs/core/src/realtime_conversation.rs +++ b/codex-rs/core/src/realtime_conversation.rs @@ -675,6 +675,7 @@ async fn prepare_realtime_start( params.prompt, params.realtime_session_id, params.output_modality, + params.include_startup_context, version, params.voice, ) @@ -742,6 +743,7 @@ pub(crate) async fn build_realtime_session_config( prompt: Option>, realtime_session_id: Option, output_modality: RealtimeOutputModality, + include_startup_context: bool, version: RealtimeWsVersion, voice: Option, ) -> CodexResult { @@ -750,13 +752,17 @@ pub(crate) async fn build_realtime_session_config( prompt, config.experimental_realtime_ws_backend_prompt.clone(), ); - 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 startup_context = if include_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() + } } + } else { + String::new() }; let prompt = match (prompt.is_empty(), startup_context.is_empty()) { (true, true) => String::new(), diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index 781592875fc1..68bf92db72f6 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -204,6 +204,7 @@ async fn start_realtime_conversation(codex: &codex_core::CodexThread) -> Result< architecture: None, model: None, output_modality: RealtimeOutputModality::Audio, + include_startup_context: true, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: None, diff --git a/codex-rs/core/tests/suite/realtime_conversation.rs b/codex-rs/core/tests/suite/realtime_conversation.rs index 85bc1827f043..fb097fa77dde 100644 --- a/codex-rs/core/tests/suite/realtime_conversation.rs +++ b/codex-rs/core/tests/suite/realtime_conversation.rs @@ -287,6 +287,7 @@ async fn conversation_start_audio_text_close_round_trip() -> Result<()> { architecture: None, model: None, output_modality: RealtimeOutputModality::Audio, + include_startup_context: true, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: None, @@ -429,6 +430,7 @@ async fn conversation_start_defaults_to_v2_and_gpt_realtime_1_5() -> Result<()> architecture: None, model: None, output_modality: RealtimeOutputModality::Audio, + include_startup_context: true, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: None, @@ -520,6 +522,7 @@ async fn conversation_webrtc_start_posts_generated_session() -> Result<()> { architecture: None, model: Some("session-override-model".to_string()), output_modality: RealtimeOutputModality::Audio, + include_startup_context: true, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: Some(ConversationStartTransport::Webrtc { @@ -700,6 +703,7 @@ async fn conversation_webrtc_start_uses_avas_architecture_query() -> Result<()> architecture: Some(RealtimeConversationArchitecture::Avas), model: None, output_modality: RealtimeOutputModality::Audio, + include_startup_context: true, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: Some(ConversationStartTransport::Webrtc { @@ -798,6 +802,7 @@ async fn conversation_webrtc_start_uses_configured_call_base_url_for_avas() -> R architecture: Some(RealtimeConversationArchitecture::Avas), model: None, output_modality: RealtimeOutputModality::Audio, + include_startup_context: true, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: Some(ConversationStartTransport::Webrtc { @@ -888,6 +893,7 @@ async fn conversation_webrtc_close_while_sideband_connecting_drops_pending_join( architecture: None, model: None, output_modality: RealtimeOutputModality::Audio, + include_startup_context: true, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: Some(ConversationStartTransport::Webrtc { @@ -975,6 +981,7 @@ async fn conversation_webrtc_sideband_connect_failure_closes_with_error() -> Res architecture: None, model: None, output_modality: RealtimeOutputModality::Audio, + include_startup_context: true, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: Some(ConversationStartTransport::Webrtc { @@ -1064,6 +1071,7 @@ async fn conversation_start_uses_openai_env_key_fallback_with_chatgpt_auth() -> architecture: None, model: None, output_modality: RealtimeOutputModality::Audio, + include_startup_context: true, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: None, @@ -1133,6 +1141,7 @@ async fn conversation_transport_close_emits_closed_event() -> Result<()> { architecture: None, model: None, output_modality: RealtimeOutputModality::Audio, + include_startup_context: true, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: None, @@ -1226,6 +1235,7 @@ async fn conversation_start_preflight_failure_emits_realtime_error_only() -> Res architecture: None, model: None, output_modality: RealtimeOutputModality::Audio, + include_startup_context: true, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: None, @@ -1273,6 +1283,7 @@ async fn conversation_start_connect_failure_emits_realtime_error_only() -> Resul architecture: None, model: None, output_modality: RealtimeOutputModality::Audio, + include_startup_context: true, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: None, @@ -1368,6 +1379,7 @@ async fn conversation_second_start_replaces_runtime() -> Result<()> { architecture: None, model: None, output_modality: RealtimeOutputModality::Audio, + include_startup_context: true, prompt: Some(Some("old".to_string())), realtime_session_id: Some("conv_old".to_string()), transport: None, @@ -1394,6 +1406,7 @@ async fn conversation_second_start_replaces_runtime() -> Result<()> { architecture: None, model: None, output_modality: RealtimeOutputModality::Audio, + include_startup_context: true, prompt: Some(Some("new".to_string())), realtime_session_id: Some("conv_new".to_string()), transport: None, @@ -1491,6 +1504,7 @@ async fn conversation_uses_experimental_realtime_ws_base_url_override() -> Resul architecture: None, model: None, output_modality: RealtimeOutputModality::Audio, + include_startup_context: true, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: None, @@ -1556,6 +1570,7 @@ async fn conversation_uses_default_realtime_backend_prompt() -> Result<()> { architecture: None, model: None, output_modality: RealtimeOutputModality::Audio, + include_startup_context: true, prompt: None, realtime_session_id: None, transport: None, @@ -1629,6 +1644,7 @@ async fn conversation_uses_empty_instructions_for_null_or_empty_prompt() -> Resu architecture: None, model: None, output_modality: RealtimeOutputModality::Audio, + include_startup_context: true, prompt, realtime_session_id: None, transport: None, @@ -1695,6 +1711,7 @@ async fn conversation_uses_explicit_start_voice() -> Result<()> { architecture: None, model: None, output_modality: RealtimeOutputModality::Audio, + include_startup_context: true, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: None, @@ -1753,6 +1770,7 @@ async fn conversation_uses_configured_realtime_voice() -> Result<()> { architecture: None, model: None, output_modality: RealtimeOutputModality::Audio, + include_startup_context: true, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: None, @@ -1799,6 +1817,7 @@ async fn conversation_rejects_voice_for_wrong_realtime_version() -> Result<()> { architecture: None, model: None, output_modality: RealtimeOutputModality::Audio, + include_startup_context: true, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: None, @@ -1846,6 +1865,7 @@ async fn conversation_uses_experimental_realtime_ws_backend_prompt_override() -> architecture: None, model: None, output_modality: RealtimeOutputModality::Audio, + include_startup_context: true, prompt: Some(Some("prompt from op".to_string())), realtime_session_id: None, transport: None, @@ -1919,6 +1939,7 @@ async fn conversation_uses_experimental_realtime_ws_startup_context_override() - architecture: None, model: None, output_modality: RealtimeOutputModality::Audio, + include_startup_context: true, prompt: Some(Some("prompt from op".to_string())), realtime_session_id: None, transport: None, @@ -1986,6 +2007,7 @@ async fn conversation_disables_realtime_startup_context_with_empty_override() -> architecture: None, model: None, output_modality: RealtimeOutputModality::Audio, + include_startup_context: true, prompt: Some(Some("prompt from op".to_string())), realtime_session_id: None, transport: None, @@ -2046,6 +2068,7 @@ async fn conversation_start_injects_startup_context_from_thread_history() -> Res architecture: None, model: None, output_modality: RealtimeOutputModality::Audio, + include_startup_context: true, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: None, @@ -2158,6 +2181,7 @@ async fn conversation_startup_context_current_thread_selects_many_turns_by_budge architecture: None, model: None, output_modality: RealtimeOutputModality::Audio, + include_startup_context: true, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: None, @@ -2266,6 +2290,7 @@ async fn conversation_startup_context_falls_back_to_workspace_map() -> Result<() architecture: None, model: None, output_modality: RealtimeOutputModality::Audio, + include_startup_context: true, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: None, @@ -2326,6 +2351,7 @@ async fn conversation_startup_context_is_truncated_and_sent_once_per_start() -> architecture: None, model: None, output_modality: RealtimeOutputModality::Audio, + include_startup_context: true, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: None, @@ -2407,6 +2433,7 @@ async fn conversation_user_text_turn_is_not_sent_to_realtime() -> Result<()> { architecture: None, model: None, output_modality: RealtimeOutputModality::Audio, + include_startup_context: true, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: None, @@ -2504,6 +2531,7 @@ async fn realtime_v2_noop_tool_call_returns_empty_function_output_without_respon architecture: None, model: None, output_modality: RealtimeOutputModality::Audio, + include_startup_context: true, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: None, @@ -2603,6 +2631,7 @@ async fn conversation_mirrors_assistant_message_text_to_realtime_handoff() -> Re architecture: None, model: None, output_modality: RealtimeOutputModality::Audio, + include_startup_context: true, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: None, @@ -2740,6 +2769,7 @@ async fn conversation_handoff_persists_across_item_done_until_turn_complete() -> architecture: None, model: None, output_modality: RealtimeOutputModality::Audio, + include_startup_context: true, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: None, @@ -2892,6 +2922,7 @@ async fn inbound_handoff_request_starts_turn() -> Result<()> { architecture: None, model: None, output_modality: RealtimeOutputModality::Audio, + include_startup_context: true, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: None, @@ -2994,6 +3025,7 @@ async fn inbound_handoff_request_uses_active_transcript() -> Result<()> { architecture: None, model: None, output_modality: RealtimeOutputModality::Audio, + include_startup_context: true, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: None, @@ -3097,6 +3129,7 @@ async fn inbound_handoff_request_sends_transcript_delta_after_each_handoff() -> architecture: None, model: None, output_modality: RealtimeOutputModality::Audio, + include_startup_context: true, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: None, @@ -3198,6 +3231,7 @@ async fn inbound_conversation_item_does_not_start_turn_and_still_forwards_audio( architecture: None, model: None, output_modality: RealtimeOutputModality::Audio, + include_startup_context: true, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: None, @@ -3321,6 +3355,7 @@ async fn delegated_turn_user_role_echo_does_not_redelegate_and_still_forwards_au architecture: None, model: None, output_modality: RealtimeOutputModality::Audio, + include_startup_context: true, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: None, @@ -3474,6 +3509,7 @@ async fn inbound_handoff_request_does_not_block_realtime_event_forwarding() -> R architecture: None, model: None, output_modality: RealtimeOutputModality::Audio, + include_startup_context: true, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: None, @@ -3611,6 +3647,7 @@ async fn inbound_handoff_request_steers_active_turn() -> Result<()> { architecture: None, model: None, output_modality: RealtimeOutputModality::Audio, + include_startup_context: true, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: None, @@ -3764,6 +3801,7 @@ async fn inbound_handoff_request_starts_turn_and_does_not_block_realtime_audio() architecture: None, model: None, output_modality: RealtimeOutputModality::Audio, + include_startup_context: true, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: None, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index b41a02b8892d..127a4ae65257 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -199,6 +199,8 @@ pub struct ConversationStartParams { pub model: Option, /// Selects whether the realtime session should produce text or audio output. pub output_modality: RealtimeOutputModality, + /// Whether to append Codex's startup context to the realtime backend prompt. + pub include_startup_context: bool, pub prompt: Option>, pub realtime_session_id: Option, pub transport: Option, From 5979d2049f94aca849e7af3d30896c138d52d285 Mon Sep 17 00:00:00 2001 From: Guinness Chen Date: Mon, 15 Jun 2026 16:47:53 -0700 Subject: [PATCH 2/2] codex: fix CI failure on PR #28405 --- codex-rs/codex-api/tests/clients.rs | 1 + codex-rs/core/src/guardian/tests.rs | 1 + codex-rs/core/src/realtime_conversation.rs | 44 ++++++++++------------ 3 files changed, 22 insertions(+), 24 deletions(-) diff --git a/codex-rs/codex-api/tests/clients.rs b/codex-rs/codex-api/tests/clients.rs index 624d008e5fb2..e362560caca4 100644 --- a/codex-rs/codex-api/tests/clients.rs +++ b/codex-rs/codex-api/tests/clients.rs @@ -312,6 +312,7 @@ async fn responses_client_stream_request_preserves_exact_json_body() -> Result<( role: "user".into(), content: vec![ContentItem::InputText { text: "hi".into() }], phase: None, + metadata: None, }], tools: Vec::new(), tool_choice: "auto".into(), diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index da03eb95a34a..fefe15cb7780 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -1696,6 +1696,7 @@ async fn guardian_review_request_layout_matches_model_visible_request_snapshot() ), }], phase: None, + metadata: None, }], ) .await; diff --git a/codex-rs/core/src/realtime_conversation.rs b/codex-rs/core/src/realtime_conversation.rs index ed69fc9028c3..2b5eb91a232e 100644 --- a/codex-rs/core/src/realtime_conversation.rs +++ b/codex-rs/core/src/realtime_conversation.rs @@ -698,6 +698,7 @@ async fn prepare_realtime_start( let config = sess.get_config().await; let transport = params .transport + .clone() .unwrap_or(ConversationStartTransport::Websocket); let mut api_provider = provider.to_api_provider(Some(AuthMode::ApiKey))?; if let Some(realtime_ws_base_url) = &config.experimental_realtime_ws_base_url { @@ -720,17 +721,7 @@ async fn prepare_realtime_start( &transport, config.realtime.session_type, )?; - let session_config = build_realtime_session_config( - sess, - params.model, - params.prompt, - params.realtime_session_id, - params.output_modality, - params.include_startup_context, - version, - params.voice, - ) - .await?; + let session_config = build_realtime_session_config(sess, ¶ms, version).await?; let requested_realtime_session_id = session_config.session_id.clone(); let extra_headers = match transport { ConversationStartTransport::Websocket => { @@ -792,20 +783,15 @@ fn validate_realtime_architecture( pub(crate) async fn build_realtime_session_config( sess: &Arc, - model: Option, - prompt: Option>, - realtime_session_id: Option, - output_modality: RealtimeOutputModality, - include_startup_context: bool, + params: &ConversationStartParams, version: RealtimeWsVersion, - voice: Option, ) -> CodexResult { let config = sess.get_config().await; let prompt = prepare_realtime_backend_prompt( - prompt, + params.prompt.clone(), config.experimental_realtime_ws_backend_prompt.clone(), ); - let startup_context = if include_startup_context { + let startup_context = if params.include_startup_context { match config.experimental_realtime_ws_startup_context.clone() { Some(startup_context) => startup_context, None => { @@ -824,7 +810,9 @@ pub(crate) async fn build_realtime_session_config( (false, false) => format!("{prompt}\n\n{startup_context}"), }; let model = Some( - model + params + .model + .clone() .or_else(|| config.experimental_realtime_ws_model.clone()) .unwrap_or_else(|| DEFAULT_REALTIME_MODEL.to_string()), ); @@ -832,7 +820,9 @@ pub(crate) async fn build_realtime_session_config( RealtimeWsVersion::V1 => RealtimeEventParser::V1, RealtimeWsVersion::V2 => RealtimeEventParser::RealtimeV2, }; - if version == RealtimeWsVersion::V1 && matches!(output_modality, RealtimeOutputModality::Text) { + if version == RealtimeWsVersion::V1 + && matches!(params.output_modality, RealtimeOutputModality::Text) + { return Err(CodexErr::InvalidRequest( "text realtime output modality requires realtime v2".to_string(), )); @@ -841,17 +831,23 @@ pub(crate) async fn build_realtime_session_config( RealtimeWsMode::Conversational => RealtimeSessionMode::Conversational, RealtimeWsMode::Transcription => RealtimeSessionMode::Transcription, }; - let voice = voice + let voice = params + .voice .or(config.realtime.voice) .unwrap_or_else(|| default_realtime_voice(version)); validate_realtime_voice(version, voice)?; Ok(RealtimeSessionConfig { instructions: prompt, model, - session_id: Some(realtime_session_id.unwrap_or_else(|| sess.thread_id.to_string())), + session_id: Some( + params + .realtime_session_id + .clone() + .unwrap_or_else(|| sess.thread_id.to_string()), + ), event_parser, session_mode, - output_modality, + output_modality: params.output_modality, voice, }) }