mirror of
https://github.com/openai/codex.git
synced 2026-10-31 19:28:49 +00:00
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:
@@ -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![]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user