Support configurable realtime BEM channel prefixes (#34816)

## What changed

- Route realtime V3 `bemTags` handoffs using `[ANALYSIS]`, `[COMMENTARY]`, and `[FINAL]` prefixes by default.
- Add `codexResponseHandoffChannelPrefixes` to realtime start requests so clients can replace the accepted prefixes for individual channels. Each channel may accept multiple prefixes, and omitted channels retain their defaults.
- Apply the configured prefixes to both complete and streamed handoff messages, without treating empty prefixes as matches.

## Testing

- Cover request deserialization, default and custom routing, partial streamed prefixes, empty-prefix handling, and end-to-end V3 handoffs.

GitOrigin-RevId: d0aef50aa71362a5beef2c6f6d28b8633da4e529
This commit is contained in:
jiayuhuang-openai
2026-07-22 19:43:35 +00:00
committed by copyberry
parent 08ae0fc0ce
commit 4ebd976312
15 changed files with 325 additions and 73 deletions
@@ -3482,6 +3482,14 @@ mod tests {
codex_responses_as_items: None,
codex_response_item_prefix: None,
codex_response_handoff_mode: Some(CodexResponseHandoffMode::BemTags),
codex_response_handoff_channel_prefixes: Some(std::collections::BTreeMap::from([
("analysis".to_string(), vec!["[THINKING]".to_string()]),
(
"commentary".to_string(),
vec!["[PROGRESS]".to_string(), "[UPDATE]".to_string()],
),
("final".to_string(), vec!["[DONE]".to_string()]),
])),
thread_id: "thr_123".to_string(),
model: Some("realtime-treatment-model".to_string()),
output_modality: RealtimeOutputModality::Audio,
@@ -3514,6 +3522,11 @@ mod tests {
"codexResponsesAsItems": null,
"codexResponseItemPrefix": null,
"codexResponseHandoffMode": "bemTags",
"codexResponseHandoffChannelPrefixes": {
"analysis": ["[THINKING]"],
"commentary": ["[PROGRESS]", "[UPDATE]"],
"final": ["[DONE]"]
},
"model": "realtime-treatment-model",
"outputModality": "audio",
"includeStartupContext": false,
@@ -3549,6 +3562,7 @@ mod tests {
codex_responses_as_items: None,
codex_response_item_prefix: None,
codex_response_handoff_mode: None,
codex_response_handoff_channel_prefixes: None,
thread_id: "thr_123".to_string(),
model: None,
output_modality: RealtimeOutputModality::Audio,
@@ -3572,6 +3586,7 @@ mod tests {
"codexResponsesAsItems": null,
"codexResponseItemPrefix": null,
"codexResponseHandoffMode": null,
"codexResponseHandoffChannelPrefixes": null,
"model": null,
"outputModality": "audio",
"includeStartupContext": null,
@@ -3593,6 +3608,7 @@ mod tests {
codex_responses_as_items: None,
codex_response_item_prefix: None,
codex_response_handoff_mode: None,
codex_response_handoff_channel_prefixes: None,
thread_id: "thr_123".to_string(),
model: None,
output_modality: RealtimeOutputModality::Audio,
@@ -3616,6 +3632,7 @@ mod tests {
"codexResponsesAsItems": null,
"codexResponseItemPrefix": null,
"codexResponseHandoffMode": null,
"codexResponseHandoffChannelPrefixes": null,
"model": null,
"outputModality": "audio",
"includeStartupContext": null,
@@ -3837,6 +3854,7 @@ mod tests {
codex_responses_as_items: None,
codex_response_item_prefix: None,
codex_response_handoff_mode: None,
codex_response_handoff_channel_prefixes: None,
thread_id: "thr_123".to_string(),
model: None,
output_modality: RealtimeOutputModality::Audio,
@@ -9,6 +9,7 @@ use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value as JsonValue;
use std::collections::BTreeMap;
use ts_rs::TS;
/// EXPERIMENTAL - thread realtime audio chunk.
@@ -87,6 +88,10 @@ pub struct ThreadRealtimeStartParams {
/// default to `thinking`. Realtime V1 and V2 ignore this setting.
#[ts(optional = nullable)]
pub codex_response_handoff_mode: Option<CodexResponseHandoffMode>,
/// Overrides BEM channel prefixes by `analysis`, `commentary`, or `final`.
/// Omitted channels retain their default uppercase bracketed prefixes.
#[ts(optional = nullable)]
pub codex_response_handoff_channel_prefixes: Option<BTreeMap<String, Vec<String>>>,
/// Overrides the configured realtime model for this session only.
#[ts(optional = nullable)]
pub model: Option<String>,
@@ -4462,3 +4462,28 @@ fn realtime_start_omitted_initial_items_remain_none() {
assert_eq!(params.initial_items, None);
}
#[test]
fn realtime_start_deserializes_client_handoff_channel_prefixes() {
let params = serde_json::from_value::<ThreadRealtimeStartParams>(json!({
"threadId": "thread_123",
"outputModality": "audio",
"codexResponseHandoffChannelPrefixes": {
"analysis": ["[THINKING]"],
"commentary": ["[PROGRESS]", "[UPDATE]"],
"final": ["[DONE]"]
}
}))
.expect("params should deserialize");
assert_eq!(
params.codex_response_handoff_channel_prefixes,
Some(BTreeMap::from([
("analysis".to_string(), vec!["[THINKING]".to_string()]),
(
"commentary".to_string(),
vec!["[PROGRESS]".to_string(), "[UPDATE]".to_string()],
),
("final".to_string(), vec!["[DONE]".to_string()]),
]))
);
}
+6 -1
View File
@@ -983,7 +983,12 @@ for every automatic response. Pass `"commentary"` to route every response to
commentary, or `"bemTags"` to route BEM commentary tags to `commentary`, final
tags to `speakable`, and analysis tags to `commentary`. Unparsable BEM output
falls back to `speakable`. BEM routing reads the raw envelope and preserves it
in the appended text for the frontend model. This
in the appended text for the frontend model. With `"bemTags"`, clients may pass
`codexResponseHandoffChannelPrefixes` to override the accepted prefixes for
individual channels, for example
`{"analysis":["[THINKING]"],"commentary":["[PROGRESS]","[UPDATE]"],"final":["[DONE]"]}`.
Omitted channels keep the hard-coded `[ANALYSIS]`, `[COMMENTARY]`, and `[FINAL]`
defaults. This
setting has no effect on V1 or V2. V3 handoffs never prepend the legacy `"Agent Final Message"` label. Older
clients may continue to send the removed `codexResponseHandoffPrefix` field; the
server ignores unknown request fields.
@@ -1078,6 +1078,8 @@ 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(),
codex_response_handoff_channel_prefixes: params
.codex_response_handoff_channel_prefixes,
model: params.model,
output_modality: params.output_modality,
include_startup_context: params.include_startup_context.unwrap_or(true),
@@ -94,6 +94,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,
codex_response_handoff_channel_prefixes: None,
thread_id: "thr_123".to_string(),
model: None,
output_modality: RealtimeOutputModality::Audio,
@@ -224,6 +225,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,
codex_response_handoff_channel_prefixes: None,
thread_id: "thr_123".to_string(),
model: None,
output_modality: RealtimeOutputModality::Audio,
@@ -61,6 +61,7 @@ use pretty_assertions::assert_eq;
use serde::de::DeserializeOwned;
use serde_json::Value;
use serde_json::json;
use std::collections::BTreeMap;
use std::path::Path;
use std::sync::Arc;
use std::sync::Mutex;
@@ -367,6 +368,7 @@ impl RealtimeE2eHarness {
.unwrap_or(false)
.then(|| RESPONSE_ITEM_PREFIX.to_string()),
codex_response_handoff_mode,
codex_response_handoff_channel_prefixes: None,
codex_responses_as_items,
model: None,
output_modality: RealtimeOutputModality::Audio,
@@ -424,6 +426,7 @@ impl RealtimeE2eHarness {
.unwrap_or(false)
.then(|| RESPONSE_ITEM_PREFIX.to_string()),
codex_response_handoff_mode: None,
codex_response_handoff_channel_prefixes: None,
codex_responses_as_items,
model: None,
output_modality: RealtimeOutputModality::Audio,
@@ -446,6 +449,7 @@ impl RealtimeE2eHarness {
async fn start_frameless_bidi_realtime(
&mut self,
codex_response_handoff_mode: Option<CodexResponseHandoffMode>,
codex_response_handoff_channel_prefixes: Option<BTreeMap<String, Vec<String>>>,
initial_items: Option<Vec<ThreadRealtimeInitialItem>>,
) -> Result<ThreadRealtimeStartedNotification> {
let start_request_id = self
@@ -456,6 +460,7 @@ impl RealtimeE2eHarness {
flush_transcript_tail_on_session_end: None,
codex_response_item_prefix: None,
codex_response_handoff_mode,
codex_response_handoff_channel_prefixes,
codex_responses_as_items: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
@@ -704,6 +709,7 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> {
codex_responses_as_items: None,
codex_response_item_prefix: None,
codex_response_handoff_mode: None,
codex_response_handoff_channel_prefixes: None,
thread_id: thread_start.thread.id.clone(),
model: Some("realtime-treatment-model".to_string()),
output_modality: RealtimeOutputModality::Audio,
@@ -982,6 +988,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,
codex_response_handoff_channel_prefixes: None,
thread_id: thread_start.thread.id.clone(),
model: None,
output_modality: RealtimeOutputModality::Audio,
@@ -1076,6 +1083,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,
codex_response_handoff_channel_prefixes: None,
thread_id: thread_start.thread.id.clone(),
model: None,
output_modality: RealtimeOutputModality::Text,
@@ -1251,6 +1259,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,
codex_response_handoff_channel_prefixes: None,
thread_id: thread_start.thread.id.clone(),
model: None,
output_modality: RealtimeOutputModality::Audio,
@@ -1347,6 +1356,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,
codex_response_handoff_channel_prefixes: None,
thread_id: thread_id.clone(),
model: None,
output_modality: RealtimeOutputModality::Audio,
@@ -2120,6 +2130,7 @@ async fn websocket_v3_passes_initial_items_through_session_start() -> Result<()>
let started = harness
.start_frameless_bidi_realtime(
/*codex_response_handoff_mode*/ None,
/*codex_response_handoff_channel_prefixes*/ None,
Some(vec![
ThreadRealtimeInitialItem {
role: ConversationTextRole::Developer,
@@ -2158,10 +2169,27 @@ async fn websocket_v3_passes_initial_items_through_session_start() -> Result<()>
async fn websocket_v3_routes_handoffs_by_session_mode() -> Result<()> {
skip_if_no_network!(Ok(()));
for (mode, expected_channels) in [
(None, [None, None, None, None]),
for (mode, channel_prefixes, texts, expected_channels) in [
(
None,
None,
[
"[ANALYSIS]silent context",
"[COMMENTARY]still working",
"[FINAL]finished",
"unparsable BEM output",
],
[None, None, None, None],
),
(
Some(CodexResponseHandoffMode::Commentary),
None,
[
"[ANALYSIS]silent context",
"[COMMENTARY]still working",
"[FINAL]finished",
"unparsable BEM output",
],
[
Some("commentary"),
Some("commentary"),
@@ -2171,6 +2199,36 @@ async fn websocket_v3_routes_handoffs_by_session_mode() -> Result<()> {
),
(
Some(CodexResponseHandoffMode::BemTags),
None,
[
"[ANALYSIS]silent context",
"[COMMENTARY]still working",
"[FINAL]finished",
"unparsable BEM output",
],
[
Some("commentary"),
Some("commentary"),
Some("speakable"),
Some("speakable"),
],
),
(
Some(CodexResponseHandoffMode::BemTags),
Some(BTreeMap::from([
("analysis".to_string(), vec!["[THOUGHT]".to_string()]),
(
"commentary".to_string(),
vec!["[PROGRESS]".to_string(), "[UPDATE]".to_string()],
),
("final".to_string(), vec!["[DONE]".to_string()]),
])),
[
"[THOUGHT]silent context",
"[UPDATE]still working",
"[DONE]finished",
"unparsable BEM output",
],
[
Some("commentary"),
Some("commentary"),
@@ -2179,11 +2237,7 @@ async fn websocket_v3_routes_handoffs_by_session_mode() -> Result<()> {
],
),
] {
let analysis_text = "<|start|>assistant<|channel|>analysis<|message|>silent context<|end|>";
let commentary_text =
"<|start|>assistant<|channel|>commentary<|message|>still working<|end|>";
let final_text = "<|start|>assistant<|channel|>final<|message|>finished<|end|>";
let fallback_text = "unparsable BEM output";
let [analysis_text, commentary_text, final_text, fallback_text] = texts;
let analysis = responses::ev_assistant_message("msg-analysis", analysis_text);
let commentary = responses::ev_assistant_message("msg-commentary", commentary_text);
let final_answer = responses::ev_assistant_message("msg-final", final_text);
@@ -2225,7 +2279,7 @@ async fn websocket_v3_routes_handoffs_by_session_mode() -> Result<()> {
.await?;
let started = harness
.start_frameless_bidi_realtime(mode, /*initial_items*/ None)
.start_frameless_bidi_realtime(mode, channel_prefixes, /*initial_items*/ None)
.await?;
assert_eq!(started.version, RealtimeConversationVersion::V3);
let _ = harness
@@ -2937,6 +2991,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,
codex_response_handoff_channel_prefixes: None,
thread_id: thread_start.thread.id,
model: None,
output_modality: RealtimeOutputModality::Audio,
@@ -2999,6 +3054,7 @@ async fn realtime_conversation_requires_feature_flag() -> Result<()> {
codex_responses_as_items: None,
codex_response_item_prefix: None,
codex_response_handoff_mode: None,
codex_response_handoff_channel_prefixes: None,
thread_id: thread_start.thread.id.clone(),
model: None,
output_modality: RealtimeOutputModality::Audio,
+25 -29
View File
@@ -64,6 +64,7 @@ use http::HeaderMap;
use http::HeaderValue;
use http::header::AUTHORIZATION;
use serde_json::json;
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
@@ -138,6 +139,7 @@ struct RealtimeHandoffState {
codex_responses_as_items: bool,
codex_response_item_prefix: Option<String>,
codex_response_handoff_mode: CodexResponseHandoffMode,
codex_response_handoff_channel_prefixes: Arc<BTreeMap<String, Vec<String>>>,
session_kind: RealtimeSessionKind,
event_parser: RealtimeEventParser,
}
@@ -430,28 +432,6 @@ struct RealtimeInputChannels {
}
impl RealtimeHandoffState {
fn new(
output_tx: Sender<RealtimeOutbound>,
client_managed_handoffs: bool,
codex_responses_as_items: bool,
codex_response_item_prefix: Option<String>,
codex_response_handoff_mode: CodexResponseHandoffMode,
session_kind: RealtimeSessionKind,
event_parser: RealtimeEventParser,
) -> Self {
Self {
output_tx,
last_output: Arc::new(Mutex::new(None)),
stream: Arc::new(Mutex::new(RealtimeHandoffStreamState::default())),
client_managed_handoffs,
codex_responses_as_items,
codex_response_item_prefix,
codex_response_handoff_mode,
session_kind,
event_parser,
}
}
fn streams_handoff_append(&self) -> bool {
self.event_parser == RealtimeEventParser::FramelessBidi
&& !self.client_managed_handoffs
@@ -484,6 +464,7 @@ struct RealtimeStart {
codex_responses_as_items: bool,
codex_response_item_prefix: Option<String>,
codex_response_handoff_mode: CodexResponseHandoffMode,
codex_response_handoff_channel_prefixes: Option<BTreeMap<String, Vec<String>>>,
realtime_call_api_provider: Option<ApiProvider>,
session_config: RealtimeSessionConfig,
model_client: ModelClient,
@@ -543,6 +524,7 @@ impl RealtimeConversationManager {
codex_responses_as_items,
codex_response_item_prefix,
codex_response_handoff_mode,
codex_response_handoff_channel_prefixes,
realtime_call_api_provider,
session_config,
model_client,
@@ -566,15 +548,20 @@ impl RealtimeConversationManager {
let realtime_active = Arc::new(AtomicBool::new(true));
let stop_token = CancellationToken::new();
let handoff = RealtimeHandoffState::new(
handoff_output_tx,
let handoff = RealtimeHandoffState {
output_tx: handoff_output_tx,
last_output: Arc::new(Mutex::new(None)),
stream: Arc::new(Mutex::new(RealtimeHandoffStreamState::default())),
client_managed_handoffs,
codex_responses_as_items,
codex_response_item_prefix,
codex_response_handoff_mode,
codex_response_handoff_channel_prefixes: Arc::new(
codex_response_handoff_channel_prefixes.unwrap_or_default(),
),
session_kind,
event_parser,
);
};
let input_channels = RealtimeInputChannels {
text_rx,
handoff_output_rx,
@@ -755,7 +742,10 @@ impl RealtimeConversationManager {
return Ok(());
}
let phase = if handoff.routes_handoff_by_bem() {
match bem_message_phase(&output_text) {
match bem_message_phase(
&output_text,
&handoff.codex_response_handoff_channel_prefixes,
) {
Some(phase) => Some(phase),
None => {
warn!("BEM output did not contain a recognized channel header");
@@ -855,9 +845,11 @@ impl RealtimeConversationManager {
} else {
phase
},
bem_channel_parser: handoff
.routes_handoff_by_bem()
.then(BemChannelParser::default),
bem_channel_parser: handoff.routes_handoff_by_bem().then(|| {
BemChannelParser::new(Arc::clone(
&handoff.codex_response_handoff_channel_prefixes,
))
}),
prefix_final_message: handoff.event_parser == RealtimeEventParser::V1,
sent_bytes: 0,
buffered_text: String::new(),
@@ -1104,6 +1096,7 @@ struct PreparedRealtimeConversationStart {
codex_responses_as_items: bool,
codex_response_item_prefix: Option<String>,
codex_response_handoff_mode: CodexResponseHandoffMode,
codex_response_handoff_channel_prefixes: Option<BTreeMap<String, Vec<String>>>,
realtime_call_api_provider: Option<ApiProvider>,
requested_realtime_session_id: Option<String>,
version: RealtimeWsVersion,
@@ -1195,6 +1188,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,
codex_response_handoff_channel_prefixes: params.codex_response_handoff_channel_prefixes,
realtime_call_api_provider,
requested_realtime_session_id,
version,
@@ -1393,6 +1387,7 @@ async fn handle_start_inner(
codex_responses_as_items,
codex_response_item_prefix,
codex_response_handoff_mode,
codex_response_handoff_channel_prefixes,
realtime_call_api_provider,
requested_realtime_session_id,
version,
@@ -1412,6 +1407,7 @@ async fn handle_start_inner(
codex_responses_as_items,
codex_response_item_prefix,
codex_response_handoff_mode,
codex_response_handoff_channel_prefixes,
realtime_call_api_provider,
session_config,
model_client: sess.services.model_client.clone(),
+33 -11
View File
@@ -1,16 +1,30 @@
use codex_protocol::models::MessagePhase;
use std::collections::BTreeMap;
use std::sync::Arc;
const BEM_ASSISTANT_CHANNEL_PREFIX: &str = "<|start|>assistant<|channel|>";
const BEM_MESSAGE_MARKER: &str = "<|message|>";
pub(super) fn message_phase(text: &str) -> Option<MessagePhase> {
let channel_and_message = text.strip_prefix(BEM_ASSISTANT_CHANNEL_PREFIX)?;
let (channel, _) = channel_and_message.split_once(BEM_MESSAGE_MARKER)?;
match channel {
"analysis" | "commentary" => Some(MessagePhase::Commentary),
"final" => Some(MessagePhase::FinalAnswer),
_ => None,
pub(super) fn message_phase(
text: &str,
channel_prefixes: &BTreeMap<String, Vec<String>>,
) -> Option<MessagePhase> {
for (channel, default_prefix, phase) in [
("analysis", "[ANALYSIS]", MessagePhase::Commentary),
("commentary", "[COMMENTARY]", MessagePhase::Commentary),
("final", "[FINAL]", MessagePhase::FinalAnswer),
] {
let matches = channel_prefixes.get(channel).map_or_else(
|| text.starts_with(default_prefix),
|prefixes| {
prefixes
.iter()
.any(|prefix| !prefix.is_empty() && text.starts_with(prefix))
},
);
if matches {
return Some(phase);
}
}
None
}
/// Buffers a streamed BEM message until its channel header is complete.
@@ -19,18 +33,26 @@ pub(super) fn message_phase(text: &str) -> Option<MessagePhase> {
/// the frontend model can distinguish BEM `analysis` from `commentary`.
#[derive(Debug, Default)]
pub(super) struct ChannelParser {
channel_prefixes: Arc<BTreeMap<String, Vec<String>>>,
buffered_text: String,
phase: Option<MessagePhase>,
}
impl ChannelParser {
pub(super) fn new(channel_prefixes: Arc<BTreeMap<String, Vec<String>>>) -> Self {
Self {
channel_prefixes,
..Self::default()
}
}
pub(super) fn push(&mut self, text: &str) -> Option<String> {
if self.phase.is_some() {
return Some(text.to_string());
}
self.buffered_text.push_str(text);
self.phase = message_phase(&self.buffered_text);
self.phase = message_phase(&self.buffered_text, &self.channel_prefixes);
self.phase.as_ref()?;
Some(std::mem::take(&mut self.buffered_text))
}
@@ -2,34 +2,95 @@ use super::ChannelParser;
use super::message_phase;
use codex_protocol::models::MessagePhase;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
use std::sync::Arc;
#[test]
fn maps_bem_channels_to_realtime_phases() {
let channel_prefixes = BTreeMap::new();
for (channel, expected) in [
("analysis", MessagePhase::Commentary),
("commentary", MessagePhase::Commentary),
("final", MessagePhase::FinalAnswer),
("ANALYSIS", MessagePhase::Commentary),
("COMMENTARY", MessagePhase::Commentary),
("FINAL", MessagePhase::FinalAnswer),
] {
assert_eq!(
message_phase(&format!(
"<|start|>assistant<|channel|>{channel}<|message|>text<|end|>"
)),
message_phase(&format!("[{channel}]text"), &channel_prefixes),
Some(expected)
);
}
}
#[test]
fn client_prefixes_override_only_their_configured_channels() {
let channel_prefixes = BTreeMap::from([
(
"analysis".to_string(),
vec!["[THINKING]".to_string(), "[THOUGHT]".to_string()],
),
(
"commentary".to_string(),
vec!["[PROGRESS]".to_string(), "[UPDATE]".to_string()],
),
]);
for (text, expected) in [
("[THINKING]reasoning", MessagePhase::Commentary),
("[THOUGHT]reasoning", MessagePhase::Commentary),
("[PROGRESS]working", MessagePhase::Commentary),
("[UPDATE]working", MessagePhase::Commentary),
("[FINAL]finished", MessagePhase::FinalAnswer),
] {
assert_eq!(message_phase(text, &channel_prefixes), Some(expected));
}
assert_eq!(
message_phase("[ANALYSIS]old analysis prefix", &channel_prefixes),
None
);
assert_eq!(
message_phase("[COMMENTARY]old commentary prefix", &channel_prefixes),
None
);
}
#[test]
fn empty_client_prefixes_do_not_match_every_message() {
let channel_prefixes = BTreeMap::from([("analysis".to_string(), vec![String::new()])]);
assert_eq!(message_phase("plain output", &channel_prefixes), None);
assert_eq!(
message_phase("[FINAL]finished", &channel_prefixes),
Some(MessagePhase::FinalAnswer)
);
}
#[test]
fn buffers_streamed_text_until_the_bem_channel_is_complete() {
let mut parser = ChannelParser::default();
assert_eq!(parser.push("<|start|>assistant<|channel|>com"), None);
assert_eq!(parser.push("[COM"), None);
assert_eq!(
parser.push("mentary<|message|>progress"),
Some("<|start|>assistant<|channel|>commentary<|message|>progress".to_string())
parser.push("MENTARY]progress"),
Some("[COMMENTARY]progress".to_string())
);
assert_eq!(parser.phase(), Some(MessagePhase::Commentary));
assert_eq!(parser.push(" update"), Some(" update".to_string()));
}
#[test]
fn buffers_a_client_prefix_until_the_streamed_header_is_complete() {
let channel_prefixes = BTreeMap::from([(
"commentary".to_string(),
vec!["[PROGRESS]".to_string(), "[UPDATE]".to_string()],
)]);
let mut parser = ChannelParser::new(Arc::new(channel_prefixes));
assert_eq!(parser.push("[PRO"), None);
assert_eq!(
parser.push("GRESS]working"),
Some("[PROGRESS]working".to_string())
);
assert_eq!(parser.phase(), Some(MessagePhase::Commentary));
assert_eq!(parser.push("<|end|>"), Some("<|end|>".to_string()));
}
#[test]
@@ -15,7 +15,10 @@ use codex_protocol::protocol::CodexResponseHandoffMode;
use codex_protocol::protocol::RealtimeHandoffRequested;
use codex_protocol::protocol::RealtimeTranscriptEntry;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
use std::sync::Arc;
use std::time::Instant;
use tokio::sync::Mutex;
#[test]
fn prefers_handoff_input_transcript_over_active_transcript() {
@@ -147,15 +150,18 @@ fn wraps_realtime_delegation_input_with_xml_escaping_without_transcript() {
#[tokio::test]
async fn clears_active_handoff_explicitly() {
let (tx, _rx) = bounded(1);
let state = RealtimeHandoffState::new(
tx,
/*client_managed_handoffs*/ false,
/*codex_responses_as_items*/ false,
/*codex_response_item_prefix*/ None,
CodexResponseHandoffMode::Thinking,
RealtimeSessionKind::V1,
/*event_parser*/ RealtimeEventParser::V1,
);
let state = RealtimeHandoffState {
output_tx: tx,
last_output: Arc::new(Mutex::new(None)),
stream: Arc::new(Mutex::new(Default::default())),
client_managed_handoffs: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_mode: CodexResponseHandoffMode::Thinking,
codex_response_handoff_channel_prefixes: Arc::new(BTreeMap::new()),
session_kind: RealtimeSessionKind::V1,
event_parser: RealtimeEventParser::V1,
};
state.stream.lock().await.active_handoff = Some("handoff_1".to_string());
assert_eq!(
@@ -246,6 +246,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,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -42,6 +42,7 @@ use core_test_support::wait_for_event_match;
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;
@@ -289,6 +290,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,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -438,6 +440,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,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -528,6 +531,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,
codex_response_handoff_channel_prefixes: None,
model: Some("session-override-model".to_string()),
output_modality: RealtimeOutputModality::Audio,
include_startup_context: false,
@@ -651,6 +655,7 @@ async fn conversation_webrtc_start_posts_generated_session() -> Result<()> {
codex_response_item_prefix: None,
codex_response_handoff_mode:
codex_protocol::protocol::CodexResponseHandoffMode::Thinking,
codex_response_handoff_channel_prefixes: None,
model: Some("session-override-model".to_string()),
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -841,6 +846,7 @@ async fn conversation_webrtc_start_uses_avas_query() -> Result<()> {
codex_response_item_prefix: None,
codex_response_handoff_mode:
codex_protocol::protocol::CodexResponseHandoffMode::Thinking,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -942,6 +948,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,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -1005,6 +1012,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,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -1078,6 +1086,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,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -1175,6 +1184,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,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -1269,6 +1279,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,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -1365,6 +1376,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,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -1459,6 +1471,7 @@ async fn assert_transport_close_tail_flush(
codex_response_item_prefix: None,
codex_response_handoff_mode:
codex_protocol::protocol::CodexResponseHandoffMode::Thinking,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -1578,6 +1591,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,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -1632,6 +1646,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,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -1734,6 +1749,7 @@ async fn conversation_second_start_replaces_runtime() -> Result<()> {
codex_response_item_prefix: None,
codex_response_handoff_mode:
codex_protocol::protocol::CodexResponseHandoffMode::Thinking,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -1767,6 +1783,7 @@ async fn conversation_second_start_replaces_runtime() -> Result<()> {
codex_response_item_prefix: None,
codex_response_handoff_mode:
codex_protocol::protocol::CodexResponseHandoffMode::Thinking,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -1871,6 +1888,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,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -1943,6 +1961,7 @@ async fn conversation_uses_default_realtime_backend_prompt() -> Result<()> {
codex_response_item_prefix: None,
codex_response_handoff_mode:
codex_protocol::protocol::CodexResponseHandoffMode::Thinking,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -2023,6 +2042,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,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -2096,6 +2116,7 @@ async fn conversation_uses_explicit_start_voice() -> Result<()> {
codex_response_item_prefix: None,
codex_response_handoff_mode:
codex_protocol::protocol::CodexResponseHandoffMode::Thinking,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -2161,6 +2182,7 @@ async fn conversation_uses_configured_realtime_voice() -> Result<()> {
codex_response_item_prefix: None,
codex_response_handoff_mode:
codex_protocol::protocol::CodexResponseHandoffMode::Thinking,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -2214,6 +2236,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,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -2268,6 +2291,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,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -2348,6 +2372,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,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -2422,6 +2447,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,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -2489,6 +2515,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,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -2611,6 +2638,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,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -2726,6 +2754,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,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -2793,6 +2822,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,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -2881,6 +2911,7 @@ async fn conversation_user_text_turn_is_not_sent_to_realtime() -> Result<()> {
codex_response_item_prefix: None,
codex_response_handoff_mode:
codex_protocol::protocol::CodexResponseHandoffMode::Thinking,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -2985,6 +3016,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,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -3091,6 +3123,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,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -3166,17 +3199,17 @@ async fn conversation_mirrors_assistant_message_text_to_realtime_handoff() -> Re
async fn conversation_flushes_assistant_deltas_every_200ms_for_v3_handoff() -> Result<()> {
skip_if_no_network!(Ok(()));
let initial_commentary_text = "<|start|>assistant<|channel|>com";
let first_commentary_delta = "mentary<|message|>seed first ";
let second_commentary_delta = format!("{}<|end|>", "x".repeat(94));
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 commentary_item_added =
responses::ev_message_item_added("msg_commentary", initial_commentary_text);
let commentary_item_done = responses::ev_assistant_message("msg_commentary", &commentary_text);
let initial_final_text = "<|start|>assistant<|channel|>fi";
let final_delta = "nal<|message|>done<|end|>";
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);
@@ -3258,6 +3291,13 @@ 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,
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,
@@ -3427,6 +3467,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,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -3586,6 +3627,7 @@ async fn inbound_handoff_request_starts_turn() -> Result<()> {
codex_response_item_prefix: None,
codex_response_handoff_mode:
codex_protocol::protocol::CodexResponseHandoffMode::Thinking,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -3703,6 +3745,7 @@ async fn inbound_handoff_request_uses_active_transcript() -> Result<()> {
codex_response_item_prefix: None,
codex_response_handoff_mode:
codex_protocol::protocol::CodexResponseHandoffMode::Thinking,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -3813,6 +3856,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,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -3942,6 +3986,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,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -4032,6 +4077,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,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -4162,6 +4208,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,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -4322,6 +4369,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,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -4471,6 +4519,7 @@ async fn inbound_handoff_request_steers_active_turn() -> Result<()> {
codex_response_item_prefix: None,
codex_response_handoff_mode:
codex_protocol::protocol::CodexResponseHandoffMode::Thinking,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -4631,6 +4680,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,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -177,6 +177,7 @@ fn start_params(version: RealtimeConversationVersion) -> ConversationStartParams
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_mode: CodexResponseHandoffMode::Thinking,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
+2
View File
@@ -216,6 +216,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,
/// 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.
pub model: Option<String>,
/// Selects whether the realtime session should produce text or audio output.