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
@@ -1082,6 +1082,36 @@ impl TurnRequestProcessor {
request_id: &ConnectionRequestId,
params: ThreadRealtimeStartParams,
) -> Result<Option<ThreadRealtimeStartResponse>, JSONRPCErrorError> {
let attaches_existing_call = matches!(
&params.transport,
Some(ThreadRealtimeStartTransport::ExistingCall { .. })
);
if attaches_existing_call {
let unsupported_option = if params.include_startup_context == Some(true) {
Some("includeStartupContext")
} else if params.prompt.is_some() {
Some("prompt")
} else if params
.initial_items
.as_ref()
.is_some_and(|items| !items.is_empty())
{
Some("initialItems")
} else if params.model.is_some() {
Some("model")
} else if params.voice.is_some() {
Some("voice")
} else if params.delegation_ack_filler.is_some() {
Some("delegationAckFiller")
} else {
None
};
if let Some(option) = unsupported_option {
return Err(invalid_request(format!(
"existingCall transport does not support {option}"
)));
}
}
let Some((_, thread)) = self
.prepare_realtime_conversation_thread(request_id, &params.thread_id)
.await?
@@ -1104,7 +1134,9 @@ impl TurnRequestProcessor {
.codex_response_handoff_channel_prefixes,
model: params.model,
output_modality: params.output_modality,
include_startup_context: params.include_startup_context.unwrap_or(true),
include_startup_context: params
.include_startup_context
.unwrap_or(!attaches_existing_call),
initial_items: params
.initial_items
.unwrap_or_default()
@@ -1125,6 +1157,9 @@ impl TurnRequestProcessor {
ThreadRealtimeStartTransport::Webrtc { sdp } => {
ConversationStartTransport::Webrtc { sdp }
}
ThreadRealtimeStartTransport::ExistingCall { call_id } => {
ConversationStartTransport::ExistingCall { call_id }
}
}),
version: params.version,
voice: params.voice,