@@ -47,7 +47,7 @@ pub struct ToolCallRecord {
4747}
4848
4949/// Conversation history manager with forge-style compaction support
50- #[ derive( Debug , Clone ) ]
50+ #[ derive( Debug , Clone , Serialize , Deserialize ) ]
5151pub struct ConversationHistory {
5252 /// Full conversation turns
5353 turns : Vec < ConversationTurn > ,
@@ -235,6 +235,29 @@ impl ConversationHistory {
235235 self . context_summary = ContextSummary :: new ( ) ;
236236 }
237237
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+
238261 /// Perform forge-style compaction with smart eviction
239262 /// Returns the summary that was created (for logging/display)
240263 pub fn compact ( & mut self ) -> Option < String > {
@@ -482,6 +505,16 @@ impl ConversationHistory {
482505 . iter ( )
483506 . map ( |s| s. as_str ( ) )
484507 }
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+ }
485518}
486519
487520/// Helper to truncate text with ellipsis
@@ -622,4 +655,178 @@ mod tests {
622655 let reason = history. compaction_reason ( ) ;
623656 assert ! ( reason. is_some( ) ) ;
624657 }
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+ }
625832}
0 commit comments