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 tool_calls_pending: Vec<ToolCallSummary>,
53 turn_count: u32,
54 max_turns: usize,
55}
56
57fn truncate_string(mut s: String, max_chars: usize) -> String {
59 if s.len() <= max_chars {
60 return s; }
62 if let Some((idx, _)) = s.char_indices().nth(max_chars) {
64 s.truncate(idx);
65 }
66 s
67}
68
69impl TranscriptBuffer {
70 pub fn new() -> Self {
72 Self::with_capacity(DEFAULT_MAX_TURNS)
73 }
74
75 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 pub fn push_input(&mut self, text: &str) {
92 self.current_user.push_str(text);
93 }
94
95 pub fn push_output(&mut self, text: &str) {
97 self.current_model.push_str(text);
98 }
99
100 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 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 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 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 pub fn all_turns(&mut self) -> &[TranscriptTurn] {
159 self.turns.make_contiguous()
160 }
161
162 pub fn retained_count(&self) -> usize {
164 self.turns.len()
165 }
166
167 pub fn turn_count(&self) -> u32 {
169 self.turn_count
170 }
171
172 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 pub fn set_input_transcription(&mut self, text: &str) {
198 self.current_user.clear();
199 self.current_user.push_str(text);
200 }
201
202 pub fn set_output_transcription(&mut self, text: &str) {
204 self.current_model.clear();
205 self.current_model.push_str(text);
206 }
207
208 pub fn truncate_current_model_turn(&mut self) {
210 self.current_model.clear();
211 }
212
213 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 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 pub fn snapshot_window(&mut self, n: usize) -> TranscriptWindow {
233 TranscriptWindow::new(self.window(n).to_vec())
234 }
235
236 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#[derive(Debug, Clone)]
260pub struct TranscriptWindow {
261 turns: Vec<TranscriptTurn>,
262}
263
264impl TranscriptWindow {
265 pub fn new(turns: Vec<TranscriptTurn>) -> Self {
267 Self { turns }
268 }
269
270 pub fn turns(&self) -> &[TranscriptTurn] {
272 &self.turns
273 }
274
275 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 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 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 pub fn len(&self) -> usize {
318 self.turns.len()
319 }
320
321 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 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 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 buf.truncate_current_model_turn();
436 assert!(buf.has_pending()); let turn = buf.end_turn().expect("should produce a turn");
438 assert_eq!(turn.user, "user said something");
439 assert_eq!(turn.model, ""); }
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 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 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 assert_eq!(buf.all_turns()[0].tool_calls.len(), 1);
641 }
642}