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