Skip to main content

gemini_adk_rs/live/
transcript.rs

1//! Transcript accumulation buffer for Live sessions.
2//!
3//! Automatically accumulates input/output transcripts per-turn with windowing
4//! support for OOB extraction pipelines.
5
6use std::collections::VecDeque;
7use std::fmt::Write;
8use std::time::Instant;
9
10/// Summary of a tool call within a conversation turn.
11#[derive(Debug, Clone)]
12pub struct ToolCallSummary {
13    /// Name of the tool that was called.
14    pub name: String,
15    /// First 200 chars of JSON args.
16    pub args_summary: String,
17    /// First 200 chars of JSON result.
18    pub result_summary: String,
19}
20
21/// A single completed conversation turn with accumulated transcripts.
22#[derive(Debug, Clone)]
23pub struct TranscriptTurn {
24    /// Sequential turn number (0-based).
25    pub turn_number: u32,
26    /// Accumulated user (input) transcript for this turn.
27    pub user: String,
28    /// Accumulated model (output) transcript for this turn.
29    pub model: String,
30    /// Tool calls that occurred during this turn.
31    pub tool_calls: Vec<ToolCallSummary>,
32    /// When this turn was finalized.
33    pub timestamp: Instant,
34}
35
36/// Default maximum number of completed turns retained in the ring buffer.
37const DEFAULT_MAX_TURNS: usize = 50;
38
39/// Accumulates input/output transcripts and segments them by turn boundaries.
40///
41/// Uses a ring buffer (`VecDeque`) that evicts the oldest turns when
42/// `max_turns` is reached. This prevents unbounded memory growth in
43/// long-running voice sessions.
44///
45/// Thread safety: wrap in `Arc<parking_lot::Mutex<TranscriptBuffer>>` when
46/// sharing between fast lane (push) and control lane (end_turn / window).
47#[derive(Debug)]
48pub struct TranscriptBuffer {
49    turns: VecDeque<TranscriptTurn>,
50    current_user: String,
51    current_model: String,
52    /// Caller words that arrived after the model began speaking this turn.
53    /// A late transcription tail if the turn ends normally; a barge-in that
54    /// starts the next turn if it ends in an interruption.
55    late_user: String,
56    /// The model's turn in progress was interrupted.
57    interrupted: bool,
58    tool_calls_pending: Vec<ToolCallSummary>,
59    turn_count: u32,
60    max_turns: usize,
61}
62
63/// Truncate a string to at most `max_chars` characters, reusing the original when possible.
64fn truncate_string(mut s: String, max_chars: usize) -> String {
65    if s.len() <= max_chars {
66        return s; // fast path: ASCII strings under limit
67    }
68    // Find the byte index of the max_chars-th char boundary
69    if let Some((idx, _)) = s.char_indices().nth(max_chars) {
70        s.truncate(idx);
71    }
72    s
73}
74
75impl TranscriptBuffer {
76    /// Create a new transcript buffer with the default capacity.
77    pub fn new() -> Self {
78        Self::with_capacity(DEFAULT_MAX_TURNS)
79    }
80
81    /// Create a buffer with a custom maximum turn capacity.
82    ///
83    /// When the buffer reaches `max_turns` completed turns, the oldest
84    /// turn is evicted on each new `end_turn()`.
85    pub fn with_capacity(max_turns: usize) -> Self {
86        Self {
87            turns: VecDeque::with_capacity(max_turns.min(64)),
88            current_user: String::new(),
89            current_model: String::new(),
90            late_user: String::new(),
91            interrupted: false,
92            tool_calls_pending: Vec::new(),
93            turn_count: 0,
94            max_turns,
95        }
96    }
97
98    /// Append input (user speech) transcript text.
99    ///
100    /// Words that arrive once the model has started speaking are kept apart
101    /// until the turn ends: see [`mark_interrupted`](Self::mark_interrupted).
102    pub fn push_input(&mut self, text: &str) {
103        if self.current_model.is_empty() {
104            self.current_user.push_str(text);
105        } else {
106            self.late_user.push_str(text);
107        }
108    }
109
110    /// Record that the caller interrupted the model's turn in progress.
111    ///
112    /// The caller words that arrived after the model began speaking are the
113    /// barge-in: when the turn ends they open the next turn instead of being
114    /// filed, before the model's words, under the turn they interrupted.
115    pub fn mark_interrupted(&mut self) {
116        self.interrupted = true;
117    }
118
119    /// Everything the caller has said in the turn in progress.
120    fn pending_user(&self) -> String {
121        format!("{}{}", self.current_user, self.late_user)
122    }
123
124    /// Append output (model speech) transcript text.
125    pub fn push_output(&mut self, text: &str) {
126        self.current_model.push_str(text);
127    }
128
129    /// Record a tool call summary for the current turn.
130    ///
131    /// Args and result are truncated to 200 characters of their JSON representation.
132    pub fn push_tool_call(
133        &mut self,
134        name: String,
135        args: &serde_json::Value,
136        result: &serde_json::Value,
137    ) {
138        let args_str = serde_json::to_string(args).unwrap_or_default();
139        let result_str = serde_json::to_string(result).unwrap_or_default();
140        self.tool_calls_pending.push(ToolCallSummary {
141            name,
142            args_summary: truncate_string(args_str, 200),
143            result_summary: truncate_string(result_str, 200),
144        });
145    }
146
147    /// Finalize the current turn and return it.
148    ///
149    /// Resets the current accumulators for the next turn.
150    /// Only creates a turn if there is any transcript content.
151    pub fn end_turn(&mut self) -> Option<TranscriptTurn> {
152        let barge_in = if std::mem::take(&mut self.interrupted) {
153            std::mem::take(&mut self.late_user)
154        } else {
155            let tail = std::mem::take(&mut self.late_user);
156            self.current_user.push_str(&tail);
157            String::new()
158        };
159        if self.current_user.is_empty()
160            && self.current_model.is_empty()
161            && self.tool_calls_pending.is_empty()
162        {
163            self.current_user = barge_in;
164            return None;
165        }
166
167        let turn = TranscriptTurn {
168            turn_number: self.turn_count,
169            user: std::mem::replace(&mut self.current_user, barge_in),
170            model: std::mem::take(&mut self.current_model),
171            tool_calls: std::mem::take(&mut self.tool_calls_pending),
172            timestamp: Instant::now(),
173        };
174        self.turn_count += 1;
175        // Evict oldest turn if at capacity
176        if self.turns.len() >= self.max_turns {
177            self.turns.pop_front();
178        }
179        self.turns.push_back(turn);
180        Some(self.turns.back().unwrap().clone())
181    }
182
183    /// Get the last `n` completed turns as a contiguous slice.
184    ///
185    /// Requires `&mut self` to ensure VecDeque contiguity.
186    pub fn window(&mut self, n: usize) -> &[TranscriptTurn] {
187        let slice = self.turns.make_contiguous();
188        let start = slice.len().saturating_sub(n);
189        &slice[start..]
190    }
191
192    /// All completed turns as a contiguous slice.
193    ///
194    /// Requires `&mut self` to ensure VecDeque contiguity.
195    pub fn all_turns(&mut self) -> &[TranscriptTurn] {
196        self.turns.make_contiguous()
197    }
198
199    /// Number of retained turns (may be less than `turn_count` due to eviction).
200    pub fn retained_count(&self) -> usize {
201        self.turns.len()
202    }
203
204    /// Number of completed turns.
205    pub fn turn_count(&self) -> u32 {
206        self.turn_count
207    }
208
209    /// Format the last `n` turns as a human-readable transcript for LLM consumption.
210    pub fn format_window(&mut self, n: usize) -> String {
211        let window = self.window(n);
212        let mut out = String::new();
213        for turn in window {
214            if !turn.user.is_empty() {
215                let _ = writeln!(out, "User: {}", turn.user.trim());
216            }
217            for tc in &turn.tool_calls {
218                let _ = writeln!(
219                    out,
220                    "[Tool: {}({}) \u{2192} {}]",
221                    tc.name, tc.args_summary, tc.result_summary
222                );
223            }
224            if !turn.model.is_empty() {
225                let _ = writeln!(out, "Assistant: {}", turn.model.trim());
226            }
227            let _ = writeln!(out);
228        }
229        out
230    }
231
232    /// Set server-provided input transcription for current turn.
233    /// Overwrites client-accumulated input if server transcription is available.
234    pub fn set_input_transcription(&mut self, text: &str) {
235        self.current_user.clear();
236        self.late_user.clear();
237        self.current_user.push_str(text);
238    }
239
240    /// Set server-provided output transcription for current turn.
241    pub fn set_output_transcription(&mut self, text: &str) {
242        self.current_model.clear();
243        self.current_model.push_str(text);
244    }
245
246    /// Clear the model's text from the turn in progress.
247    pub fn truncate_current_model_turn(&mut self) {
248        self.current_model.clear();
249    }
250
251    /// Cut the model's text in the turn in progress to its first
252    /// `heard_chars` characters, ending at the last whole word. The runtime
253    /// calls this on an interruption with what the listener heard; see
254    /// [`playback`](super::playback).
255    pub fn cut_current_model_turn(&mut self, heard_chars: usize) {
256        let keep = super::playback::heard_prefix(&self.current_model, heard_chars);
257        self.current_model.truncate(keep);
258    }
259
260    /// Whether there is any pending (un-finalized) transcript content.
261    pub fn has_pending(&self) -> bool {
262        !self.current_user.is_empty()
263            || !self.late_user.is_empty()
264            || !self.current_model.is_empty()
265            || !self.tool_calls_pending.is_empty()
266    }
267
268    /// Create a `TranscriptWindow` snapshot of the last `n` completed turns.
269    ///
270    /// This is a cheap clone operation designed for passing to phase callbacks.
271    pub fn snapshot_window(&mut self, n: usize) -> TranscriptWindow {
272        TranscriptWindow::new(self.window(n).to_vec())
273    }
274
275    /// Snapshot including the current in-progress turn (not yet finalized).
276    ///
277    /// Used by `GenerationComplete` extractors to see the model's full output
278    /// before the turn is finalized.
279    pub fn snapshot_window_with_current(&mut self, n: usize) -> TranscriptWindow {
280        let mut turns: Vec<TranscriptTurn> = self.window(n).to_vec();
281        if self.has_pending() {
282            turns.push(TranscriptTurn {
283                turn_number: self.turn_count,
284                user: self.pending_user(),
285                model: self.current_model.clone(),
286                tool_calls: self.tool_calls_pending.clone(),
287                timestamp: std::time::Instant::now(),
288            });
289        }
290        TranscriptWindow::new(turns)
291    }
292}
293
294/// A read-only snapshot of recent transcript turns for context construction.
295///
296/// Cheap to create (clone of ~5 small structs). Used by `on_enter_context`
297/// callbacks to reference recent conversation without holding the buffer lock.
298#[derive(Debug, Clone)]
299pub struct TranscriptWindow {
300    turns: Vec<TranscriptTurn>,
301}
302
303impl TranscriptWindow {
304    /// Create a window from a vec of turns.
305    pub fn new(turns: Vec<TranscriptTurn>) -> Self {
306        Self { turns }
307    }
308
309    /// The turns in this window.
310    pub fn turns(&self) -> &[TranscriptTurn] {
311        &self.turns
312    }
313
314    /// Format all turns as human-readable text for LLM consumption.
315    pub fn formatted(&self) -> String {
316        use std::fmt::Write as _;
317        let mut out = String::new();
318        for turn in &self.turns {
319            if !turn.user.is_empty() {
320                let _ = writeln!(out, "User: {}", turn.user.trim());
321            }
322            for tc in &turn.tool_calls {
323                let _ = writeln!(
324                    out,
325                    "[Tool: {}({}) \u{2192} {}]",
326                    tc.name, tc.args_summary, tc.result_summary
327                );
328            }
329            if !turn.model.is_empty() {
330                let _ = writeln!(out, "Assistant: {}", turn.model.trim());
331            }
332            let _ = writeln!(out);
333        }
334        out
335    }
336
337    /// Last user utterance, if any.
338    pub fn last_user(&self) -> Option<&str> {
339        self.turns
340            .iter()
341            .rev()
342            .find(|t| !t.user.is_empty())
343            .map(|t| t.user.as_str())
344    }
345
346    /// Last model utterance, if any.
347    pub fn last_model(&self) -> Option<&str> {
348        self.turns
349            .iter()
350            .rev()
351            .find(|t| !t.model.is_empty())
352            .map(|t| t.model.as_str())
353    }
354
355    /// Number of turns in this window.
356    pub fn len(&self) -> usize {
357        self.turns.len()
358    }
359
360    /// Whether the window is empty.
361    pub fn is_empty(&self) -> bool {
362        self.turns.is_empty()
363    }
364}
365
366impl Default for TranscriptBuffer {
367    fn default() -> Self {
368        Self::new()
369    }
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375
376    #[test]
377    fn accumulate_and_end_turn() {
378        let mut buf = TranscriptBuffer::new();
379        buf.push_input("Hello ");
380        buf.push_input("there");
381        buf.push_output("Hi! How can I help?");
382        let turn = buf.end_turn().expect("should produce a turn");
383        assert_eq!(turn.turn_number, 0);
384        assert_eq!(turn.user, "Hello there");
385        assert_eq!(turn.model, "Hi! How can I help?");
386        assert_eq!(buf.turn_count(), 1);
387    }
388
389    #[test]
390    fn end_turn_empty_returns_none() {
391        let mut buf = TranscriptBuffer::new();
392        assert!(buf.end_turn().is_none());
393    }
394
395    #[test]
396    fn window_returns_last_n() {
397        let mut buf = TranscriptBuffer::new();
398        for i in 0..5 {
399            buf.push_input(&format!("user-{i}"));
400            buf.push_output(&format!("model-{i}"));
401            buf.end_turn();
402        }
403        let w = buf.window(3);
404        assert_eq!(w.len(), 3);
405        assert_eq!(w[0].turn_number, 2);
406        assert_eq!(w[1].turn_number, 3);
407        assert_eq!(w[2].turn_number, 4);
408    }
409
410    #[test]
411    fn window_larger_than_turns() {
412        let mut buf = TranscriptBuffer::new();
413        buf.push_input("only turn");
414        buf.end_turn();
415        let w = buf.window(10);
416        assert_eq!(w.len(), 1);
417    }
418
419    #[test]
420    fn format_window_produces_readable_text() {
421        let mut buf = TranscriptBuffer::new();
422        buf.push_input("What's the weather?");
423        buf.push_output("It's sunny and 22 degrees.");
424        buf.end_turn();
425        buf.push_input("And tomorrow?");
426        buf.push_output("Rain expected.");
427        buf.end_turn();
428
429        let formatted = buf.format_window(2);
430        assert!(formatted.contains("User: What's the weather?"));
431        assert!(formatted.contains("Assistant: It's sunny and 22 degrees."));
432        assert!(formatted.contains("User: And tomorrow?"));
433        assert!(formatted.contains("Assistant: Rain expected."));
434    }
435
436    #[test]
437    fn has_pending() {
438        let mut buf = TranscriptBuffer::new();
439        assert!(!buf.has_pending());
440        buf.push_input("hello");
441        assert!(buf.has_pending());
442        buf.end_turn();
443        assert!(!buf.has_pending());
444    }
445
446    #[test]
447    fn set_input_transcription_overwrites_accumulated() {
448        let mut buf = TranscriptBuffer::new();
449        buf.push_input("partial ");
450        buf.push_input("input");
451        // Server provides authoritative transcription
452        buf.set_input_transcription("server transcription");
453        let turn = buf.end_turn().expect("should produce a turn");
454        assert_eq!(turn.user, "server transcription");
455    }
456
457    #[test]
458    fn set_output_transcription_overwrites_accumulated() {
459        let mut buf = TranscriptBuffer::new();
460        buf.push_output("partial ");
461        buf.push_output("output");
462        // Server provides authoritative transcription
463        buf.set_output_transcription("server output");
464        let turn = buf.end_turn().expect("should produce a turn");
465        assert_eq!(turn.model, "server output");
466    }
467
468    #[test]
469    fn truncate_current_model_turn_clears_model_text() {
470        let mut buf = TranscriptBuffer::new();
471        buf.push_input("user said something");
472        buf.push_output("model was saying something but got");
473        // Interruption happens
474        buf.truncate_current_model_turn();
475        assert!(buf.has_pending()); // user text is still there
476        let turn = buf.end_turn().expect("should produce a turn");
477        assert_eq!(turn.user, "user said something");
478        assert_eq!(turn.model, ""); // model output was truncated
479    }
480
481    #[test]
482    fn multiple_turns_all_tracked() {
483        let mut buf = TranscriptBuffer::new();
484        buf.push_input("a");
485        buf.end_turn();
486        buf.push_output("b");
487        buf.end_turn();
488        buf.push_input("c");
489        buf.push_output("d");
490        buf.end_turn();
491        assert_eq!(buf.all_turns().len(), 3);
492        assert_eq!(buf.turn_count(), 3);
493    }
494
495    #[test]
496    fn push_tool_call_records_summary() {
497        let mut buf = TranscriptBuffer::new();
498        buf.push_input("check weather");
499        buf.push_tool_call(
500            "get_weather".to_string(),
501            &serde_json::json!({"city": "London"}),
502            &serde_json::json!({"temp": 22, "condition": "sunny"}),
503        );
504        buf.push_output("It's sunny in London.");
505        let turn = buf.end_turn().expect("should produce a turn");
506        assert_eq!(turn.tool_calls.len(), 1);
507        assert_eq!(turn.tool_calls[0].name, "get_weather");
508        assert!(turn.tool_calls[0].args_summary.contains("London"));
509        assert!(turn.tool_calls[0].result_summary.contains("sunny"));
510    }
511
512    #[test]
513    fn push_tool_call_truncates_long_args() {
514        let mut buf = TranscriptBuffer::new();
515        let long_value = "x".repeat(500);
516        buf.push_input("do something");
517        buf.push_tool_call(
518            "big_tool".to_string(),
519            &serde_json::json!({"data": long_value}),
520            &serde_json::json!({"ok": true}),
521        );
522        let turn = buf.end_turn().expect("should produce a turn");
523        assert!(turn.tool_calls[0].args_summary.chars().count() <= 200);
524    }
525
526    #[test]
527    fn multiple_tool_calls_in_one_turn() {
528        let mut buf = TranscriptBuffer::new();
529        buf.push_input("plan my trip");
530        buf.push_tool_call(
531            "get_weather".to_string(),
532            &serde_json::json!({"city": "Paris"}),
533            &serde_json::json!({"temp": 18}),
534        );
535        buf.push_tool_call(
536            "get_flights".to_string(),
537            &serde_json::json!({"from": "NYC", "to": "Paris"}),
538            &serde_json::json!({"price": 450}),
539        );
540        buf.push_output("Here's your trip plan.");
541        let turn = buf.end_turn().expect("should produce a turn");
542        assert_eq!(turn.tool_calls.len(), 2);
543        assert_eq!(turn.tool_calls[0].name, "get_weather");
544        assert_eq!(turn.tool_calls[1].name, "get_flights");
545    }
546
547    #[test]
548    fn tool_calls_appear_in_format_window() {
549        let mut buf = TranscriptBuffer::new();
550        buf.push_input("What's the weather?");
551        buf.push_tool_call(
552            "get_weather".to_string(),
553            &serde_json::json!({"city": "London"}),
554            &serde_json::json!({"temp": 22}),
555        );
556        buf.push_output("It's 22 degrees in London.");
557        buf.end_turn();
558
559        let formatted = buf.format_window(1);
560        assert!(formatted.contains("User: What's the weather?"));
561        assert!(formatted.contains("[Tool: get_weather("));
562        assert!(formatted.contains("London"));
563        assert!(formatted.contains("\u{2192}"));
564        assert!(formatted.contains("22"));
565        assert!(formatted.contains("Assistant: It's 22 degrees in London."));
566    }
567
568    #[test]
569    fn tool_call_only_turn_creates_turn() {
570        let mut buf = TranscriptBuffer::new();
571        // A turn with only a tool call and no user/model text
572        buf.push_tool_call(
573            "ping".to_string(),
574            &serde_json::json!({}),
575            &serde_json::json!({"pong": true}),
576        );
577        assert!(buf.has_pending());
578        let turn = buf
579            .end_turn()
580            .expect("tool-call-only turn should be created");
581        assert_eq!(turn.tool_calls.len(), 1);
582        assert_eq!(turn.user, "");
583        assert_eq!(turn.model, "");
584    }
585
586    #[test]
587    fn snapshot_window_creates_window() {
588        let mut buf = TranscriptBuffer::new();
589        buf.push_input("Hello");
590        buf.push_output("Hi there!");
591        buf.end_turn();
592        buf.push_input("How are you?");
593        buf.push_output("I'm good!");
594        buf.end_turn();
595
596        let window = buf.snapshot_window(5);
597        assert_eq!(window.len(), 2);
598        assert_eq!(window.last_user(), Some("How are you?"));
599        assert_eq!(window.last_model(), Some("I'm good!"));
600        assert!(!window.is_empty());
601    }
602
603    #[test]
604    fn transcript_window_formatted() {
605        let mut buf = TranscriptBuffer::new();
606        buf.push_input("What's the weather?");
607        buf.push_output("It's sunny.");
608        buf.end_turn();
609
610        let window = buf.snapshot_window(1);
611        let formatted = window.formatted();
612        assert!(formatted.contains("User: What's the weather?"));
613        assert!(formatted.contains("Assistant: It's sunny."));
614    }
615
616    #[test]
617    fn transcript_window_empty() {
618        let mut buf = TranscriptBuffer::new();
619        let window = buf.snapshot_window(5);
620        assert!(window.is_empty());
621        assert_eq!(window.len(), 0);
622        assert_eq!(window.last_user(), None);
623        assert_eq!(window.last_model(), None);
624    }
625
626    #[test]
627    fn ring_cap_evicts_oldest() {
628        let mut buf = TranscriptBuffer::with_capacity(3);
629        for i in 0..5 {
630            buf.push_input(&format!("user-{i}"));
631            buf.push_output(&format!("model-{i}"));
632            buf.end_turn();
633        }
634        // Only last 3 retained
635        assert_eq!(buf.retained_count(), 3);
636        assert_eq!(buf.turn_count(), 5);
637        let turns = buf.all_turns();
638        assert_eq!(turns[0].turn_number, 2);
639        assert_eq!(turns[1].turn_number, 3);
640        assert_eq!(turns[2].turn_number, 4);
641    }
642
643    #[test]
644    fn ring_cap_window_within_retained() {
645        let mut buf = TranscriptBuffer::with_capacity(4);
646        for i in 0..10 {
647            buf.push_input(&format!("u{i}"));
648            buf.end_turn();
649        }
650        let w = buf.window(2);
651        assert_eq!(w.len(), 2);
652        assert_eq!(w[0].turn_number, 8);
653        assert_eq!(w[1].turn_number, 9);
654    }
655
656    #[test]
657    fn default_capacity_is_50() {
658        let buf = TranscriptBuffer::new();
659        assert_eq!(buf.max_turns, DEFAULT_MAX_TURNS);
660    }
661
662    #[test]
663    fn tool_calls_reset_after_end_turn() {
664        let mut buf = TranscriptBuffer::new();
665        buf.push_input("turn 1");
666        buf.push_tool_call(
667            "tool_a".to_string(),
668            &serde_json::json!({"x": 1}),
669            &serde_json::json!({"y": 2}),
670        );
671        buf.end_turn();
672
673        buf.push_input("turn 2");
674        buf.push_output("no tools this time");
675        let turn2 = buf.end_turn().expect("should produce turn 2");
676        assert!(turn2.tool_calls.is_empty());
677
678        // Verify turn 1 still has its tool call
679        assert_eq!(buf.all_turns()[0].tool_calls.len(), 1);
680    }
681
682    #[test]
683    fn a_barge_in_opens_the_next_turn() {
684        let mut buf = TranscriptBuffer::new();
685        buf.push_input("Four at seven, under Rossi.");
686        buf.push_output("Four at seven under Rossi. Shall I");
687        buf.push_input("Yes, book it.");
688        buf.mark_interrupted();
689        let interrupted = buf.end_turn().unwrap();
690        assert_eq!(interrupted.user, "Four at seven, under Rossi.");
691
692        // The caller's words are the turn in progress, ahead of the reply.
693        let pending = buf.snapshot_window_with_current(1);
694        assert_eq!(pending.turns().last().unwrap().user, "Yes, book it.");
695        buf.push_output("Booked.");
696        let next = buf.end_turn().unwrap();
697        assert_eq!(next.user, "Yes, book it.");
698        assert_eq!(next.model, "Booked.");
699    }
700
701    #[test]
702    fn a_late_transcription_tail_stays_in_its_turn() {
703        let mut buf = TranscriptBuffer::new();
704        buf.push_input("Table for four");
705        buf.push_output("Sure, ");
706        buf.push_input(" tomorrow.");
707        let turn = buf.end_turn().unwrap();
708        assert_eq!(turn.user, "Table for four tomorrow.");
709        assert!(!buf.has_pending());
710    }
711
712    #[test]
713    fn a_barge_in_over_an_unheard_reply_still_opens_the_next_turn() {
714        let mut buf = TranscriptBuffer::new();
715        buf.push_output("Let me");
716        buf.push_input("Actually, five people.");
717        buf.mark_interrupted();
718        buf.cut_current_model_turn(0);
719        assert!(buf.end_turn().is_none(), "nothing was heard or said before");
720        assert!(buf.has_pending());
721        buf.push_output("Five, got it.");
722        assert_eq!(buf.end_turn().unwrap().user, "Actually, five people.");
723    }
724}