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 6a69cc899d..e4522657cd 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -13,6 +13,7 @@ use codex_protocol::models::FunctionCallOutputBody; use codex_protocol::models::FunctionCallOutputContentItem; use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::ImageReference as CoreImageReference; +use codex_protocol::protocol::RealtimeConversationVersion; use codex_protocol::protocol::TurnEnvironmentRequests; use codex_protocol::protocol::TurnSettingsUpdate; use codex_protocol::protocol::TurnSettingsUpdateOutcome; @@ -1322,6 +1323,23 @@ impl TurnRequestProcessor { else { return Ok(None); }; + let voice = match params.voice { + Some(voice) => Some(Some(voice)), + None if !attaches_existing_call + && params.version == Some(RealtimeConversationVersion::V3) => + { + let current = thread.config().await; + let latest = Box::pin(self.config_manager.load_latest_config_with_session_layers( + ¤t.config_layer_stack, + ¤t.cwd, + )) + .await + .map_err(|error| config_load_error(&error))?; + // Preserve a cleared preference instead of inheriting the thread's old voice. + Some(latest.realtime.voice) + } + None => None, + }; self.submit_core_op( request_id, thread.as_ref(), @@ -1371,7 +1389,7 @@ impl TurnRequestProcessor { } }), version: params.version, - voice: params.voice, + voice, }), ) .await 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 48f782673f..3d5cb6dfd8 100644 --- a/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs +++ b/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs @@ -2681,7 +2681,7 @@ async fn webrtc_v3_start_posts_live_session_and_joins_without_session_update() - assert_call_create_multipart( harness.call_capture.single_request(), "v=offer\r\n", - r#"{"audio":{"output":{"voice":"cove"}},"delegation":{"ack_filler":false,"type":"client"},"instructions":"backend prompt\n\nstartup context","model":"gpt-live-1-codex"}"#, + r#"{"audio":{"output":{"voice":"marin"}},"delegation":{"ack_filler":false,"type":"client"},"instructions":"backend prompt\n\nstartup context","model":"gpt-live-1.1-codex"}"#, "/v1/live", )?; assert!( @@ -2712,6 +2712,75 @@ async fn webrtc_v3_start_posts_live_session_and_joins_without_session_update() - Ok(()) } +#[test_case(None, None, "marin"; "cleared setting uses API default")] +#[test_case(Some("maple"), None, "maple"; "changed setting takes effect")] +#[test_case(Some("maple"), Some("marin"), "marin"; "explicit voice overrides setting")] +#[tokio::test] +async fn webrtc_v3_uses_current_voice_setting( + saved_voice: Option<&str>, + requested_voice: Option<&str>, + expected_voice: &str, +) -> Result<()> { + skip_if_no_network!(Ok(())); + + let mut harness = RealtimeE2eHarness::new( + RealtimeTestVersion::V1, + no_main_loop_responses(), + realtime_sideband(vec![open_realtime_sideband_connection(vec![vec![]])]), + ) + .await?; + let config_path = harness._codex_home.path().join("config.toml"); + let original_config = std::fs::read_to_string(&config_path)?; + std::fs::write( + &config_path, + original_config.replace("[realtime]", "[realtime]\nvoice = \"cove\""), + )?; + let request_id = harness + .mcp + .send_thread_start_request_with_auto_env(ThreadStartParams::default()) + .await?; + let thread: ThreadStartResponse = + timeout(DEFAULT_TIMEOUT, harness.mcp.read_response(request_id)).await??; + + // The thread retains Cove. The next call must use the current setting, including removal. + let updated_config = match saved_voice { + Some(voice) => { + original_config.replace("[realtime]", &format!("[realtime]\nvoice = {voice:?}")) + } + None => original_config, + }; + std::fs::write(config_path, updated_config)?; + let request_id = harness + .mcp + .send_thread_realtime_start_request(serde_json::from_value(json!({ + "threadId": thread.thread.id, + "version": "v3", + "transport": {"type": "webrtc", "sdp": "v=offer\r\n"}, + "outputModality": "audio", + "includeStartupContext": false, + "prompt": "voice selection test", + "voice": requested_voice, + }))?) + .await?; + let _: ThreadRealtimeStartResponse = + timeout(DEFAULT_TIMEOUT, harness.mcp.read_response(request_id)).await??; + let _: ThreadRealtimeSdpNotification = harness.read_notification("thread/realtime/sdp").await?; + assert_call_create_multipart( + harness.call_capture.single_request(), + "v=offer\r\n", + &json!({ + "audio": {"output": {"voice": expected_voice}}, + "delegation": {"type": "client"}, + "instructions": "backend prompt", + "model": "gpt-live-1.1-codex", + }) + .to_string(), + "/v1/live", + )?; + harness.shutdown().await; + Ok(()) +} + #[tokio::test] async fn webrtc_v1_default_automatic_output_uses_handoff_append() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/codex-api/src/endpoint/realtime_call.rs b/codex-rs/codex-api/src/endpoint/realtime_call.rs index 745d0b0405..c82ae02adc 100644 --- a/codex-rs/codex-api/src/endpoint/realtime_call.rs +++ b/codex-rs/codex-api/src/endpoint/realtime_call.rs @@ -10,6 +10,7 @@ use codex_client::HttpTransport; use codex_client::Request; use codex_client::RequestBody; use codex_client::RequestTelemetry; +use codex_protocol::protocol::RealtimeVoice; use http::HeaderMap; use http::HeaderValue; use http::Method; @@ -129,7 +130,7 @@ impl RealtimeCallClient { pub async fn create_with_session_and_headers( &self, sdp: String, - session_config: RealtimeSessionConfig, + mut session_config: RealtimeSessionConfig, extra_headers: HeaderMap, ) -> Result { trace!(target: "codex_api::realtime_websocket::wire", "realtime call request SDP: {sdp}"); @@ -137,6 +138,10 @@ impl RealtimeCallClient { // session payload is sent with call creation. Legacy sidebands still send session.update // after joining; Frameless sidebands attach to the session that is already running. validate_avas_session_config(&session_config)?; + if self.uses_backend_request_shape() { + // ChatGPT keeps its own default; API calls use the wire adapter's default. + session_config.voice.get_or_insert(RealtimeVoice::Cove); + } let event_parser = session_config.event_parser; let path = self.path_for_session(event_parser); let mut session = realtime_session_json(session_config)?; @@ -396,14 +401,14 @@ mod tests { event_parser: RealtimeEventParser::V1, session_mode: RealtimeSessionMode::Conversational, output_modality: RealtimeOutputModality::Audio, - voice: RealtimeVoice::Cove, + voice: Some(RealtimeVoice::Cove), } } fn realtime_v2_session_config(session_id: &str) -> RealtimeSessionConfig { RealtimeSessionConfig { event_parser: RealtimeEventParser::RealtimeV2, - voice: RealtimeVoice::Marin, + voice: Some(RealtimeVoice::Marin), ..realtime_session_config(session_id) } } @@ -559,32 +564,38 @@ mod tests { #[tokio::test] async fn sends_frameless_session_call_to_live_without_legacy_query_params() { - let transport = CapturingTransport::with_location("/v1/live/rtc_frameless"); - let client = RealtimeCallClient::new( - transport.clone(), - provider("https://api.openai.com/v1"), - Arc::new(DummyAuth), - ); + for (voice, expected_voice) in [ + (None, "marin"), + (Some(RealtimeVoice::Marin), "marin"), + (Some(RealtimeVoice::Cove), "cove"), + ] { + let transport = CapturingTransport::with_location("/v1/live/rtc_frameless"); + let client = RealtimeCallClient::new( + transport.clone(), + provider("https://api.openai.com/v1"), + Arc::new(DummyAuth), + ); - let response = client - .create_with_session( - "v=offer\r\n".to_string(), - frameless_bidi_session_config("sess-api"), - ) - .await - .expect("request should succeed"); + let mut session_config = frameless_bidi_session_config("sess-api"); + session_config.voice = voice; + let response = client + .create_with_session("v=offer\r\n".to_string(), session_config) + .await + .expect("request should succeed"); - assert_eq!(response.call_id, "rtc_frameless"); - let request = transport.last_request.lock().unwrap().clone().unwrap(); - assert_eq!(request.method, Method::POST); - assert_eq!(request.url, "https://api.openai.com/v1/live"); - let Some(RequestBody::Raw(body)) = request.body else { - panic!("multipart body should be raw"); - }; - let body = std::str::from_utf8(&body).expect("multipart body should be utf-8"); - assert!(body.contains("\"model\":\"gpt-realtime\"")); - assert!(body.contains("\"delegation\":{\"type\":\"client\"}")); - assert!(!body.contains("\"id\":\"sess-api\"")); + assert_eq!(response.call_id, "rtc_frameless"); + let request = transport.last_request.lock().unwrap().clone().unwrap(); + assert_eq!(request.method, Method::POST); + assert_eq!(request.url, "https://api.openai.com/v1/live"); + let Some(RequestBody::Raw(body)) = request.body else { + panic!("multipart body should be raw"); + }; + let body = std::str::from_utf8(&body).expect("multipart body should be utf-8"); + assert!(body.contains("\"model\":\"gpt-realtime\"")); + assert!(body.contains("\"delegation\":{\"type\":\"client\"}")); + assert!(!body.contains("\"id\":\"sess-api\"")); + assert!(body.contains(&format!("\"voice\":\"{expected_voice}\""))); + } } #[tokio::test] @@ -696,56 +707,60 @@ mod tests { #[tokio::test] async fn sends_backend_frameless_session_call_to_realtime_calls() { - let transport = CapturingTransport::with_location("/v1/live/rtc_backend_frameless"); - let client = RealtimeCallClient::new( - transport.clone(), - provider("https://chatgpt.com/backend-api/codex"), - Arc::new(DummyAuth), - ); - let mut session_config = frameless_bidi_session_config("sess-backend"); - session_config.initial_items = vec![ - ConversationTextParams { - text: "Remember this.".to_string(), - role: ConversationTextRole::Developer, - }, - ConversationTextParams { - text: "Understood.".to_string(), - role: ConversationTextRole::Assistant, - }, - ]; - - let response = client - .create_with_session("v=offer\r\n".to_string(), session_config) - .await - .expect("request should succeed"); - - assert_eq!(response.call_id, "rtc_backend_frameless"); - let request = transport.last_request.lock().unwrap().clone().unwrap(); - assert_eq!(request.method, Method::POST); - assert_eq!( - request.url, - "https://chatgpt.com/backend-api/codex/realtime/calls?intent=quicksilver&architecture=avas" - ); - let Some(RequestBody::Json(body)) = request.body else { - panic!("backend request body should be JSON"); - }; - assert_eq!(body["session"]["delegation"]["type"], "client"); - assert!(body["session"].get("id").is_none()); - assert_eq!( - body["session"]["initial_items"], - serde_json::json!([ - { - "type": "message", - "role": "developer", - "content": [{"type": "input_text", "text": "Remember this."}], + for (voice, expected_voice) in [(None, "cove"), (Some(RealtimeVoice::Maple), "maple")] { + let transport = CapturingTransport::with_location("/v1/live/rtc_backend_frameless"); + let client = RealtimeCallClient::new( + transport.clone(), + provider("https://chatgpt.com/backend-api/codex"), + Arc::new(DummyAuth), + ); + let mut session_config = frameless_bidi_session_config("sess-backend"); + session_config.voice = voice; + session_config.initial_items = vec![ + ConversationTextParams { + text: "Remember this.".to_string(), + role: ConversationTextRole::Developer, }, - { - "type": "message", - "role": "assistant", - "content": [{"type": "output_text", "text": "Understood."}], + ConversationTextParams { + text: "Understood.".to_string(), + role: ConversationTextRole::Assistant, }, - ]) - ); + ]; + + let response = client + .create_with_session("v=offer\r\n".to_string(), session_config) + .await + .expect("request should succeed"); + + assert_eq!(response.call_id, "rtc_backend_frameless"); + let request = transport.last_request.lock().unwrap().clone().unwrap(); + assert_eq!(request.method, Method::POST); + assert_eq!( + request.url, + "https://chatgpt.com/backend-api/codex/realtime/calls?intent=quicksilver&architecture=avas" + ); + let Some(RequestBody::Json(body)) = request.body else { + panic!("backend request body should be JSON"); + }; + assert_eq!(body["session"]["delegation"]["type"], "client"); + assert_eq!(body["session"]["audio"]["output"]["voice"], expected_voice); + assert!(body["session"].get("id").is_none()); + assert_eq!( + body["session"]["initial_items"], + serde_json::json!([ + { + "type": "message", + "role": "developer", + "content": [{"type": "input_text", "text": "Remember this."}], + }, + { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "Understood."}], + }, + ]) + ); + } } #[tokio::test] diff --git a/codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs b/codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs index 25c5582a8f..33b9265235 100644 --- a/codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs +++ b/codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs @@ -1154,6 +1154,7 @@ impl RealtimeWebsocketClient { RealtimeSessionInitialization::ExistingCall => false, }; if initialize_session { + let voice = config.voice_or_default(); debug!( session_id = config.session_id.as_deref().unwrap_or(""), "realtime websocket sending session.update" @@ -1165,7 +1166,7 @@ impl RealtimeWebsocketClient { config.initial_items, config.session_mode, config.output_modality, - config.voice, + voice, config.delegation_ack_filler, ) .await?; @@ -2590,7 +2591,7 @@ mod tests { event_parser: RealtimeEventParser::V1, session_mode: RealtimeSessionMode::Conversational, output_modality: RealtimeOutputModality::Audio, - voice: RealtimeVoice::Breeze, + voice: Some(RealtimeVoice::Breeze), }, HeaderMap::new(), HeaderMap::new(), @@ -2916,7 +2917,7 @@ mod tests { event_parser: RealtimeEventParser::RealtimeV2, session_mode: RealtimeSessionMode::Conversational, output_modality: RealtimeOutputModality::Audio, - voice: RealtimeVoice::Cedar, + voice: Some(RealtimeVoice::Cedar), }, HeaderMap::new(), HeaderMap::new(), @@ -3043,7 +3044,7 @@ mod tests { event_parser: RealtimeEventParser::RealtimeV2, session_mode: RealtimeSessionMode::Transcription, output_modality: RealtimeOutputModality::Audio, - voice: RealtimeVoice::Marin, + voice: Some(RealtimeVoice::Marin), }, HeaderMap::new(), HeaderMap::new(), @@ -3149,7 +3150,7 @@ mod tests { event_parser: RealtimeEventParser::V1, session_mode: RealtimeSessionMode::Transcription, output_modality: RealtimeOutputModality::Audio, - voice: RealtimeVoice::Cove, + voice: Some(RealtimeVoice::Cove), }, HeaderMap::new(), HeaderMap::new(), @@ -3241,7 +3242,7 @@ mod tests { event_parser: RealtimeEventParser::V1, session_mode: RealtimeSessionMode::Conversational, output_modality: RealtimeOutputModality::Audio, - voice: RealtimeVoice::Cove, + voice: Some(RealtimeVoice::Cove), }, HeaderMap::new(), HeaderMap::new(), diff --git a/codex-rs/codex-api/src/endpoint/realtime_websocket/methods_auth_tests.rs b/codex-rs/codex-api/src/endpoint/realtime_websocket/methods_auth_tests.rs index 1e621974be..ddc84c67d7 100644 --- a/codex-rs/codex-api/src/endpoint/realtime_websocket/methods_auth_tests.rs +++ b/codex-rs/codex-api/src/endpoint/realtime_websocket/methods_auth_tests.rs @@ -70,7 +70,7 @@ fn session() -> RealtimeSessionConfig { event_parser: RealtimeEventParser::V1, session_mode: RealtimeSessionMode::Conversational, output_modality: RealtimeOutputModality::Audio, - voice: RealtimeVoice::Cedar, + voice: Some(RealtimeVoice::Cedar), } } diff --git a/codex-rs/codex-api/src/endpoint/realtime_websocket/methods_common.rs b/codex-rs/codex-api/src/endpoint/realtime_websocket/methods_common.rs index e5482e0134..5133f2af57 100644 --- a/codex-rs/codex-api/src/endpoint/realtime_websocket/methods_common.rs +++ b/codex-rs/codex-api/src/endpoint/realtime_websocket/methods_common.rs @@ -138,17 +138,16 @@ pub(super) fn session_update_message( } pub fn session_update_session_json(config: RealtimeSessionConfig) -> JsonResult { + let voice = config.voice_or_default(); match config.event_parser { RealtimeWireAdapter::V1 | RealtimeWireAdapter::RealtimeV2 => { let mut session = match config.event_parser { - RealtimeWireAdapter::V1 => { - v1_session_update_session(config.instructions, config.voice) - } + RealtimeWireAdapter::V1 => v1_session_update_session(config.instructions, voice), RealtimeWireAdapter::RealtimeV2 => v2_session_update_session( config.instructions, config.session_mode, config.output_modality, - config.voice, + voice, ), RealtimeWireAdapter::FramelessBidi => unreachable!(), }; @@ -160,7 +159,7 @@ pub fn session_update_session_json(config: RealtimeSessionConfig) -> JsonResult< config.model, config.instructions, config.initial_items, - config.voice, + voice, config.delegation_ack_filler, )), } diff --git a/codex-rs/codex-api/src/endpoint/realtime_websocket/protocol.rs b/codex-rs/codex-api/src/endpoint/realtime_websocket/protocol.rs index b7fc073dfa..1053a79fc2 100644 --- a/codex-rs/codex-api/src/endpoint/realtime_websocket/protocol.rs +++ b/codex-rs/codex-api/src/endpoint/realtime_websocket/protocol.rs @@ -44,7 +44,19 @@ pub struct RealtimeSessionConfig { pub event_parser: RealtimeEventParser, pub session_mode: RealtimeSessionMode, pub output_modality: RealtimeOutputModality, - pub voice: RealtimeVoice, + /// Leave unset to select the default for the API or ChatGPT call destination. + pub voice: Option, +} + +impl RealtimeSessionConfig { + pub(super) fn voice_or_default(&self) -> RealtimeVoice { + self.voice.unwrap_or(match self.event_parser { + RealtimeEventParser::V1 => RealtimeVoice::Cove, + RealtimeEventParser::FramelessBidi | RealtimeEventParser::RealtimeV2 => { + RealtimeVoice::Marin + } + }) + } } #[derive(Debug, Clone, Serialize)] diff --git a/codex-rs/codex-api/tests/realtime_websocket_e2e.rs b/codex-rs/codex-api/tests/realtime_websocket_e2e.rs index d42d4ec7d9..f78d4f13b7 100644 --- a/codex-rs/codex-api/tests/realtime_websocket_e2e.rs +++ b/codex-rs/codex-api/tests/realtime_websocket_e2e.rs @@ -162,7 +162,7 @@ async fn realtime_ws_e2e_session_create_and_event_flow() { event_parser: RealtimeEventParser::V1, session_mode: RealtimeSessionMode::Conversational, output_modality: RealtimeOutputModality::Audio, - voice: RealtimeVoice::Cove, + voice: Some(RealtimeVoice::Cove), }, HeaderMap::new(), HeaderMap::new(), @@ -268,7 +268,7 @@ async fn realtime_ws_connect_webrtc_sideband_retries_join_until_server_is_availa event_parser: RealtimeEventParser::RealtimeV2, session_mode: RealtimeSessionMode::Conversational, output_modality: RealtimeOutputModality::Audio, - voice: RealtimeVoice::Marin, + voice: Some(RealtimeVoice::Marin), }, "rtc_test", HeaderMap::new(), @@ -349,7 +349,7 @@ async fn realtime_ws_existing_call_retries_activation_404_with_a_bounded_budget( event_parser: RealtimeEventParser::RealtimeV2, session_mode: RealtimeSessionMode::Conversational, output_modality: RealtimeOutputModality::Audio, - voice: RealtimeVoice::Marin, + voice: Some(RealtimeVoice::Marin), }, "rtc_prepared", HeaderMap::new(), @@ -405,7 +405,7 @@ async fn realtime_ws_existing_call_transport_close_preserves_session() { event_parser, session_mode: RealtimeSessionMode::Conversational, output_modality: RealtimeOutputModality::Audio, - voice: RealtimeVoice::Marin, + voice: Some(RealtimeVoice::Marin), }, "rtc_frontend_owned", HeaderMap::new(), @@ -461,7 +461,7 @@ async fn realtime_ws_frameless_session_close_still_ends_session() { event_parser: RealtimeEventParser::FramelessBidi, session_mode: RealtimeSessionMode::Conversational, output_modality: RealtimeOutputModality::Audio, - voice: RealtimeVoice::Marin, + voice: Some(RealtimeVoice::Marin), }, "rtc_session_owner", HeaderMap::new(), @@ -525,7 +525,7 @@ async fn realtime_ws_e2e_send_while_next_event_waits() { event_parser: RealtimeEventParser::V1, session_mode: RealtimeSessionMode::Conversational, output_modality: RealtimeOutputModality::Audio, - voice: RealtimeVoice::Cove, + voice: Some(RealtimeVoice::Cove), }, HeaderMap::new(), HeaderMap::new(), @@ -595,7 +595,7 @@ async fn realtime_ws_e2e_disconnected_emitted_once() { event_parser: RealtimeEventParser::V1, session_mode: RealtimeSessionMode::Conversational, output_modality: RealtimeOutputModality::Audio, - voice: RealtimeVoice::Cove, + voice: Some(RealtimeVoice::Cove), }, HeaderMap::new(), HeaderMap::new(), @@ -661,7 +661,7 @@ async fn realtime_ws_e2e_ignores_unknown_text_events() { event_parser: RealtimeEventParser::V1, session_mode: RealtimeSessionMode::Conversational, output_modality: RealtimeOutputModality::Audio, - voice: RealtimeVoice::Cove, + voice: Some(RealtimeVoice::Cove), }, HeaderMap::new(), HeaderMap::new(), @@ -770,7 +770,7 @@ async fn realtime_ws_e2e_realtime_v2_parser_emits_handoff_requested() { event_parser: RealtimeEventParser::RealtimeV2, session_mode: RealtimeSessionMode::Conversational, output_modality: RealtimeOutputModality::Audio, - voice: RealtimeVoice::Marin, + voice: Some(RealtimeVoice::Marin), }, HeaderMap::new(), HeaderMap::new(), diff --git a/codex-rs/codex-api/tests/realtime_websocket_tls.rs b/codex-rs/codex-api/tests/realtime_websocket_tls.rs index 5668ff669c..e1c9ecac32 100644 --- a/codex-rs/codex-api/tests/realtime_websocket_tls.rs +++ b/codex-rs/codex-api/tests/realtime_websocket_tls.rs @@ -145,7 +145,7 @@ async fn check_connection(address: String) { event_parser: RealtimeEventParser::V1, session_mode: RealtimeSessionMode::Conversational, output_modality: RealtimeOutputModality::Audio, - voice: RealtimeVoice::Cove, + voice: Some(RealtimeVoice::Cove), }, HeaderMap::new(), HeaderMap::new(), diff --git a/codex-rs/core/src/client_tests.rs b/codex-rs/core/src/client_tests.rs index 56976c1793..d93642397b 100644 --- a/codex-rs/core/src/client_tests.rs +++ b/codex-rs/core/src/client_tests.rs @@ -2410,7 +2410,7 @@ async fn realtime_call_preserves_captured_account_binding(change: RealtimeCreden event_parser: codex_api::RealtimeEventParser::V1, session_mode: codex_api::RealtimeSessionMode::Conversational, output_modality: codex_protocol::protocol::RealtimeOutputModality::Audio, - voice: codex_protocol::protocol::RealtimeVoice::Cove, + voice: Some(codex_protocol::protocol::RealtimeVoice::Cove), }, http::HeaderMap::new(), Some(api_provider), @@ -2743,7 +2743,7 @@ async fn realtime_custom_call_keeps_selected_key_for_openai_sideband(mode: Runti event_parser: codex_api::RealtimeEventParser::V1, session_mode: codex_api::RealtimeSessionMode::Conversational, output_modality: codex_protocol::protocol::RealtimeOutputModality::Audio, - voice: codex_protocol::protocol::RealtimeVoice::Cove, + voice: Some(codex_protocol::protocol::RealtimeVoice::Cove), }, http::HeaderMap::new(), Some(call_provider), diff --git a/codex-rs/core/src/realtime_conversation.rs b/codex-rs/core/src/realtime_conversation.rs index 4b258419e7..0c72872a58 100644 --- a/codex-rs/core/src/realtime_conversation.rs +++ b/codex-rs/core/src/realtime_conversation.rs @@ -114,7 +114,7 @@ const HANDOFF_STREAM_TRUNCATION_MARKER: &str = "\n…output truncated…\n"; const AGENT_FINAL_MESSAGE_PREFIX: &str = "\"Agent Final Message\":\n\n"; const STANDALONE_HANDOFF_ID: &str = "codex"; const DEFAULT_REALTIME_MODEL: &str = "gpt-realtime-1.5"; -const DEFAULT_FRAMELESS_REALTIME_MODEL: &str = "gpt-live-1-codex"; +const DEFAULT_FRAMELESS_REALTIME_MODEL: &str = "gpt-live-1.1-codex"; pub(crate) const REALTIME_USER_TEXT_PREFIX: &str = "[USER] "; pub(crate) const REALTIME_BACKEND_TEXT_PREFIX: &str = "[BACKEND] "; const REALTIME_V2_HANDOFF_COMPLETE_ACKNOWLEDGEMENT: &str = @@ -1795,11 +1795,10 @@ pub(crate) async fn build_realtime_session_config( ConfiguredRealtimeVoice::Use => config.realtime.voice, ConfiguredRealtimeVoice::Ignore => None, }; - let voice = params - .voice - .or(config_voice) - .unwrap_or_else(|| default_realtime_voice(version)); - validate_realtime_voice(version, voice)?; + let voice = params.voice.unwrap_or(config_voice); + if let Some(voice) = voice { + validate_realtime_voice(version, voice)?; + } Ok(RealtimeSessionConfig { instructions: prompt, initial_items: params.initial_items.clone(), @@ -1818,14 +1817,6 @@ pub(crate) async fn build_realtime_session_config( }) } -fn default_realtime_voice(version: RealtimeWsVersion) -> RealtimeVoice { - let voices = RealtimeVoicesList::builtin(); - match version { - RealtimeWsVersion::V1 | RealtimeWsVersion::V3 => voices.default_v1, - RealtimeWsVersion::V2 => voices.default_v2, - } -} - fn prefix_realtime_text(text: String, prefix: &str, session_kind: RealtimeSessionKind) -> String { if session_kind != RealtimeSessionKind::V2 || text.is_empty() || text.starts_with(prefix) { return text; diff --git a/codex-rs/core/tests/suite/realtime_conversation.rs b/codex-rs/core/tests/suite/realtime_conversation.rs index 8cc7191826..3a8442c935 100644 --- a/codex-rs/core/tests/suite/realtime_conversation.rs +++ b/codex-rs/core/tests/suite/realtime_conversation.rs @@ -741,7 +741,7 @@ async fn conversation_websocket_transports_send_codex_headers_without_creating_a .expect("realtime websocket connection failed"); let (expected_uri, expected_realtime_session_id) = match transport { ConversationStartTransport::Websocket => ( - "/v1/live?model=gpt-live-1-codex", + "/v1/live?model=gpt-live-1.1-codex", Some(conversation.thread_id.to_string()), ), ConversationStartTransport::ExistingCall { .. } => ("/v1/live/rtc_existing", None), @@ -804,15 +804,15 @@ async fn conversation_websocket_transports_send_codex_headers_without_creating_a Ok(()) } -#[test_case(None, "gpt-live-1-codex", None, None; "default model without source")] +#[test_case(None, "gpt-live-1.1-codex", None, None; "default model without source")] #[test_case(Some("session-override-model"), "session-override-model", None, None; "explicit model without source")] -#[test_case(None, "gpt-live-1-codex", Some(ThreadSource::User), Some("user".to_string()); "user source")] -#[test_case(None, "gpt-live-1-codex", Some(ThreadSource::Feature("voice_chat".to_string())), Some("voice_chat".to_string()); "voice chat source")] -#[test_case(None, "gpt-live-1-codex", Some(ThreadSource::Feature("realtime_voice".to_string())), Some("realtime_voice".to_string()); "realtime voice source")] -#[test_case(None, "gpt-live-1-codex", Some(ThreadSource::GuardianReview), Some("guardian_review".to_string()); "child shares parent session")] -#[test_case(None, "gpt-live-1-codex", Some(ThreadSource::Feature("x".repeat(/*n*/ 256))), Some("x".repeat(/*n*/ 256)); "source at byte limit")] -#[test_case(None, "gpt-live-1-codex", Some(ThreadSource::Feature("x".repeat(/*n*/ 257))), None; "oversized source omitted")] -#[test_case(None, "gpt-live-1-codex", Some(ThreadSource::Feature("é\r\n\"".to_string())), Some("é\r\n\"".to_string()); "source escaped for headers")] +#[test_case(None, "gpt-live-1.1-codex", Some(ThreadSource::User), Some("user".to_string()); "user source")] +#[test_case(None, "gpt-live-1.1-codex", Some(ThreadSource::Feature("voice_chat".to_string())), Some("voice_chat".to_string()); "voice chat source")] +#[test_case(None, "gpt-live-1.1-codex", Some(ThreadSource::Feature("realtime_voice".to_string())), Some("realtime_voice".to_string()); "realtime voice source")] +#[test_case(None, "gpt-live-1.1-codex", Some(ThreadSource::GuardianReview), Some("guardian_review".to_string()); "child shares parent session")] +#[test_case(None, "gpt-live-1.1-codex", Some(ThreadSource::Feature("x".repeat(/*n*/ 256))), Some("x".repeat(/*n*/ 256)); "source at byte limit")] +#[test_case(None, "gpt-live-1.1-codex", Some(ThreadSource::Feature("x".repeat(/*n*/ 257))), None; "oversized source omitted")] +#[test_case(None, "gpt-live-1.1-codex", Some(ThreadSource::Feature("é\r\n\"".to_string())), Some("é\r\n\"".to_string()); "source escaped for headers")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn conversation_webrtc_frameless_chatgpt_sends_codex_headers_to_backend( model: Option<&str>, @@ -985,11 +985,13 @@ async fn conversation_webrtc_frameless_chatgpt_sends_codex_headers_to_backend( "sdp": body["sdp"], "delegation": body["session"]["delegation"]["type"], "model": body["session"]["model"], + "voice": body["session"]["audio"]["output"]["voice"], }), json!({ "sdp": "v=offer\r\n", "delegation": "client", "model": expected_model, + "voice": "cove", }) ); @@ -1769,7 +1771,7 @@ async fn conversation_webrtc_default_v1_rejects_explicit_v2_voice() -> Result<() sdp: "v=offer\r\n".to_string(), }), version: None, - voice: Some(RealtimeVoice::Cedar), + voice: Some(Some(RealtimeVoice::Cedar)), })) .await?; @@ -3028,14 +3030,28 @@ async fn conversation_uses_empty_instructions_for_null_or_empty_prompt() -> Resu Ok(()) } +#[test_case(RealtimeConversationVersion::V1, None, Some(RealtimeVoice::Breeze), "breeze"; "v1 explicit voice")] +#[test_case(RealtimeConversationVersion::V3, None, None, "marin"; "v3 API default")] +#[test_case(RealtimeConversationVersion::V3, None, Some(RealtimeVoice::Marin), "marin"; "v3 explicit API voice")] +#[test_case(RealtimeConversationVersion::V3, Some(RealtimeVoice::Cove), Some(RealtimeVoice::Marin), "marin"; "explicit voice overrides config")] +#[test_case(RealtimeConversationVersion::V3, Some(RealtimeVoice::Cove), None, "cove"; "v3 configured voice")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn conversation_uses_explicit_start_voice() -> Result<()> { +async fn conversation_selects_realtime_voice( + version: RealtimeConversationVersion, + configured_voice: Option, + voice: Option, + expected_voice: &str, +) -> Result<()> { skip_if_no_network!(Ok(())); // Responses prewarm can reconnect; give realtime its own scripted connections. let startup_server = start_websocket_server(vec![vec![]]).await; let server = start_websocket_server(vec![vec![vec![json!({ - "type": "session.updated", + "type": if version == RealtimeConversationVersion::V3 { + "session.started" + } else { + "session.updated" + }, "session": { "id": "sess_voice", "instructions": "backend prompt" } })]]]) .await; @@ -3043,6 +3059,7 @@ async fn conversation_uses_explicit_start_voice() -> Result<()> { let realtime_base_url = server.uri().to_string(); move |config| { config.experimental_realtime_ws_base_url = Some(realtime_base_url); + config.realtime.voice = configured_voice; } }); let test = builder.build_with_websocket_server(&startup_server).await?; @@ -3073,8 +3090,8 @@ async fn conversation_uses_explicit_start_voice() -> Result<()> { realtime_session_id: None, voice_session_id: None, transport: None, - version: None, - voice: Some(RealtimeVoice::Breeze), + version: Some(version), + voice: voice.map(Some), })) .await?; @@ -3094,7 +3111,7 @@ async fn conversation_uses_explicit_start_voice() -> Result<()> { let connections = server.connections(); assert_eq!( connections[0][0].body_json()["session"]["audio"]["output"]["voice"], - "breeze" + expected_voice ); startup_server.shutdown().await; @@ -3209,7 +3226,7 @@ async fn conversation_rejects_voice_for_wrong_realtime_version() -> Result<()> { voice_session_id: None, transport: None, version: None, - voice: Some(RealtimeVoice::Cove), + voice: Some(Some(RealtimeVoice::Cove)), })) .await?; diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 6aeba01792..cb89b25150 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -224,7 +224,8 @@ pub struct ConversationStartParams { pub transport: Option, /// Overrides the configured realtime protocol version for this session only. pub version: Option, - pub voice: Option, + /// None inherits the thread setting; Some(None) selects the provider default. + pub voice: Option>, } /// Controls when an in-process realtime attachment may establish a connection. @@ -413,6 +414,7 @@ impl RealtimeVoicesList { RealtimeVoice::Arbor, RealtimeVoice::Sol, RealtimeVoice::Cove, + RealtimeVoice::Marin, RealtimeVoice::Aube, RealtimeVoice::Bubbie, RealtimeVoice::Haetsal, diff --git a/codex-rs/tui/src/app/realtime_settings.rs b/codex-rs/tui/src/app/realtime_settings.rs index 426b52f879..83dda0e702 100644 --- a/codex-rs/tui/src/app/realtime_settings.rs +++ b/codex-rs/tui/src/app/realtime_settings.rs @@ -12,18 +12,14 @@ use codex_protocol::protocol::RealtimeVoicesList; use codex_realtime_webrtc::AudioDeviceKind; use uuid::Uuid; -/// Unknown voices belong to newer servers; omit the override instead of blocking audio. -/// An unset preference explicitly selects the V1/V3 server default, not a stale thread preference. -fn configured_voice( - config: &codex_app_server_protocol::Config, - default_voice: RealtimeVoice, -) -> Result> { +/// Display the saved preference without guessing a provider-specific default. +fn configured_voice(config: &codex_app_server_protocol::Config) -> Result> { let value = config .additional .get("realtime") .and_then(|realtime| realtime.get("voice")); match value { - None | Some(serde_json::Value::Null) => Ok(Some(default_voice)), + None | Some(serde_json::Value::Null) => Ok(None), Some(value @ serde_json::Value::String(_)) => { Ok(serde_json::from_value(value.clone()).ok()) } @@ -197,7 +193,6 @@ impl App { pub(super) async fn effective_realtime_voice( app_server: &AppServerSession, cwd: &std::path::Path, - voices: &RealtimeVoicesList, ) -> Result> { // Session setup installs the active conversation cwd for both server modes. let config = crate::config_update::read_effective_config_if_supported( @@ -207,7 +202,7 @@ impl App { .await?; config .as_ref() - .map(|config| configured_voice(&config.config, voices.default_v1)) + .map(|config| configured_voice(&config.config)) .transpose() .map(Option::flatten) } @@ -221,13 +216,7 @@ impl App { return; } }; - match Self::effective_realtime_voice( - app_server, - &self.chat_widget.config_ref().cwd, - &voices, - ) - .await - { + match Self::effective_realtime_voice(app_server, &self.chat_widget.config_ref().cwd).await { Ok(voice) => self.chat_widget.open_realtime_voices(voice, voices), Err(error) => self .chat_widget @@ -250,19 +239,10 @@ impl App { .await { Ok(response) => { - let voices = match Self::realtime_voices(app_server).await { - Ok(voices) => voices, - Err(error) => { - self.chat_widget.add_error_message(format!( - "Voice preference was saved, but the effective voice could not be confirmed: {error}" - )); - return; - } - }; let effective = crate::config_update::read_effective_config_if_supported( app_server.request_handle(), &self.chat_widget.config_ref().cwd, ).await.and_then(|config| match config.as_ref() { - Some(config) => configured_voice(&config.config, voices.default_v1), + Some(config) => configured_voice(&config.config), None if response.status == codex_app_server_protocol::WriteStatus::OkOverridden => Err(color_eyre::eyre::eyre!("the saved voice is overridden, but this server cannot read effective settings")), None => Ok(Some(voice)), }); diff --git a/codex-rs/tui/src/app/tests/realtime_handoff_e2e.rs b/codex-rs/tui/src/app/tests/realtime_handoff_e2e.rs index dbc2770284..6999cda88b 100644 --- a/codex-rs/tui/src/app/tests/realtime_handoff_e2e.rs +++ b/codex-rs/tui/src/app/tests/realtime_handoff_e2e.rs @@ -134,7 +134,7 @@ async fn delegated_core_events_keep_private_output_hidden_and_deliver_final_spee &app.config, &thread, "v=offer\r\n".to_string(), - codex_protocol::protocol::RealtimeVoicesList::builtin().default_v1, + /*voice*/ None, ) .await?; assert_eq!(String::from(call.answer_sdp), "v=answer\r\n"); diff --git a/codex-rs/tui/src/app/tests/realtime_requests.rs b/codex-rs/tui/src/app/tests/realtime_requests.rs index 0f16088ba3..535fce5e34 100644 --- a/codex-rs/tui/src/app/tests/realtime_requests.rs +++ b/codex-rs/tui/src/app/tests/realtime_requests.rs @@ -85,10 +85,18 @@ enum ItemEventKind { Completed, } -async fn mock_voice_calls(config: &mut crate::legacy_core::config::Config) -> wiremock::MockServer { +async fn mock_voice_calls( + config: &mut crate::legacy_core::config::Config, + base_path: &str, +) -> wiremock::MockServer { let server = wiremock::MockServer::start().await; + let path = if base_path == "/v1" { + "/v1/live" + } else { + "/backend-api/realtime/calls" + }; wiremock::Mock::given(wiremock::matchers::method("POST")) - .and(wiremock::matchers::path("/backend-api/realtime/calls")) + .and(wiremock::matchers::path(path)) .respond_with( wiremock::ResponseTemplate::new(201) .insert_header("Location", "/realtime/calls/rtc_tui") @@ -97,7 +105,7 @@ async fn mock_voice_calls(config: &mut crate::legacy_core::config::Config) -> wi .mount(&server) .await; config.experimental_realtime_webrtc_call_base_url = - Some(format!("{}/backend-api", server.uri())); + Some(format!("{}{base_path}", server.uri())); server } @@ -108,11 +116,17 @@ async fn remote_voice_start_routes_v3_offer_and_effective_preference() -> Result (HistoryCapabilities::Current, Some("juniper")), (HistoryCapabilities::ConfigReadUnsupported(-32601), None), (HistoryCapabilities::Current, Some("cove")), - (HistoryCapabilities::VoiceCatalogCustom, Some("maple")), + (HistoryCapabilities::VoiceCatalogCustom, None), (HistoryCapabilities::ConfigReadUnknownVoice, None), ] { - check_remote_voice_start(capabilities, expected_voice).await?; + check_remote_voice_start(capabilities, expected_voice, "/backend-api").await?; } + check_remote_voice_start( + HistoryCapabilities::Current, + /*expected_voice*/ None, + "/v1", + ) + .await?; Ok(()) } @@ -133,9 +147,10 @@ async fn next_call_result( async fn check_remote_voice_start( capabilities: super::session_lifecycle_requests::HistoryCapabilities, expected_voice: Option<&str>, + base_path: &str, ) -> Result<()> { let (mut app, mut events, _ops) = make_test_app_with_channels().await; - let calls = mock_voice_calls(&mut app.config).await; + let calls = mock_voice_calls(&mut app.config, base_path).await; let (mut app_server, requests, proxy) = super::session_lifecycle_requests::start_recording_app_server_with_realtime_speech( &app.config, @@ -212,13 +227,19 @@ async fn check_remote_voice_start( assert_eq!(calls.len(), 1); assert_eq!(calls[0].headers["thread-id"], thread_id.to_string()); assert_eq!(calls[0].headers["session-id"], thread_id.to_string()); - let body = calls[0].body_json::()?; - assert_eq!(body["sdp"], "v=0\r\n"); - let default_voice = codex_protocol::protocol::RealtimeVoicesList::builtin().default_v1; - assert_eq!( - body["session"]["audio"]["output"]["voice"], - expected_voice.unwrap_or(default_voice.wire_name()) - ); + if base_path == "/v1" { + let body = std::str::from_utf8(&calls[0].body)?; + assert!(body.contains(r#""voice":"marin""#)); + assert!(body.contains(r#""model":"gpt-live-1.1-codex""#)); + } else { + let body = calls[0].body_json::()?; + assert_eq!(body["sdp"], "v=0\r\n"); + assert_eq!(body["session"]["model"], "gpt-live-1.1-codex"); + assert_eq!( + body["session"]["audio"]["output"]["voice"], + expected_voice.unwrap_or("cove") + ); + } let starts = recorded_params(&requests, "thread/realtime/start"); assert_eq!(starts.len(), 1); let start = &starts[0]; @@ -1982,7 +2003,7 @@ async fn remote_voice_config_read_failure_does_not_start_with_local_voice() -> R async fn embedded_voice_settings_follow_project_after_thread_switch() -> Result<()> { use codex_protocol::protocol::RealtimeVoice; let (mut app, mut events, _ops) = make_test_app_with_channels().await; - let calls = mock_voice_calls(&mut app.config).await; + let calls = mock_voice_calls(&mut app.config, "/backend-api").await; let projects = tempfile::tempdir()?; for voice in [RealtimeVoice::Maple, RealtimeVoice::Sol] { let cwd = projects.path().join(voice.wire_name()); @@ -2100,7 +2121,7 @@ async fn remote_voice_catalog_success_and_failure() -> Result<()> { (HistoryCapabilities::VoiceCatalogUnavailable, false), ] { let (mut app, mut events, _ops) = make_test_app_with_channels().await; - let (mut server, requests, proxy) = + let (server, requests, proxy) = super::session_lifecycle_requests::start_recording_app_server_with_realtime_speech( &app.config, capabilities, @@ -2117,7 +2138,8 @@ async fn remote_voice_catalog_success_and_failure() -> Result<()> { &app.chat_widget, /*width*/ 80, ); - assert_eq!(popup.contains("1. maple (current)"), expected_catalog); + assert_eq!(popup.contains("1. maple"), expected_catalog); + assert!(!popup.contains("(current)")); assert!(!popup.contains(" 3. spruce")); if expected_catalog { insta::assert_snapshot!("remote_voice_picker_server_default", popup); @@ -2128,81 +2150,20 @@ async fn remote_voice_catalog_success_and_failure() -> Result<()> { ); if !expected_catalog { assert!(!app.chat_widget.has_active_view()); - for phase in ["picker", "save", "start"] { - match phase { - "save" => { - let previous = ( - app.config.realtime.voice, - app.chat_widget.config_ref().realtime.voice, - ); - app.persist_realtime_voice( - &server, - codex_protocol::protocol::RealtimeVoice::Juniper, - ) - .await; - assert_eq!( - ( - app.config.realtime.voice, - app.chat_widget.config_ref().realtime.voice, - ), - previous, - ); - let saved: toml::Value = toml::from_str(&std::fs::read_to_string( - app.config.codex_home.join("config.toml"), - )?)?; - assert_eq!(saved["realtime"]["voice"].as_str(), Some("juniper")); - } - "start" => { - let thread_id = ThreadId::new(); - app.chat_widget - .handle_thread_session_quiet(test_thread_session( - thread_id, - app.config.cwd.to_path_buf(), - )); - app.active_thread_id = Some(thread_id); - crate::chatwidget::start_voice_attempt_for_test( - &mut app.chat_widget, - thread_id, - /*attempt_id*/ 1, - ); - assert!(app.chat_widget.may_receive_realtime_transcripts()); - let mut tui = crate::tui::test_support::make_test_tui()?; - Box::pin(app.handle_event( - &mut tui, - &mut server, - AppEvent::CodexOp(Op::RealtimeConversationStart { - thread_id, - attempt_id: 1, - offer_sdp: String::from("v=0\r\n").into(), - }), - )) - .await?; - Box::pin(app.handle_event( - &mut tui, - &mut server, - next_call_result(&mut events).await?, - )) - .await?; - assert!(!app.chat_widget.may_receive_realtime_transcripts()); - assert!(recorded_params(&requests, "thread/realtime/start").is_empty()); - } - _ => {} - } - let messages = std::iter::from_fn(|| events.try_recv().ok()) - .filter_map(|event| match event { - AppEvent::InsertHistoryCell(cell) => Some( - cell.display_lines(/*width*/ 80) - .into_iter() - .map(|line| line.to_string()) - .collect::>() - .join("\n"), - ), - _ => None, - }) - .collect::>() - .join("\n"); - insta::assert_snapshot!(format!("voice_catalog_failure_{phase}"), messages); - } + let messages = std::iter::from_fn(|| events.try_recv().ok()) + .filter_map(|event| match event { + AppEvent::InsertHistoryCell(cell) => Some( + cell.display_lines(/*width*/ 80) + .into_iter() + .map(|line| line.to_string()) + .collect::>() + .join("\n"), + ), + _ => None, + }) + .collect::>() + .join("\n"); + insta::assert_snapshot!("voice_catalog_failure_picker", messages); } server.shutdown().await?; proxy.await??; diff --git a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs index 6230e2e30e..98587be60a 100644 --- a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs +++ b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs @@ -1752,9 +1752,8 @@ pub(super) async fn start_recording_app_server_with_realtime_speech( serde_json::json!("future_voice"); } if custom_voice_catalog && let Ok(value) = &mut result { - value["voices"]["v1"] = + value["voices"]["v3"] = serde_json::json!(["maple", "cove", "juniper"]); - value["voices"]["defaultV1"] = serde_json::json!("maple"); } if background { let terminal = r#"{"data":[{"itemId":"x","processId":"x","command":"x","cwd":"/"}],"nextCursor":null}"#; diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__remote_voice_picker_server_default.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__remote_voice_picker_server_default.snap index b4792ba8f4..9e67918733 100644 --- a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__remote_voice_picker_server_default.snap +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__remote_voice_picker_server_default.snap @@ -6,7 +6,7 @@ expression: popup Applies to your next voice conversation. -› 1. maple (current) +› 1. maple 2. cove 3. juniper 4. Back diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__voice_catalog_failure_save.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__voice_catalog_failure_save.snap deleted file mode 100644 index 05354441e0..0000000000 --- a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__voice_catalog_failure_save.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui/src/app/tests/realtime_requests.rs -expression: messages ---- -■ Voice preference was saved, but the effective voice could not be confirmed: thread/realtime/listVoices failed: method not found (code -32601) diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__voice_catalog_failure_start.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__voice_catalog_failure_start.snap deleted file mode 100644 index 15c04a0717..0000000000 --- a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__voice_catalog_failure_start.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui/src/app/tests/realtime_requests.rs -expression: messages ---- -■ Voice conversation failed: thread/realtime/listVoices failed: method not found (code -32601) diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index 1c21ebebc1..47b631e091 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -919,10 +919,7 @@ impl App { let tx = self.app_event_tx.clone(); tokio::spawn(async move { let create = async { - let voices = Self::realtime_voices(&server).await?; - let voice = Self::effective_realtime_voice(&server, &cwd, &voices) - .await? - .unwrap_or(voices.default_v1); + let voice = Self::effective_realtime_voice(&server, &cwd).await?; let thread = server .thread_read(thread_id, /*include_turns*/ false) .await?; diff --git a/codex-rs/tui/src/chatwidget/realtime_settings.rs b/codex-rs/tui/src/chatwidget/realtime_settings.rs index 3b5f6611d3..5a281f1136 100644 --- a/codex-rs/tui/src/chatwidget/realtime_settings.rs +++ b/codex-rs/tui/src/chatwidget/realtime_settings.rs @@ -58,9 +58,8 @@ impl ChatWidget { current: Option, voices: RealtimeVoicesList, ) { - // The TUI uses V3, which shares the V1 voice catalog. let mut items: Vec = voices - .v1 + .v3 .into_iter() .map(|voice| SelectionItem { name: voice.wire_name().to_string(), diff --git a/codex-rs/tui/src/chatwidget/realtime_settings_tests.rs b/codex-rs/tui/src/chatwidget/realtime_settings_tests.rs index e9bcc6d5e6..756d9b1971 100644 --- a/codex-rs/tui/src/chatwidget/realtime_settings_tests.rs +++ b/codex-rs/tui/src/chatwidget/realtime_settings_tests.rs @@ -66,7 +66,7 @@ async fn voice_settings_uses_server_catalog() { chat.open_realtime_voices( Some(RealtimeVoice::Cove), RealtimeVoicesList { - v1: vec![RealtimeVoice::Cove, RealtimeVoice::Maple], + v3: vec![RealtimeVoice::Cove, RealtimeVoice::Maple], ..RealtimeVoicesList::builtin() }, ); diff --git a/codex-rs/tui/src/realtime_call.rs b/codex-rs/tui/src/realtime_call.rs index 37bfbee368..5b2a3f5712 100644 --- a/codex-rs/tui/src/realtime_call.rs +++ b/codex-rs/tui/src/realtime_call.rs @@ -34,7 +34,7 @@ pub(crate) async fn create( config: &Config, thread: &Thread, offer: String, - voice: RealtimeVoice, + voice: Option, ) -> Result { ensure!( config.realtime.session_type == RealtimeWsMode::Conversational, @@ -76,7 +76,7 @@ pub(crate) async fn create( config .experimental_realtime_ws_model .clone() - .unwrap_or_else(|| "gpt-live-1-codex".to_string()), + .unwrap_or_else(|| "gpt-live-1.1-codex".to_string()), ), session_id: Some(id), event_parser: RealtimeEventParser::FramelessBidi,