Route realtime V3 handoffs by response channel (#33903)

## What changed

- Add `codexResponseHandoffMode` to `thread/realtime/start` for V3 sessions, with `thinking` as the default and `commentary` and `bemTags` routing modes.
- Map BEM `analysis` and `commentary` output to the commentary channel and `final` output to the speakable channel, while preserving the original BEM envelope. Unrecognized output falls back to speakable.
- Mark explicit speech appends as speakable and stop adding the legacy `"Agent Final Message"` prefix to V3 handoffs. V1 and V2 continue to ignore the new setting.

## Testing

- Cover channel selection for every routing mode, streamed BEM header parsing, fallback behavior, explicit speech, and V1 compatibility.

GitOrigin-RevId: 1974578257488f2914b252c9c1990ba38831c96b
This commit is contained in:
jiayuhuang-openai
2026-07-17 21:12:40 +00:00
committed by copyberry
parent 161748a68e
commit 025db22058
26 changed files with 851 additions and 167 deletions
@@ -288,6 +288,14 @@
],
"type": "object"
},
"CodexResponseHandoffMode": {
"enum": [
"thinking",
"commentary",
"bemTags"
],
"type": "string"
},
"CollaborationMode": {
"description": "Collaboration mode for a Codex session.",
"properties": {
@@ -7416,6 +7416,14 @@
}
]
},
"CodexResponseHandoffMode": {
"enum": [
"thinking",
"commentary",
"bemTags"
],
"type": "string"
},
"CollabAgentState": {
"properties": {
"message": {
@@ -3609,6 +3609,14 @@
}
]
},
"CodexResponseHandoffMode": {
"enum": [
"thinking",
"commentary",
"bemTags"
],
"type": "string"
},
"CollabAgentState": {
"properties": {
"message": {
@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type CodexResponseHandoffMode = "thinking" | "commentary" | "bemTags";
+1
View File
@@ -10,6 +10,7 @@ export type { AutoCompactTokenLimitScope } from "./AutoCompactTokenLimitScope";
export type { ClientInfo } from "./ClientInfo";
export type { ClientNotification } from "./ClientNotification";
export type { ClientRequest } from "./ClientRequest";
export type { CodexResponseHandoffMode } from "./CodexResponseHandoffMode";
export type { CollaborationMode } from "./CollaborationMode";
export type { ContentItem } from "./ContentItem";
export type { ConversationGitInfo } from "./ConversationGitInfo";
@@ -1764,6 +1764,7 @@ mod tests {
use codex_protocol::config_types::MultiAgentMode;
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY;
use codex_protocol::parse_command::ParsedCommand;
use codex_protocol::protocol::CodexResponseHandoffMode;
use codex_protocol::protocol::RealtimeConversationVersion;
use codex_protocol::protocol::RealtimeOutputModality;
use codex_protocol::protocol::RealtimeVoice;
@@ -3390,6 +3391,7 @@ mod tests {
flush_transcript_tail_on_session_end: Some(true),
codex_responses_as_items: None,
codex_response_item_prefix: None,
codex_response_handoff_mode: Some(CodexResponseHandoffMode::BemTags),
thread_id: "thr_123".to_string(),
model: Some("realtime-treatment-model".to_string()),
output_modality: RealtimeOutputModality::Audio,
@@ -3411,6 +3413,7 @@ mod tests {
"flushTranscriptTailOnSessionEnd": true,
"codexResponsesAsItems": null,
"codexResponseItemPrefix": null,
"codexResponseHandoffMode": "bemTags",
"model": "realtime-treatment-model",
"outputModality": "audio",
"includeStartupContext": false,
@@ -3435,6 +3438,7 @@ mod tests {
flush_transcript_tail_on_session_end: None,
codex_responses_as_items: None,
codex_response_item_prefix: None,
codex_response_handoff_mode: None,
thread_id: "thr_123".to_string(),
model: None,
output_modality: RealtimeOutputModality::Audio,
@@ -3456,6 +3460,7 @@ mod tests {
"flushTranscriptTailOnSessionEnd": null,
"codexResponsesAsItems": null,
"codexResponseItemPrefix": null,
"codexResponseHandoffMode": null,
"model": null,
"outputModality": "audio",
"includeStartupContext": null,
@@ -3475,6 +3480,7 @@ mod tests {
flush_transcript_tail_on_session_end: None,
codex_responses_as_items: None,
codex_response_item_prefix: None,
codex_response_handoff_mode: None,
thread_id: "thr_123".to_string(),
model: None,
output_modality: RealtimeOutputModality::Audio,
@@ -3496,6 +3502,7 @@ mod tests {
"flushTranscriptTailOnSessionEnd": null,
"codexResponsesAsItems": null,
"codexResponseItemPrefix": null,
"codexResponseHandoffMode": null,
"model": null,
"outputModality": "audio",
"includeStartupContext": null,
@@ -3715,6 +3722,7 @@ mod tests {
flush_transcript_tail_on_session_end: None,
codex_responses_as_items: None,
codex_response_item_prefix: None,
codex_response_handoff_mode: None,
thread_id: "thr_123".to_string(),
model: None,
output_modality: RealtimeOutputModality::Audio,
@@ -1,3 +1,4 @@
use codex_protocol::protocol::CodexResponseHandoffMode;
use codex_protocol::protocol::ConversationTextRole;
use codex_protocol::protocol::RealtimeAudioFrame as CoreRealtimeAudioFrame;
use codex_protocol::protocol::RealtimeConversationVersion;
@@ -74,12 +75,18 @@ pub struct ThreadRealtimeStartParams {
/// TODO: Remove this rollout knob once transcript-tail flushing is always enabled.
#[ts(optional = nullable)]
pub flush_transcript_tail_on_session_end: Option<bool>,
// TODO: Remove this experiment-only delivery path after response-item testing is complete.
/// Sends automatic Codex responses as realtime conversation items instead of handoff appends.
#[ts(optional = nullable)]
pub codex_responses_as_items: Option<bool>,
// TODO: Remove this experiment-only prefix with `codex_responses_as_items`.
/// Optional prefix added to automatic Codex response items when `codexResponsesAsItems` is true.
#[ts(optional = nullable)]
pub codex_response_item_prefix: Option<String>,
/// Selects how automatic Codex responses are routed in Frameless Bidi sessions. Omitted values
/// default to `thinking`. Realtime V1 and V2 ignore this setting.
#[ts(optional = nullable)]
pub codex_response_handoff_mode: Option<CodexResponseHandoffMode>,
/// Overrides the configured realtime model for this session only.
#[ts(optional = nullable)]
pub model: Option<String>,
+11 -6
View File
@@ -172,7 +172,7 @@ Example with notification opt-out:
- `thread/inject_items` — append raw Responses API items to a loaded thread’s model-visible history without starting a user turn; returns `{}` on success.
- `turn/steer` — add user input to an already in-flight regular turn without starting a new turn; returns the active `turnId` that accepted the input. `clientUserMessageId` is optional; when supplied, the corresponding `userMessage` item echoes it as `clientId`. Review and manual compaction turns reject `turn/steer`.
- `turn/interrupt` — request cancellation of an in-flight turn by `(thread_id, turn_id)`; success is an empty `{}` response and the turn finishes with `status: "interrupted"`.
- `thread/realtime/start` — start a thread-scoped realtime session (experimental); pass `outputModality: "text"` or `outputModality: "audio"` to choose model output, optionally pass `model` and `version` to override configured realtime selection for this session only, and pass `includeStartupContext: false` to omit Codex's generated startup context. Version `"v1"` uses legacy Bidi `conversation.handoff.*`, `"v2"` uses the Realtime Voice API, and `"v3"` preserves V1 Codex Voice behavior while using Frameless Bidi `delegation.*`. V1 and V3 send commentary as raw handoff appends and label final or phase-less agent output with `"Agent Final Message":`; V3 streams these appends as Codex produces text deltas. Pass `clientManagedHandoffs: true` to disable automatic Codex response delivery so only the client's explicit append calls produce handoffs. Pass `codexResponsesAsItems: true` to send automatic Codex responses as realtime conversation items instead, and optionally pass `codexResponseItemPrefix` to prepend experiment instructions to those items. Returns `{}` and streams `thread/realtime/*` notifications. Omit `transport` for the websocket transport, or pass `{ "type": "webrtc", "sdp": "..." }` to create a Bidi WebRTC session from a browser-generated SDP offer; the remote answer SDP is emitted as `thread/realtime/sdp`. Conversation `version: "v2"` requests remain unsupported for WebRTC.
- `thread/realtime/start` — start a thread-scoped realtime session (experimental); pass `outputModality: "text"` or `outputModality: "audio"` to choose model output, optionally pass `model` and `version` to override configured realtime selection for this session only, and pass `includeStartupContext: false` to omit Codex's generated startup context. Version `"v1"` uses legacy Bidi `conversation.handoff.*`, `"v2"` uses the Realtime Voice API, and `"v3"` preserves V1 Codex Voice behavior while using Frameless Bidi `delegation.*`. For V3 automatic Codex text, `codexResponseHandoffMode` accepts `"thinking"` (the default; all output uses channel-less thinking appends), `"commentary"` (all output uses the commentary channel), or `"bemTags"` (the raw BEM envelope selects the API channel: BEM `analysis` and `commentary` use `commentary`, while BEM `final` and unparsable output use `speakable`). The BEM envelope remains in the appended text for the frontend model to interpret. V1 and V2 ignore this setting. V3 handoffs do not prepend the legacy `"Agent Final Message"` label. Pass `clientManagedHandoffs: true` to disable automatic Codex response delivery so only the client's explicit append calls produce handoffs. Pass `codexResponsesAsItems: true` to send automatic Codex responses as realtime conversation items instead, and optionally pass `codexResponseItemPrefix` to prepend experiment instructions to those items. Returns `{}` and streams `thread/realtime/*` notifications. Omit `transport` for the websocket transport, or pass `{ "type": "webrtc", "sdp": "..." }` to create a Bidi WebRTC session from a browser-generated SDP offer; the remote answer SDP is emitted as `thread/realtime/sdp`. Conversation `version: "v2"` requests remain unsupported for WebRTC.
- `thread/realtime/appendAudio` — append an input audio chunk to the active realtime session (experimental); returns `{}`.
- `thread/realtime/appendText` — append text input to the active realtime session with a required `role` of `user`, `developer`, or `assistant` (experimental); returns `{}`. Older clients that omit `role` default to `user`.
- `thread/realtime/appendSpeech` — append text that the realtime model should speak to the user (experimental); returns `{}`.
@@ -923,11 +923,16 @@ Pass `codexResponsesAsItems: true` to inject automatic Codex responses with
path. When using that mode, `codexResponseItemPrefix` can prepend short
experiment instructions to each automatic Codex response item. Omit
`codexResponsesAsItems`, or pass `false`, to preserve the default speakable
behavior. In that default mode, V1 and V3 append commentary without a prefix
and label final or phase-less agent output with `"Agent Final Message":`. V3
emits each text delta as a `delegation.context.append` instead of waiting for
the completed agent message. Older clients may continue to send the removed
`codexResponseHandoffPrefix` field; the server ignores unknown request fields.
behavior. In V3, automatic handoffs default to
`codexResponseHandoffMode: "thinking"`, which omits the context append `channel`
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
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.
Call
`thread/realtime/appendText` to append app-provided realtime text items, or
`thread/realtime/appendSpeech` when the app decides a realtime update should be
@@ -1077,6 +1077,7 @@ impl TurnRequestProcessor {
.unwrap_or(false),
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(),
model: params.model,
output_modality: params.output_modality,
include_startup_context: params.include_startup_context.unwrap_or(true),
@@ -93,6 +93,7 @@ async fn realtime_conversation_start_requires_experimental_api_capability() -> R
flush_transcript_tail_on_session_end: None,
codex_responses_as_items: None,
codex_response_item_prefix: None,
codex_response_handoff_mode: None,
thread_id: "thr_123".to_string(),
model: None,
output_modality: RealtimeOutputModality::Audio,
@@ -221,6 +222,7 @@ async fn realtime_webrtc_start_requires_experimental_api_capability() -> Result<
flush_transcript_tail_on_session_end: None,
codex_responses_as_items: None,
codex_response_item_prefix: None,
codex_response_handoff_mode: None,
thread_id: "thr_123".to_string(),
model: None,
output_modality: RealtimeOutputModality::Audio,
@@ -44,6 +44,7 @@ use codex_app_server_protocol::TurnStartedNotification;
use codex_app_server_protocol::UserInput as V2UserInput;
use codex_features::FEATURES;
use codex_features::Feature;
use codex_protocol::protocol::CodexResponseHandoffMode;
use codex_protocol::protocol::ConversationTextRole;
use codex_protocol::protocol::RealtimeConversationVersion;
use codex_protocol::protocol::RealtimeOutputModality;
@@ -332,6 +333,7 @@ impl RealtimeE2eHarness {
offer_sdp,
/*client_managed_handoffs*/ None,
/*codex_responses_as_items*/ None,
/*codex_response_handoff_mode*/ None,
RealtimeConversationVersion::V1,
)
.await
@@ -345,6 +347,7 @@ impl RealtimeE2eHarness {
offer_sdp,
/*client_managed_handoffs*/ None,
/*codex_responses_as_items*/ Some(true),
/*codex_response_handoff_mode*/ None,
RealtimeConversationVersion::V1,
)
.await
@@ -355,6 +358,7 @@ impl RealtimeE2eHarness {
offer_sdp: &str,
client_managed_handoffs: Option<bool>,
codex_responses_as_items: Option<bool>,
codex_response_handoff_mode: Option<CodexResponseHandoffMode>,
version: RealtimeConversationVersion,
) -> Result<StartedWebrtcRealtime> {
// Starts realtime through the public JSON-RPC method, then waits for the same client-visible
@@ -368,6 +372,7 @@ impl RealtimeE2eHarness {
codex_response_item_prefix: codex_responses_as_items
.unwrap_or(false)
.then(|| RESPONSE_ITEM_PREFIX.to_string()),
codex_response_handoff_mode,
codex_responses_as_items,
model: None,
output_modality: RealtimeOutputModality::Audio,
@@ -428,6 +433,7 @@ impl RealtimeE2eHarness {
codex_response_item_prefix: codex_responses_as_items
.unwrap_or(false)
.then(|| RESPONSE_ITEM_PREFIX.to_string()),
codex_response_handoff_mode: None,
codex_responses_as_items,
model: None,
output_modality: RealtimeOutputModality::Audio,
@@ -451,7 +457,10 @@ impl RealtimeE2eHarness {
.await
}
async fn start_frameless_bidi_realtime(&mut self) -> Result<ThreadRealtimeStartedNotification> {
async fn start_frameless_bidi_realtime(
&mut self,
codex_response_handoff_mode: Option<CodexResponseHandoffMode>,
) -> Result<ThreadRealtimeStartedNotification> {
let start_request_id = self
.mcp
.send_thread_realtime_start_request(ThreadRealtimeStartParams {
@@ -459,6 +468,7 @@ impl RealtimeE2eHarness {
client_managed_handoffs: None,
flush_transcript_tail_on_session_end: None,
codex_response_item_prefix: None,
codex_response_handoff_mode,
codex_responses_as_items: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
@@ -730,6 +740,7 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> {
flush_transcript_tail_on_session_end: None,
codex_responses_as_items: None,
codex_response_item_prefix: None,
codex_response_handoff_mode: None,
thread_id: thread_start.thread.id.clone(),
model: Some("realtime-treatment-model".to_string()),
output_modality: RealtimeOutputModality::Audio,
@@ -1024,6 +1035,7 @@ async fn realtime_start_can_skip_startup_context() -> Result<()> {
flush_transcript_tail_on_session_end: None,
codex_responses_as_items: None,
codex_response_item_prefix: None,
codex_response_handoff_mode: None,
thread_id: thread_start.thread.id.clone(),
model: None,
output_modality: RealtimeOutputModality::Audio,
@@ -1125,6 +1137,7 @@ async fn realtime_text_output_modality_requests_text_output_and_final_transcript
flush_transcript_tail_on_session_end: None,
codex_responses_as_items: None,
codex_response_item_prefix: None,
codex_response_handoff_mode: None,
thread_id: thread_start.thread.id.clone(),
model: None,
output_modality: RealtimeOutputModality::Text,
@@ -1312,6 +1325,7 @@ async fn realtime_conversation_stop_emits_closed_notification() -> Result<()> {
flush_transcript_tail_on_session_end: None,
codex_responses_as_items: None,
codex_response_item_prefix: None,
codex_response_handoff_mode: None,
thread_id: thread_start.thread.id.clone(),
model: None,
output_modality: RealtimeOutputModality::Audio,
@@ -1419,6 +1433,7 @@ async fn realtime_webrtc_start_emits_sdp_notification() -> Result<()> {
flush_transcript_tail_on_session_end: None,
codex_responses_as_items: None,
codex_response_item_prefix: None,
codex_response_handoff_mode: None,
thread_id: thread_id.clone(),
model: None,
output_modality: RealtimeOutputModality::Audio,
@@ -1612,6 +1627,7 @@ async fn webrtc_v3_start_posts_live_session_and_joins_without_session_update() -
"v=offer\r\n",
/*client_managed_handoffs*/ None,
/*codex_responses_as_items*/ None,
/*codex_response_handoff_mode*/ None,
RealtimeConversationVersion::V3,
)
.await?;
@@ -1742,6 +1758,7 @@ async fn webrtc_v1_client_managed_handoffs_disable_automatic_output() -> Result<
"v=offer\r\n",
/*client_managed_handoffs*/ Some(true),
/*codex_responses_as_items*/ None,
/*codex_response_handoff_mode*/ None,
RealtimeConversationVersion::V1,
)
.await?;
@@ -1799,6 +1816,74 @@ async fn webrtc_v1_client_managed_handoffs_disable_automatic_output() -> Result<
Ok(())
}
#[tokio::test]
async fn webrtc_v1_ignores_codex_response_handoff_mode() -> Result<()> {
skip_if_no_network!(Ok(()));
let mut commentary = responses::ev_assistant_message("msg-commentary", "background progress");
commentary["item"]["phase"] = json!("commentary");
let mut final_answer = responses::ev_assistant_message("msg-final", "background complete");
final_answer["item"]["phase"] = json!("final_answer");
let mut harness = RealtimeE2eHarness::new(
RealtimeTestVersion::V1,
main_loop_responses(vec![responses::sse(vec![
responses::ev_response_created("resp-1"),
commentary,
final_answer,
responses::ev_completed("resp-1"),
])]),
realtime_sideband(vec![realtime_sideband_connection(vec![
vec![
session_updated("sess_v1_channel_handoff"),
json!({
"type": "conversation.handoff.requested",
"handoff_id": "handoff_channel",
"item_id": "item_channel",
"input_transcript": "run the background task"
}),
],
vec![],
vec![],
vec![],
])]),
)
.await?;
let started = harness
.start_webrtc_realtime_with_codex_response_routing(
"v=offer\r\n",
/*client_managed_handoffs*/ None,
/*codex_responses_as_items*/ None,
/*codex_response_handoff_mode*/ Some(CodexResponseHandoffMode::BemTags),
RealtimeConversationVersion::V1,
)
.await?;
assert_eq!(started.started.version, RealtimeConversationVersion::V1);
let _ = harness
.read_notification::<TurnCompletedNotification>("turn/completed")
.await?;
assert_eq!(
harness.sideband_outbound_request(/*request_index*/ 1).await,
json!({
"type": "conversation.handoff.append",
"handoff_id": "handoff_channel",
"output_text": "background progress",
})
);
assert_eq!(
harness.sideband_outbound_request(/*request_index*/ 2).await,
json!({
"type": "conversation.handoff.append",
"handoff_id": "handoff_channel",
"output_text": "\"Agent Final Message\":\n\nbackground complete",
})
);
harness.shutdown().await;
Ok(())
}
#[tokio::test]
async fn webrtc_v1_handoff_request_delegates_context_and_manual_append_speaks() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -2138,114 +2223,123 @@ async fn websocket_v2_assistant_output_without_handoff_reaches_realtime_context(
}
#[tokio::test]
async fn websocket_v3_frameless_delegation_runs_codex_and_appends_context() -> Result<()> {
async fn websocket_v3_routes_handoffs_by_session_mode() -> Result<()> {
skip_if_no_network!(Ok(()));
let mut harness = RealtimeE2eHarness::new(
RealtimeTestVersion::V1,
main_loop_responses(vec![create_final_assistant_message_sse_response(
"delegated from frameless",
)?]),
realtime_sideband(vec![realtime_sideband_connection(vec![
vec![
session_started("sess_frameless"),
json!({
"type": "delegation.created",
"offset_ms": 100,
"item": {
"id": "delegation_frameless",
"type": "delegation",
"target": "client",
"content": [{
"type": "input_text",
"text": "delegate from frameless"
}]
}
}),
for (mode, expected_channels) in [
(None, [None, None, None, None]),
(
Some(CodexResponseHandoffMode::Commentary),
[
Some("commentary"),
Some("commentary"),
Some("commentary"),
Some("commentary"),
],
vec![],
vec![],
vec![],
])]),
)
.await?;
),
(
Some(CodexResponseHandoffMode::BemTags),
[
Some("commentary"),
Some("commentary"),
Some("speakable"),
Some("speakable"),
],
),
] {
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 = 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);
let fallback = responses::ev_assistant_message("msg-fallback", fallback_text);
let mut harness = RealtimeE2eHarness::new(
RealtimeTestVersion::V1,
main_loop_responses(vec![responses::sse(vec![
responses::ev_response_created("resp-1"),
analysis,
commentary,
final_answer,
fallback,
responses::ev_completed("resp-1"),
])]),
realtime_sideband(vec![realtime_sideband_connection(vec![
vec![
session_started("sess_frameless"),
json!({
"type": "delegation.created",
"offset_ms": 100,
"item": {
"id": "delegation_frameless",
"type": "delegation",
"target": "client",
"content": [{
"type": "input_text",
"text": "delegate from frameless"
}]
}
}),
],
vec![],
vec![],
vec![],
vec![],
vec![],
])]),
)
.await?;
let started = harness.start_frameless_bidi_realtime().await?;
assert_eq!(started.version, RealtimeConversationVersion::V3);
let started = harness.start_frameless_bidi_realtime(mode).await?;
assert_eq!(started.version, RealtimeConversationVersion::V3);
let _ = harness
.read_notification::<TurnCompletedNotification>("turn/completed")
.await?;
for (request_index, (text, channel)) in
[analysis_text, commentary_text, final_text, fallback_text]
.into_iter()
.zip(expected_channels)
.enumerate()
{
let mut expected = json!({
"type": "delegation.context.append",
"delegation_item_id": "delegation_frameless",
"content": [{
"type": "input_text",
"text": text
}]
});
if let Some(channel) = channel {
expected["channel"] = json!(channel);
}
assert_eq!(
harness
.sideband_outbound_request(/*request_index*/ request_index + 1)
.await,
expected
);
}
let session_update = harness.sideband_outbound_request(/*request_index*/ 0).await;
assert_eq!(session_update["type"], "session.update");
assert_eq!(session_update["session"]["delegation"]["type"], "client");
assert_eq!(
harness.realtime_server.single_handshake().uri(),
"/v1/live?model=gpt-live-1-boulder-alpha"
);
assert_eq!(
harness
.realtime_server
.single_handshake()
.header("openai-alpha")
.as_deref(),
Some("quicksilver=v2")
);
.append_speech(harness.thread_id.clone(), "manual spoken update")
.await?;
assert_eq!(
harness.sideband_outbound_request(/*request_index*/ 5).await,
json!({
"type": "session.context.append",
"content": [{
"type": "input_text",
"text": "manual spoken update"
}],
"channel": "speakable"
})
);
let turn_started = harness
.read_notification::<TurnStartedNotification>("turn/started")
.await?;
assert_eq!(turn_started.thread_id, harness.thread_id);
let turn_completed = harness
.read_notification::<TurnCompletedNotification>("turn/completed")
.await?;
assert_eq!(turn_completed.thread_id, harness.thread_id);
let requests = harness.main_loop_responses_requests().await?;
assert_eq!(requests.len(), 1);
assert!(
response_request_contains_text(&requests[0], "delegate from frameless"),
"delegated Responses request should contain Frameless input: {}",
requests[0]
);
assert_eq!(
harness.sideband_outbound_request(/*request_index*/ 1).await,
json!({
"type": "delegation.context.append",
"delegation_item_id": "delegation_frameless",
"content": [{
"type": "input_text",
"text": "\"Agent Final Message\":\n\ndelegated from frameless"
}]
})
);
harness
.append_speech(harness.thread_id.clone(), "standalone frameless update")
.await?;
assert_eq!(
harness.sideband_outbound_request(/*request_index*/ 2).await,
json!({
"type": "session.context.append",
"content": [{
"type": "input_text",
"text": "standalone frameless update"
}]
})
);
harness
.append_text(harness.thread_id.clone(), "frameless text context")
.await?;
assert_eq!(
harness.sideband_outbound_request(/*request_index*/ 3).await,
json!({
"type": "session.context.append",
"content": [{
"type": "input_text",
"text": "frameless text context"
}]
})
);
harness.shutdown().await;
harness.shutdown().await;
}
Ok(())
}
@@ -2913,6 +3007,7 @@ async fn realtime_webrtc_start_surfaces_backend_error() -> Result<()> {
flush_transcript_tail_on_session_end: None,
codex_responses_as_items: None,
codex_response_item_prefix: None,
codex_response_handoff_mode: None,
thread_id: thread_start.thread.id,
model: None,
output_modality: RealtimeOutputModality::Audio,
@@ -2982,6 +3077,7 @@ async fn realtime_conversation_requires_feature_flag() -> Result<()> {
flush_transcript_tail_on_session_end: None,
codex_responses_as_items: None,
codex_response_item_prefix: None,
codex_response_handoff_mode: None,
thread_id: thread_start.thread.id.clone(),
model: None,
output_modality: RealtimeOutputModality::Audio,
+1
View File
@@ -15,6 +15,7 @@ pub use memories::MemoriesClient;
pub use models::ModelsClient;
pub use realtime_call::RealtimeCallClient;
pub use realtime_call::RealtimeCallResponse;
pub use realtime_websocket::RealtimeContextAppendChannel;
pub use realtime_websocket::RealtimeEventParser;
pub use realtime_websocket::RealtimeOutputModality;
pub use realtime_websocket::RealtimeSessionConfig;
@@ -9,6 +9,7 @@ use crate::endpoint::realtime_websocket::methods_frameless_bidi::context_append_
use crate::endpoint::realtime_websocket::methods_frameless_bidi::delegation_context_append_message as frameless_delegation_context_append_message;
use crate::endpoint::realtime_websocket::methods_frameless_bidi::session_context_append_message as frameless_session_context_append_message;
use crate::endpoint::realtime_websocket::protocol::RealtimeAudioFrame;
use crate::endpoint::realtime_websocket::protocol::RealtimeContextAppendChannel;
use crate::endpoint::realtime_websocket::protocol::RealtimeEvent;
use crate::endpoint::realtime_websocket::protocol::RealtimeEventParser;
use crate::endpoint::realtime_websocket::protocol::RealtimeOutboundMessage;
@@ -211,6 +212,7 @@ pub struct RealtimeWebsocketWriter {
stream: Arc<WsStream>,
is_closed: Arc<AtomicBool>,
event_parser: RealtimeEventParser,
context_append_channel: Option<RealtimeContextAppendChannel>,
}
#[derive(Clone)]
@@ -281,6 +283,7 @@ impl RealtimeWebsocketConnection {
stream: Arc::clone(&stream),
is_closed: Arc::clone(&is_closed),
event_parser,
context_append_channel: None,
},
events: RealtimeWebsocketEvents {
rx_message,
@@ -294,6 +297,11 @@ impl RealtimeWebsocketConnection {
}
impl RealtimeWebsocketWriter {
pub fn with_context_append_channel(mut self, channel: RealtimeContextAppendChannel) -> Self {
self.context_append_channel = Some(channel);
self
}
pub async fn send_audio_frame(&self, frame: RealtimeAudioFrame) -> Result<(), ApiError> {
let message = match self.event_parser {
RealtimeEventParser::V1 | RealtimeEventParser::RealtimeV2 => {
@@ -315,6 +323,7 @@ impl RealtimeWebsocketWriter {
self.event_parser,
text,
role,
self.context_append_channel,
))
.await
}
@@ -328,6 +337,7 @@ impl RealtimeWebsocketWriter {
self.event_parser,
handoff_id,
output_text,
self.context_append_channel,
))
.await
}
@@ -341,6 +351,7 @@ impl RealtimeWebsocketWriter {
self.event_parser,
handoff_id,
output_text,
self.context_append_channel,
))
.await
}
@@ -354,6 +365,7 @@ impl RealtimeWebsocketWriter {
self.event_parser,
call_id,
output_text,
self.context_append_channel,
))
.await
}
@@ -413,6 +425,7 @@ impl RealtimeWebsocketWriter {
match message {
RealtimeOutboundMessage::DelegationContextAppend {
delegation_item_id,
channel,
content,
} => {
if let Some(content) = content.first() {
@@ -420,17 +433,20 @@ impl RealtimeWebsocketWriter {
self.send_json_frame(&frameless_delegation_context_append_message(
delegation_item_id.clone(),
chunk,
*channel,
))
.await?;
}
return Ok(());
}
}
RealtimeOutboundMessage::SessionContextAppend { content } => {
RealtimeOutboundMessage::SessionContextAppend { channel, content } => {
if let Some(content) = content.first() {
for chunk in context_append_chunks(&content.text) {
self.send_json_frame(&frameless_session_context_append_message(chunk))
.await?;
self.send_json_frame(&frameless_session_context_append_message(
chunk, *channel,
))
.await?;
}
return Ok(());
}
@@ -10,6 +10,7 @@ use crate::endpoint::realtime_websocket::methods_v2::conversation_function_call_
use crate::endpoint::realtime_websocket::methods_v2::conversation_item_create_message as v2_conversation_item_create_message;
use crate::endpoint::realtime_websocket::methods_v2::session_update_session as v2_session_update_session;
use crate::endpoint::realtime_websocket::methods_v2::websocket_intent as v2_websocket_intent;
use crate::endpoint::realtime_websocket::protocol::RealtimeContextAppendChannel;
use crate::endpoint::realtime_websocket::protocol::RealtimeOutboundMessage;
use crate::endpoint::realtime_websocket::protocol::RealtimeOutputModality;
use crate::endpoint::realtime_websocket::protocol::RealtimeSessionConfig;
@@ -40,10 +41,13 @@ pub(super) fn conversation_item_create_message(
wire_adapter: RealtimeWireAdapter,
text: String,
role: ConversationTextRole,
context_append_channel: Option<RealtimeContextAppendChannel>,
) -> RealtimeOutboundMessage {
match wire_adapter {
RealtimeWireAdapter::V1 => v1_conversation_item_create_message(text, role),
RealtimeWireAdapter::FramelessBidi => frameless_session_context_append_message(text),
RealtimeWireAdapter::FramelessBidi => {
frameless_session_context_append_message(text, context_append_channel)
}
RealtimeWireAdapter::RealtimeV2 => v2_conversation_item_create_message(text, role),
}
}
@@ -52,12 +56,15 @@ pub(super) fn conversation_handoff_append_message(
wire_adapter: RealtimeWireAdapter,
handoff_id: String,
output_text: String,
context_append_channel: Option<RealtimeContextAppendChannel>,
) -> RealtimeOutboundMessage {
match wire_adapter {
RealtimeWireAdapter::V1 => v1_conversation_handoff_append_message(handoff_id, output_text),
RealtimeWireAdapter::FramelessBidi => {
frameless_delegation_context_append_message(handoff_id, output_text)
}
RealtimeWireAdapter::FramelessBidi => frameless_delegation_context_append_message(
handoff_id,
output_text,
context_append_channel,
),
RealtimeWireAdapter::RealtimeV2 => {
unreachable!("realtime v2 does not send conversation handoff output")
}
@@ -68,10 +75,13 @@ pub(super) fn standalone_handoff_message(
wire_adapter: RealtimeWireAdapter,
handoff_id: String,
output_text: String,
context_append_channel: Option<RealtimeContextAppendChannel>,
) -> RealtimeOutboundMessage {
match wire_adapter {
RealtimeWireAdapter::V1 => v1_conversation_handoff_append_message(handoff_id, output_text),
RealtimeWireAdapter::FramelessBidi => frameless_session_context_append_message(output_text),
RealtimeWireAdapter::FramelessBidi => {
frameless_session_context_append_message(output_text, context_append_channel)
}
RealtimeWireAdapter::RealtimeV2 => {
unreachable!("realtime v2 does not send standalone handoff output")
}
@@ -82,6 +92,7 @@ pub(super) fn conversation_function_call_output_message(
wire_adapter: RealtimeWireAdapter,
call_id: String,
output_text: String,
context_append_channel: Option<RealtimeContextAppendChannel>,
) -> RealtimeOutboundMessage {
match wire_adapter {
RealtimeWireAdapter::V1 => v1_conversation_handoff_append_message(
@@ -90,7 +101,8 @@ pub(super) fn conversation_function_call_output_message(
),
RealtimeWireAdapter::FramelessBidi => frameless_delegation_context_append_message(
call_id,
format!("{AGENT_FINAL_MESSAGE_PREFIX}{output_text}"),
output_text,
context_append_channel,
),
RealtimeWireAdapter::RealtimeV2 => {
v2_conversation_function_call_output_message(call_id, output_text)
@@ -1,6 +1,7 @@
use super::conversation_function_call_output_message;
use super::conversation_handoff_append_message;
use super::standalone_handoff_message;
use crate::endpoint::realtime_websocket::protocol::RealtimeContextAppendChannel;
use crate::endpoint::realtime_websocket::protocol::RealtimeWireAdapter;
use pretty_assertions::assert_eq;
use serde_json::Value;
@@ -8,16 +9,18 @@ use serde_json::json;
use serde_json::to_value;
#[test]
fn identical_handoff_output_encodes_for_each_bidi_wire_protocol() {
fn context_append_channel_only_encodes_for_frameless_handoff_output() {
let legacy = conversation_handoff_append_message(
RealtimeWireAdapter::V1,
"handoff-123".to_string(),
"The result".to_string(),
Some(RealtimeContextAppendChannel::Commentary),
);
let frameless = conversation_handoff_append_message(
RealtimeWireAdapter::FramelessBidi,
"handoff-123".to_string(),
"The result".to_string(),
Some(RealtimeContextAppendChannel::Commentary),
);
assert_eq!(
@@ -33,6 +36,7 @@ fn identical_handoff_output_encodes_for_each_bidi_wire_protocol() {
json!({
"type": "delegation.context.append",
"delegation_item_id": "handoff-123",
"channel": "commentary",
"content": [{"type": "input_text", "text": "The result"}],
})
);
@@ -44,11 +48,13 @@ fn standalone_handoff_uses_session_context_for_frameless() {
RealtimeWireAdapter::V1,
"codex".to_string(),
"Speak this".to_string(),
Some(RealtimeContextAppendChannel::Speakable),
);
let frameless = standalone_handoff_message(
RealtimeWireAdapter::FramelessBidi,
"codex".to_string(),
"Speak this".to_string(),
Some(RealtimeContextAppendChannel::Speakable),
);
assert_eq!(
@@ -63,19 +69,20 @@ fn standalone_handoff_uses_session_context_for_frameless() {
to_value(frameless).expect("frameless standalone handoff should serialize"),
json!({
"type": "session.context.append",
"channel": "speakable",
"content": [{"type": "input_text", "text": "Speak this"}],
})
);
}
#[test]
fn completed_handoff_preserves_legacy_payload_text_in_frameless() {
let expected_text = Value::String("\"Agent Final Message\":\n\nDone".to_string());
fn completed_handoff_only_prefixes_v1_payload_text() {
for wire_adapter in [RealtimeWireAdapter::V1, RealtimeWireAdapter::FramelessBidi] {
let encoded = to_value(conversation_function_call_output_message(
wire_adapter,
"handoff-123".to_string(),
"Done".to_string(),
Some(RealtimeContextAppendChannel::Speakable),
))
.expect("handoff output should serialize");
let text = match wire_adapter {
@@ -83,6 +90,23 @@ fn completed_handoff_preserves_legacy_payload_text_in_frameless() {
RealtimeWireAdapter::FramelessBidi => &encoded["content"][0]["text"],
RealtimeWireAdapter::RealtimeV2 => unreachable!(),
};
assert_eq!(text, &expected_text);
assert_eq!(
text,
&Value::String(match wire_adapter {
RealtimeWireAdapter::V1 => "\"Agent Final Message\":\n\nDone".to_string(),
RealtimeWireAdapter::FramelessBidi => "Done".to_string(),
RealtimeWireAdapter::RealtimeV2 => unreachable!(),
})
);
assert_eq!(
encoded.get("channel").cloned(),
match wire_adapter {
RealtimeWireAdapter::V1 => None,
RealtimeWireAdapter::FramelessBidi => {
Some(Value::String("speakable".to_string()))
}
RealtimeWireAdapter::RealtimeV2 => unreachable!(),
}
);
}
}
@@ -1,5 +1,6 @@
use crate::endpoint::realtime_websocket::protocol::FramelessContentType;
use crate::endpoint::realtime_websocket::protocol::FramelessInputTextContent;
use crate::endpoint::realtime_websocket::protocol::RealtimeContextAppendChannel;
use crate::endpoint::realtime_websocket::protocol::RealtimeOutboundMessage;
use crate::endpoint::realtime_websocket::protocol::RealtimeVoice;
use serde_json::Value;
@@ -10,15 +11,21 @@ const CONTEXT_APPEND_MAX_BYTES: usize = 500;
pub(super) fn delegation_context_append_message(
delegation_item_id: String,
text: String,
channel: Option<RealtimeContextAppendChannel>,
) -> RealtimeOutboundMessage {
RealtimeOutboundMessage::DelegationContextAppend {
delegation_item_id,
channel,
content: input_text_content(text),
}
}
pub(super) fn session_context_append_message(text: String) -> RealtimeOutboundMessage {
pub(super) fn session_context_append_message(
text: String,
channel: Option<RealtimeContextAppendChannel>,
) -> RealtimeOutboundMessage {
RealtimeOutboundMessage::SessionContextAppend {
channel,
content: input_text_content(text),
}
}
@@ -14,6 +14,7 @@ pub use methods::RealtimeWebsocketConnection;
pub use methods::RealtimeWebsocketEvents;
pub use methods::RealtimeWebsocketWriter;
pub use methods_common::session_update_session_json;
pub use protocol::RealtimeContextAppendChannel;
pub use protocol::RealtimeEventParser;
pub use protocol::RealtimeOutputModality;
pub use protocol::RealtimeSessionConfig;
@@ -25,6 +25,14 @@ pub enum RealtimeSessionMode {
Transcription,
}
/// Selects the semantic stream used for Frameless Bidi context appends.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum RealtimeContextAppendChannel {
Speakable,
Commentary,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RealtimeSessionConfig {
pub instructions: String,
@@ -51,10 +59,14 @@ pub(super) enum RealtimeOutboundMessage {
#[serde(rename = "delegation.context.append")]
DelegationContextAppend {
delegation_item_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
channel: Option<RealtimeContextAppendChannel>,
content: Vec<FramelessInputTextContent>,
},
#[serde(rename = "session.context.append")]
SessionContextAppend {
#[serde(skip_serializing_if = "Option::is_none")]
channel: Option<RealtimeContextAppendChannel>,
content: Vec<FramelessInputTextContent>,
},
#[serde(rename = "session.close")]
+1
View File
@@ -52,6 +52,7 @@ pub use crate::endpoint::MemoriesClient;
pub use crate::endpoint::ModelsClient;
pub use crate::endpoint::RealtimeCallClient;
pub use crate::endpoint::RealtimeCallResponse;
pub use crate::endpoint::RealtimeContextAppendChannel;
pub use crate::endpoint::RealtimeEventParser;
pub use crate::endpoint::RealtimeOutputModality;
pub use crate::endpoint::RealtimeSessionConfig;
+279 -31
View File
@@ -16,6 +16,7 @@ use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use codex_api::ApiError;
use codex_api::Provider as ApiProvider;
use codex_api::RealtimeAudioFrame;
use codex_api::RealtimeContextAppendChannel;
use codex_api::RealtimeEvent;
use codex_api::RealtimeEventParser;
use codex_api::RealtimeSessionConfig;
@@ -36,6 +37,7 @@ use codex_protocol::error::CodexErr;
use codex_protocol::error::Result as CodexResult;
use codex_protocol::models::MessagePhase;
use codex_protocol::protocol::CodexErrorInfo;
use codex_protocol::protocol::CodexResponseHandoffMode;
use codex_protocol::protocol::ConversationAudioParams;
use codex_protocol::protocol::ConversationSpeechParams;
use codex_protocol::protocol::ConversationStartParams;
@@ -74,6 +76,11 @@ use tracing::error;
use tracing::info;
use tracing::warn;
mod bem;
use self::bem::ChannelParser as BemChannelParser;
use self::bem::message_phase as bem_message_phase;
const AUDIO_IN_QUEUE_CAPACITY: usize = 256;
const TEXT_IN_QUEUE_CAPACITY: usize = 64;
const HANDOFF_OUT_QUEUE_CAPACITY: usize = 64;
@@ -121,15 +128,22 @@ enum RealtimeSessionKind {
#[derive(Clone, Debug)]
struct RealtimeHandoffState {
output_tx: Sender<RealtimeOutbound>,
last_output_text: Arc<Mutex<Option<String>>>,
last_output: Arc<Mutex<Option<RealtimeHandoffOutput>>>,
stream: Arc<Mutex<RealtimeHandoffStreamState>>,
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,
}
#[derive(Clone, Debug)]
struct RealtimeHandoffOutput {
text: String,
phase: Option<MessagePhase>,
}
#[derive(Debug, Default)]
struct RealtimeHandoffStreamState {
active_handoff: Option<String>,
@@ -140,6 +154,8 @@ struct RealtimeHandoffStreamState {
struct RealtimeStreamedItem {
handoff_id: String,
phase: Option<MessagePhase>,
bem_channel_parser: Option<BemChannelParser>,
prefix_final_message: bool,
sent_bytes: usize,
buffered_text: String,
tail_text: String,
@@ -154,7 +170,10 @@ impl RealtimeStreamedItem {
}
fn output_prefix(&self) -> &'static str {
if self.sent_bytes == 0 && !matches!(self.phase, Some(MessagePhase::Commentary)) {
if self.prefix_final_message
&& self.sent_bytes == 0
&& !matches!(self.phase, Some(MessagePhase::Commentary))
{
AGENT_FINAL_MESSAGE_PREFIX
} else {
""
@@ -182,6 +201,36 @@ impl RealtimeStreamedItem {
if text.is_empty() {
return;
}
let text = if let Some(parser) = self.bem_channel_parser.as_mut() {
let Some(text) = parser.push(text) else {
return;
};
self.phase = parser.phase();
text
} else {
text.to_string()
};
self.push_output_text(&text);
}
fn finish_input(&mut self) {
let Some(parser) = self.bem_channel_parser.as_mut() else {
return;
};
self.phase = parser.phase();
let text = parser.finish();
if self.phase.is_none() && !text.is_empty() {
warn!("BEM output ended before a recognized channel header was received");
self.phase = Some(MessagePhase::FinalAnswer);
}
self.push_output_text(&text);
}
fn push_output_text(&mut self, text: &str) {
if text.is_empty() {
return;
}
if self.truncated {
self.tail_text.push_str(text);
self.tail_text =
@@ -256,12 +305,35 @@ fn take_last_bytes_at_char_boundary(text: &str, max_bytes: usize) -> &str {
#[derive(Debug, PartialEq, Eq)]
enum RealtimeOutbound {
StandaloneHandoff { text: String },
HandoffUpdate { handoff_id: String, text: String },
HandoffAppend { handoff_id: String, text: String },
CompletedHandoff { handoff_id: String, text: String },
ConversationItem { text: String },
HandoffCompleteAck { handoff_id: String },
StandaloneHandoff {
text: String,
phase: Option<MessagePhase>,
},
StandaloneSpeech {
text: String,
},
HandoffUpdate {
handoff_id: String,
text: String,
phase: Option<MessagePhase>,
},
HandoffAppend {
handoff_id: String,
text: String,
phase: Option<MessagePhase>,
},
CompletedHandoff {
handoff_id: String,
text: String,
phase: Option<MessagePhase>,
},
ConversationItem {
text: String,
phase: Option<MessagePhase>,
},
HandoffCompleteAck {
handoff_id: String,
},
}
#[derive(Debug, PartialEq, Eq)]
@@ -359,16 +431,18 @@ impl RealtimeHandoffState {
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_text: Arc::new(Mutex::new(None)),
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,
}
@@ -379,6 +453,11 @@ impl RealtimeHandoffState {
&& !self.client_managed_handoffs
&& !self.codex_responses_as_items
}
fn routes_handoff_by_bem(&self) -> bool {
self.event_parser == RealtimeEventParser::FramelessBidi
&& self.codex_response_handoff_mode == CodexResponseHandoffMode::BemTags
}
}
#[allow(dead_code)]
@@ -400,6 +479,7 @@ struct RealtimeStart {
flush_transcript_tail_on_session_end: bool,
codex_responses_as_items: bool,
codex_response_item_prefix: Option<String>,
codex_response_handoff_mode: CodexResponseHandoffMode,
realtime_call_api_provider: Option<ApiProvider>,
session_config: RealtimeSessionConfig,
model_client: ModelClient,
@@ -458,6 +538,7 @@ impl RealtimeConversationManager {
flush_transcript_tail_on_session_end,
codex_responses_as_items,
codex_response_item_prefix,
codex_response_handoff_mode,
realtime_call_api_provider,
session_config,
model_client,
@@ -486,6 +567,7 @@ impl RealtimeConversationManager {
client_managed_handoffs,
codex_responses_as_items,
codex_response_item_prefix,
codex_response_handoff_mode,
session_kind,
event_parser,
);
@@ -668,28 +750,45 @@ impl RealtimeConversationManager {
if handoff.client_managed_handoffs {
return Ok(());
}
let phase = if handoff.routes_handoff_by_bem() {
match bem_message_phase(&output_text) {
Some(phase) => Some(phase),
None => {
warn!("BEM output did not contain a recognized channel header");
Some(MessagePhase::FinalAnswer)
}
}
} else {
phase
};
let is_commentary = matches!(phase, Some(MessagePhase::Commentary));
let active_handoff = handoff.stream.lock().await.active_handoff.clone();
let output = match active_handoff {
Some(handoff_id) => {
let output_text = realtime_backend_output(output_text, handoff.session_kind);
*handoff.last_output_text.lock().await = Some(output_text.clone());
*handoff.last_output.lock().await = Some(RealtimeHandoffOutput {
text: output_text.clone(),
phase: phase.clone(),
});
if handoff.codex_responses_as_items {
RealtimeOutbound::ConversationItem {
text: realtime_backend_item(
output_text,
handoff.codex_response_item_prefix.as_deref(),
),
phase,
}
} else if handoff.session_kind == RealtimeSessionKind::V1 && is_commentary {
} else if handoff.event_parser == RealtimeEventParser::V1 && is_commentary {
RealtimeOutbound::HandoffAppend {
handoff_id,
text: output_text,
phase,
}
} else {
RealtimeOutbound::HandoffUpdate {
handoff_id,
text: output_text,
phase,
}
}
}
@@ -702,14 +801,16 @@ impl RealtimeConversationManager {
output_text,
handoff.codex_response_item_prefix.as_deref(),
),
phase,
}
} else {
RealtimeOutbound::StandaloneHandoff {
text: if handoff.session_kind == RealtimeSessionKind::V1 && !is_commentary {
text: if handoff.event_parser == RealtimeEventParser::V1 && !is_commentary {
format!("{AGENT_FINAL_MESSAGE_PREFIX}{output_text}")
} else {
output_text
},
phase,
}
}
}
@@ -745,7 +846,15 @@ impl RealtimeConversationManager {
};
let mut streamed_item = RealtimeStreamedItem {
handoff_id,
phase,
phase: if handoff.routes_handoff_by_bem() {
None
} else {
phase
},
bem_channel_parser: handoff
.routes_handoff_by_bem()
.then(BemChannelParser::default),
prefix_final_message: handoff.event_parser == RealtimeEventParser::V1,
sent_bytes: 0,
buffered_text: String::new(),
tail_text: String::new(),
@@ -821,6 +930,7 @@ impl RealtimeConversationManager {
let Some(mut streamed_item) = handoff.stream.lock().await.items.remove(item_id) else {
return false;
};
streamed_item.finish_input();
let chunk = streamed_item.drain_final_chunk();
let sent_output = streamed_item.sent_bytes > 0;
if let Some(text) = chunk {
@@ -829,6 +939,7 @@ impl RealtimeConversationManager {
.send(RealtimeOutbound::HandoffAppend {
handoff_id: streamed_item.handoff_id,
text,
phase: streamed_item.phase,
})
.await;
}
@@ -852,7 +963,7 @@ impl RealtimeConversationManager {
handoff
.output_tx
.send(RealtimeOutbound::StandaloneHandoff {
.send(RealtimeOutbound::StandaloneSpeech {
text: realtime_backend_output(text, handoff.session_kind),
})
.await
@@ -879,7 +990,7 @@ impl RealtimeConversationManager {
let Some(handoff_id) = handoff.stream.lock().await.active_handoff.clone() else {
return Ok(());
};
let Some(output_text) = handoff.last_output_text.lock().await.clone() else {
let Some(last_output) = handoff.last_output.lock().await.clone() else {
return Ok(());
};
@@ -888,7 +999,8 @@ impl RealtimeConversationManager {
} else {
RealtimeOutbound::CompletedHandoff {
handoff_id,
text: output_text,
text: last_output.text,
phase: last_output.phase,
}
};
@@ -910,7 +1022,7 @@ impl RealtimeConversationManager {
stream.active_handoff = None;
stream.items.clear();
}
*handoff.last_output_text.lock().await = None;
*handoff.last_output.lock().await = None;
}
}
@@ -987,6 +1099,7 @@ struct PreparedRealtimeConversationStart {
flush_transcript_tail_on_session_end: bool,
codex_responses_as_items: bool,
codex_response_item_prefix: Option<String>,
codex_response_handoff_mode: CodexResponseHandoffMode,
realtime_call_api_provider: Option<ApiProvider>,
requested_realtime_session_id: Option<String>,
version: RealtimeWsVersion,
@@ -1072,6 +1185,7 @@ async fn prepare_realtime_start(
flush_transcript_tail_on_session_end: params.flush_transcript_tail_on_session_end,
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,
realtime_call_api_provider,
requested_realtime_session_id,
version,
@@ -1243,6 +1357,7 @@ async fn handle_start_inner(
flush_transcript_tail_on_session_end,
codex_responses_as_items,
codex_response_item_prefix,
codex_response_handoff_mode,
realtime_call_api_provider,
requested_realtime_session_id,
version,
@@ -1261,6 +1376,7 @@ async fn handle_start_inner(
flush_transcript_tail_on_session_end,
codex_responses_as_items,
codex_response_item_prefix,
codex_response_handoff_mode,
realtime_call_api_provider,
session_config,
model_client: sess.services.model_client.clone(),
@@ -1700,7 +1816,7 @@ async fn handle_text_input(
}
async fn flush_streamed_handoff_item(handoff: &RealtimeHandoffState, item_id: &str) {
let (handoff_id, text) = {
let (handoff_id, text, phase) = {
let mut stream = handoff.stream.lock().await;
let Some(streamed_item) = stream.items.get_mut(item_id) else {
return;
@@ -1710,11 +1826,19 @@ async fn flush_streamed_handoff_item(handoff: &RealtimeHandoffState, item_id: &s
return;
};
streamed_item.last_flush_at = Instant::now();
(streamed_item.handoff_id.clone(), text)
(
streamed_item.handoff_id.clone(),
text,
streamed_item.phase.clone(),
)
};
let _ = handoff
.output_tx
.send(RealtimeOutbound::HandoffAppend { handoff_id, text })
.send(RealtimeOutbound::HandoffAppend {
handoff_id,
text,
phase,
})
.await;
}
@@ -1730,6 +1854,26 @@ fn schedule_streamed_handoff_flush(
});
}
fn v3_output_writer(
writer: &RealtimeWebsocketWriter,
phase: Option<&MessagePhase>,
handoff_mode: CodexResponseHandoffMode,
) -> RealtimeWebsocketWriter {
let channel = match handoff_mode {
CodexResponseHandoffMode::Thinking => None,
CodexResponseHandoffMode::Commentary => Some(RealtimeContextAppendChannel::Commentary),
CodexResponseHandoffMode::BemTags => match phase {
Some(MessagePhase::FinalAnswer) => Some(RealtimeContextAppendChannel::Speakable),
Some(MessagePhase::Commentary) => Some(RealtimeContextAppendChannel::Commentary),
None => Some(RealtimeContextAppendChannel::Speakable),
},
};
match channel {
Some(channel) => writer.clone().with_context_append_channel(channel),
None => writer.clone(),
}
}
async fn handle_handoff_output(
handoff_output: Result<RealtimeOutbound, RecvError>,
writer: &RealtimeWebsocketWriter,
@@ -1739,34 +1883,117 @@ async fn handle_handoff_output(
response_create_queue: &mut RealtimeResponseCreateQueue,
) -> anyhow::Result<()> {
let handoff_output = handoff_output.context("handoff output channel closed")?;
let result = match event_parser {
RealtimeEventParser::V1 | RealtimeEventParser::FramelessBidi => match handoff_output {
RealtimeOutbound::StandaloneHandoff { text } => {
RealtimeEventParser::V1 => match handoff_output {
RealtimeOutbound::StandaloneHandoff { text, phase: _ } => {
writer
.send_standalone_handoff(STANDALONE_HANDOFF_ID.to_string(), text)
.await
}
RealtimeOutbound::HandoffUpdate { handoff_id, text }
| RealtimeOutbound::CompletedHandoff { handoff_id, text } => {
RealtimeOutbound::StandaloneSpeech { text } => {
writer
.send_standalone_handoff(STANDALONE_HANDOFF_ID.to_string(), text)
.await
}
RealtimeOutbound::HandoffUpdate {
handoff_id,
text,
phase: _,
}
| RealtimeOutbound::CompletedHandoff {
handoff_id,
text,
phase: _,
} => {
writer
.send_conversation_function_call_output(handoff_id, text)
.await
}
RealtimeOutbound::HandoffAppend { handoff_id, text } => {
RealtimeOutbound::HandoffAppend {
handoff_id,
text,
phase: _,
} => {
writer
.send_conversation_handoff_append(handoff_id, text)
.await
}
RealtimeOutbound::ConversationItem { text } => {
RealtimeOutbound::ConversationItem { text, phase: _ } => {
writer
.send_conversation_item_create(text, ConversationTextRole::Developer)
.await
}
RealtimeOutbound::HandoffCompleteAck { .. } => Ok(()),
},
RealtimeEventParser::FramelessBidi => match handoff_output {
RealtimeOutbound::StandaloneHandoff { text, phase } => {
v3_output_writer(
writer,
phase.as_ref(),
handoff_state.codex_response_handoff_mode,
)
.send_standalone_handoff(STANDALONE_HANDOFF_ID.to_string(), text)
.await
}
RealtimeOutbound::StandaloneSpeech { text } => {
writer
.clone()
.with_context_append_channel(RealtimeContextAppendChannel::Speakable)
.send_standalone_handoff(STANDALONE_HANDOFF_ID.to_string(), text)
.await
}
RealtimeOutbound::HandoffUpdate {
handoff_id,
text,
phase,
} => {
v3_output_writer(
writer,
phase.as_ref(),
handoff_state.codex_response_handoff_mode,
)
.send_conversation_function_call_output(handoff_id, text)
.await
}
RealtimeOutbound::HandoffAppend {
handoff_id,
text,
phase,
} => {
v3_output_writer(
writer,
phase.as_ref(),
handoff_state.codex_response_handoff_mode,
)
.send_conversation_handoff_append(handoff_id, text)
.await
}
RealtimeOutbound::CompletedHandoff {
handoff_id,
text,
phase,
} => {
v3_output_writer(
writer,
phase.as_ref(),
handoff_state.codex_response_handoff_mode,
)
.send_conversation_function_call_output(handoff_id, text)
.await
}
RealtimeOutbound::ConversationItem { text, phase } => {
v3_output_writer(
writer,
phase.as_ref(),
handoff_state.codex_response_handoff_mode,
)
.send_conversation_item_create(text, ConversationTextRole::Developer)
.await
}
RealtimeOutbound::HandoffCompleteAck { .. } => Ok(()),
},
RealtimeEventParser::RealtimeV2 => match handoff_output {
RealtimeOutbound::StandaloneHandoff { text } => {
RealtimeOutbound::StandaloneHandoff { text, phase: _ } => {
if let Err(err) = writer
.send_conversation_item_create(text, ConversationTextRole::User)
.await
@@ -1778,8 +2005,28 @@ async fn handle_handoff_output(
.await;
}
}
RealtimeOutbound::HandoffUpdate { handoff_id, text }
| RealtimeOutbound::HandoffAppend { handoff_id, text } => {
RealtimeOutbound::StandaloneSpeech { text } => {
if let Err(err) = writer
.send_conversation_item_create(text, ConversationTextRole::User)
.await
{
Err(err)
} else {
return response_create_queue
.request_create(writer, events_tx, "standalone handoff")
.await;
}
}
RealtimeOutbound::HandoffUpdate {
handoff_id,
text,
phase: _,
}
| RealtimeOutbound::HandoffAppend {
handoff_id,
text,
phase: _,
} => {
let active_handoff = handoff_state.stream.lock().await.active_handoff.clone();
match active_handoff {
Some(active_handoff) if active_handoff == handoff_id => {}
@@ -1795,6 +2042,7 @@ async fn handle_handoff_output(
RealtimeOutbound::CompletedHandoff {
handoff_id,
text: _,
phase: _,
} => {
if let Err(err) = writer
.send_conversation_function_call_output(
@@ -1810,7 +2058,7 @@ async fn handle_handoff_output(
.await;
}
}
RealtimeOutbound::ConversationItem { text } => {
RealtimeOutbound::ConversationItem { text, phase: _ } => {
writer
.send_conversation_item_create(text, ConversationTextRole::Developer)
.await
@@ -0,0 +1,49 @@
use codex_protocol::models::MessagePhase;
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,
}
}
/// Buffers a streamed BEM message until its channel header is complete.
///
/// Once the channel is known, the original envelope is released unchanged so
/// the frontend model can distinguish BEM `analysis` from `commentary`.
#[derive(Debug, Default)]
pub(super) struct ChannelParser {
buffered_text: String,
phase: Option<MessagePhase>,
}
impl ChannelParser {
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.as_ref()?;
Some(std::mem::take(&mut self.buffered_text))
}
pub(super) fn phase(&self) -> Option<MessagePhase> {
self.phase.clone()
}
pub(super) fn finish(&mut self) -> String {
std::mem::take(&mut self.buffered_text)
}
}
#[cfg(test)]
#[path = "bem_tests.rs"]
mod tests;
@@ -0,0 +1,42 @@
use super::ChannelParser;
use super::message_phase;
use codex_protocol::models::MessagePhase;
use pretty_assertions::assert_eq;
#[test]
fn maps_bem_channels_to_realtime_phases() {
for (channel, expected) in [
("analysis", MessagePhase::Commentary),
("commentary", MessagePhase::Commentary),
("final", MessagePhase::FinalAnswer),
] {
assert_eq!(
message_phase(&format!(
"<|start|>assistant<|channel|>{channel}<|message|>text<|end|>"
)),
Some(expected)
);
}
}
#[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("mentary<|message|>progress"),
Some("<|start|>assistant<|channel|>commentary<|message|>progress".to_string())
);
assert_eq!(parser.phase(), Some(MessagePhase::Commentary));
assert_eq!(parser.push("<|end|>"), Some("<|end|>".to_string()));
}
#[test]
fn preserves_unrecognized_output_when_the_stream_finishes() {
let mut parser = ChannelParser::default();
assert_eq!(parser.push("plain output"), None);
assert_eq!(parser.finish(), "plain output");
assert_eq!(parser.phase(), None);
}
@@ -11,6 +11,7 @@ use crate::context::RealtimeDelegationSource;
use async_channel::bounded;
use codex_api::RealtimeEventParser;
use codex_protocol::models::MessagePhase;
use codex_protocol::protocol::CodexResponseHandoffMode;
use codex_protocol::protocol::RealtimeHandoffRequested;
use codex_protocol::protocol::RealtimeTranscriptEntry;
use pretty_assertions::assert_eq;
@@ -151,6 +152,7 @@ async fn clears_active_handoff_explicitly() {
/*client_managed_handoffs*/ false,
/*codex_responses_as_items*/ false,
/*codex_response_item_prefix*/ None,
CodexResponseHandoffMode::Thinking,
RealtimeSessionKind::V1,
/*event_parser*/ RealtimeEventParser::V1,
);
@@ -170,6 +172,8 @@ fn streamed_handoff_preserves_a_bounded_final_tail() {
let mut item = RealtimeStreamedItem {
handoff_id: "handoff_1".to_string(),
phase: Some(MessagePhase::FinalAnswer),
bem_channel_parser: None,
prefix_final_message: true,
sent_bytes: 0,
buffered_text: String::new(),
tail_text: String::new(),
@@ -193,6 +197,25 @@ fn streamed_handoff_preserves_a_bounded_final_tail() {
assert!(output.ends_with("TAIL"));
}
#[test]
fn streamed_v3_handoff_omits_the_final_message_prefix() {
let mut item = RealtimeStreamedItem {
handoff_id: "handoff_1".to_string(),
phase: Some(MessagePhase::FinalAnswer),
bem_channel_parser: None,
prefix_final_message: false,
sent_bytes: 0,
buffered_text: String::new(),
tail_text: String::new(),
truncated: false,
last_flush_at: Instant::now(),
flush_scheduled: false,
};
item.push_text("done");
assert_eq!(item.drain_final_chunk(), Some("done".to_string()));
}
#[test]
fn uses_quicksilver_alpha_header_for_realtime_v1() {
let headers = realtime_request_headers(
@@ -212,6 +212,8 @@ async fn start_realtime_conversation(codex: &codex_core::CodexThread) -> Result<
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_mode:
codex_protocol::protocol::CodexResponseHandoffMode::Thinking,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -287,6 +287,8 @@ async fn conversation_start_audio_text_close_round_trip() -> Result<()> {
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_mode:
codex_protocol::protocol::CodexResponseHandoffMode::Thinking,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -433,6 +435,8 @@ async fn conversation_start_defaults_to_v2_and_gpt_realtime_1_5() -> Result<()>
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_mode:
codex_protocol::protocol::CodexResponseHandoffMode::Thinking,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -528,6 +532,8 @@ async fn conversation_webrtc_start_posts_generated_session() -> Result<()> {
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_mode:
codex_protocol::protocol::CodexResponseHandoffMode::Thinking,
model: Some("session-override-model".to_string()),
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -715,6 +721,8 @@ async fn conversation_webrtc_start_uses_avas_query() -> Result<()> {
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_mode:
codex_protocol::protocol::CodexResponseHandoffMode::Thinking,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -813,6 +821,8 @@ async fn conversation_webrtc_default_v1_ignores_configured_v2_voice() -> Result<
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_mode:
codex_protocol::protocol::CodexResponseHandoffMode::Thinking,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -873,6 +883,8 @@ async fn conversation_webrtc_default_v1_rejects_explicit_v2_voice() -> Result<()
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_mode:
codex_protocol::protocol::CodexResponseHandoffMode::Thinking,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -943,6 +955,8 @@ async fn conversation_webrtc_start_uses_configured_call_base_url_for_avas() -> R
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_mode:
codex_protocol::protocol::CodexResponseHandoffMode::Thinking,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -1037,6 +1051,8 @@ async fn conversation_webrtc_close_while_sideband_connecting_drops_pending_join(
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_mode:
codex_protocol::protocol::CodexResponseHandoffMode::Thinking,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -1128,6 +1144,8 @@ async fn conversation_webrtc_sideband_connect_failure_closes_with_error() -> Res
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_mode:
codex_protocol::protocol::CodexResponseHandoffMode::Thinking,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -1221,6 +1239,8 @@ async fn conversation_start_uses_openai_env_key_fallback_with_chatgpt_auth() ->
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_mode:
codex_protocol::protocol::CodexResponseHandoffMode::Thinking,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -1312,6 +1332,8 @@ async fn assert_transport_close_tail_flush(
flush_transcript_tail_on_session_end,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_mode:
codex_protocol::protocol::CodexResponseHandoffMode::Thinking,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -1428,6 +1450,8 @@ async fn conversation_start_preflight_failure_emits_realtime_error_only() -> Res
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_mode:
codex_protocol::protocol::CodexResponseHandoffMode::Thinking,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -1479,6 +1503,8 @@ async fn conversation_start_connect_failure_emits_realtime_error_only() -> Resul
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_mode:
codex_protocol::protocol::CodexResponseHandoffMode::Thinking,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -1578,6 +1604,8 @@ async fn conversation_second_start_replaces_runtime() -> Result<()> {
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_mode:
codex_protocol::protocol::CodexResponseHandoffMode::Thinking,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -1608,6 +1636,8 @@ async fn conversation_second_start_replaces_runtime() -> Result<()> {
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_mode:
codex_protocol::protocol::CodexResponseHandoffMode::Thinking,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -1709,6 +1739,8 @@ async fn conversation_uses_experimental_realtime_ws_base_url_override() -> Resul
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_mode:
codex_protocol::protocol::CodexResponseHandoffMode::Thinking,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -1778,6 +1810,8 @@ async fn conversation_uses_default_realtime_backend_prompt() -> Result<()> {
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_mode:
codex_protocol::protocol::CodexResponseHandoffMode::Thinking,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -1855,6 +1889,8 @@ async fn conversation_uses_empty_instructions_for_null_or_empty_prompt() -> Resu
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_mode:
codex_protocol::protocol::CodexResponseHandoffMode::Thinking,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -1925,6 +1961,8 @@ async fn conversation_uses_explicit_start_voice() -> Result<()> {
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_mode:
codex_protocol::protocol::CodexResponseHandoffMode::Thinking,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -1987,6 +2025,8 @@ async fn conversation_uses_configured_realtime_voice() -> Result<()> {
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_mode:
codex_protocol::protocol::CodexResponseHandoffMode::Thinking,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -2037,6 +2077,8 @@ async fn conversation_rejects_voice_for_wrong_realtime_version() -> Result<()> {
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_mode:
codex_protocol::protocol::CodexResponseHandoffMode::Thinking,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -2088,6 +2130,8 @@ async fn conversation_uses_experimental_realtime_ws_backend_prompt_override() ->
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_mode:
codex_protocol::protocol::CodexResponseHandoffMode::Thinking,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -2165,6 +2209,8 @@ async fn conversation_uses_experimental_realtime_ws_startup_context_override() -
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_mode:
codex_protocol::protocol::CodexResponseHandoffMode::Thinking,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -2236,6 +2282,8 @@ async fn conversation_disables_realtime_startup_context_with_empty_override() ->
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_mode:
codex_protocol::protocol::CodexResponseHandoffMode::Thinking,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -2300,6 +2348,8 @@ async fn conversation_start_injects_startup_context_from_thread_history() -> Res
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_mode:
codex_protocol::protocol::CodexResponseHandoffMode::Thinking,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -2419,6 +2469,8 @@ async fn conversation_startup_context_current_thread_selects_many_turns_by_budge
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_mode:
codex_protocol::protocol::CodexResponseHandoffMode::Thinking,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -2531,6 +2583,8 @@ async fn conversation_startup_context_falls_back_to_workspace_map() -> Result<()
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_mode:
codex_protocol::protocol::CodexResponseHandoffMode::Thinking,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -2595,6 +2649,8 @@ async fn conversation_startup_context_is_truncated_and_sent_once_per_start() ->
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_mode:
codex_protocol::protocol::CodexResponseHandoffMode::Thinking,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -2680,6 +2736,8 @@ async fn conversation_user_text_turn_is_not_sent_to_realtime() -> Result<()> {
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_mode:
codex_protocol::protocol::CodexResponseHandoffMode::Thinking,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -2781,6 +2839,8 @@ async fn realtime_v2_noop_tool_call_returns_empty_function_output_without_respon
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_mode:
codex_protocol::protocol::CodexResponseHandoffMode::Thinking,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -2884,6 +2944,8 @@ async fn conversation_mirrors_assistant_message_text_to_realtime_handoff() -> Re
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_mode:
codex_protocol::protocol::CodexResponseHandoffMode::Thinking,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -2958,22 +3020,20 @@ 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 = "seed ";
let first_commentary_delta = "first ";
let second_commentary_delta = "x".repeat(94);
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 commentary_text =
format!("{initial_commentary_text}{first_commentary_delta}{second_commentary_delta}");
let (gate_commentary_done_tx, gate_commentary_done_rx) = oneshot::channel();
let mut commentary_item_added =
let commentary_item_added =
responses::ev_message_item_added("msg_commentary", initial_commentary_text);
commentary_item_added["item"]["phase"] = json!("commentary");
let mut commentary_item_done =
responses::ev_assistant_message("msg_commentary", &commentary_text);
commentary_item_done["item"]["phase"] = json!("commentary");
let mut final_item_added = responses::ev_message_item_added("msg_final", "");
final_item_added["item"]["phase"] = json!("final_answer");
let mut final_item_done = responses::ev_assistant_message("msg_final", "done");
final_item_done["item"]["phase"] = json!("final_answer");
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 final_text = format!("{initial_final_text}{final_delta}");
let final_item_added = responses::ev_message_item_added("msg_final", initial_final_text);
let final_item_done = responses::ev_assistant_message("msg_final", &final_text);
let response_chunks = vec![
StreamingSseChunk {
gate: None,
@@ -3001,7 +3061,7 @@ async fn conversation_flushes_assistant_deltas_every_200ms_for_v3_handoff() -> R
},
StreamingSseChunk {
gate: None,
body: sse_event(responses::ev_output_text_delta("done")),
body: sse_event(responses::ev_output_text_delta(final_delta)),
},
StreamingSseChunk {
gate: None,
@@ -3050,6 +3110,8 @@ async fn conversation_flushes_assistant_deltas_every_200ms_for_v3_handoff() -> R
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_mode:
codex_protocol::protocol::CodexResponseHandoffMode::BemTags,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -3100,6 +3162,7 @@ async fn conversation_flushes_assistant_deltas_every_200ms_for_v3_handoff() -> R
json!({
"type": "delegation.context.append",
"delegation_item_id": "delegation_stream",
"channel": "commentary",
"content": [{ "type": "input_text", "text": commentary_text }]
})
);
@@ -3116,9 +3179,10 @@ async fn conversation_flushes_assistant_deltas_every_200ms_for_v3_handoff() -> R
json!({
"type": "delegation.context.append",
"delegation_item_id": "delegation_stream",
"channel": "speakable",
"content": [{
"type": "input_text",
"text": "\"Agent Final Message\":\n\ndone"
"text": final_text
}]
})
);
@@ -3214,6 +3278,8 @@ async fn conversation_handoff_persists_across_item_done_until_turn_complete() ->
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_mode:
codex_protocol::protocol::CodexResponseHandoffMode::BemTags,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -3370,6 +3436,8 @@ async fn inbound_handoff_request_starts_turn() -> Result<()> {
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_mode:
codex_protocol::protocol::CodexResponseHandoffMode::Thinking,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -3484,6 +3552,8 @@ async fn inbound_handoff_request_uses_active_transcript() -> Result<()> {
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_mode:
codex_protocol::protocol::CodexResponseHandoffMode::Thinking,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -3591,6 +3661,8 @@ async fn inbound_handoff_request_sends_transcript_delta_after_each_handoff() ->
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_mode:
codex_protocol::protocol::CodexResponseHandoffMode::Thinking,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -3717,6 +3789,8 @@ async fn conversation_close_routes_only_remaining_transcript_tail_once() -> Resu
flush_transcript_tail_on_session_end: true,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_mode:
codex_protocol::protocol::CodexResponseHandoffMode::Thinking,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -3804,6 +3878,8 @@ async fn inbound_conversation_item_does_not_start_turn_and_still_forwards_audio(
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_mode:
codex_protocol::protocol::CodexResponseHandoffMode::Thinking,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -3931,6 +4007,8 @@ async fn delegated_turn_user_role_echo_does_not_redelegate_and_still_forwards_au
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_mode:
codex_protocol::protocol::CodexResponseHandoffMode::Thinking,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -4088,6 +4166,8 @@ async fn inbound_handoff_request_does_not_block_realtime_event_forwarding() -> R
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_mode:
codex_protocol::protocol::CodexResponseHandoffMode::Thinking,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -4234,6 +4314,8 @@ async fn inbound_handoff_request_steers_active_turn() -> Result<()> {
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_mode:
codex_protocol::protocol::CodexResponseHandoffMode::Thinking,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
@@ -4391,6 +4473,8 @@ async fn inbound_handoff_request_starts_turn_and_does_not_block_realtime_audio()
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_mode:
codex_protocol::protocol::CodexResponseHandoffMode::Thinking,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
+13
View File
@@ -213,6 +213,9 @@ pub struct ConversationStartParams {
pub codex_responses_as_items: bool,
/// Optional prefix added to automatic Codex response items when `codex_responses_as_items` is set.
pub codex_response_item_prefix: Option<String>,
/// Selects how automatic Codex handoffs are routed in Frameless Bidi sessions.
/// Realtime V1 and V2 ignore this setting.
pub codex_response_handoff_mode: CodexResponseHandoffMode,
/// Overrides the configured realtime model for this session only.
pub model: Option<String>,
/// Selects whether the realtime session should produce text or audio output.
@@ -1623,6 +1626,16 @@ pub enum RealtimeConversationVersion {
V3,
}
#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
pub enum CodexResponseHandoffMode {
#[default]
Thinking,
Commentary,
BemTags,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)]
pub struct RealtimeConversationStartedEvent {
pub realtime_session_id: Option<String>,