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