Add opt-in reasoning status for realtime V3 delegations (#47377)

## What changed

Add `backend_reasoning_status`, disabled by default, to relay public reasoning summaries during active, server-managed realtime V3 delegations.

Use the last non-empty summary from each completed reasoning item, truncate it to 256 bytes at a character boundary, and append it as `[STATUS]` context on the `commentary` channel. Discard queued updates if their delegation is no longer active.

## Testing

Add an integration test covering enabled and disabled behavior, selection of the latest non-empty summary, exclusion of raw reasoning and partial summaries, and delivery without triggering `response.create` or changing the model's reasoning-summary setting.

GitOrigin-RevId: 75ce87dbbbab15a6d9d0ad0b23737738837192fd
This commit is contained in:
guinness-oai
2026-09-22 22:20:00 +00:00
committed by copyberry
parent 418199f6ad
commit 90e653014a
20 changed files with 303 additions and 9 deletions
@@ -4009,6 +4009,7 @@ mod tests {
codex_responses_as_items: None,
codex_response_item_prefix: None,
codex_response_handoff_mode: Some(CodexResponseHandoffMode::BemTags),
backend_reasoning_status: false,
codex_response_handoff_channel_prefixes: Some(std::collections::BTreeMap::from([
("analysis".to_string(), vec!["[THINKING]".to_string()]),
(
@@ -4095,6 +4096,7 @@ mod tests {
codex_responses_as_items: None,
codex_response_item_prefix: None,
codex_response_handoff_mode: None,
backend_reasoning_status: false,
codex_response_handoff_channel_prefixes: None,
thread_id: "thr_123".to_string(),
model: None,
@@ -4147,6 +4149,7 @@ mod tests {
codex_responses_as_items: None,
codex_response_item_prefix: None,
codex_response_handoff_mode: None,
backend_reasoning_status: false,
codex_response_handoff_channel_prefixes: None,
thread_id: "thr_123".to_string(),
model: None,
@@ -4399,6 +4402,7 @@ mod tests {
codex_responses_as_items: None,
codex_response_item_prefix: None,
codex_response_handoff_mode: None,
backend_reasoning_status: false,
codex_response_handoff_channel_prefixes: None,
thread_id: "thr_123".to_string(),
model: None,
@@ -220,6 +220,9 @@ pub struct ThreadRealtimeStartParams {
/// default to `thinking`. Realtime V1 and V2 ignore this setting.
#[ts(optional = nullable)]
pub codex_response_handoff_mode: Option<CodexResponseHandoffMode>,
/// Relays public reasoning summaries as quiet context for realtime V3 delegations. Defaults to false.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub backend_reasoning_status: bool,
/// Overrides BEM channel prefixes by `analysis`, `commentary`, or `final`.
/// Omitted channels retain their default uppercase bracketed prefixes.
#[ts(optional = nullable)]
@@ -1238,6 +1238,7 @@ impl TurnRequestProcessor {
codex_responses_as_items: params.codex_responses_as_items.unwrap_or(false),
codex_response_item_prefix: params.codex_response_item_prefix,
codex_response_handoff_mode: params.codex_response_handoff_mode.unwrap_or_default(),
backend_reasoning_status: params.backend_reasoning_status,
codex_response_handoff_channel_prefixes: params
.codex_response_handoff_channel_prefixes,
model: params.model,
@@ -99,6 +99,7 @@ async fn realtime_conversation_start_requires_experimental_api_capability() -> R
codex_responses_as_items: None,
codex_response_item_prefix: None,
codex_response_handoff_mode: None,
backend_reasoning_status: false,
codex_response_handoff_channel_prefixes: None,
thread_id: "thr_123".to_string(),
model: None,
@@ -240,6 +241,7 @@ async fn realtime_webrtc_start_requires_experimental_api_capability() -> Result<
codex_responses_as_items: None,
codex_response_item_prefix: None,
codex_response_handoff_mode: None,
backend_reasoning_status: false,
codex_response_handoff_channel_prefixes: None,
thread_id: "thr_123".to_string(),
model: None,
@@ -393,6 +393,7 @@ impl RealtimeE2eHarness {
.unwrap_or(false)
.then(|| RESPONSE_ITEM_PREFIX.to_string()),
codex_response_handoff_mode,
backend_reasoning_status: false,
codex_response_handoff_channel_prefixes: None,
codex_responses_as_items,
model: None,
@@ -454,6 +455,7 @@ impl RealtimeE2eHarness {
.unwrap_or(false)
.then(|| RESPONSE_ITEM_PREFIX.to_string()),
codex_response_handoff_mode: None,
backend_reasoning_status: false,
codex_response_handoff_channel_prefixes: None,
codex_responses_as_items,
model: None,
@@ -491,6 +493,7 @@ impl RealtimeE2eHarness {
flush_transcript_tail_on_session_end: None,
codex_response_item_prefix: None,
codex_response_handoff_mode,
backend_reasoning_status: false,
codex_response_handoff_channel_prefixes,
codex_responses_as_items: None,
model: None,
@@ -709,6 +712,7 @@ async fn realtime_conversation_streams_timeline_items() -> Result<()> {
codex_responses_as_items: None,
codex_response_item_prefix: None,
codex_response_handoff_mode: None,
backend_reasoning_status: false,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
@@ -905,6 +909,7 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> {
codex_responses_as_items: None,
codex_response_item_prefix: None,
codex_response_handoff_mode: None,
backend_reasoning_status: false,
codex_response_handoff_channel_prefixes: None,
thread_id: thread_start.thread.id.clone(),
model: Some("realtime-treatment-model".to_string()),
@@ -1474,6 +1479,7 @@ async fn realtime_start_can_skip_startup_context() -> Result<()> {
codex_responses_as_items: None,
codex_response_item_prefix: None,
codex_response_handoff_mode: None,
backend_reasoning_status: false,
codex_response_handoff_channel_prefixes: None,
thread_id: thread_start.thread.id.clone(),
model: None,
@@ -1571,6 +1577,7 @@ async fn realtime_text_output_modality_requests_text_output_and_final_transcript
codex_responses_as_items: None,
codex_response_item_prefix: None,
codex_response_handoff_mode: None,
backend_reasoning_status: false,
codex_response_handoff_channel_prefixes: None,
thread_id: thread_start.thread.id.clone(),
model: None,
@@ -1748,6 +1755,7 @@ async fn realtime_conversation_stop_emits_closed_notification() -> Result<()> {
codex_responses_as_items: None,
codex_response_item_prefix: None,
codex_response_handoff_mode: None,
backend_reasoning_status: false,
codex_response_handoff_channel_prefixes: None,
thread_id: thread_start.thread.id.clone(),
model: None,
@@ -1822,6 +1830,7 @@ async fn realtime_mode_uses_client_instructions_on_entry_and_exit() -> Result<()
codex_responses_as_items: None,
codex_response_item_prefix: None,
codex_response_handoff_mode: None,
backend_reasoning_status: false,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
@@ -2106,6 +2115,7 @@ async fn realtime_webrtc_start_emits_sdp_notification() -> Result<()> {
codex_responses_as_items: None,
codex_response_item_prefix: None,
codex_response_handoff_mode: None,
backend_reasoning_status: false,
codex_response_handoff_channel_prefixes: None,
thread_id: thread_id.clone(),
model: None,
@@ -2324,6 +2334,7 @@ async fn existing_call_attaches_without_reinitializing_the_client_session(
flush_transcript_tail_on_session_end: None,
codex_response_item_prefix: None,
codex_response_handoff_mode: None,
backend_reasoning_status: false,
codex_response_handoff_channel_prefixes: None,
codex_responses_as_items: None,
model: None,
@@ -2412,6 +2423,7 @@ async fn existing_call_rejects_client_owned_session_configuration(option: &str)
flush_transcript_tail_on_session_end: None,
codex_response_item_prefix: None,
codex_response_handoff_mode: None,
backend_reasoning_status: false,
codex_response_handoff_channel_prefixes: None,
codex_responses_as_items: None,
model: None,
@@ -3931,6 +3943,7 @@ async fn realtime_webrtc_start_surfaces_backend_error() -> Result<()> {
codex_responses_as_items: None,
codex_response_item_prefix: None,
codex_response_handoff_mode: None,
backend_reasoning_status: false,
codex_response_handoff_channel_prefixes: None,
thread_id: thread_start.thread.id,
model: None,
@@ -100,6 +100,7 @@ const OUTPUT_EVENTS_QUEUE_CAPACITY: usize = 256;
const REALTIME_THREAD_SOURCE_MAX_BYTES: usize = 256;
const REALTIME_STARTUP_CONTEXT_TOKEN_BUDGET: usize = 5_300;
const REALTIME_ASSISTANT_OUTPUT_TOKEN_BUDGET: usize = 1_000;
const REALTIME_REASONING_STATUS_MAX_BYTES: usize = 256;
const REALTIME_INITIAL_ITEMS_MAX_COUNT: usize = 128;
const REALTIME_INITIAL_ITEMS_MAX_TOKENS: usize = 8_192;
const REALTIME_MODE_INSTRUCTIONS_MAX_TOKENS: usize = 8_192;
@@ -189,6 +190,7 @@ struct RealtimeHandoffState {
codex_responses_as_items: bool,
codex_response_item_prefix: Option<String>,
codex_response_handoff_mode: CodexResponseHandoffMode,
backend_reasoning_status: bool,
codex_response_handoff_channel_prefixes: Arc<BTreeMap<String, Vec<String>>>,
session_kind: RealtimeSessionKind,
event_parser: RealtimeEventParser,
@@ -361,6 +363,10 @@ fn take_last_bytes_at_char_boundary(text: &str, max_bytes: usize) -> &str {
#[derive(Clone, Debug, PartialEq, Eq)]
enum RealtimeOutbound {
QuietReasoningStatus {
handoff_id: String,
text: String,
},
StandaloneHandoff {
text: String,
phase: Option<MessagePhase>,
@@ -516,6 +522,7 @@ struct RealtimeStart {
codex_responses_as_items: bool,
codex_response_item_prefix: Option<String>,
codex_response_handoff_mode: CodexResponseHandoffMode,
backend_reasoning_status: bool,
codex_response_handoff_channel_prefixes: Option<BTreeMap<String, Vec<String>>>,
realtime_call_api_provider: Option<ApiProvider>,
session_config: RealtimeSessionConfig,
@@ -610,6 +617,7 @@ impl RealtimeConversationManager {
codex_responses_as_items,
codex_response_item_prefix,
codex_response_handoff_mode,
backend_reasoning_status,
codex_response_handoff_channel_prefixes,
realtime_call_api_provider,
session_config,
@@ -644,6 +652,7 @@ impl RealtimeConversationManager {
codex_responses_as_items,
codex_response_item_prefix,
codex_response_handoff_mode,
backend_reasoning_status,
codex_response_handoff_channel_prefixes: Arc::new(
codex_response_handoff_channel_prefixes.unwrap_or_default(),
),
@@ -840,6 +849,34 @@ impl RealtimeConversationManager {
Ok(())
}
pub(crate) async fn send_reasoning_status(&self, text: &str) -> CodexResult<()> {
let handoff = self
.state
.lock()
.await
.as_ref()
.map(|state| state.handoff.clone());
let Some(handoff) = handoff.filter(|handoff| {
handoff.backend_reasoning_status
&& !handoff.client_managed_handoffs
&& handoff.event_parser == RealtimeEventParser::FramelessBidi
}) else {
return Ok(());
};
let Some(handoff_id) = handoff.stream.lock().await.active_handoff.clone() else {
return Ok(());
};
let text = take_bytes_at_char_boundary(text, REALTIME_REASONING_STATUS_MAX_BYTES);
handoff
.output_tx
.send(RealtimeOutbound::QuietReasoningStatus {
handoff_id,
text: format!("[STATUS] {text}"),
})
.await
.map_err(|_| CodexErr::InvalidRequest("conversation is not running".to_string()))
}
pub(crate) async fn handoff_out(
&self,
output_text: String,
@@ -1215,6 +1252,7 @@ struct PreparedRealtimeConversationStart {
codex_responses_as_items: bool,
codex_response_item_prefix: Option<String>,
codex_response_handoff_mode: CodexResponseHandoffMode,
backend_reasoning_status: bool,
codex_response_handoff_channel_prefixes: Option<BTreeMap<String, Vec<String>>>,
realtime_start_instructions: Option<String>,
realtime_end_instructions: Option<String>,
@@ -1357,6 +1395,7 @@ async fn prepare_realtime_start(
codex_responses_as_items: params.codex_responses_as_items,
codex_response_item_prefix: params.codex_response_item_prefix,
codex_response_handoff_mode: params.codex_response_handoff_mode,
backend_reasoning_status: params.backend_reasoning_status,
codex_response_handoff_channel_prefixes: params.codex_response_handoff_channel_prefixes,
realtime_start_instructions: params.realtime_start_instructions,
realtime_end_instructions: params.realtime_end_instructions,
@@ -1580,6 +1619,7 @@ async fn handle_start_inner(
codex_responses_as_items,
codex_response_item_prefix,
codex_response_handoff_mode,
backend_reasoning_status,
codex_response_handoff_channel_prefixes,
realtime_start_instructions,
realtime_end_instructions,
@@ -1609,6 +1649,7 @@ async fn handle_start_inner(
codex_responses_as_items,
codex_response_item_prefix,
codex_response_handoff_mode,
backend_reasoning_status,
codex_response_handoff_channel_prefixes,
realtime_call_api_provider,
session_config,
@@ -2190,6 +2231,7 @@ async fn handle_handoff_output(
let handoff_output = handoff_output.context("handoff output channel closed")?;
let result = match event_parser {
RealtimeEventParser::V1 => match handoff_output {
RealtimeOutbound::QuietReasoningStatus { .. } => Ok(()),
RealtimeOutbound::StandaloneHandoff { text, phase: _ } => {
writer
.send_standalone_handoff(STANDALONE_HANDOFF_ID.to_string(), text)
@@ -2231,6 +2273,18 @@ async fn handle_handoff_output(
RealtimeOutbound::HandoffCompleteAck { .. } => Ok(()),
},
RealtimeEventParser::FramelessBidi => match handoff_output {
RealtimeOutbound::QuietReasoningStatus { handoff_id, text } => {
if handoff_state.stream.lock().await.active_handoff.as_deref() != Some(&handoff_id)
{
return Ok(());
}
// An omitted channel defaults to speakable on this wire protocol.
writer
.clone()
.with_context_append_channel(RealtimeContextAppendChannel::Commentary)
.send_conversation_handoff_append(handoff_id, text)
.await
}
RealtimeOutbound::StandaloneHandoff { text, phase } => {
v3_output_writer(
writer,
@@ -2298,6 +2352,7 @@ async fn handle_handoff_output(
RealtimeOutbound::HandoffCompleteAck { .. } => Ok(()),
},
RealtimeEventParser::RealtimeV2 => match handoff_output {
RealtimeOutbound::QuietReasoningStatus { .. } => Ok(()),
RealtimeOutbound::StandaloneHandoff { text, phase: _ } => {
if let Err(err) = writer
.send_conversation_item_create(text, ConversationTextRole::User)
@@ -214,6 +214,7 @@ async fn clears_active_handoff_explicitly() {
client_managed_handoffs: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
backend_reasoning_status: false,
codex_response_handoff_mode: CodexResponseHandoffMode::Thinking,
codex_response_handoff_channel_prefixes: Arc::new(BTreeMap::new()),
session_kind: RealtimeSessionKind::V1,
+10 -3
View File
@@ -278,6 +278,7 @@ use self::session::SessionSettingsCommit;
pub(crate) use self::session::SessionSettingsUpdate;
#[cfg(test)]
use self::turn::AssistantMessageStreamParsers;
use self::turn::RealtimeEventText;
use self::turn::agent_message_text;
#[cfg(test)]
use self::turn::collect_explicit_app_ids_from_skill_items;
@@ -2491,10 +2492,16 @@ impl Session {
}
_ => {}
}
let Some((text, phase)) = realtime_text_for_event(msg) else {
return;
let result = match realtime_text_for_event(msg) {
Some(RealtimeEventText::Handoff(text, phase)) => {
self.conversation.handoff_out(text, phase).await
}
Some(RealtimeEventText::QuietReasoning(text)) => {
self.conversation.send_reasoning_status(&text).await
}
None => return,
};
if let Err(err) = self.conversation.handoff_out(text, phase).await {
if let Err(err) = result {
debug!("failed to mirror event text to realtime conversation: {err}");
}
}
+27 -5
View File
@@ -2000,7 +2000,13 @@ pub(super) fn agent_message_text(item: &codex_protocol::items::AgentMessageItem)
.collect()
}
pub(super) fn realtime_text_for_event(msg: &EventMsg) -> Option<(String, Option<MessagePhase>)> {
#[derive(Debug, PartialEq)]
pub(super) enum RealtimeEventText {
Handoff(String, Option<MessagePhase>),
QuietReasoning(String),
}
pub(super) fn realtime_text_for_event(msg: &EventMsg) -> Option<RealtimeEventText> {
match msg {
EventMsg::ElicitationRequest(request)
if matches!(
@@ -2008,11 +2014,27 @@ pub(super) fn realtime_text_for_event(msg: &EventMsg) -> Option<(String, Option<
codex_protocol::approvals::ElicitationRequest::UserVerification { .. }
) =>
{
Some((UserVerificationNotice.render(), None))
Some(RealtimeEventText::Handoff(
UserVerificationNotice.render(),
None,
))
}
EventMsg::AgentMessage(event) => Some((event.message.clone(), event.phase.clone())),
EventMsg::AgentMessage(event) => Some(RealtimeEventText::Handoff(
event.message.clone(),
event.phase.clone(),
)),
EventMsg::ItemCompleted(event) => match &event.item {
TurnItem::AgentMessage(item) => Some((agent_message_text(item), item.phase.clone())),
TurnItem::AgentMessage(item) => Some(RealtimeEventText::Handoff(
agent_message_text(item),
item.phase.clone(),
)),
TurnItem::Reasoning(item) => item
.summary_text
.iter()
.rev()
.map(|summary| summary.trim())
.find(|summary| !summary.is_empty())
.map(|summary| RealtimeEventText::QuietReasoning(summary.to_owned())),
_ => None,
},
EventMsg::ExecApprovalRequest(_)
@@ -2030,7 +2052,7 @@ pub(super) fn realtime_text_for_event(msg: &EventMsg) -> Option<(String, Option<
};
serde_json::to_string(msg)
.ok()
.map(|request| (format!("{message}\n\n{request}"), None))
.map(|request| RealtimeEventText::Handoff(format!("{message}\n\n{request}"), None))
}
EventMsg::Error(_)
| EventMsg::Warning(_)
+1 -1
View File
@@ -103,7 +103,7 @@ fn realtime_user_verification_notice_excludes_request_payload() {
});
assert_eq!(
realtime_text_for_event(&event),
Some((
Some(RealtimeEventText::Handoff(
"<user_verification_notice>User verification is required. Please respond in the app.</user_verification_notice>".to_string(),
None,
)),
@@ -104,6 +104,7 @@ async fn start_realtime_conversation(codex: &codex_core::CodexThread) -> Result<
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,
@@ -316,6 +316,7 @@ async fn conversation_start_audio_text_close_round_trip() -> Result<()> {
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,
@@ -497,6 +498,7 @@ async fn conversation_records_history_without_an_event_observer(
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,
@@ -607,6 +609,7 @@ async fn conversation_start_defaults_to_v2_and_gpt_realtime_1_5() -> Result<()>
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,
@@ -702,6 +705,7 @@ async fn conversation_websocket_transports_send_codex_headers_without_creating_a
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,
@@ -872,6 +876,7 @@ async fn conversation_webrtc_frameless_chatgpt_sends_codex_headers_to_backend(
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,
@@ -1033,6 +1038,7 @@ async fn conversation_webrtc_start_posts_generated_session() -> Result<()> {
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,
@@ -1397,6 +1403,7 @@ async fn conversation_webrtc_live_reconnects_sideband_after_unclean_disconnect(
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,
@@ -1552,6 +1559,7 @@ async fn conversation_webrtc_start_uses_avas_query() -> Result<()> {
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,
@@ -1657,6 +1665,7 @@ async fn conversation_webrtc_default_v1_ignores_configured_v2_voice() -> Result<
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,
@@ -1724,6 +1733,7 @@ async fn conversation_webrtc_default_v1_rejects_explicit_v2_voice() -> Result<()
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,
@@ -1801,6 +1811,7 @@ async fn conversation_webrtc_start_uses_configured_call_base_url_for_avas() -> R
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,
@@ -1915,6 +1926,7 @@ async fn conversation_webrtc_close_while_sideband_connecting_drops_pending_join(
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,
@@ -2022,6 +2034,7 @@ async fn conversation_webrtc_sideband_connect_failure_closes_with_error() -> Res
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,
@@ -2122,6 +2135,7 @@ async fn conversation_start_uses_openai_env_key_fallback_with_chatgpt_auth() ->
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,
@@ -2220,6 +2234,7 @@ async fn assert_transport_close_tail_flush(
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,
@@ -2343,6 +2358,7 @@ async fn conversation_start_preflight_failure_emits_realtime_error_only() -> Res
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,
@@ -2401,6 +2417,7 @@ async fn conversation_start_connect_failure_emits_realtime_error_only() -> Resul
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,
@@ -2507,6 +2524,7 @@ async fn conversation_second_start_replaces_runtime() -> Result<()> {
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,
@@ -2544,6 +2562,7 @@ async fn conversation_second_start_replaces_runtime() -> Result<()> {
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,
@@ -2652,6 +2671,7 @@ async fn conversation_uses_experimental_realtime_ws_base_url_override() -> Resul
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,
@@ -2728,6 +2748,7 @@ async fn conversation_uses_default_realtime_backend_prompt() -> Result<()> {
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,
@@ -2812,6 +2833,7 @@ async fn conversation_uses_empty_instructions_for_null_or_empty_prompt() -> Resu
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,
@@ -2889,6 +2911,7 @@ async fn conversation_uses_explicit_start_voice() -> Result<()> {
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,
@@ -2958,6 +2981,7 @@ async fn conversation_uses_configured_realtime_voice() -> Result<()> {
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,
@@ -3015,6 +3039,7 @@ async fn conversation_rejects_voice_for_wrong_realtime_version() -> Result<()> {
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,
@@ -3073,6 +3098,7 @@ async fn conversation_uses_experimental_realtime_ws_backend_prompt_override() ->
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,
@@ -3157,6 +3183,7 @@ async fn conversation_uses_experimental_realtime_ws_startup_context_override() -
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,
@@ -3235,6 +3262,7 @@ async fn conversation_disables_realtime_startup_context_with_empty_override() ->
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,
@@ -3306,6 +3334,7 @@ async fn conversation_start_injects_startup_context_from_thread_history() -> Res
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,
@@ -3433,6 +3462,7 @@ async fn conversation_startup_context_current_thread_selects_many_turns_by_budge
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,
@@ -3552,6 +3582,7 @@ async fn conversation_startup_context_falls_back_to_workspace_map() -> Result<()
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,
@@ -3623,6 +3654,7 @@ async fn conversation_startup_context_is_truncated_and_sent_once_per_start() ->
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,
@@ -3727,6 +3759,7 @@ async fn conversation_user_text_turn_is_not_sent_to_realtime(ephemeral: bool) ->
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,
@@ -3867,6 +3900,7 @@ async fn realtime_v2_noop_tool_call_returns_empty_function_output_without_respon
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,
@@ -3977,6 +4011,7 @@ async fn conversation_mirrors_assistant_message_text_to_realtime_handoff() -> Re
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,
@@ -4051,6 +4086,137 @@ async fn conversation_mirrors_assistant_message_text_to_realtime_handoff() -> Re
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,
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(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn conversation_flushes_assistant_deltas_every_200ms_for_v3_handoff() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -4148,6 +4314,7 @@ async fn conversation_flushes_assistant_deltas_every_200ms_for_v3_handoff() -> R
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(),
@@ -4327,6 +4494,7 @@ async fn conversation_handoff_persists_across_item_done_until_turn_complete() ->
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,
@@ -4492,6 +4660,7 @@ async fn inbound_handoff_request_starts_turn_and_promotes_its_artifact() -> Resu
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,
@@ -4656,6 +4825,7 @@ async fn inbound_handoff_request_uses_active_transcript() -> Result<()> {
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,
@@ -4770,6 +4940,7 @@ async fn inbound_handoff_request_sends_transcript_delta_after_each_handoff() ->
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,
@@ -4903,6 +5074,7 @@ async fn conversation_close_routes_only_remaining_transcript_tail_once() -> Resu
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,
@@ -4997,6 +5169,7 @@ async fn inbound_conversation_item_does_not_start_turn_and_still_forwards_audio(
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,
@@ -5131,6 +5304,7 @@ async fn delegated_turn_user_role_echo_does_not_redelegate_and_still_forwards_au
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,
@@ -5295,6 +5469,7 @@ async fn inbound_handoff_request_does_not_block_realtime_event_forwarding() -> R
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,
@@ -5448,6 +5623,7 @@ async fn inbound_handoff_request_steers_active_turn() -> Result<()> {
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,
@@ -5606,6 +5782,7 @@ async fn inbound_handoff_request_starts_turn_and_does_not_block_realtime_audio()
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,
@@ -204,6 +204,7 @@ fn start_params(version: RealtimeConversationVersion) -> ConversationStartParams
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_mode: CodexResponseHandoffMode::Thinking,
backend_reasoning_status: false,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
@@ -97,6 +97,7 @@ async fn misalignment_retires_late_voice_handoff_before_it_starts_a_turn() -> Re
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,
@@ -66,6 +66,7 @@ async fn existing_call_uses_selected_endpoint_and_runtime_auth(
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_mode: CodexResponseHandoffMode::Thinking,
backend_reasoning_status: false,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
@@ -140,6 +140,7 @@ async fn webrtc_sideband_honors_configured_system_proxy() -> Result<()> {
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_mode: CodexResponseHandoffMode::Thinking,
backend_reasoning_status: false,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
@@ -269,6 +269,7 @@ async fn host_drain_closes_realtime_after_handoff_error() -> anyhow::Result<()>
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: codex_protocol::protocol::RealtimeOutputModality::Audio,
+2
View File
@@ -215,6 +215,8 @@ pub struct ConversationStartParams {
/// Selects how automatic Codex handoffs are routed in Frameless Bidi sessions.
/// Realtime V1 and V2 ignore this setting.
pub codex_response_handoff_mode: CodexResponseHandoffMode,
/// Relays public reasoning summaries as quiet context for realtime V3 delegations.
pub backend_reasoning_status: bool,
/// Optional client-selected BEM prefixes keyed by `analysis`, `commentary`, and `final`.
pub codex_response_handoff_channel_prefixes: Option<BTreeMap<String, Vec<String>>>,
/// Overrides the configured realtime model for this session only.
@@ -37,6 +37,7 @@ impl AppServerSession {
codex_responses_as_items: None,
codex_response_item_prefix: None,
codex_response_handoff_mode: None,
backend_reasoning_status: false,
codex_response_handoff_channel_prefixes: None,
model,
output_modality: RealtimeOutputModality::Audio,