Support attaching to existing realtime calls (#39876)

## Why

Clients that create and negotiate realtime calls themselves need Codex to join
those calls without creating another call or overwriting the client-owned
session configuration.

## What changed

- Add the `existingCall` transport to `thread/realtime/start`, accepting a
  client-provided `callId` and optional `realtimeSessionId`.
- Attach Codex over the call's sideband WebSocket without SDP negotiation or a
  session update, and reconnect that sideband while preserving transcript
  state.
- Default existing calls to realtime v1, support v1 and v3, and reject v2 or
  options that would reconfigure the client-owned session.
- Encode call IDs as a single URL path segment for v3 sideband connections.

## Testing

Add protocol, app-server, core, reconnection, authentication-header, and URL
encoding coverage for existing-call attachments.

GitOrigin-RevId: 2046b70566f98efa55381bb1e461ea3cc68256c5
This commit is contained in:
Zahan Malkani
2026-08-21 06:41:28 +00:00
committed by copyberry
parent 2aaefa32b0
commit 536f86e5cc
18 changed files with 765 additions and 34 deletions
@@ -1,6 +1,7 @@
use anyhow::Context;
use anyhow::Result;
use chrono::Utc;
use codex_config::config_toml::RealtimeWsMode;
use codex_config::config_toml::RealtimeWsVersion;
use codex_core::TurnInputRequest;
use codex_core::test_support::auth_manager_from_auth;
@@ -54,6 +55,7 @@ use std::process::Command;
use std::sync::Arc;
use std::sync::Mutex;
use std::time::Duration;
use test_case::test_case;
use tokio::io::AsyncReadExt;
use tokio::io::AsyncWriteExt;
use tokio::sync::oneshot;
@@ -507,6 +509,85 @@ async fn conversation_start_defaults_to_v2_and_gpt_realtime_1_5() -> Result<()>
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn conversation_existing_call_attaches_without_creating_another_call() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let realtime_server = start_websocket_server(vec![vec![vec![]]]).await;
let realtime_ws_base_url = realtime_server.uri().to_string();
let mut builder = test_codex().with_config(move |config| {
config.experimental_realtime_ws_backend_prompt = Some("backend prompt".to_string());
config.experimental_realtime_ws_base_url = Some(realtime_ws_base_url);
config.realtime.session_type = RealtimeWsMode::Transcription;
});
let test = builder.build_with_auto_env(&server).await?;
test.codex
.submit(Op::RealtimeConversationStart(ConversationStartParams {
client_managed_handoffs: false,
delegation_ack_filler: None,
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_mode:
codex_protocol::protocol::CodexResponseHandoffMode::Thinking,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: false,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: None,
realtime_session_id: None,
transport: Some(ConversationStartTransport::ExistingCall {
call_id: "rtc_existing".to_string(),
}),
version: Some(RealtimeConversationVersion::V3),
voice: None,
}))
.await?;
let started = wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::RealtimeConversationStarted(started) => Some(Ok(started.clone())),
EventMsg::Error(err) => Some(Err(err.clone())),
_ => None,
})
.await
.expect("existing call sideband attachment failed");
assert_eq!(
(started.version, started.realtime_session_id),
(RealtimeConversationVersion::V3, None)
);
let handshake = realtime_server.single_handshake();
assert_eq!(handshake.uri(), "/v1/live/rtc_existing");
assert_eq!(
handshake.header("authorization").as_deref(),
Some("Bearer dummy")
);
assert_eq!(handshake.header("x-session-id"), None);
assert!(
server
.received_requests()
.await
.context("mock server should record requests")?
.iter()
.all(|request| !request.url.path().ends_with("/realtime/calls")),
"attaching to an existing call must not create another realtime call"
);
test.codex.submit(Op::RealtimeConversationClose).await?;
let _closed = wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::RealtimeConversationClosed(closed) => Some(closed.clone()),
_ => None,
})
.await;
realtime_server.shutdown().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn conversation_webrtc_frameless_chatgpt_sends_codex_headers_to_backend() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -823,10 +904,22 @@ async fn conversation_webrtc_start_posts_generated_session() -> Result<()> {
Ok(())
}
#[test_case(
ConversationStartTransport::Webrtc { sdp: "v=offer\r\n".to_string() };
"core-created webrtc"
)]
#[test_case(
ConversationStartTransport::ExistingCall { call_id: "rtc_reconnect".to_string() };
"client-created existing call"
)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn conversation_webrtc_live_reconnects_sideband_after_unclean_disconnect() -> Result<()> {
async fn conversation_webrtc_live_reconnects_sideband_after_unclean_disconnect(
transport: ConversationStartTransport,
) -> Result<()> {
skip_if_no_network!(Ok(()));
let attaches_existing_call =
matches!(&transport, ConversationStartTransport::ExistingCall { .. });
let server = start_mock_server().await;
Mock::given(method("POST"))
.and(path("/v1/live"))
@@ -979,7 +1072,7 @@ async fn conversation_webrtc_live_reconnects_sideband_after_unclean_disconnect()
config.experimental_realtime_ws_base_url = Some(realtime_ws_base_url);
config.realtime.version = RealtimeWsVersion::V3;
});
let test = builder.build(&server).await?;
let test = builder.build_with_auto_env(&server).await?;
test.codex
.submit(Op::RealtimeConversationStart(ConversationStartParams {
@@ -993,15 +1086,17 @@ async fn conversation_webrtc_live_reconnects_sideband_after_unclean_disconnect()
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
include_startup_context: !attaches_existing_call,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
transport: Some(ConversationStartTransport::Webrtc {
sdp: "v=offer\r\n".to_string(),
}),
prompt: if attaches_existing_call {
None
} else {
Some(Some("backend prompt".to_string()))
},
realtime_session_id: attaches_existing_call.then(|| "sess_client_owned".to_string()),
transport: Some(transport),
version: Some(RealtimeConversationVersion::V3),
voice: None,
}))