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)> {