Skip to main content

atman_runtime/
compaction.rs

1use crate::message::{Message, MessagePart, MessageRole};
2
3pub const KEEP_RECENT_MESSAGES: usize = 10;
4pub const KEEP_RECENT_USER_TURNS: usize = 5;
5const KEEP_RECENT_TOKEN_FRACTION: f64 = 0.05;
6
7pub fn estimate_tokens_for_message(msg: &Message) -> u64 {
8    let mut chars = 0usize;
9    for part in &msg.parts {
10        chars += match part {
11            MessagePart::CompactSummary { summary, .. } => summary.len(),
12            MessagePart::Text { text } => text.len(),
13            MessagePart::Thinking { thinking, .. } => thinking.len(),
14            MessagePart::ToolResult { content, .. } => content.len(),
15            MessagePart::Image { .. } => 512,
16            MessagePart::ToolUse { name, input, .. } => name.len() + input.to_string().len(),
17        };
18    }
19    chars = chars.saturating_add(estimate_role_overhead(msg.role));
20    (chars as f64 / 3.5).ceil() as u64
21}
22
23fn estimate_role_overhead(role: MessageRole) -> usize {
24    match role {
25        MessageRole::System => 12,
26        MessageRole::User => 8,
27        MessageRole::Assistant => 8,
28        MessageRole::Tool => 16,
29    }
30}
31
32pub fn estimate_tokens_for_messages(messages: &[Message]) -> u64 {
33    messages.iter().map(estimate_tokens_for_message).sum()
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct CompactRange {
38    pub start: usize,
39    pub end: usize,
40    pub tokens_saved_estimate: u64,
41}
42
43pub fn is_plan_related(msg: &Message) -> bool {
44    for part in &msg.parts {
45        match part {
46            MessagePart::ToolUse { name, .. } if name.starts_with("plan.") => return true,
47            MessagePart::ToolResult { content, .. } if content.starts_with("# Plan:") => {
48                return true;
49            }
50            _ => {}
51        }
52    }
53    false
54}
55
56pub fn is_compaction_summary(msg: &Message) -> bool {
57    if !matches!(msg.role, MessageRole::System) {
58        return false;
59    }
60    msg.parts
61        .iter()
62        .any(|part| matches!(part, MessagePart::CompactSummary { .. }))
63}
64
65fn find_kth_recent_user(messages: &[Message], k: usize) -> usize {
66    let mut user_count = 0;
67    for (index, message) in messages.iter().enumerate().rev() {
68        if message.role == MessageRole::User {
69            user_count += 1;
70            if user_count == k {
71                return index;
72            }
73        }
74    }
75    0
76}
77
78pub fn find_compact_range(messages: &[Message], budget: u64) -> Option<CompactRange> {
79    let total = estimate_tokens_for_messages(messages);
80    if total <= budget || messages.len() < 4 {
81        return None;
82    }
83
84    let start = messages
85        .iter()
86        .rposition(is_compaction_summary)
87        .unwrap_or(0);
88    let keep_recent_tokens = (budget as f64 * KEEP_RECENT_TOKEN_FRACTION).ceil() as u64;
89    let mut recent_tokens = 0u64;
90    let mut token_end = messages.len();
91    for (index, message) in messages.iter().enumerate().rev() {
92        recent_tokens = recent_tokens.saturating_add(estimate_tokens_for_message(message));
93        token_end = index;
94        if recent_tokens >= keep_recent_tokens {
95            break;
96        }
97    }
98    let message_end = messages.len().saturating_sub(KEEP_RECENT_MESSAGES);
99    let end = message_end
100        .min(token_end)
101        .min(find_kth_recent_user(messages, KEEP_RECENT_USER_TURNS));
102    if end < start + 2 {
103        return None;
104    }
105
106    let tokens_saved_estimate = messages[start..end]
107        .iter()
108        .map(estimate_tokens_for_message)
109        .sum();
110    Some(CompactRange {
111        start,
112        end,
113        tokens_saved_estimate,
114    })
115}
116
117pub fn estimate_compacted_message_tokens(
118    messages: &[Message],
119    range: &CompactRange,
120    summary: &str,
121) -> u64 {
122    let turn_id = messages
123        .get(range.start)
124        .map(|m| m.turn_id.clone())
125        .unwrap_or_else(crate::event::TurnId::now);
126    let after = replace_range_with_summary(messages, range, summary.to_string(), turn_id);
127    estimate_tokens_for_messages(&after)
128}
129
130pub fn filter_orphan_tool_messages(messages: &mut Vec<Message>) {
131    let use_ids: std::collections::HashSet<String> = messages
132        .iter()
133        .flat_map(|m| {
134            m.parts.iter().filter_map(|p| match p {
135                MessagePart::ToolUse { id, .. } => Some(id.clone()),
136                _ => None,
137            })
138        })
139        .collect();
140    let mut seen_results: std::collections::HashSet<String> = std::collections::HashSet::new();
141    messages.retain(|m| {
142        for p in &m.parts {
143            if let MessagePart::ToolResult { tool_use_id, .. } = p {
144                if !use_ids.contains(tool_use_id) {
145                    return false;
146                }
147                if !seen_results.insert(tool_use_id.clone()) {
148                    return false;
149                }
150            }
151        }
152        true
153    });
154}
155
156pub fn find_compact_summaries(messages: &[Message]) -> Vec<CompactSummary> {
157    let mut out = Vec::new();
158    for (idx, msg) in messages.iter().enumerate() {
159        if let Some(summary) = compact_summary(msg) {
160            out.push(CompactSummary {
161                message_index: idx,
162                seq_start: summary.seq_start,
163                seq_end: summary.seq_end,
164                count: summary.count,
165            });
166        }
167    }
168    out
169}
170
171#[derive(Debug, Clone, PartialEq, Eq)]
172pub struct CompactSummary {
173    pub message_index: usize,
174    pub seq_start: u64,
175    pub seq_end: u64,
176    pub count: usize,
177}
178
179struct CompactSummaryPart {
180    seq_start: u64,
181    seq_end: u64,
182    count: usize,
183}
184
185fn extract_anchor(messages: &[Message]) -> Option<(String, &[Message])> {
186    let first = messages.first()?;
187    let summary = first.parts.iter().find_map(|part| match part {
188        MessagePart::CompactSummary { summary, .. } => Some(summary.clone()),
189        _ => None,
190    })?;
191    Some((summary, &messages[1..]))
192}
193
194fn compact_summary(msg: &Message) -> Option<CompactSummaryPart> {
195    if msg.role != MessageRole::System {
196        return None;
197    }
198    msg.parts.iter().find_map(|part| match part {
199        MessagePart::CompactSummary {
200            seq_start,
201            seq_end,
202            count,
203            ..
204        } => Some(CompactSummaryPart {
205            seq_start: *seq_start,
206            seq_end: *seq_end,
207            count: *count,
208        }),
209        _ => None,
210    })
211}
212
213pub async fn maybe_auto_compact(
214    session: &crate::session::Session,
215    model: &str,
216    providers: &crate::provider::ProviderRegistry,
217) {
218    let _compact_guard = session.acquire_compact_lock().await;
219    maybe_auto_compact_locked(session, model, providers).await;
220}
221
222pub fn spawn_auto_compact(
223    session: std::sync::Arc<crate::session::Session>,
224    model: String,
225    providers: crate::provider::ProviderRegistry,
226) {
227    tokio::task::spawn_blocking(move || {
228        let Ok(rt) = tokio::runtime::Builder::new_current_thread()
229            .enable_all()
230            .build()
231        else {
232            session.push_system_note("compaction skipped: background runtime init failed".into());
233            return;
234        };
235        rt.block_on(async move {
236            maybe_auto_compact(&session, &model, &providers).await;
237        });
238    });
239}
240
241pub async fn start_auto_compact(
242    session: std::sync::Arc<crate::session::Session>,
243    model: String,
244    providers: crate::provider::ProviderRegistry,
245) {
246    let compact_guard = session.acquire_compact_lock_owned().await;
247    tokio::task::spawn_blocking(move || {
248        let Ok(rt) = tokio::runtime::Builder::new_current_thread()
249            .enable_all()
250            .build()
251        else {
252            drop(compact_guard);
253            session.push_system_note("compaction skipped: background runtime init failed".into());
254            return;
255        };
256        rt.block_on(async move {
257            maybe_auto_compact_locked(&session, &model, &providers).await;
258            drop(compact_guard);
259        });
260    });
261}
262
263async fn maybe_auto_compact_locked(
264    session: &crate::session::Session,
265    model: &str,
266    providers: &crate::provider::ProviderRegistry,
267) {
268    let forced = session.take_manual_compact_request();
269    let info = crate::model_registry::model_info(model);
270    let trigger = info.compaction_trigger_threshold();
271    let target = info.compaction_target_after();
272    let msgs = session.messages();
273    let provider_tokens = session.last_input_tokens();
274    let current = if provider_tokens > 0 {
275        provider_tokens
276    } else {
277        estimate_tokens_for_messages(&msgs)
278    };
279    if !forced && current <= trigger {
280        return;
281    }
282    if !forced && !session.approval_cooldown_ok_for_compact() {
283        return;
284    }
285    let Some(range) = find_compact_range(&msgs, target) else {
286        session.emit_compact_warning(
287            model,
288            current,
289            trigger,
290            info.context_budget,
291            "no compactible span — history too short or already fully compacted",
292        );
293        return;
294    };
295    let _ = session
296        .stream_tx()
297        .send(crate::stream::StreamFrame::CompactionSummary {
298            phase: crate::stream::CompactionPhase::Running,
299            range_start: range.start,
300            range_end: range.end.saturating_sub(1),
301            summary: String::new(),
302            before_tokens: current,
303            after_tokens: 0,
304            compacted_count: range.end - range.start,
305        });
306    let send_failed = |session: &crate::session::Session, reason: &str| {
307        let _ = session
308            .stream_tx()
309            .send(crate::stream::StreamFrame::CompactionSummary {
310                phase: crate::stream::CompactionPhase::Failed,
311                range_start: range.start,
312                range_end: range.end.saturating_sub(1),
313                summary: reason.to_string(),
314                before_tokens: current,
315                after_tokens: current,
316                compacted_count: range.end - range.start,
317            });
318    };
319    let mut filtered: Vec<Message> = msgs[range.start..range.end].to_vec();
320    filter_orphan_tool_messages(&mut filtered);
321    let (anchor, new_messages) = extract_anchor(&filtered)
322        .map(|(anchor, remaining)| (Some(anchor), remaining.to_vec()))
323        .unwrap_or_else(|| (None, filtered.clone()));
324    let summary =
325        match generate_llm_summary(anchor.as_deref(), &new_messages, model, providers).await {
326            Ok(text) => text,
327            Err(err) => {
328                session.emit_compact_warning(
329                    model,
330                    current,
331                    trigger,
332                    info.context_budget,
333                    &format!("LLM summary failed: {err}. Degraded to placeholder."),
334                );
335                format!(
336                    "[atman: compacted {} messages, LLM summary unavailable at {}]",
337                    range.end - range.start,
338                    chrono::Utc::now().to_rfc3339()
339                )
340            }
341        };
342    let final_summary =
343        match request_review_if_enabled(session, forced, &filtered, &range, current, summary).await
344        {
345            ReviewOutcome::Commit(s) => s,
346            ReviewOutcome::Rejected => {
347                send_failed(
348                    session,
349                    "compaction rejected by user; keeping full transcript",
350                );
351                session.push_system_note(
352                    "compaction rejected by user; keeping full transcript".into(),
353                );
354                return;
355            }
356        };
357    let after_tokens = estimate_compacted_message_tokens(&msgs, &range, &final_summary);
358    if after_tokens >= current {
359        send_failed(
360            session,
361            &format!(
362                "compaction skipped: summary would not shrink transcript ({} >= {} tokens)",
363                after_tokens, current
364            ),
365        );
366        session.push_system_note(format!(
367            "compaction skipped: summary would not shrink transcript ({} >= {} tokens)",
368            after_tokens, current
369        ));
370        return;
371    }
372    match session.compact_messages(final_summary, range, current) {
373        Some(result) => {
374            session.push_system_note(format!(
375                "auto-compacted {}..{} — {} → {} tokens",
376                result.compacted_start,
377                result.compacted_end,
378                result.before_tokens,
379                result.after_tokens
380            ));
381        }
382        None => {
383            session.emit_compact_warning(
384                model,
385                current,
386                trigger,
387                info.context_budget,
388                "no compactible span — history too short or already fully compacted",
389            );
390        }
391    }
392}
393
394enum ReviewOutcome {
395    Commit(String),
396    Rejected,
397}
398
399async fn request_review_if_enabled(
400    session: &crate::session::Session,
401    forced: bool,
402    slice: &[Message],
403    range: &CompactRange,
404    tokens_before: u64,
405    summary: String,
406) -> ReviewOutcome {
407    if !session.compact_review_mode().should_review(forced) {
408        return ReviewOutcome::Commit(summary);
409    }
410    let reviews = session.compact_reviews();
411    if reviews.subscriber_count() == 0 {
412        return ReviewOutcome::Commit(summary);
413    }
414    let pending = crate::session::PendingCompactReview {
415        review_id: uuid::Uuid::now_v7().to_string(),
416        summary: summary.clone(),
417        slice_preview: format_slice_for_preview(slice),
418        slice_count: slice.len(),
419        range_start: range.start,
420        range_end: range.end,
421        tokens_before,
422        emitted_at: chrono::Utc::now(),
423    };
424    let rx = reviews.request(pending);
425    match rx.await {
426        Ok(crate::session::CompactReviewDecision::AcceptAsIs) => ReviewOutcome::Commit(summary),
427        Ok(crate::session::CompactReviewDecision::AcceptEdited { summary: edited }) => {
428            ReviewOutcome::Commit(edited)
429        }
430        Ok(crate::session::CompactReviewDecision::Reject) | Err(_) => ReviewOutcome::Rejected,
431    }
432}
433
434fn format_slice_for_preview(slice: &[Message]) -> String {
435    let mut out = String::new();
436    for (i, msg) in slice.iter().enumerate() {
437        let role = msg.role.as_str();
438        let body = serialize_message_for_summary(msg);
439        let truncated: String = body.chars().take(400).collect();
440        out.push_str(&format!("[{i}] {role}: {truncated}\n"));
441    }
442    out.chars().take(16_000).collect()
443}
444
445const SUMMARY_SYSTEM_PROMPT: &str =
446    "You are an anchored context summarization assistant for coding sessions.";
447
448const SUMMARY_INSTRUCTIONS: &str = r#"You are an anchored context summarization assistant.
449
450Below is:
4511. <current-anchor>: the existing handoff state, which is authoritative and must be preserved.
4522. <new-messages>: only the messages that arrived since the anchor was written.
453
454Merge the NEW facts from <new-messages> INTO the current anchor, producing an upgraded full anchor.
455
456STRUCTURAL RULES (data model, not optional style):
457- ## Objective: unchanged unless the new messages show the user explicitly redirected.
458- ### Completed: ONLY ADD newly completed items. Never remove or re-evaluate an existing completed item. If a completed item is now in question, add it to ### Active or ### Blocked instead. NEVER delete from Completed.
459- ### Active: update based on new messages; move newly-done items to Completed.
460- ### Blocked: update based on new messages; remove resolved ones.
461- ## Decisions: only add new decisions. Never remove old ones.
462- ## Next Move: replace based on current end state.
463- Keep every section, even when empty.
464- Preserve exact file paths, symbols, commands, error strings, identifiers.
465
466Output exactly this Markdown structure:
467## Objective
468## Important Details
469## Work State
470### Completed
471### Active
472### Blocked
473## Decisions
474## Next Move
475## Relevant Files
476
477Do not mention the summary process or that context was compacted.
478Respond in the same language as the conversation."#;
479
480async fn generate_llm_summary(
481    anchor: Option<&str>,
482    slice: &[Message],
483    model: &str,
484    providers: &crate::provider::ProviderRegistry,
485) -> Result<String, crate::error::RuntimeError> {
486    let provider = providers.resolve(model).ok_or_else(|| {
487        crate::error::RuntimeError::ToolFailed(format!("no provider for {model}"))
488    })?;
489    let payload = format_slice_for_summary(slice);
490    let (messages, dump_user) = if let Some(anchor) = anchor {
491        let anchor_user = format!("<current-anchor>\n{anchor}\n</current-anchor>");
492        let new_user =
493            format!("<new-messages>\n{payload}\n</new-messages>\n\n{SUMMARY_INSTRUCTIONS}");
494        (
495            vec![
496                Message::user_text(crate::event::TurnId::now(), anchor_user.clone()),
497                Message::user_text(crate::event::TurnId::now(), new_user.clone()),
498            ],
499            format!("{anchor_user}\n\n{new_user}"),
500        )
501    } else {
502        let user = format!(
503            "<conversation_history>\n{payload}\n</conversation_history>\n\n{SUMMARY_INSTRUCTIONS}"
504        );
505        (
506            vec![Message::user_text(
507                crate::event::TurnId::now(),
508                user.clone(),
509            )],
510            user,
511        )
512    };
513    if let Ok(dir) = std::env::var("ATMAN_COMPACT_DUMP") {
514        let _ = std::fs::write(
515            format!("{dir}/compact_request.txt"),
516            format!("=== SYSTEM ===\n{SUMMARY_SYSTEM_PROMPT}\n\n=== USER ===\n{dump_user}"),
517        );
518    }
519    let req = crate::provider::LlmRequest {
520        model: model.into(),
521        messages,
522        system: Some(SUMMARY_SYSTEM_PROMPT.into()),
523        input: crate::value::Value::Unit,
524        schema: None,
525        cache_prompt: false,
526        tools: Vec::new(),
527        thinking_enabled: false,
528        stall_timeout_secs: 0,
529    };
530    let outcome = provider.call(req).await?;
531    let text = outcome.text_concat();
532    if text.trim().is_empty() {
533        return Err(crate::error::RuntimeError::ToolFailed(
534            "empty summary from provider".into(),
535        ));
536    }
537    Ok(text)
538}
539
540fn format_slice_for_summary(slice: &[Message]) -> String {
541    let mut out = String::new();
542    for (i, msg) in slice.iter().enumerate() {
543        let role = msg.role.as_str();
544        let body = serialize_message_for_summary(msg);
545        let truncated: String = body.chars().take(4000).collect();
546        out.push_str(&format!("[{i}] {role}: {truncated}\n\n"));
547    }
548    out.chars().take(120_000).collect()
549}
550
551fn serialize_message_for_summary(msg: &Message) -> String {
552    let mut parts = Vec::new();
553    for part in &msg.parts {
554        match part {
555            MessagePart::CompactSummary { summary, .. } => {
556                parts.push(summary.clone());
557            }
558            MessagePart::Text { text } => {
559                parts.push(text.clone());
560            }
561            MessagePart::Thinking { thinking, .. } => {
562                let truncated: String = thinking.chars().take(1000).collect();
563                parts.push(format!("[thinking: {truncated}]"));
564            }
565            MessagePart::ToolUse { name, input, .. } => {
566                let input_str = if input.is_null() {
567                    String::new()
568                } else {
569                    input.to_string()
570                };
571                let truncated: String = input_str.chars().take(2000).collect();
572                parts.push(format!("[tool_call: {name}({truncated})]"));
573            }
574            MessagePart::ToolResult {
575                content,
576                is_error,
577                tool_use_id,
578            } => {
579                let truncated: String = content.chars().take(3000).collect();
580                let marker = if *is_error { "ERROR" } else { "ok" };
581                let id_short: String = tool_use_id.chars().take(12).collect();
582                parts.push(format!("[tool_result {id_short}… {marker}: {truncated}]"));
583            }
584            MessagePart::Image { .. } => {
585                parts.push("[image]".into());
586            }
587        }
588    }
589    parts.join(" ")
590}
591
592pub fn replace_range_with_summary(
593    messages: &[Message],
594    range: &CompactRange,
595    summary: String,
596    turn_id: crate::event::TurnId,
597) -> Vec<Message> {
598    let mut out = Vec::with_capacity(1 + messages.len().saturating_sub(range.end));
599    out.push(Message::system_compact_summary(
600        turn_id,
601        summary,
602        range.start as u64,
603        range.end.saturating_sub(1) as u64,
604        range.end - range.start,
605    ));
606    out.extend_from_slice(&messages[range.end..]);
607    out
608}
609
610/// Result of compacting a messages_handle in place.
611#[derive(Debug, Clone, PartialEq, Eq)]
612pub struct HandleCompactResult {
613    pub before_tokens: u64,
614    pub after_tokens: u64,
615    pub compacted_start: usize,
616    pub compacted_end: usize,
617}
618
619/// Compact a messages_handle in place (data-layer primitive, operates on any
620/// FlowRun's segment). Returns `None` if under `budget` or no compactable
621/// range. Caller should hold the FlowRun's `compact_lock`.
622pub fn compact_messages_on_handle(
623    handle: &std::sync::Arc<std::sync::Mutex<Vec<Message>>>,
624    summary: String,
625    budget: u64,
626) -> Option<HandleCompactResult> {
627    let mut msgs = handle.lock().unwrap();
628    let before_tokens = estimate_tokens_for_messages(&msgs);
629    let range = find_compact_range(&msgs, budget)?;
630    let turn_id = msgs
631        .get(range.start)
632        .map(|m| m.turn_id.clone())
633        .unwrap_or_else(crate::event::TurnId::now);
634    let after = replace_range_with_summary(&msgs, &range, summary, turn_id);
635    let after_tokens = estimate_tokens_for_messages(&after);
636    if after_tokens >= before_tokens {
637        return None;
638    }
639    let result = HandleCompactResult {
640        before_tokens,
641        after_tokens,
642        compacted_start: range.start,
643        compacted_end: range.end.saturating_sub(1),
644    };
645    *msgs = after;
646    Some(result)
647}
648
649#[cfg(test)]
650mod tests {
651    use super::*;
652    use crate::event::TurnId;
653
654    fn user(text: &str) -> Message {
655        Message::user_text(TurnId::now(), text)
656    }
657    fn assistant(text: &str) -> Message {
658        Message::assistant_text(TurnId::now(), text)
659    }
660    fn system(text: &str) -> Message {
661        Message::system_text(TurnId::now(), text)
662    }
663
664    #[test]
665    fn summary_instructions_keep_decisions_before_next_move() {
666        let objective = SUMMARY_INSTRUCTIONS
667            .find("## Objective")
668            .expect("objective");
669        let decisions = SUMMARY_INSTRUCTIONS
670            .find("## Decisions")
671            .expect("decisions");
672        let next_move = SUMMARY_INSTRUCTIONS
673            .find("## Next Move")
674            .expect("next move");
675        assert!(objective < decisions);
676        assert!(decisions < next_move);
677    }
678
679    #[test]
680    fn estimate_scales_with_char_length() {
681        let short = user("hi");
682        let long = user(&"x".repeat(3500));
683        assert!(estimate_tokens_for_message(&long) > estimate_tokens_for_message(&short) * 100);
684    }
685
686    #[test]
687    fn find_compact_returns_none_when_under_budget() {
688        let msgs = vec![user("a"), assistant("b"), user("c"), assistant("d")];
689        assert!(find_compact_range(&msgs, 1000).is_none());
690    }
691
692    #[test]
693    fn find_compact_returns_none_for_short_history() {
694        let msgs = vec![user(&"x".repeat(9000))];
695        assert!(find_compact_range(&msgs, 100).is_none());
696    }
697
698    #[test]
699    fn find_kth_recent_user_handles_exact_excess_and_mixed_history() {
700        let exact = vec![
701            user("u0"),
702            assistant("a0"),
703            system("s0"),
704            tool_result("call-0", "result", false),
705            user("u1"),
706            assistant("a1"),
707            user("u2"),
708            system("s1"),
709            user("u3"),
710            assistant("a3"),
711            user("u4"),
712        ];
713        // With exactly five users, the fifth recent user is the first message.
714        assert_eq!(find_kth_recent_user(&exact, KEEP_RECENT_USER_TURNS), 0);
715
716        let mut excess = exact.clone();
717        excess.push(user("u5"));
718        assert_eq!(find_kth_recent_user(&excess, KEEP_RECENT_USER_TURNS), 4);
719
720        let too_few = vec![user("a"), assistant("b"), assistant("c")];
721        assert_eq!(find_kth_recent_user(&too_few, KEEP_RECENT_USER_TURNS), 0);
722    }
723
724    #[test]
725    fn find_compact_range_preserves_minimum_recent_messages_without_anchor() {
726        let mut msgs = vec![system("head")];
727        msgs.extend((0..25).map(|index| assistant(&format!("old {index}"))));
728        msgs.extend(
729            (0..6).flat_map(|index| [user(&format!("user {index}")), assistant("assistant")]),
730        );
731        let range = find_compact_range(&msgs, 1).expect("range");
732        assert_eq!(range.start, 0);
733        assert_eq!(range.end, msgs.len() - KEEP_RECENT_MESSAGES);
734    }
735
736    #[test]
737    fn find_compact_range_preserves_minimum_window_for_large_tool_result() {
738        let mut msgs = vec![user(&"h".repeat(500_000))];
739        for index in 1..31 {
740            if matches!(index, 20 | 22 | 24 | 26 | 28 | 30) {
741                msgs.push(user(&"u".repeat(800)));
742            } else {
743                msgs.push(assistant(&"a".repeat(800)));
744            }
745        }
746        msgs.push(tool_result("call-large", &"t".repeat(22_000), false));
747
748        let budget = 120_000;
749        let minimum_recent_tokens = (budget as f64 * KEEP_RECENT_TOKEN_FRACTION).ceil() as u64;
750        assert!(estimate_tokens_for_message(msgs.last().unwrap()) > minimum_recent_tokens);
751
752        let range = find_compact_range(&msgs, budget).expect("range");
753        assert!(
754            range.end <= msgs.len() - KEEP_RECENT_MESSAGES,
755            "range was {range:?}"
756        );
757        assert!(msgs.len() - range.end >= KEEP_RECENT_MESSAGES);
758        assert!(estimate_tokens_for_messages(&msgs[range.end..]) >= minimum_recent_tokens);
759    }
760
761    #[test]
762    fn find_compact_range_handles_four_to_twenty_one_message_histories() {
763        for len in 4..=21 {
764            let msgs = (0..len)
765                .map(|_| user(&"x".repeat(5000)))
766                .collect::<Vec<_>>();
767            assert_eq!(
768                find_compact_range(&msgs, 1).is_some(),
769                len >= KEEP_RECENT_MESSAGES + 2,
770                "len={len}"
771            );
772        }
773    }
774
775    #[test]
776    fn find_compact_range_recent_users_limit_mixed_history() {
777        let msgs = vec![
778            system("head"),
779            assistant("a0"),
780            user("u0"),
781            tool_result("call-0", "r0", false),
782            assistant("a1"),
783            user("u1"),
784            assistant("a2"),
785            tool_result("call-1", "r1", false),
786            user("u2"),
787            assistant("a3"),
788            system("note"),
789            user("u3"),
790            tool_result("call-2", "r2", false),
791            assistant("a4"),
792            user("u4"),
793            assistant("a5"),
794            tool_result("call-3", "r3", false),
795            user("u5"),
796            assistant("a6"),
797            system("tail"),
798            assistant("a7"),
799        ];
800
801        let range = find_compact_range(&msgs, 1).expect("range");
802        assert_eq!(range.end, 5);
803        assert_eq!(msgs[range.end].role, MessageRole::User);
804    }
805
806    #[test]
807    fn find_compact_range_preserves_recent_user_turns_after_anchor() {
808        let mut msgs = vec![system("head"), compaction_summary("summary")];
809        msgs.extend((0..6).flat_map(|index| {
810            [
811                user(&format!("user {index}")),
812                assistant("assistant"),
813                assistant("tool fragment"),
814            ]
815        }));
816        msgs.extend((0..12).map(|_| assistant("recent fragment")));
817        let range = find_compact_range(&msgs, 1).expect("range");
818        assert_eq!(range.start, 1);
819        assert_eq!(range.end, 5);
820        assert_eq!(msgs[range.end].role, MessageRole::User);
821    }
822
823    #[test]
824    fn find_compact_range_returns_none_when_end_cannot_cover_two_messages() {
825        let msgs = vec![
826            system("head"),
827            compaction_summary("summary"),
828            assistant("tail"),
829            user("tail"),
830        ];
831        assert!(find_compact_range(&msgs, 1).is_none());
832    }
833
834    #[test]
835    fn extract_anchor_removes_leading_compact_summary() {
836        let messages = vec![compaction_summary("anchor"), user("new")];
837        let (anchor, remaining) = extract_anchor(&messages).expect("anchor");
838        assert_eq!(anchor, "anchor");
839        assert_eq!(remaining, &messages[1..]);
840    }
841
842    #[test]
843    fn extract_anchor_returns_none_without_leading_summary() {
844        let messages = vec![user("new")];
845        assert!(extract_anchor(&messages).is_none());
846    }
847
848    #[test]
849    fn compact_messages_on_handle_replaces_range_in_place() {
850        let mut messages = vec![system("head")];
851        messages.extend((0..9).map(|index| assistant(&format!("old {index}"))));
852        messages.extend(
853            (0..6).flat_map(|index| [user(&format!("user {index}")), assistant(&"x".repeat(4000))]),
854        );
855        messages.extend((0..10).map(|index| assistant(&format!("recent {index}"))));
856        messages.push(user("tail"));
857        let handle: std::sync::Arc<std::sync::Mutex<Vec<Message>>> =
858            std::sync::Arc::new(std::sync::Mutex::new(messages));
859        // The recent-message and recent-user limits meet at the fifth recent user.
860        // The compacted prefix is replaced by one summary while the tail remains.
861        let result = compact_messages_on_handle(&handle, "gist".into(), 100);
862        let result = result.expect("should compact");
863        assert!(result.after_tokens < result.before_tokens);
864        let msgs = handle.lock().unwrap();
865        assert_eq!(result.compacted_start, 0);
866        assert_eq!(result.compacted_end, 13);
867        assert!(is_compaction_summary(&msgs[0]));
868        assert_eq!(msgs.last().unwrap().text_concat(), "tail");
869    }
870
871    #[test]
872    fn compact_messages_on_handle_none_when_under_budget() {
873        let handle: std::sync::Arc<std::sync::Mutex<Vec<Message>>> =
874            std::sync::Arc::new(std::sync::Mutex::new(vec![user("short")]));
875        assert!(compact_messages_on_handle(&handle, "g".into(), 100_000).is_none());
876    }
877
878    #[test]
879    fn compact_messages_on_handle_none_when_summary_would_not_shrink() {
880        // 4 messages, all tiny → find_compact_range returns a range but the
881        // summary message itself is comparable in size, so after >= before.
882        // Construct a case where find_compact_range returns Some but shrink
883        // check rejects it: make the range cover near-empty messages so the
884        // summary overhead exceeds the savings.
885        let handle: std::sync::Arc<std::sync::Mutex<Vec<Message>>> =
886            std::sync::Arc::new(std::sync::Mutex::new(vec![
887                system("h"),
888                user("."),
889                assistant("."),
890                user("."),
891                assistant("."),
892                user("t"),
893            ]));
894        // budget=1 forces a range, but messages are so small the summary won't help
895        let result = compact_messages_on_handle(&handle, "x".into(), 1);
896        // Either no range found (len 6 but tiny), or shrink rejected.
897        // The key invariant: handle is unchanged if None.
898        let before_len = handle.lock().unwrap().len();
899        if result.is_none() {
900            assert_eq!(handle.lock().unwrap().len(), before_len);
901        }
902    }
903
904    #[test]
905    fn replace_range_puts_summary_system_message_in_place() {
906        let msgs = vec![
907            system("head"),
908            user("m1"),
909            assistant("m2"),
910            user("m3"),
911            assistant("m4"),
912            user("tail"),
913        ];
914        let range = CompactRange {
915            start: 1,
916            end: 5,
917            tokens_saved_estimate: 100,
918        };
919        let out = replace_range_with_summary(
920            &msgs,
921            &range,
922            "gist: talked about m1..m4".into(),
923            TurnId::now(),
924        );
925        assert_eq!(out.len(), 2, "summary + tail");
926        assert_eq!(out[0].role, MessageRole::System);
927        assert!(out[0].text_concat().contains("gist: talked about"));
928        assert!(matches!(
929            out[0].parts.as_slice(),
930            [MessagePart::CompactSummary {
931                seq_start: 1,
932                seq_end: 4,
933                count: 4,
934                ..
935            }]
936        ));
937        assert_eq!(out[1].role, MessageRole::User);
938        assert_eq!(out[1].text_concat(), "tail");
939    }
940
941    #[test]
942    fn find_compact_range_anchors_on_latest_structured_summary() {
943        let mut msgs = vec![
944            system("head"),
945            Message::system_compact_summary(TurnId::now(), "old", 0, 1, 2),
946        ];
947        msgs.extend(
948            (0..6).flat_map(|index| [user(&format!("user {index}")), assistant("assistant")]),
949        );
950        msgs.extend((0..12).map(|index| assistant(&format!("recent {index}"))));
951        let range = find_compact_range(&msgs, 1).expect("range");
952        assert_eq!(range.start, 1);
953        assert_eq!(range.end, 4);
954    }
955
956    fn assistant_with_tool_use(text: &str, tool_name: &str, input: serde_json::Value) -> Message {
957        Message {
958            role: MessageRole::Assistant,
959            parts: vec![
960                MessagePart::Text { text: text.into() },
961                MessagePart::ToolUse {
962                    id: "call_test".into(),
963                    name: tool_name.into(),
964                    input,
965                },
966            ],
967            turn_id: TurnId::now(),
968        }
969    }
970
971    fn tool_result(id: &str, content: &str, is_error: bool) -> Message {
972        Message {
973            role: MessageRole::Tool,
974            parts: vec![MessagePart::ToolResult {
975                tool_use_id: id.into(),
976                content: content.into(),
977                is_error,
978            }],
979            turn_id: TurnId::now(),
980        }
981    }
982
983    fn thinking(text: &str) -> Message {
984        Message {
985            role: MessageRole::Assistant,
986            parts: vec![
987                MessagePart::Thinking {
988                    thinking: text.into(),
989                    signature: None,
990                },
991                MessagePart::Text {
992                    text: "after thinking".into(),
993                },
994            ],
995            turn_id: TurnId::now(),
996        }
997    }
998
999    #[test]
1000    fn format_slice_for_summary_includes_tool_use() {
1001        let slice = vec![
1002            user("read the file"),
1003            assistant_with_tool_use(
1004                "let me check",
1005                "fs.read",
1006                serde_json::json!({"path": "/tmp/foo.rs"}),
1007            ),
1008            tool_result("call_test", "fn main() {}", false),
1009        ];
1010        let out = format_slice_for_summary(&slice);
1011        assert!(out.contains("fs.read"), "missing tool name: {out}");
1012        assert!(out.contains("/tmp/foo.rs"), "missing tool input: {out}");
1013        assert!(
1014            out.contains("fn main()"),
1015            "missing tool_result content: {out}"
1016        );
1017        assert!(out.contains("tool_call"), "missing tool_call marker: {out}");
1018        assert!(
1019            out.contains("tool_result"),
1020            "missing tool_result marker: {out}"
1021        );
1022    }
1023
1024    #[test]
1025    fn format_slice_for_summary_includes_thinking() {
1026        let slice = vec![thinking("I should consider the edge case")];
1027        let out = format_slice_for_summary(&slice);
1028        assert!(out.contains("thinking"), "missing thinking marker: {out}");
1029        assert!(out.contains("edge case"), "missing thinking content: {out}");
1030    }
1031
1032    #[test]
1033    fn format_slice_for_summary_marks_error_tool_results() {
1034        let slice = vec![tool_result("call_1", "permission denied", true)];
1035        let out = format_slice_for_summary(&slice);
1036        assert!(out.contains("ERROR"), "missing ERROR marker: {out}");
1037    }
1038
1039    #[test]
1040    fn format_slice_for_summary_truncates_long_tool_input() {
1041        let long_input = serde_json::json!({"content": "x".repeat(5000)});
1042        let slice = vec![assistant_with_tool_use("check", "fs.write", long_input)];
1043        let out = format_slice_for_summary(&slice);
1044        let tool_call_line = out
1045            .lines()
1046            .find(|l| l.contains("tool_call"))
1047            .unwrap_or_else(|| panic!("no tool_call line in {out}"));
1048        assert!(
1049            tool_call_line.chars().count() < 2200,
1050            "tool_call line not truncated: {tool_call_line}"
1051        );
1052    }
1053
1054    fn compaction_summary(text: &str) -> Message {
1055        Message::system_compact_summary(TurnId::now(), text, 1, 5, 5)
1056    }
1057
1058    #[test]
1059    fn is_compaction_summary_detects_structured_variant() {
1060        assert!(is_compaction_summary(&compaction_summary("gist")));
1061        assert!(!is_compaction_summary(&system("plain system msg")));
1062        assert!(!is_compaction_summary(&user("user msg")));
1063    }
1064
1065    #[test]
1066    fn find_compact_range_spans_across_compaction_summaries() {
1067        let mut msgs = vec![
1068            system("head"),
1069            user(&"x".repeat(3000)),
1070            assistant(&"y".repeat(3000)),
1071            user(&"z".repeat(3000)),
1072            compaction_summary("first compaction summary"),
1073        ];
1074        msgs.extend(
1075            (0..6).flat_map(|index| [user(&format!("user {index}")), assistant(&"x".repeat(3000))]),
1076        );
1077        msgs.extend((0..10).map(|index| assistant(&format!("recent {index}"))));
1078        let range = find_compact_range(&msgs, 500).expect("expected range across summary");
1079        assert_eq!(
1080            range.start, 4,
1081            "range should anchor at the structured summary"
1082        );
1083        assert!(
1084            range.end > 4,
1085            "range should include later work, got {range:?}"
1086        );
1087        assert!(
1088            range.end - range.start >= 3,
1089            "range must cover >= 3 msgs, got {}",
1090            range.end - range.start
1091        );
1092    }
1093
1094    #[test]
1095    fn find_compact_starts_from_summary() {
1096        let mut msgs = vec![user("a"), assistant("b"), compaction_summary("summary 1")];
1097        msgs.extend(
1098            (0..6).flat_map(|index| [user(&format!("user {index}")), assistant("assistant")]),
1099        );
1100        msgs.extend((0..12).map(|index| assistant(&format!("recent {index}"))));
1101        let range = find_compact_range(&msgs, 10).expect("expected range");
1102        assert_eq!(
1103            range.start, 2,
1104            "should start from the compact summary anchor"
1105        );
1106        assert_eq!(range.end, 5, "the fifth recent user is retained");
1107    }
1108
1109    #[test]
1110    fn find_compact_range_includes_older_compaction_summaries() {
1111        let mut msgs = vec![compaction_summary("summary 0")];
1112        msgs.extend((0..6).flat_map(|index| {
1113            [
1114                user(&format!("old user {index}")),
1115                assistant(&"x".repeat(2000)),
1116            ]
1117        }));
1118        msgs.push(compaction_summary("summary 1"));
1119        msgs.extend((0..6).flat_map(|index| {
1120            [
1121                user(&format!("new user {index}")),
1122                assistant(&"z".repeat(2000)),
1123            ]
1124        }));
1125        msgs.extend((0..10).map(|index| assistant(&format!("tail {index}"))));
1126        let range = find_compact_range(&msgs, 500).expect("expected range");
1127        assert_eq!(range.start, 13, "should compact from the latest summary");
1128        assert!(
1129            range.end > range.start,
1130            "should include work after the latest summary"
1131        );
1132    }
1133
1134    #[test]
1135    fn compacted_message_tokens_detects_growth() {
1136        let msgs = vec![compaction_summary("summary 0"), user("a"), assistant("b")];
1137        let range = CompactRange {
1138            start: 1,
1139            end: 3,
1140            tokens_saved_estimate: 0,
1141        };
1142        let before = estimate_tokens_for_messages(&msgs);
1143        let after = estimate_compacted_message_tokens(
1144            &msgs,
1145            &range,
1146            "a very long summary that expands the transcript a lot",
1147        );
1148        assert!(after > before, "expected growth to be detectable");
1149    }
1150
1151    #[test]
1152    fn find_compact_starts_from_zero_without_summary() {
1153        let mut msgs = (0..26)
1154            .map(|index| assistant(&format!("old {index}")))
1155            .collect::<Vec<_>>();
1156        msgs.extend(
1157            (0..6).flat_map(|index| [user(&format!("user {index}")), assistant("assistant")]),
1158        );
1159        let range = find_compact_range(&msgs, 10).expect("expected range");
1160        assert_eq!(range.start, 0, "should start from 0 without summary");
1161        assert_eq!(range.end, 28, "the recent-message limit is retained");
1162    }
1163
1164    #[test]
1165    fn filter_orphan_tool_messages_removes_orphan_results() {
1166        use crate::message::{Message, MessagePart, MessageRole};
1167        let turn = TurnId::now();
1168        let msgs = vec![
1169            Message {
1170                role: MessageRole::Tool,
1171                parts: vec![MessagePart::ToolResult {
1172                    tool_use_id: "orphan".into(),
1173                    content: "no matching use".into(),
1174                    is_error: false,
1175                }],
1176                turn_id: turn.clone(),
1177            },
1178            Message {
1179                role: MessageRole::Assistant,
1180                parts: vec![MessagePart::ToolUse {
1181                    id: "call_1".into(),
1182                    name: "fs.read".into(),
1183                    input: serde_json::json!({}),
1184                }],
1185                turn_id: turn.clone(),
1186            },
1187            Message {
1188                role: MessageRole::Tool,
1189                parts: vec![MessagePart::ToolResult {
1190                    tool_use_id: "call_1".into(),
1191                    content: "ok".into(),
1192                    is_error: false,
1193                }],
1194                turn_id: turn,
1195            },
1196        ];
1197        let mut filtered = msgs;
1198        filter_orphan_tool_messages(&mut filtered);
1199        assert_eq!(filtered.len(), 2, "orphan result should be removed");
1200    }
1201}