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(
451 summary,
452 "Errors and Solutions",
453 )),
454 debugging_steps: parse_numbered_items(extract_section_content(summary, "Debugging Steps")),
455 warnings_and_notes: parse_token_counts(extract_section_content(
456 summary,
457 "Warnings and Notes",
458 )),
459 current_state: parse_summary_field(summary, "Current State"),
460 next_steps: parse_numbered_items(extract_section_content(summary, "Next Steps")),
461 })
462}
463
464pub fn summary_to_message(summary: &ParsedCompactionSummary) -> Message {
469 let mut content = String::from("# Conversation Summary\n\n");
470
471 content.push_str("## User Intent\n");
472 content.push_str(&summary.user_intent);
473 content.push_str("\n\n");
474
475 if !summary.key_decisions.is_empty() {
476 content.push_str("## Key Decisions\n");
477 for decision in &summary.key_decisions {
478 content.push_str("- ");
479 content.push_str(decision);
480 content.push('\n');
481 }
482 content.push('\n');
483 }
484
485 if !summary.code_changes.is_empty() {
486 content.push_str("## Code Changes\n");
487 for change in &summary.code_changes {
488 content.push_str(&format!("### File: {}\n", change.file));
489 content.push_str(&format!("- **Change**: {}\n", change.change));
490 content.push_str(&format!("- **Rationale**: {}\n", change.rationale));
491 content.push_str(&format!("- **Impact**: {}\n", change.impact));
492 content.push('\n');
493 }
494 }
495
496 if !summary.errors_and_solutions.is_empty() {
497 content.push_str("## Errors and Solutions\n");
498 for error in &summary.errors_and_solutions {
499 content.push_str(&format!("### Error: {}\n", error.error));
500 content.push_str(&format!("- **Location**: {}\n", error.location));
501 content.push_str(&format!("- **Solution**: {}\n", error.solution));
502 content.push_str(&format!("- **Prevention**: {}\n", error.prevention));
503 content.push('\n');
504 }
505 }
506
507 if !summary.debugging_steps.is_empty() {
508 content.push_str("## Debugging Steps\n");
509 for (i, step) in summary.debugging_steps.iter().enumerate() {
510 content.push_str(&format!("{}. {}\n", i + 1, step));
511 }
512 content.push('\n');
513 }
514
515 if !summary.warnings_and_notes.is_empty() {
516 content.push_str("## Warnings and Notes\n");
517 for warning in &summary.warnings_and_notes {
518 content.push_str("- ");
519 content.push_str(warning);
520 content.push('\n');
521 }
522 content.push('\n');
523 }
524
525 content.push_str("## Current State\n");
526 content.push_str(&summary.current_state);
527 content.push_str("\n\n");
528
529 if !summary.next_steps.is_empty() {
530 content.push_str("## Next Steps\n");
531 for (i, step) in summary.next_steps.iter().enumerate() {
532 content.push_str(&format!("{}. {}\n", i + 1, step));
533 }
534 }
535
536 Message {
537 role: Role::System,
538 content,
539 tool_calls: vec![],
540 tool_result: None,
541 }
542}
543
544#[cfg(test)]
545mod tests {
546 use super::*;
547
548 #[test]
549 fn test_compaction_strategy_default() {
550 let strategy = CompactionStrategy::default();
551 assert_eq!(strategy.keep_recent, 10);
552 assert!(strategy.keep_keywords.contains(&"error".to_string()));
553 assert!(strategy.keep_tool_results);
554 assert!(strategy.keep_system);
555 }
556
557 #[test]
558 fn test_build_compaction_prompt() {
559 let messages = vec![
560 Message {
561 role: Role::User,
562 content: "Fix the bug in main.rs".to_string(),
563 tool_calls: vec![],
564 tool_result: None,
565 },
566 Message {
567 role: Role::Assistant,
568 content: "I'll read the file first.".to_string(),
569 tool_calls: vec![],
570 tool_result: None,
571 },
572 ];
573
574 let prompt = build_compaction_prompt(&messages, &CompactionStrategy::default());
575 assert!(prompt.contains("Fix the bug in main.rs"));
576 assert!(prompt.contains("I'll read the file first."));
577 assert!(prompt.contains("Original Conversation"));
578 }
579
580 #[test]
581 fn test_compact_messages() {
582 let messages = vec![
583 Message {
584 role: Role::System,
585 content: "You are a coding agent.".to_string(),
586 tool_calls: vec![],
587 tool_result: None,
588 },
589 Message {
590 role: Role::User,
591 content: "Fix the error".to_string(),
592 tool_calls: vec![],
593 tool_result: None,
594 },
595 Message {
596 role: Role::Assistant,
597 content: "I'll help.".to_string(),
598 tool_calls: vec![],
599 tool_result: None,
600 },
601 ];
602
603 let strategy = CompactionStrategy {
605 keep_recent: 1,
606 keep_keywords: vec![],
607 keep_tool_results: false,
608 keep_system: false,
609 };
610 let result = compact_messages(messages, &strategy);
611
612 assert_eq!(result.original_count, 3);
613 assert!(result.compacted_count > 0);
616 assert!(result.tokens_saved > 0);
617 }
618
619 #[test]
620 fn test_compaction_preserves_system_messages() {
621 let messages = vec![
622 Message {
623 role: Role::System,
624 content: "System prompt".to_string(),
625 tool_calls: vec![],
626 tool_result: None,
627 },
628 Message {
629 role: Role::User,
630 content: "User message".to_string(),
631 tool_calls: vec![],
632 tool_result: None,
633 },
634 ];
635
636 let strategy = CompactionStrategy {
637 keep_system: true,
638 ..Default::default()
639 };
640
641 let result = compact_messages(messages, &strategy);
642 assert!(result
643 .messages
644 .iter()
645 .any(|m| m.role == Role::System && m.content == "System prompt"));
646 }
647
648 #[test]
649 fn test_compaction_preserves_keyword_messages() {
650 let messages = vec![
651 Message {
652 role: Role::User,
653 content: "Fix the error".to_string(),
654 tool_calls: vec![],
655 tool_result: None,
656 },
657 Message {
658 role: Role::User,
659 content: "Regular message".to_string(),
660 tool_calls: vec![],
661 tool_result: None,
662 },
663 ];
664
665 let strategy = CompactionStrategy {
666 keep_keywords: vec!["error".to_string()],
667 ..Default::default()
668 };
669
670 let result = compact_messages(messages, &strategy);
671 assert!(result.messages.iter().any(|m| m.content == "Fix the error"));
672 }
673
674 #[test]
675 fn test_compact_messages_empty_input() {
676 let result = compact_messages(vec![], &CompactionStrategy::default());
677 assert_eq!(result.original_count, 0);
678 assert_eq!(result.compacted_count, 0);
679 assert_eq!(result.tokens_saved, 0);
680 assert!(result.messages.is_empty());
681 }
682
683 #[test]
684 fn test_compact_messages_deduplication() {
685 let messages = vec![
687 Message {
688 role: Role::User,
689 content: "Same content".to_string(),
690 tool_calls: vec![],
691 tool_result: None,
692 },
693 Message {
694 role: Role::Assistant,
695 content: "Same content".to_string(),
696 tool_calls: vec![],
697 tool_result: None,
698 },
699 ];
700
701 let strategy = CompactionStrategy {
702 keep_recent: 10,
703 keep_keywords: vec![],
704 keep_tool_results: false,
705 keep_system: false,
706 };
707 let result = compact_messages(messages, &strategy);
708
709 let matching = result
711 .messages
712 .iter()
713 .filter(|m| m.content == "Same content")
714 .count();
715 assert_eq!(matching, 1, "Duplicate content should be deduplicated");
716 }
717
718 #[test]
719 fn test_compact_messages_preserves_order() {
720 let messages = vec![
721 Message {
722 role: Role::System,
723 content: "System".to_string(),
724 tool_calls: vec![],
725 tool_result: None,
726 },
727 Message {
728 role: Role::User,
729 content: "User-1".to_string(),
730 tool_calls: vec![],
731 tool_result: None,
732 },
733 Message {
734 role: Role::Assistant,
735 content: "Assistant-1".to_string(),
736 tool_calls: vec![],
737 tool_result: None,
738 },
739 Message {
740 role: Role::User,
741 content: "User-2".to_string(),
742 tool_calls: vec![],
743 tool_result: None,
744 },
745 ];
746
747 let strategy = CompactionStrategy {
748 keep_recent: 10,
749 keep_keywords: vec![],
750 keep_tool_results: false,
751 keep_system: false,
752 };
753 let result = compact_messages(messages, &strategy);
754
755 let contents: Vec<_> = result.messages.iter().map(|m| m.content.clone()).collect();
757 assert_eq!(contents, vec!["System", "User-1", "Assistant-1", "User-2"]);
758 }
759
760 #[test]
761 fn test_compact_messages_tool_results() {
762 use crate::agent::{ToolCallRequest, ToolResultMessage};
763
764 let messages = vec![
765 Message {
766 role: Role::Assistant,
767 content: "Running tool".to_string(),
768 tool_calls: vec![ToolCallRequest {
769 id: "1".to_string(),
770 name: "read_file".to_string(),
771 arguments: serde_json::json!({}),
772 }],
773 tool_result: Some(ToolResultMessage {
774 tool_call_id: "1".to_string(),
775 content: serde_json::json!("file content here"),
776 success: true,
777 }),
778 },
779 Message {
780 role: Role::User,
781 content: "Just a message".to_string(),
782 tool_calls: vec![],
783 tool_result: None,
784 },
785 ];
786
787 let strategy = CompactionStrategy {
788 keep_recent: 1,
789 keep_keywords: vec![],
790 keep_tool_results: true,
791 keep_system: false,
792 };
793 let result = compact_messages(messages, &strategy);
794
795 assert!(result.messages.iter().any(|m| m.tool_result.is_some()));
797 }
798
799 #[test]
800 fn test_compact_messages_all_system() {
801 let messages = vec![
802 Message {
803 role: Role::System,
804 content: "System 1".to_string(),
805 tool_calls: vec![],
806 tool_result: None,
807 },
808 Message {
809 role: Role::System,
810 content: "System 2".to_string(),
811 tool_calls: vec![],
812 tool_result: None,
813 },
814 ];
815
816 let strategy = CompactionStrategy {
817 keep_recent: 0,
818 keep_keywords: vec![],
819 keep_tool_results: false,
820 keep_system: true,
821 };
822 let result = compact_messages(messages, &strategy);
823
824 assert_eq!(result.compacted_count, 2);
825 assert!(result.messages.iter().all(|m| m.role == Role::System));
826 }
827
828 #[test]
829 fn test_compact_messages_keyword_case_insensitive() {
830 let messages = vec![
831 Message {
832 role: Role::User,
833 content: "Found the ERROR in the code".to_string(),
834 tool_calls: vec![],
835 tool_result: None,
836 },
837 Message {
838 role: Role::User,
839 content: "Normal text".to_string(),
840 tool_calls: vec![],
841 tool_result: None,
842 },
843 ];
844
845 let strategy = CompactionStrategy {
846 keep_keywords: vec!["error".to_string()],
847 keep_recent: 0,
848 keep_tool_results: false,
849 keep_system: false,
850 };
851 let result = compact_messages(messages, &strategy);
852
853 assert!(result.messages.iter().any(|m| m.content.contains("ERROR")));
854 }
855
856 #[test]
857 fn test_compact_messages_recent_boundary() {
858 let messages: Vec<_> = (0..10)
860 .map(|i| Message {
861 role: Role::User,
862 content: format!("Message {}", i),
863 tool_calls: vec![],
864 tool_result: None,
865 })
866 .collect();
867
868 let strategy = CompactionStrategy {
869 keep_recent: 10,
870 keep_keywords: vec![],
871 keep_tool_results: false,
872 keep_system: false,
873 };
874 let result = compact_messages(messages, &strategy);
875
876 assert_eq!(result.compacted_count, 10);
878 }
879
880 #[test]
881 fn test_estimate_tokens_saved_no_reduction() {
882 assert_eq!(estimate_tokens_saved(5, 5), 0);
883 }
884
885 #[test]
886 fn test_estimate_tokens_saved_reduction() {
887 assert_eq!(estimate_tokens_saved(10, 4), 24); }
890
891 #[test]
892 fn test_parse_compaction_summary_user_intent() {
893 let summary = r#"
894# Conversation Summary
895
896## User Intent
897Build a new feature for the app
898
899## Key Decisions
900- Use Axum for the web server
901- Store sessions in JSON files
902
903## Code Changes
904None
905
906## Errors and Solutions
907None
908
909## Debugging Steps
910None
911
912## Warnings and Notes
913None
914
915## Current State
916Feature is partially implemented
917
918## Next Steps
9191. Write tests
9202. Deploy
921"#;
922 let parsed = parse_compaction_summary(summary).unwrap();
923
924 assert_eq!(parsed.user_intent, "Build a new feature for the app");
925 assert_eq!(parsed.key_decisions.len(), 2);
926 assert_eq!(parsed.key_decisions[0], "Use Axum for the web server");
927 assert!(parsed.code_changes.is_empty());
928 assert!(parsed.errors_and_solutions.is_empty());
929 assert_eq!(parsed.current_state, "Feature is partially implemented");
930 assert_eq!(parsed.next_steps.len(), 2);
931 }
932
933 #[test]
934 fn test_parse_compaction_summary_code_changes() {
935 let summary = r#"
936## Code Changes
937
938### File: src/main.rs
939- **Change**: Added health endpoint
940- **Rationale**: Needed for Kubernetes probes
941- **Impact**: /health now returns 200
942
943### File: src/lib.rs
944- **Change**: Added session storage
945- **Rationale**: Sessions were lost on restart
946- **Impact**: Sessions persist across restarts
947
948## User Intent
949None
950
951## Key Decisions
952None
953
954## Errors and Solutions
955None
956
957## Debugging Steps
958None
959
960## Warnings and Notes
961None
962
963## Current State
964None
965
966## Next Steps
967None
968"#;
969 let parsed = parse_compaction_summary(summary).unwrap();
970
971 assert_eq!(parsed.code_changes.len(), 2);
972 assert_eq!(parsed.code_changes[0].file, "src/main.rs");
973 assert_eq!(parsed.code_changes[0].change, "Added health endpoint");
974 assert_eq!(
975 parsed.code_changes[0].rationale,
976 "Needed for Kubernetes probes"
977 );
978 assert_eq!(parsed.code_changes[0].impact, "/health now returns 200");
979 assert_eq!(parsed.code_changes[1].file, "src/lib.rs");
980 }
981
982 #[test]
983 fn test_parse_compaction_summary_errors_and_solutions() {
984 let summary = r#"
985## Errors and Solutions
986
987### Error: Rust compiler error E0432
988- **Location**: src/main.rs:10
989- **Solution**: Added missing import
990- **Prevention**: Run cargo check before committing
991
992## User Intent
993None
994
995## Key Decisions
996None
997
998## Code Changes
999None
1000
1001## Debugging Steps
1002None
1003
1004## Warnings and Notes
1005None
1006
1007## Current State
1008None
1009
1010## Next Steps
1011None
1012"#;
1013 let parsed = parse_compaction_summary(summary).unwrap();
1014
1015 assert_eq!(parsed.errors_and_solutions.len(), 1);
1016 assert_eq!(
1017 parsed.errors_and_solutions[0].error,
1018 "Rust compiler error E0432"
1019 );
1020 assert_eq!(parsed.errors_and_solutions[0].location, "src/main.rs:10");
1021 assert_eq!(
1022 parsed.errors_and_solutions[0].solution,
1023 "Added missing import"
1024 );
1025 assert_eq!(
1026 parsed.errors_and_solutions[0].prevention,
1027 "Run cargo check before committing"
1028 );
1029 }
1030
1031 #[test]
1032 fn test_parse_compaction_summary_debugging_steps() {
1033 let summary = r#"
1034## Debugging Steps
10351. Set breakpoints in the handler
10362. Run cargo test
10373. Check logs for errors
1038
1039## User Intent
1040None
1041
1042## Key Decisions
1043None
1044
1045## Code Changes
1046None
1047
1048## Errors and Solutions
1049None
1050
1051## Warnings and Notes
1052None
1053
1054## Current State
1055None
1056
1057## Next Steps
1058None
1059"#;
1060 let parsed = parse_compaction_summary(summary).unwrap();
1061
1062 assert_eq!(parsed.debugging_steps.len(), 3);
1063 assert_eq!(parsed.debugging_steps[0], "Set breakpoints in the handler");
1064 assert_eq!(parsed.debugging_steps[1], "Run cargo test");
1065 assert_eq!(parsed.debugging_steps[2], "Check logs for errors");
1066 }
1067
1068 #[test]
1069 fn test_parse_compaction_summary_warnings_and_notes() {
1070 let summary = r#"
1071## Warnings and Notes
1072- The config file uses TOML format
1073- Sessions expire after 24 hours
1074
1075## User Intent
1076None
1077
1078## Key Decisions
1079None
1080
1081## Code Changes
1082None
1083
1084## Errors and Solutions
1085None
1086
1087## Debugging Steps
1088None
1089
1090## Current State
1091None
1092
1093## Next Steps
1094None
1095"#;
1096 let parsed = parse_compaction_summary(summary).unwrap();
1097
1098 assert_eq!(parsed.warnings_and_notes.len(), 2);
1099 assert_eq!(
1100 parsed.warnings_and_notes[0],
1101 "The config file uses TOML format"
1102 );
1103 }
1104
1105 #[test]
1106 fn test_parse_compaction_summary_empty_input() {
1107 let result = parse_compaction_summary("");
1108 assert!(result.is_ok());
1109 let parsed = result.unwrap();
1110 assert_eq!(parsed.user_intent, "");
1111 assert!(parsed.key_decisions.is_empty());
1112 assert!(parsed.code_changes.is_empty());
1113 }
1114
1115 #[test]
1116 fn test_parse_compaction_summary_partial_content() {
1117 let summary = r#"
1119## User Intent
1120Just this
1121
1122## Key Decisions
1123None
1124
1125## Code Changes
1126None
1127
1128## Errors and Solutions
1129None
1130
1131## Debugging Steps
1132None
1133
1134## Warnings and Notes
1135None
1136
1137## Current State
1138None
1139
1140## Next Steps
1141None
1142"#;
1143 let parsed = parse_compaction_summary(summary).unwrap();
1144 assert_eq!(parsed.user_intent, "Just this");
1145 }
1146
1147 #[test]
1148 fn test_parse_compaction_summary_code_change_missing_fields() {
1149 let summary = r#"
1151## Code Changes
1152
1153### File: src/main.rs
1154
1155Some free-form text that is not a bullet
1156
1157## User Intent
1158None
1159
1160## Key Decisions
1161None
1162
1163## Errors and Solutions
1164None
1165
1166## Debugging Steps
1167None
1168
1169## Warnings and Notes
1170None
1171
1172## Current State
1173None
1174
1175## Next Steps
1176None
1177"#;
1178 let parsed = parse_compaction_summary(summary).unwrap();
1179
1180 assert_eq!(parsed.code_changes.len(), 1);
1182 assert_eq!(parsed.code_changes[0].file, "src/main.rs");
1183 assert_eq!(parsed.code_changes[0].change, "");
1184 assert_eq!(parsed.code_changes[0].rationale, "");
1185 assert_eq!(parsed.code_changes[0].impact, "");
1186 }
1187
1188 #[test]
1189 fn test_parse_compaction_summary_truly_empty_file() {
1190 let summary = r#"
1192## Code Changes
1193
1194### File:
1195- **Change**: Should not appear
1196
1197## User Intent
1198None
1199
1200## Key Decisions
1201None
1202
1203## Errors and Solutions
1204None
1205
1206## Debugging Steps
1207None
1208
1209## Warnings and Notes
1210None
1211
1212## Current State
1213None
1214
1215## Next Steps
1216None
1217"#;
1218 let parsed = parse_compaction_summary(summary).unwrap();
1219
1220 assert!(parsed.code_changes.is_empty());
1222 }
1223
1224 #[test]
1225 fn test_parse_compaction_summary_no_header_prefix() {
1226 let summary = "This is just free-form text without section headers.";
1228 let parsed = parse_compaction_summary(summary).unwrap();
1229 assert_eq!(parsed.user_intent, "");
1230 }
1231
1232 #[test]
1233 fn test_summary_to_message_basic() {
1234 let summary = ParsedCompactionSummary {
1235 user_intent: "Build a feature".to_string(),
1236 key_decisions: vec!["Use Rust".to_string()],
1237 code_changes: vec![],
1238 errors_and_solutions: vec![],
1239 debugging_steps: vec![],
1240 warnings_and_notes: vec![],
1241 current_state: "In progress".to_string(),
1242 next_steps: vec![],
1243 };
1244
1245 let msg = summary_to_message(&summary);
1246 assert_eq!(msg.role, Role::System);
1247 assert!(msg.content.contains("## User Intent"));
1248 assert!(msg.content.contains("Build a feature"));
1249 assert!(msg.content.contains("## Key Decisions"));
1250 assert!(msg.content.contains("Use Rust"));
1251 assert!(msg.content.contains("## Current State"));
1252 assert!(msg.content.contains("In progress"));
1253 assert!(msg.tool_calls.is_empty());
1254 assert!(msg.tool_result.is_none());
1255 }
1256
1257 #[test]
1258 fn test_summary_to_message_full_structure() {
1259 use super::{CodeChange, ErrorSolution};
1260
1261 let summary = ParsedCompactionSummary {
1262 user_intent: "Fix the bug".to_string(),
1263 key_decisions: vec!["Decision 1".to_string(), "Decision 2".to_string()],
1264 code_changes: vec![CodeChange {
1265 file: "src/main.rs".to_string(),
1266 change: "Added check".to_string(),
1267 rationale: "To prevent crash".to_string(),
1268 impact: "Crash fixed".to_string(),
1269 }],
1270 errors_and_solutions: vec![ErrorSolution {
1271 error: "Panic at line 10".to_string(),
1272 location: "src/main.rs:10".to_string(),
1273 solution: "Added null check".to_string(),
1274 prevention: "Use Option types".to_string(),
1275 }],
1276 debugging_steps: vec!["Step 1".to_string(), "Step 2".to_string()],
1277 warnings_and_notes: vec!["Note: runs on Linux only".to_string()],
1278 current_state: "Fixed".to_string(),
1279 next_steps: vec!["Deploy".to_string()],
1280 };
1281
1282 let msg = summary_to_message(&summary);
1283
1284 assert!(msg.content.contains("### File: src/main.rs"));
1285 assert!(msg.content.contains("- **Change**: Added check"));
1286 assert!(msg.content.contains("- **Rationale**: To prevent crash"));
1287 assert!(msg.content.contains("### Error: Panic at line 10"));
1288 assert!(msg.content.contains("- **Solution**: Added null check"));
1289 assert!(msg.content.contains("1. Step 1"));
1290 assert!(msg.content.contains("1. Deploy"));
1291 assert!(msg.content.contains("Note: runs on Linux only"));
1292 }
1293
1294 #[test]
1295 fn test_summary_to_message_empty_sections_omitted() {
1296 let summary = ParsedCompactionSummary {
1297 user_intent: "Just this".to_string(),
1298 key_decisions: vec![],
1299 code_changes: vec![],
1300 errors_and_solutions: vec![],
1301 debugging_steps: vec![],
1302 warnings_and_notes: vec![],
1303 current_state: "".to_string(),
1304 next_steps: vec![],
1305 };
1306
1307 let msg = summary_to_message(&summary);
1308
1309 assert!(msg.content.contains("## User Intent"));
1311 assert!(msg.content.contains("Just this"));
1312 assert!(!msg.content.contains("## Key Decisions"));
1313 assert!(!msg.content.contains("## Code Changes"));
1314 assert!(!msg.content.contains("## Next Steps"));
1315 }
1316
1317 #[test]
1318 fn test_compaction_strategy_custom_values() {
1319 let strategy = CompactionStrategy {
1320 keep_recent: 5,
1321 keep_keywords: vec!["critical".to_string(), "urgent".to_string()],
1322 keep_tool_results: false,
1323 keep_system: false,
1324 };
1325
1326 assert_eq!(strategy.keep_recent, 5);
1327 assert_eq!(strategy.keep_keywords.len(), 2);
1328 assert!(!strategy.keep_tool_results);
1329 assert!(!strategy.keep_system);
1330 }
1331
1332 #[test]
1333 fn test_compact_messages_keywords_with_tool_calls() {
1334 let messages = vec![
1336 Message {
1337 role: Role::Assistant,
1338 content: "Calling read_file".to_string(),
1339 tool_calls: vec![crate::agent::ToolCallRequest {
1340 id: "1".to_string(),
1341 name: "read_file".to_string(),
1342 arguments: serde_json::json!({}),
1343 }],
1344 tool_result: None, },
1346 Message {
1347 role: Role::User,
1348 content: "Fix the bug".to_string(),
1349 tool_calls: vec![],
1350 tool_result: None,
1351 },
1352 ];
1353
1354 let strategy = CompactionStrategy {
1355 keep_recent: 0,
1356 keep_keywords: vec!["bug".to_string()],
1357 keep_tool_results: true,
1358 keep_system: false,
1359 };
1360 let result = compact_messages(messages, &strategy);
1361
1362 assert!(result.messages.iter().any(|m| m.content == "Fix the bug"));
1364 }
1367}