diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index e2b2a9b0c1..0021b29b78 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -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, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/realtime.rs b/codex-rs/app-server-protocol/src/protocol/v2/realtime.rs index 234b2089e9..cede06a43d 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/realtime.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/realtime.rs @@ -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, + /// 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>>, /// Overrides the configured realtime model for this session only. #[ts(optional = nullable)] pub model: Option, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index 23c0c2a941..7b38dceb2d 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -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::(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()]), + ])) + ); +} diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 97570b543a..40d76de738 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -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. diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index 663c4604f0..2477a6c991 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -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), diff --git a/codex-rs/app-server/tests/suite/v2/experimental_api.rs b/codex-rs/app-server/tests/suite/v2/experimental_api.rs index 3bc29d07eb..e099c514c6 100644 --- a/codex-rs/app-server/tests/suite/v2/experimental_api.rs +++ b/codex-rs/app-server/tests/suite/v2/experimental_api.rs @@ -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, diff --git a/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs b/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs index bb3957cf9b..06620b95bd 100644 --- a/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs +++ b/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs @@ -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, + codex_response_handoff_channel_prefixes: Option>>, initial_items: Option>, ) -> Result { 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, diff --git a/codex-rs/core/src/realtime_conversation.rs b/codex-rs/core/src/realtime_conversation.rs index 1264df6451..198c9f858c 100644 --- a/codex-rs/core/src/realtime_conversation.rs +++ b/codex-rs/core/src/realtime_conversation.rs @@ -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, codex_response_handoff_mode: CodexResponseHandoffMode, + codex_response_handoff_channel_prefixes: Arc>>, session_kind: RealtimeSessionKind, event_parser: RealtimeEventParser, } @@ -430,28 +432,6 @@ struct RealtimeInputChannels { } impl RealtimeHandoffState { - fn new( - output_tx: Sender, - client_managed_handoffs: bool, - codex_responses_as_items: bool, - codex_response_item_prefix: Option, - 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, codex_response_handoff_mode: CodexResponseHandoffMode, + codex_response_handoff_channel_prefixes: Option>>, realtime_call_api_provider: Option, 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, codex_response_handoff_mode: CodexResponseHandoffMode, + codex_response_handoff_channel_prefixes: Option>>, realtime_call_api_provider: Option, requested_realtime_session_id: Option, 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(), diff --git a/codex-rs/core/src/realtime_conversation/bem.rs b/codex-rs/core/src/realtime_conversation/bem.rs index c6b6df9822..ebe4727cc5 100644 --- a/codex-rs/core/src/realtime_conversation/bem.rs +++ b/codex-rs/core/src/realtime_conversation/bem.rs @@ -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 { - 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>, +) -> Option { + 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 { /// the frontend model can distinguish BEM `analysis` from `commentary`. #[derive(Debug, Default)] pub(super) struct ChannelParser { + channel_prefixes: Arc>>, buffered_text: String, phase: Option, } impl ChannelParser { + pub(super) fn new(channel_prefixes: Arc>>) -> Self { + Self { + channel_prefixes, + ..Self::default() + } + } + pub(super) fn push(&mut self, text: &str) -> Option { 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)) } diff --git a/codex-rs/core/src/realtime_conversation/bem_tests.rs b/codex-rs/core/src/realtime_conversation/bem_tests.rs index 580bf4a828..cccf8e722c 100644 --- a/codex-rs/core/src/realtime_conversation/bem_tests.rs +++ b/codex-rs/core/src/realtime_conversation/bem_tests.rs @@ -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] diff --git a/codex-rs/core/src/realtime_conversation_tests.rs b/codex-rs/core/src/realtime_conversation_tests.rs index 4c69238fc1..53b5faae15 100644 --- a/codex-rs/core/src/realtime_conversation_tests.rs +++ b/codex-rs/core/src/realtime_conversation_tests.rs @@ -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!( diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index 3f09936745..4e3b8615f9 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -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, diff --git a/codex-rs/core/tests/suite/realtime_conversation.rs b/codex-rs/core/tests/suite/realtime_conversation.rs index e10a5c54dd..569be6a6eb 100644 --- a/codex-rs/core/tests/suite/realtime_conversation.rs +++ b/codex-rs/core/tests/suite/realtime_conversation.rs @@ -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, diff --git a/codex-rs/core/tests/suite/realtime_initial_items.rs b/codex-rs/core/tests/suite/realtime_initial_items.rs index 4eb5673857..53f9ff7fc3 100644 --- a/codex-rs/core/tests/suite/realtime_initial_items.rs +++ b/codex-rs/core/tests/suite/realtime_initial_items.rs @@ -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, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 434fea10bc..17f3f53670 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -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>>, /// Overrides the configured realtime model for this session only. pub model: Option, /// Selects whether the realtime session should produce text or audio output.