Preserve realtime V3 transcripts in conversation history (#58221)

## Why

Long realtime conversations could lose speech when active transcripts and handoff transcript fields were truncated.

## What changed

- Record completed V3 transcript chunks in model and client history once the active transcript exceeds 4 KiB, without triggering inference.
- Keep unfinished utterances pending to preserve conversation order, and persist completed chunks before subsequent handoffs.
- Preserve full V3 transcript text at handoff and session-end tail flush, and avoid adding handoff input already recorded in transcript history.

## Testing

Add integration coverage for long transcripts, XML escaping, recording before handoff without inference, repeated handoff input, and both session-end tail flush settings.

GitOrigin-RevId: 62218b811809c4cf5b16fb0247a335eb474f9ab9
This commit is contained in:
guinness-oai
2026-10-30 02:43:02 +00:00
committed by copyberry
parent cc2f80d515
commit c9750d7f50
10 changed files with 260 additions and 475 deletions
@@ -38,7 +38,9 @@ use http::HeaderMap;
use http::HeaderValue;
use http::StatusCode;
use std::collections::HashMap;
use std::collections::HashSet;
use std::collections::VecDeque;
use std::hash::BuildHasher;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
@@ -62,8 +64,6 @@ use url::Url;
const REALTIME_WIRE_LOG_TARGET: &str = "codex_api::realtime_websocket::wire";
const OPENAI_REALTIME_API_BASE_URL: &str = "https://api.openai.com/v1";
const MAX_ACTIVE_TRANSCRIPT_BYTES: usize = 8 * 1024;
const TRUNCATED_TRANSCRIPT_PREFIX: &str = "…";
struct WsStream {
tx_command: mpsc::Sender<WsCommand>,
@@ -271,48 +271,20 @@ pub struct RealtimeTranscriptState {
#[derive(Default)]
struct ActiveTranscriptState {
entries: Vec<ActiveTranscriptEntry>,
last_frame: Option<TranscriptFrame>,
frame_sequence: u64,
input_last_raw_frame: Option<u64>,
output_last_raw_frame: Option<u64>,
entries: Vec<RealtimeTranscriptEntry>,
// Handoff input can echo an utterance already written to child history.
flushed_user_inputs: HashSet<u64>,
new_input_entry: bool,
new_output_entry: bool,
}
impl ActiveTranscriptState {
fn take_entries(&mut self) -> Vec<RealtimeTranscriptEntry> {
self.input_last_raw_frame = None;
self.output_last_raw_frame = None;
self.entries
.drain(..)
.map(|entry| entry.transcript)
.collect()
self.flushed_user_inputs.clear();
std::mem::take(&mut self.entries)
}
}
struct ActiveTranscriptEntry {
transcript: RealtimeTranscriptEntry,
last_raw_frame: Option<u64>,
truncated_prefix_bytes: usize,
}
impl ActiveTranscriptEntry {
fn retained_text(&self) -> &str {
if self.truncated_prefix_bytes > 0 {
&self.transcript.text[TRUNCATED_TRANSCRIPT_PREFIX.len()..]
} else {
&self.transcript.text
}
}
}
#[derive(Clone, Copy, PartialEq, Eq, serde::Deserialize)]
struct TranscriptFrame {
start_ms: u64,
end_ms: u64,
}
impl RealtimeWebsocketConnection {
pub async fn send_audio_frame(&self, frame: RealtimeAudioFrame) -> Result<(), ApiError> {
self.writer.send_audio_frame(frame).await
@@ -622,17 +594,7 @@ impl RealtimeWebsocketEvents {
Message::Text(text) => {
trace!(target: REALTIME_WIRE_LOG_TARGET, "realtime websocket event: {text}");
if let Some(mut event) = parse_realtime_event(&text, self.event_parser) {
let frame = if self.event_parser == RealtimeEventParser::FramelessBidi
&& matches!(
event,
RealtimeEvent::InputTranscriptDelta(_)
| RealtimeEvent::OutputTranscriptDelta(_)
) {
serde_json::from_str::<TranscriptFrame>(&text).ok()
} else {
None
};
self.update_active_transcript(&mut event, frame).await;
self.update_active_transcript(&mut event).await;
debug!(?event, "realtime websocket parsed event");
return Ok(Some(event));
}
@@ -688,26 +650,11 @@ impl RealtimeWebsocketEvents {
}
}
async fn update_active_transcript(
&self,
event: &mut RealtimeEvent,
frame: Option<TranscriptFrame>,
) {
async fn update_active_transcript(&self, event: &mut RealtimeEvent) {
let mut active_transcript = self.transcript_state.active.lock().await;
let frame_sequence = frame.map(|frame| {
// Both speakers in one frame have equal recency, regardless of wire event order.
// Count observed frames so reconnects do not depend on an absolute timestamp origin.
if active_transcript.last_frame != Some(frame) {
truncate_active_transcript(&mut active_transcript.entries);
active_transcript.frame_sequence += 1;
active_transcript.last_frame = Some(frame);
}
active_transcript.frame_sequence
});
match event {
RealtimeEvent::InputAudioSpeechStarted(_) => {
active_transcript.new_input_entry = true;
active_transcript.input_last_raw_frame = None;
}
RealtimeEvent::InputTranscriptDelta(RealtimeTranscriptDelta { delta, .. }) => {
let force_new = active_transcript.new_input_entry;
@@ -717,11 +664,9 @@ impl RealtimeWebsocketEvents {
delta,
force_new,
self.event_parser,
frame_sequence,
);
if !delta.is_empty() || self.event_parser != RealtimeEventParser::FramelessBidi {
active_transcript.new_input_entry = false;
active_transcript.input_last_raw_frame = frame_sequence;
}
}
RealtimeEvent::OutputTranscriptDelta(RealtimeTranscriptDelta { delta, .. }) => {
@@ -732,53 +677,43 @@ impl RealtimeWebsocketEvents {
delta,
force_new,
self.event_parser,
frame_sequence,
);
if !delta.is_empty() || self.event_parser != RealtimeEventParser::FramelessBidi {
active_transcript.new_output_entry = false;
active_transcript.output_last_raw_frame = frame_sequence;
}
}
RealtimeEvent::InputTranscriptDone(done) => {
let force_new = active_transcript.new_input_entry;
let last_raw_frame = active_transcript.input_last_raw_frame;
apply_transcript_done(
&mut active_transcript.entries,
"user",
&done.text,
force_new,
self.event_parser,
last_raw_frame,
);
active_transcript.new_input_entry =
self.event_parser == RealtimeEventParser::FramelessBidi;
active_transcript.input_last_raw_frame = None;
}
RealtimeEvent::OutputTranscriptDone(done) => {
let force_new = active_transcript.new_output_entry;
let last_raw_frame = active_transcript.output_last_raw_frame;
apply_transcript_done(
&mut active_transcript.entries,
"assistant",
&done.text,
force_new,
self.event_parser,
last_raw_frame,
);
active_transcript.new_output_entry =
self.event_parser == RealtimeEventParser::FramelessBidi;
active_transcript.output_last_raw_frame = None;
}
RealtimeEvent::HandoffRequested(handoff) => {
truncate_active_transcript(&mut active_transcript.entries);
append_handoff_input(&mut active_transcript.entries, &handoff.input_transcript);
append_handoff_input(&mut active_transcript, &handoff.input_transcript);
handoff.active_transcript = active_transcript.take_entries();
active_transcript.new_input_entry = true;
active_transcript.new_output_entry = true;
}
RealtimeEvent::ResponseCreated(_) => {
active_transcript.new_output_entry = true;
active_transcript.output_last_raw_frame = None;
}
RealtimeEvent::SessionUpdated { .. }
| RealtimeEvent::AudioOut(_)
@@ -792,113 +727,68 @@ impl RealtimeWebsocketEvents {
| RealtimeEvent::HistoryTranscriptDelta { .. }
| RealtimeEvent::HistoryItemCompleted(_) => {}
}
if let Some(frame_sequence) = frame_sequence {
// Wait for both speakers before evicting utterances from this frame. Each updated
// utterance is bounded independently, so pending transcript text is at most the previous
// 8 KiB transcript plus 8 KiB per speaker, even if no further event arrives.
for entry in &mut active_transcript.entries {
if entry.last_raw_frame == Some(frame_sequence) {
truncate_transcript_entry(entry, MAX_ACTIVE_TRANSCRIPT_BYTES);
}
}
} else {
truncate_active_transcript(&mut active_transcript.entries);
}
pub async fn take_completed_transcript(
&self,
event: &RealtimeEvent,
threshold: usize,
) -> Vec<RealtimeTranscriptEntry> {
if self.event_parser != RealtimeEventParser::FramelessBidi
|| !matches!(
event,
RealtimeEvent::InputTranscriptDone(_) | RealtimeEvent::OutputTranscriptDone(_)
)
{
return Vec::new();
}
let mut active = self.transcript_state.active.lock().await;
// Only the other speaker can still be open after turn.done. Keep their utterance
// and everything after it in place so history stays in conversation order.
let open_role = match event {
RealtimeEvent::InputTranscriptDone(_) if !active.new_output_entry => Some("assistant"),
RealtimeEvent::OutputTranscriptDone(_) if !active.new_input_entry => Some("user"),
_ => None,
};
let entries = &active.entries;
let end = open_role
.and_then(|role| entries.iter().rposition(|entry| entry.role == role))
.unwrap_or(entries.len());
if end == 0 || transcript_entries_bytes(entries) <= threshold {
return Vec::new();
}
let completed: Vec<_> = active.entries.drain(..end).collect();
let recorded = &mut active.flushed_user_inputs;
let hash = recorded.hasher().clone();
recorded.extend(
completed
.iter()
.filter(|entry| entry.role == "user")
.map(|entry| hash.hash_one(entry.text.trim())),
);
completed
}
}
impl RealtimeTranscriptState {
pub async fn take_transcript_tail(&self) -> Vec<RealtimeTranscriptEntry> {
let mut active = self.active.lock().await;
truncate_active_transcript(&mut active.entries);
active.take_entries()
self.active.lock().await.take_entries()
}
}
fn truncate_active_transcript(entries: &mut Vec<ActiveTranscriptEntry>) {
let mut total_bytes = transcript_entries_bytes(entries.iter().map(|entry| &entry.transcript));
while total_bytes > MAX_ACTIVE_TRANSCRIPT_BYTES && entries.len() > 1 {
// An earlier utterance can contain the newest speech. Evict by raw frame recency,
// preserving the legacy FIFO policy without timing.
let oldest = if entries.iter().all(|entry| entry.last_raw_frame.is_some()) {
entries
.iter()
.enumerate()
.min_by_key(|(_, entry)| entry.last_raw_frame)
.map(|(index, _)| index)
.unwrap_or_default()
} else {
0
};
if let Some(frame) = entries[oldest].last_raw_frame
&& entries
.iter()
.all(|entry| entry.last_raw_frame == Some(frame))
{
// Both speakers have equally recent speech. Keep their suffixes in conversation
// order, letting short entries keep their text before dividing the remaining budget.
let mut remaining_bytes = MAX_ACTIVE_TRANSCRIPT_BYTES;
let mut remaining_entries = entries.len();
let mut by_size: Vec<_> = entries.iter_mut().collect();
by_size.sort_unstable_by_key(|entry| transcript_entry_bytes(&entry.transcript));
for entry in by_size {
let entry_budget = remaining_bytes / remaining_entries;
truncate_transcript_entry(entry, entry_budget);
remaining_bytes -= transcript_entry_bytes(&entry.transcript);
remaining_entries -= 1;
}
return;
}
total_bytes -= transcript_entry_bytes(&entries.remove(oldest).transcript);
}
if let Some(entry) = entries.first_mut() {
truncate_transcript_entry(entry, MAX_ACTIVE_TRANSCRIPT_BYTES);
}
}
fn truncate_transcript_entry(entry: &mut ActiveTranscriptEntry, max_bytes: usize) {
let entry_overhead = entry.transcript.role.len() + 3;
let max_text_bytes = max_bytes.saturating_sub(entry_overhead);
if entry.transcript.text.len() <= max_text_bytes {
return;
}
let mut start = entry
.transcript
.text
.len()
.saturating_sub(max_text_bytes.saturating_sub(TRUNCATED_TRANSCRIPT_PREFIX.len()));
while !entry.transcript.text.is_char_boundary(start) {
start += 1;
}
let previous_marker_bytes = if entry.truncated_prefix_bytes > 0 {
TRUNCATED_TRANSCRIPT_PREFIX.len()
} else {
0
};
entry.truncated_prefix_bytes += start - previous_marker_bytes;
entry.transcript.text = format!(
"{TRUNCATED_TRANSCRIPT_PREFIX}{}",
&entry.transcript.text[start..]
);
}
fn transcript_entries_bytes<'a>(
entries: impl IntoIterator<Item = &'a RealtimeTranscriptEntry>,
) -> usize {
entries.into_iter().map(transcript_entry_bytes).sum()
}
fn transcript_entry_bytes(entry: &RealtimeTranscriptEntry) -> usize {
entry.role.len() + entry.text.len() + 3
fn transcript_entries_bytes(entries: &[RealtimeTranscriptEntry]) -> usize {
entries
.iter()
.map(|entry| entry.role.len() + entry.text.len() + 3)
.sum()
}
fn append_transcript_delta(
entries: &mut Vec<ActiveTranscriptEntry>,
entries: &mut Vec<RealtimeTranscriptEntry>,
role: &str,
delta: &str,
force_new: bool,
event_parser: RealtimeEventParser,
frame_sequence: Option<u64>,
) {
if delta.is_empty() {
return;
@@ -907,37 +797,30 @@ fn append_transcript_delta(
// V3 interleaves the two speakers' transcripts. Legacy protocols retain
// adjacent speaker runs and their existing response lifecycle semantics.
let entry = match event_parser {
RealtimeEventParser::FramelessBidi => entries
.iter_mut()
.rev()
.find(|entry| entry.transcript.role == role),
RealtimeEventParser::V1 | RealtimeEventParser::RealtimeV2 => entries
.last_mut()
.filter(|entry| entry.transcript.role == role),
RealtimeEventParser::FramelessBidi => {
entries.iter_mut().rev().find(|entry| entry.role == role)
}
RealtimeEventParser::V1 | RealtimeEventParser::RealtimeV2 => {
entries.last_mut().filter(|entry| entry.role == role)
}
};
if !force_new && let Some(last_entry) = entry {
last_entry.transcript.text.push_str(delta);
last_entry.last_raw_frame = frame_sequence;
last_entry.text.push_str(delta);
return;
}
entries.push(ActiveTranscriptEntry {
transcript: RealtimeTranscriptEntry {
role: role.to_string(),
text: delta.to_string(),
},
last_raw_frame: frame_sequence,
truncated_prefix_bytes: 0,
entries.push(RealtimeTranscriptEntry {
role: role.to_string(),
text: delta.to_string(),
});
}
fn apply_transcript_done(
entries: &mut Vec<ActiveTranscriptEntry>,
entries: &mut Vec<RealtimeTranscriptEntry>,
role: &str,
text: &str,
force_new: bool,
event_parser: RealtimeEventParser,
last_raw_frame: Option<u64>,
) {
if text.is_empty() {
return;
@@ -946,59 +829,49 @@ fn apply_transcript_done(
// V3 interleaves the two speakers' transcripts. Legacy protocols retain
// adjacent speaker runs and their existing response lifecycle semantics.
let entry = match event_parser {
RealtimeEventParser::FramelessBidi => entries
.iter_mut()
.rev()
.find(|entry| entry.transcript.role == role),
RealtimeEventParser::V1 | RealtimeEventParser::RealtimeV2 => entries
.last_mut()
.filter(|entry| entry.transcript.role == role),
RealtimeEventParser::FramelessBidi => {
entries.iter_mut().rev().find(|entry| entry.role == role)
}
RealtimeEventParser::V1 | RealtimeEventParser::RealtimeV2 => {
entries.last_mut().filter(|entry| entry.role == role)
}
};
if !force_new && let Some(last_entry) = entry {
// A delayed V3 final can arrive after deltas from the next utterance.
// Preserve accumulated speech unless the final extends it.
if event_parser != RealtimeEventParser::FramelessBidi
|| text
.get(last_entry.truncated_prefix_bytes..)
.is_some_and(|text| text.starts_with(last_entry.retained_text()))
if event_parser != RealtimeEventParser::FramelessBidi || text.starts_with(&last_entry.text)
{
last_entry.transcript.text = text.to_string();
last_entry.truncated_prefix_bytes = 0;
last_entry.text = text.to_string();
}
return;
}
entries.push(ActiveTranscriptEntry {
transcript: RealtimeTranscriptEntry {
role: role.to_string(),
text: text.to_string(),
},
// A final for an evicted current utterance must not become newer than retained speech.
last_raw_frame: if force_new { None } else { last_raw_frame },
truncated_prefix_bytes: 0,
entries.push(RealtimeTranscriptEntry {
role: role.to_string(),
text: text.to_string(),
});
}
fn append_handoff_input(entries: &mut Vec<ActiveTranscriptEntry>, input: &str) {
fn append_handoff_input(active: &mut ActiveTranscriptState, input: &str) {
let input = input.trim();
if input.is_empty() || contains_transcript_entry(entries, "user", input) {
let recorded = &active.flushed_user_inputs;
if input.is_empty()
|| contains_transcript_entry(&active.entries, "user", input)
|| (!recorded.is_empty() && recorded.contains(&recorded.hasher().hash_one(input)))
{
return;
}
entries.push(ActiveTranscriptEntry {
transcript: RealtimeTranscriptEntry {
role: "user".to_string(),
text: input.to_string(),
},
last_raw_frame: None,
truncated_prefix_bytes: 0,
active.entries.push(RealtimeTranscriptEntry {
role: "user".to_string(),
text: input.to_string(),
});
}
fn contains_transcript_entry(entries: &[ActiveTranscriptEntry], role: &str, text: &str) -> bool {
fn contains_transcript_entry(entries: &[RealtimeTranscriptEntry], role: &str, text: &str) -> bool {
entries
.iter()
.any(|entry| entry.transcript.role == role && entry.transcript.text.trim() == text.trim())
.any(|entry| entry.role == role && entry.text.trim() == text.trim())
}
pub struct RealtimeWebsocketClient {
@@ -1611,18 +1484,14 @@ mod tests {
let mut covered = RealtimeEvent::InputTranscriptDelta(RealtimeTranscriptDelta {
delta: "already handed off".to_string(),
});
events
.update_active_transcript(&mut covered, /*frame*/ None)
.await;
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, /*frame*/ None)
.await;
events.update_active_transcript(&mut handoff).await;
assert_eq!(
handoff,
RealtimeEvent::HandoffRequested(RealtimeHandoffRequested {
@@ -1639,9 +1508,7 @@ mod tests {
let mut tail = RealtimeEvent::OutputTranscriptDelta(RealtimeTranscriptDelta {
delta: "tail".to_string(),
});
events
.update_active_transcript(&mut tail, /*frame*/ None)
.await;
events.update_active_transcript(&mut tail).await;
assert_eq!(
events.take_transcript_tail().await,
vec![RealtimeTranscriptEntry {
@@ -1666,9 +1533,7 @@ mod tests {
let mut partial = RealtimeEvent::InputTranscriptDelta(RealtimeTranscriptDelta {
delta: "hello wor".to_string(),
});
first_events
.update_active_transcript(&mut partial, /*frame*/ None)
.await;
first_events.update_active_transcript(&mut partial).await;
let (_second_tx, second_rx) = async_channel::unbounded();
let second_events = RealtimeWebsocketEvents {
@@ -1681,9 +1546,7 @@ mod tests {
let mut done = RealtimeEvent::InputTranscriptDone(RealtimeTranscriptDone {
text: "hello world".to_string(),
});
second_events
.update_active_transcript(&mut done, /*frame*/ None)
.await;
second_events.update_active_transcript(&mut done).await;
assert_eq!(
second_events.take_transcript_tail().await,
@@ -1695,35 +1558,6 @@ mod tests {
assert_eq!(second_events.take_transcript_tail().await, vec![]);
}
#[tokio::test]
async fn active_transcript_retains_a_bounded_suffix() {
let (_tx_message, rx_message) = async_channel::unbounded();
let events = RealtimeWebsocketEvents {
rx_message,
pending_events: Arc::new(Mutex::new(VecDeque::new())),
transcript_state: RealtimeTranscriptState::default(),
event_parser: RealtimeEventParser::FramelessBidi,
is_closed: Arc::new(AtomicBool::new(false)),
};
let mut oversized = RealtimeEvent::InputTranscriptDelta(RealtimeTranscriptDelta {
delta: format!("old{}new", "x".repeat(MAX_ACTIVE_TRANSCRIPT_BYTES)),
});
for frame in [
None,
Some(TranscriptFrame {
start_ms: 0,
end_ms: 200,
}),
] {
events.update_active_transcript(&mut oversized, frame).await;
let tail = events.take_transcript_tail().await;
assert!(transcript_entries_bytes(&tail) <= MAX_ACTIVE_TRANSCRIPT_BYTES);
assert!(tail[0].text.starts_with(TRUNCATED_TRANSCRIPT_PREFIX));
assert!(tail[0].text.ends_with("new"));
}
}
#[test]
fn terminal_sideband_handshake_statuses_are_not_retryable() {
for status in [StatusCode::NOT_FOUND, StatusCode::GONE] {
@@ -24,9 +24,7 @@ fn entry(role: &str, text: &str) -> RealtimeTranscriptEntry {
async fn observe(events: &RealtimeWebsocketEvents, mut event: RealtimeEvent) {
let original = event.clone();
events
.update_active_transcript(&mut event, /*frame*/ None)
.await;
events.update_active_transcript(&mut event).await;
assert_eq!(
event, original,
"live transcript events must pass through unchanged"
@@ -131,176 +129,3 @@ async fn completed_utterances_keep_repeated_speech_and_final_only_turns_distinct
);
}
}
#[tokio::test]
async fn transcript_budget_uses_frame_recency_without_reordering_speakers() {
use serde_json::json;
let old_user = "u".repeat(4000);
let assistant = "a".repeat(4000);
let newest_user = "n".repeat(211);
let current_user = "u".repeat(4160);
let same_frame_user = "n".repeat(22);
let unicode_user = "é".repeat(2000);
let oversized_user = "u".repeat(MAX_ACTIVE_TRANSCRIPT_BYTES);
let extended_assistant = format!("{assistant}{newest_user}!");
for (frames, final_assistant, expected) in [
(
vec![
("user", old_user.as_str(), Some(0)),
("assistant", assistant.as_str(), Some(200)),
("user", newest_user.as_str(), Some(400)),
// Commit eviction before the delayed final arrives.
("user", "", Some(600)),
],
// A late final must keep the evicted assistant's old raw-frame age.
Some(assistant.as_str()),
vec![entry("user", &format!("{old_user}{newest_user}"))],
),
(
vec![
("assistant", assistant.as_str(), Some(0)),
("user", current_user.as_str(), Some(200)),
// The first event already exceeds the budget, before output updates its age.
("user", same_frame_user.as_str(), Some(400)),
// Same-frame input is emitted first; output must not become newer.
("assistant", "bbbbbbbbbbbbbbbbbbbb", Some(400)),
],
None,
vec![
entry("assistant", &format!("{assistant}bbbbbbbbbbbbbbbbbbbb")),
entry("user", &format!("…{}{same_frame_user}", "u".repeat(4128))),
],
),
(
vec![
("user", old_user.as_str(), Some(0)),
("assistant", assistant.as_str(), Some(200)),
("user", newest_user.as_str(), Some(400)),
("assistant", "b", Some(400)),
],
None,
vec![
entry("user", &format!("…{}{newest_user}", "u".repeat(3958))),
entry("assistant", &format!("{assistant}b")),
],
),
(
vec![
("user", old_user.as_str(), Some(0)),
("assistant", assistant.as_str(), Some(200)),
// Reversing the same-frame events must retain the same suffixes.
("assistant", "b", Some(400)),
("user", newest_user.as_str(), Some(400)),
// Advancing the frame also commits eviction before the tail is taken.
("user", "", Some(600)),
],
None,
vec![
entry("user", &format!("…{}{newest_user}", "u".repeat(3958))),
entry("assistant", &format!("{assistant}b")),
],
),
(
vec![
("user", unicode_user.as_str(), Some(0)),
("assistant", assistant.as_str(), Some(200)),
("user", newest_user.as_str(), Some(400)),
("assistant", newest_user.as_str(), Some(400)),
],
None,
vec![
entry("user", &format!("…{}{newest_user}", "é".repeat(1937))),
entry("assistant", &format!("…{}{newest_user}", "a".repeat(3871))),
],
),
(
vec![
("user", oversized_user.as_str(), Some(0)),
("assistant", "b", Some(0)),
],
None,
vec![
entry("user", &format!("…{}", "u".repeat(8169))),
entry("assistant", "b"),
],
),
(
vec![
("assistant", assistant.as_str(), Some(0)),
("user", old_user.as_str(), Some(200)),
("assistant", newest_user.as_str(), Some(400)),
("user", "b", Some(400)),
// Trim the tied utterances before a final extends the retained suffix.
("user", "", Some(600)),
],
Some(extended_assistant.as_str()),
vec![
entry("assistant", &format!("…{}{newest_user}!", "a".repeat(3957))),
entry("user", &format!("{old_user}b")),
],
),
(
vec![
("user", old_user.as_str(), None),
("assistant", assistant.as_str(), None),
("user", newest_user.as_str(), None),
],
None,
vec![entry("assistant", &assistant)],
),
(
vec![
("assistant", "I will", Some(0)),
("user", "Stop!", Some(200)),
("assistant", " do it", Some(200)),
],
Some("I will do it"),
vec![entry("assistant", "I will do it"), entry("user", "Stop!")],
),
] {
let state = RealtimeTranscriptState::default();
let mut events = events(state.clone());
let (sender, receiver) = async_channel::unbounded();
events.rx_message = receiver;
for (role, text, start_ms) in frames {
let event_type = if role == "user" {
"input_transcript.added"
} else {
"output_transcript.added"
};
let mut payload = json!({"type": event_type, "item": {"text": text}});
if let Some(start_ms) = start_ms {
payload["start_ms"] = json!(start_ms);
payload["end_ms"] = json!(start_ms + 200);
}
sender
.send(Ok(Message::Text(payload.to_string().into())))
.await
.unwrap();
let delta = RealtimeTranscriptDelta { delta: text.into() };
assert_eq!(
events.next_event().await.unwrap(),
Some(if role == "user" {
RealtimeEvent::InputTranscriptDelta(delta)
} else {
RealtimeEvent::OutputTranscriptDelta(delta)
}),
);
}
if let Some(text) = final_assistant {
sender.send(Ok(Message::Text(json!({
"type": "turn.done",
"turn": {"role": "assistant", "transcript": text, "start_ms": 0, "end_ms": 800}
}).to_string().into()))).await.unwrap();
assert_eq!(
events.next_event().await.unwrap(),
Some(RealtimeEvent::OutputTranscriptDone(
RealtimeTranscriptDone { text: text.into() }
)),
);
}
assert_eq!(state.take_transcript_tail().await, expected);
assert_eq!(state.take_transcript_tail().await, vec![]);
}
}