diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst index b18386f968..00075353ea 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst differ diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index b710f18f14..03778a3f25 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -4083,6 +4083,7 @@ mod tests { realtime_end_instructions: Some("Resume normal text responses.".to_string()), prompt: Some(Some("You are on a call".to_string())), realtime_session_id: Some("sess_456".to_string()), + voice_session_id: Some("voice_789".to_string()), transport: None, version: Some(RealtimeConversationVersion::V3), voice: Some(RealtimeVoice::Marin), @@ -4122,6 +4123,7 @@ mod tests { "realtimeEndInstructions": "Resume normal text responses.", "prompt": "You are on a call", "realtimeSessionId": "sess_456", + "voiceSessionId": "voice_789", "transport": null, "version": "v3", "voice": "marin" @@ -4154,6 +4156,7 @@ mod tests { realtime_end_instructions: None, prompt: None, realtime_session_id: None, + voice_session_id: None, transport: None, version: None, voice: None, @@ -4179,6 +4182,7 @@ mod tests { "realtimeStartInstructions": null, "realtimeEndInstructions": null, "realtimeSessionId": null, + "voiceSessionId": null, "transport": null, "version": null, "voice": null @@ -4207,6 +4211,7 @@ mod tests { realtime_end_instructions: None, prompt: Some(None), realtime_session_id: None, + voice_session_id: None, transport: None, version: None, voice: None, @@ -4233,6 +4238,7 @@ mod tests { "realtimeEndInstructions": null, "prompt": null, "realtimeSessionId": null, + "voiceSessionId": null, "transport": null, "version": null, "voice": null 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 fa79c1fd87..31a4b10e5e 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/realtime.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/realtime.rs @@ -257,6 +257,10 @@ pub struct ThreadRealtimeStartParams { pub prompt: Option>, #[ts(optional = nullable)] pub realtime_session_id: Option, + /// Client-generated identifier for the product voice session. This is distinct from + /// `realtimeSessionId`. + #[ts(optional = nullable)] + pub voice_session_id: Option, #[ts(optional = nullable)] pub transport: Option, /// Overrides the configured realtime protocol version for this session only. 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 1b9a253bec..435f0e3e0a 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -1283,6 +1283,7 @@ impl TurnRequestProcessor { realtime_end_instructions: params.realtime_end_instructions, prompt: params.prompt, realtime_session_id: params.realtime_session_id, + voice_session_id: params.voice_session_id, transport: params.transport.map(|transport| match transport { ThreadRealtimeStartTransport::Websocket => { ConversationStartTransport::Websocket 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 b3b33bc385..39d0d9b65f 100644 --- a/codex-rs/app-server/tests/suite/v2/experimental_api.rs +++ b/codex-rs/app-server/tests/suite/v2/experimental_api.rs @@ -149,6 +149,7 @@ async fn realtime_conversation_start_requires_experimental_api_capability() -> R realtime_end_instructions: None, prompt: Some(Some("hello".to_string())), realtime_session_id: None, + voice_session_id: None, transport: None, version: None, voice: None, @@ -291,6 +292,7 @@ async fn realtime_webrtc_start_requires_experimental_api_capability() -> Result< realtime_end_instructions: None, prompt: Some(Some("hello".to_string())), realtime_session_id: None, + voice_session_id: None, transport: Some(ThreadRealtimeStartTransport::Webrtc { sdp: "v=offer\r\n".to_string(), }), 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 e4f5029ce0..e5cd011a83 100644 --- a/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs +++ b/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs @@ -411,6 +411,7 @@ impl RealtimeE2eHarness { realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, + voice_session_id: None, transport: Some(ThreadRealtimeStartTransport::Webrtc { sdp: offer_sdp.to_string(), }), @@ -473,6 +474,7 @@ impl RealtimeE2eHarness { realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, + voice_session_id: None, transport: None, version: None, voice: None, @@ -511,6 +513,7 @@ impl RealtimeE2eHarness { realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, + voice_session_id: None, transport: None, version: Some(RealtimeConversationVersion::V3), voice: None, @@ -729,6 +732,7 @@ async fn realtime_conversation_streams_timeline_items() -> Result<()> { realtime_end_instructions: None, prompt: None, realtime_session_id: None, + voice_session_id: None, transport: None, version: None, voice: None, @@ -927,6 +931,7 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> { realtime_end_instructions: None, prompt: None, realtime_session_id: None, + voice_session_id: None, transport: None, version: None, voice: Some(RealtimeVoice::Cedar), @@ -1507,6 +1512,7 @@ async fn realtime_start_can_skip_startup_context() -> Result<()> { realtime_end_instructions: None, prompt: None, realtime_session_id: None, + voice_session_id: None, transport: None, version: None, voice: None, @@ -1605,6 +1611,7 @@ async fn realtime_text_output_modality_requests_text_output_and_final_transcript realtime_end_instructions: None, prompt: None, realtime_session_id: None, + voice_session_id: None, transport: None, version: None, voice: None, @@ -1822,6 +1829,7 @@ async fn realtime_conversation_stop_emits_closed_notification( realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, + voice_session_id: None, transport: None, version: Some(RealtimeConversationVersion::V1), voice: None, @@ -1980,6 +1988,7 @@ async fn realtime_mode_uses_client_instructions_on_entry_and_exit() -> Result<() realtime_end_instructions: Some(end_instructions.to_string()), prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, + voice_session_id: None, transport: None, version: None, voice: None, @@ -2266,6 +2275,7 @@ async fn realtime_webrtc_start_emits_sdp_notification() -> Result<()> { realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, + voice_session_id: None, transport: Some(ThreadRealtimeStartTransport::Webrtc { sdp: "v=offer\r\n".to_string(), }), @@ -2458,6 +2468,7 @@ async fn existing_call_attaches_without_reinitializing_the_client_session( expected_handshake_uri: &str, ) -> Result<()> { skip_if_no_network!(Ok(())); + let voice_session_id = "voice-session-client-owned"; let mut harness = RealtimeE2eHarness::new( RealtimeTestVersion::V1, @@ -2485,6 +2496,7 @@ async fn existing_call_attaches_without_reinitializing_the_client_session( realtime_end_instructions: None, prompt: None, realtime_session_id: realtime_session_id.map(str::to_string), + voice_session_id: Some(voice_session_id.to_string()), transport: Some(ThreadRealtimeStartTransport::ExistingCall { call_id: "rtc_existing".to_string(), }), @@ -2522,6 +2534,14 @@ async fn existing_call_attaches_without_reinitializing_the_client_session( harness.realtime_server.single_handshake().uri(), expected_handshake_uri ); + assert_eq!( + harness + .realtime_server + .single_handshake() + .header("voice-session-id") + .as_deref(), + Some(voice_session_id) + ); assert!( harness.realtime_server.single_connection().is_empty(), "attaching to an existing call must not overwrite the client session" @@ -2574,6 +2594,7 @@ async fn existing_call_rejects_client_owned_session_configuration(option: &str) realtime_end_instructions: None, prompt: None, realtime_session_id: None, + voice_session_id: None, transport: Some(ThreadRealtimeStartTransport::ExistingCall { call_id: "rtc_existing".to_string(), }), @@ -4094,6 +4115,7 @@ async fn realtime_webrtc_start_surfaces_backend_error() -> Result<()> { realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, + voice_session_id: None, transport: Some(ThreadRealtimeStartTransport::Webrtc { sdp: "v=offer\r\n".to_string(), }), diff --git a/codex-rs/core/src/realtime_conversation.rs b/codex-rs/core/src/realtime_conversation.rs index dab0d42f6a..d1c1fb11cf 100644 --- a/codex-rs/core/src/realtime_conversation.rs +++ b/codex-rs/core/src/realtime_conversation.rs @@ -1444,6 +1444,7 @@ async fn prepare_realtime_start( let realtime_api_key = realtime_api_key(auth.as_ref(), &provider)?; realtime_request_headers( requested_realtime_session_id.as_deref(), + params.voice_session_id.as_deref(), Some(realtime_api_key.as_str()), event_parser, originator.as_str(), @@ -1453,6 +1454,7 @@ async fn prepare_realtime_start( | ConversationStartTransport::ExistingCall { .. } => { realtime_request_headers( requested_realtime_session_id.as_deref(), + params.voice_session_id.as_deref(), /*api_key*/ None, event_parser, originator.as_str(), @@ -1951,6 +1953,7 @@ fn realtime_api_key(auth: Option<&CodexAuth>, provider: &ModelProviderInfo) -> C fn realtime_request_headers( realtime_session_id: Option<&str>, + voice_session_id: Option<&str>, api_key: Option<&str>, event_parser: RealtimeEventParser, originator: &str, @@ -1973,6 +1976,12 @@ fn realtime_request_headers( headers.insert("x-session-id", realtime_session_id); } + if let Some(voice_session_id) = voice_session_id + && let Ok(voice_session_id) = HeaderValue::from_str(voice_session_id) + { + headers.insert("voice-session-id", voice_session_id); + } + if let Some(api_key) = api_key { let auth_value = HeaderValue::from_str(&format!("Bearer {api_key}")).map_err(|err| { CodexErr::InvalidRequest(format!("invalid realtime api key header: {err}")) diff --git a/codex-rs/core/src/realtime_conversation_tests.rs b/codex-rs/core/src/realtime_conversation_tests.rs index 98e1e61a18..9d827989aa 100644 --- a/codex-rs/core/src/realtime_conversation_tests.rs +++ b/codex-rs/core/src/realtime_conversation_tests.rs @@ -419,6 +419,7 @@ fn streamed_v3_handoff_omits_the_final_message_prefix() { fn uses_quicksilver_alpha_header_for_realtime_v1() { let headers = realtime_request_headers( Some("session_1"), + /*voice_session_id*/ None, Some("sk-test"), RealtimeEventParser::V1, "codex_work_desktop", @@ -438,6 +439,7 @@ fn uses_quicksilver_alpha_header_for_realtime_v1() { fn omits_quicksilver_alpha_header_for_realtime_v2() { let headers = realtime_request_headers( Some("session_1"), + /*voice_session_id*/ None, Some("sk-test"), RealtimeEventParser::RealtimeV2, "codex_work_desktop", @@ -452,6 +454,7 @@ fn omits_quicksilver_alpha_header_for_realtime_v2() { fn uses_frameless_alpha_header_for_realtime_v3() { let headers = realtime_request_headers( Some("session_1"), + /*voice_session_id*/ None, Some("sk-test"), RealtimeEventParser::FramelessBidi, "codex_work_desktop", @@ -476,6 +479,7 @@ fn realtime_headers_include_only_non_default_originator() { ] { let headers = realtime_request_headers( Some("session_1"), + /*voice_session_id*/ None, Some("sk-test"), RealtimeEventParser::RealtimeV2, originator, @@ -491,3 +495,29 @@ fn realtime_headers_include_only_non_default_originator() { ); } } + +#[test] +fn realtime_headers_keep_realtime_and_voice_session_ids_distinct() { + let headers = realtime_request_headers( + Some("realtime_session_1"), + Some("voice_session_1"), + /*api_key*/ None, + RealtimeEventParser::RealtimeV2, + "codex_work_desktop", + ) + .expect("headers") + .expect("headers"); + + assert_eq!( + headers + .get("x-session-id") + .and_then(|value| value.to_str().ok()), + Some("realtime_session_1") + ); + assert_eq!( + headers + .get("voice-session-id") + .and_then(|value| value.to_str().ok()), + Some("voice_session_1") + ); +} diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index a77b923e7e..467a3509d9 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -117,6 +117,7 @@ async fn start_realtime_conversation(codex: &codex_core::CodexThread) -> Result< realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, + voice_session_id: None, transport: None, version: None, voice: None, diff --git a/codex-rs/core/tests/suite/realtime_attachment.rs b/codex-rs/core/tests/suite/realtime_attachment.rs index ea43c266ed..1edadc7977 100644 --- a/codex-rs/core/tests/suite/realtime_attachment.rs +++ b/codex-rs/core/tests/suite/realtime_attachment.rs @@ -145,6 +145,7 @@ fn params(session: &str, url: &str) -> ConversationStartParams { realtime_end_instructions: None, prompt: None, realtime_session_id: Some(session.to_owned()), + voice_session_id: None, transport: Some(ConversationStartTransport::ExistingCall { call_id: format!("rtc_{session}"), sideband_base_url: Some(url.to_owned()), diff --git a/codex-rs/core/tests/suite/realtime_conversation.rs b/codex-rs/core/tests/suite/realtime_conversation.rs index 31f78c4d11..c4b773aae5 100644 --- a/codex-rs/core/tests/suite/realtime_conversation.rs +++ b/codex-rs/core/tests/suite/realtime_conversation.rs @@ -332,6 +332,7 @@ async fn conversation_start_audio_text_close_round_trip() -> Result<()> { realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, + voice_session_id: None, transport: None, version: None, voice: None, @@ -514,6 +515,7 @@ async fn conversation_records_history_without_an_event_observer( realtime_end_instructions: None, prompt: Some(Some("fixture".to_string())), realtime_session_id: Some("voice-1".to_string()), + voice_session_id: None, transport: None, version: None, voice: None, @@ -625,6 +627,7 @@ async fn conversation_start_defaults_to_v2_and_gpt_realtime_1_5() -> Result<()> realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, + voice_session_id: None, transport: None, version: None, voice: None, @@ -720,6 +723,7 @@ async fn conversation_websocket_transports_send_codex_headers_without_creating_a realtime_end_instructions: None, prompt: None, realtime_session_id: None, + voice_session_id: None, transport: Some(transport.clone()), version: Some(RealtimeConversationVersion::V3), voice: None, @@ -870,6 +874,7 @@ async fn conversation_webrtc_frameless_chatgpt_sends_codex_headers_to_backend( let codex = &conversation.thread; assert_eq!(codex.config_snapshot().await.thread_source, thread_source); let requested_realtime_session_id = Uuid::new_v4().to_string(); + let voice_session_id = "voice-session-test".to_string(); codex .submit(Op::RealtimeConversationStart(ConversationStartParams { @@ -893,6 +898,7 @@ async fn conversation_webrtc_frameless_chatgpt_sends_codex_headers_to_backend( realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: Some(requested_realtime_session_id.clone()), + voice_session_id: Some(voice_session_id.clone()), transport: Some(ConversationStartTransport::Webrtc { sdp: "v=offer\r\n".to_string(), }), @@ -935,6 +941,10 @@ async fn conversation_webrtc_frameless_chatgpt_sends_codex_headers_to_backend( .headers .get("x-session-id") .and_then(|value| value.to_str().ok()), + request + .headers + .get("voice-session-id") + .and_then(|value| value.to_str().ok()), request .headers .get("authorization") @@ -947,6 +957,7 @@ async fn conversation_webrtc_frameless_chatgpt_sends_codex_headers_to_backend( Some(expected_session_id.as_str()), Some(expected_thread_id.as_str()), Some(requested_realtime_session_id.as_str()), + Some(voice_session_id.as_str()), Some("Bearer Access Token"), ) ); @@ -1052,6 +1063,7 @@ async fn conversation_webrtc_start_posts_generated_session() -> Result<()> { realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, + voice_session_id: None, transport: Some(ConversationStartTransport::Webrtc { sdp: "v=offer\r\n".to_string(), }), @@ -1421,6 +1433,7 @@ async fn conversation_webrtc_live_reconnects_sideband_after_unclean_disconnect( Some(Some("backend prompt".to_string())) }, realtime_session_id: attaches_existing_call.then(|| "sess_client_owned".to_string()), + voice_session_id: None, transport: Some(transport), version: Some(RealtimeConversationVersion::V3), voice: None, @@ -1573,6 +1586,7 @@ async fn conversation_webrtc_start_uses_avas_query() -> Result<()> { realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, + voice_session_id: None, transport: Some(ConversationStartTransport::Webrtc { sdp: "v=offer\r\n".to_string(), }), @@ -1679,6 +1693,7 @@ async fn conversation_webrtc_default_v1_ignores_configured_v2_voice() -> Result< realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, + voice_session_id: None, transport: Some(ConversationStartTransport::Webrtc { sdp: "v=offer\r\n".to_string(), }), @@ -1747,6 +1762,7 @@ async fn conversation_webrtc_default_v1_rejects_explicit_v2_voice() -> Result<() realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, + voice_session_id: None, transport: Some(ConversationStartTransport::Webrtc { sdp: "v=offer\r\n".to_string(), }), @@ -1825,6 +1841,7 @@ async fn conversation_webrtc_start_uses_configured_call_base_url_for_avas() -> R realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, + voice_session_id: None, transport: Some(ConversationStartTransport::Webrtc { sdp: "v=offer\r\n".to_string(), }), @@ -1940,6 +1957,7 @@ async fn conversation_webrtc_close_while_sideband_connecting_drops_pending_join( realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, + voice_session_id: None, transport: Some(ConversationStartTransport::Webrtc { sdp: "v=offer\r\n".to_string(), }), @@ -2048,6 +2066,7 @@ async fn conversation_webrtc_sideband_connect_failure_closes_with_error() -> Res realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, + voice_session_id: None, transport: Some(ConversationStartTransport::Webrtc { sdp: "v=offer\r\n".to_string(), }), @@ -2156,6 +2175,7 @@ async fn conversation_start_uses_openai_env_key_fallback_with_chatgpt_auth() -> realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, + voice_session_id: None, transport: None, version: None, voice: None, @@ -2258,6 +2278,7 @@ async fn assert_transport_close_tail_flush( realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, + voice_session_id: None, transport: None, version: None, voice: None, @@ -2392,6 +2413,7 @@ async fn conversation_start_preflight_failure_emits_realtime_error_only() -> Res realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, + voice_session_id: None, transport: None, version: None, voice: None, @@ -2451,6 +2473,7 @@ async fn conversation_start_connect_failure_emits_realtime_error_only() -> Resul realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, + voice_session_id: None, transport: None, version: None, voice: None, @@ -2564,6 +2587,7 @@ async fn conversation_second_start_replaces_runtime() -> Result<()> { realtime_end_instructions: None, prompt: Some(Some("old".to_string())), realtime_session_id: Some("conv_old".to_string()), + voice_session_id: None, transport: None, version: None, voice: None, @@ -2602,6 +2626,7 @@ async fn conversation_second_start_replaces_runtime() -> Result<()> { realtime_end_instructions: None, prompt: Some(Some("new".to_string())), realtime_session_id: Some("conv_new".to_string()), + voice_session_id: None, transport: None, version: None, voice: None, @@ -2712,6 +2737,7 @@ async fn conversation_uses_experimental_realtime_ws_base_url_override() -> Resul realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, + voice_session_id: None, transport: None, version: None, voice: None, @@ -2792,6 +2818,7 @@ async fn conversation_uses_default_realtime_backend_prompt() -> Result<()> { realtime_end_instructions: None, prompt: None, realtime_session_id: None, + voice_session_id: None, transport: None, version: None, voice: None, @@ -2883,6 +2910,7 @@ async fn conversation_uses_empty_instructions_for_null_or_empty_prompt() -> Resu realtime_end_instructions: None, prompt, realtime_session_id: None, + voice_session_id: None, transport: None, version: None, voice: None, @@ -2967,6 +2995,7 @@ async fn conversation_uses_explicit_start_voice() -> Result<()> { realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, + voice_session_id: None, transport: None, version: None, voice: Some(RealtimeVoice::Breeze), @@ -3041,6 +3070,7 @@ async fn conversation_uses_configured_realtime_voice() -> Result<()> { realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, + voice_session_id: None, transport: None, version: None, voice: None, @@ -3100,6 +3130,7 @@ async fn conversation_rejects_voice_for_wrong_realtime_version() -> Result<()> { realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, + voice_session_id: None, transport: None, version: None, voice: Some(RealtimeVoice::Cove), @@ -3162,6 +3193,7 @@ async fn conversation_uses_experimental_realtime_ws_backend_prompt_override() -> realtime_end_instructions: None, prompt: Some(Some("prompt from op".to_string())), realtime_session_id: None, + voice_session_id: None, transport: None, version: None, voice: None, @@ -3248,6 +3280,7 @@ async fn conversation_uses_experimental_realtime_ws_startup_context_override() - realtime_end_instructions: None, prompt: Some(Some("prompt from op".to_string())), realtime_session_id: None, + voice_session_id: None, transport: None, version: None, voice: None, @@ -3327,6 +3360,7 @@ async fn conversation_disables_realtime_startup_context_with_empty_override() -> realtime_end_instructions: None, prompt: Some(Some("prompt from op".to_string())), realtime_session_id: None, + voice_session_id: None, transport: None, version: None, voice: None, @@ -3399,6 +3433,7 @@ async fn conversation_start_injects_startup_context_from_thread_history() -> Res realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, + voice_session_id: None, transport: None, version: None, voice: None, @@ -3533,6 +3568,7 @@ async fn conversation_startup_context_current_thread_selects_many_turns_by_budge realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, + voice_session_id: None, transport: None, version: None, voice: None, @@ -3653,6 +3689,7 @@ async fn conversation_startup_context_falls_back_to_workspace_map() -> Result<() realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, + voice_session_id: None, transport: None, version: None, voice: None, @@ -3725,6 +3762,7 @@ async fn conversation_startup_context_is_truncated_and_sent_once_per_start() -> realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, + voice_session_id: None, transport: None, version: None, voice: None, @@ -3830,6 +3868,7 @@ async fn conversation_user_text_turn_is_not_sent_to_realtime(ephemeral: bool) -> realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, + voice_session_id: None, transport: None, version: None, voice: None, @@ -3971,6 +4010,7 @@ async fn realtime_v2_noop_tool_call_returns_empty_function_output_without_respon realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, + voice_session_id: None, transport: None, version: None, voice: None, @@ -4089,6 +4129,7 @@ async fn conversation_mirrors_assistant_message_text_to_realtime_handoff( realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, + voice_session_id: None, transport: None, version: None, voice: None, @@ -4223,6 +4264,7 @@ async fn conversation_relays_only_public_reasoning_as_quiet_status(enabled: bool realtime_end_instructions: None, prompt: None, realtime_session_id: None, + voice_session_id: None, transport: None, version: None, voice: None, @@ -4415,6 +4457,7 @@ async fn conversation_routes_assistant_messages_for_v3_handoff( realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, + voice_session_id: None, transport: None, version: None, voice: None, @@ -4609,6 +4652,7 @@ async fn conversation_handoff_persists_across_item_done_until_turn_complete( realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, + voice_session_id: None, transport: None, version: None, voice: None, @@ -4775,6 +4819,7 @@ async fn inbound_handoff_request_starts_turn_and_promotes_its_artifact() -> Resu realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, + voice_session_id: None, transport: None, version: None, voice: None, @@ -4940,6 +4985,7 @@ async fn inbound_handoff_request_uses_active_transcript() -> Result<()> { realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, + voice_session_id: None, transport: None, version: None, voice: None, @@ -5055,6 +5101,7 @@ async fn inbound_handoff_request_sends_transcript_delta_after_each_handoff() -> realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, + voice_session_id: None, transport: None, version: None, voice: None, @@ -5194,6 +5241,7 @@ async fn conversation_close_routes_only_remaining_transcript_tail_once() -> Resu realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, + voice_session_id: None, transport: None, version: None, voice: None, @@ -5300,6 +5348,7 @@ async fn inbound_conversation_item_does_not_start_turn_and_still_forwards_audio( realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, + voice_session_id: None, transport: None, version: None, voice: None, @@ -5435,6 +5484,7 @@ async fn delegated_turn_user_role_echo_does_not_redelegate_and_still_forwards_au realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, + voice_session_id: None, transport: None, version: None, voice: None, @@ -5600,6 +5650,7 @@ async fn inbound_handoff_request_does_not_block_realtime_event_forwarding() -> R realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, + voice_session_id: None, transport: None, version: None, voice: None, @@ -5785,6 +5836,7 @@ async fn inbound_handoff_request_updates_realtime_state_during_active_turn( realtime_end_instructions: Some("Return to normal text updates.".to_string()), prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, + voice_session_id: None, transport: None, version: None, voice: None, @@ -6026,6 +6078,7 @@ async fn inbound_handoff_request_starts_turn_and_does_not_block_realtime_audio() realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, + voice_session_id: None, transport: None, version: None, voice: None, diff --git a/codex-rs/core/tests/suite/realtime_initial_items.rs b/codex-rs/core/tests/suite/realtime_initial_items.rs index 6122efdd79..ae9cb4e7a0 100644 --- a/codex-rs/core/tests/suite/realtime_initial_items.rs +++ b/codex-rs/core/tests/suite/realtime_initial_items.rs @@ -227,6 +227,7 @@ fn start_params(version: RealtimeConversationVersion) -> ConversationStartParams realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, + voice_session_id: None, transport: None, version: Some(version), voice: None, diff --git a/codex-rs/core/tests/suite/realtime_misalignment.rs b/codex-rs/core/tests/suite/realtime_misalignment.rs index d823e87540..c45d747818 100644 --- a/codex-rs/core/tests/suite/realtime_misalignment.rs +++ b/codex-rs/core/tests/suite/realtime_misalignment.rs @@ -107,6 +107,7 @@ async fn misalignment_retires_late_voice_handoff_before_it_starts_a_turn() -> Re realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, + voice_session_id: None, transport: None, version: None, voice: None, diff --git a/codex-rs/core/tests/suite/realtime_sideband_endpoint.rs b/codex-rs/core/tests/suite/realtime_sideband_endpoint.rs index c4b4356476..a2b49409b5 100644 --- a/codex-rs/core/tests/suite/realtime_sideband_endpoint.rs +++ b/codex-rs/core/tests/suite/realtime_sideband_endpoint.rs @@ -87,6 +87,7 @@ async fn existing_call_uses_selected_endpoint_and_runtime_auth( realtime_end_instructions: None, prompt: None, realtime_session_id: None, + voice_session_id: None, transport: Some(ConversationStartTransport::ExistingCall { call_id: "rtc_existing".to_string(), sideband_base_url: match selection { diff --git a/codex-rs/core/tests/suite/realtime_system_proxy.rs b/codex-rs/core/tests/suite/realtime_system_proxy.rs index 7c7da31727..ce2996365d 100644 --- a/codex-rs/core/tests/suite/realtime_system_proxy.rs +++ b/codex-rs/core/tests/suite/realtime_system_proxy.rs @@ -150,6 +150,7 @@ async fn webrtc_sideband_honors_configured_system_proxy() -> Result<()> { realtime_end_instructions: None, prompt: None, realtime_session_id: None, + voice_session_id: None, transport: Some(ConversationStartTransport::Webrtc { sdp: "v=offer\r\n".to_string(), }), diff --git a/codex-rs/core/tests/suite/turn_input_submission.rs b/codex-rs/core/tests/suite/turn_input_submission.rs index 374fe229eb..6e3628c6e6 100644 --- a/codex-rs/core/tests/suite/turn_input_submission.rs +++ b/codex-rs/core/tests/suite/turn_input_submission.rs @@ -436,6 +436,7 @@ async fn host_drain_closes_realtime_after_handoff_error() -> anyhow::Result<()> realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, + voice_session_id: None, transport: None, version: None, voice: None, diff --git a/codex-rs/protocol/src/conversation_start_params_tests.rs b/codex-rs/protocol/src/conversation_start_params_tests.rs index 6ccb5e4f1e..d0ea1e0c68 100644 --- a/codex-rs/protocol/src/conversation_start_params_tests.rs +++ b/codex-rs/protocol/src/conversation_start_params_tests.rs @@ -29,6 +29,7 @@ fn realtime_start_debug_redacts_text_and_transport() { realtime_end_instructions: Some(secret.to_owned()), prompt: Some(Some(secret.to_owned())), realtime_session_id: Some(secret.to_owned()), + voice_session_id: Some(secret.to_owned()), transport: None, version: Some(RealtimeConversationVersion::V3), voice: None, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 146209407b..ac01685923 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -209,6 +209,9 @@ pub struct ConversationStartParams { pub realtime_end_instructions: Option, pub prompt: Option>, pub realtime_session_id: Option, + /// Client-generated identifier for the product voice session. This is distinct from the + /// Realtime API session identifier. + pub voice_session_id: Option, pub transport: Option, /// Overrides the configured realtime protocol version for this session only. pub version: Option, diff --git a/codex-rs/tui/src/app_server_session/realtime.rs b/codex-rs/tui/src/app_server_session/realtime.rs index 1e8483e103..1ad46db83a 100644 --- a/codex-rs/tui/src/app_server_session/realtime.rs +++ b/codex-rs/tui/src/app_server_session/realtime.rs @@ -47,6 +47,7 @@ impl AppServerSession { realtime_end_instructions: None, prompt: None, realtime_session_id: None, + voice_session_id: None, transport: Some(ThreadRealtimeStartTransport::Webrtc { sdp: offer_sdp }), version: Some(RealtimeConversationVersion::V3), voice,