Update realtime handoff transcript handling (#18597)

## Summary

This PR aims to improve integration between the realtime model and the
codex agent by sharing more context with each other. In particular, we
now share full realtime conversation transcript deltas in addition to
the delegation message.

realtime_conversation.rs now turns a handoff into:
```
<realtime_delegation>
  <input>...</input>
  <transcript_delta>...</transcript_delta>
</realtime_delegation>
```

## Implementation notes

The transcript is accumulated in the realtime websocket layer as parsed
realtime events arrive. When a background-agent handoff is requested,
the current transcript snapshot is copied onto the handoff event and
then serialized by `realtime_conversation.rs` into the hidden realtime
delegation envelope that Codex receives as user-turn context.

For Realtime V2, the session now explicitly enables input audio
transcription, and the parser handles the relevant input/output
transcript completion events so the snapshot includes both user speech
and realtime model responses. The delegation `<input>` remains the
actual handoff request, while `<transcript_delta>` carries the
surrounding conversation history for context.

Reviewers should note that the transcript payload is intended for Codex
context sharing, not UI rendering. The realtime delegation envelope
should stay hidden from the user-facing transcript surface, while still
being included in the background-agent turn so Codex can answer with the
same conversational context the realtime model had.
This commit is contained in:
guinness-oai authored and GitHub committed 2026-04-20 14:04:09 -07:00
1 parent 14ebfbced9
commit 126bd6e7a8
11 files changed
+572 -98

No files matched your search

@@ -217,6 +217,8 @@ pub struct RealtimeWebsocketEvents {
#[derive(Default)]
struct ActiveTranscriptState {
entries: Vec<RealtimeTranscriptEntry>,
new_input_entry: bool,
new_output_entry: bool,
}
impl RealtimeWebsocketConnection {
@@ -417,38 +419,76 @@ impl RealtimeWebsocketEvents {
async fn update_active_transcript(&self, event: &mut RealtimeEvent) {
let mut active_transcript = self.active_transcript.lock().await;
match event {
RealtimeEvent::InputAudioSpeechStarted(_) => {}
RealtimeEvent::InputAudioSpeechStarted(_) => {
active_transcript.new_input_entry = true;
}
RealtimeEvent::InputTranscriptDelta(RealtimeTranscriptDelta { delta, .. }) => {
append_transcript_delta(&mut active_transcript.entries, "user", delta);
let force_new = active_transcript.new_input_entry;
append_transcript_delta(&mut active_transcript.entries, "user", delta, force_new);
active_transcript.new_input_entry = false;
}
RealtimeEvent::OutputTranscriptDelta(RealtimeTranscriptDelta { delta, .. }) => {
append_transcript_delta(&mut active_transcript.entries, "assistant", delta);
let force_new = active_transcript.new_output_entry;
append_transcript_delta(
&mut active_transcript.entries,
"assistant",
delta,
force_new,
);
active_transcript.new_output_entry = false;
}
RealtimeEvent::InputTranscriptDone(done) => {
let force_new = active_transcript.new_input_entry;
apply_transcript_done(
&mut active_transcript.entries,
"user",
&done.text,
force_new,
);
active_transcript.new_input_entry = false;
}
RealtimeEvent::OutputTranscriptDone(done) => {
let force_new = active_transcript.new_output_entry;
apply_transcript_done(
&mut active_transcript.entries,
"assistant",
&done.text,
force_new,
);
active_transcript.new_output_entry = false;
}
RealtimeEvent::HandoffRequested(handoff) => {
if self.event_parser == RealtimeEventParser::V1 {
handoff.active_transcript = std::mem::take(&mut active_transcript.entries);
}
append_handoff_input(&mut active_transcript.entries, &handoff.input_transcript);
handoff.active_transcript = active_transcript.entries.clone();
active_transcript.new_input_entry = true;
active_transcript.new_output_entry = true;
}
RealtimeEvent::ResponseCreated(_) => {
active_transcript.new_output_entry = true;
}
RealtimeEvent::SessionUpdated { .. }
| RealtimeEvent::InputTranscriptDone(_)
| RealtimeEvent::OutputTranscriptDone(_)
| RealtimeEvent::AudioOut(_)
| RealtimeEvent::ResponseCreated(_)
| RealtimeEvent::ResponseCancelled(_)
| RealtimeEvent::ResponseDone(_)
| RealtimeEvent::ConversationItemAdded(_)
| RealtimeEvent::ConversationItemDone { .. }
| RealtimeEvent::ConversationItemAdded(_)
| RealtimeEvent::Error(_) => {}
}
}
}
fn append_transcript_delta(entries: &mut Vec<RealtimeTranscriptEntry>, role: &str, delta: &str) {
fn append_transcript_delta(
entries: &mut Vec<RealtimeTranscriptEntry>,
role: &str,
delta: &str,
force_new: bool,
) {
if delta.is_empty() {
return;
}
if let Some(last_entry) = entries.last_mut()
if !force_new
&& let Some(last_entry) = entries.last_mut()
&& last_entry.role == role
{
last_entry.text.push_str(delta);
@@ -461,6 +501,48 @@ fn append_transcript_delta(entries: &mut Vec<RealtimeTranscriptEntry>, role: &st
});
}
fn apply_transcript_done(
entries: &mut Vec<RealtimeTranscriptEntry>,
role: &str,
text: &str,
force_new: bool,
) {
if text.is_empty() {
return;
}
if !force_new
&& let Some(last_entry) = entries.last_mut()
&& last_entry.role == role
{
last_entry.text = text.to_string();
return;
}
entries.push(RealtimeTranscriptEntry {
role: role.to_string(),
text: text.to_string(),
});
}
fn append_handoff_input(entries: &mut Vec<RealtimeTranscriptEntry>, input: &str) {
let input = input.trim();
if input.is_empty() || contains_transcript_entry(entries, "user", input) {
return;
}
entries.push(RealtimeTranscriptEntry {
role: "user".to_string(),
text: input.to_string(),
});
}
fn contains_transcript_entry(entries: &[RealtimeTranscriptEntry], role: &str, text: &str) -> bool {
entries
.iter()
.any(|entry| entry.role == role && entry.text.trim() == text.trim())
}
pub struct RealtimeWebsocketClient {
provider: Provider,
}
@@ -867,6 +949,44 @@ mod tests {
);
}
#[test]
fn parse_v1_input_audio_transcription_delta_event() {
let payload = json!({
"type": "conversation.item.input_audio_transcription.delta",
"item_id": "item_input_1",
"content_index": 0,
"delta": "hello"
})
.to_string();
assert_eq!(
parse_realtime_event(payload.as_str(), RealtimeEventParser::V1),
Some(RealtimeEvent::InputTranscriptDelta(
RealtimeTranscriptDelta {
delta: "hello".to_string(),
}
))
);
}
#[test]
fn parse_v1_input_audio_transcription_completed_event() {
let payload = json!({
"type": "conversation.item.input_audio_transcription.completed",
"item_id": "item_input_1",
"content_index": 0,
"transcript": "hello world"
})
.to_string();
assert_eq!(
parse_realtime_event(payload.as_str(), RealtimeEventParser::V1),
Some(RealtimeEvent::InputTranscriptDone(RealtimeTranscriptDone {
text: "hello world".to_string(),
}))
);
}
#[test]
fn parse_output_transcript_delta_event() {
let payload = json!({
@@ -885,6 +1005,66 @@ mod tests {
);
}
#[test]
fn parse_v1_output_audio_transcript_delta_event() {
let payload = json!({
"type": "response.output_audio_transcript.delta",
"delta": "hi"
})
.to_string();
assert_eq!(
parse_realtime_event(payload.as_str(), RealtimeEventParser::V1),
Some(RealtimeEvent::OutputTranscriptDelta(
RealtimeTranscriptDelta {
delta: "hi".to_string(),
}
))
);
}
#[test]
fn parse_v1_output_audio_transcript_done_event() {
let payload = json!({
"type": "response.output_audio_transcript.done",
"transcript": "hi there"
})
.to_string();
assert_eq!(
parse_realtime_event(payload.as_str(), RealtimeEventParser::V1),
Some(RealtimeEvent::OutputTranscriptDone(
RealtimeTranscriptDone {
text: "hi there".to_string(),
}
))
);
}
#[test]
fn parse_v1_item_done_output_text_event() {
let payload = json!({
"type": "conversation.item.done",
"item": {
"id": "item_output_1",
"type": "message",
"role": "assistant",
"content": [
{"type": "output_text", "text": "hello"},
{"type": "output_text", "text": " world"}
]
}
})
.to_string();
assert_eq!(
parse_realtime_event(payload.as_str(), RealtimeEventParser::V1),
Some(RealtimeEvent::ConversationItemDone {
item_id: "item_output_1".to_string(),
})
);
}
#[test]
fn parse_realtime_v2_handoff_tool_call_event() {
let payload = json!({
@@ -930,6 +1110,58 @@ mod tests {
);
}
#[test]
fn parse_realtime_v2_output_audio_transcript_done_event() {
let payload = json!({
"type": "response.output_audio_transcript.done",
"transcript": "hello there"
})
.to_string();
assert_eq!(
parse_realtime_event(payload.as_str(), RealtimeEventParser::RealtimeV2),
Some(RealtimeEvent::OutputTranscriptDone(
RealtimeTranscriptDone {
text: "hello there".to_string(),
}
))
);
}
#[test]
fn parse_realtime_v2_output_text_done_event() {
let payload = json!({
"type": "response.output_text.done",
"text": "hello there"
})
.to_string();
assert_eq!(
parse_realtime_event(payload.as_str(), RealtimeEventParser::RealtimeV2),
Some(RealtimeEvent::OutputTranscriptDone(
RealtimeTranscriptDone {
text: "hello there".to_string(),
}
))
);
}
#[test]
fn parse_realtime_v2_conversation_item_created_event() {
let payload = json!({
"type": "conversation.item.created",
"item": {"type": "message", "role": "user"}
})
.to_string();
assert_eq!(
parse_realtime_event(payload.as_str(), RealtimeEventParser::RealtimeV2),
Some(RealtimeEvent::ConversationItemAdded(
json!({"type": "message", "role": "user"})
))
);
}
#[test]
fn parse_realtime_v2_item_done_output_text_event() {
let payload = json!({
@@ -948,11 +1180,9 @@ mod tests {
assert_eq!(
parse_realtime_event(payload.as_str(), RealtimeEventParser::RealtimeV2),
Some(RealtimeEvent::OutputTranscriptDone(
RealtimeTranscriptDone {
text: "hello world".to_string(),
}
))
Some(RealtimeEvent::ConversationItemDone {
item_id: "item_output_1".to_string(),
})
);
}
@@ -1582,6 +1812,12 @@ mod tests {
"type": "near_field",
})
);
assert_eq!(
first_json["session"]["audio"]["input"]["transcription"],
json!({
"model": "gpt-4o-mini-transcribe",
})
);
assert_eq!(
first_json["session"]["audio"]["input"]["turn_detection"],
json!({
@@ -1756,6 +1992,12 @@ mod tests {
Value::String("transcription".to_string())
);
assert!(first_json["session"].get("instructions").is_none());
assert_eq!(
first_json["session"]["audio"]["input"]["transcription"],
json!({
"model": "gpt-4o-mini-transcribe",
})
);
assert!(first_json["session"]["audio"].get("output").is_none());
assert!(first_json["session"].get("tools").is_none());