From b05266d61b04efd4aa783316acf7b36476fe4066 Mon Sep 17 00:00:00 2001 From: cooper-oai Date: Fri, 13 Nov 2026 04:43:18 +0000 Subject: [PATCH] Update realtime model and honor provider-specific voice defaults (#61259) ## Why An unset voice preference should use the call destination's default. Changed or cleared preferences should also take effect on the next V3 call, even when the thread retains an older setting. ## What changed - Use `gpt-live-1.1-codex` as the default V3 realtime model. - Preserve an unset voice until request serialization, defaulting V3 API calls to `marin` and ChatGPT calls to `cove`, while honoring explicit voice selections. - Reload current voice settings when starting a new V3 call through the app server, including cleared preferences. - Populate the TUI voice picker from the V3 catalog, add `marin` to that catalog, and remove voice-catalog lookups from call startup and preference saving. ## Testing Add regression coverage for API and ChatGPT voice defaults, explicit overrides, changed and cleared settings, and TUI call requests and voice selection. GitOrigin-RevId: d0dd4dcd4bcad9ab869d7bf524e7095a88ffcfaa --- .../src/request_processors/turn_processor.rs | 20 ++- .../tests/suite/v2/realtime_conversation.rs | 71 +++++++- .../codex-api/src/endpoint/realtime_call.rs | 165 ++++++++++-------- .../endpoint/realtime_websocket/methods.rs | 13 +- .../realtime_websocket/methods_auth_tests.rs | 2 +- .../realtime_websocket/methods_common.rs | 9 +- .../endpoint/realtime_websocket/protocol.rs | 14 +- .../codex-api/tests/realtime_websocket_e2e.rs | 18 +- .../codex-api/tests/realtime_websocket_tls.rs | 2 +- codex-rs/core/src/client_tests.rs | 4 +- codex-rs/core/src/realtime_conversation.rs | 19 +- .../core/tests/suite/realtime_conversation.rs | 49 ++++-- codex-rs/protocol/src/protocol.rs | 4 +- codex-rs/tui/src/app/realtime_settings.rs | 32 +--- .../tui/src/app/tests/realtime_handoff_e2e.rs | 2 +- .../tui/src/app/tests/realtime_requests.rs | 143 ++++++--------- .../app/tests/session_lifecycle_requests.rs | 3 +- ...s__remote_voice_picker_server_default.snap | 2 +- ..._requests__voice_catalog_failure_save.snap | 5 - ...requests__voice_catalog_failure_start.snap | 5 - codex-rs/tui/src/app/thread_routing.rs | 5 +- .../tui/src/chatwidget/realtime_settings.rs | 3 +- .../src/chatwidget/realtime_settings_tests.rs | 2 +- codex-rs/tui/src/realtime_call.rs | 4 +- 24 files changed, 323 insertions(+), 273 deletions(-) delete mode 100644 codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__voice_catalog_failure_save.snap delete mode 100644 codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__voice_catalog_failure_start.snap 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,