1use std::ops::Range;
2
3use imp_llm::{truncate_chars_with_suffix, ContentBlock, Message};
4
5use crate::context::estimate_tokens;
6use crate::error::Result;
7use crate::session::{sanitize_messages, SessionEntry, SessionManager};
8
9fn truncate_for_display(text: &str, max_chars: usize) -> String {
10 truncate_chars_with_suffix(text, max_chars, "...")
11}
12
13#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct AssistantActionGroup {
20 pub range: Range<usize>,
21}
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum CompactionStrategy {
30 Local,
31 ProviderNative,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct CompactionCapabilities<'a> {
38 pub provider_id: &'a str,
39 pub model_id: &'a str,
40 pub allow_provider_native: bool,
41}
42
43pub fn select_compaction_strategy(capabilities: &CompactionCapabilities<'_>) -> CompactionStrategy {
50 if capabilities.allow_provider_native
51 && matches!(
52 capabilities.provider_id,
53 "openai" | "openai-codex" | "anthropic"
54 )
55 {
56 return CompactionStrategy::ProviderNative;
57 }
58 CompactionStrategy::Local
59}
60#[derive(Debug, Clone)]
62pub struct PreparedCompaction {
63 pub summary_input: Vec<Message>,
65 pub preserved_tail: Vec<Message>,
67 pub preserved_tail_start: usize,
69 pub groups: Vec<AssistantActionGroup>,
71 pub shrunk_tool_results: usize,
74}
75
76impl PreparedCompaction {
77 pub fn should_compact(&self) -> bool {
78 !self.summary_input.is_empty()
79 }
80}
81
82pub fn assistant_action_groups(messages: &[Message]) -> Vec<AssistantActionGroup> {
89 let assistant_indices: Vec<usize> = messages
90 .iter()
91 .enumerate()
92 .filter_map(|(idx, msg)| matches!(msg, Message::Assistant(_)).then_some(idx))
93 .collect();
94
95 let mut groups = Vec::new();
96 for (group_idx, &assistant_idx) in assistant_indices.iter().enumerate() {
97 let mut start = assistant_idx;
98 while start > 0 {
99 match &messages[start - 1] {
100 Message::User(_) => start -= 1,
101 _ => break,
102 }
103 }
104 let end = assistant_indices
105 .get(group_idx + 1)
106 .copied()
107 .unwrap_or(messages.len());
108 groups.push(AssistantActionGroup { range: start..end });
109 }
110
111 groups
112}
113
114pub fn shrink_messages_for_summary(messages: &[Message]) -> (Vec<Message>, usize) {
117 let mut shrunk = messages.to_vec();
118 let mut args_map = std::collections::HashMap::<String, String>::new();
119
120 for msg in &shrunk {
121 if let Message::Assistant(assistant) = msg {
122 for block in &assistant.content {
123 if let ContentBlock::ToolCall { id, arguments, .. } = block {
124 let args_json = serde_json::to_string(arguments).unwrap_or_default();
125 args_map.insert(id.clone(), truncate_for_display(&args_json, 100));
126 }
127 }
128 }
129 }
130
131 let mut shrunk_count = 0;
132 for msg in &mut shrunk {
133 if let Message::ToolResult(result) = msg {
134 let byte_count: usize = result
135 .content
136 .iter()
137 .map(|block| match block {
138 ContentBlock::Text { text } => text.len(),
139 _ => 0,
140 })
141 .sum();
142 let args_summary = args_map
143 .get(&result.tool_call_id)
144 .map(|s| s.as_str())
145 .unwrap_or("");
146 let placeholder = format!(
147 "[Output omitted — ran {}({}), returned {} bytes]",
148 result.tool_name, args_summary, byte_count
149 );
150 result.content = vec![ContentBlock::Text { text: placeholder }];
151 shrunk_count += 1;
152 }
153 }
154
155 (shrunk, shrunk_count)
156}
157
158pub fn prepare_messages_for_compaction(
165 messages: &[Message],
166 keep_recent_groups: usize,
167) -> PreparedCompaction {
168 let groups = assistant_action_groups(messages);
169
170 if groups.len() <= keep_recent_groups {
171 let mut preserved_tail = messages.to_vec();
172 sanitize_messages(&mut preserved_tail);
173 return PreparedCompaction {
174 summary_input: Vec::new(),
175 preserved_tail,
176 preserved_tail_start: 0,
177 groups,
178 shrunk_tool_results: 0,
179 };
180 }
181
182 let preserved_tail_start = groups[groups.len() - keep_recent_groups].range.start;
183
184 let summary_prefix = &messages[..preserved_tail_start];
185 let preserved_tail_slice = &messages[preserved_tail_start..];
186
187 let (mut summary_input, shrunk_tool_results) = shrink_messages_for_summary(summary_prefix);
188 let mut preserved_tail = preserved_tail_slice.to_vec();
189
190 sanitize_messages(&mut summary_input);
191 sanitize_messages(&mut preserved_tail);
192
193 PreparedCompaction {
194 summary_input,
195 preserved_tail,
196 preserved_tail_start,
197 groups,
198 shrunk_tool_results,
199 }
200}
201
202pub const COMPACTION_SUMMARY_PREFIX: &str = "[CONTEXT COMPACTION] Earlier turns were compacted. \
207Use the summary below plus the preserved recent messages to continue. \
208Avoid repeating completed work:\n";
209
210fn build_summary_prompt(messages: &[Message]) -> String {
212 let mut serialized = String::new();
213 for msg in messages {
214 match msg {
215 Message::User(user) => {
216 let text: String = user
217 .content
218 .iter()
219 .filter_map(|b| match b {
220 ContentBlock::Text { text } => Some(text.as_str()),
221 _ => None,
222 })
223 .collect::<Vec<_>>()
224 .join("\n");
225 serialized.push_str(&format!(
226 "[USER]: {}\n\n",
227 truncate_for_display(&text, 3000)
228 ));
229 }
230 Message::Assistant(assistant) => {
231 let mut parts = Vec::new();
232 for block in &assistant.content {
233 match block {
234 ContentBlock::Text { text } => {
235 parts.push(truncate_for_display(text, 3000));
236 }
237 ContentBlock::ToolCall {
238 name, arguments, ..
239 } => {
240 let args_str = serde_json::to_string(arguments).unwrap_or_default();
241 parts.push(format!(
242 "[tool call: {}({})]",
243 name,
244 truncate_for_display(&args_str, 500)
245 ));
246 }
247 ContentBlock::Thinking { text } => {
248 parts.push(format!("[thinking: {}]", truncate_for_display(text, 500)));
249 }
250 _ => {}
251 }
252 }
253 serialized.push_str(&format!("[ASSISTANT]: {}\n\n", parts.join("\n")));
254 }
255 Message::ToolResult(result) => {
256 let text: String = result
257 .content
258 .iter()
259 .filter_map(|b| match b {
260 ContentBlock::Text { text } => Some(text.as_str()),
261 _ => None,
262 })
263 .collect::<Vec<_>>()
264 .join("\n");
265 serialized.push_str(&format!(
266 "[TOOL RESULT {}]: {}\n\n",
267 result.tool_name,
268 truncate_for_display(&text, 3000)
269 ));
270 }
271 }
272 }
273
274 format!(
275 "Create a structured handoff summary for a later assistant that will \
276 continue this conversation after earlier turns are compacted.\n\n\
277 TURNS TO SUMMARIZE:\n{serialized}\n\
278 Use this structure:\n\n\
279 ## Goal\n[What the user is trying to accomplish]\n\n\
280 ## Completed Work\n[Work already done — include file paths, commands run, results]\n\n\
281 ## Current State\n[State of the codebase/task right now]\n\n\
282 ## Key Decisions\n[Important technical decisions and why]\n\n\
283 ## Relevant Files\n[Files read, modified, or created — with brief note on each]\n\n\
284 ## Errors / Warnings\n[Errors encountered and how they were resolved]\n\n\
285 ## Next Step\n[What needs to happen next]\n\n\
286 Be specific — include file paths, command outputs, error messages, and \
287 concrete values. Do not include any preamble or prefix. Write only the \
288 summary body."
289 )
290}
291
292pub const DEFAULT_KEEP_RECENT_GROUPS: usize = 4;
296
297#[derive(Debug, Clone)]
299pub struct CompactionResult {
300 pub summary: String,
301 pub first_kept_id: String,
302 pub tokens_before: u32,
303 pub tokens_after: u32,
304 pub compaction_entry_id: String,
305}
306
307pub fn execute_manual_compaction<F>(
320 session: &mut SessionManager,
321 keep_recent_groups: usize,
322 generate_summary: F,
323) -> Result<Option<CompactionResult>>
324where
325 F: FnOnce(&str) -> Option<String>,
326{
327 let raw_messages = session.get_active_messages();
328 let tokens_before = raw_messages
329 .iter()
330 .map(|m| {
331 let json = serde_json::to_string(m).unwrap_or_default();
332 estimate_tokens(&json)
333 })
334 .sum();
335
336 let prepared = prepare_messages_for_compaction(&raw_messages, keep_recent_groups);
337 if !prepared.should_compact() {
338 return Ok(None);
339 }
340
341 let prompt = build_summary_prompt(&prepared.summary_input);
343
344 let summary_body = generate_summary(&prompt).unwrap_or_else(|| {
346 prepared
348 .summary_input
349 .iter()
350 .filter_map(|m| match m {
351 Message::User(user) => user.content.iter().find_map(|b| match b {
352 ContentBlock::Text { text } => Some(text.clone()),
353 _ => None,
354 }),
355 _ => None,
356 })
357 .collect::<Vec<_>>()
358 .join("\n")
359 });
360
361 let summary_text = format!("{COMPACTION_SUMMARY_PREFIX}{summary_body}");
362
363 let branch = session.get_branch();
366 let first_kept_id = if prepared.preserved_tail_start < raw_messages.len() {
367 let mut msg_idx = 0usize;
370 let mut found_id = None;
371 for entry in &branch {
372 if let SessionEntry::Message { id, .. } = entry {
373 if msg_idx == prepared.preserved_tail_start {
374 found_id = Some(id.clone());
375 break;
376 }
377 msg_idx += 1;
378 }
379 }
380 found_id.unwrap_or_default()
381 } else {
382 String::new()
383 };
384
385 let tokens_after: u32 = {
386 let summary_tokens = estimate_tokens(&summary_text);
387 let tail_tokens: u32 = prepared
388 .preserved_tail
389 .iter()
390 .map(|m| {
391 let json = serde_json::to_string(m).unwrap_or_default();
392 estimate_tokens(&json)
393 })
394 .sum();
395 summary_tokens + tail_tokens
396 };
397
398 let compaction_entry_id = uuid::Uuid::new_v4().to_string();
399 session.append(SessionEntry::Compaction {
400 id: compaction_entry_id.clone(),
401 parent_id: None,
402 summary: summary_text.clone(),
403 first_kept_id: first_kept_id.clone(),
404 tokens_before,
405 tokens_after,
406 })?;
407
408 Ok(Some(CompactionResult {
409 summary: summary_text,
410 first_kept_id,
411 tokens_before,
412 tokens_after,
413 compaction_entry_id,
414 }))
415}
416
417pub fn execute_compaction_with_retry<F>(
425 session: &mut SessionManager,
426 mut keep_recent_groups: usize,
427 max_retries: u32,
428 mut generate_summary: F,
429) -> Result<Option<CompactionResult>>
430where
431 F: FnMut(&str) -> Option<String>,
432{
433 for attempt in 0..=max_retries {
434 let result = execute_manual_compaction(session, keep_recent_groups, &mut generate_summary)?;
435 match result {
436 Some(r) => return Ok(Some(r)),
437 None if attempt < max_retries => {
438 keep_recent_groups += 2;
439 }
440 None => return Ok(None),
441 }
442 }
443 Ok(None)
444}
445
446#[cfg(test)]
447mod tests {
448 use super::*;
449 use crate::session::SessionManager;
450 use imp_llm::{AssistantMessage, StopReason, ToolResultMessage};
451
452 #[test]
453 fn compaction_strategy_defaults_to_local() {
454 let caps = CompactionCapabilities {
455 provider_id: "anthropic",
456 model_id: "claude-sonnet",
457 allow_provider_native: false,
458 };
459 assert_eq!(select_compaction_strategy(&caps), CompactionStrategy::Local);
460 }
461
462 #[test]
463 fn compaction_strategy_exposes_provider_native_seam_for_supported_providers() {
464 let openai = CompactionCapabilities {
465 provider_id: "openai-codex",
466 model_id: "gpt-5-codex",
467 allow_provider_native: true,
468 };
469 assert_eq!(
470 select_compaction_strategy(&openai),
471 CompactionStrategy::ProviderNative
472 );
473
474 let anthropic = CompactionCapabilities {
475 provider_id: "anthropic",
476 model_id: "claude-sonnet-4-5",
477 allow_provider_native: true,
478 };
479 assert_eq!(
480 select_compaction_strategy(&anthropic),
481 CompactionStrategy::ProviderNative
482 );
483 }
484
485 #[test]
486 fn compaction_strategy_keeps_unknown_providers_local() {
487 let caps = CompactionCapabilities {
488 provider_id: "deepseek",
489 model_id: "deepseek-chat",
490 allow_provider_native: true,
491 };
492 assert_eq!(select_compaction_strategy(&caps), CompactionStrategy::Local);
493 }
494
495 fn make_user(text: &str) -> Message {
496 Message::user(text)
497 }
498
499 fn make_assistant_tool_call(
500 call_id: &str,
501 tool_name: &str,
502 args: serde_json::Value,
503 ) -> Message {
504 Message::Assistant(AssistantMessage {
505 content: vec![ContentBlock::ToolCall {
506 id: call_id.into(),
507 name: tool_name.into(),
508 arguments: args,
509 }],
510 usage: None,
511 stop_reason: StopReason::ToolUse,
512 timestamp: 1000,
513 })
514 }
515
516 fn make_assistant_text(text: &str) -> Message {
517 Message::Assistant(AssistantMessage {
518 content: vec![ContentBlock::Text { text: text.into() }],
519 usage: None,
520 stop_reason: StopReason::EndTurn,
521 timestamp: 1000,
522 })
523 }
524
525 fn make_tool_result(call_id: &str, tool_name: &str, output: &str) -> Message {
526 Message::ToolResult(ToolResultMessage {
527 tool_call_id: call_id.into(),
528 tool_name: tool_name.into(),
529 content: vec![ContentBlock::Text {
530 text: output.into(),
531 }],
532 is_error: false,
533 details: serde_json::Value::Null,
534 timestamp: 1000,
535 })
536 }
537
538 #[test]
539 fn context_compaction_groups_pull_in_prompting_user_messages() {
540 let messages = vec![
541 make_user("first prompt"),
542 make_assistant_text("first answer"),
543 make_user("second prompt"),
544 make_assistant_tool_call("c1", "read", serde_json::json!({"path": "src/main.rs"})),
545 make_tool_result("c1", "read", "fn main() {}"),
546 make_assistant_text("done"),
547 ];
548
549 let groups = assistant_action_groups(&messages);
550 assert_eq!(groups.len(), 3);
551 assert_eq!(groups[0].range, 0..3);
552 assert_eq!(groups[1].range, 2..5);
553 assert_eq!(groups[2].range, 5..6);
554 }
555
556 #[test]
557 fn context_compaction_prepare_keeps_recent_groups_verbatim() {
558 let messages = vec![
559 make_user("prompt 1"),
560 make_assistant_text("answer 1"),
561 make_user("prompt 2"),
562 make_assistant_text("answer 2"),
563 make_user("prompt 3"),
564 make_assistant_text("answer 3"),
565 ];
566
567 let prepared = prepare_messages_for_compaction(&messages, 2);
568 assert!(prepared.should_compact());
569 assert_eq!(prepared.preserved_tail_start, 2);
570 assert_eq!(prepared.summary_input.len(), 2);
571 assert_eq!(prepared.preserved_tail.len(), 4);
572 match &prepared.preserved_tail[0] {
573 Message::User(user) => match user.content.as_slice() {
574 [ContentBlock::Text { text }] => assert_eq!(text, "prompt 2"),
575 other => panic!("unexpected content: {other:?}"),
576 },
577 other => panic!("unexpected message: {other:?}"),
578 }
579 }
580
581 #[test]
582 fn context_compaction_prepare_shrinks_tool_heavy_prefix() {
583 let large_output = "x".repeat(4000);
584 let messages = vec![
585 make_user("prompt 1"),
586 make_assistant_tool_call("c1", "grep", serde_json::json!({"pattern": "foo"})),
587 make_tool_result("c1", "grep", &large_output),
588 make_user("prompt 2"),
589 make_assistant_text("answer 2"),
590 ];
591
592 let original_bytes: usize = serde_json::to_string(&messages[..3]).unwrap().len();
593 let prepared = prepare_messages_for_compaction(&messages, 1);
594 let shrunk_bytes: usize = serde_json::to_string(&prepared.summary_input)
595 .unwrap()
596 .len();
597
598 assert_eq!(prepared.shrunk_tool_results, 1);
599 assert!(shrunk_bytes < original_bytes);
600 let tool_result_text = match &prepared.summary_input[2] {
601 Message::ToolResult(result) => match result.content.as_slice() {
602 [ContentBlock::Text { text }] => text.clone(),
603 other => panic!("unexpected tool result content: {other:?}"),
604 },
605 other => panic!("unexpected summary input message: {other:?}"),
606 };
607 assert!(tool_result_text.starts_with("[Output omitted"));
608 assert!(tool_result_text.contains("grep"));
609 }
610
611 #[test]
612 fn context_compaction_prepare_sanitizes_unpaired_messages() {
613 let messages = vec![
614 make_user("prompt 1"),
615 make_assistant_tool_call("c1", "grep", serde_json::json!({"pattern": "foo"})),
616 make_user("prompt 2"),
617 make_assistant_text("answer 2"),
618 ];
619
620 let prepared = prepare_messages_for_compaction(&messages, 1);
621 assert_eq!(prepared.summary_input.len(), 1);
622 match &prepared.summary_input[0] {
623 Message::User(user) => match user.content.as_slice() {
624 [ContentBlock::Text { text }] => assert_eq!(text, "prompt 1"),
625 other => panic!("unexpected content: {other:?}"),
626 },
627 other => panic!("unexpected summary input: {other:?}"),
628 }
629 }
630
631 #[test]
632 fn context_compaction_prepare_noops_when_history_is_short() {
633 let messages = vec![make_user("prompt"), make_assistant_text("answer")];
634 let prepared = prepare_messages_for_compaction(&messages, 4);
635 assert!(!prepared.should_compact());
636 assert!(prepared.summary_input.is_empty());
637 assert_eq!(prepared.preserved_tail.len(), 2);
638 }
639
640 fn make_session_entry(id: &str, msg: Message) -> SessionEntry {
643 SessionEntry::Message {
644 id: id.into(),
645 parent_id: None,
646 message: msg,
647 }
648 }
649
650 #[test]
651 fn compact_executor_persists_compaction_entry_and_changes_active_history() {
652 let mut mgr = SessionManager::in_memory();
653 mgr.append(make_session_entry("u1", make_user("first request")))
654 .unwrap();
655 mgr.append(make_session_entry(
656 "a1",
657 make_assistant_text("first answer"),
658 ))
659 .unwrap();
660 mgr.append(make_session_entry("u2", make_user("second request")))
661 .unwrap();
662 mgr.append(make_session_entry(
663 "a2",
664 make_assistant_text("second answer"),
665 ))
666 .unwrap();
667 mgr.append(make_session_entry("u3", make_user("third request")))
668 .unwrap();
669 mgr.append(make_session_entry(
670 "a3",
671 make_assistant_text("third answer"),
672 ))
673 .unwrap();
674
675 let raw_before = mgr.get_messages().len();
676 assert_eq!(raw_before, 6);
677
678 let result = execute_manual_compaction(&mut mgr, 2, |_prompt| {
679 Some("## Goal\nTest compaction".into())
680 })
681 .unwrap();
682
683 assert!(result.is_some());
684 let result = result.unwrap();
685 assert!(result.summary.contains("CONTEXT COMPACTION"));
686 assert!(result.summary.contains("Test compaction"));
687 assert!(result.tokens_before > 0);
688 assert!(result.tokens_after > 0);
689 assert!(result.tokens_after <= result.tokens_before);
690
691 let raw_after = mgr.get_messages().len();
693 assert_eq!(raw_after, raw_before);
694
695 let active = mgr.get_active_messages();
697 assert!(active.len() < raw_before);
698 match &active[0] {
700 Message::User(user) => match user.content.as_slice() {
701 [ContentBlock::Text { text }] => {
702 assert!(text.contains("CONTEXT COMPACTION"));
703 }
704 other => panic!("unexpected content: {other:?}"),
705 },
706 other => panic!("unexpected message: {other:?}"),
707 }
708 }
709
710 #[test]
711 fn compact_executor_returns_none_for_short_history() {
712 let mut mgr = SessionManager::in_memory();
713 mgr.append(make_session_entry("u1", make_user("only prompt")))
714 .unwrap();
715 mgr.append(make_session_entry("a1", make_assistant_text("only answer")))
716 .unwrap();
717
718 let result = execute_manual_compaction(&mut mgr, 4, |_| Some("summary".into())).unwrap();
719 assert!(result.is_none());
720 }
721
722 #[test]
723 fn compact_executor_uses_fallback_when_summarizer_returns_none() {
724 let mut mgr = SessionManager::in_memory();
725 for i in 0..6 {
726 let uid = format!("u{i}");
727 let aid = format!("a{i}");
728 mgr.append(make_session_entry(&uid, make_user(&format!("prompt {i}"))))
729 .unwrap();
730 mgr.append(make_session_entry(
731 &aid,
732 make_assistant_text(&format!("answer {i}")),
733 ))
734 .unwrap();
735 }
736
737 let result = execute_manual_compaction(&mut mgr, 2, |_prompt| None).unwrap();
738
739 assert!(result.is_some());
740 let result = result.unwrap();
741 assert!(result.summary.contains("prompt 0"));
743 }
744}