Files
codex/codex-rs/core/tests/suite/realtime_conversation.rs
T
cooper-oai b05266d61b 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
2026-11-13 05:04:45 +00:00

6241 lines
231 KiB
Rust

use anyhow::Context;
use anyhow::Result;
use chrono::Utc;
use codex_app_server_protocol::ThreadRealtimeItemContent;
use codex_app_server_protocol::ThreadRealtimeSessionOutcome;
use codex_app_server_protocol::ThreadRealtimeTranscriptRole;
use codex_app_server_protocol::ThreadTimelineEntry;
use codex_config::config_toml::RealtimeWsMode;
use codex_config::config_toml::RealtimeWsVersion;
use codex_core::StartThreadOptions;
use codex_core::TurnInputRequest;
use codex_core::TurnInputSubmission;
use codex_core::test_support::auth_manager_from_auth;
use codex_history::InitialHistory;
use codex_history::ResponseItemEnvelope;
use codex_history::RolloutItem;
use codex_login::CodexAuth;
use codex_login::OPENAI_API_KEY_ENV_VAR;
use codex_protocol::ThreadId;
use codex_protocol::mcp::ClientMcpExtensions;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
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::ConversationTextRole;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::InternalSessionSource;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::RealtimeAudioFrame;
use codex_protocol::protocol::RealtimeConversationRealtimeEvent;
use codex_protocol::protocol::RealtimeConversationVersion;
use codex_protocol::protocol::RealtimeEvent;
use codex_protocol::protocol::RealtimeHandoffRequested;
use codex_protocol::protocol::RealtimeNoopRequested;
use codex_protocol::protocol::RealtimeOutputModality;
use codex_protocol::protocol::RealtimeTranscriptEntry;
use codex_protocol::protocol::RealtimeVoice;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::ThreadHistoryMode;
use codex_protocol::protocol::ThreadSource;
use codex_protocol::user_input::UserInput;
use codex_thread_store::ListTimelineParams;
use core_test_support::responses;
use core_test_support::responses::WebSocketConnectionConfig;
use core_test_support::responses::start_mock_server;
use core_test_support::responses::start_websocket_server;
use core_test_support::responses::start_websocket_server_with_headers;
use core_test_support::skip_if_no_network;
use core_test_support::streaming_sse::StreamingSseChunk;
use core_test_support::streaming_sse::start_streaming_sse_server;
use core_test_support::test_codex::TestCodex;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
use core_test_support::wait_for_event_match;
use futures::SinkExt;
use futures::StreamExt;
use pretty_assertions::assert_eq;
use serde_json::Value;
use serde_json::json;
use std::collections::BTreeMap;
use std::fs;
use std::process::Command;
use std::sync::Arc;
use std::sync::Mutex;
use std::time::Duration;
use test_case::test_case;
use test_case::test_matrix;
use tokio::io::AsyncReadExt;
use tokio::io::AsyncWriteExt;
use tokio::sync::oneshot;
use tokio::time::timeout;
use tokio_tungstenite::accept_hdr_async;
use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::tungstenite::handshake::server::Request;
use tokio_tungstenite::tungstenite::handshake::server::Response;
use tokio_tungstenite::tungstenite::protocol::CloseFrame;
use tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode;
use uuid::Uuid;
use wiremock::Match;
use wiremock::Mock;
use wiremock::Request as WiremockRequest;
use wiremock::ResponseTemplate;
use wiremock::matchers::method;
use wiremock::matchers::path;
use wiremock::matchers::path_regex;
const STARTUP_CONTEXT_HEADER: &str = "Startup context from Codex.";
const STARTUP_CONTEXT_OPEN_TAG: &str = "<startup_context>";
const STARTUP_CONTEXT_CLOSE_TAG: &str = "</startup_context>";
const REALTIME_BACKEND_PROMPT: &str = codex_prompts::BACKEND_PROMPT;
const USER_FIRST_NAME_PLACEHOLDER: &str = "{{ user_first_name }}";
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 normalized_json_string(raw: &str) -> Result<String> {
let value: Value = serde_json::from_str(raw).context("expected JSON fixture to parse")?;
serde_json::to_string(&value).context("expected JSON fixture to serialize")
}
fn websocket_request_text(
request: &core_test_support::responses::WebSocketRequest,
) -> Option<String> {
request.body_json()["item"]["content"][0]["text"]
.as_str()
.map(str::to_owned)
}
fn websocket_request_instructions(
request: &core_test_support::responses::WebSocketRequest,
) -> Option<String> {
request.body_json()["session"]["instructions"]
.as_str()
.map(str::to_owned)
}
async fn wait_for_websocket_request(
server: &core_test_support::responses::WebSocketTestServer,
connection_index: usize,
request_index: usize,
) -> Result<core_test_support::responses::WebSocketRequest> {
timeout(
Duration::from_secs(2),
server.wait_for_request(connection_index, request_index),
)
.await
.with_context(|| {
format!("timed out waiting for websocket request {connection_index}/{request_index}")
})
}
fn expected_realtime_backend_prompt() -> String {
REALTIME_BACKEND_PROMPT
.trim_end()
.replace(USER_FIRST_NAME_PLACEHOLDER, &test_user_first_name())
}
fn test_user_first_name() -> String {
[whoami::realname(), whoami::username()]
.into_iter()
.filter_map(|name| name.split_whitespace().next().map(str::to_string))
.find(|name| !name.is_empty())
.unwrap_or_else(|| "there".to_string())
}
async fn wait_for_matching_websocket_request<F>(
server: &core_test_support::responses::WebSocketTestServer,
description: &str,
predicate: F,
) -> core_test_support::responses::WebSocketRequest
where
F: Fn(&core_test_support::responses::WebSocketRequest) -> bool,
{
let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
loop {
if let Some(request) = server
.connections()
.iter()
.flat_map(|connection| connection.iter())
.find(|request| predicate(request))
.cloned()
{
return request;
}
assert!(
tokio::time::Instant::now() < deadline,
"timed out waiting for {description}"
);
tokio::time::sleep(Duration::from_millis(10)).await;
}
}
fn run_realtime_conversation_test_in_subprocess(
test_name: &str,
openai_api_key: Option<&str>,
) -> Result<()> {
let mut command = Command::new(std::env::current_exe()?);
command
.arg("--exact")
.arg(test_name)
.env(REALTIME_CONVERSATION_TEST_SUBPROCESS_ENV_VAR, "1");
// The child talks to a loopback websocket server; parent proxy settings can
// route that connection away from the test server in Bazel environments.
for &key in codex_network_proxy::PROXY_ENV_KEYS {
command.env_remove(key);
}
match openai_api_key {
Some(openai_api_key) => {
command.env(OPENAI_API_KEY_ENV_VAR, openai_api_key);
}
None => {
command.env_remove(OPENAI_API_KEY_ENV_VAR);
}
}
let output = command.output()?;
assert!(
output.status.success(),
"subprocess test `{test_name}` failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
Ok(())
}
async fn seed_recent_thread(
test: &TestCodex,
title: &str,
first_user_message: &str,
slug: &str,
) -> Result<()> {
let db = test.codex.state_db().context("state db enabled")?;
let thread_id = ThreadId::new();
let updated_at = Utc::now();
let rollout_path = test
.codex_home_path()
.join(format!("rollout-{thread_id}.jsonl"));
// This helper seeds SQLite metadata directly. Local listing drops stale metadata rows whose
// rollout path no longer exists, so create the placeholder path that the test metadata points
// at without exercising rollout writing in this realtime-context test.
std::fs::write(&rollout_path, "")?;
let metadata_builder = codex_state::ThreadMetadataBuilder::new(
thread_id,
rollout_path,
updated_at,
SessionSource::Cli,
)
.with_cwd(test.workspace_path(format!("workspace-{slug}")))
.with_model_provider(Some("test-provider".to_string()))
.with_git_branch(Some(format!("branch-{slug}")));
let mut metadata = metadata_builder.build("test-provider");
metadata.title = title.to_string();
metadata.first_user_message = Some(first_user_message.to_string());
db.upsert_thread(&metadata).await?;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn conversation_start_audio_text_close_round_trip() -> Result<()> {
skip_if_no_network!(Ok(()));
let api_server = start_mock_server().await;
let server = start_websocket_server(vec![vec![
vec![json!({
"type": "session.updated",
"session": { "id": "sess_1", "instructions": "backend prompt" }
})],
vec![],
vec![
json!({
"type": "conversation.output_audio.delta",
"delta": "AQID",
"sample_rate": 24000,
"channels": 1
}),
json!({
"type": "conversation.item.added",
"item": {
"type": "message",
"role": "assistant",
"content": [{"type": "text", "text": "hi"}]
}
}),
],
]])
.await;
let mut builder = test_codex().with_config({
let provider_base_url = format!("{}/v1", server.uri());
move |config| {
// Exercise the default Realtime endpoint without Responses prewarm
// connections competing for this server's scripted replies.
config.model_provider.base_url = Some(provider_base_url);
config.model_provider.supports_websockets = false;
config.experimental_realtime_ws_base_url = None;
config.experimental_realtime_ws_model = Some("realtime-test-model".to_string());
config.realtime.version = RealtimeWsVersion::V1;
}
});
let test = builder.build_with_auto_env(&api_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,
backend_reasoning_status: false,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
voice_session_id: None,
transport: None,
version: None,
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("conversation start failed");
assert!(started.realtime_session_id.is_some());
assert_eq!(started.version, RealtimeConversationVersion::V1);
let session_updated = wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload:
RealtimeEvent::SessionUpdated {
realtime_session_id: session_id,
..
},
}) => Some(session_id.clone()),
_ => None,
})
.await;
assert_eq!(session_updated, "sess_1");
test.codex
.submit(Op::RealtimeConversationAudio(ConversationAudioParams {
frame: RealtimeAudioFrame {
data: "AQID".to_string(),
sample_rate: 24000,
num_channels: 1,
samples_per_channel: Some(480),
item_id: None,
},
}))
.await?;
test.codex
.submit(Op::RealtimeConversationText(ConversationTextParams {
text: "hello".to_string(),
role: ConversationTextRole::User,
}))
.await?;
let audio_out = wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload: RealtimeEvent::AudioOut(frame),
}) => Some(frame.clone()),
_ => None,
})
.await;
assert_eq!(audio_out.data, "AQID");
let connections = server.connections();
assert_eq!(connections.len(), 1);
let connection = &connections[0];
assert_eq!(connection.len(), 3);
assert_eq!(
connection[0].body_json()["type"].as_str(),
Some("session.update")
);
assert_eq!(
connection[0].body_json()["session"]["audio"]["output"]["voice"],
"cove"
);
let initial_instructions = websocket_request_instructions(&connection[0])
.expect("initial session update instructions");
assert!(initial_instructions.starts_with("backend prompt"));
assert_eq!(
server.handshakes()[0]
.header("x-session-id")
.expect("session.update x-session-id header"),
started
.realtime_session_id
.as_deref()
.expect("started session id should be present")
);
assert_eq!(
server.handshakes()[0].header("authorization").as_deref(),
Some("Bearer dummy")
);
assert_eq!(
server.handshakes()[0].uri(),
"/v1/realtime?intent=quicksilver&model=realtime-test-model"
);
let mut request_types = [
connection[1].body_json()["type"]
.as_str()
.expect("request type")
.to_string(),
connection[2].body_json()["type"]
.as_str()
.expect("request type")
.to_string(),
];
request_types.sort();
assert_eq!(
request_types,
[
"conversation.item.create".to_string(),
"input_audio_buffer.append".to_string(),
]
);
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;
assert!(matches!(
closed.reason.as_deref(),
Some("requested" | "transport_closed")
));
test.codex.ensure_rollout_materialized().await;
let history = test.codex.load_history(/*include_archived*/ false).await?;
assert!(
!history
.items
.iter()
.any(|item| matches!(item, RolloutItem::RealtimeItem(_)))
);
server.shutdown().await;
Ok(())
}
// No host consumes realtime events in this test. The injected store must receive
// canonical history even when the host has no realtime notification adapter.
#[test_case(ThreadRealtimeSessionOutcome::Ended; "transport_close")]
#[test_case(ThreadRealtimeSessionOutcome::Failed; "error_close")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn conversation_records_history_without_an_event_observer(
outcome: ThreadRealtimeSessionOutcome,
) -> Result<()> {
skip_if_no_network!(Ok(()));
let api_server = start_mock_server().await;
let mut events = vec![
json!({ "type": "session.updated", "session": { "id": "voice-1" } }),
json!({ "type": "response.output_text.delta", "delta": "assistant first" }),
json!({ "type": "conversation.item.input_audio_transcription.delta", "delta": "user second" }),
];
if outcome == ThreadRealtimeSessionOutcome::Failed {
events.push(json!({ "type": "error", "error": { "message": "fixture failure" } }));
}
let realtime_server = start_websocket_server(vec![vec![events.clone()], vec![events]]).await;
let mut builder = test_codex()
.with_history_mode(ThreadHistoryMode::Paginated)
.with_config({
let url = realtime_server.uri().to_string();
move |config| {
config.experimental_realtime_ws_base_url = Some(url);
config.realtime.version = RealtimeWsVersion::V2;
}
});
let test = builder.build_with_auto_env(&api_server).await?;
test.codex.ensure_rollout_materialized().await;
let mut expected = Vec::new();
for session_count in 1..=2 {
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,
backend_reasoning_status: false,
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: Some(Some("fixture".to_string())),
realtime_session_id: Some("voice-1".to_string()),
voice_session_id: None,
transport: None,
version: None,
voice: None,
}))
.await?;
let items = timeout(Duration::from_secs(10), async {
loop {
test.thread_store
.flush_thread(test.session_configured.thread_id)
.await?;
let history = test
.thread_store
.list_timeline(ListTimelineParams {
thread_id: test.session_configured.thread_id,
cursor: None,
page_size: 100,
})
.await?;
let items = history
.items
.into_iter()
.filter_map(|item| match item {
ThreadTimelineEntry::Realtime { item, .. } => Some(item),
_ => None,
})
.collect::<Vec<_>>();
if items
.iter()
.filter(|item| {
matches!(
item.content,
ThreadRealtimeItemContent::RealtimeSessionClosed { .. }
)
})
.count()
== session_count
{
break Ok::<_, anyhow::Error>(items);
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.context("Core should persist history without a host observer")??;
expected.extend([
ThreadRealtimeItemContent::RealtimeSessionStarted,
ThreadRealtimeItemContent::TranscriptSegment {
role: ThreadRealtimeTranscriptRole::Assistant,
text: "assistant first".to_string(),
},
ThreadRealtimeItemContent::TranscriptSegment {
role: ThreadRealtimeTranscriptRole::User,
text: "user second".to_string(),
},
ThreadRealtimeItemContent::RealtimeSessionClosed { outcome },
]);
assert_eq!(
items
.iter()
.map(|item| item.content.clone())
.collect::<Vec<_>>(),
expected
);
let ids = items
.iter()
.map(|item| item.id.as_str())
.collect::<std::collections::HashSet<_>>();
assert_eq!(ids.len(), items.len());
assert!(
items
.iter()
.all(|item| item.realtime_session_id == "voice-1")
);
}
test.codex.submit(Op::Shutdown).await?;
realtime_server.shutdown().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn conversation_start_defaults_to_v2_and_gpt_realtime_1_5() -> Result<()> {
skip_if_no_network!(Ok(()));
let api_server = start_mock_server().await;
let realtime_server = start_websocket_server(vec![vec![vec![]]]).await;
let realtime_base_url = realtime_server.uri().to_string();
let mut builder = test_codex().with_config(move |config| {
config.experimental_realtime_ws_base_url = Some(realtime_base_url);
config.experimental_realtime_ws_startup_context = Some(String::new());
});
let test = builder.build(&api_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,
backend_reasoning_status: false,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
voice_session_id: None,
transport: None,
version: None,
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("conversation start failed");
assert!(
realtime_server
.wait_for_handshakes(/*expected*/ 1, Duration::from_secs(2))
.await
);
let session_update = realtime_server
.wait_for_request(/*connection_index*/ 0, /*request_index*/ 0)
.await;
let body = session_update.body_json();
assert_eq!(
json!({
"startedVersion": started.version,
"handshakeUri": realtime_server.single_handshake().uri(),
"voice": body["session"]["audio"]["output"]["voice"],
"instructions": body["session"]["instructions"],
}),
json!({
"startedVersion": RealtimeConversationVersion::V2,
"handshakeUri": "/v1/realtime?model=gpt-realtime-1.5",
"voice": "marin",
"instructions": "backend prompt",
})
);
realtime_server.shutdown().await;
Ok(())
}
#[test_matrix(
[ConversationStartTransport::Websocket, ConversationStartTransport::ExistingCall { call_id: "rtc_existing".to_string(), sideband_base_url: None }],
[None, Some(ThreadSource::User)]
)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn conversation_websocket_transports_send_codex_headers_without_creating_a_call(
transport: ConversationStartTransport,
thread_source: Option<ThreadSource>,
) -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let realtime_server = start_websocket_server(vec![vec![vec![json!({
"type": "session.started",
"session": { "id": "rtc_existing", "instructions": "backend prompt" }
})]]])
.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?;
let conversation = test
.thread_manager
.start_thread(StartThreadOptions {
thread_source: thread_source.clone(),
..test.start_thread_options().await
})
.await?;
let codex = &conversation.thread;
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,
backend_reasoning_status: false,
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,
voice_session_id: None,
transport: Some(transport.clone()),
version: Some(RealtimeConversationVersion::V3),
voice: None,
}))
.await?;
let started = wait_for_event_match(codex, |msg| match msg {
EventMsg::RealtimeConversationStarted(started) => Some(Ok(started.clone())),
EventMsg::Error(err) => Some(Err(err.clone())),
_ => None,
})
.await
.expect("realtime websocket connection failed");
let (expected_uri, expected_realtime_session_id) = match transport {
ConversationStartTransport::Websocket => (
"/v1/live?model=gpt-live-1.1-codex",
Some(conversation.thread_id.to_string()),
),
ConversationStartTransport::ExistingCall { .. } => ("/v1/live/rtc_existing", None),
ConversationStartTransport::Webrtc { .. } => unreachable!("websocket transports only"),
};
assert_eq!(
(started.version, started.realtime_session_id),
(
RealtimeConversationVersion::V3,
expected_realtime_session_id.clone()
)
);
let handshake = realtime_server.single_handshake();
assert_eq!(handshake.uri(), expected_uri);
assert_eq!(
handshake.header("authorization").as_deref(),
Some("Bearer dummy")
);
assert_eq!(
handshake.header("x-session-id"),
expected_realtime_session_id
);
assert_eq!(
(
handshake.header("session-id"),
handshake.header("thread-id")
),
(
Some(conversation.session_configured.session_id.to_string()),
Some(conversation.thread_id.to_string()),
)
);
assert_eq!(
handshake
.header("x-codex-turn-metadata")
.map(|value| serde_json::from_str::<Value>(&value))
.transpose()?,
thread_source.map(|source| json!({ "thread_source": source }))
);
assert!(
server
.received_requests()
.await
.context("mock server should record requests")?
.iter()
.all(|request| !request.url.path().ends_with("/realtime/calls")),
"websocket transports must not create another realtime call over HTTP"
);
codex.submit(Op::RealtimeConversationClose).await?;
let _closed = wait_for_event_match(codex, |msg| match msg {
EventMsg::RealtimeConversationClosed(closed) => Some(closed.clone()),
_ => None,
})
.await;
codex.shutdown_and_wait().await?;
test.codex.shutdown_and_wait().await?;
realtime_server.shutdown().await;
Ok(())
}
#[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.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>,
expected_model: &str,
thread_source: Option<ThreadSource>,
expected_source: Option<String>,
) -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let capture = RealtimeCallRequestCapture::new();
Mock::given(method("POST"))
.and(path_regex(".*/backend-api/codex/realtime/calls$"))
.and(capture.clone())
.respond_with(
ResponseTemplate::new(200)
.insert_header("Location", "/v1/live/rtc_core_test")
.set_body_string("v=answer\r\n"),
)
.mount(&server)
.await;
let realtime_server = start_websocket_server(vec![vec![vec![json!({
"type": "session.started",
"session": { "id": "rtc_core_test", "instructions": "backend prompt" }
})]]])
.await;
let backend_base_url = format!("{}/backend-api/codex", server.uri());
let realtime_ws_base_url = realtime_server.uri().to_string();
let mut builder = test_codex()
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
.with_config(move |config| {
config.model_provider.base_url = Some(backend_base_url);
config.experimental_realtime_ws_backend_prompt = Some("backend prompt".to_string());
config.experimental_realtime_ws_base_url = Some(realtime_ws_base_url);
});
let test = builder.build_with_auto_env(&server).await?;
let mut options = StartThreadOptions {
thread_source: thread_source.clone(),
..test.start_thread_options().await
};
let conversation = if thread_source == Some(ThreadSource::GuardianReview) {
options.session_source = Some(SessionSource::Internal(InternalSessionSource::Guardian));
let child = test
.thread_manager
.spawn_internal_session(test.session_configured.thread_id, options)
.await?;
assert_eq!(
child.session_configured.session_id,
test.session_configured.session_id
);
assert_ne!(
child.session_configured.session_id.to_string(),
child.session_configured.thread_id.to_string()
);
child
} else {
test.thread_manager.start_thread(options).await?
};
let codex = &conversation.thread;
assert_eq!(codex.config_snapshot().await.thread_source, thread_source);
let requested_realtime_session_id = Uuid::new_v4().to_string();
let voice_session_id = "voice-session-test".to_string();
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,
backend_reasoning_status: false,
codex_response_handoff_channel_prefixes: None,
model: model.map(str::to_string),
output_modality: RealtimeOutputModality::Audio,
include_startup_context: false,
initial_items: vec![ConversationTextParams {
text: "private voice transcript".to_string(),
role: ConversationTextRole::User,
}],
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: Some(requested_realtime_session_id.clone()),
voice_session_id: Some(voice_session_id.clone()),
transport: Some(ConversationStartTransport::Webrtc {
sdp: "v=offer\r\n".to_string(),
}),
version: Some(RealtimeConversationVersion::V3),
voice: None,
}))
.await?;
let created = wait_for_event_match(codex, |msg| match msg {
EventMsg::RealtimeConversationSdp(created) => Some(Ok(created.clone())),
EventMsg::Error(err) => Some(Err(err.clone())),
_ => None,
})
.await
.expect("conversation call create failed");
assert_eq!(created.sdp, "v=answer\r\n");
let request = capture.single_request();
let expected_session_id = conversation.session_configured.session_id.to_string();
let expected_thread_id = conversation.session_configured.thread_id.to_string();
assert_ne!(requested_realtime_session_id, expected_session_id);
assert_ne!(requested_realtime_session_id, expected_thread_id);
assert_eq!(
(
request.url.path(),
request.url.query(),
request
.headers
.get("openai-alpha")
.and_then(|value| value.to_str().ok()),
request
.headers
.get("session-id")
.and_then(|value| value.to_str().ok()),
request
.headers
.get("thread-id")
.and_then(|value| value.to_str().ok()),
request
.headers
.get("x-session-id")
.and_then(|value| value.to_str().ok()),
request
.headers
.get("voice-session-id")
.and_then(|value| value.to_str().ok()),
request
.headers
.get("authorization")
.and_then(|value| value.to_str().ok()),
),
(
"/backend-api/codex/realtime/calls",
Some("intent=quicksilver&architecture=avas"),
Some("quicksilver=v2"),
Some(expected_session_id.as_str()),
Some(expected_thread_id.as_str()),
Some(requested_realtime_session_id.as_str()),
Some(voice_session_id.as_str()),
Some("Bearer Access Token"),
)
);
let metadata = request
.headers
.get("x-codex-turn-metadata")
.map(|value| serde_json::from_slice::<Value>(value.as_bytes()))
.transpose()?;
// A continuous Voice call has no single backing turn. Only the saved source belongs here.
assert_eq!(
metadata,
expected_source.map(|source| json!({ "thread_source": source }))
);
for value in request.headers.values() {
let value = value.to_str()?;
assert!(!value.contains("backend prompt"));
assert!(!value.contains("private voice transcript"));
}
let body: Value =
serde_json::from_slice(&request.body).context("backend body should be JSON")?;
assert_eq!(
json!({
"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",
})
);
codex.submit(Op::RealtimeConversationClose).await?;
let _closed = wait_for_event_match(codex, |msg| match msg {
EventMsg::RealtimeConversationClosed(closed) => Some(closed.clone()),
_ => None,
})
.await;
codex.shutdown_and_wait().await?;
test.codex.shutdown_and_wait().await?;
realtime_server.shutdown().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn conversation_webrtc_start_posts_generated_session() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let sideband_accept_delay = Duration::from_millis(1000);
let capture = RealtimeCallRequestCapture::new();
Mock::given(method("POST"))
.and(path_regex(".*/realtime/calls$"))
.and(capture.clone())
.respond_with(
ResponseTemplate::new(200)
.insert_header("Location", "/v1/realtime/calls/calls/rtc_core_test")
.set_body_string("v=answer\r\n"),
)
.mount(&server)
.await;
let realtime_server = start_websocket_server_with_headers(vec![WebSocketConnectionConfig {
requests: vec![
vec![json!({
"type": "session.updated",
"session": { "id": "sess_webrtc", "instructions": "backend prompt" }
})],
vec![],
],
response_headers: Vec::new(),
accept_delay: Some(sideband_accept_delay),
close_after_requests: false,
}])
.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_model = Some("realtime-test-model".to_string());
config.experimental_realtime_ws_startup_context = Some("startup context".to_string());
config.experimental_realtime_ws_base_url = Some(realtime_ws_base_url);
config.realtime.version = RealtimeWsVersion::V2;
});
let test = builder.build(&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,
backend_reasoning_status: false,
codex_response_handoff_channel_prefixes: None,
model: Some("session-override-model".to_string()),
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
voice_session_id: None,
transport: Some(ConversationStartTransport::Webrtc {
sdp: "v=offer\r\n".to_string(),
}),
version: Some(RealtimeConversationVersion::V1),
voice: None,
}))
.await?;
// Phase 1: the client gets the SDP answer that configures its peer connection, and then the
// normal realtime event stream from the joined sideband WebSocket.
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
.expect("conversation call create failed");
assert_eq!(created.sdp, "v=answer\r\n");
assert!(
realtime_server.handshakes().is_empty(),
"SDP should be emitted before the delayed sideband websocket joins"
);
test.codex
.submit(Op::RealtimeConversationText(ConversationTextParams {
text: "queued before sideband".to_string(),
role: ConversationTextRole::User,
}))
.await?;
let session_updated = wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload:
RealtimeEvent::SessionUpdated {
realtime_session_id: session_id,
..
},
}) => Some(session_id.clone()),
_ => None,
})
.await;
assert_eq!(session_updated, "sess_webrtc");
// Phase 2: call creation posts the offer and generated session together, so the media leg can
// begin inference before the sideband WebSocket is ready.
let request = capture.single_request();
assert_eq!(request.url.path(), "/v1/realtime/calls");
assert_eq!(
request.url.query(),
Some("intent=quicksilver&architecture=avas")
);
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":"cove"}},"type":"quicksilver","model":"session-override-model","instructions":"backend prompt\n\nstartup context"}"#;
let session = normalized_json_string(session)?;
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"
)
);
// Phase 3: the server joins that same call over the direct sideband WebSocket, sends the
// ordinary session.update, and keeps the conversation alive until the client closes it.
let session_update = wait_for_websocket_request(
&realtime_server,
/*connection_index*/ 0,
/*request_index*/ 0,
)
.await?;
assert_eq!(
session_update.body_json()["type"].as_str(),
Some("session.update")
);
assert!(
websocket_request_instructions(&session_update)
.context("session.update should include instructions")?
.contains("startup context")
);
let queued_text = wait_for_websocket_request(
&realtime_server,
/*connection_index*/ 0,
/*request_index*/ 1,
)
.await?;
assert_eq!(
websocket_request_text(&queued_text).as_deref(),
Some("queued before sideband")
);
let handshake = realtime_server.single_handshake();
assert_eq!(
handshake.uri(),
"/v1/realtime?intent=quicksilver&call_id=rtc_core_test"
);
assert_eq!(
handshake.header("authorization").as_deref(),
Some("Bearer dummy")
);
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;
assert!(matches!(
closed.reason.as_deref(),
Some("requested" | "transport_closed")
));
realtime_server.shutdown().await;
Ok(())
}
#[derive(Clone, Copy)]
enum SidebandReconnectTransport {
Webrtc,
ExistingCallConfiguredEndpoint,
ExistingCallPerCallEndpoint,
}
#[test_case(SidebandReconnectTransport::Webrtc; "core-created webrtc")]
#[test_case(
SidebandReconnectTransport::ExistingCallConfiguredEndpoint;
"client-created existing call"
)]
#[test_case(
SidebandReconnectTransport::ExistingCallPerCallEndpoint;
"client-created existing call with per-call endpoint"
)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn conversation_webrtc_live_reconnects_sideband_after_unclean_disconnect(
transport: SidebandReconnectTransport,
) -> Result<()> {
skip_if_no_network!(Ok(()));
let attaches_existing_call = !matches!(transport, SidebandReconnectTransport::Webrtc);
let server = start_mock_server().await;
Mock::given(method("POST"))
.and(path("/v1/live"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("Location", "/v1/live/rtc_reconnect")
.set_body_string("v=answer\r\n"),
)
.mount(&server)
.await;
let _response_mock = responses::mount_sse_once(
&server,
responses::sse(vec![
responses::ev_response_created("resp-reconnect"),
responses::ev_assistant_message("msg-reconnect", "ok"),
responses::ev_completed("resp-reconnect"),
]),
)
.await;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
let realtime_ws_base_url = format!("ws://{}", listener.local_addr()?);
let unused_endpoint = start_websocket_server(vec![vec![]]).await;
let configured_base_url = match transport {
SidebandReconnectTransport::Webrtc
| SidebandReconnectTransport::ExistingCallConfiguredEndpoint => {
realtime_ws_base_url.clone()
}
SidebandReconnectTransport::ExistingCallPerCallEndpoint => {
unused_endpoint.uri().to_string()
}
};
let transport = match transport {
SidebandReconnectTransport::Webrtc => ConversationStartTransport::Webrtc {
sdp: "v=offer\r\n".to_string(),
},
SidebandReconnectTransport::ExistingCallConfiguredEndpoint => {
ConversationStartTransport::ExistingCall {
call_id: "rtc_reconnect".to_string(),
sideband_base_url: None,
}
}
SidebandReconnectTransport::ExistingCallPerCallEndpoint => {
ConversationStartTransport::ExistingCall {
call_id: "rtc_reconnect".to_string(),
sideband_base_url: Some(realtime_ws_base_url),
}
}
};
let (inbound_flood_started_tx, inbound_flood_started_rx) = oneshot::channel();
let sideband_server = tokio::spawn(async move {
let validate_handshake = |request: &Request, response: Response| {
assert_eq!(request.uri().to_string(), "/v1/live/rtc_reconnect");
Ok(response)
};
let (stream, _) = listener.accept().await?;
let mut websocket = accept_hdr_async(stream, validate_handshake).await?;
websocket
.send(Message::Text(
json!({
"type": "input_transcript.added",
"item": { "text": "hello wor" }
})
.to_string()
.into(),
))
.await?;
let (mut websocket_tx, mut websocket_rx) = websocket.split();
inbound_flood_started_tx
.send(())
.map_err(|_| anyhow::anyhow!("realtime client dropped before inbound flood"))?;
let inbound_flood = tokio::spawn(async move {
let mut event_count = 0_u32;
loop {
websocket_tx
.send(Message::Text(
json!({
"type": "session.updated",
"session": { "id": "sess_reconnect_flood" }
})
.to_string()
.into(),
))
.await?;
event_count += 1;
if event_count.is_multiple_of(64) {
tokio::task::yield_now().await;
}
}
#[allow(unreachable_code)]
Ok::<(), anyhow::Error>(())
});
timeout(Duration::from_secs(5), async {
loop {
let message = websocket_rx
.next()
.await
.context("sideband closed before outbound text")??;
let Message::Text(payload) = message else {
continue;
};
let payload: Value = serde_json::from_str(&payload)?;
let saw_outbound_text = payload["type"] == "session.context.append"
&& payload["content"]
.as_array()
.into_iter()
.flatten()
.filter_map(|content| content["text"].as_str())
.any(|text| text.contains("outbound during inbound flood"));
if saw_outbound_text {
break Ok::<(), anyhow::Error>(());
}
}
})
.await
.context("outbound text was starved by sustained inbound traffic")??;
inbound_flood.abort();
drop(websocket_rx);
let (stream, _) = listener.accept().await?;
let mut websocket = accept_hdr_async(stream, validate_handshake).await?;
websocket
.send(Message::Text(
json!({
"type": "turn.done",
"turn": { "role": "user", "transcript": "hello world" }
})
.to_string()
.into(),
))
.await?;
websocket
.send(Message::Text(
json!({
"type": "output_transcript.added",
"item": { "text": "after reconnect" }
})
.to_string()
.into(),
))
.await?;
websocket
.send(Message::Text(
json!({
"type": "delegation.created",
"item": {
"id": "handoff_reconnect",
"type": "delegation",
"target": "client",
"content": [{ "type": "input_text", "text": "hello world" }]
}
})
.to_string()
.into(),
))
.await?;
drop(websocket);
let (mut stream, _) = listener.accept().await?;
let mut request = vec![0_u8; 4096];
let request_len = stream.read(&mut request).await?;
if !request[..request_len].starts_with(b"GET /v1/live/rtc_reconnect HTTP/1.1\r\n") {
anyhow::bail!("expected reconnect websocket handshake for the same call");
}
stream
.write_all(b"HTTP/1.1 410 Gone\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
.await?;
if timeout(Duration::from_secs(1), listener.accept())
.await
.is_ok()
{
anyhow::bail!("terminal sideband response was retried");
}
Ok::<(), anyhow::Error>(())
});
let mut builder = test_codex().with_config(move |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(String::new());
config.experimental_realtime_ws_base_url = Some(configured_base_url);
config.realtime.version = RealtimeWsVersion::V3;
});
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,
backend_reasoning_status: false,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: !attaches_existing_call,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
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()),
voice_session_id: None,
transport: Some(transport),
version: Some(RealtimeConversationVersion::V3),
voice: None,
}))
.await?;
timeout(Duration::from_secs(5), inbound_flood_started_rx)
.await
.context("timed out waiting for sustained inbound traffic")?
.context("sideband server stopped before sustained inbound traffic")?;
test.codex
.submit(Op::RealtimeConversationText(ConversationTextParams {
text: "outbound during inbound flood".to_string(),
role: ConversationTextRole::User,
}))
.await?;
let handoff = timeout(Duration::from_secs(10), async {
let mut saw_post_reconnect_transcript = false;
loop {
match test.codex.next_event().await?.msg {
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload: RealtimeEvent::OutputTranscriptDelta(delta),
}) if delta.delta == "after reconnect" => {
saw_post_reconnect_transcript = true;
}
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload: RealtimeEvent::HandoffRequested(handoff),
}) => {
if !saw_post_reconnect_transcript {
anyhow::bail!("handoff arrived before the post-reconnect transcript");
}
break Ok::<_, anyhow::Error>(handoff);
}
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload: RealtimeEvent::Error(message),
}) => anyhow::bail!("reconnect emitted a realtime error: {message}"),
EventMsg::RealtimeConversationClosed(closed) => {
anyhow::bail!("reconnect closed the conversation early: {closed:?}")
}
EventMsg::Error(err) => {
anyhow::bail!("reconnect emitted an app error: {}", err.message)
}
_ => {}
}
}
})
.await
.context("timed out waiting for the reconnected sideband")??;
assert_eq!(
handoff,
RealtimeHandoffRequested {
handoff_id: "handoff_reconnect".to_string(),
item_id: "handoff_reconnect".to_string(),
input_transcript: "hello world".to_string(),
active_transcript: vec![
RealtimeTranscriptEntry {
role: "user".to_string(),
text: "hello world".to_string(),
},
RealtimeTranscriptEntry {
role: "assistant".to_string(),
text: "after reconnect".to_string(),
}
],
}
);
let closed = timeout(Duration::from_secs(10), async {
loop {
match test.codex.next_event().await?.msg {
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload: RealtimeEvent::Error(message),
}) => anyhow::bail!("terminal reconnect emitted a realtime error: {message}"),
EventMsg::RealtimeConversationClosed(closed) => {
break Ok::<_, anyhow::Error>(closed);
}
EventMsg::Error(err) => {
anyhow::bail!("terminal reconnect emitted an app error: {}", err.message)
}
_ => {}
}
}
})
.await
.context("timed out waiting for the terminal reconnect response")??;
assert_eq!(closed.reason.as_deref(), Some("transport_closed"));
timeout(Duration::from_secs(10), sideband_server)
.await
.context("timed out waiting for sideband server")???;
assert!(unused_endpoint.handshakes().is_empty());
test.codex.shutdown_and_wait().await?;
unused_endpoint.shutdown().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn conversation_webrtc_start_uses_avas_query() -> Result<()> {
skip_if_no_network!(Ok(()));
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)
.insert_header("Location", "/v1/realtime/calls/calls/rtc_avas_test")
.set_body_string("v=answer\r\n"),
)
.mount(&server)
.await;
let realtime_server = start_websocket_server_with_headers(vec![WebSocketConnectionConfig {
requests: vec![
vec![json!({
"type": "session.updated",
"session": { "id": "sess_webrtc", "instructions": "backend prompt" }
})],
vec![],
],
response_headers: Vec::new(),
accept_delay: None,
close_after_requests: false,
}])
.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.version = RealtimeWsVersion::V1;
});
let test = builder.build(&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,
backend_reasoning_status: false,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
voice_session_id: None,
transport: Some(ConversationStartTransport::Webrtc {
sdp: "v=offer\r\n".to_string(),
}),
version: None,
voice: None,
}))
.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
.expect("conversation call create failed");
assert_eq!(created.sdp, "v=answer\r\n");
let request = capture.single_request();
assert_eq!(request.url.path(), "/v1/realtime/calls");
assert_eq!(
request.url.query(),
Some("intent=quicksilver&architecture=avas")
);
let session_updated = wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload:
RealtimeEvent::SessionUpdated {
realtime_session_id,
..
},
}) => Some(realtime_session_id.clone()),
_ => None,
})
.await;
assert_eq!(session_updated, "sess_webrtc");
let handshake = realtime_server.single_handshake();
assert_eq!(
handshake.uri(),
"/v1/realtime?intent=quicksilver&call_id=rtc_avas_test"
);
assert_eq!(
handshake.header("authorization").as_deref(),
Some("Bearer dummy")
);
test.codex.submit(Op::RealtimeConversationClose).await?;
realtime_server.shutdown().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn conversation_webrtc_default_v1_ignores_configured_v2_voice() -> Result<()> {
skip_if_no_network!(Ok(()));
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)
.insert_header("Location", "/v1/realtime/calls/calls/rtc_voice_default")
.set_body_string("v=answer\r\n"),
)
.mount(&server)
.await;
let realtime_server = start_websocket_server_with_headers(vec![WebSocketConnectionConfig {
requests: vec![vec![json!({
"type": "session.updated",
"session": { "id": "sess_webrtc_voice", "instructions": "backend prompt" }
})]],
response_headers: Vec::new(),
accept_delay: None,
close_after_requests: false,
}])
.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.version = RealtimeWsVersion::V2;
config.realtime.voice = Some(RealtimeVoice::Cedar);
});
let test = builder.build(&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,
backend_reasoning_status: false,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
voice_session_id: None,
transport: Some(ConversationStartTransport::Webrtc {
sdp: "v=offer\r\n".to_string(),
}),
version: None,
voice: None,
}))
.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
.expect("conversation call create failed");
assert_eq!(created.sdp, "v=answer\r\n");
let request = capture.single_request();
let body = String::from_utf8(request.body).context("multipart body should be utf-8")?;
assert!(body.contains(r#""type":"quicksilver""#));
assert!(body.contains(r#""voice":"cove""#));
assert!(!body.contains(r#""voice":"cedar""#));
let session_update = wait_for_websocket_request(
&realtime_server,
/*connection_index*/ 0,
/*request_index*/ 0,
)
.await?;
assert_eq!(
session_update.body_json()["session"]["audio"]["output"]["voice"],
"cove"
);
test.codex.submit(Op::RealtimeConversationClose).await?;
realtime_server.shutdown().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn conversation_webrtc_default_v1_rejects_explicit_v2_voice() -> Result<()> {
skip_if_no_network!(Ok(()));
let api_server = start_mock_server().await;
let mut builder = test_codex().with_config(|config| {
config.realtime.version = RealtimeWsVersion::V2;
});
let test = builder.build(&api_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,
backend_reasoning_status: false,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
voice_session_id: None,
transport: Some(ConversationStartTransport::Webrtc {
sdp: "v=offer\r\n".to_string(),
}),
version: None,
voice: Some(Some(RealtimeVoice::Cedar)),
}))
.await?;
let error = wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload: RealtimeEvent::Error(message),
}) => Some(message.clone()),
_ => None,
})
.await;
assert!(error.contains("realtime voice `cedar` is not supported for v1"));
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn conversation_webrtc_start_uses_configured_call_base_url_for_avas() -> Result<()> {
skip_if_no_network!(Ok(()));
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)
.insert_header("Location", "/v1/realtime/rtc_local_avas_test")
.set_body_string("v=answer\r\n"),
)
.mount(&server)
.await;
let realtime_server = start_websocket_server_with_headers(vec![WebSocketConnectionConfig {
requests: vec![
vec![json!({
"type": "session.updated",
"session": { "id": "sess_webrtc", "instructions": "backend prompt" }
})],
vec![],
],
response_headers: Vec::new(),
accept_delay: None,
close_after_requests: false,
}])
.await;
let realtime_ws_base_url = realtime_server.uri().to_string();
let realtime_call_base_url = format!("{}/v1", server.uri());
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.experimental_realtime_webrtc_call_base_url = Some(realtime_call_base_url);
config.realtime.version = RealtimeWsVersion::V1;
});
let test = builder.build(&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,
backend_reasoning_status: false,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
voice_session_id: None,
transport: Some(ConversationStartTransport::Webrtc {
sdp: "v=offer\r\n".to_string(),
}),
version: None,
voice: None,
}))
.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
.expect("conversation call create failed");
assert_eq!(created.sdp, "v=answer\r\n");
let request = capture.single_request();
assert_eq!(request.url.path(), "/v1/realtime/calls");
assert_eq!(
request.url.query(),
Some("intent=quicksilver&architecture=avas")
);
let session_updated = wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload:
RealtimeEvent::SessionUpdated {
realtime_session_id,
..
},
}) => Some(realtime_session_id.clone()),
_ => None,
})
.await;
assert_eq!(session_updated, "sess_webrtc");
let handshake = realtime_server.single_handshake();
assert_eq!(
handshake.uri(),
"/v1/realtime?intent=quicksilver&call_id=rtc_local_avas_test"
);
assert_eq!(
handshake.header("authorization").as_deref(),
Some("Bearer dummy")
);
test.codex.submit(Op::RealtimeConversationClose).await?;
realtime_server.shutdown().await;
Ok(())
}
#[test_case(RealtimeConversationVersion::V1; "preview")]
#[test_case(RealtimeConversationVersion::V3; "frameless")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn conversation_existing_call_stop_closes_only_observer_transport(
version: RealtimeConversationVersion,
) -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
let realtime_ws_base_url = format!("ws://{}", listener.local_addr()?);
let observer = tokio::spawn(async move {
let (stream, _) = listener.accept().await?;
let mut websocket = tokio_tungstenite::accept_async(stream).await?;
// The first frame must be the transport close, never session initialization or SessionClose.
let frame = websocket.next().await.context("observer frame")??;
Ok::<_, anyhow::Error>(frame)
});
let mut builder = test_codex().with_config(move |config| {
config.experimental_realtime_ws_base_url = Some(realtime_ws_base_url);
});
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,
backend_reasoning_status: false,
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,
voice_session_id: None,
transport: Some(ConversationStartTransport::ExistingCall {
call_id: "rtc_frontend_owned".to_string(),
sideband_base_url: None,
}),
version: Some(version),
voice: None,
}))
.await?;
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::RealtimeConversationStarted(_))
})
.await;
test.codex.submit(Op::RealtimeConversationClose).await?;
let closed = wait_for_event_match(&test.codex, |event| match event {
EventMsg::RealtimeConversationClosed(closed) => Some(closed.clone()),
_ => None,
})
.await;
assert_eq!(closed.reason.as_deref(), Some("requested"));
assert_eq!(
timeout(Duration::from_secs(5), observer)
.await
.context("observer close timed out")???,
Message::Close(Some(CloseFrame {
code: CloseCode::Normal,
reason: "".into()
}))
);
test.codex.shutdown_and_wait().await?;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn conversation_webrtc_close_while_sideband_connecting_drops_pending_join() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
Mock::given(method("POST"))
.and(path_regex(".*/realtime/calls$"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("Location", "/v1/realtime/calls/calls/rtc_close_pending")
.set_body_string("v=answer\r\n"),
)
.mount(&server)
.await;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
let realtime_ws_base_url = format!("ws://{}", listener.local_addr()?);
let (pending_handshake_tx, pending_handshake_rx) = oneshot::channel();
let (release_handshake_tx, release_handshake_rx) = oneshot::channel();
let handshake_task = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await?;
let mut request = Vec::new();
while !request.windows(4).any(|window| window == b"\r\n\r\n") {
let bytes_read = stream.read_buf(&mut request).await?;
anyhow::ensure!(
bytes_read > 0,
"sideband closed before sending its handshake"
);
}
pending_handshake_tx
.send(())
.expect("pending sideband handshake should have a receiver");
release_handshake_rx.await?;
stream.read_to_end(&mut request).await?;
Ok::<Vec<u8>, anyhow::Error>(request)
});
let mut builder = test_codex().with_config(move |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(String::new());
config.experimental_realtime_ws_base_url = Some(realtime_ws_base_url);
config.realtime.version = RealtimeWsVersion::V1;
});
let test = builder.build(&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,
backend_reasoning_status: false,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
voice_session_id: None,
transport: Some(ConversationStartTransport::Webrtc {
sdp: "v=offer\r\n".to_string(),
}),
version: None,
voice: None,
}))
.await?;
let sdp = wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::RealtimeConversationSdp(created) => Some(created.sdp.clone()),
_ => None,
})
.await;
assert_eq!(sdp, "v=answer\r\n");
timeout(Duration::from_secs(10), pending_handshake_rx)
.await
.context("timed out waiting for the sideband handshake")??;
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;
assert_eq!(closed.reason.as_deref(), Some("requested"));
release_handshake_tx
.send(())
.expect("pending sideband handshake should still be waiting");
let handshake_request = timeout(Duration::from_secs(10), handshake_task)
.await
.context("timed out waiting for the canceled sideband handshake")???;
assert!(
handshake_request.starts_with(b"GET "),
"pending sideband connection should close before completing its handshake"
);
timeout(Duration::from_secs(10), async {
test.codex.submit(Op::Shutdown).await?;
loop {
match test.codex.next_event().await?.msg {
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload: RealtimeEvent::Error(message),
}) => {
anyhow::bail!("pending sideband task emitted a stale realtime error: {message}")
}
EventMsg::RealtimeConversationClosed(closed) => {
anyhow::bail!(
"pending sideband task emitted a duplicate close event: {closed:?}"
)
}
EventMsg::ShutdownComplete => break,
_ => {}
}
}
test.codex.wait_until_terminated().await;
Ok::<(), anyhow::Error>(())
})
.await
.context("timed out waiting for realtime session shutdown")??;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn conversation_webrtc_sideband_connect_failure_closes_with_error() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
Mock::given(method("POST"))
.and(path_regex(".*/realtime/calls$"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("Location", "/v1/realtime/calls/calls/rtc_sideband_failure")
.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(String::new());
config.experimental_realtime_ws_base_url = Some("http://127.0.0.1:1".to_string());
// Keep the failure-path test inside wait_for_event's timeout on Windows,
// where refused localhost websocket connects can take around two seconds.
config.model_provider.request_max_retries = Some(0);
config.realtime.version = RealtimeWsVersion::V1;
});
let test = builder.build(&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,
backend_reasoning_status: false,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
voice_session_id: None,
transport: Some(ConversationStartTransport::Webrtc {
sdp: "v=offer\r\n".to_string(),
}),
version: None,
voice: None,
}))
.await?;
let started = wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::RealtimeConversationStarted(started) => Some(started.clone()),
_ => None,
})
.await;
assert!(started.realtime_session_id.is_some());
let sdp = wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::RealtimeConversationSdp(created) => Some(created.sdp.clone()),
_ => None,
})
.await;
assert_eq!(sdp, "v=answer\r\n");
let err = wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload: RealtimeEvent::Error(message),
}) => Some(message.clone()),
_ => None,
})
.await;
assert!(!err.is_empty());
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("error"));
test.codex
.submit(Op::RealtimeConversationText(ConversationTextParams {
text: "after sideband failure".to_string(),
role: ConversationTextRole::User,
}))
.await?;
let err = wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::Error(err) => Some(err.clone()),
_ => None,
})
.await;
assert_eq!(err.message, "conversation is not running");
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() {
return run_realtime_conversation_test_in_subprocess(
"suite::realtime_conversation::conversation_start_uses_openai_env_key_fallback_with_chatgpt_auth",
Some("env-realtime-key"),
);
}
skip_if_no_network!(Ok(()));
// Startup prewarm can reconnect after the empty connection closes. Keep its
// Responses requests from consuming the realtime session's scripted reply.
let startup_server = start_websocket_server(vec![vec![]]).await;
let server = start_websocket_server(vec![vec![vec![json!({
"type": "session.updated",
"session": { "id": "sess_env", "instructions": "backend prompt" }
})]]])
.await;
let mut builder = test_codex()
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
.with_config({
let realtime_base_url = server.uri().to_string();
move |config| {
config.experimental_realtime_ws_base_url = Some(realtime_base_url);
}
});
let test = builder.build_with_websocket_server(&startup_server).await?;
assert!(
startup_server
.wait_for_handshakes(/*expected*/ 1, Duration::from_secs(2))
.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,
backend_reasoning_status: false,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
voice_session_id: None,
transport: None,
version: None,
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("conversation start failed");
assert!(started.realtime_session_id.is_some());
let session_updated = wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload:
RealtimeEvent::SessionUpdated {
realtime_session_id: session_id,
..
},
}) => Some(session_id.clone()),
_ => None,
})
.await;
assert_eq!(session_updated, "sess_env");
assert_eq!(server.handshakes().len(), 1);
assert_eq!(
server.handshakes()[0].header("authorization").as_deref(),
Some("Bearer env-realtime-key")
);
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;
startup_server.shutdown().await;
server.shutdown().await;
Ok(())
}
async fn assert_transport_close_tail_flush(
flush_transcript_tail_on_session_end: bool,
) -> Result<()> {
skip_if_no_network!(Ok(()));
let api_server = start_mock_server().await;
let response_mock = responses::mount_sse_once(
&api_server,
responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_assistant_message("msg-1", "ok"),
responses::ev_completed("resp-1"),
]),
)
.await;
let realtime_server = start_websocket_server(vec![vec![vec![
json!({
"type": "session.updated",
"session": { "id": "sess_1", "instructions": "backend prompt" }
}),
json!({
"type": "conversation.input_transcript.delta",
"delta": "transport tail"
}),
]]])
.await;
let mut builder = test_codex().with_config({
let realtime_base_url = realtime_server.uri().to_string();
move |config| {
config.experimental_realtime_ws_base_url = Some(realtime_base_url);
config.realtime.version = RealtimeWsVersion::V1;
}
});
let test = builder.build(&api_server).await?;
test.codex.ensure_rollout_materialized().await;
test.codex
.submit(Op::RealtimeConversationStart(ConversationStartParams {
client_managed_handoffs: false,
delegation_ack_filler: None,
flush_transcript_tail_on_session_end,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_mode:
codex_protocol::protocol::CodexResponseHandoffMode::Thinking,
backend_reasoning_status: false,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
voice_session_id: None,
transport: None,
version: None,
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("conversation start failed");
assert!(started.realtime_session_id.is_some());
let session_updated = wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload:
RealtimeEvent::SessionUpdated {
realtime_session_id: session_id,
..
},
}) => Some(session_id.clone()),
_ => None,
})
.await;
assert_eq!(session_updated, "sess_1");
let closed = wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::RealtimeConversationClosed(closed) => Some(closed.clone()),
EventMsg::TurnStarted(_) | EventMsg::ItemStarted(_) | EventMsg::ItemCompleted(_) => {
panic!("idle transcript flush must not create an unfinished client turn")
}
_ => None,
})
.await;
assert_eq!(closed.reason.as_deref(), Some("transport_closed"));
// Closed must be a history barrier, including when there is no active turn.
let history = test.codex.load_history(/*include_archived*/ false).await?;
let tails = history.items.iter().filter(|item| {
matches!(item, RolloutItem::ResponseItem(ResponseItemEnvelope { item: ResponseItem::Message { role, content, .. }, .. })
if role == "user" && content.iter().any(|item| matches!(item,
ContentItem::InputText { text } if text.contains("<source>transcript_tail_flush</source>")
&& text.contains("user: transport tail"))))
}).count();
assert_eq!(tails, usize::from(flush_transcript_tail_on_session_end));
assert!(response_mock.requests().is_empty());
test.submit_text_turn("continue in text").await?;
let user_texts = response_mock.single_request().message_input_texts("user");
assert_eq!(
user_texts
.iter()
.any(|text| text.contains("user: transport tail")),
flush_transcript_tail_on_session_end
);
realtime_server.shutdown().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn conversation_transport_close_tail_flush_is_opt_in() -> Result<()> {
assert_transport_close_tail_flush(/*flush_transcript_tail_on_session_end*/ false).await?;
assert_transport_close_tail_flush(/*flush_transcript_tail_on_session_end*/ true).await?;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn conversation_audio_before_start_emits_error() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_websocket_server(vec![]).await;
let mut builder = test_codex();
let test = builder.build_with_websocket_server(&server).await?;
test.codex
.submit(Op::RealtimeConversationAudio(ConversationAudioParams {
frame: RealtimeAudioFrame {
data: "AQID".to_string(),
sample_rate: 24000,
num_channels: 1,
samples_per_channel: Some(480),
item_id: None,
},
}))
.await?;
let err = wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::Error(err) => Some(err.clone()),
_ => None,
})
.await;
assert_eq!(err.codex_error_info, Some(CodexErrorInfo::BadRequest));
assert_eq!(err.message, "conversation is not running");
server.shutdown().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn conversation_start_preflight_failure_emits_realtime_error_only() -> Result<()> {
if std::env::var_os(REALTIME_CONVERSATION_TEST_SUBPROCESS_ENV_VAR).is_none() {
return run_realtime_conversation_test_in_subprocess(
"suite::realtime_conversation::conversation_start_preflight_failure_emits_realtime_error_only",
/*openai_api_key*/ None,
);
}
skip_if_no_network!(Ok(()));
let server = start_websocket_server(vec![]).await;
let mut builder = test_codex().with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing());
let test = builder.build_with_websocket_server(&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,
backend_reasoning_status: false,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
voice_session_id: None,
transport: None,
version: None,
voice: None,
}))
.await?;
let err = wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload: RealtimeEvent::Error(message),
}) => Some(message.clone()),
_ => None,
})
.await;
assert_eq!(err, "realtime conversation requires API key auth");
let closed = timeout(Duration::from_millis(200), async {
wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::RealtimeConversationClosed(closed) => Some(closed.clone()),
_ => None,
})
.await
})
.await;
assert!(closed.is_err(), "preflight failure should not emit closed");
server.shutdown().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn conversation_start_connect_failure_emits_realtime_error_only() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_websocket_server(vec![]).await;
let mut builder = test_codex().with_config(|config| {
config.experimental_realtime_ws_base_url = Some("http://127.0.0.1:1".to_string());
config.realtime.version = RealtimeWsVersion::V1;
});
let test = builder.build_with_websocket_server(&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,
backend_reasoning_status: false,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
voice_session_id: None,
transport: None,
version: None,
voice: None,
}))
.await?;
let err = wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload: RealtimeEvent::Error(message),
}) => Some(message.clone()),
_ => None,
})
.await;
assert!(!err.is_empty());
let closed = timeout(Duration::from_millis(200), async {
wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::RealtimeConversationClosed(closed) => Some(closed.clone()),
_ => None,
})
.await
})
.await;
assert!(closed.is_err(), "connect failure should not emit closed");
server.shutdown().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn conversation_text_before_start_emits_error() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_websocket_server(vec![]).await;
let mut builder = test_codex();
let test = builder.build_with_websocket_server(&server).await?;
test.codex
.submit(Op::RealtimeConversationText(ConversationTextParams {
text: "hello".to_string(),
role: ConversationTextRole::User,
}))
.await?;
let err = wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::Error(err) => Some(err.clone()),
_ => None,
})
.await;
assert_eq!(err.codex_error_info, Some(CodexErrorInfo::BadRequest));
assert_eq!(err.message, "conversation is not running");
server.shutdown().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn conversation_second_start_replaces_runtime() -> 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",
"session": { "id": "sess_old", "instructions": "old" }
})]],
vec![
vec![json!({
"type": "session.updated",
"session": { "id": "sess_new", "instructions": "new" }
})],
vec![json!({
"type": "conversation.output_audio.delta",
"delta": "AQID",
"sample_rate": 24000,
"channels": 1
})],
],
])
.await;
let mut builder = test_codex().with_config({
let realtime_base_url = server.uri().to_string();
move |config| {
config.experimental_realtime_ws_base_url = Some(realtime_base_url);
}
});
let test = builder.build_with_websocket_server(&startup_server).await?;
assert!(
startup_server
.wait_for_handshakes(/*expected*/ 1, Duration::from_secs(2))
.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,
backend_reasoning_status: false,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("old".to_string())),
realtime_session_id: Some("conv_old".to_string()),
voice_session_id: None,
transport: None,
version: None,
voice: None,
}))
.await?;
wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload:
RealtimeEvent::SessionUpdated {
realtime_session_id: session_id,
..
},
}) if session_id == "sess_old" => Some(Ok(())),
EventMsg::Error(err) => Some(Err(err.clone())),
_ => None,
})
.await
.expect("first conversation start failed");
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,
backend_reasoning_status: false,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("new".to_string())),
realtime_session_id: Some("conv_new".to_string()),
voice_session_id: None,
transport: None,
version: None,
voice: None,
}))
.await?;
wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload:
RealtimeEvent::SessionUpdated {
realtime_session_id: session_id,
..
},
}) if session_id == "sess_new" => Some(Ok(())),
EventMsg::Error(err) => Some(Err(err.clone())),
_ => None,
})
.await
.expect("second conversation start failed");
test.codex
.submit(Op::RealtimeConversationAudio(ConversationAudioParams {
frame: RealtimeAudioFrame {
data: "AQID".to_string(),
sample_rate: 24000,
num_channels: 1,
samples_per_channel: Some(480),
item_id: None,
},
}))
.await?;
let _ = wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload: RealtimeEvent::AudioOut(frame),
}) if frame.data == "AQID" => Some(()),
_ => None,
})
.await;
let connections = server.connections();
assert_eq!(connections.len(), 2);
assert_eq!(connections[0].len(), 1);
let old_instructions =
websocket_request_instructions(&connections[0][0]).expect("old session instructions");
assert!(old_instructions.starts_with("old"));
assert_eq!(
server.handshakes()[0].header("x-session-id").as_deref(),
Some("conv_old")
);
assert_eq!(connections[1].len(), 2);
let new_instructions =
websocket_request_instructions(&connections[1][0]).expect("new session instructions");
assert!(new_instructions.starts_with("new"));
assert_eq!(
server.handshakes()[1].header("x-session-id").as_deref(),
Some("conv_new")
);
assert_eq!(
connections[1][1].body_json()["type"].as_str(),
Some("input_audio_buffer.append")
);
startup_server.shutdown().await;
server.shutdown().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn conversation_uses_experimental_realtime_ws_base_url_override() -> Result<()> {
skip_if_no_network!(Ok(()));
let startup_server = start_websocket_server(vec![vec![]]).await;
let realtime_server = start_websocket_server(vec![vec![vec![json!({
"type": "session.updated",
"session": { "id": "sess_override", "instructions": "backend prompt" }
})]]])
.await;
let mut builder = test_codex().with_config({
let realtime_base_url = realtime_server.uri().to_string();
move |config| {
config.experimental_realtime_ws_base_url = Some(realtime_base_url);
config.realtime.version = RealtimeWsVersion::V1;
}
});
let test = builder.build_with_websocket_server(&startup_server).await?;
assert!(
startup_server
.wait_for_handshakes(/*expected*/ 1, Duration::from_secs(2))
.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,
backend_reasoning_status: false,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
voice_session_id: None,
transport: None,
version: None,
voice: None,
}))
.await?;
let session_updated = wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload:
RealtimeEvent::SessionUpdated {
realtime_session_id: session_id,
..
},
}) => Some(session_id.clone()),
_ => None,
})
.await;
assert_eq!(session_updated, "sess_override");
let startup_connections = startup_server.connections();
assert_eq!(startup_connections.len(), 1);
let realtime_connections = realtime_server.connections();
assert_eq!(realtime_connections.len(), 1);
assert_eq!(
realtime_connections[0][0].body_json()["type"].as_str(),
Some("session.update")
);
startup_server.shutdown().await;
realtime_server.shutdown().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn conversation_uses_default_realtime_backend_prompt() -> 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",
"session": { "id": "sess_default", "instructions": "default" }
})]]])
.await;
let mut builder = test_codex().with_config({
let realtime_base_url = server.uri().to_string();
move |config| {
config.experimental_realtime_ws_base_url = Some(realtime_base_url);
config.experimental_realtime_ws_startup_context =
Some("controlled startup context".to_string());
}
});
let test = builder.build_with_websocket_server(&startup_server).await?;
assert!(
startup_server
.wait_for_handshakes(/*expected*/ 1, Duration::from_secs(2))
.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,
backend_reasoning_status: false,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: None,
realtime_session_id: None,
voice_session_id: None,
transport: None,
version: None,
voice: None,
}))
.await?;
let session_updated = wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload:
RealtimeEvent::SessionUpdated {
realtime_session_id: session_id,
..
},
}) => Some(session_id.clone()),
_ => None,
})
.await;
assert_eq!(session_updated, "sess_default");
let connections = server.connections();
assert_eq!(connections.len(), 1);
let instructions =
websocket_request_instructions(&connections[0][0]).expect("default session instructions");
assert_eq!(
instructions,
format!(
"{}\n\ncontrolled startup context",
expected_realtime_backend_prompt()
)
);
startup_server.shutdown().await;
server.shutdown().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn conversation_uses_empty_instructions_for_null_or_empty_prompt() -> 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",
"session": { "id": "sess_null", "instructions": "" }
})]],
vec![vec![json!({
"type": "session.updated",
"session": { "id": "sess_empty", "instructions": "" }
})]],
])
.await;
let mut builder = test_codex().with_config({
let realtime_base_url = server.uri().to_string();
move |config| {
config.experimental_realtime_ws_base_url = Some(realtime_base_url);
config.experimental_realtime_ws_startup_context = Some(String::new());
}
});
let test = builder.build_with_websocket_server(&startup_server).await?;
assert!(
startup_server
.wait_for_handshakes(/*expected*/ 1, Duration::from_secs(2))
.await
);
for (prompt, expected_session_id) in [
(Some(None), "sess_null"),
(Some(Some(String::new())), "sess_empty"),
] {
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,
backend_reasoning_status: false,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt,
realtime_session_id: None,
voice_session_id: None,
transport: None,
version: None,
voice: None,
}))
.await?;
let session_updated = wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload:
RealtimeEvent::SessionUpdated {
realtime_session_id: session_id,
..
},
}) => Some(session_id.clone()),
_ => None,
})
.await;
assert_eq!(session_updated, expected_session_id);
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;
}
let connections = server.connections();
assert_eq!(connections.len(), 2);
let null_instructions =
websocket_request_instructions(&connections[0][0]).expect("null prompt instructions");
let empty_instructions =
websocket_request_instructions(&connections[1][0]).expect("empty prompt instructions");
assert_eq!(null_instructions, "");
assert_eq!(empty_instructions, "");
startup_server.shutdown().await;
server.shutdown().await;
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_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": if version == RealtimeConversationVersion::V3 {
"session.started"
} else {
"session.updated"
},
"session": { "id": "sess_voice", "instructions": "backend prompt" }
})]]])
.await;
let mut builder = test_codex().with_config({
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?;
assert!(
startup_server
.wait_for_handshakes(/*expected*/ 1, Duration::from_secs(2))
.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,
backend_reasoning_status: false,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
voice_session_id: None,
transport: None,
version: Some(version),
voice: voice.map(Some),
}))
.await?;
let session_updated = wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload:
RealtimeEvent::SessionUpdated {
realtime_session_id: session_id,
..
},
}) => Some(session_id.clone()),
_ => None,
})
.await;
assert_eq!(session_updated, "sess_voice");
let connections = server.connections();
assert_eq!(
connections[0][0].body_json()["session"]["audio"]["output"]["voice"],
expected_voice
);
startup_server.shutdown().await;
server.shutdown().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn conversation_uses_configured_realtime_voice() -> 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",
"session": { "id": "sess_config_voice", "instructions": "backend prompt" }
})]]])
.await;
let mut builder = test_codex().with_config({
let realtime_base_url = server.uri().to_string();
move |config| {
config.experimental_realtime_ws_base_url = Some(realtime_base_url);
config.realtime.voice = Some(RealtimeVoice::Cove);
}
});
let test = builder.build_with_websocket_server(&startup_server).await?;
assert!(
startup_server
.wait_for_handshakes(/*expected*/ 1, Duration::from_secs(2))
.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,
backend_reasoning_status: false,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
voice_session_id: None,
transport: None,
version: None,
voice: None,
}))
.await?;
let session_updated = wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload:
RealtimeEvent::SessionUpdated {
realtime_session_id: session_id,
..
},
}) => Some(session_id.clone()),
_ => None,
})
.await;
assert_eq!(session_updated, "sess_config_voice");
let connections = server.connections();
assert_eq!(
connections[0][0].body_json()["session"]["audio"]["output"]["voice"],
"cove"
);
startup_server.shutdown().await;
server.shutdown().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn conversation_rejects_voice_for_wrong_realtime_version() -> Result<()> {
skip_if_no_network!(Ok(()));
let api_server = start_mock_server().await;
let mut builder = test_codex().with_config(|config| {
config.realtime.version = RealtimeWsVersion::V2;
});
let test = builder.build(&api_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,
backend_reasoning_status: false,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
voice_session_id: None,
transport: None,
version: None,
voice: Some(Some(RealtimeVoice::Cove)),
}))
.await?;
let error = wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload: RealtimeEvent::Error(message),
}) => Some(message.clone()),
_ => None,
})
.await;
assert!(error.contains("realtime voice `cove` is not supported for v2"));
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn conversation_uses_experimental_realtime_ws_backend_prompt_override() -> 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",
"session": { "id": "sess_override", "instructions": "prompt from config" }
})]]])
.await;
let mut builder = test_codex().with_config({
let realtime_base_url = server.uri().to_string();
move |config| {
config.experimental_realtime_ws_base_url = Some(realtime_base_url);
config.experimental_realtime_ws_backend_prompt = Some("prompt from config".to_string());
}
});
let test = builder.build_with_websocket_server(&startup_server).await?;
assert!(
startup_server
.wait_for_handshakes(/*expected*/ 1, Duration::from_secs(2))
.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,
backend_reasoning_status: false,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("prompt from op".to_string())),
realtime_session_id: None,
voice_session_id: None,
transport: None,
version: None,
voice: None,
}))
.await?;
let session_updated = wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload:
RealtimeEvent::SessionUpdated {
realtime_session_id: session_id,
..
},
}) => Some(session_id.clone()),
_ => None,
})
.await;
assert_eq!(session_updated, "sess_override");
let connections = server.connections();
assert_eq!(connections.len(), 1);
let overridden_instructions = websocket_request_instructions(&connections[0][0])
.expect("overridden session instructions");
assert!(overridden_instructions.starts_with("prompt from config"));
startup_server.shutdown().await;
server.shutdown().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn conversation_uses_experimental_realtime_ws_startup_context_override() -> Result<()> {
skip_if_no_network!(Ok(()));
let startup_server = start_websocket_server(vec![vec![]]).await;
let realtime_server = start_websocket_server(vec![vec![vec![json!({
"type": "session.updated",
"session": { "id": "sess_custom_context", "instructions": "prompt from config" }
})]]])
.await;
let mut builder = test_codex().with_config({
let realtime_base_url = realtime_server.uri().to_string();
move |config| {
config.experimental_realtime_ws_base_url = Some(realtime_base_url);
config.realtime.version = RealtimeWsVersion::V1;
config.experimental_realtime_ws_backend_prompt = Some("prompt from config".to_string());
config.experimental_realtime_ws_startup_context =
Some("custom startup context".to_string());
}
});
let test = builder.build_with_websocket_server(&startup_server).await?;
seed_recent_thread(
&test,
"Recent work: cleaned up startup flows and reviewed websocket routing.",
"Investigate realtime startup context",
"custom-context",
)
.await?;
fs::create_dir_all(test.workspace_path("docs"))?;
fs::write(test.workspace_path("README.md"), "workspace marker")?;
assert!(
startup_server
.wait_for_handshakes(/*expected*/ 1, Duration::from_secs(2))
.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,
backend_reasoning_status: false,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("prompt from op".to_string())),
realtime_session_id: None,
voice_session_id: None,
transport: None,
version: None,
voice: None,
}))
.await?;
let startup_context_request = wait_for_matching_websocket_request(
&realtime_server,
"startup context request with instructions",
|request| websocket_request_instructions(request).is_some(),
)
.await;
let instructions = websocket_request_instructions(&startup_context_request)
.expect("custom startup context request should contain instructions");
assert_eq!(instructions, "prompt from config\n\ncustom startup context");
assert!(!instructions.contains(STARTUP_CONTEXT_HEADER));
assert!(!instructions.contains("## Machine / Workspace Map"));
startup_server.shutdown().await;
realtime_server.shutdown().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn conversation_disables_realtime_startup_context_with_empty_override() -> Result<()> {
skip_if_no_network!(Ok(()));
let startup_server = start_websocket_server(vec![vec![]]).await;
let realtime_server = start_websocket_server(vec![vec![vec![json!({
"type": "session.updated",
"session": { "id": "sess_no_context", "instructions": "prompt from config" }
})]]])
.await;
let mut builder = test_codex().with_config({
let realtime_base_url = realtime_server.uri().to_string();
move |config| {
config.experimental_realtime_ws_base_url = Some(realtime_base_url);
config.realtime.version = RealtimeWsVersion::V1;
config.experimental_realtime_ws_backend_prompt = Some("prompt from config".to_string());
config.experimental_realtime_ws_startup_context = Some(String::new());
}
});
let test = builder.build_with_websocket_server(&startup_server).await?;
seed_recent_thread(
&test,
"Recent work: cleaned up startup flows and reviewed websocket routing.",
"Investigate realtime startup context",
"no-context",
)
.await?;
fs::create_dir_all(test.workspace_path("docs"))?;
fs::write(test.workspace_path("README.md"), "workspace marker")?;
assert!(
startup_server
.wait_for_handshakes(/*expected*/ 1, Duration::from_secs(2))
.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,
backend_reasoning_status: false,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("prompt from op".to_string())),
realtime_session_id: None,
voice_session_id: None,
transport: None,
version: None,
voice: None,
}))
.await?;
let startup_context_request = wait_for_matching_websocket_request(
&realtime_server,
"startup context disable request with instructions",
|request| websocket_request_instructions(request).is_some(),
)
.await;
let instructions = websocket_request_instructions(&startup_context_request)
.expect("startup context disable request should contain instructions");
assert_eq!(instructions, "prompt from config");
assert!(!instructions.contains(STARTUP_CONTEXT_HEADER));
assert!(!instructions.contains("## Machine / Workspace Map"));
startup_server.shutdown().await;
realtime_server.shutdown().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn conversation_start_injects_startup_context_from_thread_history() -> Result<()> {
skip_if_no_network!(Ok(()));
let startup_server = start_websocket_server(vec![vec![]]).await;
let realtime_server = start_websocket_server(vec![vec![vec![json!({
"type": "session.updated",
"session": { "id": "sess_context", "instructions": "backend prompt" }
})]]])
.await;
let mut builder = test_codex().with_config({
let realtime_base_url = realtime_server.uri().to_string();
move |config| {
config.experimental_realtime_ws_base_url = Some(realtime_base_url);
config.realtime.version = RealtimeWsVersion::V1;
}
});
let test = builder.build_with_websocket_server(&startup_server).await?;
seed_recent_thread(
&test,
"Recent work: cleaned up startup flows and reviewed websocket routing.",
"Investigate realtime startup context",
"latest",
)
.await?;
fs::create_dir_all(test.workspace_path("docs"))?;
fs::write(test.workspace_path("README.md"), "workspace marker")?;
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,
backend_reasoning_status: false,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
voice_session_id: None,
transport: None,
version: None,
voice: None,
}))
.await?;
let startup_context_request = wait_for_matching_websocket_request(
&realtime_server,
"startup context request with instructions",
|request| websocket_request_instructions(request).is_some(),
)
.await;
let startup_context = websocket_request_instructions(&startup_context_request)
.expect("startup context request should contain instructions");
assert!(startup_context.contains(STARTUP_CONTEXT_OPEN_TAG));
assert!(startup_context.contains(STARTUP_CONTEXT_CLOSE_TAG));
assert!(startup_context.contains(STARTUP_CONTEXT_HEADER));
assert!(!startup_context.contains("## User"));
assert!(startup_context.contains("### "));
assert!(startup_context.contains("Recent sessions: 1"));
assert!(startup_context.contains("Latest branch: branch-latest"));
assert!(startup_context.contains("User asks:"));
assert!(startup_context.contains("Investigate realtime startup context"));
assert!(startup_context.contains("## Machine / Workspace Map"));
assert!(startup_context.contains("README.md"));
assert!(!startup_context.contains(MEMORY_PROMPT_PHRASE));
startup_server.shutdown().await;
realtime_server.shutdown().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn conversation_startup_context_current_thread_selects_many_turns_by_budget() -> Result<()> {
skip_if_no_network!(Ok(()));
let api_server = start_mock_server().await;
let realtime_server = start_websocket_server(vec![vec![vec![json!({
"type": "session.updated",
"session": { "id": "sess_current_thread_budget", "instructions": "backend prompt" }
})]]])
.await;
let latest_long_user_turn = format!(
"latest-long-start {} latest-long-middle {} latest-long-end",
"head detail ".repeat(120),
"tail detail ".repeat(170),
);
let user_turns = (1..=7)
.map(|index| {
format!(
"short-turn-{index}-start {} short-turn-{index}-end",
"detail ".repeat(86)
)
})
.chain([latest_long_user_turn.clone()]);
let mut builder = test_codex().with_config({
let realtime_base_url = realtime_server.uri().to_string();
move |config| {
config.experimental_realtime_ws_base_url = Some(realtime_base_url);
config.realtime.version = RealtimeWsVersion::V1;
}
});
let test = builder.build(&api_server).await?;
// Seed completed turns through a resumed thread so this remains an
// end-to-end startup-context test without paying for a model turn per
// fixture entry in platform CI.
let history = user_turns
.enumerate()
.flat_map(|(index, user_turn)| {
let turn_number = index + 1;
let assistant_turn = format!("assistant turn {turn_number}");
[
ResponseItem::Message {
status: None,
encrypted_content: None,
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText { text: user_turn }],
phase: None,
internal_chat_message_metadata_passthrough: None,
},
ResponseItem::Message {
status: None,
encrypted_content: None,
id: None,
role: "assistant".to_string(),
content: vec![ContentItem::OutputText {
annotations: None,
logprobs: None,
text: assistant_turn,
}],
phase: None,
internal_chat_message_metadata_passthrough: None,
},
]
.map(|item| RolloutItem::ResponseItem(item.into()))
})
.collect::<Vec<_>>();
test.codex.shutdown_and_wait().await?;
let resumed_thread = test
.thread_manager
.resume_thread_with_history(
test.config.clone(),
InitialHistory::Forked(history),
auth_manager_from_auth(CodexAuth::from_api_key("dummy")),
/*parent_trace*/ None,
ClientMcpExtensions::default(),
)
.await?;
let codex = resumed_thread.thread;
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,
backend_reasoning_status: false,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
voice_session_id: None,
transport: None,
version: None,
voice: None,
}))
.await?;
let startup_context_request = wait_for_matching_websocket_request(
&realtime_server,
"current thread budget startup context request with instructions",
|request| websocket_request_instructions(request).is_some(),
)
.await;
let startup_context = websocket_request_instructions(&startup_context_request)
.expect("startup context request should contain instructions");
// Isolate only the Current Thread section; the startup prompt may also include
// workspace and notes sections after it.
let current_thread_start = startup_context
.find("## Current Thread")
.expect("startup context should include current thread section");
let current_thread_and_rest = &startup_context[current_thread_start..];
let current_thread_end = [
"\n## Recent Work",
"\n## Machine / Workspace Map",
"\n## Notes",
]
.iter()
.filter_map(|marker| current_thread_and_rest.find(marker))
.min()
.unwrap_or(current_thread_and_rest.len());
let current_thread = &current_thread_and_rest[..current_thread_end];
let rendered_turns = current_thread
.split("\n### ")
.skip(1)
.map(|turn| format!("### {turn}"))
.collect::<Vec<_>>();
let over_budget_turns = rendered_turns
.iter()
.filter_map(|turn| {
let token_count = turn.len().div_ceil(4);
(token_count > 300).then(|| {
(
turn.lines().next().unwrap_or_default().to_string(),
token_count,
)
})
})
.collect::<Vec<_>>();
let latest_rendered_source =
format!("### Latest turn\nUser:\n{latest_long_user_turn}\n\nAssistant:\nassistant turn 8");
// Snapshot the actual section so turn order, oldest-first omission, and
// start/end truncation behavior are reviewed together.
let snapshot = format!(
"latest_source_tokens: {}\nrendered_turn_count: {}\nover_budget_turns: {over_budget_turns:?}\n\n{current_thread}",
latest_rendered_source.len().div_ceil(4),
rendered_turns.len(),
);
insta::assert_snapshot!(
"conversation_startup_context_current_thread_selects_many_turns_by_budget",
snapshot
);
// The input includes a turn over 300 approximate tokens, and every rendered
// turn still fits the per-turn cap after labels and truncation markers.
assert_eq!(
(
latest_rendered_source.len().div_ceil(4) > 300,
over_budget_turns,
),
(true, Vec::<(String, usize)>::new()),
);
codex.shutdown_and_wait().await?;
realtime_server.shutdown().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn conversation_startup_context_falls_back_to_workspace_map() -> Result<()> {
skip_if_no_network!(Ok(()));
let startup_server = start_websocket_server(vec![vec![]]).await;
let realtime_server = start_websocket_server(vec![vec![vec![json!({
"type": "session.updated",
"session": { "id": "sess_workspace", "instructions": "backend prompt" }
})]]])
.await;
let mut builder = test_codex().with_config({
let realtime_base_url = realtime_server.uri().to_string();
move |config| {
config.experimental_realtime_ws_base_url = Some(realtime_base_url);
config.realtime.version = RealtimeWsVersion::V1;
}
});
let test = builder.build_with_websocket_server(&startup_server).await?;
fs::create_dir_all(test.workspace_path("codex-rs/core"))?;
fs::write(test.workspace_path("notes.txt"), "workspace marker")?;
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,
backend_reasoning_status: false,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
voice_session_id: None,
transport: None,
version: None,
voice: None,
}))
.await?;
let startup_context_request = wait_for_matching_websocket_request(
&realtime_server,
"workspace-map startup context request with instructions",
|request| websocket_request_instructions(request).is_some(),
)
.await;
let startup_context = websocket_request_instructions(&startup_context_request)
.expect("startup context request should contain instructions");
assert!(startup_context.contains(STARTUP_CONTEXT_OPEN_TAG));
assert!(startup_context.contains(STARTUP_CONTEXT_CLOSE_TAG));
assert!(startup_context.contains(STARTUP_CONTEXT_HEADER));
assert!(startup_context.contains("## Machine / Workspace Map"));
assert!(startup_context.contains("notes.txt"));
assert!(startup_context.contains("codex-rs/"));
startup_server.shutdown().await;
realtime_server.shutdown().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn conversation_startup_context_is_truncated_and_sent_once_per_start() -> Result<()> {
skip_if_no_network!(Ok(()));
let startup_server = start_websocket_server(vec![vec![]]).await;
let realtime_server = start_websocket_server(vec![vec![
vec![json!({
"type": "session.updated",
"session": { "id": "sess_truncated", "instructions": "backend prompt" }
})],
vec![],
]])
.await;
let oversized_summary = "recent work ".repeat(3_500);
let mut builder = test_codex().with_config({
let realtime_base_url = realtime_server.uri().to_string();
move |config| {
config.experimental_realtime_ws_base_url = Some(realtime_base_url);
config.realtime.version = RealtimeWsVersion::V1;
}
});
let test = builder.build_with_websocket_server(&startup_server).await?;
seed_recent_thread(&test, &oversized_summary, "summary", "oversized").await?;
fs::write(test.workspace_path("marker.txt"), "marker")?;
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,
backend_reasoning_status: false,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
voice_session_id: None,
transport: None,
version: None,
voice: None,
}))
.await?;
let startup_context_request = wait_for_matching_websocket_request(
&realtime_server,
"truncated startup context request with instructions",
|request| websocket_request_instructions(request).is_some(),
)
.await;
let startup_context = websocket_request_instructions(&startup_context_request)
.expect("startup context request should contain instructions");
assert!(startup_context.contains(STARTUP_CONTEXT_OPEN_TAG));
assert!(startup_context.contains(STARTUP_CONTEXT_CLOSE_TAG));
assert!(startup_context.contains(STARTUP_CONTEXT_HEADER));
assert!(startup_context.len() <= 20_500);
test.codex
.submit(Op::RealtimeConversationText(ConversationTextParams {
text: "hello".to_string(),
role: ConversationTextRole::User,
}))
.await?;
let explicit_text_request = wait_for_matching_websocket_request(
&realtime_server,
"explicit realtime text request",
|request| websocket_request_text(request).as_deref() == Some("hello"),
)
.await;
assert_eq!(
websocket_request_text(&explicit_text_request),
Some("hello".to_string())
);
startup_server.shutdown().await;
realtime_server.shutdown().await;
Ok(())
}
#[test_case(false; "durable")]
#[test_case(true; "ephemeral")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn conversation_user_text_turn_is_not_sent_to_realtime(ephemeral: bool) -> Result<()> {
skip_if_no_network!(Ok(()));
let api_server = start_mock_server().await;
let response_mock = responses::mount_sse_once(
&api_server,
responses::sse(vec![
responses::ev_response_created("resp_user_text"),
responses::ev_completed("resp_user_text"),
]),
)
.await;
let realtime_server = start_websocket_server(vec![vec![
vec![
json!({
"type": "session.updated",
"session": { "id": "sess_user_text", "instructions": "backend prompt" }
}),
json!({
"type": "response.output_text.delta",
"delta": "spoken before typed input"
}),
],
vec![],
]])
.await;
let mut builder = test_codex()
.with_history_mode(ThreadHistoryMode::Paginated)
.with_config({
let realtime_base_url = realtime_server.uri().to_string();
move |config| {
config.ephemeral = ephemeral;
config.realtime.version = RealtimeWsVersion::V2;
config.experimental_realtime_ws_base_url = Some(realtime_base_url);
config.experimental_realtime_ws_startup_context = Some(String::new());
}
});
let test = builder.build_with_auto_env(&api_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,
backend_reasoning_status: false,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
voice_session_id: None,
transport: None,
version: None,
voice: None,
}))
.await?;
let session_updated = wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload:
RealtimeEvent::SessionUpdated {
realtime_session_id: session_id,
..
},
}) => Some(session_id.clone()),
_ => None,
})
.await;
assert_eq!(session_updated, "sess_user_text");
wait_for_event(&test.codex, |event| {
if let EventMsg::RealtimeConversationRealtime(event) = event {
match event.payload {
RealtimeEvent::HistoryItemStarted(_)
| RealtimeEvent::HistoryTranscriptDelta { .. }
| RealtimeEvent::HistoryItemCompleted(_) => assert!(!ephemeral),
RealtimeEvent::OutputTranscriptDelta(_) => return true,
_ => {}
}
}
false
})
.await;
let user_text = "typed follow-up for realtime";
test.codex
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
text: user_text.to_string(),
text_elements: Vec::new(),
}]))
.await?;
let turn_complete = wait_for_event_match(&test.codex, |event| match event {
EventMsg::TurnComplete(turn_complete) => Some(turn_complete.clone()),
_ => None,
})
.await;
assert_eq!(turn_complete.last_agent_message, None);
let model_user_texts = response_mock.single_request().message_input_texts("user");
assert!(model_user_texts.iter().any(|text| text == user_text));
if !ephemeral {
test.thread_store
.flush_thread(test.session_configured.thread_id)
.await?;
let timeline = test
.thread_store
.list_timeline(ListTimelineParams {
thread_id: test.session_configured.thread_id,
cursor: None,
page_size: 100,
})
.await?;
assert_eq!(
timeline.items.iter().filter_map(|entry| match entry {
ThreadTimelineEntry::Realtime { item, .. }
if matches!(&item.content, ThreadRealtimeItemContent::TranscriptSegment { text, .. } if text == "spoken before typed input") => Some("speech"),
ThreadTimelineEntry::Item { item, .. }
if matches!(&item.payload, codex_app_server_protocol::ThreadItemPayload::UserMessage { .. }) => Some("typed input"),
_ => None,
}).collect::<Vec<_>>(),
vec!["speech", "typed input"]
);
}
let realtime_connections = realtime_server.connections();
assert_eq!(realtime_connections.len(), 1);
assert_eq!(realtime_connections[0].len(), 1);
assert_eq!(
realtime_connections[0][0].body_json()["type"].as_str(),
Some("session.update")
);
realtime_server.shutdown().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn realtime_v2_noop_tool_call_returns_empty_function_output_without_response() -> Result<()> {
skip_if_no_network!(Ok(()));
let api_server = start_mock_server().await;
let realtime_server = start_websocket_server(vec![vec![
vec![
json!({
"type": "session.updated",
"session": { "id": "sess_silent", "instructions": "backend prompt" }
}),
json!({
"type": "conversation.item.done",
"item": {
"id": "item_silent",
"type": "function_call",
"name": "remain_silent",
"call_id": "call_silent",
"arguments": "{}"
}
}),
],
vec![],
]])
.await;
let mut builder = test_codex().with_config({
let realtime_base_url = realtime_server.uri().to_string();
move |config| {
config.experimental_realtime_ws_base_url = Some(realtime_base_url);
config.realtime.version = RealtimeWsVersion::V2;
}
});
let test = builder.build(&api_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,
backend_reasoning_status: false,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
voice_session_id: None,
transport: None,
version: None,
voice: None,
}))
.await?;
let _ = wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload: RealtimeEvent::NoopRequested(RealtimeNoopRequested { call_id, .. }),
}) if call_id == "call_silent" => Some(()),
_ => None,
})
.await;
let function_output = realtime_server
.wait_for_request(/*connection_index*/ 0, /*request_index*/ 1)
.await;
assert_eq!(
function_output.body_json(),
json!({
"type": "conversation.item.create",
"item": {
"type": "function_call_output",
"call_id": "call_silent",
"output": ""
}
})
);
let realtime_response_create = timeout(Duration::from_millis(200), async {
wait_for_matching_websocket_request(
&realtime_server,
"unexpected realtime response request for noop tool call",
|request| request.body_json()["type"].as_str() == Some("response.create"),
)
.await
})
.await;
assert!(
realtime_response_create.is_err(),
"noop tool calls should not request a realtime response"
);
realtime_server.shutdown().await;
Ok(())
}
#[test_matrix([None, Some("final_answer"), Some("commentary"), Some("partial_answer")], [false, true])]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn conversation_mirrors_assistant_message_text_to_realtime_handoff(
phase: Option<&str>,
active_handoff: bool,
) -> Result<()> {
skip_if_no_network!(Ok(()));
let api_server = start_mock_server().await;
let mut message = responses::ev_assistant_message("msg_1", "assistant says hi");
if let Some(phase) = phase {
message["item"]["phase"] = json!(phase);
}
let _response_mock = responses::mount_sse_once(
&api_server,
responses::sse(vec![
responses::ev_response_created("resp_1"),
message,
responses::ev_completed("resp_1"),
]),
)
.await;
let mut realtime_events = vec![json!({
"type": "session.updated",
"session": { "id": "sess_1", "instructions": "backend prompt" }
})];
if active_handoff {
realtime_events.extend([
json!({
"type": "conversation.input_transcript.delta",
"delta": "delegate hello"
}),
json!({
"type": "conversation.handoff.requested",
"handoff_id": "handoff_1",
"item_id": "item_1",
"input_transcript": "delegate hello"
}),
]);
}
let realtime_server = start_websocket_server(vec![vec![realtime_events, vec![]]]).await;
let mut builder = test_codex().with_config({
let realtime_base_url = realtime_server.uri().to_string();
move |config| {
config.experimental_realtime_ws_base_url = Some(realtime_base_url);
config.realtime.version = RealtimeWsVersion::V1;
}
});
let test = builder.build_with_auto_env(&api_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,
backend_reasoning_status: false,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
voice_session_id: None,
transport: None,
version: None,
voice: None,
}))
.await?;
let session_updated = wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload:
RealtimeEvent::SessionUpdated {
realtime_session_id: session_id,
..
},
}) => Some(session_id.clone()),
_ => None,
})
.await;
assert_eq!(session_updated, "sess_1");
if active_handoff {
let _ = wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload: RealtimeEvent::HandoffRequested(handoff),
}) if handoff.handoff_id == "handoff_1" => Some(()),
_ => None,
})
.await;
} else {
test.codex
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
text: "hello without a realtime delegation".to_string(),
text_elements: Vec::new(),
}]))
.await?;
}
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
let append = realtime_server
.wait_for_request(/*connection_index*/ 0, /*request_index*/ 1)
.await;
let realtime_connections = realtime_server.connections();
assert_eq!(realtime_connections.len(), 1);
assert_eq!(realtime_connections[0].len(), 2);
assert_eq!(
realtime_connections[0][0].body_json()["type"].as_str(),
Some("session.update")
);
let expected_text = if matches!(phase, Some("commentary" | "partial_answer")) {
"assistant says hi"
} else {
"\"Agent Final Message\":\n\nassistant says hi"
};
assert_eq!(
append.body_json(),
json!({
"type": "conversation.handoff.append",
"handoff_id": if active_handoff { "handoff_1" } else { "codex" },
"output_text": expected_text,
})
);
realtime_server.shutdown().await;
Ok(())
}
#[test_case(true; "enabled")]
#[test_case(false; "disabled")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn conversation_relays_only_public_reasoning_as_quiet_status(enabled: bool) -> Result<()> {
skip_if_no_network!(Ok(()));
let (item_tx, item_rx) = oneshot::channel();
let (response_tx, response_rx) = oneshot::channel();
let chunks = vec![
StreamingSseChunk {
gate: None,
body: responses::sse(vec![
responses::ev_response_created("summary_response"),
responses::ev_reasoning_item_added("reasoning", &[""]),
responses::ev_reasoning_text_delta("Private raw reasoning."),
responses::ev_reasoning_summary_text_delta("Partial summary."),
]),
},
StreamingSseChunk {
gate: Some(item_rx),
body: sse_event(responses::ev_reasoning_item(
"reasoning",
&["Older public summary", "Checking availability.", " "],
&["Private raw reasoning."],
)),
},
StreamingSseChunk {
gate: Some(response_rx),
body: sse_event(responses::ev_completed("summary_response")),
},
];
let (api_server, completions) = start_streaming_sse_server(vec![chunks]).await;
let realtime_server = start_websocket_server(vec![vec![vec![
json!({"type":"session.started", "session":{"id":"summary_session"}}),
json!({"type":"delegation.created", "item":{"id":"summary_delegation", "type":"delegation", "target":"client", "content":[{"type":"input_text","text":"check availability"}]}}),
], vec![]]]).await;
let mut builder = test_codex().with_config({
let realtime_base_url = realtime_server.uri().to_string();
move |config| {
config.experimental_realtime_ws_base_url = Some(realtime_base_url);
config.realtime.version = RealtimeWsVersion::V3;
config.model_reasoning_summary =
Some(codex_protocol::config_types::ReasoningSummary::None);
}
});
let test = builder.build_with_streaming_server(&api_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,
// The generic default omits its wire channel; quiet status must override it.
codex_response_handoff_mode:
codex_protocol::protocol::CodexResponseHandoffMode::Thinking,
backend_reasoning_status: enabled,
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,
voice_session_id: None,
transport: None,
version: None,
voice: None,
}))
.await?;
wait_for_event(&test.codex, |event| {
matches!(event,
EventMsg::ReasoningContentDelta(delta) if delta.delta == "Partial summary."
)
})
.await;
let next_request = || {
wait_for_websocket_request(
&realtime_server,
/*connection_index*/ 0,
/*request_index*/ 1,
)
};
assert!(
timeout(Duration::from_millis(150), next_request())
.await
.is_err()
);
let requests = api_server.requests().await;
let model_request: Value = serde_json::from_slice(&requests[0])?;
assert_eq!(model_request["reasoning"].get("summary"), None);
let _ = item_tx.send(());
wait_for_event(&test.codex, |event| matches!(event,
EventMsg::ItemCompleted(item) if matches!(&item.item, codex_protocol::items::TurnItem::Reasoning(reasoning) if reasoning.id == "reasoning")
)).await;
if enabled {
assert_eq!(
next_request().await?.body_json(),
json!({
"type":"delegation.context.append", "delegation_item_id":"summary_delegation", "channel":"commentary",
"content":[{"type":"input_text","text":"[STATUS] Checking availability."}]
})
);
} else {
assert!(
timeout(Duration::from_millis(150), next_request())
.await
.is_err()
);
}
let _ = response_tx.send(());
completions
.into_iter()
.next()
.expect("one response")
.await?;
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
let requests = realtime_server.single_connection();
assert_eq!(requests.len(), if enabled { 2 } else { 1 });
assert!(requests.iter().all(|request| {
let body = request.body_json();
body["type"] != "response.create" && !body.to_string().contains("Private raw reasoning")
}));
realtime_server.shutdown().await;
api_server.shutdown().await;
Ok(())
}
#[test_matrix([None, Some("partial_answer")], [false, true])]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn conversation_routes_assistant_messages_for_v3_handoff(
phase: Option<&str>,
streamed: bool,
) -> Result<()> {
skip_if_no_network!(Ok(()));
let initial_commentary_text = "[PRO";
let first_commentary_delta = "GRESS]seed first ";
let second_commentary_delta = "x".repeat(94);
let commentary_text =
format!("{initial_commentary_text}{first_commentary_delta}{second_commentary_delta}");
let (gate_commentary_done_tx, gate_commentary_done_rx) = oneshot::channel();
let mut commentary_item_added =
responses::ev_message_item_added("msg_commentary", initial_commentary_text);
let mut commentary_item_done =
responses::ev_assistant_message("msg_commentary", &commentary_text);
if let Some(phase) = phase {
commentary_item_added["item"]["phase"] = json!(phase);
commentary_item_done["item"]["phase"] = json!(phase);
}
let initial_final_text = "[DO";
let final_delta = "NE]done";
let final_text = format!("{initial_final_text}{final_delta}");
let final_item_added = responses::ev_message_item_added("msg_final", initial_final_text);
let final_item_done = responses::ev_assistant_message("msg_final", &final_text);
let mut response_chunks = vec![StreamingSseChunk {
gate: None,
body: sse_event(responses::ev_response_created("resp_stream")),
}];
if streamed {
response_chunks.extend([
StreamingSseChunk {
gate: None,
body: sse_event(commentary_item_added),
},
StreamingSseChunk {
gate: None,
body: sse_event(responses::ev_output_text_delta(first_commentary_delta)),
},
StreamingSseChunk {
gate: None,
body: sse_event(responses::ev_output_text_delta(&second_commentary_delta)),
},
]);
}
response_chunks.extend([
StreamingSseChunk {
gate: streamed.then_some(gate_commentary_done_rx),
body: sse_event(commentary_item_done),
},
StreamingSseChunk {
gate: None,
body: sse_event(final_item_added),
},
StreamingSseChunk {
gate: None,
body: sse_event(responses::ev_output_text_delta(final_delta)),
},
StreamingSseChunk {
gate: None,
body: sse_event(final_item_done),
},
StreamingSseChunk {
gate: None,
body: sse_event(responses::ev_completed("resp_stream")),
},
]);
let (api_server, completions) = start_streaming_sse_server(vec![response_chunks]).await;
let realtime_server = start_websocket_server(vec![vec![
vec![
json!({
"type": "session.started",
"session": { "id": "sess_stream", "instructions": "backend prompt" }
}),
json!({
"type": "delegation.created",
"item": {
"id": "delegation_stream",
"type": "delegation",
"target": "client",
"content": [{ "type": "input_text", "text": "delegate streaming" }]
}
}),
],
vec![],
vec![],
]])
.await;
let mut builder = test_codex().with_config({
let realtime_base_url = realtime_server.uri().to_string();
move |config| {
config.experimental_realtime_ws_base_url = Some(realtime_base_url);
config.realtime.version = RealtimeWsVersion::V3;
}
});
let test = builder.build_with_streaming_server(&api_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::BemTags,
backend_reasoning_status: false,
codex_response_handoff_channel_prefixes: Some(BTreeMap::from([
(
"commentary".to_string(),
vec!["[PROGRESS]".to_string(), "[UPDATE]".to_string()],
),
("final".to_string(), vec!["[DONE]".to_string()]),
])),
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
voice_session_id: None,
transport: None,
version: None,
voice: None,
}))
.await?;
let _ = wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload:
RealtimeEvent::SessionUpdated {
realtime_session_id,
..
},
}) if realtime_session_id == "sess_stream" => Some(()),
_ => None,
})
.await;
let _ = wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload: RealtimeEvent::HandoffRequested(handoff),
}) if handoff.handoff_id == "delegation_stream" => Some(()),
_ => None,
})
.await;
if streamed {
let _ = wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::AgentMessageContentDelta(event)
if event.item_id == "msg_commentary" && event.delta == first_commentary_delta =>
{
Some(())
}
_ => None,
})
.await;
}
assert_eq!(
wait_for_websocket_request(
&realtime_server,
/*connection_index*/ 0,
/*request_index*/ 1,
)
.await?
.body_json(),
json!({
"type": "delegation.context.append",
"delegation_item_id": "delegation_stream",
"channel": if phase.is_some() { "speakable" } else { "commentary" },
"content": [{ "type": "input_text", "text": commentary_text }]
})
);
let _ = gate_commentary_done_tx.send(());
assert_eq!(
wait_for_websocket_request(
&realtime_server,
/*connection_index*/ 0,
/*request_index*/ 2,
)
.await?
.body_json(),
json!({
"type": "delegation.context.append",
"delegation_item_id": "delegation_stream",
"channel": "speakable",
"content": [{
"type": "input_text",
"text": final_text
}]
})
);
completions
.into_iter()
.next()
.expect("missing delegated turn completion")
.await
.expect("delegated turn request did not complete");
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
tokio::time::sleep(Duration::from_millis(50)).await;
assert_eq!(
realtime_server.single_connection().len(),
3,
"completed assistant item must not resend the full text"
);
realtime_server.shutdown().await;
api_server.shutdown().await;
Ok(())
}
#[test_matrix(["commentary", "partial_answer"], [false, true])]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn conversation_handoff_persists_across_item_done_until_turn_complete(
phase: &str,
streamed: bool,
) -> Result<()> {
skip_if_no_network!(Ok(()));
let (gate_second_message_tx, gate_second_message_rx) = oneshot::channel();
let mut commentary_message = responses::ev_assistant_message("msg-1", "assistant message 1");
commentary_message["item"]["phase"] = json!(phase);
let mut final_message = responses::ev_assistant_message("msg-2", "assistant message 2");
final_message["item"]["phase"] = json!("final_answer");
let mut first_chunks = vec![StreamingSseChunk {
gate: None,
body: sse_event(responses::ev_response_created("resp-1")),
}];
if streamed {
let mut added = responses::ev_message_item_added("msg-1", "");
added["item"]["phase"] = json!(phase);
first_chunks.extend([
StreamingSseChunk {
gate: None,
body: sse_event(added),
},
StreamingSseChunk {
gate: None,
body: sse_event(responses::ev_output_text_delta("assistant message 1")),
},
]);
}
first_chunks.extend([
StreamingSseChunk {
gate: None,
body: sse_event(commentary_message),
},
StreamingSseChunk {
gate: Some(gate_second_message_rx),
body: sse_event(final_message),
},
StreamingSseChunk {
gate: None,
body: sse_event(responses::ev_completed("resp-1")),
},
]);
let (api_server, completions) = start_streaming_sse_server(vec![first_chunks]).await;
let realtime_server = start_websocket_server(vec![vec![
vec![
json!({
"type": "session.updated",
"session": { "id": "sess_item_done", "instructions": "backend prompt" }
}),
json!({
"type": "conversation.input_transcript.delta",
"delta": "delegate now"
}),
json!({
"type": "conversation.handoff.requested",
"handoff_id": "handoff_item_done",
"item_id": "item_item_done",
"input_transcript": "delegate now"
}),
],
vec![json!({
"type": "conversation.item.done",
"item": { "id": "item_item_done" }
})],
vec![],
]])
.await;
let mut builder = test_codex().with_config({
let realtime_base_url = realtime_server.uri().to_string();
move |config| {
config.experimental_realtime_ws_base_url = Some(realtime_base_url);
config.realtime.version = RealtimeWsVersion::V1;
}
});
let test = builder.build_with_streaming_server(&api_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::BemTags,
backend_reasoning_status: false,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
voice_session_id: None,
transport: None,
version: None,
voice: None,
}))
.await?;
let _ = wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload:
RealtimeEvent::SessionUpdated {
realtime_session_id: session_id,
..
},
}) if session_id == "sess_item_done" => Some(()),
_ => None,
})
.await;
let _ = wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload: RealtimeEvent::HandoffRequested(handoff),
}) if handoff.handoff_id == "handoff_item_done" => Some(()),
_ => None,
})
.await;
let first_append = realtime_server
.wait_for_request(/*connection_index*/ 0, /*request_index*/ 1)
.await;
assert_eq!(
first_append.body_json()["type"].as_str(),
Some("conversation.handoff.append")
);
assert_eq!(
first_append.body_json()["handoff_id"].as_str(),
Some("handoff_item_done")
);
assert_eq!(
first_append.body_json()["output_text"].as_str(),
Some("assistant message 1")
);
let _ = wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload: RealtimeEvent::ConversationItemDone { item_id },
}) if item_id == "item_item_done" => Some(()),
_ => None,
})
.await;
let _ = gate_second_message_tx.send(());
let second_append = realtime_server
.wait_for_request(/*connection_index*/ 0, /*request_index*/ 2)
.await;
assert_eq!(
second_append.body_json()["type"].as_str(),
Some("conversation.handoff.append")
);
assert_eq!(
second_append.body_json()["handoff_id"].as_str(),
Some("handoff_item_done")
);
assert_eq!(
second_append.body_json()["output_text"].as_str(),
Some("\"Agent Final Message\":\n\nassistant message 2")
);
let completion = completions
.into_iter()
.next()
.expect("missing delegated turn completion");
completion
.await
.expect("delegated turn request did not complete");
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
realtime_server.shutdown().await;
api_server.shutdown().await;
Ok(())
}
fn sse_event(event: Value) -> String {
responses::sse(vec![event])
}
fn message_input_texts(body: &Value, role: &str) -> Vec<String> {
body.get("input")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter(|item| item.get("type").and_then(Value::as_str) == Some("message"))
.filter(|item| item.get("role").and_then(Value::as_str) == Some(role))
.filter_map(|item| item.get("content").and_then(Value::as_array))
.flatten()
.filter(|span| span.get("type").and_then(Value::as_str) == Some("input_text"))
.filter_map(|span| span.get("text").and_then(Value::as_str).map(str::to_owned))
.collect()
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn inbound_handoff_request_starts_turn_and_promotes_its_artifact() -> Result<()> {
skip_if_no_network!(Ok(()));
let api_server = start_mock_server().await;
let response_mock = responses::mount_sse_once(
&api_server,
responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_assistant_message("msg-1", "::codex-realtime-inline{}\nShared artifact"),
responses::ev_completed("resp-1"),
]),
)
.await;
let realtime_server = start_websocket_server(vec![vec![vec![
json!({
"type": "session.updated",
"session": { "id": "sess_inbound", "instructions": "backend prompt" }
}),
json!({
"type": "conversation.input_transcript.delta",
"delta": "text from realtime"
}),
json!({
"type": "conversation.handoff.requested",
"handoff_id": "handoff_inbound",
"item_id": "item_inbound",
"input_transcript": "text from realtime"
}),
]]])
.await;
let mut builder = test_codex()
.with_history_mode(ThreadHistoryMode::Paginated)
.with_config({
let realtime_base_url = realtime_server.uri().to_string();
move |config| {
config.experimental_realtime_ws_base_url = Some(realtime_base_url);
config.realtime.version = RealtimeWsVersion::V1;
}
});
let test = builder.build_with_auto_env(&api_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,
backend_reasoning_status: false,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
voice_session_id: None,
transport: None,
version: None,
voice: None,
}))
.await?;
let session_updated = wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload:
RealtimeEvent::SessionUpdated {
realtime_session_id: session_id,
..
},
}) => Some(session_id.clone()),
_ => None,
})
.await;
assert_eq!(session_updated, "sess_inbound");
let _ = wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload: RealtimeEvent::HandoffRequested(handoff),
}) if handoff.handoff_id == "handoff_inbound"
&& handoff.input_transcript == "text from realtime" =>
{
Some(())
}
_ => None,
})
.await;
let turn_id = loop {
let event = test.codex.next_event().await?;
if let EventMsg::TurnStarted(turn_started) = event.msg {
break turn_started.turn_id;
}
};
Uuid::parse_str(&turn_id).context("realtime-routed turn ID should be a UUID")?;
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
test.thread_store
.flush_thread(test.session_configured.thread_id)
.await?;
let timeline = test
.thread_store
.list_timeline(ListTimelineParams {
thread_id: test.session_configured.thread_id,
cursor: None,
page_size: 100,
})
.await?;
let promotions = timeline
.items
.into_iter()
.filter_map(|entry| match entry {
ThreadTimelineEntry::Realtime { item, .. }
if matches!(
item.content,
ThreadRealtimeItemContent::BemItemPromoted { .. }
) =>
{
Some(item.content)
}
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(
promotions,
vec![ThreadRealtimeItemContent::BemItemPromoted {
turn_id,
item_id: "msg-1".to_string(),
presentation:
codex_app_server_protocol::ThreadRealtimeBemItemPresentation::InlineMarkdown,
}]
);
let request = response_mock.single_request();
let turn_metadata: Value = serde_json::from_str(
request
.header("x-codex-turn-metadata")
.as_deref()
.context("realtime-routed turn should include turn metadata")?,
)?;
assert_eq!(turn_metadata["turn_trigger"].as_str(), Some("realtime"));
let user_texts = request.message_input_texts("user");
assert!(user_texts.iter().any(|text| text
== "<realtime_delegation>\n <input>text from realtime</input>\n <transcript_delta>user: text from realtime</transcript_delta>\n</realtime_delegation>"));
realtime_server.shutdown().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn inbound_handoff_request_uses_active_transcript() -> Result<()> {
skip_if_no_network!(Ok(()));
let api_server = start_mock_server().await;
let response_mock = responses::mount_sse_once(
&api_server,
responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_assistant_message("msg-1", "ok"),
responses::ev_completed("resp-1"),
]),
)
.await;
let realtime_server = start_websocket_server(vec![vec![vec![
json!({
"type": "session.updated",
"session": { "id": "sess_inbound_multi", "instructions": "backend prompt" }
}),
json!({
"type": "conversation.output_transcript.delta",
"delta": "assistant context"
}),
json!({
"type": "conversation.input_transcript.delta",
"delta": "delegated query"
}),
json!({
"type": "conversation.output_transcript.delta",
"delta": "assist confirm"
}),
json!({
"type": "conversation.handoff.requested",
"handoff_id": "handoff_inbound_multi",
"item_id": "item_inbound_multi",
"input_transcript": "ignored"
}),
]]])
.await;
let mut builder = test_codex().with_config({
let realtime_base_url = realtime_server.uri().to_string();
move |config| {
config.experimental_realtime_ws_base_url = Some(realtime_base_url);
config.realtime.version = RealtimeWsVersion::V1;
}
});
let test = builder.build(&api_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,
backend_reasoning_status: false,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
voice_session_id: None,
transport: None,
version: None,
voice: None,
}))
.await?;
let _ = wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload:
RealtimeEvent::SessionUpdated {
realtime_session_id: session_id,
..
},
}) => Some(session_id.clone()),
_ => None,
})
.await;
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
let request = response_mock.single_request();
let user_texts = request.message_input_texts("user");
assert!(user_texts.iter().any(|text| text
== "<realtime_delegation>\n <input>ignored</input>\n <transcript_delta>assistant: assistant context\nuser: delegated query\nassistant: assist confirm\nuser: ignored</transcript_delta>\n</realtime_delegation>"));
realtime_server.shutdown().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn inbound_handoff_request_sends_transcript_delta_after_each_handoff() -> Result<()> {
skip_if_no_network!(Ok(()));
let api_server = start_mock_server().await;
let response_mock = responses::mount_sse_sequence(
&api_server,
vec![
responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_assistant_message("msg-1", "first ok"),
responses::ev_completed("resp-1"),
]),
responses::sse(vec![
responses::ev_response_created("resp-2"),
responses::ev_assistant_message("msg-2", "second ok"),
responses::ev_completed("resp-2"),
]),
],
)
.await;
let realtime_server = start_websocket_server(vec![vec![
vec![
json!({
"type": "session.updated",
"session": { "id": "sess_inbound_clear", "instructions": "backend prompt" }
}),
json!({
"type": "conversation.input_transcript.delta",
"delta": "first question"
}),
json!({
"type": "conversation.handoff.requested",
"handoff_id": "handoff_inbound_clear_1",
"item_id": "item_inbound_clear_1",
"input_transcript": "first question"
}),
],
vec![],
vec![
json!({
"type": "conversation.input_transcript.delta",
"delta": "second question"
}),
json!({
"type": "conversation.handoff.requested",
"handoff_id": "handoff_inbound_clear_2",
"item_id": "item_inbound_clear_2",
"input_transcript": "second question"
}),
],
]])
.await;
let mut builder = test_codex().with_config({
let realtime_base_url = realtime_server.uri().to_string();
move |config| {
config.experimental_realtime_ws_base_url = Some(realtime_base_url);
config.realtime.version = RealtimeWsVersion::V1;
}
});
let test = builder.build(&api_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,
backend_reasoning_status: false,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
voice_session_id: None,
transport: None,
version: None,
voice: None,
}))
.await?;
let _ = wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload:
RealtimeEvent::SessionUpdated {
realtime_session_id: session_id,
..
},
}) => Some(session_id.clone()),
_ => None,
})
.await;
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
test.codex
.submit(Op::RealtimeConversationAudio(ConversationAudioParams {
frame: RealtimeAudioFrame {
data: "AQID".to_string(),
sample_rate: 24000,
num_channels: 1,
samples_per_channel: Some(480),
item_id: None,
},
}))
.await?;
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
let requests = response_mock.requests();
assert_eq!(requests.len(), 2);
let first_user_texts = requests[0].message_input_texts("user");
assert!(first_user_texts.iter().any(|text| text
== "<realtime_delegation>\n <input>first question</input>\n <transcript_delta>user: first question</transcript_delta>\n</realtime_delegation>"));
let second_user_texts = requests[1].message_input_texts("user");
assert!(second_user_texts.iter().any(|text| text
== "<realtime_delegation>\n <input>second question</input>\n <transcript_delta>user: second question</transcript_delta>\n</realtime_delegation>"));
assert!(!second_user_texts.iter().any(|text| text
== "<realtime_delegation>\n <input>second question</input>\n <transcript_delta>user: first question\nuser: second question</transcript_delta>\n</realtime_delegation>"));
realtime_server.shutdown().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn conversation_close_routes_only_remaining_transcript_tail_once() -> Result<()> {
skip_if_no_network!(Ok(()));
let api_server = start_mock_server().await;
let response_mock = responses::mount_sse_sequence(
&api_server,
vec![
responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_assistant_message("msg-1", "first ok"),
responses::ev_completed("resp-1"),
]),
responses::sse(vec![
responses::ev_response_created("resp-2"),
responses::ev_assistant_message("msg-2", "tail ok"),
responses::ev_completed("resp-2"),
]),
],
)
.await;
let realtime_server = start_websocket_server_with_headers(vec![WebSocketConnectionConfig {
requests: vec![
vec![
json!({
"type": "session.updated",
"session": { "id": "sess_tail", "instructions": "backend prompt" }
}),
json!({
"type": "conversation.input_transcript.delta",
"delta": "already handed off"
}),
json!({
"type": "conversation.handoff.requested",
"handoff_id": "handoff_tail",
"item_id": "item_tail",
"input_transcript": "already handed off"
}),
json!({
"type": "conversation.output_transcript.delta",
"delta": "remaining answer"
}),
json!({
"type": "conversation.input_transcript.delta",
"delta": "remaining question"
}),
],
vec![],
],
response_headers: Vec::new(),
accept_delay: None,
close_after_requests: false,
}])
.await;
let mut builder = test_codex().with_config({
let realtime_base_url = realtime_server.uri().to_string();
move |config| {
config.experimental_realtime_ws_base_url = Some(realtime_base_url);
config.realtime.version = RealtimeWsVersion::V1;
}
});
let test = builder.build(&api_server).await?;
test.codex
.submit(Op::RealtimeConversationStart(ConversationStartParams {
client_managed_handoffs: false,
delegation_ack_filler: None,
flush_transcript_tail_on_session_end: true,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_mode:
codex_protocol::protocol::CodexResponseHandoffMode::Thinking,
backend_reasoning_status: false,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
voice_session_id: None,
transport: None,
version: None,
voice: None,
}))
.await?;
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
test.codex.submit(Op::RealtimeConversationClose).await?;
let closed = wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::RealtimeConversationClosed(closed) => Some(closed.clone()),
EventMsg::TurnStarted(_) | EventMsg::ItemStarted(_) | EventMsg::ItemCompleted(_) => {
panic!("idle transcript flush must not create an unfinished client turn")
}
_ => None,
})
.await;
assert_eq!(closed.reason.as_deref(), Some("requested"));
let history = test.codex.load_history(/*include_archived*/ false).await?;
let tails = history.items.iter().filter(|item| {
matches!(item, RolloutItem::ResponseItem(ResponseItemEnvelope { item: ResponseItem::Message { role, content, .. }, .. })
if role == "user" && content.iter().any(|item| matches!(item,
ContentItem::InputText { text } if text.contains("<source>transcript_tail_flush</source>")
&& text.contains("assistant: remaining answer\nuser: remaining question"))))
}).count();
assert_eq!(tails, 1);
assert_eq!(response_mock.requests().len(), 1);
test.codex.submit(Op::RealtimeConversationClose).await?;
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::RealtimeConversationClosed(_))
})
.await;
test.submit_text_turn("continue in text").await?;
let requests = response_mock.requests();
assert_eq!(requests.len(), 2);
assert!(requests[0].message_input_texts("user").iter().any(|text| text
== "<realtime_delegation>\n <input>already handed off</input>\n <transcript_delta>user: already handed off</transcript_delta>\n</realtime_delegation>"));
assert!(requests[1].message_input_texts("user").iter().any(|text| text
== "<realtime_delegation>\n <source>transcript_tail_flush</source>\n <input>The user just ended their realtime session. Here is the remaining handoff/transcript tail. You probably do not have to do anything; acknowledge the handoff unless the transcript itself asks for something.</input>\n <transcript_delta>assistant: remaining answer\nuser: remaining question</transcript_delta>\n</realtime_delegation>"));
realtime_server.shutdown().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn inbound_conversation_item_does_not_start_turn_and_still_forwards_audio() -> Result<()> {
skip_if_no_network!(Ok(()));
let api_server = start_mock_server().await;
let realtime_server = start_websocket_server(vec![vec![vec![
json!({
"type": "session.updated",
"session": { "id": "sess_ignore_item", "instructions": "backend prompt" }
}),
json!({
"type": "conversation.item.added",
"item": {
"type": "message",
"role": "user",
"content": [{"type": "text", "text": "echoed local text"}]
}
}),
json!({
"type": "conversation.output_audio.delta",
"delta": "AQID",
"sample_rate": 24000,
"channels": 1
}),
]]])
.await;
let mut builder = test_codex().with_config({
let realtime_base_url = realtime_server.uri().to_string();
move |config| {
config.experimental_realtime_ws_base_url = Some(realtime_base_url);
config.realtime.version = RealtimeWsVersion::V1;
}
});
let test = builder.build(&api_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,
backend_reasoning_status: false,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
voice_session_id: None,
transport: None,
version: None,
voice: None,
}))
.await?;
let _ = wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload:
RealtimeEvent::SessionUpdated {
realtime_session_id: session_id,
..
},
}) if session_id == "sess_ignore_item" => Some(()),
_ => None,
})
.await;
let audio_out = tokio::time::timeout(
Duration::from_millis(500),
wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload: RealtimeEvent::AudioOut(frame),
}) => Some(frame.clone()),
_ => None,
}),
)
.await
.expect("timed out waiting for realtime audio after conversation item");
assert_eq!(audio_out.data, "AQID");
let unexpected_turn_started = tokio::time::timeout(
Duration::from_millis(200),
wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::TurnStarted(_) => Some(()),
_ => None,
}),
)
.await;
assert!(unexpected_turn_started.is_err());
realtime_server.shutdown().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn delegated_turn_user_role_echo_does_not_redelegate_and_still_forwards_audio() -> Result<()>
{
skip_if_no_network!(Ok(()));
let start = std::time::Instant::now();
let (gate_completed_tx, gate_completed_rx) = oneshot::channel();
let first_chunks = vec![
StreamingSseChunk {
gate: None,
body: sse_event(responses::ev_response_created("resp-1")),
},
StreamingSseChunk {
gate: None,
body: sse_event(responses::ev_assistant_message(
"msg-1",
"assistant says hi",
)),
},
StreamingSseChunk {
gate: Some(gate_completed_rx),
body: sse_event(responses::ev_completed("resp-1")),
},
];
let (api_server, completions) = start_streaming_sse_server(vec![first_chunks]).await;
let realtime_server = start_websocket_server(vec![vec![
vec![
json!({
"type": "session.updated",
"session": { "id": "sess_echo_guard", "instructions": "backend prompt" }
}),
json!({
"type": "conversation.input_transcript.delta",
"delta": "delegate now"
}),
json!({
"type": "conversation.handoff.requested",
"handoff_id": "handoff_echo_guard",
"item_id": "item_echo_guard",
"input_transcript": "delegate now"
}),
],
vec![
json!({
"type": "conversation.item.added",
"item": {
"type": "message",
"role": "user",
"content": [{"type": "text", "text": "assistant says hi"}]
}
}),
json!({
"type": "conversation.output_audio.delta",
"delta": "AQID",
"sample_rate": 24000,
"channels": 1
}),
],
]])
.await;
let mut builder = test_codex().with_model("gpt-5.4").with_config({
let realtime_base_url = realtime_server.uri().to_string();
move |config| {
config.experimental_realtime_ws_base_url = Some(realtime_base_url);
config.realtime.version = RealtimeWsVersion::V1;
}
});
let test = builder.build_with_streaming_server(&api_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,
backend_reasoning_status: false,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
voice_session_id: None,
transport: None,
version: None,
voice: None,
}))
.await?;
let _ = wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload:
RealtimeEvent::SessionUpdated {
realtime_session_id: session_id,
..
},
}) if session_id == "sess_echo_guard" => Some(()),
_ => None,
})
.await;
let _ = wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload: RealtimeEvent::HandoffRequested(handoff),
}) if handoff.input_transcript == "delegate now" => Some(()),
_ => None,
})
.await;
eprintln!(
"[realtime test +{}ms] saw trigger text={:?}",
start.elapsed().as_millis(),
"delegate now"
);
let mirrored_request = realtime_server
.wait_for_request(/*connection_index*/ 0, /*request_index*/ 1)
.await;
let mirrored_request_body = mirrored_request.body_json();
eprintln!(
"[realtime test +{}ms] saw mirrored request type={:?} handoff_id={:?} text={:?}",
start.elapsed().as_millis(),
mirrored_request_body["type"].as_str(),
mirrored_request_body["handoff_id"].as_str(),
mirrored_request_body["output_text"].as_str(),
);
assert_eq!(
mirrored_request_body["type"].as_str(),
Some("conversation.handoff.append")
);
assert_eq!(
mirrored_request_body["handoff_id"].as_str(),
Some("handoff_echo_guard")
);
assert_eq!(
mirrored_request_body["output_text"].as_str(),
Some("\"Agent Final Message\":\n\nassistant says hi")
);
let audio_out = wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload: RealtimeEvent::AudioOut(frame),
}) => Some(frame.clone()),
_ => None,
})
.await;
eprintln!(
"[realtime test +{}ms] saw audio out data={} sample_rate={} num_channels={}",
start.elapsed().as_millis(),
audio_out.data,
audio_out.sample_rate,
audio_out.num_channels
);
assert_eq!(audio_out.data, "AQID");
let completion = completions
.into_iter()
.next()
.expect("missing delegated turn completion");
let _ = gate_completed_tx.send(());
completion
.await
.expect("delegated turn request did not complete");
eprintln!(
"[realtime test +{}ms] delegated completion resolved",
start.elapsed().as_millis()
);
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
let requests = api_server.requests().await;
assert_eq!(requests.len(), 1);
realtime_server.shutdown().await;
api_server.shutdown().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn inbound_handoff_request_does_not_block_realtime_event_forwarding() -> Result<()> {
skip_if_no_network!(Ok(()));
let (gate_completed_tx, gate_completed_rx) = oneshot::channel();
let first_chunks = vec![
StreamingSseChunk {
gate: None,
body: sse_event(responses::ev_response_created("resp-1")),
},
StreamingSseChunk {
gate: Some(gate_completed_rx),
body: sse_event(responses::ev_completed("resp-1")),
},
];
let (api_server, completions) = start_streaming_sse_server(vec![first_chunks]).await;
let realtime_server = start_websocket_server(vec![vec![vec![
json!({
"type": "session.updated",
"session": { "id": "sess_non_blocking", "instructions": "backend prompt" }
}),
json!({
"type": "conversation.input_transcript.delta",
"delta": "delegate now"
}),
json!({
"type": "conversation.handoff.requested",
"handoff_id": "handoff_non_blocking",
"item_id": "item_non_blocking",
"input_transcript": "delegate now"
}),
json!({
"type": "conversation.output_audio.delta",
"delta": "AQID",
"sample_rate": 24000,
"channels": 1
}),
]]])
.await;
let mut builder = test_codex().with_model("gpt-5.4").with_config({
let realtime_base_url = realtime_server.uri().to_string();
move |config| {
config.experimental_realtime_ws_base_url = Some(realtime_base_url);
config.realtime.version = RealtimeWsVersion::V1;
}
});
let test = builder.build_with_streaming_server(&api_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,
backend_reasoning_status: false,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
voice_session_id: None,
transport: None,
version: None,
voice: None,
}))
.await?;
let _ = wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload:
RealtimeEvent::SessionUpdated {
realtime_session_id: session_id,
..
},
}) if session_id == "sess_non_blocking" => Some(()),
_ => None,
})
.await;
let _ = wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload: RealtimeEvent::HandoffRequested(handoff),
}) if handoff.input_transcript == "delegate now" => Some(()),
_ => None,
})
.await;
let audio_out = tokio::time::timeout(
Duration::from_millis(500),
wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload: RealtimeEvent::AudioOut(frame),
}) => Some(frame.clone()),
_ => None,
}),
)
.await
.expect("timed out waiting for realtime audio while delegated turn was still pending");
assert_eq!(audio_out.data, "AQID");
let completion = completions
.into_iter()
.next()
.expect("missing delegated turn completion");
let _ = gate_completed_tx.send(());
completion
.await
.expect("delegated turn request did not complete");
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
realtime_server.shutdown().await;
api_server.shutdown().await;
Ok(())
}
#[test_case(false, false; "without_tail")]
#[test_case(true, false; "with_tail")]
#[test_case(true, true; "interrupt_after_close")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn inbound_handoff_request_updates_realtime_state_during_active_turn(
flush_transcript_tail_on_session_end: bool,
interrupt: bool,
) -> Result<()> {
skip_if_no_network!(Ok(()));
let (gate_completed_tx, gate_completed_rx) = oneshot::channel();
let first_chunks = vec![
StreamingSseChunk {
gate: None,
body: sse_event(responses::ev_response_created("resp-1")),
},
StreamingSseChunk {
gate: None,
body: sse_event(responses::ev_message_item_added("msg-1", "")),
},
StreamingSseChunk {
gate: None,
body: sse_event(responses::ev_output_text_delta("first ")),
},
StreamingSseChunk {
gate: None,
body: sse_event(responses::ev_output_text_delta("turn")),
},
StreamingSseChunk {
gate: None,
body: sse_event(responses::ev_assistant_message("msg-1", "first turn")),
},
StreamingSseChunk {
gate: Some(gate_completed_rx),
body: sse_event(responses::ev_completed("resp-1")),
},
];
let (second_completed_tx, second_completed_rx) = oneshot::channel();
let second_chunks = vec![
StreamingSseChunk {
gate: None,
body: sse_event(responses::ev_response_created("resp-2")),
},
StreamingSseChunk {
gate: Some(second_completed_rx),
body: sse_event(responses::ev_completed("resp-2")),
},
];
let third_chunks = vec![StreamingSseChunk {
gate: None,
body: sse_event(responses::ev_completed("resp-3")),
}];
let (api_server, completions) =
start_streaming_sse_server(vec![first_chunks, second_chunks, third_chunks]).await;
let realtime_server = start_websocket_server_with_headers(vec![WebSocketConnectionConfig {
requests: vec![
vec![json!({
"type": "session.updated",
"session": { "id": "sess_steer", "instructions": "backend prompt" }
})],
vec![
json!({
"type": "conversation.input_transcript.delta",
"delta": "steer via realtime"
}),
json!({
"type": "conversation.handoff.requested",
"handoff_id": "handoff_steer",
"item_id": "item_steer",
"input_transcript": "steer via realtime"
}),
json!({
"type": "conversation.input_transcript.delta",
"delta": "final transcript tail"
}),
],
],
response_headers: Vec::new(),
accept_delay: None,
close_after_requests: false,
}])
.await;
let mut builder = test_codex().with_model("gpt-5.4").with_config({
let realtime_base_url = realtime_server.uri().to_string();
move |config| {
config.experimental_realtime_ws_base_url = Some(realtime_base_url);
config.realtime.version = RealtimeWsVersion::V1;
}
});
let test = builder.build_with_streaming_server(&api_server).await?;
let started = test
.codex
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
text: "first prompt".to_string(),
text_elements: Vec::new(),
}]))
.await?;
let TurnInputSubmission::Started { turn_id, .. } = started else {
panic!("expected the text input to start a turn");
};
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::AgentMessageContentDelta(_))
})
.await;
test.codex
.submit(Op::RealtimeConversationStart(ConversationStartParams {
client_managed_handoffs: false,
delegation_ack_filler: None,
flush_transcript_tail_on_session_end,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_mode:
codex_protocol::protocol::CodexResponseHandoffMode::Thinking,
backend_reasoning_status: false,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: Some("Give frequent spoken progress updates.".to_string()),
realtime_end_instructions: Some("Return to normal text updates.".to_string()),
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
voice_session_id: None,
transport: None,
version: None,
voice: None,
}))
.await?;
let _ = wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload:
RealtimeEvent::SessionUpdated {
realtime_session_id: session_id,
..
},
}) if session_id == "sess_steer" => Some(()),
_ => None,
})
.await;
test.codex
.submit(Op::RealtimeConversationAudio(ConversationAudioParams {
frame: RealtimeAudioFrame {
data: "AQID".to_string(),
sample_rate: 24000,
num_channels: 1,
samples_per_channel: Some(480),
item_id: None,
},
}))
.await?;
let _ = wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload: RealtimeEvent::HandoffRequested(handoff),
}) if handoff.input_transcript == "steer via realtime" => Some(()),
_ => None,
})
.await;
let mut completion_iter = completions.into_iter();
let first_completion = completion_iter.next().expect("missing first completion");
let second_completion = completion_iter.next().expect("missing second completion");
let third_completion = completion_iter.next().expect("missing third completion");
let _ = gate_completed_tx.send(());
first_completion
.await
.expect("first request did not complete");
timeout(
Duration::from_secs(10),
api_server.wait_for_request_count(/*count*/ 2),
)
.await
.context("steered request did not start")?;
// Closed and the persisted tail must not wait for the in-flight model response.
test.codex.submit(Op::RealtimeConversationClose).await?;
let closed = wait_for_event_match(&test.codex, |event| match event {
EventMsg::RealtimeConversationClosed(closed) => Some(closed.clone()),
_ => None,
})
.await;
assert_eq!(closed.reason.as_deref(), Some("requested"));
if flush_transcript_tail_on_session_end {
let history = test.codex.load_history(/*include_archived*/ false).await?;
assert!(history.items.iter().any(|item| {
matches!(item, RolloutItem::ResponseItem(ResponseItemEnvelope {
item: ResponseItem::Message { role, content, .. }, ..
}) if role == "user" && content.iter().any(|item| matches!(item,
ContentItem::InputText { text } if text.contains("user: final transcript tail"))))
}));
}
if interrupt {
test.codex.submit(Op::Interrupt).await?;
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnAborted(_))
})
.await;
let _ = second_completed_tx.send(());
realtime_server.shutdown().await;
api_server.shutdown().await;
return Ok(());
}
let steered = test
.codex
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
text: "continue in text".to_string(),
text_elements: Vec::new(),
}]))
.await?;
assert_eq!(
steered,
TurnInputSubmission::Steered {
root_turn_id: turn_id.clone(),
turn_id
}
);
let _ = second_completed_tx.send(());
second_completion
.await
.expect("second request did not complete");
third_completion
.await
.expect("third request did not complete");
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
let requests = api_server.requests().await;
assert_eq!(requests.len(), 3);
let first_body: Value = serde_json::from_slice(&requests[0]).expect("parse first request");
let second_body: Value = serde_json::from_slice(&requests[1]).expect("parse second request");
let first_texts = message_input_texts(&first_body, "user");
let second_texts = message_input_texts(&second_body, "user");
assert!(first_texts.iter().any(|text| text == "first prompt"));
assert!(
!first_texts
.iter()
.any(|text| text
== "<realtime_delegation>\n <input>steer via realtime</input>\n <transcript_delta>user: steer via realtime</transcript_delta>\n</realtime_delegation>")
);
assert!(second_texts.iter().any(|text| text == "first prompt"));
assert!(second_texts.iter().any(|text| text
== "<realtime_delegation>\n <input>steer via realtime</input>\n <transcript_delta>user: steer via realtime</transcript_delta>\n</realtime_delegation>"));
let realtime_instructions = requests
.iter()
.map(|request| {
let body: Value = serde_json::from_slice(request).expect("parse request");
message_input_texts(&body, "developer")
.into_iter()
.filter(|text| text.contains("<realtime_conversation>"))
.collect::<Vec<_>>()
})
.collect::<Vec<_>>();
let start_instructions =
"<realtime_conversation>\nGive frequent spoken progress updates.\n</realtime_conversation>";
let end_instructions =
"<realtime_conversation>\nReturn to normal text updates.\n</realtime_conversation>";
assert!(realtime_instructions[0].is_empty());
assert_eq!(
realtime_instructions[1],
vec![start_instructions.to_string()]
);
assert_eq!(
realtime_instructions.last().expect("missing final request"),
&vec![start_instructions.to_string(), end_instructions.to_string()]
);
if flush_transcript_tail_on_session_end {
for request in &requests {
let body: Value = serde_json::from_slice(request)?;
let input = body["input"].as_array().expect("request input array");
if let Some(end) = input
.iter()
.position(|item| item.to_string().contains("Return to normal text updates."))
{
let tail = input
.iter()
.position(|item| item.to_string().contains("user: final transcript tail"))
.expect("realtime_end must not overtake the tail");
assert!(tail < end);
}
}
}
realtime_server.shutdown().await;
api_server.shutdown().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn inbound_handoff_request_starts_turn_and_does_not_block_realtime_audio() -> Result<()> {
skip_if_no_network!(Ok(()));
let (gate_completed_tx, gate_completed_rx) = oneshot::channel();
let first_chunks = vec![
StreamingSseChunk {
gate: None,
body: sse_event(responses::ev_response_created("resp-1")),
},
StreamingSseChunk {
gate: Some(gate_completed_rx),
body: sse_event(responses::ev_completed("resp-1")),
},
];
let (api_server, completions) = start_streaming_sse_server(vec![first_chunks]).await;
let delegated_text = "delegate from handoff request";
let realtime_server = start_websocket_server(vec![vec![vec![
json!({
"type": "session.updated",
"session": { "id": "sess_handoff_request", "instructions": "backend prompt" }
}),
json!({
"type": "conversation.input_transcript.delta",
"delta": delegated_text
}),
json!({
"type": "conversation.handoff.requested",
"handoff_id": "handoff_audio",
"item_id": "item_audio",
"input_transcript": delegated_text
}),
json!({
"type": "conversation.output_audio.delta",
"delta": "AQID",
"sample_rate": 24000,
"channels": 1
}),
]]])
.await;
let mut builder = test_codex().with_model("gpt-5.4").with_config({
let realtime_base_url = realtime_server.uri().to_string();
move |config| {
config.experimental_realtime_ws_base_url = Some(realtime_base_url);
config.realtime.version = RealtimeWsVersion::V1;
}
});
let test = builder.build_with_streaming_server(&api_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,
backend_reasoning_status: false,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
voice_session_id: None,
transport: None,
version: None,
voice: None,
}))
.await?;
let _ = wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload:
RealtimeEvent::SessionUpdated {
realtime_session_id: session_id,
..
},
}) if session_id == "sess_handoff_request" => Some(()),
_ => None,
})
.await;
let _ = wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload: RealtimeEvent::HandoffRequested(handoff),
}) => (handoff.handoff_id == "handoff_audio" && handoff.input_transcript == delegated_text)
.then_some(()),
_ => None,
})
.await;
let audio_out = tokio::time::timeout(
Duration::from_millis(500),
wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload: RealtimeEvent::AudioOut(frame),
}) => Some(frame.clone()),
_ => None,
}),
)
.await
.expect("timed out waiting for realtime audio after handoff request");
assert_eq!(audio_out.data, "AQID");
let completion = completions
.into_iter()
.next()
.expect("missing delegated turn completion");
let _ = gate_completed_tx.send(());
completion
.await
.expect("delegated turn request did not complete");
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
let requests = api_server.requests().await;
assert_eq!(requests.len(), 1);
let first_body: Value = serde_json::from_slice(&requests[0]).expect("parse first request");
let first_texts = message_input_texts(&first_body, "user");
let expected_text = format!(
"<realtime_delegation>\n <input>{delegated_text}</input>\n <transcript_delta>user: {delegated_text}</transcript_delta>\n</realtime_delegation>"
);
assert!(first_texts.iter().any(|text| text == &expected_text));
realtime_server.shutdown().await;
api_server.shutdown().await;
Ok(())
}