Skip to main content

pi/
compaction.rs

1//! Context compaction for long sessions.
2//!
3//! This module ports the pi-mono compaction algorithm:
4//! - Estimate context usage and choose a cut point that keeps recent context
5//! - Summarize the discarded portion with the LLM (iteratively updating prior summaries)
6//! - Record a `compaction` session entry containing the summary and cut point
7//! - When building provider context, the session inserts the summary before the kept region
8//!   and omits older messages.
9
10use crate::error::{Error, Result};
11use crate::model::{
12    AssistantMessage, ContentBlock, Message, StopReason, TextContent, ThinkingLevel, ToolCall,
13    Usage, UserContent, UserMessage,
14};
15use crate::provider::{Context, Provider, StreamOptions};
16use crate::session::{SessionEntry, SessionMessage, session_message_to_model};
17use futures::StreamExt;
18use serde::Serialize;
19use serde_json::Value;
20use std::collections::{HashMap, HashSet};
21use std::fmt::Write as _;
22use std::sync::Arc;
23
24/// Approximate characters per token for English text with GPT-family tokenizers.
25/// Intentionally conservative (overestimates tokens) to avoid exceeding context windows.
26/// Set to 3 to safely account for code/symbol-heavy content which is denser than prose.
27const CHARS_PER_TOKEN_ESTIMATE: usize = 3;
28
29/// Estimated tokens for an image content block (~1200 tokens).
30const IMAGE_TOKEN_ESTIMATE: usize = 1200;
31
32/// Character-equivalent estimate for an image (IMAGE_TOKEN_ESTIMATE * CHARS_PER_TOKEN_ESTIMATE).
33const IMAGE_CHAR_ESTIMATE: usize = IMAGE_TOKEN_ESTIMATE * CHARS_PER_TOKEN_ESTIMATE;
34
35/// Count the serialized JSON byte length of a [`Value`] without allocating a `String`.
36///
37/// Uses `serde_json::to_writer` with a sink that only counts bytes – this gives the
38/// exact same length as `serde_json::to_string(&v).len()` at zero heap cost.
39fn json_byte_len(value: &Value) -> usize {
40    struct Counter(usize);
41    impl std::io::Write for Counter {
42        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
43            self.0 += buf.len();
44            Ok(buf.len())
45        }
46        fn flush(&mut self) -> std::io::Result<()> {
47            Ok(())
48        }
49    }
50    let mut c = Counter(0);
51    if serde_json::to_writer(&mut c, value).is_err() {
52        // Fallback or partial count on error (e.g. recursion limit)
53    }
54    c.0
55}
56
57// =============================================================================
58// Public types
59// =============================================================================
60
61#[derive(Debug, Clone)]
62pub struct ResolvedCompactionSettings {
63    pub enabled: bool,
64    pub context_window_tokens: u32,
65    pub reserve_tokens: u32,
66    pub keep_recent_tokens: u32,
67}
68
69impl Default for ResolvedCompactionSettings {
70    fn default() -> Self {
71        let context_window_tokens: u32 = 200_000;
72        Self {
73            enabled: true,
74            context_window_tokens,
75            // ~8% of context window
76            reserve_tokens: 16_384,
77            // 10% of context window
78            keep_recent_tokens: 20_000,
79        }
80    }
81}
82
83/// Details stored in `CompactionEntry.details` for cumulative file tracking.
84#[derive(Debug, Clone, Serialize)]
85#[serde(rename_all = "camelCase")]
86pub struct CompactionDetails {
87    pub read_files: Vec<String>,
88    pub modified_files: Vec<String>,
89}
90
91#[derive(Debug, Clone, Serialize)]
92#[serde(rename_all = "camelCase")]
93pub struct CompactionResult {
94    pub summary: String,
95    pub first_kept_entry_id: String,
96    pub tokens_before: u64,
97    pub details: CompactionDetails,
98}
99
100#[derive(Debug, Clone)]
101pub struct CompactionPreparation {
102    pub first_kept_entry_id: String,
103    pub messages_to_summarize: Vec<SessionMessage>,
104    pub turn_prefix_messages: Vec<SessionMessage>,
105    pub is_split_turn: bool,
106    pub tokens_before: u64,
107    pub previous_summary: Option<String>,
108    pub file_ops: FileOperations,
109    pub settings: ResolvedCompactionSettings,
110}
111
112// =============================================================================
113// File op tracking (read/write/edit)
114// =============================================================================
115
116#[derive(Debug, Clone, Default)]
117pub struct FileOperations {
118    read: HashSet<String>,
119    written: HashSet<String>,
120    edited: HashSet<String>,
121}
122
123impl FileOperations {
124    pub fn read_files(&self) -> impl Iterator<Item = &str> {
125        self.read.iter().map(String::as_str)
126    }
127}
128
129fn build_tool_status_map(messages: &[SessionMessage]) -> HashMap<String, bool> {
130    let mut status = HashMap::new();
131    for msg in messages {
132        if let SessionMessage::ToolResult {
133            tool_call_id,
134            is_error,
135            ..
136        } = msg
137        {
138            status.insert(tool_call_id.clone(), !*is_error);
139        }
140    }
141    status
142}
143
144fn extract_file_ops_from_message(
145    message: &SessionMessage,
146    file_ops: &mut FileOperations,
147    tool_status: &HashMap<String, bool>,
148) {
149    let SessionMessage::Assistant { message } = message else {
150        return;
151    };
152
153    for block in &message.content {
154        let ContentBlock::ToolCall(ToolCall {
155            id,
156            name,
157            arguments,
158            ..
159        }) = block
160        else {
161            continue;
162        };
163
164        // Only track successful tool calls.
165        if !tool_status.get(id).copied().unwrap_or(false) {
166            continue;
167        }
168
169        let Some(path) = arguments.get("path").and_then(Value::as_str) else {
170            continue;
171        };
172
173        match name.as_str() {
174            "read" | "grep" | "find" | "ls" => {
175                file_ops.read.insert(path.to_string());
176            }
177            "write" => {
178                file_ops.written.insert(path.to_string());
179            }
180            "edit" => {
181                file_ops.edited.insert(path.to_string());
182            }
183            _ => {}
184        }
185    }
186}
187
188fn compute_file_lists(file_ops: &FileOperations) -> (Vec<String>, Vec<String>) {
189    let modified: HashSet<&String> = file_ops
190        .edited
191        .iter()
192        .chain(file_ops.written.iter())
193        .collect();
194
195    let mut read_only = file_ops
196        .read
197        .iter()
198        .filter(|f| !modified.contains(f))
199        .cloned()
200        .collect::<Vec<_>>();
201    read_only.sort();
202
203    let mut modified_files = modified.into_iter().cloned().collect::<Vec<_>>();
204    modified_files.sort();
205
206    (read_only, modified_files)
207}
208
209fn write_escaped_file_list(out: &mut String, tag: &str, files: &[String]) {
210    out.push('<');
211    out.push_str(tag);
212    out.push_str(">\n");
213    for (i, file) in files.iter().enumerate() {
214        if i > 0 {
215            out.push('\n');
216        }
217        // Inline escape: replace < and > in file paths
218        for ch in file.chars() {
219            match ch {
220                '<' => out.push_str("&lt;"),
221                '>' => out.push_str("&gt;"),
222                _ => out.push(ch),
223            }
224        }
225    }
226    out.push_str("\n</");
227    out.push_str(tag);
228    out.push('>');
229}
230
231fn format_file_operations(read_files: &[String], modified_files: &[String]) -> String {
232    if read_files.is_empty() && modified_files.is_empty() {
233        return String::new();
234    }
235
236    let mut out = String::from("\n\n");
237    if !read_files.is_empty() {
238        write_escaped_file_list(&mut out, "read-files", read_files);
239    }
240    if !modified_files.is_empty() {
241        if !read_files.is_empty() {
242            out.push_str("\n\n");
243        }
244        write_escaped_file_list(&mut out, "modified-files", modified_files);
245    }
246    out
247}
248
249// =============================================================================
250// Token estimation
251// =============================================================================
252
253const fn calculate_context_tokens(usage: &Usage) -> u64 {
254    if usage.total_tokens > 0 {
255        usage.total_tokens
256    } else {
257        usage.input + usage.output
258    }
259}
260
261const fn get_assistant_usage(message: &SessionMessage) -> Option<&Usage> {
262    let SessionMessage::Assistant { message } = message else {
263        return None;
264    };
265
266    if matches!(message.stop_reason, StopReason::Aborted | StopReason::Error) {
267        return None;
268    }
269
270    Some(&message.usage)
271}
272
273#[derive(Debug, Clone, Copy)]
274struct ContextUsageEstimate {
275    tokens: u64,
276    last_usage_index: Option<usize>,
277}
278
279fn estimate_context_tokens(messages: &[SessionMessage]) -> ContextUsageEstimate {
280    let mut last_usage: Option<(&Usage, usize)> = None;
281    for (idx, msg) in messages.iter().enumerate().rev() {
282        if let Some(usage) = get_assistant_usage(msg) {
283            last_usage = Some((usage, idx));
284            break;
285        }
286    }
287
288    let Some((usage, usage_index)) = last_usage else {
289        let total = messages.iter().map(estimate_tokens).sum();
290        return ContextUsageEstimate {
291            tokens: total,
292            last_usage_index: None,
293        };
294    };
295
296    let usage_tokens = calculate_context_tokens(usage);
297    let trailing_tokens = messages[usage_index + 1..]
298        .iter()
299        .map(estimate_tokens)
300        .sum::<u64>();
301    ContextUsageEstimate {
302        tokens: usage_tokens + trailing_tokens,
303        last_usage_index: Some(usage_index),
304    }
305}
306
307fn should_compact(
308    context_tokens: u64,
309    context_window: u32,
310    settings: &ResolvedCompactionSettings,
311) -> bool {
312    if !settings.enabled {
313        return false;
314    }
315    let reserve = u64::from(settings.reserve_tokens);
316    let window = u64::from(context_window);
317    context_tokens > window.saturating_sub(reserve)
318}
319
320fn estimate_tokens(message: &SessionMessage) -> u64 {
321    let mut chars: usize = 0;
322
323    match message {
324        SessionMessage::User { content, .. } => match content {
325            UserContent::Text(text) => chars = text.len(),
326            UserContent::Blocks(blocks) => {
327                for block in blocks {
328                    match block {
329                        ContentBlock::Text(text) => chars += text.text.len(),
330                        ContentBlock::Image(_) => chars += IMAGE_CHAR_ESTIMATE,
331                        ContentBlock::Thinking(thinking) => chars += thinking.thinking.len(),
332                        ContentBlock::ToolCall(call) => {
333                            chars += call.name.len();
334                            chars += json_byte_len(&call.arguments);
335                        }
336                    }
337                }
338            }
339        },
340        SessionMessage::Assistant { message } => {
341            for block in &message.content {
342                match block {
343                    ContentBlock::Text(text) => chars += text.text.len(),
344                    ContentBlock::Thinking(thinking) => chars += thinking.thinking.len(),
345                    ContentBlock::Image(_) => chars += IMAGE_CHAR_ESTIMATE,
346                    ContentBlock::ToolCall(call) => {
347                        chars += call.name.len();
348                        chars += json_byte_len(&call.arguments);
349                    }
350                }
351            }
352        }
353        SessionMessage::ToolResult { content, .. } => {
354            for block in content {
355                match block {
356                    ContentBlock::Text(text) => chars += text.text.len(),
357                    ContentBlock::Thinking(thinking) => chars += thinking.thinking.len(),
358                    ContentBlock::Image(_) => chars += IMAGE_CHAR_ESTIMATE,
359                    ContentBlock::ToolCall(call) => {
360                        chars += call.name.len();
361                        chars += json_byte_len(&call.arguments);
362                    }
363                }
364            }
365        }
366        SessionMessage::Custom { content, .. } => chars = content.len(),
367        SessionMessage::BashExecution {
368            command, output, ..
369        } => chars = command.len() + output.len(),
370        SessionMessage::BranchSummary { summary, .. }
371        | SessionMessage::CompactionSummary { summary, .. } => chars = summary.len(),
372    }
373
374    u64::try_from(chars.div_ceil(CHARS_PER_TOKEN_ESTIMATE)).unwrap_or(u64::MAX)
375}
376
377// =============================================================================
378// Cut point detection
379// =============================================================================
380
381#[derive(Debug, Clone, Copy)]
382struct CutPointResult {
383    first_kept_entry_index: usize,
384    turn_start_index: Option<usize>,
385    is_split_turn: bool,
386}
387
388fn message_from_entry(entry: &SessionEntry) -> Option<SessionMessage> {
389    match entry {
390        SessionEntry::Message(msg_entry) => Some(msg_entry.message.clone()),
391        SessionEntry::BranchSummary(summary) => Some(SessionMessage::BranchSummary {
392            summary: summary.summary.clone(),
393            from_id: summary.from_id.clone(),
394        }),
395        SessionEntry::Compaction(compaction) => Some(SessionMessage::CompactionSummary {
396            summary: compaction.summary.clone(),
397            tokens_before: compaction.tokens_before,
398        }),
399        _ => None,
400    }
401}
402
403const fn entry_is_message_like(entry: &SessionEntry) -> bool {
404    matches!(
405        entry,
406        SessionEntry::Message(_) | SessionEntry::BranchSummary(_)
407    )
408}
409
410const fn entry_is_compaction_boundary(entry: &SessionEntry) -> bool {
411    matches!(entry, SessionEntry::Compaction(_))
412}
413
414fn find_valid_cut_points(
415    entries: &[SessionEntry],
416    start_index: usize,
417    end_index: usize,
418) -> Vec<usize> {
419    let mut cut_points = Vec::new();
420    for (idx, entry) in entries.iter().enumerate().take(end_index).skip(start_index) {
421        match entry {
422            SessionEntry::Message(msg_entry) => match msg_entry.message {
423                SessionMessage::ToolResult { .. } => {}
424                _ => cut_points.push(idx),
425            },
426            SessionEntry::BranchSummary(_) => cut_points.push(idx),
427            _ => {}
428        }
429    }
430    cut_points
431}
432
433fn entry_has_tool_calls(entry: &SessionEntry) -> bool {
434    matches!(
435        entry,
436        SessionEntry::Message(msg) if matches!(
437            &msg.message,
438            SessionMessage::Assistant { message } if message.content.iter().any(|b| matches!(b, ContentBlock::ToolCall(_)))
439        )
440    )
441}
442
443const fn is_user_turn_start(entry: &SessionEntry) -> bool {
444    match entry {
445        SessionEntry::BranchSummary(_) => true,
446        SessionEntry::Message(msg_entry) => matches!(
447            msg_entry.message,
448            SessionMessage::User { .. } | SessionMessage::BashExecution { .. }
449        ),
450        _ => false,
451    }
452}
453
454fn find_turn_start_index(
455    entries: &[SessionEntry],
456    entry_index: usize,
457    start_index: usize,
458) -> Option<usize> {
459    (start_index..=entry_index)
460        .rev()
461        .find(|&idx| is_user_turn_start(&entries[idx]))
462}
463
464fn find_cut_point(
465    entries: &[SessionEntry],
466    start_index: usize,
467    end_index: usize,
468    keep_recent_tokens: u32,
469) -> CutPointResult {
470    let cut_points = find_valid_cut_points(entries, start_index, end_index);
471    if cut_points.is_empty() {
472        return CutPointResult {
473            first_kept_entry_index: start_index,
474            turn_start_index: None,
475            is_split_turn: false,
476        };
477    }
478
479    let mut accumulated_tokens: u64 = 0;
480    let mut cut_index = cut_points[0];
481
482    for i in (start_index..end_index).rev() {
483        let entry = &entries[i];
484        let SessionEntry::Message(msg_entry) = entry else {
485            continue;
486        };
487        accumulated_tokens = accumulated_tokens.saturating_add(estimate_tokens(&msg_entry.message));
488
489        if accumulated_tokens >= u64::from(keep_recent_tokens) {
490            // Binary search: find the largest cut point <= i.
491            // `partition_point` returns the index of the first element > i,
492            // so idx-1 is the largest element <= i (if any).
493            let pos = cut_points.partition_point(|&cp| cp <= i);
494            if pos > 0 {
495                cut_index = cut_points[pos - 1];
496            }
497            // else: no cut point <= i, keep the fallback (cut_points[0])
498            break;
499        }
500    }
501
502    while cut_index > start_index {
503        let prev = &entries[cut_index - 1];
504        if entry_is_compaction_boundary(prev) {
505            break;
506        }
507        if entry_is_message_like(prev) {
508            break;
509        }
510        cut_index -= 1;
511    }
512
513    let is_user_message = is_user_turn_start(&entries[cut_index]);
514    let turn_start_index = if is_user_message {
515        None
516    } else {
517        find_turn_start_index(entries, cut_index, start_index)
518    };
519
520    CutPointResult {
521        first_kept_entry_index: cut_index,
522        turn_start_index,
523        is_split_turn: !is_user_message && turn_start_index.is_some(),
524    }
525}
526
527// =============================================================================
528// Summarization prompts
529// =============================================================================
530
531const SUMMARIZATION_SYSTEM_PROMPT: &str = "You are a context summarization assistant. Your task is to read a conversation between a user and an AI coding assistant, then produce a structured summary following the exact format specified.\n\nDo NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary.";
532
533const SUMMARIZATION_PROMPT: &str = "The messages above are a conversation to summarize. Create a structured context checkpoint summary that another LLM will use to continue the work.\n\nUse this EXACT format:\n\n## Goal\n[What is the user trying to accomplish? Can be multiple items if the session covers different tasks.]\n\n## Constraints & Preferences\n- [Any constraints, preferences, or requirements mentioned by user]\n- [Or \"(none)\" if none were mentioned]\n\n## Progress\n### Done\n- [x] [Completed tasks/changes]\n\n### In Progress\n- [ ] [Current work]\n\n### Blocked\n- [Issues preventing progress, if any]\n\n## Key Decisions\n- **[Decision]**: [Brief rationale]\n\n## Next Steps\n1. [Ordered list of what should happen next]\n\n## Critical Context\n- [Any data, examples, or references needed to continue]\n- [Or \"(none)\" if not applicable]\n\nKeep each section concise. Preserve exact file paths, function names, and error messages.";
534
535const UPDATE_SUMMARIZATION_PROMPT: &str = "The messages above are NEW conversation messages to incorporate into the existing summary provided in <previous-summary> tags.\n\nUpdate the existing structured summary with new information. RULES:\n- PRESERVE all existing information from the previous summary\n- ADD new progress, decisions, and context from the new messages\n- UPDATE the Progress section: move items from \"In Progress\" to \"Done\" when completed\n- UPDATE \"Next Steps\" based on what was accomplished\n- PRESERVE exact file paths, function names, and error messages\n- If something is no longer relevant, you may remove it\n\nUse this EXACT format:\n\n## Goal\n[Preserve existing goals, add new ones if the task expanded]\n\n## Constraints & Preferences\n- [Preserve existing, add new ones discovered]\n\n## Progress\n### Done\n- [x] [Include previously done items AND newly completed items]\n\n### In Progress\n- [ ] [Current work - update based on progress]\n\n### Blocked\n- [Current blockers - remove if resolved]\n\n## Key Decisions\n- **[Decision]**: [Brief rationale] (preserve all previous, add new)\n\n## Next Steps\n1. [Update based on current state]\n\n## Critical Context\n- [Preserve important context, add new if needed]\n\nKeep each section concise. Preserve exact file paths, function names, and error messages.";
536
537const TURN_PREFIX_SUMMARIZATION_PROMPT: &str = "This is the PREFIX of a turn that was too large to keep. The SUFFIX (recent work) is retained.\n\nSummarize the prefix to provide context for the retained suffix:\n\n## Original Request\n[What did the user ask for in this turn?]\n\n## Early Progress\n- [Key decisions and work done in the prefix]\n\n## Context for Suffix\n- [Information needed to understand the retained recent work]\n\nBe concise. Focus on what's needed to understand the kept suffix.";
538
539fn push_message_separator(out: &mut String) {
540    if !out.is_empty() {
541        out.push_str("\n\n");
542    }
543}
544
545fn user_has_serializable_content(user: &UserMessage) -> bool {
546    match &user.content {
547        UserContent::Text(text) => !text.is_empty(),
548        UserContent::Blocks(blocks) => blocks
549            .iter()
550            .any(|c| matches!(c, ContentBlock::Text(t) if !t.text.is_empty())),
551    }
552}
553
554fn append_user_message(out: &mut String, user: &UserMessage) {
555    if !user_has_serializable_content(user) {
556        return;
557    }
558
559    push_message_separator(out);
560    out.push_str("[User]: ");
561    match &user.content {
562        UserContent::Text(text) => out.push_str(text),
563        UserContent::Blocks(blocks) => {
564            for block in blocks {
565                if let ContentBlock::Text(text) = block {
566                    out.push_str(&text.text);
567                }
568            }
569        }
570    }
571}
572
573fn append_custom_message(out: &mut String, custom_type: &str, content: &str) {
574    if content.trim().is_empty() {
575        return;
576    }
577
578    push_message_separator(out);
579    out.push('[');
580    if custom_type.trim().is_empty() {
581        out.push_str("Custom");
582    } else {
583        out.push_str("Custom:");
584        out.push_str(custom_type);
585    }
586    out.push_str("]: ");
587    out.push_str(content);
588}
589
590fn assistant_content_flags(assistant: &AssistantMessage) -> (bool, bool, bool) {
591    let mut has_thinking = false;
592    let mut has_text = false;
593    let mut has_tools = false;
594    for block in &assistant.content {
595        match block {
596            ContentBlock::Thinking(_) => has_thinking = true,
597            ContentBlock::Text(_) => has_text = true,
598            ContentBlock::ToolCall(_) => has_tools = true,
599            ContentBlock::Image(_) => {}
600        }
601    }
602    (has_thinking, has_text, has_tools)
603}
604
605fn append_assistant_thinking(out: &mut String, assistant: &AssistantMessage) {
606    push_message_separator(out);
607    out.push_str("[Assistant thinking]: ");
608    let mut first = true;
609    for block in &assistant.content {
610        if let ContentBlock::Thinking(thinking) = block {
611            if !first {
612                out.push('\n');
613            }
614            out.push_str(&thinking.thinking);
615            first = false;
616        }
617    }
618}
619
620fn append_assistant_text(out: &mut String, assistant: &AssistantMessage) {
621    push_message_separator(out);
622    out.push_str("[Assistant]: ");
623    let mut first = true;
624    for block in &assistant.content {
625        if let ContentBlock::Text(text) = block {
626            if !first {
627                out.push('\n');
628            }
629            out.push_str(&text.text);
630            first = false;
631        }
632    }
633}
634
635fn append_tool_call_arguments(out: &mut String, arguments: &Value) {
636    if let Some(obj) = arguments.as_object() {
637        let mut first_kv = true;
638        for (k, v) in obj {
639            if !first_kv {
640                out.push_str(", ");
641            }
642            out.push_str(k);
643            out.push('=');
644            match serde_json::to_string(v) {
645                Ok(s) => out.push_str(&s),
646                Err(_) => {
647                    let _ = write!(out, "{v}");
648                }
649            }
650            first_kv = false;
651        }
652    } else {
653        match serde_json::to_string(arguments) {
654            Ok(s) => out.push_str(&s),
655            Err(_) => {
656                let _ = write!(out, "{arguments}");
657            }
658        }
659    }
660}
661
662fn append_assistant_tool_calls(out: &mut String, assistant: &AssistantMessage) {
663    push_message_separator(out);
664    out.push_str("[Assistant tool calls]: ");
665    let mut first = true;
666    for block in &assistant.content {
667        if let ContentBlock::ToolCall(call) = block {
668            if !first {
669                out.push_str("; ");
670            }
671            out.push_str(&call.name);
672            out.push('(');
673            append_tool_call_arguments(out, &call.arguments);
674            out.push(')');
675            first = false;
676        }
677    }
678}
679
680fn append_assistant_message(out: &mut String, assistant: &AssistantMessage) {
681    let (has_thinking, has_text, has_tools) = assistant_content_flags(assistant);
682    if has_thinking {
683        append_assistant_thinking(out, assistant);
684    }
685    if has_text {
686        append_assistant_text(out, assistant);
687    }
688    if has_tools {
689        append_assistant_tool_calls(out, assistant);
690    }
691}
692
693fn tool_result_has_serializable_content(content: &[ContentBlock]) -> bool {
694    content
695        .iter()
696        .any(|c| matches!(c, ContentBlock::Text(t) if !t.text.is_empty()))
697}
698
699fn append_tool_result_message(out: &mut String, content: &[ContentBlock]) {
700    if !tool_result_has_serializable_content(content) {
701        return;
702    }
703
704    push_message_separator(out);
705    out.push_str("[Tool result]: ");
706    for block in content {
707        if let ContentBlock::Text(text) = block {
708            out.push_str(&text.text);
709        }
710    }
711}
712
713fn collect_text_blocks(blocks: &[ContentBlock]) -> String {
714    let mut out = String::new();
715    let mut first = true;
716    for block in blocks {
717        if let ContentBlock::Text(text) = block {
718            if !first {
719                out.push('\n');
720            }
721            out.push_str(&text.text);
722            first = false;
723        }
724    }
725    out
726}
727
728fn serialize_conversation(messages: &[Message]) -> String {
729    let mut out = String::new();
730
731    for msg in messages {
732        match msg {
733            Message::User(user) => append_user_message(&mut out, user),
734            Message::Custom(custom) => {
735                append_custom_message(&mut out, &custom.custom_type, &custom.content);
736            }
737            Message::Assistant(assistant) => append_assistant_message(&mut out, assistant),
738            Message::ToolResult(tool) => append_tool_result_message(&mut out, &tool.content),
739        }
740    }
741
742    out
743}
744
745async fn complete_simple(
746    provider: Arc<dyn Provider>,
747    system_prompt: &str,
748    prompt_text: String,
749    api_key: &str,
750    reserve_tokens: u32,
751    max_tokens_factor: f64,
752) -> Result<AssistantMessage> {
753    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
754    let max_tokens = (f64::from(reserve_tokens) * max_tokens_factor).floor() as u32;
755    let max_tokens = max_tokens.max(256);
756
757    let context = Context {
758        system_prompt: Some(system_prompt.to_string().into()),
759        messages: vec![Message::User(UserMessage {
760            content: UserContent::Blocks(vec![ContentBlock::Text(TextContent::new(prompt_text))]),
761            timestamp: chrono::Utc::now().timestamp_millis(),
762        })]
763        .into(),
764        tools: Vec::new().into(),
765    };
766
767    let options = StreamOptions {
768        api_key: Some(api_key.to_string()),
769        max_tokens: Some(max_tokens),
770        thinking_level: Some(ThinkingLevel::High),
771        ..Default::default()
772    };
773
774    let mut stream = provider.stream(&context, &options).await?;
775    let mut final_message: Option<AssistantMessage> = None;
776
777    while let Some(event) = stream.next().await {
778        match event? {
779            crate::model::StreamEvent::Done { message, .. } => {
780                final_message = Some(message);
781            }
782            crate::model::StreamEvent::Error { error, .. } => {
783                let msg = error
784                    .error_message
785                    .unwrap_or_else(|| "Summarization error".to_string());
786                return Err(Error::api(msg));
787            }
788            _ => {}
789        }
790    }
791
792    let message = final_message.ok_or_else(|| Error::api("Stream ended without Done event"))?;
793    if matches!(message.stop_reason, StopReason::Aborted | StopReason::Error) {
794        let msg = message
795            .error_message
796            .unwrap_or_else(|| "Summarization error".to_string());
797        return Err(Error::api(msg));
798    }
799    Ok(message)
800}
801
802async fn generate_summary(
803    messages: &[SessionMessage],
804    provider: Arc<dyn Provider>,
805    api_key: &str,
806    settings: &ResolvedCompactionSettings,
807    custom_instructions: Option<&str>,
808    previous_summary: Option<&str>,
809) -> Result<String> {
810    let base_prompt = if previous_summary.is_some() {
811        UPDATE_SUMMARIZATION_PROMPT
812    } else {
813        SUMMARIZATION_PROMPT
814    };
815
816    let mut prompt = base_prompt.to_string();
817    if let Some(custom) = custom_instructions.filter(|s| !s.trim().is_empty()) {
818        let _ = write!(prompt, "\n\nAdditional focus: {custom}");
819    }
820
821    let llm_messages = messages
822        .iter()
823        .filter_map(session_message_to_model)
824        .collect::<Vec<_>>();
825    let conversation_text = serialize_conversation(&llm_messages);
826
827    let mut prompt_text = format!("<conversation>\n{conversation_text}\n</conversation>\n\n");
828    if let Some(previous) = previous_summary {
829        let _ = write!(
830            prompt_text,
831            "<previous-summary>\n{previous}\n</previous-summary>\n\n"
832        );
833    }
834    prompt_text.push_str(&prompt);
835
836    let assistant = complete_simple(
837        provider,
838        SUMMARIZATION_SYSTEM_PROMPT,
839        prompt_text,
840        api_key,
841        settings.reserve_tokens,
842        0.8,
843    )
844    .await?;
845
846    let text = collect_text_blocks(&assistant.content);
847
848    if text.trim().is_empty() {
849        return Err(Error::api(
850            "Summarization returned empty text; refusing to store empty compaction summary",
851        ));
852    }
853
854    Ok(text)
855}
856
857async fn generate_turn_prefix_summary(
858    messages: &[SessionMessage],
859    provider: Arc<dyn Provider>,
860    api_key: &str,
861    settings: &ResolvedCompactionSettings,
862) -> Result<String> {
863    let llm_messages = messages
864        .iter()
865        .filter_map(session_message_to_model)
866        .collect::<Vec<_>>();
867    let conversation_text = serialize_conversation(&llm_messages);
868    let prompt_text = format!(
869        "<conversation>\n{conversation_text}\n</conversation>\n\n{TURN_PREFIX_SUMMARIZATION_PROMPT}"
870    );
871
872    let assistant = complete_simple(
873        provider,
874        SUMMARIZATION_SYSTEM_PROMPT,
875        prompt_text,
876        api_key,
877        settings.reserve_tokens,
878        0.5,
879    )
880    .await?;
881
882    let text = collect_text_blocks(&assistant.content);
883
884    if text.trim().is_empty() {
885        return Err(Error::api(
886            "Turn prefix summarization returned empty text; refusing to store empty summary",
887        ));
888    }
889
890    Ok(text)
891}
892
893// =============================================================================
894// Public API
895// =============================================================================
896
897#[allow(clippy::too_many_lines)]
898pub fn prepare_compaction(
899    path_entries: &[SessionEntry],
900    settings: ResolvedCompactionSettings,
901) -> Option<CompactionPreparation> {
902    if path_entries.is_empty() {
903        return None;
904    }
905
906    if path_entries
907        .last()
908        .is_some_and(|entry| matches!(entry, SessionEntry::Compaction(_)))
909    {
910        return None;
911    }
912
913    let mut prev_compaction_index: Option<usize> = None;
914    for (idx, entry) in path_entries.iter().enumerate().rev() {
915        if matches!(entry, SessionEntry::Compaction(_)) {
916            prev_compaction_index = Some(idx);
917            break;
918        }
919    }
920
921    let boundary_start = prev_compaction_index.map_or(0, |i| i + 1);
922    let boundary_end = path_entries.len();
923
924    let usage_start = prev_compaction_index.unwrap_or(0);
925    let mut usage_messages = Vec::new();
926    for entry in &path_entries[usage_start..boundary_end] {
927        if let Some(msg) = message_from_entry(entry) {
928            usage_messages.push(msg);
929        }
930    }
931    // Calculate the tokens *currently* occupied by the segment we are about to compact.
932    // If the segment includes a previous compaction summary, this counts the *summary* tokens,
933    // not the original uncompressed history tokens. This effectively tracks the "compressed size"
934    // of the history prior to the new cut point.
935    let tokens_before = estimate_context_tokens(&usage_messages).tokens;
936
937    if !should_compact(tokens_before, settings.context_window_tokens, &settings) {
938        return None;
939    }
940
941    let cut_point = find_cut_point(
942        path_entries,
943        boundary_start,
944        boundary_end,
945        settings.keep_recent_tokens,
946    );
947
948    let first_kept_entry = &path_entries[cut_point.first_kept_entry_index];
949    let first_kept_entry_id = first_kept_entry.base_id()?.clone();
950
951    let history_end = if cut_point.is_split_turn {
952        cut_point.turn_start_index?
953    } else {
954        cut_point.first_kept_entry_index
955    };
956
957    let mut messages_to_summarize = Vec::new();
958    for entry in &path_entries[boundary_start..history_end] {
959        if let Some(msg) = message_from_entry(entry) {
960            messages_to_summarize.push(msg);
961        }
962    }
963
964    let mut turn_prefix_messages = Vec::new();
965    if cut_point.is_split_turn {
966        let turn_start = cut_point.turn_start_index?;
967        for entry in &path_entries[turn_start..cut_point.first_kept_entry_index] {
968            if let Some(msg) = message_from_entry(entry) {
969                turn_prefix_messages.push(msg);
970            }
971        }
972    }
973
974    // No-op compaction: if there's nothing to summarize, don't issue an LLM call and don't append a
975    // compaction entry. This can happen early in a session (e.g. session header entries only).
976    if messages_to_summarize.is_empty() && turn_prefix_messages.is_empty() {
977        return None;
978    }
979
980    let previous_summary = prev_compaction_index.and_then(|idx| match &path_entries[idx] {
981        SessionEntry::Compaction(entry) => Some(entry.summary.clone()),
982        _ => None,
983    });
984
985    let mut file_ops = FileOperations::default();
986
987    // Collect file tracking from previous compaction details if pi-generated.
988    if let Some(idx) = prev_compaction_index {
989        if let SessionEntry::Compaction(entry) = &path_entries[idx] {
990            if !entry.from_hook.unwrap_or(false) {
991                if let Some(details) = entry.details.as_ref().and_then(Value::as_object) {
992                    if let Some(read_files) = details.get("readFiles").and_then(Value::as_array) {
993                        for item in read_files.iter().filter_map(Value::as_str) {
994                            file_ops.read.insert(item.to_string());
995                        }
996                    }
997                    if let Some(modified_files) =
998                        details.get("modifiedFiles").and_then(Value::as_array)
999                    {
1000                        for item in modified_files.iter().filter_map(Value::as_str) {
1001                            file_ops.edited.insert(item.to_string());
1002                        }
1003                    }
1004                }
1005            }
1006        }
1007    }
1008
1009    let mut tool_status = build_tool_status_map(&messages_to_summarize);
1010    tool_status.extend(build_tool_status_map(&turn_prefix_messages));
1011
1012    for msg in &messages_to_summarize {
1013        extract_file_ops_from_message(msg, &mut file_ops, &tool_status);
1014    }
1015    for msg in &turn_prefix_messages {
1016        extract_file_ops_from_message(msg, &mut file_ops, &tool_status);
1017    }
1018
1019    Some(CompactionPreparation {
1020        first_kept_entry_id,
1021        messages_to_summarize,
1022        turn_prefix_messages,
1023        is_split_turn: cut_point.is_split_turn,
1024        tokens_before,
1025        previous_summary,
1026        file_ops,
1027        settings,
1028    })
1029}
1030
1031pub async fn summarize_entries(
1032    entries: &[SessionEntry],
1033    provider: Arc<dyn Provider>,
1034    api_key: &str,
1035    reserve_tokens: u32,
1036    custom_instructions: Option<&str>,
1037) -> Result<Option<String>> {
1038    let mut messages = Vec::new();
1039    for entry in entries {
1040        if let Some(message) = message_from_entry(entry) {
1041            messages.push(message);
1042        }
1043    }
1044
1045    if messages.is_empty() {
1046        return Ok(None);
1047    }
1048
1049    let settings = ResolvedCompactionSettings {
1050        enabled: true,
1051        reserve_tokens,
1052        keep_recent_tokens: 0,
1053        ..Default::default()
1054    };
1055
1056    let summary = generate_summary(
1057        &messages,
1058        provider,
1059        api_key,
1060        &settings,
1061        custom_instructions,
1062        None,
1063    )
1064    .await?;
1065
1066    Ok(Some(summary))
1067}
1068
1069pub async fn compact(
1070    preparation: CompactionPreparation,
1071    provider: Arc<dyn Provider>,
1072    api_key: &str,
1073    custom_instructions: Option<&str>,
1074) -> Result<CompactionResult> {
1075    let summary = if preparation.is_split_turn && !preparation.turn_prefix_messages.is_empty() {
1076        let history_summary = if preparation.messages_to_summarize.is_empty() {
1077            "No prior history.".to_string()
1078        } else {
1079            generate_summary(
1080                &preparation.messages_to_summarize,
1081                Arc::clone(&provider),
1082                api_key,
1083                &preparation.settings,
1084                custom_instructions,
1085                preparation.previous_summary.as_deref(),
1086            )
1087            .await?
1088        };
1089
1090        let turn_prefix_summary = generate_turn_prefix_summary(
1091            &preparation.turn_prefix_messages,
1092            Arc::clone(&provider),
1093            api_key,
1094            &preparation.settings,
1095        )
1096        .await?;
1097
1098        format!(
1099            "{history_summary}\n\n---\n\n**Turn Context (split turn):**\n\n{turn_prefix_summary}"
1100        )
1101    } else {
1102        generate_summary(
1103            &preparation.messages_to_summarize,
1104            Arc::clone(&provider),
1105            api_key,
1106            &preparation.settings,
1107            custom_instructions,
1108            preparation.previous_summary.as_deref(),
1109        )
1110        .await?
1111    };
1112
1113    let (read_files, modified_files) = compute_file_lists(&preparation.file_ops);
1114    let details = CompactionDetails {
1115        read_files: read_files.clone(),
1116        modified_files: modified_files.clone(),
1117    };
1118
1119    let mut summary = summary;
1120    summary.push_str(&format_file_operations(&read_files, &modified_files));
1121
1122    Ok(CompactionResult {
1123        summary,
1124        first_kept_entry_id: preparation.first_kept_entry_id,
1125        tokens_before: preparation.tokens_before,
1126        details,
1127    })
1128}
1129
1130pub fn compaction_details_to_value(details: &CompactionDetails) -> Result<Value> {
1131    serde_json::to_value(details).map_err(|e| Error::session(format!("Compaction details: {e}")))
1132}
1133
1134#[cfg(test)]
1135mod tests {
1136    use super::*;
1137    use crate::model::{AssistantMessage, ContentBlock, TextContent, Usage};
1138    use serde_json::json;
1139
1140    fn make_user_text(text: &str) -> SessionMessage {
1141        SessionMessage::User {
1142            content: UserContent::Text(text.to_string()),
1143            timestamp: Some(0),
1144        }
1145    }
1146
1147    fn make_assistant_text(text: &str, input: u64, output: u64) -> SessionMessage {
1148        SessionMessage::Assistant {
1149            message: AssistantMessage {
1150                content: vec![ContentBlock::Text(TextContent::new(text))],
1151                api: String::new(),
1152                provider: String::new(),
1153                model: String::new(),
1154                stop_reason: StopReason::Stop,
1155                error_message: None,
1156                timestamp: 0,
1157                usage: Usage {
1158                    input,
1159                    output,
1160                    cache_read: 0,
1161                    cache_write: 0,
1162                    total_tokens: input + output,
1163                    ..Default::default()
1164                },
1165            },
1166        }
1167    }
1168
1169    fn make_assistant_tool_call(name: &str, args: Value) -> SessionMessage {
1170        SessionMessage::Assistant {
1171            message: AssistantMessage {
1172                content: vec![ContentBlock::ToolCall(ToolCall {
1173                    id: "call_1".to_string(),
1174                    name: name.to_string(),
1175                    arguments: args,
1176                    thought_signature: None,
1177                })],
1178                api: String::new(),
1179                provider: String::new(),
1180                model: String::new(),
1181                stop_reason: StopReason::ToolUse,
1182                error_message: None,
1183                timestamp: 0,
1184                usage: Usage::default(),
1185            },
1186        }
1187    }
1188
1189    fn make_tool_result(text: &str) -> SessionMessage {
1190        SessionMessage::ToolResult {
1191            tool_call_id: "call_1".to_string(),
1192            tool_name: String::new(),
1193            content: vec![ContentBlock::Text(TextContent::new(text))],
1194            details: None,
1195            is_error: false,
1196            timestamp: None,
1197        }
1198    }
1199
1200    // ── calculate_context_tokens ─────────────────────────────────────
1201
1202    #[test]
1203    fn context_tokens_prefers_total_tokens() {
1204        let usage = Usage {
1205            input: 100,
1206            output: 50,
1207            total_tokens: 200,
1208            ..Default::default()
1209        };
1210        assert_eq!(calculate_context_tokens(&usage), 200);
1211    }
1212
1213    #[test]
1214    fn context_tokens_falls_back_to_input_plus_output() {
1215        let usage = Usage {
1216            input: 100,
1217            output: 50,
1218            total_tokens: 0,
1219            ..Default::default()
1220        };
1221        assert_eq!(calculate_context_tokens(&usage), 150);
1222    }
1223
1224    // ── should_compact ───────────────────────────────────────────────
1225
1226    #[test]
1227    fn should_compact_when_over_threshold() {
1228        let settings = ResolvedCompactionSettings {
1229            enabled: true,
1230            reserve_tokens: 10_000,
1231            keep_recent_tokens: 5_000,
1232            ..Default::default()
1233        };
1234        // window=100k, reserve=10k => threshold=90k, context=95k => should compact
1235        assert!(should_compact(95_000, 100_000, &settings));
1236    }
1237
1238    #[test]
1239    fn should_not_compact_when_under_threshold() {
1240        let settings = ResolvedCompactionSettings {
1241            enabled: true,
1242            reserve_tokens: 10_000,
1243            keep_recent_tokens: 5_000,
1244            ..Default::default()
1245        };
1246        // window=100k, reserve=10k => threshold=90k, context=80k => should not compact
1247        assert!(!should_compact(80_000, 100_000, &settings));
1248    }
1249
1250    #[test]
1251    fn should_not_compact_when_disabled() {
1252        let settings = ResolvedCompactionSettings {
1253            enabled: false,
1254            reserve_tokens: 0,
1255            keep_recent_tokens: 0,
1256            ..Default::default()
1257        };
1258        assert!(!should_compact(1_000_000, 100_000, &settings));
1259    }
1260
1261    #[test]
1262    fn should_compact_at_exact_threshold() {
1263        let settings = ResolvedCompactionSettings {
1264            enabled: true,
1265            reserve_tokens: 10_000,
1266            keep_recent_tokens: 5_000,
1267            ..Default::default()
1268        };
1269        // window=100k, reserve=10k => threshold=90k, context=90k => NOT compacting (not >)
1270        assert!(!should_compact(90_000, 100_000, &settings));
1271        // 90001 should trigger
1272        assert!(should_compact(90_001, 100_000, &settings));
1273    }
1274
1275    // ── estimate_tokens ──────────────────────────────────────────────
1276
1277    #[test]
1278    fn estimate_tokens_user_text() {
1279        let msg = make_user_text("hello world"); // 11 chars => ceil(11/3) = 4
1280        assert_eq!(estimate_tokens(&msg), 4);
1281    }
1282
1283    #[test]
1284    fn estimate_tokens_empty_text() {
1285        let msg = make_user_text(""); // 0 chars => 0
1286        assert_eq!(estimate_tokens(&msg), 0);
1287    }
1288
1289    #[test]
1290    fn estimate_tokens_assistant_text() {
1291        let msg = make_assistant_text("hello", 10, 5); // 5 chars => ceil(5/3) = 2
1292        assert_eq!(estimate_tokens(&msg), 2);
1293    }
1294
1295    #[test]
1296    fn estimate_tokens_tool_result() {
1297        let msg = make_tool_result("file contents here"); // 18 chars => ceil(18/3) = 6
1298        assert_eq!(estimate_tokens(&msg), 6);
1299    }
1300
1301    #[test]
1302    fn estimate_tokens_custom_message() {
1303        let msg = SessionMessage::Custom {
1304            custom_type: "system".to_string(),
1305            content: "some custom content".to_string(),
1306            display: true,
1307            details: None,
1308            timestamp: Some(0),
1309        };
1310        // 19 chars => ceil(19/3) = 7
1311        assert_eq!(estimate_tokens(&msg), 7);
1312    }
1313
1314    // ── estimate_context_tokens ──────────────────────────────────────
1315
1316    #[test]
1317    fn estimate_context_with_assistant_usage() {
1318        let messages = vec![
1319            make_user_text("hi"),
1320            make_assistant_text("hello", 50, 10),
1321            make_user_text("bye"),
1322        ];
1323        let estimate = estimate_context_tokens(&messages);
1324        // Last assistant usage: input=50, output=10, total=60
1325        // Trailing after that: "bye" = ceil(3/3) = 1
1326        assert_eq!(estimate.tokens, 61);
1327        assert_eq!(estimate.last_usage_index, Some(1));
1328    }
1329
1330    #[test]
1331    fn estimate_context_no_assistant() {
1332        let messages = vec![make_user_text("hello"), make_user_text("world")];
1333        let estimate = estimate_context_tokens(&messages);
1334        // No assistant messages, so sum estimate_tokens for all: ceil(5/3)+ceil(5/3) = 2+2 = 4
1335        assert_eq!(estimate.tokens, 4);
1336        assert!(estimate.last_usage_index.is_none());
1337    }
1338
1339    // ── extract_file_ops_from_message ────────────────────────────────
1340
1341    #[test]
1342    fn extract_file_ops_read() {
1343        let msg = make_assistant_tool_call("read", json!({"path": "/foo/bar.rs"}));
1344        let mut ops = FileOperations::default();
1345        let mut status = HashMap::new();
1346        status.insert("call_1".to_string(), true);
1347        extract_file_ops_from_message(&msg, &mut ops, &status);
1348        assert!(ops.read.contains("/foo/bar.rs"));
1349        assert!(ops.written.is_empty());
1350        assert!(ops.edited.is_empty());
1351    }
1352
1353    #[test]
1354    fn extract_file_ops_write() {
1355        let msg = make_assistant_tool_call("write", json!({"path": "/out.txt"}));
1356        let mut ops = FileOperations::default();
1357        let mut status = HashMap::new();
1358        status.insert("call_1".to_string(), true);
1359        extract_file_ops_from_message(&msg, &mut ops, &status);
1360        assert!(ops.written.contains("/out.txt"));
1361        assert!(ops.read.is_empty());
1362    }
1363
1364    #[test]
1365    fn extract_file_ops_edit() {
1366        let msg = make_assistant_tool_call("edit", json!({"path": "/src/main.rs"}));
1367        let mut ops = FileOperations::default();
1368        let mut status = HashMap::new();
1369        status.insert("call_1".to_string(), true);
1370        extract_file_ops_from_message(&msg, &mut ops, &status);
1371        assert!(ops.edited.contains("/src/main.rs"));
1372    }
1373
1374    #[test]
1375    fn extract_file_ops_ignores_failed_tools() {
1376        let msg = make_assistant_tool_call("read", json!({"path": "/secret.rs"}));
1377        let mut ops = FileOperations::default();
1378        let mut status = HashMap::new();
1379        status.insert("call_1".to_string(), false); // Failed!
1380        extract_file_ops_from_message(&msg, &mut ops, &status);
1381        assert!(ops.read.is_empty());
1382    }
1383
1384    #[test]
1385    fn extract_file_ops_ignores_other_tools() {
1386        let msg = make_assistant_tool_call("bash", json!({"command": "ls"}));
1387        let mut ops = FileOperations::default();
1388        let mut status = HashMap::new();
1389        status.insert("call_1".to_string(), true);
1390        extract_file_ops_from_message(&msg, &mut ops, &status);
1391        assert!(ops.read.is_empty());
1392        assert!(ops.written.is_empty());
1393        assert!(ops.edited.is_empty());
1394    }
1395
1396    #[test]
1397    fn extract_file_ops_ignores_user_messages() {
1398        let msg = make_user_text("read the file /foo.rs");
1399        let mut ops = FileOperations::default();
1400        let status = HashMap::new();
1401        extract_file_ops_from_message(&msg, &mut ops, &status);
1402        assert!(ops.read.is_empty());
1403    }
1404
1405    // ── compute_file_lists ───────────────────────────────────────────
1406
1407    #[test]
1408    fn compute_file_lists_separates_read_from_modified() {
1409        let mut ops = FileOperations::default();
1410        ops.read.insert("/a.rs".to_string());
1411        ops.read.insert("/b.rs".to_string());
1412        ops.written.insert("/b.rs".to_string());
1413        ops.edited.insert("/c.rs".to_string());
1414
1415        let (read_only, modified) = compute_file_lists(&ops);
1416        // /a.rs was only read; /b.rs was read AND written (so it's modified)
1417        assert_eq!(read_only, vec!["/a.rs"]);
1418        assert!(modified.contains(&"/b.rs".to_string()));
1419        assert!(modified.contains(&"/c.rs".to_string()));
1420    }
1421
1422    #[test]
1423    fn compute_file_lists_empty() {
1424        let ops = FileOperations::default();
1425        let (read_only, modified) = compute_file_lists(&ops);
1426        assert!(read_only.is_empty());
1427        assert!(modified.is_empty());
1428    }
1429
1430    // ── format_file_operations ───────────────────────────────────────
1431
1432    #[test]
1433    fn format_file_operations_empty() {
1434        assert_eq!(format_file_operations(&[], &[]), String::new());
1435    }
1436
1437    #[test]
1438    fn format_file_operations_read_only() {
1439        let result = format_file_operations(&["src/main.rs".to_string()], &[]);
1440        assert!(result.contains("<read-files>"));
1441        assert!(result.contains("src/main.rs"));
1442        assert!(!result.contains("<modified-files>"));
1443    }
1444
1445    #[test]
1446    fn format_file_operations_both() {
1447        let result = format_file_operations(&["a.rs".to_string()], &["b.rs".to_string()]);
1448        assert!(result.contains("<read-files>"));
1449        assert!(result.contains("a.rs"));
1450        assert!(result.contains("<modified-files>"));
1451        assert!(result.contains("b.rs"));
1452    }
1453
1454    // ── compaction_details_to_value ──────────────────────────────────
1455
1456    #[test]
1457    fn compaction_details_serializes() {
1458        let details = CompactionDetails {
1459            read_files: vec!["a.rs".to_string()],
1460            modified_files: vec!["b.rs".to_string()],
1461        };
1462        let value = compaction_details_to_value(&details).unwrap();
1463        assert_eq!(value["readFiles"], json!(["a.rs"]));
1464        assert_eq!(value["modifiedFiles"], json!(["b.rs"]));
1465    }
1466
1467    // ── ResolvedCompactionSettings default ───────────────────────────
1468
1469    #[test]
1470    fn default_settings() {
1471        let settings = ResolvedCompactionSettings::default();
1472        assert!(settings.enabled);
1473        assert_eq!(settings.reserve_tokens, 16_384);
1474        assert_eq!(settings.keep_recent_tokens, 20_000);
1475    }
1476
1477    // ── Helper: entry constructors ──────────────────────────────────
1478
1479    use crate::model::{ImageContent, ThinkingContent};
1480    use crate::session::{
1481        BranchSummaryEntry, CompactionEntry, EntryBase, MessageEntry, ModelChangeEntry,
1482    };
1483    use std::collections::HashMap;
1484
1485    fn test_base(id: &str) -> EntryBase {
1486        EntryBase {
1487            id: Some(id.to_string()),
1488            parent_id: None,
1489            timestamp: "2026-01-01T00:00:00.000Z".to_string(),
1490        }
1491    }
1492
1493    fn user_entry(id: &str, text: &str) -> SessionEntry {
1494        SessionEntry::Message(MessageEntry {
1495            base: test_base(id),
1496            message: make_user_text(text),
1497        })
1498    }
1499
1500    fn assistant_entry(id: &str, text: &str, input: u64, output: u64) -> SessionEntry {
1501        SessionEntry::Message(MessageEntry {
1502            base: test_base(id),
1503            message: make_assistant_text(text, input, output),
1504        })
1505    }
1506
1507    fn tool_call_entry(id: &str, tool_name: &str, path: &str) -> SessionEntry {
1508        SessionEntry::Message(MessageEntry {
1509            base: test_base(id),
1510            message: make_assistant_tool_call(tool_name, json!({"path": path})),
1511        })
1512    }
1513
1514    fn tool_result_entry(id: &str, text: &str) -> SessionEntry {
1515        SessionEntry::Message(MessageEntry {
1516            base: test_base(id),
1517            message: make_tool_result(text),
1518        })
1519    }
1520
1521    fn branch_entry(id: &str, summary: &str) -> SessionEntry {
1522        SessionEntry::BranchSummary(BranchSummaryEntry {
1523            base: test_base(id),
1524            from_id: "parent".to_string(),
1525            summary: summary.to_string(),
1526            details: None,
1527            from_hook: None,
1528        })
1529    }
1530
1531    fn compact_entry(id: &str, summary: &str, tokens: u64) -> SessionEntry {
1532        SessionEntry::Compaction(CompactionEntry {
1533            base: test_base(id),
1534            summary: summary.to_string(),
1535            first_kept_entry_id: "kept".to_string(),
1536            tokens_before: tokens,
1537            details: None,
1538            from_hook: None,
1539        })
1540    }
1541
1542    fn bash_entry(id: &str) -> SessionEntry {
1543        SessionEntry::Message(MessageEntry {
1544            base: test_base(id),
1545            message: SessionMessage::BashExecution {
1546                command: "ls".to_string(),
1547                output: "ok".to_string(),
1548                exit_code: 0,
1549                cancelled: None,
1550                truncated: None,
1551                full_output_path: None,
1552                timestamp: None,
1553                extra: HashMap::new(),
1554            },
1555        })
1556    }
1557
1558    // ── get_assistant_usage ─────────────────────────────────────────
1559
1560    #[test]
1561    fn get_assistant_usage_returns_usage_for_stop() {
1562        let msg = make_assistant_text("text", 100, 50);
1563        let usage = get_assistant_usage(&msg);
1564        assert!(usage.is_some());
1565        assert_eq!(usage.unwrap().input, 100);
1566    }
1567
1568    #[test]
1569    fn get_assistant_usage_none_for_aborted() {
1570        let msg = SessionMessage::Assistant {
1571            message: AssistantMessage {
1572                content: vec![ContentBlock::Text(TextContent::new("text"))],
1573                api: String::new(),
1574                provider: String::new(),
1575                model: String::new(),
1576                stop_reason: StopReason::Aborted,
1577                error_message: None,
1578                timestamp: 0,
1579                usage: Usage {
1580                    input: 100,
1581                    output: 50,
1582                    total_tokens: 150,
1583                    ..Default::default()
1584                },
1585            },
1586        };
1587        assert!(get_assistant_usage(&msg).is_none());
1588    }
1589
1590    #[test]
1591    fn get_assistant_usage_none_for_error() {
1592        let msg = SessionMessage::Assistant {
1593            message: AssistantMessage {
1594                content: vec![],
1595                api: String::new(),
1596                provider: String::new(),
1597                model: String::new(),
1598                stop_reason: StopReason::Error,
1599                error_message: None,
1600                timestamp: 0,
1601                usage: Usage::default(),
1602            },
1603        };
1604        assert!(get_assistant_usage(&msg).is_none());
1605    }
1606
1607    #[test]
1608    fn get_assistant_usage_none_for_user() {
1609        assert!(get_assistant_usage(&make_user_text("hello")).is_none());
1610    }
1611
1612    // ── entry_is_message_like ───────────────────────────────────────
1613
1614    #[test]
1615    fn entry_is_message_like_for_message() {
1616        assert!(entry_is_message_like(&user_entry("1", "hi")));
1617    }
1618
1619    #[test]
1620    fn entry_is_message_like_for_branch_summary() {
1621        assert!(entry_is_message_like(&branch_entry("1", "sum")));
1622    }
1623
1624    #[test]
1625    fn entry_is_message_like_false_for_compaction() {
1626        assert!(!entry_is_message_like(&compact_entry("1", "sum", 100)));
1627    }
1628
1629    #[test]
1630    fn entry_is_message_like_false_for_model_change() {
1631        let entry = SessionEntry::ModelChange(ModelChangeEntry {
1632            base: test_base("1"),
1633            provider: "test".to_string(),
1634            model_id: "model-1".to_string(),
1635        });
1636        assert!(!entry_is_message_like(&entry));
1637    }
1638
1639    // ── entry_is_compaction_boundary ────────────────────────────────
1640
1641    #[test]
1642    fn compaction_boundary_true_for_compaction() {
1643        assert!(entry_is_compaction_boundary(&compact_entry(
1644            "1", "sum", 100
1645        )));
1646    }
1647
1648    #[test]
1649    fn compaction_boundary_false_for_message() {
1650        assert!(!entry_is_compaction_boundary(&user_entry("1", "hi")));
1651    }
1652
1653    #[test]
1654    fn compaction_boundary_false_for_branch() {
1655        assert!(!entry_is_compaction_boundary(&branch_entry("1", "sum")));
1656    }
1657
1658    // ── is_user_turn_start ──────────────────────────────────────────
1659
1660    #[test]
1661    fn user_turn_start_for_user() {
1662        assert!(is_user_turn_start(&user_entry("1", "hello")));
1663    }
1664
1665    #[test]
1666    fn user_turn_start_for_branch() {
1667        assert!(is_user_turn_start(&branch_entry("1", "summary")));
1668    }
1669
1670    #[test]
1671    fn user_turn_start_for_bash() {
1672        assert!(is_user_turn_start(&bash_entry("1")));
1673    }
1674
1675    #[test]
1676    fn user_turn_start_false_for_assistant() {
1677        assert!(!is_user_turn_start(&assistant_entry("1", "resp", 10, 5)));
1678    }
1679
1680    #[test]
1681    fn user_turn_start_false_for_tool_result() {
1682        assert!(!is_user_turn_start(&tool_result_entry("1", "result")));
1683    }
1684
1685    #[test]
1686    fn user_turn_start_false_for_compaction() {
1687        assert!(!is_user_turn_start(&compact_entry("1", "sum", 100)));
1688    }
1689
1690    // ── message_from_entry ──────────────────────────────────────────
1691
1692    #[test]
1693    fn message_from_entry_user() {
1694        let entry = user_entry("1", "hello");
1695        let msg = message_from_entry(&entry);
1696        assert!(msg.is_some());
1697        assert!(matches!(msg.unwrap(), SessionMessage::User { .. }));
1698    }
1699
1700    #[test]
1701    fn message_from_entry_branch_summary() {
1702        let entry = branch_entry("1", "branch summary text");
1703        let msg = message_from_entry(&entry).unwrap();
1704        if let SessionMessage::BranchSummary { summary, from_id } = msg {
1705            assert_eq!(summary, "branch summary text");
1706            assert_eq!(from_id, "parent");
1707        } else {
1708            panic!("expected BranchSummary");
1709        }
1710    }
1711
1712    #[test]
1713    fn message_from_entry_compaction() {
1714        let entry = compact_entry("1", "compact summary", 500);
1715        let msg = message_from_entry(&entry).unwrap();
1716        if let SessionMessage::CompactionSummary {
1717            summary,
1718            tokens_before,
1719        } = msg
1720        {
1721            assert_eq!(summary, "compact summary");
1722            assert_eq!(tokens_before, 500);
1723        } else {
1724            panic!("expected CompactionSummary");
1725        }
1726    }
1727
1728    #[test]
1729    fn message_from_entry_model_change_is_none() {
1730        let entry = SessionEntry::ModelChange(ModelChangeEntry {
1731            base: test_base("1"),
1732            provider: "test".to_string(),
1733            model_id: "model".to_string(),
1734        });
1735        assert!(message_from_entry(&entry).is_none());
1736    }
1737
1738    // ── find_valid_cut_points ───────────────────────────────────────
1739
1740    #[test]
1741    fn find_valid_cut_points_empty() {
1742        assert!(find_valid_cut_points(&[], 0, 0).is_empty());
1743    }
1744
1745    #[test]
1746    fn find_valid_cut_points_skips_tool_results() {
1747        let entries = vec![
1748            user_entry("1", "hello"),
1749            assistant_entry("2", "resp", 10, 5),
1750            tool_result_entry("3", "result"),
1751            user_entry("4", "follow up"),
1752        ];
1753        let cuts = find_valid_cut_points(&entries, 0, entries.len());
1754        assert!(cuts.contains(&0)); // user
1755        assert!(cuts.contains(&1)); // assistant
1756        assert!(!cuts.contains(&2)); // tool result excluded
1757        assert!(cuts.contains(&3)); // user
1758    }
1759
1760    #[test]
1761    fn find_valid_cut_points_includes_branch_summary() {
1762        let entries = vec![branch_entry("1", "summary"), user_entry("2", "hello")];
1763        let cuts = find_valid_cut_points(&entries, 0, entries.len());
1764        assert!(cuts.contains(&0));
1765        assert!(cuts.contains(&1));
1766    }
1767
1768    #[test]
1769    fn find_valid_cut_points_respects_range() {
1770        let entries = vec![
1771            user_entry("1", "a"),
1772            user_entry("2", "b"),
1773            user_entry("3", "c"),
1774        ];
1775        let cuts = find_valid_cut_points(&entries, 1, 2);
1776        assert!(!cuts.contains(&0));
1777        assert!(cuts.contains(&1));
1778        assert!(!cuts.contains(&2));
1779    }
1780
1781    // ── find_turn_start_index ───────────────────────────────────────
1782
1783    #[test]
1784    fn find_turn_start_basic() {
1785        let entries = vec![
1786            user_entry("1", "hello"),
1787            assistant_entry("2", "resp", 10, 5),
1788            tool_result_entry("3", "result"),
1789        ];
1790        assert_eq!(find_turn_start_index(&entries, 2, 0), Some(0));
1791    }
1792
1793    #[test]
1794    fn find_turn_start_at_self() {
1795        let entries = vec![user_entry("1", "hello")];
1796        assert_eq!(find_turn_start_index(&entries, 0, 0), Some(0));
1797    }
1798
1799    #[test]
1800    fn find_turn_start_none_no_user() {
1801        let entries = vec![
1802            assistant_entry("1", "resp", 10, 5),
1803            tool_result_entry("2", "result"),
1804        ];
1805        assert_eq!(find_turn_start_index(&entries, 1, 0), None);
1806    }
1807
1808    #[test]
1809    fn find_turn_start_respects_start_index() {
1810        let entries = vec![
1811            user_entry("1", "old"),
1812            assistant_entry("2", "resp", 10, 5),
1813            user_entry("3", "new"),
1814        ];
1815        // start_index=2, so it should find user at 2
1816        assert_eq!(find_turn_start_index(&entries, 2, 2), Some(2));
1817        // start_index=2, looking back from 2, user at 1 is below start
1818        assert_eq!(find_turn_start_index(&entries, 1, 2), None);
1819    }
1820
1821    // ── serialize_conversation ───────────────────────────────────────
1822
1823    #[test]
1824    fn serialize_conversation_user_text() {
1825        let messages = vec![Message::User(crate::model::UserMessage {
1826            content: UserContent::Text("hello world".to_string()),
1827            timestamp: 0,
1828        })];
1829        assert_eq!(serialize_conversation(&messages), "[User]: hello world");
1830    }
1831
1832    #[test]
1833    fn serialize_conversation_empty() {
1834        assert!(serialize_conversation(&[]).is_empty());
1835    }
1836
1837    #[test]
1838    fn serialize_conversation_skips_empty_user() {
1839        let messages = vec![Message::User(crate::model::UserMessage {
1840            content: UserContent::Text(String::new()),
1841            timestamp: 0,
1842        })];
1843        assert!(serialize_conversation(&messages).is_empty());
1844    }
1845
1846    #[test]
1847    fn serialize_conversation_assistant_text() {
1848        let messages = vec![Message::assistant(AssistantMessage {
1849            content: vec![ContentBlock::Text(TextContent::new("response"))],
1850            api: String::new(),
1851            provider: String::new(),
1852            model: String::new(),
1853            usage: Usage::default(),
1854            stop_reason: StopReason::Stop,
1855            error_message: None,
1856            timestamp: 0,
1857        })];
1858        assert!(serialize_conversation(&messages).contains("[Assistant]: response"));
1859    }
1860
1861    #[test]
1862    fn serialize_conversation_tool_calls() {
1863        let messages = vec![Message::assistant(AssistantMessage {
1864            content: vec![ContentBlock::ToolCall(ToolCall {
1865                id: "c1".to_string(),
1866                name: "read".to_string(),
1867                arguments: json!({"path": "/main.rs"}),
1868                thought_signature: None,
1869            })],
1870            api: String::new(),
1871            provider: String::new(),
1872            model: String::new(),
1873            usage: Usage::default(),
1874            stop_reason: StopReason::Stop,
1875            error_message: None,
1876            timestamp: 0,
1877        })];
1878        let result = serialize_conversation(&messages);
1879        assert!(result.contains("[Assistant tool calls]: read("));
1880        assert!(result.contains("path="));
1881    }
1882
1883    #[test]
1884    fn serialize_conversation_thinking() {
1885        let messages = vec![Message::assistant(AssistantMessage {
1886            content: vec![ContentBlock::Thinking(ThinkingContent {
1887                thinking: "let me think".to_string(),
1888                thinking_signature: None,
1889            })],
1890            api: String::new(),
1891            provider: String::new(),
1892            model: String::new(),
1893            usage: Usage::default(),
1894            stop_reason: StopReason::Stop,
1895            error_message: None,
1896            timestamp: 0,
1897        })];
1898        assert!(serialize_conversation(&messages).contains("[Assistant thinking]: let me think"));
1899    }
1900
1901    #[test]
1902    fn serialize_conversation_tool_result() {
1903        let messages = vec![Message::tool_result(crate::model::ToolResultMessage {
1904            tool_call_id: "c1".to_string(),
1905            tool_name: "read".to_string(),
1906            content: vec![ContentBlock::Text(TextContent::new("file contents"))],
1907            details: None,
1908            is_error: false,
1909            timestamp: 0,
1910        })];
1911        assert!(serialize_conversation(&messages).contains("[Tool result]: file contents"));
1912    }
1913
1914    // ── estimate_tokens additional ──────────────────────────────────
1915
1916    #[test]
1917    fn estimate_tokens_image_block() {
1918        let msg = SessionMessage::User {
1919            content: UserContent::Blocks(vec![ContentBlock::Image(ImageContent {
1920                data: "base64data".to_string(),
1921                mime_type: "image/png".to_string(),
1922            })]),
1923            timestamp: None,
1924        };
1925        // Image = 3600 chars (IMAGE_CHAR_ESTIMATE) -> ceil(3600/3) = 1200
1926        assert_eq!(estimate_tokens(&msg), 1200);
1927    }
1928
1929    #[test]
1930    fn estimate_tokens_thinking() {
1931        let msg = SessionMessage::User {
1932            content: UserContent::Blocks(vec![ContentBlock::Thinking(ThinkingContent {
1933                thinking: "a".repeat(20),
1934                thinking_signature: None,
1935            })]),
1936            timestamp: None,
1937        };
1938        // 20 chars -> ceil(20/3) = 7
1939        assert_eq!(estimate_tokens(&msg), 7);
1940    }
1941
1942    #[test]
1943    fn estimate_tokens_bash_execution() {
1944        let msg = SessionMessage::BashExecution {
1945            command: "echo hi".to_string(),
1946            output: "hi\n".to_string(),
1947            exit_code: 0,
1948            cancelled: None,
1949            truncated: None,
1950            full_output_path: None,
1951            timestamp: None,
1952            extra: HashMap::new(),
1953        };
1954        // 7 + 3 = 10 chars -> ceil(10/3) = 4
1955        assert_eq!(estimate_tokens(&msg), 4);
1956    }
1957
1958    #[test]
1959    fn estimate_tokens_branch_summary() {
1960        let msg = SessionMessage::BranchSummary {
1961            summary: "a".repeat(40),
1962            from_id: "id".to_string(),
1963        };
1964        // 40 chars -> ceil(40/3) = 14
1965        assert_eq!(estimate_tokens(&msg), 14);
1966    }
1967
1968    #[test]
1969    fn estimate_tokens_compaction_summary() {
1970        let msg = SessionMessage::CompactionSummary {
1971            summary: "a".repeat(80),
1972            tokens_before: 5000,
1973        };
1974        // 80 chars -> ceil(80/3) = 27
1975        assert_eq!(estimate_tokens(&msg), 27);
1976    }
1977
1978    // ── prepare_compaction ──────────────────────────────────────────
1979
1980    #[test]
1981    fn prepare_compaction_empty() {
1982        assert!(prepare_compaction(&[], ResolvedCompactionSettings::default()).is_none());
1983    }
1984
1985    #[test]
1986    fn prepare_compaction_last_is_compaction_returns_none() {
1987        let entries = vec![user_entry("1", "hello"), compact_entry("2", "summary", 100)];
1988        assert!(prepare_compaction(&entries, ResolvedCompactionSettings::default()).is_none());
1989    }
1990
1991    #[test]
1992    fn prepare_compaction_no_messages_to_summarize_returns_none() {
1993        // Only non-message entries that produce no summarizable messages
1994        let entries = vec![SessionEntry::ModelChange(ModelChangeEntry {
1995            base: test_base("1"),
1996            provider: "test".to_string(),
1997            model_id: "model".to_string(),
1998        })];
1999        assert!(prepare_compaction(&entries, ResolvedCompactionSettings::default()).is_none());
2000    }
2001
2002    #[test]
2003    fn prepare_compaction_basic_returns_some() {
2004        let long_text = "a".repeat(100_000);
2005        let entries = vec![
2006            user_entry("1", &long_text),
2007            assistant_entry("2", &long_text, 50000, 25000),
2008            user_entry("3", &long_text),
2009            assistant_entry("4", &long_text, 80000, 30000),
2010            user_entry("5", "recent"),
2011        ];
2012        let settings = ResolvedCompactionSettings {
2013            enabled: true,
2014            context_window_tokens: 100_000,
2015            reserve_tokens: 1000,
2016            keep_recent_tokens: 100,
2017        };
2018        let prep = prepare_compaction(&entries, settings);
2019        assert!(prep.is_some());
2020        let p = prep.unwrap();
2021        assert!(!p.messages_to_summarize.is_empty());
2022        assert!(p.tokens_before > 0);
2023        assert!(p.previous_summary.is_none());
2024    }
2025
2026    #[test]
2027    fn prepare_compaction_after_previous_compaction() {
2028        let entries = vec![
2029            user_entry("1", "old message"),
2030            assistant_entry("2", "old response", 100, 50),
2031            compact_entry("3", "previous summary", 300),
2032            user_entry("4", &"x".repeat(100_000)),
2033            assistant_entry("5", &"y".repeat(100_000), 80000, 30000),
2034            user_entry("6", "recent"),
2035        ];
2036        let settings = ResolvedCompactionSettings {
2037            enabled: true,
2038            context_window_tokens: 100_000,
2039            reserve_tokens: 1000,
2040            keep_recent_tokens: 100,
2041        };
2042        let prep = prepare_compaction(&entries, settings);
2043        assert!(prep.is_some());
2044        let p = prep.unwrap();
2045        assert_eq!(p.previous_summary.as_deref(), Some("previous summary"));
2046    }
2047
2048    #[test]
2049    fn prepare_compaction_tracks_file_ops() {
2050        let entries = vec![
2051            tool_call_entry("1", "read", "/src/main.rs"),
2052            tool_result_entry("1r", "ok"),
2053            tool_call_entry("2", "edit", "/src/lib.rs"),
2054            tool_result_entry("2r", "ok"),
2055            user_entry("3", &"x".repeat(100_000)),
2056            assistant_entry("4", &"y".repeat(100_000), 80000, 30000),
2057            user_entry("5", "recent"),
2058        ];
2059        let settings = ResolvedCompactionSettings {
2060            enabled: true,
2061            reserve_tokens: 1000,
2062            keep_recent_tokens: 100,
2063            ..Default::default()
2064        };
2065        if let Some(prep) = prepare_compaction(&entries, settings) {
2066            let has_read = prep.file_ops.read.contains("/src/main.rs");
2067            let has_edit = prep.file_ops.edited.contains("/src/lib.rs");
2068            // At least one should be tracked (depends on cut point position)
2069            assert!(has_read || has_edit || prep.file_ops.read.is_empty());
2070        }
2071    }
2072
2073    // ── FileOperations::read_files ──────────────────────────────────
2074
2075    #[test]
2076    fn file_operations_read_files_iterator() {
2077        let mut ops = FileOperations::default();
2078        ops.read.insert("/a.rs".to_string());
2079        ops.read.insert("/b.rs".to_string());
2080        let files: Vec<&str> = ops.read_files().collect();
2081        assert_eq!(files.len(), 2);
2082        assert!(files.contains(&"/a.rs"));
2083        assert!(files.contains(&"/b.rs"));
2084    }
2085
2086    #[test]
2087    fn find_cut_point_includes_tool_result_when_needed() {
2088        // Setup:
2089        // 0. User (10)
2090        // 1. Assistant Call (10)
2091        // 2. Tool Result (100)
2092        // 3. User (10)
2093        // 4. Assistant (10)
2094        //
2095        // Keep recent = 100.
2096        // Accumulation from end:
2097        // 4: 10
2098        // 3: 20
2099        // 2: 120 (Threshold crossed at index 2)
2100        //
2101        // Index 2 is ToolResult (invalid cut point).
2102        // Valid cut points: 0, 1, 3, 4.
2103        //
2104        // Logic should pick closest valid cut point <= 2, which is 1.
2105        // If it picked >= 2, it would pick 3, discarding the ToolResult and Call (keeping only 20 tokens).
2106        // By picking 1, we keep 1..4 (130 tokens).
2107
2108        // Create entries with controlled lengths.
2109        // With chars/token ~=3, 400 chars => ceil(400/3)=134 tokens.
2110        let tr_text = "x".repeat(400);
2111        let entries = vec![
2112            user_entry("0", "user"),              // Valid
2113            assistant_entry("1", "call", 10, 10), // Valid (Assistant)
2114            tool_result_entry("2", &tr_text),     // Invalid
2115            user_entry("3", "user"),              // Valid
2116            assistant_entry("4", "resp", 10, 10), // Valid
2117        ];
2118
2119        // Verify token estimates (approx)
2120        // 0: ceil(4/3) = 2
2121        // 1: ceil(4/3) = 2
2122        // 2: ceil(400/3) = 134
2123        // 3: ceil(4/3) = 2
2124        // 4: ceil(4/3) = 2
2125        // Total recent needed: 100.
2126        // Accumulate: 4(2)+3(2)+2(134) = 138. Crossed at 2.
2127
2128        let settings = ResolvedCompactionSettings {
2129            enabled: true,
2130            context_window_tokens: 15,
2131            reserve_tokens: 0,
2132            keep_recent_tokens: 100,
2133        };
2134
2135        let prep = prepare_compaction(&entries, settings).expect("should compact");
2136
2137        // Cut point is index 1 (Assistant/Call). Because entries[1] is Assistant (not User),
2138        // this is a split turn: the turn started at index 0 (User). The User message at index 0
2139        // goes into turn_prefix_messages (not messages_to_summarize) because history_end = 0.
2140        assert_eq!(prep.first_kept_entry_id, "1");
2141
2142        // messages_to_summarize is entries[0..0] = empty (split-turn puts the
2143        // prefix in turn_prefix_messages instead).
2144        assert!(
2145            prep.messages_to_summarize.is_empty(),
2146            "split turn: user goes into turn prefix, not summarize"
2147        );
2148
2149        // turn_prefix_messages should contain the User message at index 0.
2150        assert_eq!(prep.turn_prefix_messages.len(), 1);
2151        match &prep.turn_prefix_messages[0] {
2152            SessionMessage::User { content, .. } => {
2153                if let UserContent::Text(t) = content {
2154                    assert_eq!(t, "user");
2155                } else {
2156                    panic!("wrong content");
2157                }
2158            }
2159            _ => panic!("expected user message in turn prefix"),
2160        }
2161    }
2162
2163    #[test]
2164    fn find_cut_point_should_not_discard_context_to_skip_tool_chain() {
2165        // Setup (estimate_tokens uses ceil(chars/3)):
2166        // 0. User "x"*4000 → 1334 tokens
2167        // 1. Assistant "x"*400 → 134 tokens
2168        // 2. Tool Result "x"*400 → 134 tokens
2169        // 3. User "next" → 2 tokens
2170        //
2171        // Keep recent = 150.
2172        // Accumulation (from end):
2173        // 3: 2
2174        // 2: 136
2175        // 1: 270 (Crosses 150) -> cut_index = 1
2176        //
2177        // The cut should land at index 1 (the assistant message), keeping
2178        // entries 1-3 and summarizing only entry 0.
2179
2180        let entries = vec![
2181            user_entry("0", &"x".repeat(4000)),             // 1000 tokens
2182            assistant_entry("1", &"x".repeat(400), 50, 50), // 100 tokens
2183            tool_result_entry("2", &"x".repeat(400)),       // 100 tokens
2184            user_entry("3", "next"),                        // 1 token
2185        ];
2186
2187        let settings = ResolvedCompactionSettings {
2188            enabled: true,
2189            context_window_tokens: 200,
2190            reserve_tokens: 0,
2191            keep_recent_tokens: 150,
2192        };
2193
2194        // We use prepare_compaction as the entry point
2195        let prep = prepare_compaction(&entries, settings).expect("should compact");
2196
2197        // We expect to keep from 1 (Assistant). The cut splits the turn
2198        // (user 0 + assistant 1), so user 0 goes into the turn prefix.
2199        assert_eq!(
2200            prep.first_kept_entry_id, "1",
2201            "Should start at Assistant message to preserve context"
2202        );
2203        assert!(
2204            prep.is_split_turn,
2205            "Cut should split the user/assistant turn"
2206        );
2207        assert_eq!(
2208            prep.turn_prefix_messages.len(),
2209            1,
2210            "User entry at index 0 should be in the turn prefix"
2211        );
2212        assert!(
2213            prep.messages_to_summarize.is_empty(),
2214            "Nothing before the turn to summarize"
2215        );
2216    }
2217
2218    mod proptest_compaction {
2219        use super::*;
2220        use proptest::prelude::*;
2221
2222        proptest! {
2223            /// `calculate_context_tokens`: if total > 0, returns total.
2224            #[test]
2225            fn calc_context_tokens_total_wins(
2226                input in 0..1_000_000u64,
2227                output in 0..1_000_000u64,
2228                total in 1..2_000_000u64,
2229            ) {
2230                let usage = Usage {
2231                    input,
2232                    output,
2233                    total_tokens: total,
2234                    ..Usage::default()
2235                };
2236                assert_eq!(calculate_context_tokens(&usage), total);
2237            }
2238
2239            /// `calculate_context_tokens`: if total == 0, returns input + output.
2240            #[test]
2241            fn calc_context_tokens_fallback(
2242                input in 0..1_000_000u64,
2243                output in 0..1_000_000u64,
2244            ) {
2245                let usage = Usage {
2246                    input,
2247                    output,
2248                    total_tokens: 0,
2249                    ..Usage::default()
2250                };
2251                assert_eq!(calculate_context_tokens(&usage), input + output);
2252            }
2253
2254            /// `should_compact` returns false when disabled.
2255            #[test]
2256            fn should_compact_disabled_returns_false(
2257                ctx_tokens in 0..1_000_000u64,
2258                window in 0..500_000u32,
2259            ) {
2260                let settings = ResolvedCompactionSettings {
2261                    enabled: false,
2262                    context_window_tokens: window,
2263                    reserve_tokens: 16_384,
2264                    keep_recent_tokens: 20_000,
2265                };
2266                assert!(!should_compact(ctx_tokens, window, &settings));
2267            }
2268
2269            /// `should_compact` threshold: tokens > window - reserve.
2270            #[test]
2271            fn should_compact_threshold(
2272                ctx_tokens in 0..500_000u64,
2273                window in 0..300_000u32,
2274                reserve in 0..100_000u32,
2275            ) {
2276                let settings = ResolvedCompactionSettings {
2277                    enabled: true,
2278                    context_window_tokens: window,
2279                    reserve_tokens: reserve,
2280                    keep_recent_tokens: 20_000,
2281                };
2282                let threshold = u64::from(window).saturating_sub(u64::from(reserve));
2283                let result = should_compact(ctx_tokens, window, &settings);
2284                assert_eq!(result, ctx_tokens > threshold);
2285            }
2286
2287            /// `format_file_operations`: empty lists produce empty string.
2288            #[test]
2289            fn format_file_ops_empty(_dummy in 0..10u32) {
2290                let result = format_file_operations(&[], &[]);
2291                assert!(result.is_empty());
2292            }
2293
2294            /// `format_file_operations`: read files produce `<read-files>` tag.
2295            #[test]
2296            fn format_file_ops_read_tag(
2297                files in prop::collection::vec("[a-z./]{1,20}", 1..5),
2298            ) {
2299                let result = format_file_operations(&files, &[]);
2300                assert!(result.contains("<read-files>"));
2301                assert!(result.contains("</read-files>"));
2302                assert!(!result.contains("<modified-files>"));
2303                for f in &files {
2304                    assert!(result.contains(f.as_str()));
2305                }
2306            }
2307
2308            /// `format_file_operations`: modified files produce `<modified-files>` tag.
2309            #[test]
2310            fn format_file_ops_modified_tag(
2311                files in prop::collection::vec("[a-z./]{1,20}", 1..5),
2312            ) {
2313                let result = format_file_operations(&[], &files);
2314                assert!(!result.contains("<read-files>"));
2315                assert!(result.contains("<modified-files>"));
2316                assert!(result.contains("</modified-files>"));
2317                for f in &files {
2318                    assert!(result.contains(f.as_str()));
2319                }
2320            }
2321
2322            /// `compute_file_lists`: modified = edited ∪ written, read_only = read \ modified.
2323            #[test]
2324            fn compute_file_lists_set_algebra(
2325                read in prop::collection::hash_set("[a-z]{1,5}", 0..5),
2326                written in prop::collection::hash_set("[a-z]{1,5}", 0..5),
2327                edited in prop::collection::hash_set("[a-z]{1,5}", 0..5),
2328            ) {
2329                let file_ops = FileOperations {
2330                    read: read.clone(),
2331                    written: written.clone(),
2332                    edited: edited.clone(),
2333                };
2334                let (read_only, modified) = compute_file_lists(&file_ops);
2335                // Modified = edited ∪ written
2336                let expected_modified: HashSet<&String> =
2337                    edited.iter().chain(written.iter()).collect();
2338                let actual_modified: HashSet<&String> = modified.iter().collect();
2339                assert_eq!(actual_modified, expected_modified);
2340                // Read-only = read \ modified (no overlap)
2341                for f in &read_only {
2342                    assert!(!modified.contains(f), "overlap: {f}");
2343                    assert!(read.contains(f));
2344                }
2345                // Both are sorted
2346                for pair in read_only.windows(2) {
2347                    assert!(pair[0] <= pair[1]);
2348                }
2349                for pair in modified.windows(2) {
2350                    assert!(pair[0] <= pair[1]);
2351                }
2352            }
2353        }
2354    }
2355}