1use super::compact::{
11 CompactConfig, CompactThresholds, CompactionStrategy, ContextSummary, SummaryFrame,
12};
13use rig::completion::Message;
14use serde::{Deserialize, Serialize};
15
16const CHARS_PER_TOKEN: usize = 4;
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct ConversationTurn {
22 pub user_message: String,
23 pub assistant_response: String,
24 pub tool_calls: Vec<ToolCallRecord>,
26 pub estimated_tokens: usize,
28 #[serde(default)]
32 pub droppable: bool,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct ToolCallRecord {
38 pub tool_name: String,
39 pub args_summary: String,
40 pub result_summary: String,
41 #[serde(default)]
43 pub tool_id: Option<String>,
44 #[serde(default)]
46 pub droppable: bool,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct ConversationHistory {
52 turns: Vec<ConversationTurn>,
54 summary_frame: Option<SummaryFrame>,
56 total_tokens: usize,
58 compact_config: CompactConfig,
60 user_turn_count: usize,
62 context_summary: ContextSummary,
64}
65
66impl Default for ConversationHistory {
67 fn default() -> Self {
68 Self::new()
69 }
70}
71
72impl ConversationHistory {
73 pub fn new() -> Self {
74 Self {
75 turns: Vec::new(),
76 summary_frame: None,
77 total_tokens: 0,
78 compact_config: CompactConfig::default(),
79 user_turn_count: 0,
80 context_summary: ContextSummary::new(),
81 }
82 }
83
84 pub fn with_config(config: CompactConfig) -> Self {
86 Self {
87 turns: Vec::new(),
88 summary_frame: None,
89 total_tokens: 0,
90 compact_config: config,
91 user_turn_count: 0,
92 context_summary: ContextSummary::new(),
93 }
94 }
95
96 pub fn aggressive() -> Self {
98 Self::with_config(CompactConfig {
99 retention_window: 5,
100 eviction_window: 0.7,
101 thresholds: CompactThresholds::aggressive(),
102 })
103 }
104
105 pub fn relaxed() -> Self {
107 Self::with_config(CompactConfig {
108 retention_window: 20,
109 eviction_window: 0.5,
110 thresholds: CompactThresholds::relaxed(),
111 })
112 }
113
114 pub fn estimate_tokens(text: &str) -> usize {
117 text.len() / CHARS_PER_TOKEN
118 }
119
120 pub fn add_turn(
122 &mut self,
123 user_message: String,
124 assistant_response: String,
125 tool_calls: Vec<ToolCallRecord>,
126 ) {
127 let droppable = !tool_calls.is_empty()
130 && tool_calls.iter().all(|tc| {
131 matches!(
132 tc.tool_name.as_str(),
133 "read_file" | "list_directory" | "analyze_project"
134 )
135 });
136
137 let turn_tokens = Self::estimate_tokens(&user_message)
138 + Self::estimate_tokens(&assistant_response)
139 + tool_calls
140 .iter()
141 .map(|tc| {
142 Self::estimate_tokens(&tc.tool_name)
143 + Self::estimate_tokens(&tc.args_summary)
144 + Self::estimate_tokens(&tc.result_summary)
145 })
146 .sum::<usize>();
147
148 self.turns.push(ConversationTurn {
149 user_message,
150 assistant_response,
151 tool_calls,
152 estimated_tokens: turn_tokens,
153 droppable,
154 });
155 self.total_tokens += turn_tokens;
156 self.user_turn_count += 1;
157 }
158
159 pub fn add_turn_droppable(
161 &mut self,
162 user_message: String,
163 assistant_response: String,
164 tool_calls: Vec<ToolCallRecord>,
165 droppable: bool,
166 ) {
167 let turn_tokens = Self::estimate_tokens(&user_message)
168 + Self::estimate_tokens(&assistant_response)
169 + tool_calls
170 .iter()
171 .map(|tc| {
172 Self::estimate_tokens(&tc.tool_name)
173 + Self::estimate_tokens(&tc.args_summary)
174 + Self::estimate_tokens(&tc.result_summary)
175 })
176 .sum::<usize>();
177
178 self.turns.push(ConversationTurn {
179 user_message,
180 assistant_response,
181 tool_calls,
182 estimated_tokens: turn_tokens,
183 droppable,
184 });
185 self.total_tokens += turn_tokens;
186 self.user_turn_count += 1;
187 }
188
189 pub fn needs_compaction(&self) -> bool {
191 let last_is_user = self
192 .turns
193 .last()
194 .map(|t| !t.user_message.is_empty())
195 .unwrap_or(false);
196
197 self.compact_config.should_compact(
198 self.total_tokens,
199 self.user_turn_count,
200 self.turns.len(),
201 last_is_user,
202 )
203 }
204
205 pub fn compaction_reason(&self) -> Option<String> {
207 self.compact_config.compaction_reason(
208 self.total_tokens,
209 self.user_turn_count,
210 self.turns.len(),
211 )
212 }
213
214 pub fn token_count(&self) -> usize {
216 self.total_tokens
217 }
218
219 pub fn turn_count(&self) -> usize {
221 self.turns.len()
222 }
223
224 pub fn user_turn_count(&self) -> usize {
226 self.user_turn_count
227 }
228
229 pub fn clear(&mut self) {
231 self.turns.clear();
232 self.summary_frame = None;
233 self.total_tokens = 0;
234 self.user_turn_count = 0;
235 self.context_summary = ContextSummary::new();
236 }
237
238 pub fn clear_turns_preserve_context(&mut self) {
243 if self.turns.len() > 1 {
245 let _ = self.compact();
246 }
247
248 self.turns.clear();
250
251 self.total_tokens = self
253 .summary_frame
254 .as_ref()
255 .map(|f| f.token_count)
256 .unwrap_or(0);
257
258 }
260
261 pub fn compact(&mut self) -> Option<String> {
264 use super::compact::strategy::{MessageMeta, MessageRole};
265 use super::compact::summary::{
266 ToolCallSummary, TurnSummary, extract_assistant_action, extract_user_intent,
267 };
268
269 if self.turns.len() < 2 {
270 return None; }
272
273 let messages: Vec<MessageMeta> = self
275 .turns
276 .iter()
277 .enumerate()
278 .flat_map(|(turn_idx, turn)| {
279 let mut metas = vec![];
280
281 metas.push(MessageMeta {
283 index: turn_idx * 2,
284 role: MessageRole::User,
285 droppable: turn.droppable,
286 has_tool_call: false,
287 is_tool_result: false,
288 tool_id: None,
289 token_count: Self::estimate_tokens(&turn.user_message),
290 });
291
292 let has_tool_call = !turn.tool_calls.is_empty();
294 let tool_id = turn.tool_calls.first().and_then(|tc| tc.tool_id.clone());
295
296 metas.push(MessageMeta {
297 index: turn_idx * 2 + 1,
298 role: MessageRole::Assistant,
299 droppable: turn.droppable,
300 has_tool_call,
301 is_tool_result: false,
302 tool_id,
303 token_count: Self::estimate_tokens(&turn.assistant_response),
304 });
305
306 metas
307 })
308 .collect();
309
310 let strategy = CompactionStrategy::default();
312
313 let range =
315 strategy.calculate_eviction_range(&messages, self.compact_config.retention_window)?;
316
317 if range.is_empty() {
318 return None;
319 }
320
321 let start_turn = range.start / 2;
323 let end_turn = range.end.div_ceil(2);
324
325 if start_turn >= end_turn || end_turn > self.turns.len() {
326 return None;
327 }
328
329 let mut new_context = ContextSummary::new();
331
332 for (i, turn) in self.turns[start_turn..end_turn].iter().enumerate() {
333 let turn_summary = TurnSummary {
334 turn_number: start_turn + i + 1,
335 user_intent: extract_user_intent(&turn.user_message, 80),
336 assistant_action: extract_assistant_action(&turn.assistant_response, 100),
337 tool_calls: turn
338 .tool_calls
339 .iter()
340 .map(|tc| ToolCallSummary {
341 tool_name: tc.tool_name.clone(),
342 args_summary: tc.args_summary.clone(),
343 result_summary: truncate_text(&tc.result_summary, 100),
344 success: !tc.result_summary.to_lowercase().contains("error"),
345 })
346 .collect(),
347 key_decisions: vec![], };
349 new_context.add_turn(turn_summary);
350 }
351
352 self.context_summary.merge(new_context);
354
355 let new_frame = SummaryFrame::from_summary(&self.context_summary);
357
358 if let Some(existing) = &self.summary_frame {
360 let merged_content = format!("{}\n\n{}", existing.content, new_frame.content);
361 let merged_tokens = existing.token_count + new_frame.token_count;
362 self.summary_frame = Some(SummaryFrame {
363 content: merged_content,
364 token_count: merged_tokens,
365 });
366 } else {
367 self.summary_frame = Some(new_frame);
368 }
369
370 let preserved_turns: Vec<_> = self.turns[end_turn..].to_vec();
372 let evicted_count = end_turn - start_turn;
373 self.turns = preserved_turns;
374
375 self.total_tokens = self
377 .summary_frame
378 .as_ref()
379 .map(|f| f.token_count)
380 .unwrap_or(0)
381 + self.turns.iter().map(|t| t.estimated_tokens).sum::<usize>();
382
383 Some(format!(
384 "Compacted {} turns ({} → {} tokens)",
385 evicted_count,
386 self.total_tokens + evicted_count * 500, self.total_tokens
388 ))
389 }
390
391 pub fn emergency_compact(&mut self) -> Option<String> {
395 let original_config = self.compact_config.clone();
397 self.compact_config = CompactConfig {
398 retention_window: 3, eviction_window: 0.9, thresholds: CompactThresholds::aggressive(),
401 };
402
403 let result = self.compact();
404
405 self.compact_config = original_config;
407 result
408 }
409
410 pub fn to_messages(&self) -> Vec<Message> {
413 use rig::OneOrMany;
414 use rig::completion::message::{AssistantContent, Text, UserContent};
415
416 let mut messages = Vec::new();
417
418 if let Some(frame) = &self.summary_frame {
420 messages.push(Message::User {
422 content: OneOrMany::one(UserContent::Text(Text {
423 text: format!("[Previous conversation context]\n{}", frame.content),
424 })),
425 });
426 messages.push(Message::Assistant {
427 id: None,
428 content: OneOrMany::one(AssistantContent::Text(Text {
429 text:
430 "I understand the previous context. I'll continue from where we left off."
431 .to_string(),
432 })),
433 });
434 }
435
436 for turn in &self.turns {
438 messages.push(Message::User {
440 content: OneOrMany::one(UserContent::Text(Text {
441 text: turn.user_message.clone(),
442 })),
443 });
444
445 let mut response_text = String::new();
447
448 if !turn.tool_calls.is_empty() {
450 response_text.push_str("[Tools used in this turn:\n");
451 for tc in &turn.tool_calls {
452 response_text.push_str(&format!(
453 " - {}({}) → {}\n",
454 tc.tool_name,
455 truncate_text(&tc.args_summary, 50),
456 truncate_text(&tc.result_summary, 100)
457 ));
458 }
459 response_text.push_str("]\n\n");
460 }
461
462 response_text.push_str(&turn.assistant_response);
464
465 messages.push(Message::Assistant {
466 id: None,
467 content: OneOrMany::one(AssistantContent::Text(Text {
468 text: response_text,
469 })),
470 });
471 }
472
473 messages
474 }
475
476 pub fn is_empty(&self) -> bool {
478 self.turns.is_empty() && self.summary_frame.is_none()
479 }
480
481 pub fn status(&self) -> String {
483 let compressed_info = if self.summary_frame.is_some() {
484 format!(" (+{} compacted)", self.context_summary.turns_compacted)
485 } else {
486 String::new()
487 };
488 format!(
489 "{} turns, ~{} tokens{}",
490 self.turns.len(),
491 self.total_tokens,
492 compressed_info
493 )
494 }
495
496 pub fn files_read(&self) -> impl Iterator<Item = &str> {
498 self.context_summary.files_read.iter().map(|s| s.as_str())
499 }
500
501 pub fn files_written(&self) -> impl Iterator<Item = &str> {
503 self.context_summary
504 .files_written
505 .iter()
506 .map(|s| s.as_str())
507 }
508
509 pub fn to_json(&self) -> Result<String, serde_json::Error> {
511 serde_json::to_string(self)
512 }
513
514 pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
516 serde_json::from_str(json)
517 }
518}
519
520fn truncate_text(text: &str, max_len: usize) -> String {
522 if text.len() <= max_len {
523 text.to_string()
524 } else {
525 format!("{}...", &text[..max_len.saturating_sub(3)])
526 }
527}
528
529#[cfg(test)]
530mod tests {
531 use super::*;
532
533 #[test]
534 fn test_add_turn() {
535 let mut history = ConversationHistory::new();
536 history.add_turn("Hello".to_string(), "Hi there!".to_string(), vec![]);
537 assert_eq!(history.turn_count(), 1);
538 assert!(!history.is_empty());
539 }
540
541 #[test]
542 fn test_droppable_detection() {
543 let mut history = ConversationHistory::new();
544
545 history.add_turn(
547 "Read the file".to_string(),
548 "Here's the content".to_string(),
549 vec![ToolCallRecord {
550 tool_name: "read_file".to_string(),
551 args_summary: "src/main.rs".to_string(),
552 result_summary: "file content...".to_string(),
553 tool_id: Some("tool_1".to_string()),
554 droppable: true,
555 }],
556 );
557 assert!(history.turns[0].droppable);
558
559 history.add_turn(
561 "Write the file".to_string(),
562 "Done".to_string(),
563 vec![ToolCallRecord {
564 tool_name: "write_file".to_string(),
565 args_summary: "src/new.rs".to_string(),
566 result_summary: "success".to_string(),
567 tool_id: Some("tool_2".to_string()),
568 droppable: false,
569 }],
570 );
571 assert!(!history.turns[1].droppable);
572 }
573
574 #[test]
575 fn test_compaction() {
576 let mut history = ConversationHistory::with_config(CompactConfig {
578 retention_window: 2,
579 eviction_window: 0.6,
580 thresholds: CompactThresholds {
581 token_threshold: Some(500),
582 turn_threshold: Some(5),
583 message_threshold: Some(10),
584 on_turn_end: None,
585 },
586 });
587
588 for i in 0..10 {
590 history.add_turn(
591 format!("Question {} with lots of text to increase token count", i),
592 format!(
593 "Answer {} with lots of detail to increase token count even more",
594 i
595 ),
596 vec![ToolCallRecord {
597 tool_name: "analyze".to_string(),
598 args_summary: "path: .".to_string(),
599 result_summary: "Found rust project with many files".to_string(),
600 tool_id: Some(format!("tool_{}", i)),
601 droppable: false,
602 }],
603 );
604 }
605
606 if history.needs_compaction() {
607 let summary = history.compact();
608 assert!(summary.is_some());
609 assert!(history.turn_count() < 10);
610 assert!(history.summary_frame.is_some());
611 }
612 }
613
614 #[test]
615 fn test_to_messages() {
616 let mut history = ConversationHistory::new();
617 history.add_turn(
618 "What is this project?".to_string(),
619 "This is a Rust CLI tool.".to_string(),
620 vec![],
621 );
622
623 let messages = history.to_messages();
624 assert_eq!(messages.len(), 2); }
626
627 #[test]
628 fn test_clear() {
629 let mut history = ConversationHistory::new();
630 history.add_turn("Test".to_string(), "Response".to_string(), vec![]);
631 history.clear();
632 assert!(history.is_empty());
633 assert_eq!(history.token_count(), 0);
634 }
635
636 #[test]
637 fn test_compaction_reason() {
638 let mut history = ConversationHistory::with_config(CompactConfig {
639 retention_window: 2,
640 eviction_window: 0.6,
641 thresholds: CompactThresholds {
642 token_threshold: Some(100),
643 turn_threshold: Some(3),
644 message_threshold: Some(5),
645 on_turn_end: None,
646 },
647 });
648
649 for i in 0..5 {
651 history.add_turn(format!("Question {}", i), format!("Answer {}", i), vec![]);
652 }
653
654 assert!(history.needs_compaction());
655 let reason = history.compaction_reason();
656 assert!(reason.is_some());
657 }
658
659 #[test]
660 fn test_clear_turns_preserve_context() {
661 let mut history = ConversationHistory::with_config(CompactConfig {
663 retention_window: 2,
664 eviction_window: 0.6,
665 thresholds: CompactThresholds {
666 token_threshold: Some(200),
667 turn_threshold: Some(3),
668 message_threshold: Some(5),
669 on_turn_end: None,
670 },
671 });
672
673 for i in 0..6 {
675 history.add_turn(
676 format!("Question {} with extra text", i),
677 format!("Answer {} with more detail", i),
678 vec![],
679 );
680 }
681
682 if history.needs_compaction() {
684 let _ = history.compact();
685 }
686
687 let had_summary_before = history.summary_frame.is_some();
689
690 history.clear_turns_preserve_context();
692
693 assert_eq!(history.turn_count(), 0, "Turns should be cleared");
695 assert!(
696 history.summary_frame.is_some() == had_summary_before,
697 "Summary frame should be preserved"
698 );
699
700 if history.summary_frame.is_some() {
702 assert!(history.token_count() > 0, "Should have tokens from summary");
703 }
704
705 let messages = history.to_messages();
707 if history.summary_frame.is_some() {
708 assert!(
709 !messages.is_empty(),
710 "Should still have summary in messages"
711 );
712 }
713 }
714
715 #[test]
716 fn test_clear_vs_clear_preserve_context() {
717 let mut history = ConversationHistory::new();
718
719 for i in 0..5 {
721 history.add_turn(format!("Q{}", i), format!("A{}", i), vec![]);
722 }
723
724 let _ = history.compact();
726 let had_summary = history.summary_frame.is_some();
727
728 let mut history_preserve = history.clone();
730 history_preserve.clear_turns_preserve_context();
731
732 let mut history_clear = history.clone();
734 history_clear.clear();
735
736 if had_summary {
738 assert!(
739 history_preserve.summary_frame.is_some(),
740 "preserve should keep summary"
741 );
742 assert!(
743 history_clear.summary_frame.is_none(),
744 "clear removes summary"
745 );
746 }
747
748 assert_eq!(history_preserve.turn_count(), 0);
750 assert_eq!(history_clear.turn_count(), 0);
751 }
752
753 #[test]
754 fn test_history_serialization() {
755 let mut history = ConversationHistory::new();
756
757 history.add_turn(
759 "What is this project?".to_string(),
760 "This is a Rust CLI tool.".to_string(),
761 vec![ToolCallRecord {
762 tool_name: "analyze".to_string(),
763 args_summary: "path: .".to_string(),
764 result_summary: "Found Rust project".to_string(),
765 tool_id: Some("tool_1".to_string()),
766 droppable: false,
767 }],
768 );
769
770 let json = history.to_json().expect("Should serialize");
772 assert!(!json.is_empty());
773
774 let restored = ConversationHistory::from_json(&json).expect("Should deserialize");
776 assert_eq!(restored.turn_count(), 1);
777 assert_eq!(restored.user_turn_count(), 1);
778
779 let messages = restored.to_messages();
781 assert!(!messages.is_empty());
782 }
783
784 #[test]
785 fn test_history_serialization_with_compaction() {
786 let mut history = ConversationHistory::with_config(CompactConfig {
788 retention_window: 2,
789 eviction_window: 0.6,
790 thresholds: CompactThresholds {
791 token_threshold: Some(200),
792 turn_threshold: Some(3),
793 message_threshold: Some(5),
794 on_turn_end: None,
795 },
796 });
797
798 for i in 0..6 {
800 history.add_turn(
801 format!("Question {} with some text", i),
802 format!("Answer {} with more detail", i),
803 vec![],
804 );
805 }
806
807 if history.needs_compaction() {
809 let _ = history.compact();
810 }
811
812 let had_summary = history.summary_frame.is_some();
813
814 let json = history.to_json().expect("Should serialize");
816
817 let restored = ConversationHistory::from_json(&json).expect("Should deserialize");
819 assert_eq!(
820 restored.summary_frame.is_some(),
821 had_summary,
822 "Summary frame should be preserved"
823 );
824
825 let messages = restored.to_messages();
827 if had_summary {
828 assert!(messages.len() >= 2, "Should have summary messages");
830 }
831 }
832}