1use serde::{Deserialize, Serialize};
7
8use super::state::{Session, SessionMessage};
9use super::types::CompactRecord;
10use super::{SessionError, SessionResult};
11use crate::client::DEFAULT_SMALL_MODEL;
12use crate::types::{CompactResult, ContentBlock, Role};
13
14pub const DEFAULT_COMPACT_THRESHOLD: f32 = 0.8;
16
17#[derive(Clone, Debug, Serialize, Deserialize)]
26pub struct CompactStrategy {
27 pub enabled: bool,
28 pub threshold_percent: f32,
29 pub summary_model: String,
30 pub max_summary_tokens: u32,
31 #[serde(default = "default_keep_coding_instructions")]
45 pub keep_coding_instructions: bool,
46 #[serde(default)]
49 pub custom_instructions: Option<String>,
50}
51
52fn default_keep_coding_instructions() -> bool {
53 true
54}
55
56impl Default for CompactStrategy {
57 fn default() -> Self {
58 Self {
59 enabled: true,
60 threshold_percent: DEFAULT_COMPACT_THRESHOLD,
61 summary_model: DEFAULT_SMALL_MODEL.to_string(),
62 max_summary_tokens: 4000,
63 keep_coding_instructions: true,
64 custom_instructions: None,
65 }
66 }
67}
68
69impl CompactStrategy {
70 pub fn disabled() -> Self {
71 Self {
72 enabled: false,
73 ..Default::default()
74 }
75 }
76
77 pub fn threshold(mut self, percent: f32) -> Self {
78 self.threshold_percent = percent.clamp(0.5, 0.95);
79 self
80 }
81
82 pub fn model(mut self, model: impl Into<String>) -> Self {
83 self.summary_model = model.into();
84 self
85 }
86
87 pub fn keep_coding_instructions(mut self, keep: bool) -> Self {
91 self.keep_coding_instructions = keep;
92 self
93 }
94
95 pub fn custom_instructions(mut self, instructions: impl Into<String>) -> Self {
97 self.custom_instructions = Some(instructions.into());
98 self
99 }
100
101 #[cfg(feature = "cli-integration")]
103 pub fn from_output_style(style: &crate::output_style::OutputStyle) -> Self {
104 Self {
105 keep_coding_instructions: style.keep_coding_instructions,
106 ..Default::default()
107 }
108 }
109}
110
111pub struct CompactExecutor {
112 strategy: CompactStrategy,
113}
114
115impl CompactExecutor {
116 pub fn new(strategy: CompactStrategy) -> Self {
117 Self { strategy }
118 }
119
120 pub fn needs_compact(&self, current_tokens: u64, max_tokens: u64) -> bool {
121 if !self.strategy.enabled {
122 return false;
123 }
124 let threshold = (max_tokens as f32 * self.strategy.threshold_percent) as u64;
125 current_tokens >= threshold
126 }
127
128 pub fn prepare_compact(&self, session: &Session) -> SessionResult<PreparedCompact> {
129 if !self.strategy.enabled {
130 return Err(SessionError::Compact {
131 message: "Compact is disabled".to_string(),
132 });
133 }
134
135 let messages = session.current_branch();
136 if messages.is_empty() {
137 return Ok(PreparedCompact::NotNeeded);
138 }
139
140 let summary_prompt = self.format_for_summary(&messages);
141
142 Ok(PreparedCompact::Ready {
143 summary_prompt,
144 message_count: messages.len(),
145 })
146 }
147
148 pub fn apply_compact(&self, session: &mut Session, summary: String) -> CompactResult {
149 let original_count = session.messages.len();
150
151 let removed_chars: usize = session
152 .messages
153 .iter()
154 .map(|m| {
155 m.content
156 .iter()
157 .filter_map(|c| c.as_text())
158 .map(|t| t.len())
159 .sum::<usize>()
160 })
161 .sum();
162 let saved_tokens = (removed_chars / 4) as u64;
163
164 let summary_msg = SessionMessage::user(vec![ContentBlock::text(format!(
166 "[Previous conversation summary]\n\n{}",
167 summary
168 ))])
169 .as_compact_summary();
170
171 let new_leaf_id = Some(summary_msg.id.clone());
172 session.messages = vec![summary_msg];
173 session.current_leaf_id = new_leaf_id;
174 session.summary = Some(summary.clone());
175 session.updated_at = chrono::Utc::now();
176
177 CompactResult::Compacted {
178 original_count,
179 new_count: 1,
180 saved_tokens: saved_tokens as usize,
181 summary,
182 }
183 }
184
185 pub fn record_compact(&self, session: &mut Session, result: &CompactResult) {
186 if let CompactResult::Compacted {
187 original_count,
188 new_count,
189 saved_tokens,
190 summary,
191 } = result
192 {
193 let record = CompactRecord::new(session.id)
194 .counts(*original_count, *new_count)
195 .summary(summary.clone())
196 .saved_tokens(*saved_tokens);
197 session.record_compact(record);
198 }
199 }
200
201 fn format_for_summary(&self, messages: &[&SessionMessage]) -> String {
202 let mut formatted = String::new();
203
204 let prompt = if self.strategy.keep_coding_instructions {
206 COMPACTION_PROMPT_FULL
207 } else {
208 COMPACTION_PROMPT_MINIMAL
209 };
210
211 formatted.push_str(prompt);
212
213 if let Some(ref instructions) = self.strategy.custom_instructions {
214 formatted.push_str("\n\n");
215 formatted.push_str("# Custom Summary Instructions\n\n");
216 formatted.push_str(instructions);
217 }
218
219 formatted.push_str("\n\n---\n\n");
220 formatted.push_str("# Conversation to summarize:\n\n");
221
222 for msg in messages {
223 let role = match msg.role {
224 Role::User => "Human",
225 Role::Assistant => "Assistant",
226 };
227
228 formatted.push_str(&format!("**{}**:\n", role));
229
230 for block in &msg.content {
231 if let Some(text) = block.as_text() {
232 let display_text = if text.len() > 8000 {
234 let mut end = 8000;
235 while !text.is_char_boundary(end) {
236 end -= 1;
237 }
238 format!(
239 "{}... [truncated, {} chars total]",
240 &text[..end],
241 text.len()
242 )
243 } else {
244 text.to_string()
245 };
246 formatted.push_str(&display_text);
247 formatted.push('\n');
248 }
249 }
250 formatted.push('\n');
251 }
252
253 formatted
254 }
255
256 pub fn strategy(&self) -> &CompactStrategy {
257 &self.strategy
258 }
259}
260
261const COMPACTION_PROMPT_FULL: &str = r#"Your task is to create a detailed summary of the conversation so far, paying close attention to the user's explicit requests and your previous actions.
264
265This summary should be thorough in capturing technical details, code patterns, and architectural decisions that would be essential for continuing development work without losing context.
266
267Before providing your final summary, wrap your analysis in <analysis> tags to organize your thoughts and ensure you've covered all necessary points. In your analysis process:
268
2691. Chronologically analyze each message and section of the conversation. For each section thoroughly identify:
270 - The user's explicit requests and intents
271 - Your approach to addressing the user's requests
272 - Key decisions, technical concepts and code patterns
273 - Specific details like:
274 - file names
275 - full code snippets
276 - function signatures
277 - file edits
278 - Errors that you ran into and how you fixed them
279 - Pay special attention to specific user feedback that you received, especially if the user told you to do something differently.
280
2812. Double-check for technical accuracy and completeness, addressing each required element thoroughly.
282
283Your summary should include the following sections:
284
2851. Primary Request and Intent: Capture all of the user's explicit requests and intents in detail
286
2872. Key Technical Concepts: List all important technical concepts, technologies, and frameworks discussed.
288
2893. Files and Code Sections: Enumerate specific files and code sections examined, modified, or created. Pay special attention to the most recent messages and include full code snippets where applicable and include a summary of why this file read or edit is important.
290
2914. Errors and fixes: List all errors that you ran into, and how you fixed them. Pay special attention to specific user feedback that you received, especially if the user told you to do something differently.
292
2935. Problem Solving: Document problems solved and any ongoing troubleshooting efforts.
294
2956. All user messages: List ALL user messages that are not tool results. These are critical for understanding the users' feedback and changing intent.
296
2977. Pending Tasks: Outline any pending tasks that you have explicitly been asked to work on.
298
2998. Current Work: Describe in detail precisely what was being worked on immediately before this summary request, paying special attention to the most recent messages from both user and assistant. Include file names and code snippets where applicable.
300
3019. Optional Next Step: List the next step that you will take that is related to the most recent work you were doing. IMPORTANT: ensure that this step is DIRECTLY in line with the user's most recent explicit requests, and the task you were working on immediately before this summary request. If your last task was concluded, then only list next steps if they are explicitly in line with the users request. Do not start on tangential requests or really old requests that were already completed without confirming with the user first.
302 If there is a next step, include direct quotes from the most recent conversation showing exactly what task you were working on and where you left off. This should be verbatim to ensure there's no drift in task interpretation.
303
304Here's an example of how your output should be structured:
305
306<example>
307<analysis>
308[Your thought process, ensuring all points are covered thoroughly and accurately]
309</analysis>
310
311<summary>
3121. Primary Request and Intent:
313 [Detailed description]
314
3152. Key Technical Concepts:
316 - [Concept 1]
317 - [Concept 2]
318 - [...]
319
3203. Files and Code Sections:
321 - [File Name 1]
322 - [Summary of why this file is important]
323 - [Summary of the changes made to this file, if any]
324 - [Important Code Snippet]
325 - [File Name 2]
326 - [Important Code Snippet]
327 - [...]
328
3294. Errors and fixes:
330 - [Detailed description of error 1]:
331 - [How you fixed the error]
332 - [User feedback on the error if any]
333 - [...]
334
3355. Problem Solving:
336 [Description of solved problems and ongoing troubleshooting]
337
3386. All user messages:
339 - [Detailed non tool use user message]
340 - [...]
341
3427. Pending Tasks:
343 - [Task 1]
344 - [Task 2]
345 - [...]
346
3478. Current Work:
348 [Precise description of current work]
349
3509. Optional Next Step:
351 [Optional Next step to take]
352</summary>
353</example>
354
355Please provide your summary based on the conversation so far, following this structure and ensuring precision and thoroughness in your response."#;
356
357const COMPACTION_PROMPT_MINIMAL: &str = r#"Your task is to create a concise summary of the conversation so far, focusing on the essential context needed to continue the interaction.
360
361Before providing your final summary, briefly analyze the conversation in <analysis> tags.
362
363Your summary should include the following sections:
364
3651. Primary Request and Intent: What the user is trying to accomplish
366
3672. Key Decisions Made: Important choices and approaches decided upon
368
3693. Current Status: What has been completed and what remains
370
3714. Next Steps: If applicable, what should be done next
372
373Here's an example of how your output should be structured:
374
375<example>
376<analysis>
377[Brief thought process]
378</analysis>
379
380<summary>
3811. Primary Request and Intent:
382 [Concise description]
383
3842. Key Decisions Made:
385 - [Decision 1]
386 - [Decision 2]
387
3883. Current Status:
389 [What's done and what remains]
390
3914. Next Steps:
392 [What to do next, if applicable]
393</summary>
394</example>
395
396Please provide a focused summary based on the conversation so far."#;
397
398#[derive(Debug)]
399pub enum PreparedCompact {
400 NotNeeded,
401 Ready {
402 summary_prompt: String,
403 message_count: usize,
404 },
405}
406
407#[cfg(test)]
408mod tests {
409 use super::*;
410 use crate::session::state::SessionConfig;
411
412 fn create_test_session(message_count: usize) -> Session {
413 let mut session = Session::new(SessionConfig::default());
414
415 for i in 0..message_count {
416 let content = if i % 2 == 0 {
417 format!("User message {}", i)
418 } else {
419 format!("Assistant response {}", i)
420 };
421
422 let msg = if i % 2 == 0 {
423 SessionMessage::user(vec![ContentBlock::text(content)])
424 } else {
425 SessionMessage::assistant(vec![ContentBlock::text(content)])
426 };
427
428 session.add_message(msg);
429 }
430
431 session
432 }
433
434 #[test]
435 fn test_compact_strategy_default() {
436 let strategy = CompactStrategy::default();
437 assert!(strategy.enabled);
438 assert_eq!(strategy.threshold_percent, 0.8);
439 assert!(strategy.keep_coding_instructions);
440 assert!(strategy.custom_instructions.is_none());
441 }
442
443 #[test]
444 fn test_compact_strategy_disabled() {
445 let strategy = CompactStrategy::disabled();
446 assert!(!strategy.enabled);
447 }
448
449 #[test]
450 fn test_compact_strategy_with_keep_coding_instructions() {
451 let strategy = CompactStrategy::default().keep_coding_instructions(false);
452 assert!(!strategy.keep_coding_instructions);
453
454 let strategy = CompactStrategy::default().keep_coding_instructions(true);
455 assert!(strategy.keep_coding_instructions);
456 }
457
458 #[test]
459 fn test_compact_strategy_with_custom_instructions() {
460 let strategy = CompactStrategy::default()
461 .custom_instructions("Focus on test output and code changes.");
462
463 assert_eq!(
464 strategy.custom_instructions,
465 Some("Focus on test output and code changes.".to_string())
466 );
467 }
468
469 #[test]
470 fn test_needs_compact() {
471 let executor = CompactExecutor::new(CompactStrategy::default().threshold(0.8));
472
473 assert!(!executor.needs_compact(70_000, 100_000));
474 assert!(executor.needs_compact(80_000, 100_000));
475 assert!(executor.needs_compact(90_000, 100_000));
476 }
477
478 #[test]
479 fn test_prepare_compact_empty() {
480 let session = Session::new(SessionConfig::default());
481 let executor = CompactExecutor::new(CompactStrategy::default());
482
483 let result = executor.prepare_compact(&session).unwrap();
484 assert!(matches!(result, PreparedCompact::NotNeeded));
485 }
486
487 #[test]
488 fn test_prepare_compact_ready_full_prompt() {
489 let session = create_test_session(10);
490 let executor =
491 CompactExecutor::new(CompactStrategy::default().keep_coding_instructions(true));
492
493 let result = executor.prepare_compact(&session).unwrap();
494
495 match result {
496 PreparedCompact::Ready {
497 summary_prompt,
498 message_count,
499 } => {
500 assert!(summary_prompt.contains("Primary Request and Intent"));
502 assert!(summary_prompt.contains("Key Technical Concepts"));
503 assert!(summary_prompt.contains("Files and Code Sections"));
504 assert!(summary_prompt.contains("Errors and fixes"));
505 assert!(summary_prompt.contains("All user messages"));
506 assert!(summary_prompt.contains("Optional Next Step"));
507 assert_eq!(message_count, 10);
508 }
509 _ => panic!("Expected Ready"),
510 }
511 }
512
513 #[test]
514 fn test_prepare_compact_ready_minimal_prompt() {
515 let session = create_test_session(10);
516 let executor =
517 CompactExecutor::new(CompactStrategy::default().keep_coding_instructions(false));
518
519 let result = executor.prepare_compact(&session).unwrap();
520
521 match result {
522 PreparedCompact::Ready {
523 summary_prompt,
524 message_count,
525 } => {
526 assert!(summary_prompt.contains("Primary Request and Intent"));
528 assert!(summary_prompt.contains("Key Decisions Made"));
529 assert!(summary_prompt.contains("Current Status"));
530 assert!(summary_prompt.contains("Next Steps"));
531 assert!(!summary_prompt.contains("Files and Code Sections"));
533 assert!(!summary_prompt.contains("All user messages"));
534 assert_eq!(message_count, 10);
535 }
536 _ => panic!("Expected Ready"),
537 }
538 }
539
540 #[test]
541 fn test_prepare_compact_with_custom_instructions() {
542 let session = create_test_session(5);
543 let executor = CompactExecutor::new(
544 CompactStrategy::default()
545 .custom_instructions("Focus on Rust code changes and test results."),
546 );
547
548 let result = executor.prepare_compact(&session).unwrap();
549
550 match result {
551 PreparedCompact::Ready { summary_prompt, .. } => {
552 assert!(summary_prompt.contains("# Custom Summary Instructions"));
553 assert!(summary_prompt.contains("Focus on Rust code changes and test results."));
554 }
555 _ => panic!("Expected Ready"),
556 }
557 }
558
559 #[test]
560 fn test_apply_compact() {
561 let mut session = create_test_session(10);
562 let executor = CompactExecutor::new(CompactStrategy::default());
563
564 let result = executor.apply_compact(&mut session, "Test summary".to_string());
565
566 match result {
567 CompactResult::Compacted {
568 original_count,
569 new_count,
570 ..
571 } => {
572 assert_eq!(original_count, 10);
573 assert_eq!(new_count, 1);
574 }
575 _ => panic!("Expected Compacted"),
576 }
577
578 assert!(session.summary.is_some());
579 assert_eq!(session.messages.len(), 1);
580 assert!(session.messages[0].is_compact_summary);
581 }
582
583 #[test]
584 fn test_prompt_contains_analysis_tags() {
585 assert!(COMPACTION_PROMPT_FULL.contains("<analysis>"));
587 assert!(COMPACTION_PROMPT_MINIMAL.contains("<analysis>"));
588 }
589
590 #[test]
591 fn test_prompt_contains_summary_tags() {
592 assert!(COMPACTION_PROMPT_FULL.contains("<summary>"));
594 assert!(COMPACTION_PROMPT_MINIMAL.contains("<summary>"));
595 }
596}