syncable_cli/agent/
history.rs

1//! Conversation history management with forge-style compaction support
2//!
3//! This module provides conversation history storage with intelligent compaction
4//! inspired by forge's context management approach:
5//! - Configurable thresholds (tokens, turns, messages)
6//! - Smart eviction strategy (protects tool-call/result adjacency)
7//! - Droppable message support for ephemeral content
8//! - Summary frame generation for compressed history
9
10use super::compact::{
11    CompactConfig, CompactThresholds, CompactionStrategy, ContextSummary, SummaryFrame,
12};
13use rig::completion::Message;
14use serde::{Deserialize, Serialize};
15
16/// Rough token estimate: ~4 characters per token
17const CHARS_PER_TOKEN: usize = 4;
18
19/// A conversation turn containing user input and assistant response
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct ConversationTurn {
22    pub user_message: String,
23    pub assistant_response: String,
24    /// Tool calls made during this turn (for context preservation)
25    pub tool_calls: Vec<ToolCallRecord>,
26    /// Estimated token count for this turn
27    pub estimated_tokens: usize,
28    /// Whether this turn can be dropped entirely (ephemeral content)
29    /// Droppable turns are typically file reads or directory listings
30    /// that can be re-fetched if needed
31    #[serde(default)]
32    pub droppable: bool,
33}
34
35/// Record of a tool call for history tracking
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct ToolCallRecord {
38    pub tool_name: String,
39    pub args_summary: String,
40    pub result_summary: String,
41    /// Tool call ID for proper message pairing (optional for backwards compat)
42    #[serde(default)]
43    pub tool_id: Option<String>,
44    /// Whether this tool result is droppable (ephemeral content like file reads)
45    #[serde(default)]
46    pub droppable: bool,
47}
48
49/// Conversation history manager with forge-style compaction support
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct ConversationHistory {
52    /// Full conversation turns
53    turns: Vec<ConversationTurn>,
54    /// Compressed summary using SummaryFrame (if any)
55    summary_frame: Option<SummaryFrame>,
56    /// Total estimated tokens in history
57    total_tokens: usize,
58    /// Compaction configuration
59    compact_config: CompactConfig,
60    /// Number of user turns (for threshold checking)
61    user_turn_count: usize,
62    /// Context summary for tracking file operations and decisions
63    context_summary: ContextSummary,
64}
65
66impl Default for ConversationHistory {
67    fn default() -> Self {
68        Self::new()
69    }
70}
71
72impl ConversationHistory {
73    pub fn new() -> Self {
74        Self {
75            turns: Vec::new(),
76            summary_frame: None,
77            total_tokens: 0,
78            compact_config: CompactConfig::default(),
79            user_turn_count: 0,
80            context_summary: ContextSummary::new(),
81        }
82    }
83
84    /// Create with custom compaction configuration
85    pub fn with_config(config: CompactConfig) -> Self {
86        Self {
87            turns: Vec::new(),
88            summary_frame: None,
89            total_tokens: 0,
90            compact_config: config,
91            user_turn_count: 0,
92            context_summary: ContextSummary::new(),
93        }
94    }
95
96    /// Create with aggressive compaction for limited context windows
97    pub fn aggressive() -> Self {
98        Self::with_config(CompactConfig {
99            retention_window: 5,
100            eviction_window: 0.7,
101            thresholds: CompactThresholds::aggressive(),
102        })
103    }
104
105    /// Create with relaxed compaction for large context windows
106    pub fn relaxed() -> Self {
107        Self::with_config(CompactConfig {
108            retention_window: 20,
109            eviction_window: 0.5,
110            thresholds: CompactThresholds::relaxed(),
111        })
112    }
113
114    /// Estimate tokens in a string (~4 characters per token)
115    /// Public so it can be used for pre-request context size estimation
116    pub fn estimate_tokens(text: &str) -> usize {
117        text.len() / CHARS_PER_TOKEN
118    }
119
120    /// Add a new conversation turn
121    pub fn add_turn(
122        &mut self,
123        user_message: String,
124        assistant_response: String,
125        tool_calls: Vec<ToolCallRecord>,
126    ) {
127        // Determine if this turn is droppable based on tool calls
128        // Turns that only read files or list directories are droppable
129        let droppable = !tool_calls.is_empty()
130            && tool_calls.iter().all(|tc| {
131                matches!(
132                    tc.tool_name.as_str(),
133                    "read_file" | "list_directory" | "analyze_project"
134                )
135            });
136
137        let turn_tokens = Self::estimate_tokens(&user_message)
138            + Self::estimate_tokens(&assistant_response)
139            + tool_calls
140                .iter()
141                .map(|tc| {
142                    Self::estimate_tokens(&tc.tool_name)
143                        + Self::estimate_tokens(&tc.args_summary)
144                        + Self::estimate_tokens(&tc.result_summary)
145                })
146                .sum::<usize>();
147
148        self.turns.push(ConversationTurn {
149            user_message,
150            assistant_response,
151            tool_calls,
152            estimated_tokens: turn_tokens,
153            droppable,
154        });
155        self.total_tokens += turn_tokens;
156        self.user_turn_count += 1;
157    }
158
159    /// Add a turn with explicit droppable flag
160    pub fn add_turn_droppable(
161        &mut self,
162        user_message: String,
163        assistant_response: String,
164        tool_calls: Vec<ToolCallRecord>,
165        droppable: bool,
166    ) {
167        let turn_tokens = Self::estimate_tokens(&user_message)
168            + Self::estimate_tokens(&assistant_response)
169            + tool_calls
170                .iter()
171                .map(|tc| {
172                    Self::estimate_tokens(&tc.tool_name)
173                        + Self::estimate_tokens(&tc.args_summary)
174                        + Self::estimate_tokens(&tc.result_summary)
175                })
176                .sum::<usize>();
177
178        self.turns.push(ConversationTurn {
179            user_message,
180            assistant_response,
181            tool_calls,
182            estimated_tokens: turn_tokens,
183            droppable,
184        });
185        self.total_tokens += turn_tokens;
186        self.user_turn_count += 1;
187    }
188
189    /// Check if compaction is needed using forge-style thresholds
190    pub fn needs_compaction(&self) -> bool {
191        let last_is_user = self
192            .turns
193            .last()
194            .map(|t| !t.user_message.is_empty())
195            .unwrap_or(false);
196
197        self.compact_config.should_compact(
198            self.total_tokens,
199            self.user_turn_count,
200            self.turns.len(),
201            last_is_user,
202        )
203    }
204
205    /// Get the reason for compaction (for logging)
206    pub fn compaction_reason(&self) -> Option<String> {
207        self.compact_config.compaction_reason(
208            self.total_tokens,
209            self.user_turn_count,
210            self.turns.len(),
211        )
212    }
213
214    /// Get current token count
215    pub fn token_count(&self) -> usize {
216        self.total_tokens
217    }
218
219    /// Get number of turns
220    pub fn turn_count(&self) -> usize {
221        self.turns.len()
222    }
223
224    /// Get number of user turns
225    pub fn user_turn_count(&self) -> usize {
226        self.user_turn_count
227    }
228
229    /// Clear all history
230    pub fn clear(&mut self) {
231        self.turns.clear();
232        self.summary_frame = None;
233        self.total_tokens = 0;
234        self.user_turn_count = 0;
235        self.context_summary = ContextSummary::new();
236    }
237
238    /// Clear turns but preserve the summary frame (for sync with truncated raw_chat_history)
239    ///
240    /// Use this instead of clear() when raw_chat_history is truncated but we want to
241    /// preserve the accumulated context from prior compaction.
242    pub fn clear_turns_preserve_context(&mut self) {
243        // First compact any remaining turns into the summary
244        if self.turns.len() > 1 {
245            let _ = self.compact();
246        }
247
248        // Now clear turns but keep summary_frame and context_summary
249        self.turns.clear();
250
251        // Recalculate tokens (just summary frame now)
252        self.total_tokens = self
253            .summary_frame
254            .as_ref()
255            .map(|f| f.token_count)
256            .unwrap_or(0);
257
258        // User turn count stays as-is for statistics
259    }
260
261    /// Perform forge-style compaction with smart eviction
262    /// Returns the summary that was created (for logging/display)
263    pub fn compact(&mut self) -> Option<String> {
264        use super::compact::strategy::{MessageMeta, MessageRole};
265        use super::compact::summary::{
266            ToolCallSummary, TurnSummary, extract_assistant_action, extract_user_intent,
267        };
268
269        if self.turns.len() < 2 {
270            return None; // Nothing to compact
271        }
272
273        // Build message metadata for strategy
274        let messages: Vec<MessageMeta> = self
275            .turns
276            .iter()
277            .enumerate()
278            .flat_map(|(turn_idx, turn)| {
279                let mut metas = vec![];
280
281                // User message
282                metas.push(MessageMeta {
283                    index: turn_idx * 2,
284                    role: MessageRole::User,
285                    droppable: turn.droppable,
286                    has_tool_call: false,
287                    is_tool_result: false,
288                    tool_id: None,
289                    token_count: Self::estimate_tokens(&turn.user_message),
290                });
291
292                // Assistant message (may have tool calls)
293                let has_tool_call = !turn.tool_calls.is_empty();
294                let tool_id = turn.tool_calls.first().and_then(|tc| tc.tool_id.clone());
295
296                metas.push(MessageMeta {
297                    index: turn_idx * 2 + 1,
298                    role: MessageRole::Assistant,
299                    droppable: turn.droppable,
300                    has_tool_call,
301                    is_tool_result: false,
302                    tool_id,
303                    token_count: Self::estimate_tokens(&turn.assistant_response),
304                });
305
306                metas
307            })
308            .collect();
309
310        // Use default strategy (evict 60% or retain 10, whichever is more conservative)
311        let strategy = CompactionStrategy::default();
312
313        // Calculate eviction range with tool-call safety
314        let range =
315            strategy.calculate_eviction_range(&messages, self.compact_config.retention_window)?;
316
317        if range.is_empty() {
318            return None;
319        }
320
321        // Convert message indices to turn indices
322        let start_turn = range.start / 2;
323        let end_turn = range.end.div_ceil(2);
324
325        if start_turn >= end_turn || end_turn > self.turns.len() {
326            return None;
327        }
328
329        // Build context summary from turns to evict
330        let mut new_context = ContextSummary::new();
331
332        for (i, turn) in self.turns[start_turn..end_turn].iter().enumerate() {
333            let turn_summary = TurnSummary {
334                turn_number: start_turn + i + 1,
335                user_intent: extract_user_intent(&turn.user_message, 80),
336                assistant_action: extract_assistant_action(&turn.assistant_response, 100),
337                tool_calls: turn
338                    .tool_calls
339                    .iter()
340                    .map(|tc| ToolCallSummary {
341                        tool_name: tc.tool_name.clone(),
342                        args_summary: tc.args_summary.clone(),
343                        result_summary: truncate_text(&tc.result_summary, 100),
344                        success: !tc.result_summary.to_lowercase().contains("error"),
345                    })
346                    .collect(),
347                key_decisions: vec![], // Could extract from assistant response
348            };
349            new_context.add_turn(turn_summary);
350        }
351
352        // Merge with existing context summary
353        self.context_summary.merge(new_context);
354
355        // Generate summary frame
356        let new_frame = SummaryFrame::from_summary(&self.context_summary);
357
358        // Merge with existing frame if present
359        if let Some(existing) = &self.summary_frame {
360            let merged_content = format!("{}\n\n{}", existing.content, new_frame.content);
361            let merged_tokens = existing.token_count + new_frame.token_count;
362            self.summary_frame = Some(SummaryFrame {
363                content: merged_content,
364                token_count: merged_tokens,
365            });
366        } else {
367            self.summary_frame = Some(new_frame);
368        }
369
370        // Keep only recent turns (non-evicted)
371        let preserved_turns: Vec<_> = self.turns[end_turn..].to_vec();
372        let evicted_count = end_turn - start_turn;
373        self.turns = preserved_turns;
374
375        // Recalculate token count
376        self.total_tokens = self
377            .summary_frame
378            .as_ref()
379            .map(|f| f.token_count)
380            .unwrap_or(0)
381            + self.turns.iter().map(|t| t.estimated_tokens).sum::<usize>();
382
383        Some(format!(
384            "Compacted {} turns ({} → {} tokens)",
385            evicted_count,
386            self.total_tokens + evicted_count * 500, // rough estimate of evicted tokens
387            self.total_tokens
388        ))
389    }
390
391    /// Emergency compaction - more aggressive than normal
392    /// Used when "input too long" error occurs and we need to reduce context urgently.
393    /// Temporarily switches to aggressive config, compacts, then restores original.
394    pub fn emergency_compact(&mut self) -> Option<String> {
395        // Switch to aggressive config temporarily
396        let original_config = self.compact_config.clone();
397        self.compact_config = CompactConfig {
398            retention_window: 3,  // Keep only 3 most recent turns
399            eviction_window: 0.9, // Evict 90% of context
400            thresholds: CompactThresholds::aggressive(),
401        };
402
403        let result = self.compact();
404
405        // Restore original config
406        self.compact_config = original_config;
407        result
408    }
409
410    /// Convert history to Rig Message format for the agent
411    /// Uses structured summary frames to preserve context
412    pub fn to_messages(&self) -> Vec<Message> {
413        use rig::OneOrMany;
414        use rig::completion::message::{AssistantContent, Text, UserContent};
415
416        let mut messages = Vec::new();
417
418        // Add summary frame as initial context if present
419        if let Some(frame) = &self.summary_frame {
420            // Add as a user message with the summary, followed by acknowledgment
421            messages.push(Message::User {
422                content: OneOrMany::one(UserContent::Text(Text {
423                    text: format!("[Previous conversation context]\n{}", frame.content),
424                })),
425            });
426            messages.push(Message::Assistant {
427                id: None,
428                content: OneOrMany::one(AssistantContent::Text(Text {
429                    text:
430                        "I understand the previous context. I'll continue from where we left off."
431                            .to_string(),
432                })),
433            });
434        }
435
436        // Add recent turns with tool call context as text
437        for turn in &self.turns {
438            // User message
439            messages.push(Message::User {
440                content: OneOrMany::one(UserContent::Text(Text {
441                    text: turn.user_message.clone(),
442                })),
443            });
444
445            // Build assistant response that includes tool call context
446            let mut response_text = String::new();
447
448            // If there were tool calls, include them as text context
449            if !turn.tool_calls.is_empty() {
450                response_text.push_str("[Tools used in this turn:\n");
451                for tc in &turn.tool_calls {
452                    response_text.push_str(&format!(
453                        "  - {}({}) → {}\n",
454                        tc.tool_name,
455                        truncate_text(&tc.args_summary, 50),
456                        truncate_text(&tc.result_summary, 100)
457                    ));
458                }
459                response_text.push_str("]\n\n");
460            }
461
462            // Add the actual response
463            response_text.push_str(&turn.assistant_response);
464
465            messages.push(Message::Assistant {
466                id: None,
467                content: OneOrMany::one(AssistantContent::Text(Text {
468                    text: response_text,
469                })),
470            });
471        }
472
473        messages
474    }
475
476    /// Check if there's any history
477    pub fn is_empty(&self) -> bool {
478        self.turns.is_empty() && self.summary_frame.is_none()
479    }
480
481    /// Get a brief status string for display
482    pub fn status(&self) -> String {
483        let compressed_info = if self.summary_frame.is_some() {
484            format!(" (+{} compacted)", self.context_summary.turns_compacted)
485        } else {
486            String::new()
487        };
488        format!(
489            "{} turns, ~{} tokens{}",
490            self.turns.len(),
491            self.total_tokens,
492            compressed_info
493        )
494    }
495
496    /// Get files that have been read during this session
497    pub fn files_read(&self) -> impl Iterator<Item = &str> {
498        self.context_summary.files_read.iter().map(|s| s.as_str())
499    }
500
501    /// Get files that have been written during this session
502    pub fn files_written(&self) -> impl Iterator<Item = &str> {
503        self.context_summary
504            .files_written
505            .iter()
506            .map(|s| s.as_str())
507    }
508
509    /// Serialize to JSON for session persistence
510    pub fn to_json(&self) -> Result<String, serde_json::Error> {
511        serde_json::to_string(self)
512    }
513
514    /// Deserialize from JSON (for session restore)
515    pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
516        serde_json::from_str(json)
517    }
518}
519
520/// Helper to truncate text with ellipsis
521fn truncate_text(text: &str, max_len: usize) -> String {
522    if text.len() <= max_len {
523        text.to_string()
524    } else {
525        format!("{}...", &text[..max_len.saturating_sub(3)])
526    }
527}
528
529#[cfg(test)]
530mod tests {
531    use super::*;
532
533    #[test]
534    fn test_add_turn() {
535        let mut history = ConversationHistory::new();
536        history.add_turn("Hello".to_string(), "Hi there!".to_string(), vec![]);
537        assert_eq!(history.turn_count(), 1);
538        assert!(!history.is_empty());
539    }
540
541    #[test]
542    fn test_droppable_detection() {
543        let mut history = ConversationHistory::new();
544
545        // Turn with only read_file should be droppable
546        history.add_turn(
547            "Read the file".to_string(),
548            "Here's the content".to_string(),
549            vec![ToolCallRecord {
550                tool_name: "read_file".to_string(),
551                args_summary: "src/main.rs".to_string(),
552                result_summary: "file content...".to_string(),
553                tool_id: Some("tool_1".to_string()),
554                droppable: true,
555            }],
556        );
557        assert!(history.turns[0].droppable);
558
559        // Turn with write_file should NOT be droppable
560        history.add_turn(
561            "Write the file".to_string(),
562            "Done".to_string(),
563            vec![ToolCallRecord {
564                tool_name: "write_file".to_string(),
565                args_summary: "src/new.rs".to_string(),
566                result_summary: "success".to_string(),
567                tool_id: Some("tool_2".to_string()),
568                droppable: false,
569            }],
570        );
571        assert!(!history.turns[1].droppable);
572    }
573
574    #[test]
575    fn test_compaction() {
576        // Use aggressive config for easier testing
577        let mut history = ConversationHistory::with_config(CompactConfig {
578            retention_window: 2,
579            eviction_window: 0.6,
580            thresholds: CompactThresholds {
581                token_threshold: Some(500),
582                turn_threshold: Some(5),
583                message_threshold: Some(10),
584                on_turn_end: None,
585            },
586        });
587
588        // Add many turns to trigger compaction
589        for i in 0..10 {
590            history.add_turn(
591                format!("Question {} with lots of text to increase token count", i),
592                format!(
593                    "Answer {} with lots of detail to increase token count even more",
594                    i
595                ),
596                vec![ToolCallRecord {
597                    tool_name: "analyze".to_string(),
598                    args_summary: "path: .".to_string(),
599                    result_summary: "Found rust project with many files".to_string(),
600                    tool_id: Some(format!("tool_{}", i)),
601                    droppable: false,
602                }],
603            );
604        }
605
606        if history.needs_compaction() {
607            let summary = history.compact();
608            assert!(summary.is_some());
609            assert!(history.turn_count() < 10);
610            assert!(history.summary_frame.is_some());
611        }
612    }
613
614    #[test]
615    fn test_to_messages() {
616        let mut history = ConversationHistory::new();
617        history.add_turn(
618            "What is this project?".to_string(),
619            "This is a Rust CLI tool.".to_string(),
620            vec![],
621        );
622
623        let messages = history.to_messages();
624        assert_eq!(messages.len(), 2); // 1 user + 1 assistant
625    }
626
627    #[test]
628    fn test_clear() {
629        let mut history = ConversationHistory::new();
630        history.add_turn("Test".to_string(), "Response".to_string(), vec![]);
631        history.clear();
632        assert!(history.is_empty());
633        assert_eq!(history.token_count(), 0);
634    }
635
636    #[test]
637    fn test_compaction_reason() {
638        let mut history = ConversationHistory::with_config(CompactConfig {
639            retention_window: 2,
640            eviction_window: 0.6,
641            thresholds: CompactThresholds {
642                token_threshold: Some(100),
643                turn_threshold: Some(3),
644                message_threshold: Some(5),
645                on_turn_end: None,
646            },
647        });
648
649        // Add turns to exceed threshold
650        for i in 0..5 {
651            history.add_turn(format!("Question {}", i), format!("Answer {}", i), vec![]);
652        }
653
654        assert!(history.needs_compaction());
655        let reason = history.compaction_reason();
656        assert!(reason.is_some());
657    }
658
659    #[test]
660    fn test_clear_turns_preserve_context() {
661        // Create history with aggressive compaction to trigger summary
662        let mut history = ConversationHistory::with_config(CompactConfig {
663            retention_window: 2,
664            eviction_window: 0.6,
665            thresholds: CompactThresholds {
666                token_threshold: Some(200),
667                turn_threshold: Some(3),
668                message_threshold: Some(5),
669                on_turn_end: None,
670            },
671        });
672
673        // Add turns to trigger compaction
674        for i in 0..6 {
675            history.add_turn(
676                format!("Question {} with extra text", i),
677                format!("Answer {} with more detail", i),
678                vec![],
679            );
680        }
681
682        // Trigger compaction to build summary
683        if history.needs_compaction() {
684            let _ = history.compact();
685        }
686
687        // Verify we have a summary frame now
688        let had_summary_before = history.summary_frame.is_some();
689
690        // Now clear turns while preserving context
691        history.clear_turns_preserve_context();
692
693        // Verify turns are cleared but summary is preserved
694        assert_eq!(history.turn_count(), 0, "Turns should be cleared");
695        assert!(
696            history.summary_frame.is_some() == had_summary_before,
697            "Summary frame should be preserved"
698        );
699
700        // Token count should only include summary frame
701        if history.summary_frame.is_some() {
702            assert!(history.token_count() > 0, "Should have tokens from summary");
703        }
704
705        // to_messages should still work and include summary
706        let messages = history.to_messages();
707        if history.summary_frame.is_some() {
708            assert!(
709                !messages.is_empty(),
710                "Should still have summary in messages"
711            );
712        }
713    }
714
715    #[test]
716    fn test_clear_vs_clear_preserve_context() {
717        let mut history = ConversationHistory::new();
718
719        // Add some turns
720        for i in 0..5 {
721            history.add_turn(format!("Q{}", i), format!("A{}", i), vec![]);
722        }
723
724        // Force compaction
725        let _ = history.compact();
726        let had_summary = history.summary_frame.is_some();
727
728        // Test clear_turns_preserve_context
729        let mut history_preserve = history.clone();
730        history_preserve.clear_turns_preserve_context();
731
732        // Test regular clear
733        let mut history_clear = history.clone();
734        history_clear.clear();
735
736        // Verify difference
737        if had_summary {
738            assert!(
739                history_preserve.summary_frame.is_some(),
740                "preserve should keep summary"
741            );
742            assert!(
743                history_clear.summary_frame.is_none(),
744                "clear removes summary"
745            );
746        }
747
748        // Both should have no turns
749        assert_eq!(history_preserve.turn_count(), 0);
750        assert_eq!(history_clear.turn_count(), 0);
751    }
752
753    #[test]
754    fn test_history_serialization() {
755        let mut history = ConversationHistory::new();
756
757        // Add some turns
758        history.add_turn(
759            "What is this project?".to_string(),
760            "This is a Rust CLI tool.".to_string(),
761            vec![ToolCallRecord {
762                tool_name: "analyze".to_string(),
763                args_summary: "path: .".to_string(),
764                result_summary: "Found Rust project".to_string(),
765                tool_id: Some("tool_1".to_string()),
766                droppable: false,
767            }],
768        );
769
770        // Serialize
771        let json = history.to_json().expect("Should serialize");
772        assert!(!json.is_empty());
773
774        // Deserialize
775        let restored = ConversationHistory::from_json(&json).expect("Should deserialize");
776        assert_eq!(restored.turn_count(), 1);
777        assert_eq!(restored.user_turn_count(), 1);
778
779        // Verify tool call preserved
780        let messages = restored.to_messages();
781        assert!(!messages.is_empty());
782    }
783
784    #[test]
785    fn test_history_serialization_with_compaction() {
786        // Create history with compaction triggered
787        let mut history = ConversationHistory::with_config(CompactConfig {
788            retention_window: 2,
789            eviction_window: 0.6,
790            thresholds: CompactThresholds {
791                token_threshold: Some(200),
792                turn_threshold: Some(3),
793                message_threshold: Some(5),
794                on_turn_end: None,
795            },
796        });
797
798        // Add many turns to trigger compaction
799        for i in 0..6 {
800            history.add_turn(
801                format!("Question {} with some text", i),
802                format!("Answer {} with more detail", i),
803                vec![],
804            );
805        }
806
807        // Trigger compaction
808        if history.needs_compaction() {
809            let _ = history.compact();
810        }
811
812        let had_summary = history.summary_frame.is_some();
813
814        // Serialize with summary
815        let json = history.to_json().expect("Should serialize");
816
817        // Deserialize and verify summary preserved
818        let restored = ConversationHistory::from_json(&json).expect("Should deserialize");
819        assert_eq!(
820            restored.summary_frame.is_some(),
821            had_summary,
822            "Summary frame should be preserved"
823        );
824
825        // to_messages should include summary
826        let messages = restored.to_messages();
827        if had_summary {
828            // Summary adds 2 messages (user + assistant acknowledgment)
829            assert!(messages.len() >= 2, "Should have summary messages");
830        }
831    }
832}