[codex] Flush trailing realtime transcript tail (#29918)

## Why

A realtime session can end after more transcript has accumulated than
was included in its last handoff. That tail already lives in core's
active transcript state, but the stop path aborted the realtime
input/fanout tasks before routing it, so the final bit of the
conversation could disappear before `thread/realtime/closed`.

This behavior is still being evaluated, so clients must opt in per
realtime session. Omitted or false leaves shutdown behavior unchanged.

## What changed

- Add optional `flushTranscriptTailOnSessionEnd` to
`thread/realtime/start`, defaulting to false in app-server.
- Expose an idempotent `take_transcript_tail()` from the existing active
transcript state using `last_handoff_entry_count`.
- When enabled, let shutdown cancel the input owner cleanly and publish
at most one final existing `<realtime_delegation>` with the remaining
text in `<transcript_delta>`.
- Have the existing fanout drain already-parsed events before routing
that final delegation, so a queued handoff wins first and is not
duplicated in the tail.
- Flush realtime shutdown before ordinary session task abort during core
cleanup.

## Validation

- `just test -p codex-app-server-protocol`
- `just test -p codex-app-server
realtime_conversation_stop_emits_closed_notification`
- `just test -p codex-core conversation_transport_close_`
- `just test -p codex-core
conversation_close_routes_only_remaining_transcript_tail_once`
- scoped `just fix` for `codex-app-server-protocol`, `codex-app-server`,
`codex-protocol`, and `codex-core`
- `just fmt`
This commit is contained in:
guinness-oai authored and GitHub committed 2026-07-06 14:12:54 -07:00
1 parent 02868b9019
commit c976741124
11 files changed
+357 -35

No files matched your search

@@ -391,6 +391,13 @@ impl RealtimeWebsocketWriter {
}
impl RealtimeWebsocketEvents {
pub async fn take_transcript_tail(&self) -> Vec<RealtimeTranscriptEntry> {
let mut active_transcript = self.active_transcript.lock().await;
let tail = active_transcript.entries[active_transcript.last_handoff_entry_count..].to_vec();
active_transcript.last_handoff_entry_count = active_transcript.entries.len();
tail
}
pub async fn next_event(&self) -> Result<Option<RealtimeEvent>, ApiError> {
if self.is_closed.load(Ordering::SeqCst) {
return Ok(None);
@@ -962,6 +969,56 @@ mod tests {
);
}
#[tokio::test]
async fn takes_only_transcript_after_last_handoff_once() {
let (_tx_message, rx_message) = async_channel::unbounded();
let events = RealtimeWebsocketEvents {
rx_message,
active_transcript: Arc::new(Mutex::new(ActiveTranscriptState::default())),
event_parser: RealtimeEventParser::V1,
is_closed: Arc::new(AtomicBool::new(false)),
};
assert_eq!(events.take_transcript_tail().await, vec![]);
let mut covered = RealtimeEvent::InputTranscriptDelta(RealtimeTranscriptDelta {
delta: "already handed off".to_string(),
});
events.update_active_transcript(&mut covered).await;
let mut handoff = RealtimeEvent::HandoffRequested(RealtimeHandoffRequested {
handoff_id: "handoff_1".to_string(),
item_id: "item_1".to_string(),
input_transcript: "already handed off".to_string(),
active_transcript: vec![],
});
events.update_active_transcript(&mut handoff).await;
assert_eq!(
handoff,
RealtimeEvent::HandoffRequested(RealtimeHandoffRequested {
handoff_id: "handoff_1".to_string(),
item_id: "item_1".to_string(),
input_transcript: "already handed off".to_string(),
active_transcript: vec![RealtimeTranscriptEntry {
role: "user".to_string(),
text: "already handed off".to_string(),
}],
})
);
let mut tail = RealtimeEvent::OutputTranscriptDelta(RealtimeTranscriptDelta {
delta: "tail".to_string(),
});
events.update_active_transcript(&mut tail).await;
assert_eq!(
events.take_transcript_tail().await,
vec![RealtimeTranscriptEntry {
role: "assistant".to_string(),
text: "tail".to_string(),
}]
);
assert_eq!(events.take_transcript_tail().await, vec![]);
}
#[test]
fn parse_input_transcript_delta_event() {
let payload = json!({