Skip to main content

imp_core/
compaction.rs

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/// A grouped assistant-action slice of message history.
14///
15/// Each group starts at an assistant message and expands backward over any
16/// immediately preceding user messages so preserved tails keep the user prompt
17/// that led into the preserved assistant work when possible.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct AssistantActionGroup {
20    pub range: Range<usize>,
21}
22
23/// Strategy selection for compaction execution.
24///
25/// `Local` is the canonical path and remains the default for correctness.
26/// `ProviderNative` is an optional optimization seam for future support of
27/// remote/provider-managed compaction or context-editing APIs.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum CompactionStrategy {
30    Local,
31    ProviderNative,
32}
33
34/// Capability descriptor used to decide whether a provider-specific compaction
35/// optimization may be attempted.
36#[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
43/// Select the preferred compaction strategy for a provider/model pair.
44///
45/// For now this always falls back to `Local` unless provider-native compaction
46/// is explicitly allowed and the provider matches a known future optimization
47/// seam. This keeps the local/manual contract canonical while avoiding TUI- or
48/// provider-specific branching throughout the rest of the codebase.
49pub 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/// Output of the deterministic pre-summary compaction-prep pipeline.
61#[derive(Debug, Clone)]
62pub struct PreparedCompaction {
63    /// Older history reduced into a summarizer-safe form.
64    pub summary_input: Vec<Message>,
65    /// Recent working context preserved verbatim (after invariant sanitization).
66    pub preserved_tail: Vec<Message>,
67    /// Index in the original message list where the preserved tail begins.
68    pub preserved_tail_start: usize,
69    /// Assistant-action groups discovered in the original message list.
70    pub groups: Vec<AssistantActionGroup>,
71    /// Number of tool result messages whose bodies were replaced with compact
72    /// placeholders inside `summary_input`.
73    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
82/// Partition a message list into assistant-action groups.
83///
84/// Groups are defined by assistant message boundaries. For each assistant
85/// message, we pull the group start backward across any directly preceding user
86/// messages so the user's prompt is preserved with the assistant work when that
87/// work survives compaction.
88pub 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
114/// Replace tool-result bodies with lightweight placeholders while keeping tool
115/// name, truncated arguments, and byte counts for debugging continuity.
116pub 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
158/// Deterministically prepare history for a later summary-generation step.
159///
160/// The returned `summary_input` is safe to send to a summarizer: older history
161/// is grouped by assistant-action ranges, tool-heavy observations are shrunk,
162/// and message-level tool-call/result invariants are sanitized. The preserved
163/// tail keeps the last `keep_recent_groups` assistant-action groups verbatim.
164pub 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
202// ── Compaction summary prompt ──────────────────────────────────────────────
203
204/// Prefix prepended to the summary in the compaction entry so that later
205/// context assembly can mark it clearly for the model.
206pub 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
210/// Build the structured summarization prompt fed to the LLM.
211fn 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
292// ── Compaction executor ───────────────────────────────────────────────────
293
294/// Default number of recent assistant-action groups to preserve verbatim.
295pub const DEFAULT_KEEP_RECENT_GROUPS: usize = 4;
296
297/// Result of a successful compaction.
298#[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
307/// Execute a manual compaction on the current branch of a session.
308///
309/// This is the main entry point for `/compact`. It:
310/// 1. Prepares the history via the safe deterministic pipeline.
311/// 2. Generates a structured summary of the older prefix.
312/// 3. Persists a `SessionEntry::Compaction` that partitions the branch.
313///
314/// The `generate_summary` closure receives the serialized summarization
315/// prompt and returns the LLM-generated summary text. This keeps the
316/// compaction module independent of specific LLM wiring.
317///
318/// Returns `None` if there is not enough history to compact.
319pub 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    // Build the summarization prompt from the shrunk older prefix.
342    let prompt = build_summary_prompt(&prepared.summary_input);
343
344    // Call the provided summarizer. If it returns None, use a fallback.
345    let summary_body = generate_summary(&prompt).unwrap_or_else(|| {
346        // Deterministic fallback: concatenate user messages from the prefix.
347        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    // Find the first kept message id from the preserved tail.
364    // We need to locate the id in the raw session branch.
365    let branch = session.get_branch();
366    let first_kept_id = if prepared.preserved_tail_start < raw_messages.len() {
367        // Walk the branch to find the entry that corresponds to the preserved
368        // tail start index in the active messages.
369        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
417// ── Convenience: compaction with overflow retry ───────────────────────────
418
419/// Execute manual compaction with overflow retry.
420///
421/// If the `generate_summary` closure returns `None` (indicating the summarizer
422/// could not handle the input), this function increases `keep_recent_groups` by
423/// 2 each retry, shrinking the summarization target, up to `max_retries` times.
424pub 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    // ── Executor tests ──────────────────────────────────────────────────
641
642    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        // Raw messages are still preserved.
692        let raw_after = mgr.get_messages().len();
693        assert_eq!(raw_after, raw_before);
694
695        // Active messages should now be: summary + preserved tail.
696        let active = mgr.get_active_messages();
697        assert!(active.len() < raw_before);
698        // First active message should be the summary.
699        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        // The fallback concatenates user messages from the summarized prefix.
742        assert!(result.summary.contains("prompt 0"));
743    }
744}