Add WebRTC transport to realtime start (#16960)

Adds WebRTC startup to the experimental app-server
`thread/realtime/start` method with an optional transport enum. The
websocket path remains the default; WebRTC offers create the realtime
session through the shared start flow and emit the answer SDP via
`thread/realtime/sdp`.

---------

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Ahmed Ibrahim
2026-04-07 15:43:38 -07:00
committed by GitHub
Unverified
parent 6c36e7d688
commit fb3dcfde1d
42 changed files with 1574 additions and 85 deletions
+26
View File
@@ -39,6 +39,8 @@ use codex_api::MemoriesClient as ApiMemoriesClient;
use codex_api::MemorySummarizeInput as ApiMemorySummarizeInput;
use codex_api::MemorySummarizeOutput as ApiMemorySummarizeOutput;
use codex_api::RawMemory as ApiRawMemory;
use codex_api::RealtimeCallClient as ApiRealtimeCallClient;
use codex_api::RealtimeSessionConfig;
use codex_api::Reasoning;
use codex_api::RequestTelemetry;
use codex_api::ReqwestTransport;
@@ -443,6 +445,30 @@ impl ModelClient {
.map_err(map_api_error)
}
pub async fn create_realtime_call(
&self,
sdp: String,
session_config: RealtimeSessionConfig,
) -> Result<String> {
self.create_realtime_call_with_headers(sdp, session_config, ApiHeaderMap::new())
.await
}
pub async fn create_realtime_call_with_headers(
&self,
sdp: String,
session_config: RealtimeSessionConfig,
extra_headers: ApiHeaderMap,
) -> Result<String> {
let client_setup = self.current_client_setup().await?;
let transport = ReqwestTransport::new(build_reqwest_client());
ApiRealtimeCallClient::new(transport, client_setup.api_provider, client_setup.api_auth)
.create_with_session_and_headers(sdp, session_config, extra_headers)
.await
.map(|response| response.sdp)
.map_err(map_api_error)
}
/// Builds memory summaries for each provided normalized raw memory.
///
/// This is a unary call (no streaming) to `/v1/memories/trace_summarize`.
+1
View File
@@ -7037,6 +7037,7 @@ fn realtime_text_for_event(msg: &EventMsg) -> Option<String> {
EventMsg::Error(_)
| EventMsg::Warning(_)
| EventMsg::RealtimeConversationStarted(_)
| EventMsg::RealtimeConversationSdp(_)
| EventMsg::RealtimeConversationRealtime(_)
| EventMsg::RealtimeConversationClosed(_)
| EventMsg::ModelReroute(_)
+141 -33
View File
@@ -1,3 +1,4 @@
use crate::client::ModelClient;
use crate::codex::Session;
use crate::realtime_context::build_realtime_startup_context;
use async_channel::Receiver;
@@ -27,12 +28,14 @@ use codex_protocol::error::Result as CodexResult;
use codex_protocol::protocol::CodexErrorInfo;
use codex_protocol::protocol::ConversationAudioParams;
use codex_protocol::protocol::ConversationStartParams;
use codex_protocol::protocol::ConversationStartTransport;
use codex_protocol::protocol::ConversationTextParams;
use codex_protocol::protocol::ErrorEvent;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::RealtimeConversationClosedEvent;
use codex_protocol::protocol::RealtimeConversationRealtimeEvent;
use codex_protocol::protocol::RealtimeConversationSdpEvent;
use codex_protocol::protocol::RealtimeConversationStartedEvent;
use codex_protocol::protocol::RealtimeHandoffRequested;
use http::HeaderMap;
@@ -139,6 +142,24 @@ struct ConversationState {
realtime_active: Arc<AtomicBool>,
}
struct RealtimeStart {
api_provider: ApiProvider,
extra_headers: Option<HeaderMap>,
session_config: RealtimeSessionConfig,
model_client: ModelClient,
sdp: Option<String>,
}
struct RealtimeStartOutput {
realtime_active: Arc<AtomicBool>,
connection: RealtimeStartConnection,
}
enum RealtimeStartConnection {
Websocket { events_rx: Receiver<RealtimeEvent> },
Webrtc { sdp: String },
}
#[allow(dead_code)]
impl RealtimeConversationManager {
pub(crate) fn new() -> Self {
@@ -154,12 +175,7 @@ impl RealtimeConversationManager {
.and_then(|state| state.realtime_active.load(Ordering::Relaxed).then_some(()))
}
pub(crate) async fn start(
&self,
api_provider: ApiProvider,
extra_headers: Option<HeaderMap>,
session_config: RealtimeSessionConfig,
) -> CodexResult<(Receiver<RealtimeEvent>, Arc<AtomicBool>)> {
async fn start(&self, start: RealtimeStart) -> CodexResult<RealtimeStartOutput> {
let previous_state = {
let mut guard = self.state.lock().await;
guard.take()
@@ -167,10 +183,37 @@ impl RealtimeConversationManager {
if let Some(state) = previous_state {
stop_conversation_state(state, RealtimeFanoutTaskStop::Abort).await;
}
self.start_inner(start).await
}
async fn start_inner(&self, start: RealtimeStart) -> CodexResult<RealtimeStartOutput> {
let RealtimeStart {
api_provider,
extra_headers,
session_config,
model_client,
sdp,
} = start;
let session_kind = match session_config.event_parser {
RealtimeEventParser::V1 => RealtimeSessionKind::V1,
RealtimeEventParser::RealtimeV2 => RealtimeSessionKind::V2,
};
let realtime_active = Arc::new(AtomicBool::new(true));
if let Some(sdp) = sdp {
let sdp = model_client
.create_realtime_call_with_headers(
sdp,
session_config,
extra_headers.unwrap_or_default(),
)
.await?;
return Ok(RealtimeStartOutput {
realtime_active,
connection: RealtimeStartConnection::Webrtc { sdp },
});
}
let client = RealtimeWebsocketClient::new(api_provider);
let connection = client
@@ -216,7 +259,10 @@ impl RealtimeConversationManager {
fanout_task: None,
realtime_active: Arc::clone(&realtime_active),
});
Ok((events_rx, realtime_active))
Ok(RealtimeStartOutput {
realtime_active,
connection: RealtimeStartConnection::Websocket { events_rx },
})
}
pub(crate) async fn register_fanout_task(
@@ -447,6 +493,7 @@ struct PreparedRealtimeConversationStart {
requested_session_id: Option<String>,
version: RealtimeWsVersion,
session_config: RealtimeSessionConfig,
transport: ConversationStartTransport,
}
async fn prepare_realtime_start(
@@ -460,16 +507,54 @@ async fn prepare_realtime_start(
.auth_manager()
.unwrap_or_else(|| Arc::clone(&sess.services.auth_manager));
let auth = auth_manager.auth().await;
let realtime_api_key = realtime_api_key(auth.as_ref(), &provider)?;
let mut api_provider = provider.to_api_provider(Some(AuthMode::ApiKey))?;
let config = sess.get_config().await;
let transport = params
.transport
.unwrap_or(ConversationStartTransport::Websocket);
let mut api_provider = if matches!(transport, ConversationStartTransport::Websocket) {
provider.to_api_provider(Some(AuthMode::ApiKey))?
} else {
provider.to_api_provider(auth.as_ref().map(CodexAuth::auth_mode))?
};
if let Some(realtime_ws_base_url) = &config.experimental_realtime_ws_base_url {
api_provider.base_url = realtime_ws_base_url.clone();
}
let version = config.realtime.version;
let session_config =
build_realtime_session_config(sess, params.prompt, params.session_id).await?;
let requested_session_id = session_config.session_id.clone();
let extra_headers = match transport {
ConversationStartTransport::Websocket => {
let realtime_api_key = realtime_api_key(auth.as_ref(), &provider)?;
realtime_request_headers(
requested_session_id.as_deref(),
Some(realtime_api_key.as_str()),
)?
}
ConversationStartTransport::Webrtc { .. } => {
realtime_request_headers(requested_session_id.as_deref(), /*api_key*/ None)?
}
};
Ok(PreparedRealtimeConversationStart {
api_provider,
extra_headers,
requested_session_id,
version,
session_config,
transport,
})
}
pub(crate) async fn build_realtime_session_config(
sess: &Arc<Session>,
prompt: String,
session_id: Option<String>,
) -> CodexResult<RealtimeSessionConfig> {
let config = sess.get_config().await;
let prompt = config
.experimental_realtime_ws_backend_prompt
.clone()
.unwrap_or(params.prompt);
.unwrap_or(prompt);
let startup_context = match config.experimental_realtime_ws_startup_context.clone() {
Some(startup_context) => startup_context,
None => {
@@ -484,8 +569,7 @@ async fn prepare_realtime_start(
format!("{prompt}\n\n{startup_context}")
};
let model = config.experimental_realtime_ws_model.clone();
let version = config.realtime.version;
let event_parser = match version {
let event_parser = match config.realtime.version {
RealtimeWsVersion::V1 => RealtimeEventParser::V1,
RealtimeWsVersion::V2 => RealtimeEventParser::RealtimeV2,
};
@@ -493,22 +577,12 @@ async fn prepare_realtime_start(
RealtimeWsMode::Conversational => RealtimeSessionMode::Conversational,
RealtimeWsMode::Transcription => RealtimeSessionMode::Transcription,
};
let requested_session_id = params.session_id.or(Some(sess.conversation_id.to_string()));
let session_config = RealtimeSessionConfig {
Ok(RealtimeSessionConfig {
instructions: prompt,
model,
session_id: requested_session_id.clone(),
session_id: Some(session_id.unwrap_or_else(|| sess.conversation_id.to_string())),
event_parser,
session_mode,
};
let extra_headers =
realtime_request_headers(requested_session_id.as_deref(), realtime_api_key.as_str())?;
Ok(PreparedRealtimeConversationStart {
api_provider,
extra_headers,
requested_session_id,
version,
session_config,
})
}
@@ -523,12 +597,21 @@ async fn handle_start_inner(
requested_session_id,
version,
session_config,
transport,
} = prepared_start;
info!("starting realtime conversation");
let (events_rx, realtime_active) = sess
.conversation
.start(api_provider, extra_headers, session_config)
.await?;
let sdp = match transport {
ConversationStartTransport::Websocket => None,
ConversationStartTransport::Webrtc { sdp } => Some(sdp),
};
let start = RealtimeStart {
api_provider,
extra_headers,
session_config,
model_client: sess.services.model_client.clone(),
sdp,
};
let start_output = sess.conversation.start(start).await?;
info!("realtime conversation started");
@@ -541,6 +624,29 @@ async fn handle_start_inner(
})
.await;
let RealtimeStartOutput {
realtime_active,
connection,
} = start_output;
let events_rx = match connection {
RealtimeStartConnection::Websocket { events_rx } => events_rx,
RealtimeStartConnection::Webrtc { sdp } => {
sess.send_event_raw(Event {
id: sub_id.to_string(),
msg: EventMsg::RealtimeConversationSdp(RealtimeConversationSdpEvent { sdp }),
})
.await;
sess.conversation.finish_if_active(&realtime_active).await;
send_realtime_conversation_closed(
sess,
sub_id.to_string(),
RealtimeConversationEnd::TransportClosed,
)
.await;
return Ok(());
}
};
let sess_clone = Arc::clone(sess);
let sub_id = sub_id.to_string();
let fanout_realtime_active = Arc::clone(&realtime_active);
@@ -660,7 +766,7 @@ fn realtime_api_key(auth: Option<&CodexAuth>, provider: &ModelProviderInfo) -> C
fn realtime_request_headers(
session_id: Option<&str>,
api_key: &str,
api_key: Option<&str>,
) -> CodexResult<Option<HeaderMap>> {
let mut headers = HeaderMap::new();
@@ -670,10 +776,12 @@ fn realtime_request_headers(
headers.insert("x-session-id", session_id);
}
let auth_value = HeaderValue::from_str(&format!("Bearer {api_key}")).map_err(|err| {
CodexErr::InvalidRequest(format!("invalid realtime api key header: {err}"))
})?;
headers.insert(AUTHORIZATION, auth_value);
if let Some(api_key) = api_key {
let auth_value = HeaderValue::from_str(&format!("Bearer {api_key}")).map_err(|err| {
CodexErr::InvalidRequest(format!("invalid realtime api key header: {err}"))
})?;
headers.insert(AUTHORIZATION, auth_value);
}
Ok(Some(headers))
}
@@ -118,6 +118,7 @@ async fn start_realtime_conversation(codex: &codex_core::CodexThread) -> Result<
.submit(Op::RealtimeConversationStart(ConversationStartParams {
prompt: "backend prompt".to_string(),
session_id: None,
transport: None,
}))
.await?;
@@ -7,6 +7,7 @@ use codex_protocol::ThreadId;
use codex_protocol::protocol::CodexErrorInfo;
use codex_protocol::protocol::ConversationAudioParams;
use codex_protocol::protocol::ConversationStartParams;
use codex_protocol::protocol::ConversationStartTransport;
use codex_protocol::protocol::ConversationTextParams;
use codex_protocol::protocol::ErrorEvent;
use codex_protocol::protocol::EventMsg;
@@ -32,15 +33,56 @@ use serde_json::Value;
use serde_json::json;
use std::fs;
use std::process::Command;
use std::sync::Arc;
use std::sync::Mutex;
use std::time::Duration;
use tokio::sync::oneshot;
use tokio::time::timeout;
use wiremock::Match;
use wiremock::Mock;
use wiremock::Request as WiremockRequest;
use wiremock::ResponseTemplate;
use wiremock::matchers::method;
use wiremock::matchers::path_regex;
const STARTUP_CONTEXT_HEADER: &str = "Startup context from Codex.";
const MEMORY_PROMPT_PHRASE: &str =
"You have access to a memory folder with guidance from prior runs.";
const REALTIME_CONVERSATION_TEST_SUBPROCESS_ENV_VAR: &str =
"CODEX_REALTIME_CONVERSATION_TEST_SUBPROCESS";
#[derive(Debug, Clone)]
struct RealtimeCallRequestCapture {
requests: Arc<Mutex<Vec<WiremockRequest>>>,
}
impl RealtimeCallRequestCapture {
fn new() -> Self {
Self {
requests: Arc::new(Mutex::new(Vec::new())),
}
}
fn single_request(&self) -> WiremockRequest {
let requests = self
.requests
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
assert_eq!(requests.len(), 1, "expected one realtime call request");
requests[0].clone()
}
}
impl Match for RealtimeCallRequestCapture {
fn matches(&self, request: &WiremockRequest) -> bool {
self.requests
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(request.clone());
true
}
}
fn websocket_request_text(
request: &core_test_support::responses::WebSocketRequest,
) -> Option<String> {
@@ -182,6 +224,7 @@ async fn conversation_start_audio_text_close_round_trip() -> Result<()> {
.submit(Op::RealtimeConversationStart(ConversationStartParams {
prompt: "backend prompt".to_string(),
session_id: None,
transport: None,
}))
.await?;
@@ -292,6 +335,89 @@ async fn conversation_start_audio_text_close_round_trip() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn conversation_webrtc_start_posts_generated_session() -> Result<()> {
let server = start_mock_server().await;
let capture = RealtimeCallRequestCapture::new();
Mock::given(method("POST"))
.and(path_regex(".*/realtime/calls$"))
.and(capture.clone())
.respond_with(ResponseTemplate::new(200).set_body_string("v=answer\r\n"))
.mount(&server)
.await;
let mut builder = test_codex().with_config(|config| {
config.experimental_realtime_ws_backend_prompt = Some("backend prompt".to_string());
config.experimental_realtime_ws_model = Some("realtime-test-model".to_string());
config.experimental_realtime_ws_startup_context = Some("startup context".to_string());
});
let test = builder.build(&server).await?;
test.codex
.submit(Op::RealtimeConversationStart(ConversationStartParams {
prompt: "backend prompt".to_string(),
session_id: None,
transport: Some(ConversationStartTransport::Webrtc {
sdp: "v=offer\r\n".to_string(),
}),
}))
.await?;
let created = wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::RealtimeConversationSdp(created) => Some(Ok(created.clone())),
EventMsg::Error(err) => Some(Err(err.clone())),
_ => None,
})
.await
.unwrap_or_else(|err: ErrorEvent| panic!("conversation call create failed: {err:?}"));
assert_eq!(created.sdp, "v=answer\r\n");
let closed = wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::RealtimeConversationClosed(closed) => Some(closed.clone()),
_ => None,
})
.await;
assert_eq!(closed.reason.as_deref(), Some("transport_closed"));
let request = capture.single_request();
assert_eq!(request.url.path(), "/v1/realtime/calls");
assert_eq!(request.url.query(), None);
assert_eq!(
request
.headers
.get("authorization")
.and_then(|value| value.to_str().ok()),
Some("Bearer dummy")
);
assert_eq!(
request
.headers
.get("content-type")
.and_then(|value| value.to_str().ok()),
Some("multipart/form-data; boundary=codex-realtime-call-boundary")
);
let body = String::from_utf8(request.body).context("multipart body should be utf-8")?;
let session = r#"{"audio":{"input":{"format":{"type":"audio/pcm","rate":24000}},"output":{"voice":"fathom"}},"type":"quicksilver","model":"realtime-test-model","instructions":"backend prompt\n\nstartup context"}"#;
assert_eq!(
body,
format!(
"--codex-realtime-call-boundary\r\n\
Content-Disposition: form-data; name=\"sdp\"\r\n\
Content-Type: application/sdp\r\n\
\r\n\
v=offer\r\n\
\r\n\
--codex-realtime-call-boundary\r\n\
Content-Disposition: form-data; name=\"session\"\r\n\
Content-Type: application/json\r\n\
\r\n\
{session}\r\n\
--codex-realtime-call-boundary--\r\n"
)
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn conversation_start_uses_openai_env_key_fallback_with_chatgpt_auth() -> Result<()> {
if std::env::var_os(REALTIME_CONVERSATION_TEST_SUBPROCESS_ENV_VAR).is_none() {
@@ -324,6 +450,7 @@ async fn conversation_start_uses_openai_env_key_fallback_with_chatgpt_auth() ->
.submit(Op::RealtimeConversationStart(ConversationStartParams {
prompt: "backend prompt".to_string(),
session_id: None,
transport: None,
}))
.await?;
@@ -383,6 +510,7 @@ async fn conversation_transport_close_emits_closed_event() -> Result<()> {
.submit(Op::RealtimeConversationStart(ConversationStartParams {
prompt: "backend prompt".to_string(),
session_id: None,
transport: None,
}))
.await?;
@@ -466,6 +594,7 @@ async fn conversation_start_preflight_failure_emits_realtime_error_only() -> Res
.submit(Op::RealtimeConversationStart(ConversationStartParams {
prompt: "backend prompt".to_string(),
session_id: None,
transport: None,
}))
.await?;
@@ -506,6 +635,7 @@ async fn conversation_start_connect_failure_emits_realtime_error_only() -> Resul
.submit(Op::RealtimeConversationStart(ConversationStartParams {
prompt: "backend prompt".to_string(),
session_id: None,
transport: None,
}))
.await?;
@@ -594,6 +724,7 @@ async fn conversation_second_start_replaces_runtime() -> Result<()> {
.submit(Op::RealtimeConversationStart(ConversationStartParams {
prompt: "old".to_string(),
session_id: Some("conv_old".to_string()),
transport: None,
}))
.await?;
wait_for_event_match(&test.codex, |msg| match msg {
@@ -610,6 +741,7 @@ async fn conversation_second_start_replaces_runtime() -> Result<()> {
.submit(Op::RealtimeConversationStart(ConversationStartParams {
prompt: "new".to_string(),
session_id: Some("conv_new".to_string()),
transport: None,
}))
.await?;
wait_for_event_match(&test.codex, |msg| match msg {
@@ -696,6 +828,7 @@ async fn conversation_uses_experimental_realtime_ws_base_url_override() -> Resul
.submit(Op::RealtimeConversationStart(ConversationStartParams {
prompt: "backend prompt".to_string(),
session_id: None,
transport: None,
}))
.await?;
@@ -750,6 +883,7 @@ async fn conversation_uses_experimental_realtime_ws_backend_prompt_override() ->
.submit(Op::RealtimeConversationStart(ConversationStartParams {
prompt: "prompt from op".to_string(),
session_id: None,
transport: None,
}))
.await?;
@@ -812,6 +946,7 @@ async fn conversation_uses_experimental_realtime_ws_startup_context_override() -
.submit(Op::RealtimeConversationStart(ConversationStartParams {
prompt: "prompt from op".to_string(),
session_id: None,
transport: None,
}))
.await?;
@@ -872,6 +1007,7 @@ async fn conversation_disables_realtime_startup_context_with_empty_override() ->
.submit(Op::RealtimeConversationStart(ConversationStartParams {
prompt: "prompt from op".to_string(),
session_id: None,
transport: None,
}))
.await?;
@@ -925,6 +1061,7 @@ async fn conversation_start_injects_startup_context_from_thread_history() -> Res
.submit(Op::RealtimeConversationStart(ConversationStartParams {
prompt: "backend prompt".to_string(),
session_id: None,
transport: None,
}))
.await?;
@@ -978,6 +1115,7 @@ async fn conversation_startup_context_falls_back_to_workspace_map() -> Result<()
.submit(Op::RealtimeConversationStart(ConversationStartParams {
prompt: "backend prompt".to_string(),
session_id: None,
transport: None,
}))
.await?;
@@ -1029,6 +1167,7 @@ async fn conversation_startup_context_is_truncated_and_sent_once_per_start() ->
.submit(Op::RealtimeConversationStart(ConversationStartParams {
prompt: "backend prompt".to_string(),
session_id: None,
transport: None,
}))
.await?;
@@ -1113,6 +1252,7 @@ async fn conversation_mirrors_assistant_message_text_to_realtime_handoff() -> Re
.submit(Op::RealtimeConversationStart(ConversationStartParams {
prompt: "backend prompt".to_string(),
session_id: None,
transport: None,
}))
.await?;
@@ -1239,6 +1379,7 @@ async fn conversation_handoff_persists_across_item_done_until_turn_complete() ->
.submit(Op::RealtimeConversationStart(ConversationStartParams {
prompt: "backend prompt".to_string(),
session_id: None,
transport: None,
}))
.await?;
@@ -1380,6 +1521,7 @@ async fn inbound_handoff_request_starts_turn() -> Result<()> {
.submit(Op::RealtimeConversationStart(ConversationStartParams {
prompt: "backend prompt".to_string(),
session_id: None,
transport: None,
}))
.await?;
@@ -1474,6 +1616,7 @@ async fn inbound_handoff_request_uses_active_transcript() -> Result<()> {
.submit(Op::RealtimeConversationStart(ConversationStartParams {
prompt: "backend prompt".to_string(),
session_id: None,
transport: None,
}))
.await?;
@@ -1566,6 +1709,7 @@ async fn inbound_handoff_request_clears_active_transcript_after_each_handoff() -
.submit(Op::RealtimeConversationStart(ConversationStartParams {
prompt: "backend prompt".to_string(),
session_id: None,
transport: None,
}))
.await?;
@@ -1665,6 +1809,7 @@ async fn inbound_conversation_item_does_not_start_turn_and_still_forwards_audio(
.submit(Op::RealtimeConversationStart(ConversationStartParams {
prompt: "backend prompt".to_string(),
session_id: None,
transport: None,
}))
.await?;
@@ -1777,6 +1922,7 @@ async fn delegated_turn_user_role_echo_does_not_redelegate_and_still_forwards_au
.submit(Op::RealtimeConversationStart(ConversationStartParams {
prompt: "backend prompt".to_string(),
session_id: None,
transport: None,
}))
.await?;
@@ -1919,6 +2065,7 @@ async fn inbound_handoff_request_does_not_block_realtime_event_forwarding() -> R
.submit(Op::RealtimeConversationStart(ConversationStartParams {
prompt: "backend prompt".to_string(),
session_id: None,
transport: None,
}))
.await?;
@@ -2045,6 +2192,7 @@ async fn inbound_handoff_request_steers_active_turn() -> Result<()> {
.submit(Op::RealtimeConversationStart(ConversationStartParams {
prompt: "backend prompt".to_string(),
session_id: None,
transport: None,
}))
.await?;
let _ = wait_for_event_match(&test.codex, |msg| match msg {
@@ -2186,6 +2334,7 @@ async fn inbound_handoff_request_starts_turn_and_does_not_block_realtime_audio()
.submit(Op::RealtimeConversationStart(ConversationStartParams {
prompt: "backend prompt".to_string(),
session_id: None,
transport: None,
}))
.await?;