Skip to main content

mermaid_cli/domain/
compaction.rs

1//! Conversation context compaction.
2//!
3//! The reducer/effect boundary treats compaction as a first-class
4//! operation: effects generate a checkpoint summary, the reducer swaps
5//! the model-visible history, and persistence archives the removed raw
6//! messages. This keeps compaction observable instead of hiding it inside
7//! a provider adapter.
8
9use chrono::{DateTime, Local};
10use serde::{Deserialize, Serialize};
11
12use crate::constants::{
13    COMPACTION_AUTO_THRESHOLD_PERCENT, COMPACTION_MAX_RESPONSE_RESERVE_TOKENS,
14    COMPACTION_MIN_RESPONSE_RESERVE_TOKENS, COMPACTION_SUMMARIZER_INPUT_TOKEN_BUDGET,
15    COMPACTION_SUMMARY_MAX_TOKENS, COMPACTION_TAIL_TOKEN_BUDGET, COMPACTION_TAIL_TURNS,
16    COMPACTION_TOOL_OUTPUT_MAX_CHARS,
17};
18use crate::models::{ChatMessage, ChatMessageKind, MessageRole, ReasoningLevel, TokenUsage};
19
20use super::cmd::ChatRequest;
21use super::state::ContextUsageSnapshot;
22
23const CHECKPOINT_MARKER: &str = "MERMAID CONTEXT CHECKPOINT";
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(rename_all = "snake_case")]
27pub enum CompactionTrigger {
28    Manual,
29    AutoThreshold,
30    ContextLimitRetry,
31    /// A response was truncated because the context window filled mid-turn;
32    /// compact and resume the run (see the reducer's truncation-recovery path).
33    TruncationRecovery,
34}
35
36impl CompactionTrigger {
37    pub fn as_str(self) -> &'static str {
38        match self {
39            Self::Manual => "manual",
40            Self::AutoThreshold => "auto_threshold",
41            Self::ContextLimitRetry => "context_limit_retry",
42            Self::TruncationRecovery => "truncation_recovery",
43        }
44    }
45
46    pub fn label(self) -> &'static str {
47        match self {
48            Self::Manual => "manual",
49            Self::AutoThreshold => "automatic",
50            Self::ContextLimitRetry => "context-limit retry",
51            Self::TruncationRecovery => "truncation recovery",
52        }
53    }
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
57pub struct CompactionPolicy {
58    pub auto_enabled: bool,
59    pub auto_threshold_percent: u8,
60    pub tail_turns: usize,
61    pub tail_token_budget: usize,
62    pub tool_output_max_chars: usize,
63    pub summary_max_tokens: usize,
64    pub summarizer_input_token_budget: usize,
65    pub min_response_reserve_tokens: usize,
66    pub max_response_reserve_tokens: usize,
67}
68
69impl Default for CompactionPolicy {
70    fn default() -> Self {
71        Self {
72            auto_enabled: true,
73            auto_threshold_percent: COMPACTION_AUTO_THRESHOLD_PERCENT,
74            tail_turns: COMPACTION_TAIL_TURNS,
75            tail_token_budget: COMPACTION_TAIL_TOKEN_BUDGET,
76            tool_output_max_chars: COMPACTION_TOOL_OUTPUT_MAX_CHARS,
77            summary_max_tokens: COMPACTION_SUMMARY_MAX_TOKENS,
78            summarizer_input_token_budget: COMPACTION_SUMMARIZER_INPUT_TOKEN_BUDGET,
79            min_response_reserve_tokens: COMPACTION_MIN_RESPONSE_RESERVE_TOKENS,
80            max_response_reserve_tokens: COMPACTION_MAX_RESPONSE_RESERVE_TOKENS,
81        }
82    }
83}
84
85impl CompactionPolicy {
86    /// Window room to hold back for the model's response when sizing
87    /// compaction. Decoupled from the on-wire output cap: an explicit user cap
88    /// is the best reserve estimate, but AUTO (`max_tokens == 0`) reserves the
89    /// baseline plus the reasoning headroom the level implies — a High/Max
90    /// turn needs more room before the window counts as "full".
91    pub fn response_reserve(self, request: &ChatRequest) -> usize {
92        let desired = if request.max_tokens > 0 {
93            request.max_tokens
94        } else {
95            self.min_response_reserve_tokens
96                + crate::models::adapters::output_budget::reasoning_output_reserve(
97                    request.reasoning,
98                )
99        };
100        desired
101            .max(self.min_response_reserve_tokens)
102            .min(self.max_response_reserve_tokens)
103    }
104}
105
106/// Why a `FinishReason::Length` stop happened.
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108pub enum LengthCause {
109    /// The per-response output cap was hit while the context window still had
110    /// room — compacting the *input* cannot help.
111    OutputCapped,
112    /// The context window itself is (nearly) full — compaction can help.
113    ContextFull,
114    /// No usage data to classify with; callers should keep the legacy
115    /// compact-and-continue behavior.
116    Unknown,
117}
118
119/// Classify a `FinishReason::Length` stop from the response usage and the
120/// known context window. The discriminator that holds even for providers whose
121/// window is unknown: a length-stop with window room to spare (or no known
122/// window at all — the normal remote-provider case) is the per-response output
123/// cap, not a full window. `usage == None` (common on tool follow-ups) stays
124/// `Unknown` so the caller preserves the legacy recovery path.
125pub fn classify_length_stop(
126    usage: Option<&TokenUsage>,
127    window: Option<usize>,
128    reserve: usize,
129) -> LengthCause {
130    let Some(u) = usage else {
131        return LengthCause::Unknown;
132    };
133    match window {
134        None => LengthCause::OutputCapped,
135        Some(w) => {
136            if u.total_tokens().saturating_add(reserve) >= w {
137                LengthCause::ContextFull
138            } else {
139                LengthCause::OutputCapped
140            }
141        },
142    }
143}
144
145#[derive(Debug, Clone)]
146pub struct CompactionRequest {
147    pub chat: ChatRequest,
148    pub trigger: CompactionTrigger,
149    pub instructions: Option<String>,
150    pub policy: CompactionPolicy,
151}
152
153impl CompactionRequest {
154    pub fn manual(chat: ChatRequest, instructions: Option<String>) -> Self {
155        Self {
156            chat,
157            trigger: CompactionTrigger::Manual,
158            instructions,
159            policy: CompactionPolicy::default(),
160        }
161    }
162
163    pub fn auto(chat: ChatRequest, trigger: CompactionTrigger) -> Self {
164        Self {
165            chat,
166            trigger,
167            instructions: None,
168            policy: CompactionPolicy::default(),
169        }
170    }
171}
172
173#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
174#[serde(rename_all = "snake_case")]
175pub enum CompactionReviewStatus {
176    Reviewed,
177    DraftValidated,
178}
179
180impl CompactionReviewStatus {
181    pub fn as_str(self) -> &'static str {
182        match self {
183            Self::Reviewed => "reviewed",
184            Self::DraftValidated => "draft_validated",
185        }
186    }
187}
188
189#[derive(Debug, Clone, Serialize, Deserialize)]
190pub struct CompactionRecord {
191    pub id: String,
192    pub trigger: CompactionTrigger,
193    pub created_at: DateTime<Local>,
194    pub before_tokens: usize,
195    pub after_tokens: usize,
196    pub archived_message_count: usize,
197    pub preserved_message_count: usize,
198    pub preserved_turn_count: usize,
199    pub summary_tokens: usize,
200    pub duration_secs: f64,
201    pub review_status: CompactionReviewStatus,
202    pub review_error: Option<String>,
203    #[serde(default)]
204    pub focus: Option<String>,
205    #[serde(default)]
206    pub archive_path: Option<String>,
207}
208
209#[derive(Debug, Clone, Serialize, Deserialize)]
210pub struct CompactionArchive {
211    pub id: String,
212    pub conversation_id: String,
213    pub created_at: DateTime<Local>,
214    pub messages: Vec<ChatMessage>,
215}
216
217#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
218pub struct CompactionResult {
219    pub record: CompactionRecord,
220    pub replacement_messages: Vec<ChatMessage>,
221    pub archived_messages: Vec<ChatMessage>,
222    pub before_snapshot: ContextUsageSnapshot,
223    pub after_snapshot: ContextUsageSnapshot,
224    pub usage: Option<TokenUsage>,
225    pub source_boundaries: Vec<CompactionBoundary>,
226}
227
228#[derive(Debug, Clone, Serialize, Deserialize)]
229pub struct CompactionBoundary {
230    /// sha256 hex over (role Debug, kind Debug, UTC RFC3339-nanos timestamp,
231    /// content), NUL-separated, content last. A fingerprint instead of a full
232    /// message clone: `source_boundaries` rides `CompactionFinished` into the
233    /// session recording, and cloning the entire pre-compaction transcript
234    /// would double both memory and recording size. UTC-normalized because
235    /// `DateTime<Local>` re-localizes on deserialize — a recording replayed
236    /// under a different TZ must still match on the instant.
237    pub fingerprint: String,
238}
239
240impl CompactionBoundary {
241    pub fn from_message(message: &ChatMessage) -> Self {
242        Self {
243            fingerprint: Self::fingerprint_of(message),
244        }
245    }
246
247    pub fn fingerprint_of(message: &ChatMessage) -> String {
248        use sha2::{Digest, Sha256};
249        use std::fmt::Write as _;
250        let mut hasher = Sha256::new();
251        hasher.update(format!("{:?}", message.role).as_bytes());
252        hasher.update([0u8]);
253        hasher.update(format!("{:?}", message.kind).as_bytes());
254        hasher.update([0u8]);
255        hasher.update(
256            message
257                .timestamp
258                .to_utc()
259                .to_rfc3339_opts(chrono::SecondsFormat::Nanos, true)
260                .as_bytes(),
261        );
262        hasher.update([0u8]);
263        hasher.update(message.content.as_bytes());
264        let digest = hasher.finalize();
265        let mut out = String::with_capacity(digest.len() * 2);
266        for byte in digest {
267            let _ = write!(out, "{byte:02x}");
268        }
269        out
270    }
271
272    pub fn matches(&self, message: &ChatMessage) -> bool {
273        Self::fingerprint_of(message) == self.fingerprint
274    }
275}
276
277#[derive(Debug, Clone)]
278pub struct PreparedCompaction {
279    pub archived_messages: Vec<ChatMessage>,
280    pub preserved_messages: Vec<ChatMessage>,
281    pub previous_summary: Option<String>,
282    pub history_excerpt: String,
283    pub summary_images: Vec<String>,
284}
285
286#[derive(Debug, Clone, PartialEq, Eq)]
287pub enum CompactionSkip {
288    NoKnownContextLimit,
289    AutoDisabled,
290    Suppressed,
291    BelowThreshold,
292    NothingToCompact,
293}
294
295impl std::fmt::Display for CompactionSkip {
296    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
297        match self {
298            Self::NoKnownContextLimit => write!(f, "model context limit is unknown"),
299            Self::AutoDisabled => write!(f, "automatic compaction is disabled"),
300            Self::Suppressed => write!(
301                f,
302                "automatic compaction is paused after a failed attempt; run /compact to retry"
303            ),
304            Self::BelowThreshold => write!(f, "context is below compaction threshold"),
305            Self::NothingToCompact => write!(f, "not enough conversation history to summarize"),
306        }
307    }
308}
309
310pub fn should_auto_compact(
311    snapshot: &ContextUsageSnapshot,
312    request: &ChatRequest,
313    policy: CompactionPolicy,
314) -> Result<(), CompactionSkip> {
315    if !policy.auto_enabled {
316        return Err(CompactionSkip::AutoDisabled);
317    }
318    if request.suppress_auto_compact {
319        return Err(CompactionSkip::Suppressed);
320    }
321    let Some(max_tokens) = snapshot.max_tokens else {
322        return Err(CompactionSkip::NoKnownContextLimit);
323    };
324    if max_tokens == 0 {
325        return Err(CompactionSkip::NoKnownContextLimit);
326    }
327
328    let reserve = policy.response_reserve(request);
329    let over_percent = snapshot
330        .used_percent
331        .is_some_and(|p| p >= policy.auto_threshold_percent);
332    let low_remaining = snapshot
333        .remaining_tokens
334        .is_some_and(|remaining| remaining <= reserve);
335    if over_percent || low_remaining {
336        Ok(())
337    } else {
338        Err(CompactionSkip::BelowThreshold)
339    }
340}
341
342pub fn context_exceeds_hard_limit(
343    snapshot: &ContextUsageSnapshot,
344    request: &ChatRequest,
345    policy: CompactionPolicy,
346) -> bool {
347    let Some(max_tokens) = snapshot.max_tokens else {
348        return false;
349    };
350    let reserve = policy.response_reserve(request);
351    snapshot.used_tokens.saturating_add(reserve) >= max_tokens
352}
353
354pub fn prepare_compaction(
355    request: &CompactionRequest,
356    max_context_tokens: Option<usize>,
357) -> Result<PreparedCompaction, CompactionSkip> {
358    let messages = &request.chat.messages;
359    if messages.len() < 3 {
360        return Err(CompactionSkip::NothingToCompact);
361    }
362
363    let split =
364        tail_start_index(messages, request.policy).ok_or(CompactionSkip::NothingToCompact)?;
365    if split == 0 {
366        return Err(CompactionSkip::NothingToCompact);
367    }
368
369    let archived_messages = messages[..split].to_vec();
370    let mut preserved_messages = messages[split..].to_vec();
371    if archived_messages.is_empty() || preserved_messages.is_empty() {
372        return Err(CompactionSkip::NothingToCompact);
373    }
374    // The tail is forwarded verbatim into the next request; scrub any
375    // pre-existing orphan `tool_use`/`tool_result` so an unpaired block can't
376    // 400 the provider (#71 forward, #F64 reverse). One exception: when the run
377    // is resuming mid-tool (a context-limit retry or truncation recovery), a
378    // genuinely-pending trailing `tool_use` is preserved so the model needn't
379    // re-derive the action from the summary (#F65). A user cancel produces the
380    // same trailing shape but ends the run, so only the resume triggers — where
381    // the awaited `tool_result` really is forthcoming — opt into preserving it.
382    let preserve_pending_tail = matches!(
383        request.trigger,
384        CompactionTrigger::ContextLimitRetry | CompactionTrigger::TruncationRecovery
385    );
386    drop_orphan_tool_calls(&mut preserved_messages, preserve_pending_tail);
387
388    let previous_summary = archived_messages
389        .iter()
390        .rev()
391        .find(|m| {
392            m.kind == ChatMessageKind::ContextCheckpoint || m.content.contains(CHECKPOINT_MARKER)
393        })
394        .map(|m| m.content.clone());
395
396    // Size the complete summarizer input against its own output cap, not the
397    // interrupted turn's response reserve. Account for the fixed prompt,
398    // previous checkpoint, focus, and attached image payloads before assigning
399    // the remainder to history.
400    let max_input_tokens = max_context_tokens
401        .map(|max| max.saturating_sub(request.policy.summary_max_tokens))
402        .filter(|max| *max > 0)
403        .unwrap_or(request.policy.summarizer_input_token_budget)
404        .min(request.policy.summarizer_input_token_budget);
405    let sizing_prepared = PreparedCompaction {
406        archived_messages: Vec::new(),
407        preserved_messages: Vec::new(),
408        previous_summary: previous_summary.clone(),
409        history_excerpt: String::new(),
410        summary_images: Vec::new(),
411    };
412    // Measure the fixed request scaffold (system prompt, template, previous
413    // checkpoint, focus, role metadata) with the SAME estimator the dispatch
414    // fit check uses, so the two can never diverge. The per-image and excerpt
415    // allocations below each round up at least as aggressively as the
416    // estimator's summed rounding, so the assembled request stays within
417    // `max_input_tokens` by construction.
418    let probe = build_summary_request(
419        &request.chat,
420        &sizing_prepared,
421        request.instructions.as_deref(),
422        request.policy,
423    );
424    let fixed_tokens = super::state::estimate_context_usage_for_request(&probe, None).used_tokens;
425    let mut remaining_tokens = max_input_tokens.saturating_sub(fixed_tokens);
426
427    // Images are model-visible source material, not merely transcript markers.
428    // Keep every image that fits, scanning newest first so recency wins the
429    // budget — but do NOT stop at the first oversized one: a single giant
430    // recent screenshot must not evict older small diagrams that fit. The text
431    // projection states how many were omitted so the checkpoint cannot
432    // silently imply complete visual coverage.
433    let all_images: Vec<String> = archived_messages
434        .iter()
435        .flat_map(|message| message.images.iter().flatten().cloned())
436        .collect();
437    let mut summary_images = Vec::new();
438    for image in all_images.iter().rev() {
439        let image_tokens = image.len().div_ceil(4);
440        if image_tokens <= remaining_tokens {
441            summary_images.push(image.clone());
442            remaining_tokens = remaining_tokens.saturating_sub(image_tokens);
443        }
444    }
445    summary_images.reverse();
446
447    let history = format_history_excerpt(
448        &archived_messages,
449        request.policy,
450        all_images.len(),
451        summary_images.len(),
452    );
453    let history_excerpt = truncate_middle(&history, remaining_tokens.saturating_mul(4));
454
455    Ok(PreparedCompaction {
456        archived_messages,
457        preserved_messages,
458        previous_summary,
459        history_excerpt,
460        summary_images,
461    })
462}
463
464pub fn build_summary_request(
465    base: &ChatRequest,
466    prepared: &PreparedCompaction,
467    focus: Option<&str>,
468    policy: CompactionPolicy,
469) -> ChatRequest {
470    let mut message = ChatMessage::user(summary_prompt(prepared, focus));
471    if !prepared.summary_images.is_empty() {
472        message.images = Some(prepared.summary_images.clone());
473    }
474    ChatRequest {
475        model_id: base.model_id.clone(),
476        messages: vec![message],
477        system_prompt: compaction_system_prompt().to_string(),
478        instructions: None,
479        reasoning: compaction_reasoning(base.reasoning),
480        temperature: 0.0,
481        max_tokens: policy.summary_max_tokens,
482        tools: Vec::new(),
483        ollama_num_ctx: base.ollama_num_ctx,
484        ollama_allow_ram_offload: base.ollama_allow_ram_offload,
485        resolved_context_window: base.resolved_context_window,
486        resolved_max_output: base.resolved_max_output,
487        output_schema: None,
488        suppress_auto_compact: false,
489    }
490}
491
492pub fn build_verification_request(
493    base: &ChatRequest,
494    prepared: &PreparedCompaction,
495    draft_summary: &str,
496    focus: Option<&str>,
497    policy: CompactionPolicy,
498) -> ChatRequest {
499    let prompt = format!(
500        "{}\n\n# Draft Summary\n{}\n\n# Verification Task\nCritically check the draft against the conversation excerpt. If it omitted specific file paths, commands, test results, tool results, user constraints, current state, or next steps, return an improved complete checkpoint. Otherwise return the draft unchanged. Return only the final checkpoint markdown.",
501        summary_prompt(prepared, focus),
502        draft_summary.trim()
503    );
504    let mut message = ChatMessage::user(prompt);
505    if !prepared.summary_images.is_empty() {
506        message.images = Some(prepared.summary_images.clone());
507    }
508    ChatRequest {
509        model_id: base.model_id.clone(),
510        messages: vec![message],
511        system_prompt: compaction_system_prompt().to_string(),
512        instructions: None,
513        reasoning: compaction_reasoning(base.reasoning),
514        temperature: 0.0,
515        max_tokens: policy.summary_max_tokens,
516        tools: Vec::new(),
517        ollama_num_ctx: base.ollama_num_ctx,
518        ollama_allow_ram_offload: base.ollama_allow_ram_offload,
519        resolved_context_window: base.resolved_context_window,
520        resolved_max_output: base.resolved_max_output,
521        output_schema: None,
522        suppress_auto_compact: false,
523    }
524}
525
526pub fn build_replacement_messages(
527    summary: &str,
528    prepared: &PreparedCompaction,
529    record: &CompactionRecord,
530) -> Vec<ChatMessage> {
531    // The summary is model-generated from the full conversation and is persisted
532    // (replacement message + conversation file). Scrub any credential it echoed
533    // back from the archived turns before it's written (#70).
534    let summary = crate::utils::redact_secrets(summary);
535    let summary = summary.as_str();
536    let checkpoint = format!(
537        "# {}\n\nCompaction id: {}\nTrigger: {}\nCreated: {}\nArchived messages: {}\nPreserved messages: {}\n\n{}",
538        CHECKPOINT_MARKER,
539        record.id,
540        record.trigger.as_str(),
541        record.created_at.to_rfc3339(),
542        record.archived_message_count,
543        record.preserved_message_count,
544        summary.trim()
545    );
546    let mut user = ChatMessage::user(checkpoint);
547    user.kind = ChatMessageKind::ContextCheckpoint;
548    user.metadata = Some(serde_json::json!({
549        "compaction_id": record.id,
550        "trigger": record.trigger.as_str(),
551        "before_tokens": record.before_tokens,
552        "after_tokens": record.after_tokens,
553        "archived_message_count": record.archived_message_count,
554        "preserved_message_count": record.preserved_message_count,
555        "preserved_turn_count": record.preserved_turn_count,
556        "duration_secs": record.duration_secs,
557        "review_status": record.review_status.as_str(),
558        "review_error": record.review_error,
559    }));
560
561    let mut assistant = ChatMessage::assistant(compaction_receipt(record));
562    assistant.kind = ChatMessageKind::ContextCheckpoint;
563    assistant.metadata = user.metadata.clone();
564
565    let mut messages = Vec::with_capacity(2 + prepared.preserved_messages.len());
566    messages.push(user);
567    messages.push(assistant);
568    messages.extend(prepared.preserved_messages.clone());
569    messages
570}
571
572pub fn compaction_receipt(record: &CompactionRecord) -> String {
573    let review = match record.review_status {
574        CompactionReviewStatus::Reviewed => "Reviewed in a second pass.".to_string(),
575        CompactionReviewStatus::DraftValidated => match &record.review_error {
576            Some(error) => format!("Used the structurally validated draft: {error}."),
577            None => "Used the structurally validated draft.".to_string(),
578        },
579    };
580    format!(
581        "Context compacted: {} -> {} tokens, archived {} messages, preserved {} messages, took {:.1}s. {} I will continue from this checkpoint.",
582        format_compact_count(record.before_tokens),
583        format_compact_count(record.after_tokens),
584        record.archived_message_count,
585        record.preserved_message_count,
586        record.duration_secs,
587        review
588    )
589}
590
591pub fn normalize_summary(text: &str) -> String {
592    let trimmed = text.trim();
593    if let Some(summary) = extract_tagged_summary(trimmed) {
594        return summary.trim().to_string();
595    }
596    trimmed.to_string()
597}
598
599pub fn validate_summary_structure(summary: &str) -> Result<(), String> {
600    const HEADINGS: [&str; 10] = [
601        "## Goal",
602        "## User Preferences And Constraints",
603        "## Project State",
604        "## Completed Work",
605        "## Current Work",
606        "## Key Decisions",
607        "## Critical Files And Symbols",
608        "## Commands Tests And Results",
609        "## Open Questions Or Risks",
610        "## Next Steps",
611    ];
612
613    // Only the ten known headings are structure. Other `## `-prefixed lines
614    // are body content — checkpoints legitimately quote markdown (commands,
615    // error output, README excerpts) and must not fail closed over it.
616    let lines: Vec<&str> = summary.lines().collect();
617    let headings: Vec<(usize, &str)> = lines
618        .iter()
619        .enumerate()
620        .filter_map(|(index, line)| {
621            let trimmed = line.trim();
622            HEADINGS.contains(&trimmed).then_some((index, trimmed))
623        })
624        .collect();
625    let actual: Vec<&str> = headings.iter().map(|(_, heading)| *heading).collect();
626    if actual != HEADINGS {
627        return Err(format!(
628            "checkpoint headings must exactly match the required order; got {}",
629            actual.join(", ")
630        ));
631    }
632
633    for (index, (line_index, heading)) in headings.iter().enumerate() {
634        let body_end = headings
635            .get(index + 1)
636            .map(|(next_index, _)| *next_index)
637            .unwrap_or(lines.len());
638        let body = lines[line_index + 1..body_end].join("\n");
639        let body = body.trim();
640        if body.is_empty() || body == "-" || (body.starts_with("- [") && body.ends_with(']')) {
641            return Err(format!(
642                "checkpoint heading {heading} has placeholder content"
643            ));
644        }
645    }
646    Ok(())
647}
648
649pub fn combine_usage(a: Option<TokenUsage>, b: Option<TokenUsage>) -> Option<TokenUsage> {
650    match (a, b) {
651        (None, None) => None,
652        (Some(u), None) | (None, Some(u)) => Some(u),
653        (Some(mut left), Some(right)) => {
654            left.prompt_tokens = left.prompt_tokens.saturating_add(right.prompt_tokens);
655            left.completion_tokens = left
656                .completion_tokens
657                .saturating_add(right.completion_tokens);
658            left.cached_input_tokens = left
659                .cached_input_tokens
660                .saturating_add(right.cached_input_tokens);
661            left.cache_creation_input_tokens = left
662                .cache_creation_input_tokens
663                .saturating_add(right.cache_creation_input_tokens);
664            left.reasoning_output_tokens = left
665                .reasoning_output_tokens
666                .saturating_add(right.reasoning_output_tokens);
667            Some(left)
668        },
669    }
670}
671
672pub fn estimate_messages_tokens(messages: &[ChatMessage]) -> usize {
673    messages.iter().map(estimate_message_tokens).sum()
674}
675
676/// Canonical compact token/count formatter shared across the reducer status
677/// text, the footer widget, chat compaction receipts, and compaction records.
678/// Abbreviates at 1k (`43.8k`, `1.2M`), exact below; a whole value drops the
679/// decimal (`128k`, not `128.0k`). Previously three copies existed with two
680/// different policies (threshold + rounding), so the same count rendered
681/// inconsistently across the UI.
682pub fn format_compact_count(value: usize) -> String {
683    if value >= 1_000_000 {
684        format_scaled(value, 1_000_000, "M")
685    } else if value >= 1_000 {
686        format_scaled(value, 1_000, "k")
687    } else {
688        value.to_string()
689    }
690}
691
692fn format_scaled(value: usize, divisor: usize, suffix: &str) -> String {
693    let whole = value / divisor;
694    let decimal = ((value % divisor) * 10) / divisor;
695    if decimal == 0 {
696        format!("{}{}", whole, suffix)
697    } else {
698        format!("{}.{}{}", whole, decimal, suffix)
699    }
700}
701
702fn compaction_system_prompt() -> &'static str {
703    "You are performing context checkpoint compaction for Mermaid, a model-agnostic agentic coding CLI. Produce a faithful handoff summary for the next model call. Preserve exact file paths, commands, errors, tool results, user preferences, decisions, current state, and next steps. Do not invent facts. Be concise but complete."
704}
705
706fn compaction_reasoning(current: ReasoningLevel) -> ReasoningLevel {
707    match current {
708        ReasoningLevel::None | ReasoningLevel::Minimal => current,
709        _ => ReasoningLevel::Low,
710    }
711}
712
713fn summary_prompt(prepared: &PreparedCompaction, focus: Option<&str>) -> String {
714    let anchor = prepared
715        .previous_summary
716        .as_deref()
717        .map(|summary| {
718            format!(
719                "A previous checkpoint exists. Update it with the newer history, preserve still-true details, and remove stale details.\n\n<previous_checkpoint>\n{}\n</previous_checkpoint>",
720                summary.trim()
721            )
722        })
723        .unwrap_or_else(|| "Create a new checkpoint from the conversation history below.".to_string());
724
725    let focus = focus
726        .filter(|s| !s.trim().is_empty())
727        .map(|s| format!("\n# User Focus Instructions\n{}\n", s.trim()))
728        .unwrap_or_default();
729
730    format!(
731        "{anchor}{focus}\n# Required Output\nReturn exactly this Markdown structure and keep section order:\n\n## Goal\n- [single-sentence task summary]\n\n## User Preferences And Constraints\n- [preferences, constraints, mode, or \"(none)\"]\n\n## Project State\n- [repo/product state and important architecture facts]\n\n## Completed Work\n- [what has already been done]\n\n## Current Work\n- [what is actively in progress]\n\n## Key Decisions\n- [decision and rationale]\n\n## Critical Files And Symbols\n- [file path or symbol: why it matters]\n\n## Commands Tests And Results\n- [command/test/result/error]\n\n## Open Questions Or Risks\n- [risk/question/blocker]\n\n## Next Steps\n- [ordered next action]\n\nRules:\n- Preserve exact paths, commands, error strings, identifiers, and numeric facts when known.\n- Mention important omitted or truncated data explicitly.\n- Do not mention that you are an AI or explain the compaction process.\n\n# Conversation History To Compact\n{}",
732        prepared.history_excerpt
733    )
734}
735
736/// Scrub orphan tool-call/tool-result pairs from the preserved tail so a
737/// forwarded unpaired block can't 400 a provider (Anthropic). Compaction keeps
738/// a recent tail verbatim; if the split inherits a pre-existing orphan, both
739/// directions must be repaired symmetrically:
740///
741///   * Forward (#71): an assistant `tool_use` whose `tool_result` never
742///     committed — e.g. a turn cancelled after the model emitted calls. Drop
743///     the orphaned calls (keeping the assistant's text) rather than fabricate
744///     results. A call with no `id` can't be paired, so it's treated as orphaned.
745///   * Reverse (#F64): a `tool_result` (role=Tool) whose `tool_use` id is no
746///     longer present among the retained messages — e.g. the assistant turn was
747///     archived while its result landed in the tail. Anthropic equally rejects a
748///     `tool_result` with no preceding `tool_use`, so drop the orphaned result.
749///
750/// When `preserve_pending_tail` is set (the run is resuming mid-tool after a
751/// context-limit retry / truncation recovery), a *trailing* assistant `tool_use`
752/// is genuinely pending execution rather than abandoned, so its calls are kept
753/// across the checkpoint and the resumed turn appends the awaited results (#F65).
754/// Only the final message qualifies: anything after an assistant `tool_use` (a
755/// tool result, a follow-up, a user cancel) means it is no longer pending. This
756/// is trigger-gated by the caller because a user cancel yields the same trailing
757/// shape but must still drop — there, the result will never arrive.
758/// Repair `tool_use`/`tool_result` pairing on a message list before it is sent
759/// to a provider (or seeded as a resumed prefix). Drops orphans in both
760/// directions: an assistant `tool_use` with no matching result, and a
761/// `tool_result` whose call is gone.
762///
763/// `preserve_pending_tail` is always `false` here: an outgoing request must
764/// never carry a trailing unanswered `tool_use` (providers 400 on it), and a
765/// cold-loaded prefix has no in-flight turn to append the awaited result — a
766/// preserved tail would be a permanent orphan. The compaction path calls
767/// [`drop_orphan_tool_calls`] directly with `true` for the truncation-recovery
768/// checkpoint, which *does* resume the pending call.
769pub(crate) fn normalize_history(messages: &mut Vec<ChatMessage>) {
770    drop_orphan_tool_calls(messages, false);
771}
772
773pub(crate) fn drop_orphan_tool_calls(messages: &mut Vec<ChatMessage>, preserve_pending_tail: bool) {
774    let pending_tail = if preserve_pending_tail
775        && messages.last().is_some_and(|m| {
776            m.role == MessageRole::Assistant && m.tool_calls.as_ref().is_some_and(|c| !c.is_empty())
777        }) {
778        Some(messages.len() - 1)
779    } else {
780        None
781    };
782
783    let answered: std::collections::HashSet<String> = messages
784        .iter()
785        .filter(|m| m.role == MessageRole::Tool)
786        .filter_map(|m| m.tool_call_id.clone())
787        .collect();
788
789    // Forward (#71): drop unanswered assistant `tool_use`, save a pending tail.
790    for (idx, m) in messages.iter_mut().enumerate() {
791        if Some(idx) == pending_tail {
792            continue;
793        }
794        let Some(calls) = m.tool_calls.as_mut() else {
795            continue;
796        };
797        calls.retain(|c| c.id.as_deref().is_some_and(|id| answered.contains(id)));
798        if calls.is_empty() {
799            m.tool_calls = None;
800        }
801        let kept: std::collections::HashSet<&str> = m
802            .tool_calls
803            .iter()
804            .flatten()
805            .filter_map(|call| call.id.as_deref())
806            .collect();
807        if let Some(continuation) = &mut m.provider_continuation {
808            continuation.retain_meta_function_calls(|call_id| kept.contains(call_id));
809        }
810    }
811
812    // Reverse (#F64): drop a `tool_result` whose `tool_use` id is no longer
813    // present among the assistant messages retained above (symmetric orphan).
814    let emitted: std::collections::HashSet<String> = messages
815        .iter()
816        .filter_map(|m| m.tool_calls.as_ref())
817        .flat_map(|calls| calls.iter())
818        .filter_map(|c| c.id.clone())
819        .collect();
820    messages.retain(|m| {
821        m.role != MessageRole::Tool
822            || m.tool_call_id
823                .as_deref()
824                .is_some_and(|id| emitted.contains(id))
825    });
826}
827
828fn tail_start_index(messages: &[ChatMessage], policy: CompactionPolicy) -> Option<usize> {
829    let mut user_turns = 0usize;
830    let mut start = None;
831    for (idx, msg) in messages.iter().enumerate().rev() {
832        if msg.role == MessageRole::User {
833            user_turns += 1;
834            start = Some(idx);
835            if user_turns >= policy.tail_turns {
836                break;
837            }
838        }
839    }
840    let mut start = start?;
841    while estimate_messages_tokens(&messages[start..]) > policy.tail_token_budget {
842        let next_user = messages
843            .iter()
844            .enumerate()
845            .skip(start + 1)
846            .find(|(_, msg)| msg.role == MessageRole::User)
847            .map(|(idx, _)| idx);
848        match next_user {
849            Some(idx) => start = idx,
850            None => break,
851        }
852    }
853    Some(start)
854}
855
856fn format_history_excerpt(
857    messages: &[ChatMessage],
858    policy: CompactionPolicy,
859    total_images: usize,
860    included_images: usize,
861) -> String {
862    let mut out = String::new();
863    if total_images > 0 {
864        out.push_str(&format!(
865            "\n[Visual context: {included_images} of {total_images} archived image attachment(s) supplied with this request; {} omitted by the input budget.]\n",
866            total_images.saturating_sub(included_images)
867        ));
868    }
869    for (idx, msg) in messages.iter().enumerate() {
870        let role = match msg.role {
871            MessageRole::User => "USER",
872            MessageRole::Assistant => "ASSISTANT",
873            MessageRole::System => "SYSTEM",
874            MessageRole::Tool => "TOOL",
875        };
876        out.push_str(&format!("\n\n--- MESSAGE {} [{}] ---\n", idx + 1, role));
877        if msg.kind != ChatMessageKind::Normal {
878            out.push_str(&format!("kind: {:?}\n", msg.kind));
879        }
880        if let Some(name) = &msg.tool_name {
881            out.push_str(&format!("tool_name: {}\n", name));
882        }
883        if let Some(id) = &msg.tool_call_id {
884            out.push_str(&format!("tool_call_id: {}\n", id));
885        }
886        if let Some(calls) = &msg.tool_calls {
887            for call in calls {
888                let mut arguments = call.function.arguments.clone();
889                crate::utils::redact_json(&mut arguments);
890                let arguments = truncate_middle(
891                    &arguments.to_string(),
892                    policy.tool_output_max_chars.saturating_mul(4),
893                );
894                out.push_str(&format!(
895                    "tool_call: id={} name={} arguments={}\n",
896                    call.id.as_deref().unwrap_or("<missing>"),
897                    call.function.name,
898                    arguments
899                ));
900            }
901        }
902        if let Some(images) = &msg.images
903            && !images.is_empty()
904        {
905            out.push_str(&format!(
906                "[{} image attachment(s) referenced above]\n",
907                images.len()
908            ));
909        }
910        for action in &msg.actions {
911            out.push_str(&format!(
912                "action: {}({}) duration={:?}\n",
913                action.action_type, action.target, action.duration_seconds
914            ));
915            if let Some(metadata) = &action.metadata {
916                out.push_str(&format!("action_metadata: {:?}\n", metadata));
917            }
918        }
919        let cap = if msg.role == MessageRole::Tool {
920            policy.tool_output_max_chars
921        } else {
922            policy.tool_output_max_chars.saturating_mul(4)
923        };
924        out.push_str(&truncate_middle(&msg.content, cap));
925    }
926    out
927}
928
929fn estimate_message_tokens(msg: &ChatMessage) -> usize {
930    let mut chars = msg.content.len();
931    chars = chars.saturating_add(format!("{:?}", msg.role).len());
932    chars = chars.saturating_add(msg.tool_name.as_deref().map(str::len).unwrap_or(0));
933    chars = chars.saturating_add(msg.tool_call_id.as_deref().map(str::len).unwrap_or(0));
934    if let Some(images) = &msg.images {
935        chars = chars.saturating_add(images.iter().map(String::len).sum::<usize>());
936    }
937    // Assistant tool calls carry the function name + a JSON arguments payload
938    // (often kilobytes for a file write or shell script). Omitting these made
939    // the estimate run systematically low for this tool-heavy agent, causing
940    // under-compaction and provider-side context overflows.
941    if let Some(tool_calls) = &msg.tool_calls {
942        for tc in tool_calls {
943            chars = chars.saturating_add(tc.function.name.len());
944            chars = chars.saturating_add(tc.function.arguments.to_string().len());
945            chars = chars.saturating_add(tc.id.as_deref().map(str::len).unwrap_or(0));
946        }
947    }
948    chars.div_ceil(4)
949}
950
951fn truncate_middle(text: &str, max_chars: usize) -> String {
952    if text.chars().count() <= max_chars {
953        return text.to_string();
954    }
955    if max_chars < 128 {
956        return text.chars().take(max_chars).collect();
957    }
958    let marker = "\n\n[... truncated during context compaction ...]\n\n";
959    let keep = max_chars.saturating_sub(marker.len());
960    let head = keep / 2;
961    let tail = keep.saturating_sub(head);
962    let start: String = text.chars().take(head).collect();
963    let end: String = text
964        .chars()
965        .rev()
966        .take(tail)
967        .collect::<Vec<_>>()
968        .into_iter()
969        .rev()
970        .collect();
971    format!("{start}{marker}{end}")
972}
973
974fn extract_tagged_summary(text: &str) -> Option<&str> {
975    let start_tag = "<summary>";
976    let end_tag = "</summary>";
977    let start = text.find(start_tag)? + start_tag.len();
978    let end = text[start..].find(end_tag)? + start;
979    Some(&text[start..end])
980}
981
982#[cfg(test)]
983mod tests {
984    use super::*;
985
986    fn request_with(messages: Vec<ChatMessage>) -> ChatRequest {
987        ChatRequest {
988            model_id: "ollama/test".to_string(),
989            messages,
990            system_prompt: "system".to_string(),
991            instructions: None,
992            reasoning: ReasoningLevel::Medium,
993            temperature: 0.7,
994            max_tokens: 4096,
995            tools: Vec::new(),
996            ollama_num_ctx: None,
997            ollama_allow_ram_offload: None,
998            resolved_context_window: None,
999            resolved_max_output: None,
1000            output_schema: None,
1001            suppress_auto_compact: false,
1002        }
1003    }
1004
1005    #[test]
1006    fn classify_length_stop_discriminates_output_cap_from_context_full() {
1007        let usage = TokenUsage::provider(16_600, 4_000);
1008        // No usage → Unknown (legacy recovery path preserved).
1009        assert_eq!(
1010            classify_length_stop(None, Some(100_000), 4_000),
1011            LengthCause::Unknown
1012        );
1013        // Unknown window + usage → the per-response output cap (the normal
1014        // remote-provider case — the GLM-5.2 misdiagnosis this fixes).
1015        assert_eq!(
1016            classify_length_stop(Some(&usage), None, 4_000),
1017            LengthCause::OutputCapped
1018        );
1019        // Window with plenty of room → still the output cap.
1020        assert_eq!(
1021            classify_length_stop(Some(&usage), Some(1_000_000), 4_000),
1022            LengthCause::OutputCapped
1023        );
1024        // prompt + completion + reserve reaching the window → genuinely full.
1025        assert_eq!(
1026            classify_length_stop(Some(&usage), Some(24_000), 4_000),
1027            LengthCause::ContextFull
1028        );
1029    }
1030
1031    #[test]
1032    fn classify_length_stop_counts_cached_and_reasoning_tokens() {
1033        let usage = TokenUsage::provider(100, 100)
1034            .with_cached_input(700)
1035            .with_cache_creation(50)
1036            .with_reasoning_output(50);
1037        assert_eq!(usage.total_tokens(), 1_000);
1038        assert_eq!(
1039            classify_length_stop(Some(&usage), Some(1_100), 100),
1040            LengthCause::ContextFull
1041        );
1042    }
1043
1044    #[test]
1045    fn summary_and_verification_requests_copy_resolved_limits() {
1046        // The summarizer calls the same model — its request must inherit the
1047        // live-discovered limits or Anthropic AUTO would fall to the 8192
1048        // floor mid-compaction.
1049        let mut base = request_with(vec![ChatMessage::user("hello")]);
1050        base.resolved_context_window = Some(1_000_000);
1051        base.resolved_max_output = Some(128_000);
1052        let prepared = PreparedCompaction {
1053            archived_messages: vec![ChatMessage::user("old")],
1054            preserved_messages: vec![],
1055            previous_summary: None,
1056            history_excerpt: "excerpt".to_string(),
1057            summary_images: Vec::new(),
1058        };
1059        let policy = CompactionPolicy::default();
1060        let summary = build_summary_request(&base, &prepared, None, policy);
1061        assert_eq!(summary.resolved_context_window, Some(1_000_000));
1062        assert_eq!(summary.resolved_max_output, Some(128_000));
1063        let verify = build_verification_request(&base, &prepared, "draft", None, policy);
1064        assert_eq!(verify.resolved_context_window, Some(1_000_000));
1065        assert_eq!(verify.resolved_max_output, Some(128_000));
1066    }
1067
1068    #[test]
1069    fn response_reserve_is_reasoning_aware_on_auto() {
1070        let policy = CompactionPolicy::default();
1071        let mut req = request_with(vec![ChatMessage::user("hello")]);
1072
1073        // AUTO: the reserve scales with the reasoning level instead of
1074        // mirroring a send-cap that no longer exists.
1075        req.max_tokens = 0;
1076        req.reasoning = ReasoningLevel::None;
1077        let base = policy.response_reserve(&req);
1078        assert_eq!(base, policy.min_response_reserve_tokens);
1079        req.reasoning = ReasoningLevel::Max;
1080        let deep = policy.response_reserve(&req);
1081        assert!(deep > base, "a Max-reasoning turn must reserve more room");
1082        assert!(deep <= policy.max_response_reserve_tokens);
1083
1084        // An explicit cap is the best reserve estimate — honored (clamped).
1085        req.max_tokens = 12_000;
1086        assert_eq!(policy.response_reserve(&req), 12_000);
1087        req.max_tokens = 1_000_000;
1088        assert_eq!(
1089            policy.response_reserve(&req),
1090            policy.max_response_reserve_tokens
1091        );
1092    }
1093
1094    #[test]
1095    fn auto_compaction_triggers_by_percent() {
1096        let snapshot = ContextUsageSnapshot::from_estimate(
1097            super::super::state::PromptTokenBreakdown {
1098                system_tokens: 0,
1099                instructions_tokens: 0,
1100                message_tokens: 86,
1101                tool_schema_tokens: 0,
1102                image_count: 0,
1103                message_count: 2,
1104                tool_count: 0,
1105            },
1106            Some(100),
1107        );
1108        let req = request_with(vec![ChatMessage::user("hello")]);
1109        assert!(should_auto_compact(&snapshot, &req, CompactionPolicy::default()).is_ok());
1110    }
1111
1112    #[test]
1113    fn auto_compaction_pause_rides_the_request() {
1114        let snapshot = ContextUsageSnapshot::from_estimate(
1115            super::super::state::PromptTokenBreakdown {
1116                system_tokens: 0,
1117                instructions_tokens: 0,
1118                message_tokens: 86,
1119                tool_schema_tokens: 0,
1120                image_count: 0,
1121                message_count: 2,
1122                tool_count: 0,
1123            },
1124            Some(100),
1125        );
1126        let mut req = request_with(vec![ChatMessage::user("hello")]);
1127        req.suppress_auto_compact = true;
1128        assert_eq!(
1129            should_auto_compact(&snapshot, &req, CompactionPolicy::default()),
1130            Err(CompactionSkip::Suppressed)
1131        );
1132    }
1133
1134    #[test]
1135    fn boundary_fingerprint_matches_only_the_identical_message() {
1136        let message = ChatMessage::user("hello");
1137        let boundary = CompactionBoundary::from_message(&message);
1138        assert!(boundary.matches(&message));
1139
1140        let mut other_content = message.clone();
1141        other_content.content = "hello!".to_string();
1142        assert!(!boundary.matches(&other_content));
1143
1144        let mut other_kind = message.clone();
1145        other_kind.kind = ChatMessageKind::ContextCheckpoint;
1146        assert!(!boundary.matches(&other_kind));
1147
1148        let mut other_time = message.clone();
1149        other_time.timestamp += chrono::Duration::nanoseconds(1);
1150        assert!(!boundary.matches(&other_time));
1151    }
1152
1153    #[test]
1154    fn prepare_preserves_recent_two_user_turns() {
1155        let messages = vec![
1156            ChatMessage::user("one"),
1157            ChatMessage::assistant("one answer"),
1158            ChatMessage::user("two"),
1159            ChatMessage::assistant("two answer"),
1160            ChatMessage::user("three"),
1161        ];
1162        let request = CompactionRequest::manual(request_with(messages), None);
1163        let prepared = prepare_compaction(&request, Some(100_000)).expect("prepared");
1164        assert_eq!(prepared.archived_messages.len(), 2);
1165        assert_eq!(prepared.preserved_messages.len(), 3);
1166        assert_eq!(prepared.preserved_messages[0].content, "two");
1167    }
1168
1169    #[test]
1170    fn prepare_projects_redacted_tool_arguments_and_archived_images() {
1171        let mut old = ChatMessage::user("inspect the screenshot");
1172        old.images = Some(vec!["aGVsbG8=".to_string()]);
1173        let mut call = ChatMessage::assistant("");
1174        call.tool_calls = Some(vec![crate::models::tool_call::ToolCall {
1175            id: Some("call_1".to_string()),
1176            function: crate::models::tool_call::FunctionCall {
1177                name: "execute_command".to_string(),
1178                arguments: serde_json::json!({
1179                    "cmd": "cargo test --workspace",
1180                    "api_key": "opaque-secret-value"
1181                }),
1182            },
1183        }]);
1184        let messages = vec![
1185            old,
1186            call,
1187            ChatMessage::tool("call_1", "execute_command", "tests passed"),
1188            ChatMessage::user("second"),
1189            ChatMessage::assistant("second answer"),
1190            ChatMessage::user("third"),
1191        ];
1192        let request = CompactionRequest::manual(request_with(messages), None);
1193        let prepared = prepare_compaction(&request, Some(100_000)).expect("prepared");
1194        assert!(prepared.history_excerpt.contains("cargo test --workspace"));
1195        assert!(prepared.history_excerpt.contains("[REDACTED]"));
1196        assert!(!prepared.history_excerpt.contains("opaque-secret-value"));
1197        assert_eq!(prepared.summary_images, vec!["aGVsbG8=".to_string()]);
1198        let summary =
1199            build_summary_request(&request.chat, &prepared, None, CompactionPolicy::default());
1200        assert_eq!(
1201            summary.messages[0].images.as_deref(),
1202            Some(prepared.summary_images.as_slice())
1203        );
1204    }
1205
1206    #[test]
1207    fn complete_summary_request_fits_known_window() {
1208        let messages = vec![
1209            ChatMessage::user("old ".repeat(40_000)),
1210            ChatMessage::assistant("old answer ".repeat(20_000)),
1211            ChatMessage::user("second"),
1212            ChatMessage::assistant("second answer"),
1213            ChatMessage::user("third"),
1214        ];
1215        let request = CompactionRequest::manual(request_with(messages), Some("focus".repeat(500)));
1216        let window = 32_000;
1217        let prepared = prepare_compaction(&request, Some(window)).expect("prepared");
1218        let summary = build_summary_request(
1219            &request.chat,
1220            &prepared,
1221            request.instructions.as_deref(),
1222            request.policy,
1223        );
1224        let usage = crate::domain::estimate_context_usage_for_request(&summary, Some(window));
1225        assert!(usage.used_tokens.saturating_add(summary.max_tokens) <= window);
1226    }
1227
1228    #[test]
1229    fn complete_summary_request_with_images_fits_known_window() {
1230        let mut old = ChatMessage::user("old ".repeat(40_000));
1231        old.images = Some(vec!["i".repeat(40_000), "j".repeat(40_000)]);
1232        let messages = vec![
1233            old,
1234            ChatMessage::assistant("old answer ".repeat(20_000)),
1235            ChatMessage::user("second"),
1236            ChatMessage::assistant("second answer"),
1237            ChatMessage::user("third"),
1238        ];
1239        let request = CompactionRequest::manual(request_with(messages), Some("focus".repeat(500)));
1240        let window = 32_000;
1241        let prepared = prepare_compaction(&request, Some(window)).expect("prepared");
1242        let summary = build_summary_request(
1243            &request.chat,
1244            &prepared,
1245            request.instructions.as_deref(),
1246            request.policy,
1247        );
1248        assert!(
1249            !prepared.summary_images.is_empty(),
1250            "the newest image fits the budget and must be attached"
1251        );
1252        let usage = crate::domain::estimate_context_usage_for_request(&summary, Some(window));
1253        assert!(
1254            usage.used_tokens.saturating_add(summary.max_tokens) <= window,
1255            "used {} + max_tokens {} > window {}",
1256            usage.used_tokens,
1257            summary.max_tokens,
1258            window
1259        );
1260    }
1261
1262    #[test]
1263    fn image_budget_keeps_every_fitting_image_newest_first() {
1264        let mut old = ChatMessage::user("inspect");
1265        // Oldest image is tiny, newest exceeds the whole input budget. One
1266        // oversized recent screenshot must not evict older small diagrams
1267        // that fit — the older image still rides along, and the projection
1268        // reports the omission honestly.
1269        old.images = Some(vec!["a".repeat(400), "b".repeat(400_000)]);
1270        let messages = vec![
1271            old,
1272            ChatMessage::assistant("looked"),
1273            ChatMessage::user("second"),
1274            ChatMessage::assistant("second answer"),
1275            ChatMessage::user("third"),
1276        ];
1277        let request = CompactionRequest::manual(request_with(messages), None);
1278        let prepared = prepare_compaction(&request, None).expect("prepared");
1279        assert_eq!(prepared.summary_images, vec!["a".repeat(400)]);
1280        assert!(
1281            prepared
1282                .history_excerpt
1283                .contains("1 of 2 archived image attachment(s)")
1284        );
1285    }
1286
1287    #[test]
1288    fn summary_structure_requires_ordered_non_placeholder_sections() {
1289        let valid = "## Goal\n- ship the fix\n\n## User Preferences And Constraints\n- none\n\n## Project State\n- ready\n\n## Completed Work\n- audit\n\n## Current Work\n- implementation\n\n## Key Decisions\n- preserve data\n\n## Critical Files And Symbols\n- compaction.rs\n\n## Commands Tests And Results\n- tests pass\n\n## Open Questions Or Risks\n- none\n\n## Next Steps\n- finish";
1290        assert!(validate_summary_structure(valid).is_ok());
1291        assert!(validate_summary_structure("## Goal\n- [single-sentence task summary]").is_err());
1292    }
1293
1294    #[test]
1295    fn summary_structure_tolerates_quoted_markdown_in_bodies() {
1296        // Checkpoints legitimately quote markdown — a `## `-prefixed line
1297        // inside a section body is content, not structure, and must not fail
1298        // the checkpoint closed.
1299        let with_quoted_heading = "## Goal\n- ship the fix\n\n## User Preferences And Constraints\n- none\n\n## Project State\n- ready\n\n## Completed Work\n- audit\n\n## Current Work\n- implementation\n\n## Key Decisions\n- preserve data\n\n## Critical Files And Symbols\n- compaction.rs\n\n## Commands Tests And Results\n- README now starts with:\n## Quick Start\ninstall the CLI\n\n## Open Questions Or Risks\n- none\n\n## Next Steps\n- finish";
1300        assert!(validate_summary_structure(with_quoted_heading).is_ok());
1301    }
1302
1303    fn tool_call(id: &str, name: &str) -> crate::models::tool_call::ToolCall {
1304        crate::models::tool_call::ToolCall {
1305            id: Some(id.to_string()),
1306            function: crate::models::tool_call::FunctionCall {
1307                name: name.to_string(),
1308                arguments: serde_json::json!({}),
1309            },
1310        }
1311    }
1312
1313    #[test]
1314    fn prepare_strips_orphan_tool_call_from_preserved_tail() {
1315        // A tail that inherits an assistant(tool_calls) with no matching result
1316        // (e.g. a cancelled tool turn) must not forward the unpaired tool_use (#71).
1317        let mut orphan = ChatMessage::assistant("calling a tool");
1318        orphan.tool_calls = Some(vec![tool_call("call_1", "do_thing")]);
1319        let messages = vec![
1320            ChatMessage::user("one"),
1321            ChatMessage::assistant("one answer"),
1322            ChatMessage::user("two"),
1323            orphan,
1324            ChatMessage::user("three"),
1325        ];
1326        let request = CompactionRequest::manual(request_with(messages), None);
1327        let prepared = prepare_compaction(&request, Some(100_000)).expect("prepared");
1328        let has_orphan = prepared
1329            .preserved_messages
1330            .iter()
1331            .any(|m| m.tool_calls.as_ref().is_some_and(|c| !c.is_empty()));
1332        assert!(
1333            !has_orphan,
1334            "orphan tool_use must be stripped from the tail"
1335        );
1336        // The message itself (its text) is preserved — only the calls are dropped.
1337        assert!(
1338            prepared
1339                .preserved_messages
1340                .iter()
1341                .any(|m| m.content == "calling a tool")
1342        );
1343    }
1344
1345    #[test]
1346    fn prepare_keeps_paired_tool_call_in_tail() {
1347        // The mirror case: a tool_call whose result is also in the tail survives.
1348        let mut asst = ChatMessage::assistant("calling");
1349        asst.tool_calls = Some(vec![tool_call("call_1", "do_thing")]);
1350        let messages = vec![
1351            ChatMessage::user("one"),
1352            ChatMessage::assistant("one answer"),
1353            ChatMessage::user("two"),
1354            asst,
1355            ChatMessage::tool("call_1", "do_thing", "ok"),
1356            ChatMessage::user("three"),
1357        ];
1358        let request = CompactionRequest::manual(request_with(messages), None);
1359        let prepared = prepare_compaction(&request, Some(100_000)).expect("prepared");
1360        let kept = prepared
1361            .preserved_messages
1362            .iter()
1363            .any(|m| m.tool_calls.as_ref().is_some_and(|c| !c.is_empty()));
1364        assert!(kept, "a tool_call paired with its result must be preserved");
1365    }
1366
1367    #[test]
1368    fn normalize_history_drops_orphan_assistant_tool_use() {
1369        let mut orphan = ChatMessage::assistant("calling a tool");
1370        orphan.tool_calls = Some(vec![tool_call("call_1", "do_thing")]);
1371        let mut messages = vec![ChatMessage::user("hi"), orphan];
1372        normalize_history(&mut messages);
1373        assert!(
1374            messages
1375                .iter()
1376                .all(|m| m.tool_calls.as_ref().is_none_or(|c| c.is_empty())),
1377            "dangling tool_use must be dropped"
1378        );
1379        assert!(
1380            messages.iter().any(|m| m.content == "calling a tool"),
1381            "the assistant text is preserved — only the unpaired call is removed"
1382        );
1383    }
1384
1385    #[test]
1386    fn normalize_history_drops_matching_meta_replay_function_call() {
1387        let mut orphan = ChatMessage::assistant("calling a tool");
1388        orphan.tool_calls = Some(vec![tool_call("call_1", "do_thing")]);
1389        orphan.provider_continuation = Some(crate::models::ProviderContinuation::MetaResponses {
1390            output: vec![crate::models::MetaResponseItem::from_wire(
1391                serde_json::json!({
1392                    "type": "function_call",
1393                    "call_id": "call_1",
1394                    "name": "do_thing",
1395                    "arguments": "{}"
1396                }),
1397            )],
1398        });
1399        let mut messages = vec![ChatMessage::user("hi"), orphan];
1400        normalize_history(&mut messages);
1401        let output = messages[1]
1402            .provider_continuation
1403            .as_ref()
1404            .and_then(crate::models::ProviderContinuation::meta_output)
1405            .unwrap();
1406        assert!(
1407            output.is_empty(),
1408            "orphan Meta function_call must also drop"
1409        );
1410    }
1411
1412    #[test]
1413    fn normalize_history_drops_orphan_tool_result() {
1414        let mut messages = vec![
1415            ChatMessage::user("hi"),
1416            ChatMessage::tool("call_ghost", "do_thing", "result with no call"),
1417        ];
1418        normalize_history(&mut messages);
1419        assert!(
1420            !messages.iter().any(|m| m.role == MessageRole::Tool),
1421            "a tool_result whose call is absent must be dropped"
1422        );
1423    }
1424
1425    #[test]
1426    fn normalize_history_keeps_well_paired_tool_calls() {
1427        let mut asst = ChatMessage::assistant("calling");
1428        asst.tool_calls = Some(vec![tool_call("call_1", "do_thing")]);
1429        let mut messages = vec![
1430            ChatMessage::user("hi"),
1431            asst,
1432            ChatMessage::tool("call_1", "do_thing", "ok"),
1433        ];
1434        let before = messages.len();
1435        normalize_history(&mut messages);
1436        assert_eq!(
1437            messages.len(),
1438            before,
1439            "a paired call+result survives intact"
1440        );
1441        assert!(
1442            messages[1]
1443                .tool_calls
1444                .as_ref()
1445                .is_some_and(|c| c.len() == 1)
1446        );
1447    }
1448
1449    #[test]
1450    fn normalize_history_drops_idless_tool_use() {
1451        let mut asst = ChatMessage::assistant("calling");
1452        asst.tool_calls = Some(vec![crate::models::tool_call::ToolCall {
1453            id: None,
1454            function: crate::models::tool_call::FunctionCall {
1455                name: "do_thing".into(),
1456                arguments: serde_json::json!({}),
1457            },
1458        }]);
1459        let mut messages = vec![asst];
1460        normalize_history(&mut messages);
1461        assert!(
1462            messages[0].tool_calls.as_ref().is_none_or(|c| c.is_empty()),
1463            "an id-less tool_use is inherently unpaired → dropped"
1464        );
1465    }
1466
1467    #[test]
1468    fn prepare_drops_reverse_orphan_tool_result_from_tail() {
1469        // The mirror of #71 (#F64): the assistant `tool_use` is archived (split
1470        // out of the tail) while its `tool_result` lands in the preserved tail.
1471        // A lone `tool_result` with no preceding `tool_use` 400s Anthropic, so it
1472        // must be dropped symmetrically.
1473        let mut asst = ChatMessage::assistant("calling");
1474        asst.tool_calls = Some(vec![tool_call("call_1", "do_thing")]);
1475        let messages = vec![
1476            ChatMessage::user("one"),
1477            asst,
1478            ChatMessage::user("two"),
1479            ChatMessage::tool("call_1", "do_thing", "result"),
1480            ChatMessage::user("three"),
1481        ];
1482        // Tail keeps the last two user turns ("two".., "three"), so the assistant
1483        // tool_use is archived but the tool_result survives into the tail.
1484        let request = CompactionRequest::manual(request_with(messages), None);
1485        let prepared = prepare_compaction(&request, Some(100_000)).expect("prepared");
1486        assert!(
1487            prepared
1488                .preserved_messages
1489                .iter()
1490                .all(|m| m.role != MessageRole::Tool),
1491            "an orphan tool_result whose tool_use was archived must be dropped"
1492        );
1493    }
1494
1495    #[test]
1496    fn prepare_keeps_pending_trailing_tool_use_on_retry() {
1497        // #F65: a context-limit retry / truncation recovery compacts mid-tool.
1498        // The trailing assistant tool_use is genuinely pending — the run resumes
1499        // and appends the result — so its calls must survive compaction.
1500        let mut pending = ChatMessage::assistant("calling a tool");
1501        pending.tool_calls = Some(vec![tool_call("call_9", "do_thing")]);
1502        let messages = vec![
1503            ChatMessage::user("one"),
1504            ChatMessage::assistant("a1"),
1505            ChatMessage::user("two"),
1506            ChatMessage::assistant("a2"),
1507            ChatMessage::user("three"),
1508            pending,
1509        ];
1510        let request =
1511            CompactionRequest::auto(request_with(messages), CompactionTrigger::ContextLimitRetry);
1512        let prepared = prepare_compaction(&request, Some(100_000)).expect("prepared");
1513        let last = prepared
1514            .preserved_messages
1515            .last()
1516            .expect("non-empty preserved tail");
1517        assert!(
1518            last.tool_calls
1519                .as_ref()
1520                .is_some_and(|c| c.iter().any(|call| call.id.as_deref() == Some("call_9"))),
1521            "a pending trailing tool_use must be preserved across a retry compaction"
1522        );
1523    }
1524
1525    #[test]
1526    fn prepare_drops_trailing_tool_use_on_manual_compaction() {
1527        // Same trailing shape, but a manual compaction is not a resume: the tool
1528        // is treated as abandoned/cancelled, so the unpaired call is still
1529        // scrubbed (#71) — only the assistant's text is kept.
1530        let mut pending = ChatMessage::assistant("calling a tool");
1531        pending.tool_calls = Some(vec![tool_call("call_9", "do_thing")]);
1532        let messages = vec![
1533            ChatMessage::user("one"),
1534            ChatMessage::assistant("a1"),
1535            ChatMessage::user("two"),
1536            ChatMessage::assistant("a2"),
1537            ChatMessage::user("three"),
1538            pending,
1539        ];
1540        let request = CompactionRequest::manual(request_with(messages), None);
1541        let prepared = prepare_compaction(&request, Some(100_000)).expect("prepared");
1542        assert!(
1543            !prepared
1544                .preserved_messages
1545                .iter()
1546                .any(|m| m.tool_calls.as_ref().is_some_and(|c| !c.is_empty())),
1547            "manual compaction must scrub the trailing orphan tool_use"
1548        );
1549        assert!(
1550            prepared
1551                .preserved_messages
1552                .iter()
1553                .any(|m| m.content == "calling a tool"),
1554            "the assistant text is kept even though the orphan call is dropped"
1555        );
1556    }
1557
1558    #[test]
1559    fn replacement_starts_with_checkpoint_and_ack() {
1560        let prepared = PreparedCompaction {
1561            archived_messages: vec![ChatMessage::user("old")],
1562            preserved_messages: vec![ChatMessage::user("new")],
1563            previous_summary: None,
1564            history_excerpt: "old".to_string(),
1565            summary_images: Vec::new(),
1566        };
1567        let record = CompactionRecord {
1568            id: "c1".to_string(),
1569            trigger: CompactionTrigger::Manual,
1570            created_at: Local::now(),
1571            before_tokens: 100,
1572            after_tokens: 25,
1573            archived_message_count: 1,
1574            preserved_message_count: 1,
1575            preserved_turn_count: 1,
1576            summary_tokens: 10,
1577            duration_secs: 1.0,
1578            review_status: CompactionReviewStatus::Reviewed,
1579            review_error: None,
1580            focus: None,
1581            archive_path: None,
1582        };
1583        let messages = build_replacement_messages("## Goal\n- continue", &prepared, &record);
1584        assert_eq!(messages[0].kind, ChatMessageKind::ContextCheckpoint);
1585        assert!(messages[0].content.contains(CHECKPOINT_MARKER));
1586        assert_eq!(messages[2].content, "new");
1587    }
1588
1589    #[test]
1590    fn replacement_metadata_records_review_status() {
1591        let prepared = PreparedCompaction {
1592            archived_messages: vec![ChatMessage::user("old")],
1593            preserved_messages: vec![ChatMessage::user("new")],
1594            previous_summary: None,
1595            history_excerpt: "old".to_string(),
1596            summary_images: Vec::new(),
1597        };
1598        let record = CompactionRecord {
1599            id: "c1".to_string(),
1600            trigger: CompactionTrigger::Manual,
1601            created_at: Local::now(),
1602            before_tokens: 100,
1603            after_tokens: 25,
1604            archived_message_count: 1,
1605            preserved_message_count: 1,
1606            preserved_turn_count: 1,
1607            summary_tokens: 10,
1608            duration_secs: 1.0,
1609            review_status: CompactionReviewStatus::DraftValidated,
1610            review_error: Some("provider overloaded".to_string()),
1611            focus: None,
1612            archive_path: None,
1613        };
1614        let messages = build_replacement_messages("## Goal\n- continue", &prepared, &record);
1615        let metadata = messages[0].metadata.as_ref().expect("metadata");
1616        assert_eq!(
1617            metadata.get("review_status").and_then(|v| v.as_str()),
1618            Some("draft_validated")
1619        );
1620        assert_eq!(
1621            metadata.get("review_error").and_then(|v| v.as_str()),
1622            Some("provider overloaded")
1623        );
1624        assert!(messages[1].content.contains("structurally validated draft"));
1625    }
1626}