mirror of
https://github.com/openai/codex.git
synced 2026-11-13 21:30:47 +00:00
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
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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(()));
|
||||
|
||||
@@ -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<T: HttpTransport> RealtimeCallClient<T> {
|
||||
pub async fn create_with_session_and_headers(
|
||||
&self,
|
||||
sdp: String,
|
||||
session_config: RealtimeSessionConfig,
|
||||
mut session_config: RealtimeSessionConfig,
|
||||
extra_headers: HeaderMap,
|
||||
) -> Result<RealtimeCallResponse, ApiError> {
|
||||
trace!(target: "codex_api::realtime_websocket::wire", "realtime call request SDP: {sdp}");
|
||||
@@ -137,6 +138,10 @@ impl<T: HttpTransport> RealtimeCallClient<T> {
|
||||
// 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]
|
||||
|
||||
@@ -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("<none>"),
|
||||
"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(),
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -138,17 +138,16 @@ pub(super) fn session_update_message(
|
||||
}
|
||||
|
||||
pub fn session_update_session_json(config: RealtimeSessionConfig) -> JsonResult<Value> {
|
||||
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,
|
||||
)),
|
||||
}
|
||||
|
||||
@@ -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<RealtimeVoice>,
|
||||
}
|
||||
|
||||
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)]
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<RealtimeVoice>,
|
||||
voice: Option<RealtimeVoice>,
|
||||
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?;
|
||||
|
||||
|
||||
@@ -224,7 +224,8 @@ pub struct ConversationStartParams {
|
||||
pub transport: Option<ConversationStartTransport>,
|
||||
/// Overrides the configured realtime protocol version for this session only.
|
||||
pub version: Option<RealtimeConversationVersion>,
|
||||
pub voice: Option<RealtimeVoice>,
|
||||
/// None inherits the thread setting; Some(None) selects the provider default.
|
||||
pub voice: Option<Option<RealtimeVoice>>,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
|
||||
@@ -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<Option<RealtimeVoice>> {
|
||||
/// Display the saved preference without guessing a provider-specific default.
|
||||
fn configured_voice(config: &codex_app_server_protocol::Config) -> Result<Option<RealtimeVoice>> {
|
||||
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<Option<RealtimeVoice>> {
|
||||
// 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)),
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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::<serde_json::Value>()?;
|
||||
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::<serde_json::Value>()?;
|
||||
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::<Vec<_>>()
|
||||
.join("\n"),
|
||||
),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.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::<Vec<_>>()
|
||||
.join("\n"),
|
||||
),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
insta::assert_snapshot!("voice_catalog_failure_picker", messages);
|
||||
}
|
||||
server.shutdown().await?;
|
||||
proxy.await??;
|
||||
|
||||
@@ -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}"#;
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ expression: popup
|
||||
Applies to your next voice conversation.
|
||||
|
||||
|
||||
› 1. maple (current)
|
||||
› 1. maple
|
||||
2. cove
|
||||
3. juniper
|
||||
4. Back
|
||||
|
||||
-5
@@ -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)
|
||||
-5
@@ -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)
|
||||
@@ -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?;
|
||||
|
||||
@@ -58,9 +58,8 @@ impl ChatWidget {
|
||||
current: Option<RealtimeVoice>,
|
||||
voices: RealtimeVoicesList,
|
||||
) {
|
||||
// The TUI uses V3, which shares the V1 voice catalog.
|
||||
let mut items: Vec<SelectionItem> = voices
|
||||
.v1
|
||||
.v3
|
||||
.into_iter()
|
||||
.map(|voice| SelectionItem {
|
||||
name: voice.wire_name().to_string(),
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
);
|
||||
|
||||
@@ -34,7 +34,7 @@ pub(crate) async fn create(
|
||||
config: &Config,
|
||||
thread: &Thread,
|
||||
offer: String,
|
||||
voice: RealtimeVoice,
|
||||
voice: Option<RealtimeVoice>,
|
||||
) -> Result<CreatedCall> {
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user