Route terminal Codex output to realtime

This commit is contained in:
Guinness Chen
2026-06-09 09:03:20 -07:00
parent 3c85d78cc9
commit a678a86cb4
14 changed files with 435 additions and 390 deletions
@@ -3,6 +3,7 @@ use crate::endpoint::realtime_websocket::methods_common::conversation_item_creat
use crate::endpoint::realtime_websocket::methods_common::normalized_session_mode;
use crate::endpoint::realtime_websocket::methods_common::session_update_session;
use crate::endpoint::realtime_websocket::methods_common::websocket_intent;
use crate::endpoint::realtime_websocket::protocol::ConversationRole;
use crate::endpoint::realtime_websocket::protocol::RealtimeAudioFrame;
use crate::endpoint::realtime_websocket::protocol::RealtimeEvent;
use crate::endpoint::realtime_websocket::protocol::RealtimeEventParser;
@@ -241,6 +242,16 @@ impl RealtimeWebsocketConnection {
.await
}
pub async fn send_conversation_handoff_append(
&self,
handoff_id: Option<String>,
output_text: String,
) -> Result<(), ApiError> {
self.writer
.send_conversation_handoff_append(handoff_id, output_text)
.await
}
pub async fn close(&self) -> Result<(), ApiError> {
self.writer.close().await
}
@@ -287,8 +298,24 @@ impl RealtimeWebsocketWriter {
}
pub async fn send_conversation_item_create(&self, text: String) -> Result<(), ApiError> {
self.send_json(&conversation_item_create_message(self.event_parser, text))
.await
self.send_json(&conversation_item_create_message(
self.event_parser,
ConversationRole::User,
text,
))
.await
}
pub async fn send_conversation_developer_item_create(
&self,
text: String,
) -> Result<(), ApiError> {
self.send_json(&conversation_item_create_message(
self.event_parser,
ConversationRole::Developer,
text,
))
.await
}
pub async fn send_conversation_function_call_output(
@@ -304,6 +331,20 @@ impl RealtimeWebsocketWriter {
.await
}
pub async fn send_conversation_handoff_append(
&self,
handoff_id: Option<String>,
output_text: String,
) -> Result<(), ApiError> {
self.send_json(
&crate::endpoint::realtime_websocket::methods_v1::conversation_handoff_append_message(
handoff_id,
output_text,
),
)
.await
}
pub async fn send_response_create(&self) -> Result<(), ApiError> {
self.send_json(&RealtimeOutboundMessage::ResponseCreate)
.await
@@ -1611,11 +1652,12 @@ mod tests {
.into_text()
.expect("text");
let fourth_json: Value = serde_json::from_str(&fourth).expect("json");
assert_eq!(fourth_json["type"], "conversation.handoff.append");
assert_eq!(fourth_json["handoff_id"], "handoff_1");
assert_eq!(
fourth_json["output_text"],
"\"Agent Final Message\":\n\nhello from background agent"
fourth_json,
json!({
"type": "conversation.handoff.append",
"output_text": "hello from background agent"
})
);
ws.send(Message::Text(
@@ -1738,8 +1780,8 @@ mod tests {
.await
.expect("send item");
connection
.send_conversation_function_call_output(
"handoff_1".to_string(),
.send_conversation_handoff_append(
/*handoff_id*/ None,
"hello from background agent".to_string(),
)
.await
@@ -6,6 +6,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::ConversationRole;
use crate::endpoint::realtime_websocket::protocol::RealtimeEventParser;
use crate::endpoint::realtime_websocket::protocol::RealtimeOutboundMessage;
use crate::endpoint::realtime_websocket::protocol::RealtimeOutputModality;
@@ -18,7 +19,6 @@ use serde_json::Value;
use serde_json::to_value;
pub(super) const REALTIME_AUDIO_SAMPLE_RATE: u32 = 24_000;
const AGENT_FINAL_MESSAGE_PREFIX: &str = "\"Agent Final Message\":\n\n";
pub(super) fn normalized_session_mode(
event_parser: RealtimeEventParser,
@@ -32,11 +32,12 @@ pub(super) fn normalized_session_mode(
pub(super) fn conversation_item_create_message(
event_parser: RealtimeEventParser,
role: ConversationRole,
text: String,
) -> RealtimeOutboundMessage {
match event_parser {
RealtimeEventParser::V1 => v1_conversation_item_create_message(text),
RealtimeEventParser::RealtimeV2 => v2_conversation_item_create_message(text),
RealtimeEventParser::V1 => v1_conversation_item_create_message(role, text),
RealtimeEventParser::RealtimeV2 => v2_conversation_item_create_message(role, text),
}
}
@@ -46,10 +47,9 @@ pub(super) fn conversation_function_call_output_message(
output_text: String,
) -> RealtimeOutboundMessage {
match event_parser {
RealtimeEventParser::V1 => v1_conversation_handoff_append_message(
call_id,
format!("{AGENT_FINAL_MESSAGE_PREFIX}{output_text}"),
),
RealtimeEventParser::V1 => {
v1_conversation_handoff_append_message(Some(call_id), output_text)
}
RealtimeEventParser::RealtimeV2 => {
v2_conversation_function_call_output_message(call_id, output_text)
}
@@ -15,11 +15,14 @@ use crate::endpoint::realtime_websocket::protocol::SessionAudioOutput;
use crate::endpoint::realtime_websocket::protocol::SessionType;
use crate::endpoint::realtime_websocket::protocol::SessionUpdateSession;
pub(super) fn conversation_item_create_message(text: String) -> RealtimeOutboundMessage {
pub(super) fn conversation_item_create_message(
role: ConversationRole,
text: String,
) -> RealtimeOutboundMessage {
RealtimeOutboundMessage::ConversationItemCreate {
item: ConversationItemPayload::Message(ConversationMessageItem {
r#type: ConversationItemType::Message,
role: ConversationRole::User,
role,
content: vec![ConversationItemContent {
r#type: ConversationContentType::InputText,
text,
@@ -29,7 +32,7 @@ pub(super) fn conversation_item_create_message(text: String) -> RealtimeOutbound
}
pub(super) fn conversation_handoff_append_message(
handoff_id: String,
handoff_id: Option<String>,
output_text: String,
) -> RealtimeOutboundMessage {
RealtimeOutboundMessage::ConversationHandoffAppend {
@@ -36,11 +36,14 @@ const REALTIME_V2_SILENCE_TOOL_NAME: &str = "remain_silent";
const REALTIME_V2_SILENCE_TOOL_DESCRIPTION: &str = "Call this when the best response is to say nothing. Use it instead of speaking after hidden system/control messages, after background agent updates in silent modes, or whenever acknowledging aloud would be distracting. This tool has no user-visible effect.";
const REALTIME_V2_INPUT_TRANSCRIPTION_MODEL: &str = "gpt-4o-mini-transcribe";
pub(super) fn conversation_item_create_message(text: String) -> RealtimeOutboundMessage {
pub(super) fn conversation_item_create_message(
role: ConversationRole,
text: String,
) -> RealtimeOutboundMessage {
RealtimeOutboundMessage::ConversationItemCreate {
item: ConversationItemPayload::Message(ConversationMessageItem {
r#type: ConversationItemType::Message,
role: ConversationRole::User,
role,
content: vec![ConversationItemContent {
r#type: ConversationContentType::InputText,
text,
@@ -38,7 +38,8 @@ pub(super) enum RealtimeOutboundMessage {
InputAudioBufferAppend { audio: String },
#[serde(rename = "conversation.handoff.append")]
ConversationHandoffAppend {
handoff_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
handoff_id: Option<String>,
output_text: String,
},
#[serde(rename = "response.create")]
@@ -171,6 +172,7 @@ pub(super) enum ConversationItemType {
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "snake_case")]
pub(super) enum ConversationRole {
Developer,
User,
}
@@ -65,6 +65,21 @@ pub(super) fn parse_transcript_done_event(
.map(|text| RealtimeTranscriptDone { text })
}
pub(super) fn parse_response_event_response_id(parsed: &Value) -> Option<String> {
parsed
.get("response")
.and_then(Value::as_object)
.and_then(|response| response.get("id"))
.and_then(Value::as_str)
.map(str::to_string)
.or_else(|| {
parsed
.get("response_id")
.and_then(Value::as_str)
.map(str::to_string)
})
}
pub(super) fn parse_error_event(parsed: &Value) -> Option<RealtimeEvent> {
parsed
.get("message")
@@ -1,11 +1,15 @@
use crate::endpoint::realtime_websocket::protocol_common::parse_error_event;
use crate::endpoint::realtime_websocket::protocol_common::parse_realtime_payload;
use crate::endpoint::realtime_websocket::protocol_common::parse_response_event_response_id;
use crate::endpoint::realtime_websocket::protocol_common::parse_session_updated_event;
use crate::endpoint::realtime_websocket::protocol_common::parse_transcript_delta_event;
use crate::endpoint::realtime_websocket::protocol_common::parse_transcript_done_event;
use codex_protocol::protocol::RealtimeAudioFrame;
use codex_protocol::protocol::RealtimeEvent;
use codex_protocol::protocol::RealtimeHandoffRequested;
use codex_protocol::protocol::RealtimeResponseCancelled;
use codex_protocol::protocol::RealtimeResponseCreated;
use codex_protocol::protocol::RealtimeResponseDone;
use serde_json::Value;
use tracing::debug;
@@ -62,6 +66,17 @@ pub(super) fn parse_realtime_event_v1(payload: &str) -> Option<RealtimeEvent> {
.cloned()
.map(RealtimeEvent::ConversationItemAdded),
"conversation.item.done" => parse_conversation_item_done_event(&parsed),
"response.created" => Some(RealtimeEvent::ResponseCreated(RealtimeResponseCreated {
response_id: parse_response_event_response_id(&parsed),
})),
"response.cancelled" => Some(RealtimeEvent::ResponseCancelled(
RealtimeResponseCancelled {
response_id: parse_response_event_response_id(&parsed),
},
)),
"response.done" => Some(RealtimeEvent::ResponseDone(RealtimeResponseDone {
response_id: parse_response_event_response_id(&parsed),
})),
"conversation.handoff.requested" => {
let handoff_id = parsed
.get("handoff_id")
@@ -1,5 +1,6 @@
use crate::endpoint::realtime_websocket::protocol_common::parse_error_event;
use crate::endpoint::realtime_websocket::protocol_common::parse_realtime_payload;
use crate::endpoint::realtime_websocket::protocol_common::parse_response_event_response_id;
use crate::endpoint::realtime_websocket::protocol_common::parse_session_updated_event;
use crate::endpoint::realtime_websocket::protocol_common::parse_transcript_delta_event;
use crate::endpoint::realtime_websocket::protocol_common::parse_transcript_done_event;
@@ -78,21 +79,6 @@ pub(super) fn parse_realtime_event_v2(payload: &str) -> Option<RealtimeEvent> {
}
}
fn parse_response_event_response_id(parsed: &Value) -> Option<String> {
parsed
.get("response")
.and_then(Value::as_object)
.and_then(|response| response.get("id"))
.and_then(Value::as_str)
.map(str::to_string)
.or_else(|| {
parsed
.get("response_id")
.and_then(Value::as_str)
.map(str::to_string)
})
}
fn parse_output_audio_delta_event(parsed: &Value) -> Option<RealtimeEvent> {
let data = parsed
.get("delta")