1use std::collections::VecDeque;
7use std::fmt::Write;
8use std::time::Instant;
9
10#[derive(Debug, Clone)]
12pub struct ToolCallSummary {
13 pub name: String,
15 pub args_summary: String,
17 pub result_summary: String,
19}
20
21#[derive(Debug, Clone)]
23pub struct TranscriptTurn {
24 pub turn_number: u32,
26 pub user: String,
28 pub model: String,
30 pub tool_calls: Vec<ToolCallSummary>,
32 pub timestamp: Instant,
34}
35
36const DEFAULT_MAX_TURNS: usize = 50;
38
39#[derive(Debug)]
48pub struct TranscriptBuffer {
49 turns: VecDeque<TranscriptTurn>,
50 current_user: String,
51 current_model: String,
52 late_user: String,
56 interrupted: bool,
58 tool_calls_pending: Vec<ToolCallSummary>,
59 turn_count: u32,
60 max_turns: usize,
61}
62
63fn truncate_string(mut s: String, max_chars: usize) -> String {
65 if s.len() <= max_chars {
66 return s; }
68 if let Some((idx, _)) = s.char_indices().nth(max_chars) {
70 s.truncate(idx);
71 }
72 s
73}
74
75impl TranscriptBuffer {
76 pub fn new() -> Self {
78 Self::with_capacity(DEFAULT_MAX_TURNS)
79 }
80
81 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 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 pub fn mark_interrupted(&mut self) {
116 self.interrupted = true;
117 }
118
119 fn pending_user(&self) -> String {
121 format!("{}{}", self.current_user, self.late_user)
122 }
123
124 pub fn push_output(&mut self, text: &str) {
126 self.current_model.push_str(text);
127 }
128
129 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 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 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 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 pub fn all_turns(&mut self) -> &[TranscriptTurn] {
196 self.turns.make_contiguous()
197 }
198
199 pub fn retained_count(&self) -> usize {
201 self.turns.len()
202 }
203
204 pub fn turn_count(&self) -> u32 {
206 self.turn_count
207 }
208
209 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 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 pub fn set_output_transcription(&mut self, text: &str) {
242 self.current_model.clear();
243 self.current_model.push_str(text);
244 }
245
246 pub fn truncate_current_model_turn(&mut self) {
248 self.current_model.clear();
249 }
250
251 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 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 pub fn snapshot_window(&mut self, n: usize) -> TranscriptWindow {
272 TranscriptWindow::new(self.window(n).to_vec())
273 }
274
275 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#[derive(Debug, Clone)]
299pub struct TranscriptWindow {
300 turns: Vec<TranscriptTurn>,
301}
302
303impl TranscriptWindow {
304 pub fn new(turns: Vec<TranscriptTurn>) -> Self {
306 Self { turns }
307 }
308
309 pub fn turns(&self) -> &[TranscriptTurn] {
311 &self.turns
312 }
313
314 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 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 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 pub fn len(&self) -> usize {
357 self.turns.len()
358 }
359
360 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 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 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 buf.truncate_current_model_turn();
475 assert!(buf.has_pending()); let turn = buf.end_turn().expect("should produce a turn");
477 assert_eq!(turn.user, "user said something");
478 assert_eq!(turn.model, ""); }
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 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 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 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 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}