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/// Extract raw text content for a named `## ` section from the summary.
312/// Returns everything between this section's header and the next `## ` (or EOF),
313/// trimmed of leading/trailing whitespace.
314fn 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
325/// Parse a named text field from the summary (joins non-empty lines, trims).
326///
327/// Handles scalar text fields such as *User Intent* and *Current State*.
328pub 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
337/// Parse bullet-list items (`- ` prefix) from section text.
338///
339/// Covers *Key Decisions* and *Warnings and Notes*.
340pub 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
347/// Parse `### File: …` subsections from the *Code Changes* section.
348///
349/// Each `### File: <path>` header starts a new [`CodeChange`] entry; the
350/// following `- **Change** / **Rationale** / **Impact**` lines populate it.
351pub 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
387/// Parse `### Error: …` subsections from the *Errors and Solutions* section.
388///
389/// Each `### Error: <desc>` header starts a new [`ErrorSolution`] entry; the
390/// following `- **Location** / **Solution** / **Prevention**` lines populate it.
391pub 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
427/// Parse numbered-list items (`1. `, `2. `, …) from section text.
428///
429/// Covers *Debugging Steps* and *Next Steps*.
430fn 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
441/// Parse a structured compaction summary from LLM output
442///
443/// This is a simple parser that extracts sections from the structured format.
444/// It's designed to be robust to minor variations in formatting.
445pub 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
464/// Convert a parsed compaction summary back to a message
465///
466/// This function converts the structured summary back into a message
467/// that can be added to the conversation history.
468pub 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        // Use a strategy that will actually drop some messages
604        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        // With keep_recent=1, only the most recent message (Assistant) is kept
614        // System and User are dropped because keep_system=false and keep_keywords=[]
615        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        // Two identical messages should result in only one kept
686        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        // Both have same content, so only one should appear (dedup)
710        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        // Should preserve original order: System, User-1, Assistant-1, User-2
756        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        // Tool result message should be kept
796        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        // Test when keep_recent exactly equals message count
859        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        // All 10 messages kept since keep_recent == 10
877        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        // 4 tokens per message
888        assert_eq!(estimate_tokens_saved(10, 4), 24); // (10-4)*4
889    }
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        // Only User Intent filled in
1118        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        // Code change with only file, no bullet fields
1150        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        // Code change with file but no bullet fields should be added (file is non-empty)
1181        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        // Section header with no actual file name should not create a code change
1191        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        // Empty file name should prevent addition
1221        assert!(parsed.code_changes.is_empty());
1222    }
1223
1224    #[test]
1225    fn test_parse_compaction_summary_no_header_prefix() {
1226        // Content without ## prefix should be ignored
1227        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        // Empty sections should be omitted (no ## Code Changes, etc.)
1310        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        // Tool calls without results should NOT be kept by keep_tool_results
1335        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, // No result yet
1345            },
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        // "Fix the bug" keyword message should be kept
1363        assert!(result.messages.iter().any(|m| m.content == "Fix the bug"));
1364        // Tool call without result should NOT be kept by keep_tool_results
1365        // (it only keeps messages that BOTH have tool_calls AND tool_result)
1366    }
1367}