From 1539ecbc5aa85ae4d2d32cd487a961da22cdc8b1 Mon Sep 17 00:00:00 2001 From: Aatroxo1 <149355494+Aatroxo1@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:50:48 +0800 Subject: [PATCH] Quote a cut's words once they are transcribed Gemini sends Interrupted with the start of the candidate's activity, before the words behind it are transcribed, and the candidate turn that is still open then holds the answer the reply was answering. The line naming the cause therefore quoted that answer for every cut, and quoting only what followed the reply read speech as wordless. The cut is now logged where it happens, and a second line quotes the start of the turn that follows it once that turn closes, or when the room ends, with how long after the cut it settled. A close that lands while the candidate is still speaking leaves the cut waiting with the words said so far, so neither the late end of the cut reply nor a pause reads speech as wordless. Closes #188 --- docs/livekit-connection-troubleshooting.md | 3 +- src/agent.rs | 28 +- src/livekit.rs | 8 + src/livekit/session.rs | 50 ++- src/livekit/turn.rs | 68 ++++ tests/agent/runtime.rs | 46 ++- tests/unit/livekit/session.rs | 429 +++++++++++++++++++++ 7 files changed, 590 insertions(+), 42 deletions(-) diff --git a/docs/livekit-connection-troubleshooting.md b/docs/livekit-connection-troubleshooting.md index a7bc42063..d7664fd04 100644 --- a/docs/livekit-connection-troubleshooting.md +++ b/docs/livekit-connection-troubleshooting.md @@ -147,7 +147,8 @@ interview ended by the browser: room=interview-local topic=control ``` The browser joined the room, Jim answered turn after turn (`codetrial turn_timing` -with `outcome=audio`), barge-in worked (`Gemini cut its own turn`), +with `outcome=audio`), barge-in worked (`Gemini cut its own turn` at the +cut, then `candidate audio since the cut` once the words are transcribed), and the session ended on the candidate pressing End interview. Voice runs both ways: the microphone reaches the agent and Jim's audio reaches the browser. diff --git a/src/agent.rs b/src/agent.rs index 2431c199e..b76af2f61 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -703,27 +703,19 @@ impl SpeakerTurn { spoken } - /// Stable for the life of one turn, so the browser patches one row instead - /// of appending a new one per fragment. - /// The tail of what this speaker has said in the turn so far. + /// The start of what this speaker has said in the turn so far. /// - /// For the log only, and bounded: a cut turn is diagnosable from what the - /// candidate was heard saying at the moment it was cut, and a full turn in - /// a log line is not. - pub fn tail(&self, max: usize) -> &str { - let text = self.text.trim(); - - // No length test beside this. `nth_back` already answers it: it yields - // nothing when there are fewer than `max` characters, and when there - // are exactly `max` it lands on the first one, so the slice is the - // whole string either way. The condition that used to be here could be - // written as `>` or `>=` without changing a single result. - match text.char_indices().nth_back(max.saturating_sub(1)) { - Some((start, _)) => &text[start..], - None => text, - } + /// For the log only, and bounded: a full turn in a log line is not + /// diagnosable, and the start is where the words that caused a cut are. + /// Taken from the cleaned turn: room noise arrives as the transcription + /// artifact, and quoting it would make a cut with no words read as one + /// with words. + pub fn head(&self, max: usize) -> String { + self.text().chars().take(max).collect() } + /// Stable for the life of one turn, so the browser patches one row instead + /// of appending a new one per fragment. pub fn segment_id(&self, speaker: &str) -> String { format!("{speaker}-{}", self.index) } diff --git a/src/livekit.rs b/src/livekit.rs index 29e1812ce..f07cde1aa 100644 --- a/src/livekit.rs +++ b/src/livekit.rs @@ -2932,6 +2932,14 @@ pub(crate) async fn run_room_with_slot( } } .await; + + // Every exit from the loop lands here, including the ones that never close + // the turns: the candidate not returning, the billing exits, the event + // stream ending, an error. A cut still waiting loses only its quote there, + // and this is the one place it is still printed, whatever exit is added. + if let Some(line) = turn.turns.take_cut_line(Instant::now(), false) { + eprintln!("{line}"); + } session::finish_turn_timing( room.name(), &mut turn.activity, diff --git a/src/livekit/session.rs b/src/livekit/session.rs index f0bbab647..c677b9918 100644 --- a/src/livekit/session.rs +++ b/src/livekit/session.rs @@ -1777,24 +1777,30 @@ async fn on_interruption( // activity detection, so a cut with the candidate mid-sentence is barge-in // working. A cut with nothing transcribed is usually speaker echo or room // noise, and naming those makes the log actionable without pretending the - // server can tell them apart. - let heard = context.turns.candidate.tail(80); + // server can tell them apart. The words trail the cut, so this line names + // the cut and goes out now, where no exit from the room can lose it, and + // the line quoting them follows once the turn after the cut closes. + let at = log_clock(context.state); eprintln!( - "timing: Gemini cut its own turn, {:.1}s of it unplayed; candidate audio so far: {}", - unplayed.as_secs_f64(), - if heard.is_empty() { - "(nothing transcribed; check speaker echo or background noise)" - } else { - heard - } + "timing: Gemini cut its own turn at={at}, {:.1}s of it unplayed", + unplayed.as_secs_f64() ); + // A cut still waiting would be replaced below, so it is settled here, with + // the turn that holds its words, whatever the voice state. + if let Some(line) = context.turns.take_cut_line(Instant::now(), false) { + eprintln!("{line}"); + } + // A cut-off turn is still over. Without this the next thing either party // says appends to the abandoned turn under its segment id, so the panel // would glue two separate utterances into one row and the report prompt // would read them as one line. close_turns(room, context); + // After the close: the turn it closed held the answer being replied to. + context.turns.note_cut(at, Instant::now()); + set_agent_state(room, context.agent_state, AGENT_STATE_LISTENING); Ok(()) } @@ -2861,13 +2867,21 @@ fn take_stale_playout( /// Both turns are counted in the ledger before either is published. The count /// is what the report reads; the publish only tells the panel, and a failed /// one used to leave the other speaker's closed turn uncounted. +/// +/// A cut waiting for its words is settled here too, unless the candidate is +/// still speaking. pub(super) fn close_turns(room: &RoomLink, context: &mut GeminiEventContext<'_>) { - let closing = close_both_turns( + let (cut_line, closing) = close_turns_settling_cut( context.turns, &mut context.state.evidence_ledger, crate::current_epoch_millis(), context.media.identity.as_deref(), + Instant::now(), + context.activity.candidate_voice_active, ); + if let Some(line) = cut_line { + eprintln!("{line}"); + } for segment in closing { context.transcripts.publish(room, segment); } @@ -2887,6 +2901,22 @@ fn close_interviewer_turn(room: &RoomLink, context: &mut GeminiEventContext<'_>) } } +/// `close_both_turns`, and the line for the cut that was waiting for these +/// turns to close. The line is read first, because closing the candidate's turn +/// clears the words it quotes. +pub(super) fn close_turns_settling_cut( + turns: &mut SpeakerTurns, + ledger: &mut crate::agent::EvidenceLedger, + receipt_timestamp_ms: u64, + candidate_identity: Option<&str>, + now: Instant, + speaking: bool, +) -> (Option, Vec) { + let cut_line = turns.take_cut_line(now, speaking); + let closing = close_both_turns(turns, ledger, receipt_timestamp_ms, candidate_identity); + (cut_line, closing) +} + /// The room-free half of `close_turns`: finishes each open turn, counts it in /// the ledger, and returns its final segment, in publishing order. Only the /// candidate's own speech is attributed to them; the interviewer publishes as diff --git a/src/livekit/turn.rs b/src/livekit/turn.rs index 431b116a5..b936d3dfd 100644 --- a/src/livekit/turn.rs +++ b/src/livekit/turn.rs @@ -2015,6 +2015,74 @@ pub(super) enum Interruptible { pub(super) struct SpeakerTurns { pub(super) interviewer: SpeakerTurn, pub(super) candidate: SpeakerTurn, + /// The cut waiting for the words behind it; see [`SpeakerTurns::note_cut`]. + cut: Option, +} + +/// How much of what the candidate said over a cut the line quotes. +const CUT_QUOTE_CHARS: usize = 80; + +/// What the line for a cut needs from the moment of the cut, and the words +/// that closes while the candidate was speaking cleared from the turn since. +#[derive(Debug)] +struct WaitingCut { + at: String, + since: Instant, + heard: String, +} + +/// The start of two stretches of speech, one after the other. +fn joined_head(earlier: &str, later: &str) -> String { + let mut words = String::from(earlier); + if !earlier.is_empty() && !later.is_empty() { + words.push(' '); + } + words.push_str(later); + words.chars().take(CUT_QUOTE_CHARS).collect() +} + +impl SpeakerTurns { + /// Gemini cut the interviewer's turn, and the line naming the cut is + /// already out. `Interrupted` arrives with the start of the candidate's + /// activity, before the transcription of whatever caused it, so the line + /// quoting the cause waits for the words. Noted after the turn being + /// replied to has been closed, or that answer is what the line quotes. A + /// cut still waiting is replaced, so settle it first. + pub(super) fn note_cut(&mut self, at: String, since: Instant) { + self.cut = Some(WaitingCut { + at, + since, + heard: String::new(), + }); + } + + /// The line for the waiting cut, once, quoting the start of what the + /// candidate said over it and saying how long after the cut that is. Read + /// before the candidate turn closes, which clears it. + /// + /// Not while the candidate is still speaking: a close that lands + /// mid-utterance, such as the late end of the cut generation, would read + /// speech as wordless. The cut keeps waiting, and keeps the words the turn + /// holds, since that close is about to clear them. + pub(super) fn take_cut_line(&mut self, now: Instant, speaking: bool) -> Option { + let cut = self.cut.as_mut()?; + let heard = joined_head(&cut.heard, &self.candidate.head(CUT_QUOTE_CHARS)); + if speaking { + cut.heard = heard; + return None; + } + let cut = self.cut.take()?; + Some(format!( + "timing: candidate audio since the cut at={}, settled {:.1}s later: {}", + cut.at, + now.saturating_duration_since(cut.since).as_secs_f64(), + if heard.is_empty() { + "(nothing transcribed; check speaker echo or background noise)" + } else { + &heard + } + )) + } } /// The two speakers in the order their turns opened. diff --git a/tests/agent/runtime.rs b/tests/agent/runtime.rs index 8215af004..409b3210e 100644 --- a/tests/agent/runtime.rs +++ b/tests/agent/runtime.rs @@ -1660,7 +1660,7 @@ fn a_detail_that_reorders_or_hides_text_is_dropped_but_localized_text_survives() /// any non-Latin script would otherwise panic on a byte index inside a /// character, and the failure would land in the middle of an interview. #[test] -fn a_cut_turn_quotes_a_bounded_tail_of_what_was_heard() { +fn a_cut_turn_quotes_a_bounded_head_of_what_was_heard() { let mut transcript = Vec::new(); let mut turn = SpeakerTurn::default(); turn.record( @@ -1668,10 +1668,10 @@ fn a_cut_turn_quotes_a_bounded_tail_of_what_was_heard() { "Candidate", "so the question is to implement a trie", ); - assert_eq!(turn.tail(80), "so the question is to implement a trie"); + assert_eq!(turn.head(80), "so the question is to implement a trie"); - // The tail, not the head: what was being said as the cut landed. - assert_eq!(turn.tail(10), "ent a trie"); + // The head, not the tail: the words that caused a cut come first. + assert_eq!(turn.head(10), "so the que"); // Multi-byte on purpose and deliberately not prose: what this pins is that // a byte index can fall inside a character, which any non-ASCII transcript @@ -1679,12 +1679,32 @@ fn a_cut_turn_quotes_a_bounded_tail_of_what_was_heard() { // byte-counted tail splits one of them. let mut wide = SpeakerTurn::default(); wide.record(&mut transcript, "Candidate", "αβγδεζηθ\u{1F3A5}"); - assert_eq!(wide.tail(4).chars().count(), 4); - assert_eq!(wide.tail(100), "αβγδεζηθ\u{1F3A5}"); + assert_eq!(wide.head(4).chars().count(), 4); + assert_eq!(wide.head(100), "αβγδεζηθ\u{1F3A5}"); // Nothing heard is the case that matters most: it separates a candidate // talking over the interviewer from a microphone hearing the interviewer. - assert_eq!(SpeakerTurn::default().tail(80), ""); + assert_eq!(SpeakerTurn::default().head(80), ""); +} + +/// Room noise arrives as the transcription artifact, and a quote of the raw +/// turn made a cut with no words read as one with words. +#[test] +fn the_head_of_a_cut_turn_leaves_the_transcription_artifact_out() { + let mut transcript = Vec::new(); + let mut turn = SpeakerTurn::default(); + turn.record(&mut transcript, "Candidate", " #hashtag"); + assert_eq!( + turn.head(80), + "", + "nothing but the artifact is nothing heard" + ); + + // Split across fragments the artifact is still found, and the words around + // it stay. + turn.record(&mut transcript, "Candidate", " wait #hash"); + turn.record(&mut transcript, "Candidate", "tag. one more"); + assert_eq!(turn.head(80), "wait one more"); } /// The same bundle, spelled a second time in the browser. @@ -1901,28 +1921,28 @@ fn grounding_is_empty_only_when_every_list_is() { assert!(InterviewGrounding::default().is_empty()); } -/// The transcript tail is the last `max` characters, counted in characters. +/// The transcript head is the first `max` characters, counted in characters. /// /// It goes into a log line beside a cut turn, and slicing by bytes would panic /// on the multi-byte text the interview is full of. #[test] -fn a_turn_tail_is_the_last_characters_and_never_splits_one() { +fn a_turn_head_is_the_first_characters_and_never_splits_one() { let turn = |text: &str| { let mut turn = SpeakerTurn::default(); turn.record(&mut Vec::new(), "Candidate", text); turn }; - assert_eq!(turn(" hello ").tail(80), "hello"); + assert_eq!(turn(" hello ").head(80), "hello"); assert_eq!( - turn("abcdef").tail(6), + turn("abcdef").head(6), "abcdef", "exactly max is not trimmed" ); - assert_eq!(turn("abcdef").tail(5), "bcdef", "one over max drops one"); + assert_eq!(turn("abcdef").head(5), "abcde", "one over max drops one"); // Two bytes each, so a byte slice at the same offset would panic. let greek = "\u{03b1}\u{03b2}\u{03b3}\u{03b4}"; - assert_eq!(turn(greek).tail(2), "\u{03b3}\u{03b4}"); + assert_eq!(turn(greek).head(2), "\u{03b1}\u{03b2}"); } /// The browser's hidden-character set and the agent's, held against each other diff --git a/tests/unit/livekit/session.rs b/tests/unit/livekit/session.rs index 36140640f..ef85e2b64 100644 --- a/tests/unit/livekit/session.rs +++ b/tests/unit/livekit/session.rs @@ -4530,6 +4530,423 @@ fn closing_both_turns_counts_each_before_anything_is_published() { assert_eq!(state.evidence_ledger.conversation.candidate_turns, 1); } +/// The line quoting a cut names what the candidate said over it, and how long +/// after the cut that was settled. +/// +/// `Interrupted` arrives with the start of the candidate's activity, before the +/// words behind it are transcribed, and the turn being replied to is still open +/// until the cut closes it. Quoting at the cut printed that answer for every +/// interruption; quoting only what followed the reply printed nothing for +/// speech. The line waits for the turn that follows the cut instead. +#[test] +fn a_cut_line_quotes_what_the_candidate_said_over_the_cut() { + let start = Instant::now(); + let mut state = RuntimeState::default(); + let mut turns = SpeakerTurns::default(); + turns.candidate.record( + &mut state.transcript, + CANDIDATE_SPEAKER, + "the input is two linked lists", + ); + + // The cut closes the answer being replied to, so it is not what follows. + let (line, _) = close_turns_settling_cut( + &mut turns, + &mut state.evidence_ledger, + 1, + None, + start, + false, + ); + assert_eq!(line, None, "no cut was waiting for that turn"); + turns.note_cut("1:33.555".to_string(), start); + + // The transcription trails the cut and arrives in pieces. + turns + .candidate + .record(&mut state.transcript, CANDIDATE_SPEAKER, "wait,"); + turns + .candidate + .record(&mut state.transcript, CANDIDATE_SPEAKER, " one more thing"); + let settled = start + Duration::from_millis(4900); + let (line, _) = close_turns_settling_cut( + &mut turns, + &mut state.evidence_ledger, + 2, + None, + settled, + false, + ); + assert_eq!( + line.as_deref(), + Some( + "timing: candidate audio since the cut at=1:33.555, settled 4.9s later: \ + wait, one more thing" + ) + ); + + // Once: the next close has no cut left to settle. + let (line, _) = close_turns_settling_cut( + &mut turns, + &mut state.evidence_ledger, + 3, + None, + settled, + false, + ); + assert_eq!(line, None); +} + +/// A cut with no words after it is the case the log exists to name, so it has +/// to read as wordless and not as the answer that was being replied to. +#[test] +fn a_cut_followed_by_no_words_says_nothing_was_transcribed() { + let start = Instant::now(); + let mut state = RuntimeState::default(); + let mut turns = SpeakerTurns::default(); + turns.candidate.record( + &mut state.transcript, + CANDIDATE_SPEAKER, + "the input is two linked lists", + ); + close_turns_settling_cut( + &mut turns, + &mut state.evidence_ledger, + 1, + None, + start, + false, + ); + turns.note_cut("0:47.000".to_string(), start); + + let settled = start + Duration::from_millis(1500); + let (line, segments) = close_turns_settling_cut( + &mut turns, + &mut state.evidence_ledger, + 2, + None, + settled, + false, + ); + assert_eq!( + line.as_deref(), + Some( + "timing: candidate audio since the cut at=0:47.000, settled 1.5s later: \ + (nothing transcribed; check speaker echo or background noise)" + ) + ); + assert!(segments.is_empty(), "no words, so no turn to publish"); +} + +/// Room noise is transcribed as an artifact and no words, so a cut over it is +/// still a wordless one and must not quote the artifact as speech. +#[test] +fn a_cut_followed_only_by_the_transcription_artifact_reads_as_wordless() { + let start = Instant::now(); + let mut state = RuntimeState::default(); + let mut turns = SpeakerTurns::default(); + turns.note_cut("0:30.000".to_string(), start); + turns + .candidate + .record(&mut state.transcript, CANDIDATE_SPEAKER, "#hashtag"); + + let (line, segments) = close_turns_settling_cut( + &mut turns, + &mut state.evidence_ledger, + 1, + None, + start, + false, + ); + let line = line.expect("the cut is settled by the close"); + assert!( + line.ends_with( + "settled 0.0s later: (nothing transcribed; check speaker echo or background noise)" + ), + "{line}" + ); + assert!(segments.is_empty(), "the artifact is not a turn to publish"); +} + +/// The words that caused a cut come first, so a long reply is quoted from its +/// start and not from the end that has nothing to do with the cut. +#[test] +fn a_long_reply_over_a_cut_is_quoted_from_its_start() { + let start = Instant::now(); + let mut state = RuntimeState::default(); + let mut turns = SpeakerTurns::default(); + turns.note_cut("2:00.000".to_string(), start); + let speech = "wait, before you go on I have a question about the space complexity of \ + the approach we just discussed, and about the empty input"; + turns + .candidate + .record(&mut state.transcript, CANDIDATE_SPEAKER, speech); + + let (line, _) = close_turns_settling_cut( + &mut turns, + &mut state.evidence_ledger, + 1, + None, + start, + false, + ); + let line = line.expect("the cut is settled by the close"); + assert!(line.ends_with(&speech[..80]), "{line}"); + assert!(!line.contains("empty input")); +} + +/// A close that lands while the candidate is still speaking does not settle the +/// cut: the words that caused it are still on their way, and reading them as +/// absent is the misdiagnosis this line exists to prevent. The close does clear +/// the turn, so the words it held stay with the cut and lead the quote. +#[test] +fn a_cut_keeps_waiting_while_the_candidate_is_still_speaking() { + let start = Instant::now(); + let mut state = RuntimeState::default(); + let mut turns = SpeakerTurns::default(); + turns.note_cut("0:10.000".to_string(), start); + turns + .candidate + .record(&mut state.transcript, CANDIDATE_SPEAKER, "wait,"); + + let (line, _) = close_turns_settling_cut( + &mut turns, + &mut state.evidence_ledger, + 1, + None, + start + Duration::from_millis(200), + true, + ); + assert_eq!(line, None, "the candidate is still speaking"); + + turns + .candidate + .record(&mut state.transcript, CANDIDATE_SPEAKER, "one more thing"); + let (line, _) = close_turns_settling_cut( + &mut turns, + &mut state.evidence_ledger, + 2, + None, + start + Duration::from_secs(3), + false, + ); + let line = line.expect("the cut was kept for the close that follows speech"); + assert!( + line.ends_with("settled 3.0s later: wait, one more thing"), + "{line}" + ); +} + +/// The words kept across closes are bounded like the rest of the quote, so a +/// long speech with several late turn ends cannot grow the line. +#[test] +fn the_words_kept_across_closes_stay_within_the_quote() { + let start = Instant::now(); + let mut state = RuntimeState::default(); + let mut turns = SpeakerTurns::default(); + turns.note_cut("0:10.000".to_string(), start); + let speech = "wait, before you go on I have a question about the space complexity of \ + the approach we just discussed, and about the empty input"; + for fragment in [speech, " and one more thing"] { + turns + .candidate + .record(&mut state.transcript, CANDIDATE_SPEAKER, fragment); + close_turns_settling_cut(&mut turns, &mut state.evidence_ledger, 1, None, start, true); + } + turns + .candidate + .record(&mut state.transcript, CANDIDATE_SPEAKER, "last words"); + + let line = turns + .take_cut_line(start + Duration::from_secs(1), false) + .expect("the cut waited for the speech to end"); + let quote = line + .split("settled 1.0s later: ") + .nth(1) + .expect("the line quotes what was heard"); + assert_eq!(quote, &speech[..80], "{line}"); +} + +/// A cut still waiting is settled before the next one replaces it, whatever the +/// voice state, with the words that were said in between. +#[test] +fn a_waiting_cut_is_settled_before_the_next_cut_replaces_it() { + let start = Instant::now(); + let mut state = RuntimeState::default(); + let mut turns = SpeakerTurns::default(); + turns.note_cut("0:10.000".to_string(), start); + turns + .candidate + .record(&mut state.transcript, CANDIDATE_SPEAKER, "mm"); + + let line = turns + .take_cut_line(start + Duration::from_secs(2), false) + .expect("the first cut is settled"); + assert!( + line.contains("at=0:10.000") && line.ends_with("settled 2.0s later: mm"), + "{line}" + ); + + // The close in `on_interruption` ends the turn that held those words. + close_both_turns(&mut turns, &mut state.evidence_ledger, 1, None); + turns.note_cut("0:20.000".to_string(), start + Duration::from_secs(10)); + + turns + .candidate + .record(&mut state.transcript, CANDIDATE_SPEAKER, "hold on"); + let line = turns + .take_cut_line(start + Duration::from_secs(11), false) + .expect("the second cut waits for its own turn"); + assert!( + line.contains("at=0:20.000") && line.ends_with("settled 1.0s later: hold on"), + "{line}" + ); +} + +/// A room that ends with a cut waiting settles it from the turn as it stands: +/// the exits that skip `close_turns` never close that turn, and the voice state +/// is no reason to hold the quote back once the room is gone. +#[test] +fn a_cut_waiting_at_teardown_is_settled_from_the_turn_as_it_stands() { + let start = Instant::now(); + let mut state = RuntimeState::default(); + let mut turns = SpeakerTurns::default(); + turns.note_cut("1:11.733".to_string(), start); + turns.candidate.record( + &mut state.transcript, + CANDIDATE_SPEAKER, + "wait, one more thing", + ); + + let line = turns + .take_cut_line(start + Duration::from_secs(2), false) + .expect("the waiting cut is settled without a close"); + assert_eq!( + line, + "timing: candidate audio since the cut at=1:11.733, settled 2.0s later: \ + wait, one more thing" + ); + assert_eq!( + turns.take_cut_line(start + Duration::from_secs(3), false), + None, + "once" + ); +} + +/// Closing turns that no cut preceded prints no cut line. +#[test] +fn closing_turns_without_a_cut_prints_no_cut_line() { + let mut state = RuntimeState::default(); + let mut turns = SpeakerTurns::default(); + turns + .candidate + .record(&mut state.transcript, CANDIDATE_SPEAKER, "Wait,"); + let (line, segments) = close_turns_settling_cut( + &mut turns, + &mut state.evidence_ledger, + 1, + None, + Instant::now(), + false, + ); + assert_eq!(line, None); + assert_eq!(segments.len(), 1); +} + +/// `on_interruption` closes the answer's turn before it notes the cut. The +/// other order settles the new cut with that answer, or at once with nothing, +/// and a test of the pieces by hand cannot tell the two apart. +#[tokio::test] +async fn an_interruption_waits_for_the_words_that_caused_it() { + let mut harness = Harness::new().await; + harness + .event(GeminiEvent::InputTranscript( + "the input is two linked lists".into(), + )) + .await; + harness.event(reply_audio()).await; + harness.event(GeminiEvent::Interrupted).await; + harness + .event(GeminiEvent::InputTranscript("wait, one more thing".into())) + .await; + + let line = harness + .turn + .turns + .take_cut_line(Instant::now(), false) + .expect("the cut waits for its words"); + assert!(line.ends_with(": wait, one more thing"), "{line}"); + assert!(!line.contains("linked lists"), "{line}"); +} + +/// After `Interrupted`, a late transcript of the cut reply makes its +/// `TurnComplete` look like a new reply's, and it reaches `close_turns` while +/// the candidate is still speaking. That close must leave the cut waiting for +/// the words. +#[tokio::test] +async fn the_late_end_of_a_cut_reply_does_not_settle_the_cut() { + let mut harness = Harness::new().await; + harness + .event(GeminiEvent::InputTranscript( + "the input is two linked lists".into(), + )) + .await; + harness.event(reply_audio()).await; + harness.event(GeminiEvent::Interrupted).await; + harness.turn.activity.candidate_voice_active = true; + harness + .event(GeminiEvent::OutputTranscript("Could you".into())) + .await; + harness.event(GeminiEvent::TurnComplete).await; + harness + .event(GeminiEvent::InputTranscript("wait, one more thing".into())) + .await; + harness.turn.activity.candidate_voice_active = false; + + let line = harness + .turn + .turns + .take_cut_line(Instant::now(), false) + .expect("the cut was still waiting for its words"); + assert!(line.ends_with(": wait, one more thing"), "{line}"); +} + +/// The words transcribed before a close that lands mid-utterance are the ones +/// the quote exists to show. The close clears the turn that holds them, so the +/// cut has to carry them to the line that is written after the rest arrives. +/// +/// A `TurnComplete` after the candidate's words never gets here: their arrival +/// sets the discard, which swallows it. The closes that do are the ones the +/// loop makes outside an event, a pause or a socket replacement, so this +/// drives `close_turns` as those do. +#[tokio::test] +async fn the_words_said_before_a_mid_speech_close_lead_the_quote() { + let mut harness = Harness::new().await; + harness + .event(GeminiEvent::InputTranscript( + "the input is two linked lists".into(), + )) + .await; + harness.event(reply_audio()).await; + harness.event(GeminiEvent::Interrupted).await; + harness.turn.activity.candidate_voice_active = true; + harness + .event(GeminiEvent::InputTranscript("wait,".into())) + .await; + harness.close(); + harness + .event(GeminiEvent::InputTranscript("one more thing".into())) + .await; + harness.turn.activity.candidate_voice_active = false; + + let line = harness + .turn + .turns + .take_cut_line(Instant::now(), false) + .expect("the close mid-speech left the cut waiting"); + assert!(line.ends_with(": wait, one more thing"), "{line}"); +} + /// One room loop's worth of state, around a room that records what it is /// sent and a Gemini socket that never answers, so the handlers themselves /// can be driven: the same `handle_gemini_event` the loop calls, in the order @@ -4576,6 +4993,18 @@ impl Harness { .unwrap(); } + /// A close the loop makes outside any Gemini event, as a pause, a hold + /// cut-off or a socket replacement does. + fn close(&mut self) { + let mut context = self.turn.context( + &mut self.output, + &mut self.gemini, + &mut self.board, + &mut self.media, + ); + close_turns(&self.room, &mut context); + } + /// The transcript segments the page was sent, once the writer has sent /// everything queued. async fn segments(&mut self) -> Vec<(String, bool)> {