Skip to main content

pawan/
compaction.rs

1//! Structured conversation compaction for context overflow handling
2//!
3//! When conversation history exceeds context limits, this module provides
4//! tools to compact the history while preserving key information like:
5//! - User's original intent and requirements
6//! - Important decisions made
7//! - Code changes and their rationale
8//! - Error messages and debugging information
9
10use crate::agent::{Message, Role};
11use serde::{Deserialize, Serialize};
12
13/// Compaction strategy for preserving different types of information
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct CompactionStrategy {
16    /// Keep the most recent N messages (default: 10)
17    pub keep_recent: usize,
18    /// Keep messages with specific keywords (e.g., "error", "fix", "bug")
19    pub keep_keywords: Vec<String>,
20    /// Keep tool call results (default: true)
21    pub keep_tool_results: bool,
22    /// Keep system messages (default: true)
23    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/// Compaction result with statistics
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct CompactionResult {
50    /// The compacted messages
51    pub messages: Vec<Message>,
52    /// Number of messages before compaction
53    pub original_count: usize,
54    /// Number of messages after compaction
55    pub compacted_count: usize,
56    /// Estimated tokens saved
57    pub tokens_saved: usize,
58}
59
60/// Build a structured compaction prompt for the LLM
61///
62/// This prompt instructs the LLM to create a structured summary that preserves
63/// essential information while reducing token count. The output format is
64/// designed to be machine-readable and easily parsed.
65pub 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    // Add messages to the prompt with clear section markers
147    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        // Add tool call information if present
162        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        // Add tool result if present
170        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
187/// Compact messages based on the given strategy
188pub fn compact_messages(messages: Vec<Message>, strategy: &CompactionStrategy) -> CompactionResult {
189    let original_count = messages.len();
190    let mut compacted = Vec::new();
191
192    // Always keep system messages if enabled
193    if strategy.keep_system {
194        compacted.extend(messages.iter().filter(|m| m.role == Role::System).cloned());
195    }
196
197    // Keep messages with keywords
198    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    // Keep tool results if enabled
211    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    // Keep the most recent messages
223    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    // Sort by original order (approximate)
236    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
254/// Estimate tokens saved by compaction (rough approximation)
255fn estimate_tokens_saved(original: usize, compacted: usize) -> usize {
256    // Assume average of 4 tokens per message
257    let avg_tokens_per_message = 4;
258    (original - compacted) * avg_tokens_per_message
259}
260
261/// Parse a structured compaction summary
262///
263/// This function parses the structured output from the LLM and extracts
264/// the different sections into a structured format.
265#[derive(Debug, Clone, Serialize, Deserialize)]
266pub struct ParsedCompactionSummary {
267    /// User's original intent
268    pub user_intent: String,
269    /// Key decisions made
270    pub key_decisions: Vec<String>,
271    /// Code changes made
272    pub code_changes: Vec<CodeChange>,
273    /// Errors encountered and their solutions
274    pub errors_and_solutions: Vec<ErrorSolution>,
275    /// Debugging steps taken
276    pub debugging_steps: Vec<String>,
277    /// Warnings and notes
278    pub warnings_and_notes: Vec<String>,
279    /// Current state of the work
280    pub current_state: String,
281    /// Next steps to take
282    pub next_steps: Vec<String>,
283}
284
285/// A code change with metadata
286#[derive(Debug, Clone, Serialize, Deserialize)]
287pub struct CodeChange {
288    /// File that was changed
289    pub file: String,
290    /// Description of the change
291    pub change: String,
292    /// Rationale for the change
293    pub rationale: String,
294    /// Impact of the change
295    pub impact: String,
296}
297
298/// An error and its solution
299#[derive(Debug, Clone, Serialize, Deserialize)]
300pub struct ErrorSolution {
301    /// Error description
302    pub error: String,
303    /// Location of the error
304    pub location: String,
305    /// How it was fixed
306    pub solution: String,
307    /// How to prevent this in the future
308    pub prevention: String,
309}
310
311/// Parse a structured compaction summary from LLM output
312///
313/// This is a simple parser that extracts sections from the structured format.
314/// It's designed to be robust to minor variations in formatting.
315pub 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        // Detect section headers
336        if let Some(rest) = line.strip_prefix("## ") {
337            current_section = Some(rest.to_string());
338            continue;
339        }
340
341        // Detect subsection headers (###)
342        if let Some(subsection) = line.strip_prefix("### ") {
343            let subsection = subsection.to_string();
344
345            // If we were building a code change, save it
346            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 we were building an error solution, save it
353            if let Some(error) = current_error.take() {
354                if !error.error.is_empty() {
355                    parsed.errors_and_solutions.push(error);
356                }
357            }
358
359            // Start new code change or error
360            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        // Process content based on current section
380        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    // Save any remaining code change or error
444    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    // Trim whitespace
456    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
462/// Convert a parsed compaction summary back to a message
463///
464/// This function converts the structured summary back into a message
465/// that can be added to the conversation history.
466pub 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        // Use a strategy that will actually drop some messages
602        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        // With keep_recent=1, only the most recent message (Assistant) is kept
612        // System and User are dropped because keep_system=false and keep_keywords=[]
613        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        // Two identical messages should result in only one kept
684        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        // Both have same content, so only one should appear (dedup)
708        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        // Should preserve original order: System, User-1, Assistant-1, User-2
754        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        // Tool result message should be kept
794        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        // Test when keep_recent exactly equals message count
857        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        // All 10 messages kept since keep_recent == 10
875        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        // 4 tokens per message
886        assert_eq!(estimate_tokens_saved(10, 4), 24); // (10-4)*4
887    }
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        // Only User Intent filled in
1104        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        // Code change with only file, no bullet fields
1136        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        // Code change with file but no bullet fields should be added (file is non-empty)
1167        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        // Section header with no actual file name should not create a code change
1177        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        // Empty file name should prevent addition
1207        assert!(parsed.code_changes.is_empty());
1208    }
1209
1210    #[test]
1211    fn test_parse_compaction_summary_no_header_prefix() {
1212        // Content without ## prefix should be ignored
1213        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        // Empty sections should be omitted (no ## Code Changes, etc.)
1296        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        // Tool calls without results should NOT be kept by keep_tool_results
1321        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, // No result yet
1331            },
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        // "Fix the bug" keyword message should be kept
1349        assert!(result.messages.iter().any(|m| m.content == "Fix the bug"));
1350        // Tool call without result should NOT be kept by keep_tool_results
1351        // (it only keeps messages that BOTH have tool_calls AND tool_result)
1352    }
1353}