1use crate::agent::{Message, Role};
11use serde::{Deserialize, Serialize};
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct CompactionStrategy {
16 pub keep_recent: usize,
18 pub keep_keywords: Vec<String>,
20 pub keep_tool_results: bool,
22 pub keep_system: bool,
24}
25
26impl Default for CompactionStrategy {
27 fn default() -> Self {
28 Self {
29 keep_recent: 10,
30 keep_keywords: vec![
31 "error".to_string(),
32 "fix".to_string(),
33 "bug".to_string(),
34 "issue".to_string(),
35 "problem".to_string(),
36 "solution".to_string(),
37 "important".to_string(),
38 "note".to_string(),
39 "warning".to_string(),
40 ],
41 keep_tool_results: true,
42 keep_system: true,
43 }
44 }
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct CompactionResult {
50 pub messages: Vec<Message>,
52 pub original_count: usize,
54 pub compacted_count: usize,
56 pub tokens_saved: usize,
58}
59
60pub fn build_compaction_prompt(messages: &[Message], _strategy: &CompactionStrategy) -> String {
66 let mut prompt = String::from(
67 r#"# Structured Conversation Compaction
68
69You are tasked with compacting a conversation history while preserving all essential information.
70
71## Your Goal
72
73Create a concise, structured summary that captures:
741. **User's Original Intent** - What the user wanted to accomplish
752. **Important Decisions** - Key decisions made during the conversation
763. **Code Changes** - What was changed and why
774. **Error Messages** - Any errors encountered and their solutions
785. **Debugging Information** - Important debugging steps and findings
796. **Warnings and Notes** - Any warnings or important notes
80
81## Output Format
82
83Your response MUST follow this exact structure:
84
85```
86# Conversation Summary
87
88## User Intent
89[Describe what the user wanted to accomplish in 1-2 sentences]
90
91## Key Decisions
92- [Decision 1]
93- [Decision 2]
94- [Decision 3]
95
96## Code Changes
97### File: [filename]
98- **Change**: [description of change]
99- **Rationale**: [why this change was made]
100- **Impact**: [what this affects]
101
102### File: [filename]
103- **Change**: [description of change]
104- **Rationale**: [why this change was made]
105- **Impact**: [what this affects]
106
107## Errors and Solutions
108### Error: [error description]
109- **Location**: [where the error occurred]
110- **Solution**: [how it was fixed]
111- **Prevention**: [how to prevent this in the future]
112
113## Debugging Steps
1141. [Step 1]
1152. [Step 2]
1163. [Step 3]
117
118## Warnings and Notes
119- [Warning or note 1]
120- [Warning or note 2]
121
122## Current State
123[Describe the current state of the work in 1-2 sentences]
124
125## Next Steps
1261. [Next step 1]
1272. [Next step 2]
1283. [Next step 3]
129```
130
131## Guidelines
132
133- Be concise but complete
134- Preserve all technical details (function names, file paths, error messages)
135- Use bullet points for lists
136- Keep each section focused and clear
137- If a section has no relevant information, write "None"
138- Maintain chronological order where relevant
139- Include specific values (numbers, strings, paths) when important
140
141## Original Conversation
142
143"#,
144 );
145
146 for (i, msg) in messages.iter().enumerate() {
148 let role = match msg.role {
149 Role::System => "SYSTEM",
150 Role::User => "USER",
151 Role::Assistant => "ASSISTANT",
152 Role::Tool => "TOOL",
153 };
154 prompt.push_str(&format!(
155 "\n### Message {} [{}]\n\n{}\n",
156 i + 1,
157 role,
158 msg.content
159 ));
160
161 if !msg.tool_calls.is_empty() {
163 prompt.push_str("\n**Tool Calls:**\n");
164 for tc in &msg.tool_calls {
165 prompt.push_str(&format!("- `{}`: {}\n", tc.name, tc.arguments));
166 }
167 }
168
169 if let Some(ref result) = msg.tool_result {
171 prompt.push_str(&format!("\n**Tool Result:**\n{}\n", result.content));
172 }
173 }
174
175 prompt.push_str(
176 r#"
177
178--- End of Original Conversation ---
179
180Please provide a structured summary following the exact format specified above.
181"#,
182 );
183
184 prompt
185}
186
187pub fn compact_messages(messages: Vec<Message>, strategy: &CompactionStrategy) -> CompactionResult {
189 let original_count = messages.len();
190 let mut compacted = Vec::new();
191
192 if strategy.keep_system {
194 compacted.extend(messages.iter().filter(|m| m.role == Role::System).cloned());
195 }
196
197 for msg in &messages {
199 let content_lower = msg.content.to_lowercase();
200 if strategy
201 .keep_keywords
202 .iter()
203 .any(|kw| content_lower.contains(&kw.to_lowercase()))
204 && !compacted.iter().any(|m| m.content == msg.content)
205 {
206 compacted.push(msg.clone());
207 }
208 }
209
210 if strategy.keep_tool_results {
212 for msg in &messages {
213 if msg.tool_result.is_some()
214 && !msg.tool_calls.is_empty()
215 && !compacted.iter().any(|m| m.content == msg.content)
216 {
217 compacted.push(msg.clone());
218 }
219 }
220 }
221
222 let recent_start = if messages.len() > strategy.keep_recent {
224 messages.len() - strategy.keep_recent
225 } else {
226 0
227 };
228
229 for msg in &messages[recent_start..] {
230 if !compacted.iter().any(|m| m.content == msg.content) {
231 compacted.push(msg.clone());
232 }
233 }
234
235 compacted.sort_by_key(|m| {
237 messages
238 .iter()
239 .position(|orig| orig.content == m.content)
240 .unwrap_or(usize::MAX)
241 });
242
243 let compacted_count = compacted.len();
244 let tokens_saved = estimate_tokens_saved(original_count, compacted_count);
245
246 CompactionResult {
247 messages: compacted,
248 original_count,
249 compacted_count,
250 tokens_saved,
251 }
252}
253
254fn estimate_tokens_saved(original: usize, compacted: usize) -> usize {
256 let avg_tokens_per_message = 4;
258 (original - compacted) * avg_tokens_per_message
259}
260
261#[derive(Debug, Clone, Serialize, Deserialize)]
266pub struct ParsedCompactionSummary {
267 pub user_intent: String,
269 pub key_decisions: Vec<String>,
271 pub code_changes: Vec<CodeChange>,
273 pub errors_and_solutions: Vec<ErrorSolution>,
275 pub debugging_steps: Vec<String>,
277 pub warnings_and_notes: Vec<String>,
279 pub current_state: String,
281 pub next_steps: Vec<String>,
283}
284
285#[derive(Debug, Clone, Serialize, Deserialize)]
287pub struct CodeChange {
288 pub file: String,
290 pub change: String,
292 pub rationale: String,
294 pub impact: String,
296}
297
298#[derive(Debug, Clone, Serialize, Deserialize)]
300pub struct ErrorSolution {
301 pub error: String,
303 pub location: String,
305 pub solution: String,
307 pub prevention: String,
309}
310
311fn extract_section_content<'a>(summary: &'a str, name: &str) -> &'a str {
315 let marker = format!("## {name}");
316 let start = match summary.find(&marker) {
317 Some(pos) => pos + marker.len(),
318 None => return "",
319 };
320 let rest = &summary[start..];
321 let end = rest.find("\n## ").unwrap_or(rest.len());
322 rest[..end].trim()
323}
324
325pub fn parse_summary_field(summary: &str, field_name: &str) -> String {
329 extract_section_content(summary, field_name)
330 .lines()
331 .map(str::trim)
332 .filter(|l| !l.is_empty())
333 .collect::<Vec<_>>()
334 .join(" ")
335}
336
337pub fn parse_token_counts(text: &str) -> Vec<String> {
341 text.lines()
342 .filter_map(|line| line.trim().strip_prefix("- ").map(String::from))
343 .filter(|s| !s.is_empty())
344 .collect()
345}
346
347pub fn parse_compressed_sections(text: &str) -> Vec<CodeChange> {
352 let mut changes = Vec::new();
353 let mut current: Option<CodeChange> = None;
354
355 for line in text.lines() {
356 let line = line.trim();
357 if let Some(file) = line.strip_prefix("### File: ") {
358 if let Some(cc) = current.take() {
359 if !cc.file.is_empty() {
360 changes.push(cc);
361 }
362 }
363 current = Some(CodeChange {
364 file: file.to_string(),
365 change: String::new(),
366 rationale: String::new(),
367 impact: String::new(),
368 });
369 } else if let Some(ref mut cc) = current {
370 if let Some(v) = line.strip_prefix("- **Change**: ") {
371 cc.change = v.to_string();
372 } else if let Some(v) = line.strip_prefix("- **Rationale**: ") {
373 cc.rationale = v.to_string();
374 } else if let Some(v) = line.strip_prefix("- **Impact**: ") {
375 cc.impact = v.to_string();
376 }
377 }
378 }
379 if let Some(cc) = current {
380 if !cc.file.is_empty() {
381 changes.push(cc);
382 }
383 }
384 changes
385}
386
387pub fn parse_preserved_sections(text: &str) -> Vec<ErrorSolution> {
392 let mut errors = Vec::new();
393 let mut current: Option<ErrorSolution> = None;
394
395 for line in text.lines() {
396 let line = line.trim();
397 if let Some(err) = line.strip_prefix("### Error: ") {
398 if let Some(e) = current.take() {
399 if !e.error.is_empty() {
400 errors.push(e);
401 }
402 }
403 current = Some(ErrorSolution {
404 error: err.to_string(),
405 location: String::new(),
406 solution: String::new(),
407 prevention: String::new(),
408 });
409 } else if let Some(ref mut e) = current {
410 if let Some(v) = line.strip_prefix("- **Location**: ") {
411 e.location = v.to_string();
412 } else if let Some(v) = line.strip_prefix("- **Solution**: ") {
413 e.solution = v.to_string();
414 } else if let Some(v) = line.strip_prefix("- **Prevention**: ") {
415 e.prevention = v.to_string();
416 }
417 }
418 }
419 if let Some(e) = current {
420 if !e.error.is_empty() {
421 errors.push(e);
422 }
423 }
424 errors
425}
426
427fn parse_numbered_items(text: &str) -> Vec<String> {
431 text.lines()
432 .filter_map(|line| {
433 let trimmed = line.trim();
434 let after_digit = trimmed.strip_prefix(|c: char| c.is_ascii_digit())?;
435 after_digit.strip_prefix(". ").map(String::from)
436 })
437 .filter(|s| !s.is_empty())
438 .collect()
439}
440
441pub fn parse_compaction_summary(summary: &str) -> Result<ParsedCompactionSummary, String> {
446 Ok(ParsedCompactionSummary {
447 user_intent: parse_summary_field(summary, "User Intent"),
448 key_decisions: parse_token_counts(extract_section_content(summary, "Key Decisions")),
449 code_changes: parse_compressed_sections(extract_section_content(summary, "Code Changes")),
450 errors_and_solutions: parse_preserved_sections(extract_section_content(summary, "Errors and Solutions")),
451 debugging_steps: parse_numbered_items(extract_section_content(summary, "Debugging Steps")),
452 warnings_and_notes: parse_token_counts(extract_section_content(summary, "Warnings and Notes")),
453 current_state: parse_summary_field(summary, "Current State"),
454 next_steps: parse_numbered_items(extract_section_content(summary, "Next Steps")),
455 })
456}
457
458pub fn summary_to_message(summary: &ParsedCompactionSummary) -> Message {
463 let mut content = String::from("# Conversation Summary\n\n");
464
465 content.push_str("## User Intent\n");
466 content.push_str(&summary.user_intent);
467 content.push_str("\n\n");
468
469 if !summary.key_decisions.is_empty() {
470 content.push_str("## Key Decisions\n");
471 for decision in &summary.key_decisions {
472 content.push_str("- ");
473 content.push_str(decision);
474 content.push('\n');
475 }
476 content.push('\n');
477 }
478
479 if !summary.code_changes.is_empty() {
480 content.push_str("## Code Changes\n");
481 for change in &summary.code_changes {
482 content.push_str(&format!("### File: {}\n", change.file));
483 content.push_str(&format!("- **Change**: {}\n", change.change));
484 content.push_str(&format!("- **Rationale**: {}\n", change.rationale));
485 content.push_str(&format!("- **Impact**: {}\n", change.impact));
486 content.push('\n');
487 }
488 }
489
490 if !summary.errors_and_solutions.is_empty() {
491 content.push_str("## Errors and Solutions\n");
492 for error in &summary.errors_and_solutions {
493 content.push_str(&format!("### Error: {}\n", error.error));
494 content.push_str(&format!("- **Location**: {}\n", error.location));
495 content.push_str(&format!("- **Solution**: {}\n", error.solution));
496 content.push_str(&format!("- **Prevention**: {}\n", error.prevention));
497 content.push('\n');
498 }
499 }
500
501 if !summary.debugging_steps.is_empty() {
502 content.push_str("## Debugging Steps\n");
503 for (i, step) in summary.debugging_steps.iter().enumerate() {
504 content.push_str(&format!("{}. {}\n", i + 1, step));
505 }
506 content.push('\n');
507 }
508
509 if !summary.warnings_and_notes.is_empty() {
510 content.push_str("## Warnings and Notes\n");
511 for warning in &summary.warnings_and_notes {
512 content.push_str("- ");
513 content.push_str(warning);
514 content.push('\n');
515 }
516 content.push('\n');
517 }
518
519 content.push_str("## Current State\n");
520 content.push_str(&summary.current_state);
521 content.push_str("\n\n");
522
523 if !summary.next_steps.is_empty() {
524 content.push_str("## Next Steps\n");
525 for (i, step) in summary.next_steps.iter().enumerate() {
526 content.push_str(&format!("{}. {}\n", i + 1, step));
527 }
528 }
529
530 Message {
531 role: Role::System,
532 content,
533 tool_calls: vec![],
534 tool_result: None,
535 }
536}
537
538#[cfg(test)]
539mod tests {
540 use super::*;
541
542 #[test]
543 fn test_compaction_strategy_default() {
544 let strategy = CompactionStrategy::default();
545 assert_eq!(strategy.keep_recent, 10);
546 assert!(strategy.keep_keywords.contains(&"error".to_string()));
547 assert!(strategy.keep_tool_results);
548 assert!(strategy.keep_system);
549 }
550
551 #[test]
552 fn test_build_compaction_prompt() {
553 let messages = vec![
554 Message {
555 role: Role::User,
556 content: "Fix the bug in main.rs".to_string(),
557 tool_calls: vec![],
558 tool_result: None,
559 },
560 Message {
561 role: Role::Assistant,
562 content: "I'll read the file first.".to_string(),
563 tool_calls: vec![],
564 tool_result: None,
565 },
566 ];
567
568 let prompt = build_compaction_prompt(&messages, &CompactionStrategy::default());
569 assert!(prompt.contains("Fix the bug in main.rs"));
570 assert!(prompt.contains("I'll read the file first."));
571 assert!(prompt.contains("Original Conversation"));
572 }
573
574 #[test]
575 fn test_compact_messages() {
576 let messages = vec![
577 Message {
578 role: Role::System,
579 content: "You are a coding agent.".to_string(),
580 tool_calls: vec![],
581 tool_result: None,
582 },
583 Message {
584 role: Role::User,
585 content: "Fix the error".to_string(),
586 tool_calls: vec![],
587 tool_result: None,
588 },
589 Message {
590 role: Role::Assistant,
591 content: "I'll help.".to_string(),
592 tool_calls: vec![],
593 tool_result: None,
594 },
595 ];
596
597 let strategy = CompactionStrategy {
599 keep_recent: 1,
600 keep_keywords: vec![],
601 keep_tool_results: false,
602 keep_system: false,
603 };
604 let result = compact_messages(messages, &strategy);
605
606 assert_eq!(result.original_count, 3);
607 assert!(result.compacted_count > 0);
610 assert!(result.tokens_saved > 0);
611 }
612
613 #[test]
614 fn test_compaction_preserves_system_messages() {
615 let messages = vec![
616 Message {
617 role: Role::System,
618 content: "System prompt".to_string(),
619 tool_calls: vec![],
620 tool_result: None,
621 },
622 Message {
623 role: Role::User,
624 content: "User message".to_string(),
625 tool_calls: vec![],
626 tool_result: None,
627 },
628 ];
629
630 let strategy = CompactionStrategy {
631 keep_system: true,
632 ..Default::default()
633 };
634
635 let result = compact_messages(messages, &strategy);
636 assert!(result
637 .messages
638 .iter()
639 .any(|m| m.role == Role::System && m.content == "System prompt"));
640 }
641
642 #[test]
643 fn test_compaction_preserves_keyword_messages() {
644 let messages = vec![
645 Message {
646 role: Role::User,
647 content: "Fix the error".to_string(),
648 tool_calls: vec![],
649 tool_result: None,
650 },
651 Message {
652 role: Role::User,
653 content: "Regular message".to_string(),
654 tool_calls: vec![],
655 tool_result: None,
656 },
657 ];
658
659 let strategy = CompactionStrategy {
660 keep_keywords: vec!["error".to_string()],
661 ..Default::default()
662 };
663
664 let result = compact_messages(messages, &strategy);
665 assert!(result.messages.iter().any(|m| m.content == "Fix the error"));
666 }
667
668 #[test]
669 fn test_compact_messages_empty_input() {
670 let result = compact_messages(vec![], &CompactionStrategy::default());
671 assert_eq!(result.original_count, 0);
672 assert_eq!(result.compacted_count, 0);
673 assert_eq!(result.tokens_saved, 0);
674 assert!(result.messages.is_empty());
675 }
676
677 #[test]
678 fn test_compact_messages_deduplication() {
679 let messages = vec![
681 Message {
682 role: Role::User,
683 content: "Same content".to_string(),
684 tool_calls: vec![],
685 tool_result: None,
686 },
687 Message {
688 role: Role::Assistant,
689 content: "Same content".to_string(),
690 tool_calls: vec![],
691 tool_result: None,
692 },
693 ];
694
695 let strategy = CompactionStrategy {
696 keep_recent: 10,
697 keep_keywords: vec![],
698 keep_tool_results: false,
699 keep_system: false,
700 };
701 let result = compact_messages(messages, &strategy);
702
703 let matching = result
705 .messages
706 .iter()
707 .filter(|m| m.content == "Same content")
708 .count();
709 assert_eq!(matching, 1, "Duplicate content should be deduplicated");
710 }
711
712 #[test]
713 fn test_compact_messages_preserves_order() {
714 let messages = vec![
715 Message {
716 role: Role::System,
717 content: "System".to_string(),
718 tool_calls: vec![],
719 tool_result: None,
720 },
721 Message {
722 role: Role::User,
723 content: "User-1".to_string(),
724 tool_calls: vec![],
725 tool_result: None,
726 },
727 Message {
728 role: Role::Assistant,
729 content: "Assistant-1".to_string(),
730 tool_calls: vec![],
731 tool_result: None,
732 },
733 Message {
734 role: Role::User,
735 content: "User-2".to_string(),
736 tool_calls: vec![],
737 tool_result: None,
738 },
739 ];
740
741 let strategy = CompactionStrategy {
742 keep_recent: 10,
743 keep_keywords: vec![],
744 keep_tool_results: false,
745 keep_system: false,
746 };
747 let result = compact_messages(messages, &strategy);
748
749 let contents: Vec<_> = result.messages.iter().map(|m| m.content.clone()).collect();
751 assert_eq!(contents, vec!["System", "User-1", "Assistant-1", "User-2"]);
752 }
753
754 #[test]
755 fn test_compact_messages_tool_results() {
756 use crate::agent::{ToolCallRequest, ToolResultMessage};
757
758 let messages = vec![
759 Message {
760 role: Role::Assistant,
761 content: "Running tool".to_string(),
762 tool_calls: vec![ToolCallRequest {
763 id: "1".to_string(),
764 name: "read_file".to_string(),
765 arguments: serde_json::json!({}),
766 }],
767 tool_result: Some(ToolResultMessage {
768 tool_call_id: "1".to_string(),
769 content: serde_json::json!("file content here"),
770 success: true,
771 }),
772 },
773 Message {
774 role: Role::User,
775 content: "Just a message".to_string(),
776 tool_calls: vec![],
777 tool_result: None,
778 },
779 ];
780
781 let strategy = CompactionStrategy {
782 keep_recent: 1,
783 keep_keywords: vec![],
784 keep_tool_results: true,
785 keep_system: false,
786 };
787 let result = compact_messages(messages, &strategy);
788
789 assert!(result.messages.iter().any(|m| m.tool_result.is_some()));
791 }
792
793 #[test]
794 fn test_compact_messages_all_system() {
795 let messages = vec![
796 Message {
797 role: Role::System,
798 content: "System 1".to_string(),
799 tool_calls: vec![],
800 tool_result: None,
801 },
802 Message {
803 role: Role::System,
804 content: "System 2".to_string(),
805 tool_calls: vec![],
806 tool_result: None,
807 },
808 ];
809
810 let strategy = CompactionStrategy {
811 keep_recent: 0,
812 keep_keywords: vec![],
813 keep_tool_results: false,
814 keep_system: true,
815 };
816 let result = compact_messages(messages, &strategy);
817
818 assert_eq!(result.compacted_count, 2);
819 assert!(result.messages.iter().all(|m| m.role == Role::System));
820 }
821
822 #[test]
823 fn test_compact_messages_keyword_case_insensitive() {
824 let messages = vec![
825 Message {
826 role: Role::User,
827 content: "Found the ERROR in the code".to_string(),
828 tool_calls: vec![],
829 tool_result: None,
830 },
831 Message {
832 role: Role::User,
833 content: "Normal text".to_string(),
834 tool_calls: vec![],
835 tool_result: None,
836 },
837 ];
838
839 let strategy = CompactionStrategy {
840 keep_keywords: vec!["error".to_string()],
841 keep_recent: 0,
842 keep_tool_results: false,
843 keep_system: false,
844 };
845 let result = compact_messages(messages, &strategy);
846
847 assert!(result.messages.iter().any(|m| m.content.contains("ERROR")));
848 }
849
850 #[test]
851 fn test_compact_messages_recent_boundary() {
852 let messages: Vec<_> = (0..10)
854 .map(|i| Message {
855 role: Role::User,
856 content: format!("Message {}", i),
857 tool_calls: vec![],
858 tool_result: None,
859 })
860 .collect();
861
862 let strategy = CompactionStrategy {
863 keep_recent: 10,
864 keep_keywords: vec![],
865 keep_tool_results: false,
866 keep_system: false,
867 };
868 let result = compact_messages(messages, &strategy);
869
870 assert_eq!(result.compacted_count, 10);
872 }
873
874 #[test]
875 fn test_estimate_tokens_saved_no_reduction() {
876 assert_eq!(estimate_tokens_saved(5, 5), 0);
877 }
878
879 #[test]
880 fn test_estimate_tokens_saved_reduction() {
881 assert_eq!(estimate_tokens_saved(10, 4), 24); }
884
885 #[test]
886 fn test_parse_compaction_summary_user_intent() {
887 let summary = r#"
888# Conversation Summary
889
890## User Intent
891Build a new feature for the app
892
893## Key Decisions
894- Use Axum for the web server
895- Store sessions in JSON files
896
897## Code Changes
898None
899
900## Errors and Solutions
901None
902
903## Debugging Steps
904None
905
906## Warnings and Notes
907None
908
909## Current State
910Feature is partially implemented
911
912## Next Steps
9131. Write tests
9142. Deploy
915"#;
916 let parsed = parse_compaction_summary(summary).unwrap();
917
918 assert_eq!(parsed.user_intent, "Build a new feature for the app");
919 assert_eq!(parsed.key_decisions.len(), 2);
920 assert_eq!(parsed.key_decisions[0], "Use Axum for the web server");
921 assert!(parsed.code_changes.is_empty());
922 assert!(parsed.errors_and_solutions.is_empty());
923 assert_eq!(parsed.current_state, "Feature is partially implemented");
924 assert_eq!(parsed.next_steps.len(), 2);
925 }
926
927 #[test]
928 fn test_parse_compaction_summary_code_changes() {
929 let summary = r#"
930## Code Changes
931
932### File: src/main.rs
933- **Change**: Added health endpoint
934- **Rationale**: Needed for Kubernetes probes
935- **Impact**: /health now returns 200
936
937### File: src/lib.rs
938- **Change**: Added session storage
939- **Rationale**: Sessions were lost on restart
940- **Impact**: Sessions persist across restarts
941
942## User Intent
943None
944
945## Key Decisions
946None
947
948## Errors and Solutions
949None
950
951## Debugging Steps
952None
953
954## Warnings and Notes
955None
956
957## Current State
958None
959
960## Next Steps
961None
962"#;
963 let parsed = parse_compaction_summary(summary).unwrap();
964
965 assert_eq!(parsed.code_changes.len(), 2);
966 assert_eq!(parsed.code_changes[0].file, "src/main.rs");
967 assert_eq!(parsed.code_changes[0].change, "Added health endpoint");
968 assert_eq!(parsed.code_changes[0].rationale, "Needed for Kubernetes probes");
969 assert_eq!(parsed.code_changes[0].impact, "/health now returns 200");
970 assert_eq!(parsed.code_changes[1].file, "src/lib.rs");
971 }
972
973 #[test]
974 fn test_parse_compaction_summary_errors_and_solutions() {
975 let summary = r#"
976## Errors and Solutions
977
978### Error: Rust compiler error E0432
979- **Location**: src/main.rs:10
980- **Solution**: Added missing import
981- **Prevention**: Run cargo check before committing
982
983## User Intent
984None
985
986## Key Decisions
987None
988
989## Code Changes
990None
991
992## Debugging Steps
993None
994
995## Warnings and Notes
996None
997
998## Current State
999None
1000
1001## Next Steps
1002None
1003"#;
1004 let parsed = parse_compaction_summary(summary).unwrap();
1005
1006 assert_eq!(parsed.errors_and_solutions.len(), 1);
1007 assert_eq!(parsed.errors_and_solutions[0].error, "Rust compiler error E0432");
1008 assert_eq!(parsed.errors_and_solutions[0].location, "src/main.rs:10");
1009 assert_eq!(parsed.errors_and_solutions[0].solution, "Added missing import");
1010 assert_eq!(
1011 parsed.errors_and_solutions[0].prevention,
1012 "Run cargo check before committing"
1013 );
1014 }
1015
1016 #[test]
1017 fn test_parse_compaction_summary_debugging_steps() {
1018 let summary = r#"
1019## Debugging Steps
10201. Set breakpoints in the handler
10212. Run cargo test
10223. Check logs for errors
1023
1024## User Intent
1025None
1026
1027## Key Decisions
1028None
1029
1030## Code Changes
1031None
1032
1033## Errors and Solutions
1034None
1035
1036## Warnings and Notes
1037None
1038
1039## Current State
1040None
1041
1042## Next Steps
1043None
1044"#;
1045 let parsed = parse_compaction_summary(summary).unwrap();
1046
1047 assert_eq!(parsed.debugging_steps.len(), 3);
1048 assert_eq!(parsed.debugging_steps[0], "Set breakpoints in the handler");
1049 assert_eq!(parsed.debugging_steps[1], "Run cargo test");
1050 assert_eq!(parsed.debugging_steps[2], "Check logs for errors");
1051 }
1052
1053 #[test]
1054 fn test_parse_compaction_summary_warnings_and_notes() {
1055 let summary = r#"
1056## Warnings and Notes
1057- The config file uses TOML format
1058- Sessions expire after 24 hours
1059
1060## User Intent
1061None
1062
1063## Key Decisions
1064None
1065
1066## Code Changes
1067None
1068
1069## Errors and Solutions
1070None
1071
1072## Debugging Steps
1073None
1074
1075## Current State
1076None
1077
1078## Next Steps
1079None
1080"#;
1081 let parsed = parse_compaction_summary(summary).unwrap();
1082
1083 assert_eq!(parsed.warnings_and_notes.len(), 2);
1084 assert_eq!(parsed.warnings_and_notes[0], "The config file uses TOML format");
1085 }
1086
1087 #[test]
1088 fn test_parse_compaction_summary_empty_input() {
1089 let result = parse_compaction_summary("");
1090 assert!(result.is_ok());
1091 let parsed = result.unwrap();
1092 assert_eq!(parsed.user_intent, "");
1093 assert!(parsed.key_decisions.is_empty());
1094 assert!(parsed.code_changes.is_empty());
1095 }
1096
1097 #[test]
1098 fn test_parse_compaction_summary_partial_content() {
1099 let summary = r#"
1101## User Intent
1102Just this
1103
1104## Key Decisions
1105None
1106
1107## Code Changes
1108None
1109
1110## Errors and Solutions
1111None
1112
1113## Debugging Steps
1114None
1115
1116## Warnings and Notes
1117None
1118
1119## Current State
1120None
1121
1122## Next Steps
1123None
1124"#;
1125 let parsed = parse_compaction_summary(summary).unwrap();
1126 assert_eq!(parsed.user_intent, "Just this");
1127 }
1128
1129 #[test]
1130 fn test_parse_compaction_summary_code_change_missing_fields() {
1131 let summary = r#"
1133## Code Changes
1134
1135### File: src/main.rs
1136
1137Some free-form text that is not a bullet
1138
1139## User Intent
1140None
1141
1142## Key Decisions
1143None
1144
1145## Errors and Solutions
1146None
1147
1148## Debugging Steps
1149None
1150
1151## Warnings and Notes
1152None
1153
1154## Current State
1155None
1156
1157## Next Steps
1158None
1159"#;
1160 let parsed = parse_compaction_summary(summary).unwrap();
1161
1162 assert_eq!(parsed.code_changes.len(), 1);
1164 assert_eq!(parsed.code_changes[0].file, "src/main.rs");
1165 assert_eq!(parsed.code_changes[0].change, "");
1166 assert_eq!(parsed.code_changes[0].rationale, "");
1167 assert_eq!(parsed.code_changes[0].impact, "");
1168 }
1169
1170 #[test]
1171 fn test_parse_compaction_summary_truly_empty_file() {
1172 let summary = r#"
1174## Code Changes
1175
1176### File:
1177- **Change**: Should not appear
1178
1179## User Intent
1180None
1181
1182## Key Decisions
1183None
1184
1185## Errors and Solutions
1186None
1187
1188## Debugging Steps
1189None
1190
1191## Warnings and Notes
1192None
1193
1194## Current State
1195None
1196
1197## Next Steps
1198None
1199"#;
1200 let parsed = parse_compaction_summary(summary).unwrap();
1201
1202 assert!(parsed.code_changes.is_empty());
1204 }
1205
1206 #[test]
1207 fn test_parse_compaction_summary_no_header_prefix() {
1208 let summary = "This is just free-form text without section headers.";
1210 let parsed = parse_compaction_summary(summary).unwrap();
1211 assert_eq!(parsed.user_intent, "");
1212 }
1213
1214 #[test]
1215 fn test_summary_to_message_basic() {
1216 let summary = ParsedCompactionSummary {
1217 user_intent: "Build a feature".to_string(),
1218 key_decisions: vec!["Use Rust".to_string()],
1219 code_changes: vec![],
1220 errors_and_solutions: vec![],
1221 debugging_steps: vec![],
1222 warnings_and_notes: vec![],
1223 current_state: "In progress".to_string(),
1224 next_steps: vec![],
1225 };
1226
1227 let msg = summary_to_message(&summary);
1228 assert_eq!(msg.role, Role::System);
1229 assert!(msg.content.contains("## User Intent"));
1230 assert!(msg.content.contains("Build a feature"));
1231 assert!(msg.content.contains("## Key Decisions"));
1232 assert!(msg.content.contains("Use Rust"));
1233 assert!(msg.content.contains("## Current State"));
1234 assert!(msg.content.contains("In progress"));
1235 assert!(msg.tool_calls.is_empty());
1236 assert!(msg.tool_result.is_none());
1237 }
1238
1239 #[test]
1240 fn test_summary_to_message_full_structure() {
1241 use super::{CodeChange, ErrorSolution};
1242
1243 let summary = ParsedCompactionSummary {
1244 user_intent: "Fix the bug".to_string(),
1245 key_decisions: vec!["Decision 1".to_string(), "Decision 2".to_string()],
1246 code_changes: vec![CodeChange {
1247 file: "src/main.rs".to_string(),
1248 change: "Added check".to_string(),
1249 rationale: "To prevent crash".to_string(),
1250 impact: "Crash fixed".to_string(),
1251 }],
1252 errors_and_solutions: vec![ErrorSolution {
1253 error: "Panic at line 10".to_string(),
1254 location: "src/main.rs:10".to_string(),
1255 solution: "Added null check".to_string(),
1256 prevention: "Use Option types".to_string(),
1257 }],
1258 debugging_steps: vec!["Step 1".to_string(), "Step 2".to_string()],
1259 warnings_and_notes: vec!["Note: runs on Linux only".to_string()],
1260 current_state: "Fixed".to_string(),
1261 next_steps: vec!["Deploy".to_string()],
1262 };
1263
1264 let msg = summary_to_message(&summary);
1265
1266 assert!(msg.content.contains("### File: src/main.rs"));
1267 assert!(msg.content.contains("- **Change**: Added check"));
1268 assert!(msg.content.contains("- **Rationale**: To prevent crash"));
1269 assert!(msg.content.contains("### Error: Panic at line 10"));
1270 assert!(msg.content.contains("- **Solution**: Added null check"));
1271 assert!(msg.content.contains("1. Step 1"));
1272 assert!(msg.content.contains("1. Deploy"));
1273 assert!(msg.content.contains("Note: runs on Linux only"));
1274 }
1275
1276 #[test]
1277 fn test_summary_to_message_empty_sections_omitted() {
1278 let summary = ParsedCompactionSummary {
1279 user_intent: "Just this".to_string(),
1280 key_decisions: vec![],
1281 code_changes: vec![],
1282 errors_and_solutions: vec![],
1283 debugging_steps: vec![],
1284 warnings_and_notes: vec![],
1285 current_state: "".to_string(),
1286 next_steps: vec![],
1287 };
1288
1289 let msg = summary_to_message(&summary);
1290
1291 assert!(msg.content.contains("## User Intent"));
1293 assert!(msg.content.contains("Just this"));
1294 assert!(!msg.content.contains("## Key Decisions"));
1295 assert!(!msg.content.contains("## Code Changes"));
1296 assert!(!msg.content.contains("## Next Steps"));
1297 }
1298
1299 #[test]
1300 fn test_compaction_strategy_custom_values() {
1301 let strategy = CompactionStrategy {
1302 keep_recent: 5,
1303 keep_keywords: vec!["critical".to_string(), "urgent".to_string()],
1304 keep_tool_results: false,
1305 keep_system: false,
1306 };
1307
1308 assert_eq!(strategy.keep_recent, 5);
1309 assert_eq!(strategy.keep_keywords.len(), 2);
1310 assert!(!strategy.keep_tool_results);
1311 assert!(!strategy.keep_system);
1312 }
1313
1314 #[test]
1315 fn test_compact_messages_keywords_with_tool_calls() {
1316 let messages = vec![
1318 Message {
1319 role: Role::Assistant,
1320 content: "Calling read_file".to_string(),
1321 tool_calls: vec![crate::agent::ToolCallRequest {
1322 id: "1".to_string(),
1323 name: "read_file".to_string(),
1324 arguments: serde_json::json!({}),
1325 }],
1326 tool_result: None, },
1328 Message {
1329 role: Role::User,
1330 content: "Fix the bug".to_string(),
1331 tool_calls: vec![],
1332 tool_result: None,
1333 },
1334 ];
1335
1336 let strategy = CompactionStrategy {
1337 keep_recent: 0,
1338 keep_keywords: vec!["bug".to_string()],
1339 keep_tool_results: true,
1340 keep_system: false,
1341 };
1342 let result = compact_messages(messages, &strategy);
1343
1344 assert!(result.messages.iter().any(|m| m.content == "Fix the bug"));
1346 }
1349}