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        suppressed_builtin_tools: Vec::new(),
490    }
491}
492
493pub fn build_verification_request(
494    base: &ChatRequest,
495    prepared: &PreparedCompaction,
496    draft_summary: &str,
497    focus: Option<&str>,
498    policy: CompactionPolicy,
499) -> ChatRequest {
500    let prompt = format!(
501        "{}\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.",
502        summary_prompt(prepared, focus),
503        draft_summary.trim()
504    );
505    let mut message = ChatMessage::user(prompt);
506    if !prepared.summary_images.is_empty() {
507        message.images = Some(prepared.summary_images.clone());
508    }
509    ChatRequest {
510        model_id: base.model_id.clone(),
511        messages: vec![message],
512        system_prompt: compaction_system_prompt().to_string(),
513        instructions: None,
514        reasoning: compaction_reasoning(base.reasoning),
515        temperature: 0.0,
516        max_tokens: policy.summary_max_tokens,
517        tools: Vec::new(),
518        ollama_num_ctx: base.ollama_num_ctx,
519        ollama_allow_ram_offload: base.ollama_allow_ram_offload,
520        resolved_context_window: base.resolved_context_window,
521        resolved_max_output: base.resolved_max_output,
522        output_schema: None,
523        suppress_auto_compact: false,
524        suppressed_builtin_tools: Vec::new(),
525    }
526}
527
528pub fn build_replacement_messages(
529    summary: &str,
530    prepared: &PreparedCompaction,
531    record: &CompactionRecord,
532) -> Vec<ChatMessage> {
533    // The summary is model-generated from the full conversation and is persisted
534    // (replacement message + conversation file). Scrub any credential it echoed
535    // back from the archived turns before it's written (#70).
536    let summary = crate::utils::redact_secrets(summary);
537    let summary = summary.as_str();
538    let checkpoint = format!(
539        "# {}\n\nCompaction id: {}\nTrigger: {}\nCreated: {}\nArchived messages: {}\nPreserved messages: {}\n\n{}",
540        CHECKPOINT_MARKER,
541        record.id,
542        record.trigger.as_str(),
543        record.created_at.to_rfc3339(),
544        record.archived_message_count,
545        record.preserved_message_count,
546        summary.trim()
547    );
548    let mut user = ChatMessage::user(checkpoint);
549    user.kind = ChatMessageKind::ContextCheckpoint;
550    user.metadata = Some(serde_json::json!({
551        "compaction_id": record.id,
552        "trigger": record.trigger.as_str(),
553        "before_tokens": record.before_tokens,
554        "after_tokens": record.after_tokens,
555        "archived_message_count": record.archived_message_count,
556        "preserved_message_count": record.preserved_message_count,
557        "preserved_turn_count": record.preserved_turn_count,
558        "duration_secs": record.duration_secs,
559        "review_status": record.review_status.as_str(),
560        "review_error": record.review_error,
561    }));
562
563    let mut assistant = ChatMessage::assistant(compaction_receipt(record));
564    assistant.kind = ChatMessageKind::ContextCheckpoint;
565    assistant.metadata = user.metadata.clone();
566
567    let mut messages = Vec::with_capacity(2 + prepared.preserved_messages.len());
568    messages.push(user);
569    messages.push(assistant);
570    messages.extend(prepared.preserved_messages.clone());
571    messages
572}
573
574pub fn compaction_receipt(record: &CompactionRecord) -> String {
575    let review = match record.review_status {
576        CompactionReviewStatus::Reviewed => "Reviewed in a second pass.".to_string(),
577        CompactionReviewStatus::DraftValidated => match &record.review_error {
578            Some(error) => format!("Used the structurally validated draft: {error}."),
579            None => "Used the structurally validated draft.".to_string(),
580        },
581    };
582    format!(
583        "Context compacted: {} -> {} tokens, archived {} messages, preserved {} messages, took {:.1}s. {} I will continue from this checkpoint.",
584        format_compact_count(record.before_tokens),
585        format_compact_count(record.after_tokens),
586        record.archived_message_count,
587        record.preserved_message_count,
588        record.duration_secs,
589        review
590    )
591}
592
593pub fn normalize_summary(text: &str) -> String {
594    let trimmed = text.trim();
595    if let Some(summary) = extract_tagged_summary(trimmed) {
596        return summary.trim().to_string();
597    }
598    trimmed.to_string()
599}
600
601pub fn validate_summary_structure(summary: &str) -> Result<(), String> {
602    const HEADINGS: [&str; 10] = [
603        "## Goal",
604        "## User Preferences And Constraints",
605        "## Project State",
606        "## Completed Work",
607        "## Current Work",
608        "## Key Decisions",
609        "## Critical Files And Symbols",
610        "## Commands Tests And Results",
611        "## Open Questions Or Risks",
612        "## Next Steps",
613    ];
614
615    // Only the ten known headings are structure. Other `## `-prefixed lines
616    // are body content — checkpoints legitimately quote markdown (commands,
617    // error output, README excerpts) and must not fail closed over it.
618    let lines: Vec<&str> = summary.lines().collect();
619    let headings: Vec<(usize, &str)> = lines
620        .iter()
621        .enumerate()
622        .filter_map(|(index, line)| {
623            let trimmed = line.trim();
624            HEADINGS.contains(&trimmed).then_some((index, trimmed))
625        })
626        .collect();
627    let actual: Vec<&str> = headings.iter().map(|(_, heading)| *heading).collect();
628    if actual != HEADINGS {
629        return Err(format!(
630            "checkpoint headings must exactly match the required order; got {}",
631            actual.join(", ")
632        ));
633    }
634
635    for (index, (line_index, heading)) in headings.iter().enumerate() {
636        let body_end = headings
637            .get(index + 1)
638            .map(|(next_index, _)| *next_index)
639            .unwrap_or(lines.len());
640        let body = lines[line_index + 1..body_end].join("\n");
641        let body = body.trim();
642        if body.is_empty() || body == "-" || (body.starts_with("- [") && body.ends_with(']')) {
643            return Err(format!(
644                "checkpoint heading {heading} has placeholder content"
645            ));
646        }
647    }
648    Ok(())
649}
650
651pub fn combine_usage(a: Option<TokenUsage>, b: Option<TokenUsage>) -> Option<TokenUsage> {
652    match (a, b) {
653        (None, None) => None,
654        (Some(u), None) | (None, Some(u)) => Some(u),
655        (Some(mut left), Some(right)) => {
656            left.prompt_tokens = left.prompt_tokens.saturating_add(right.prompt_tokens);
657            left.completion_tokens = left
658                .completion_tokens
659                .saturating_add(right.completion_tokens);
660            left.cached_input_tokens = left
661                .cached_input_tokens
662                .saturating_add(right.cached_input_tokens);
663            left.cache_creation_input_tokens = left
664                .cache_creation_input_tokens
665                .saturating_add(right.cache_creation_input_tokens);
666            left.reasoning_output_tokens = left
667                .reasoning_output_tokens
668                .saturating_add(right.reasoning_output_tokens);
669            Some(left)
670        },
671    }
672}
673
674pub fn estimate_messages_tokens(messages: &[ChatMessage]) -> usize {
675    messages.iter().map(estimate_message_tokens).sum()
676}
677
678/// Canonical compact token/count formatter shared across the reducer status
679/// text, the footer widget, chat compaction receipts, and compaction records.
680/// Abbreviates at 1k (`43.8k`, `1.2M`), exact below; a whole value drops the
681/// decimal (`128k`, not `128.0k`). Previously three copies existed with two
682/// different policies (threshold + rounding), so the same count rendered
683/// inconsistently across the UI.
684pub fn format_compact_count(value: usize) -> String {
685    if value >= 1_000_000 {
686        format_scaled(value, 1_000_000, "M")
687    } else if value >= 1_000 {
688        format_scaled(value, 1_000, "k")
689    } else {
690        value.to_string()
691    }
692}
693
694fn format_scaled(value: usize, divisor: usize, suffix: &str) -> String {
695    let whole = value / divisor;
696    let decimal = ((value % divisor) * 10) / divisor;
697    if decimal == 0 {
698        format!("{}{}", whole, suffix)
699    } else {
700        format!("{}.{}{}", whole, decimal, suffix)
701    }
702}
703
704fn compaction_system_prompt() -> &'static str {
705    "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."
706}
707
708fn compaction_reasoning(current: ReasoningLevel) -> ReasoningLevel {
709    match current {
710        ReasoningLevel::None | ReasoningLevel::Minimal => current,
711        _ => ReasoningLevel::Low,
712    }
713}
714
715fn summary_prompt(prepared: &PreparedCompaction, focus: Option<&str>) -> String {
716    let anchor = prepared
717        .previous_summary
718        .as_deref()
719        .map(|summary| {
720            format!(
721                "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>",
722                summary.trim()
723            )
724        })
725        .unwrap_or_else(|| "Create a new checkpoint from the conversation history below.".to_string());
726
727    let focus = focus
728        .filter(|s| !s.trim().is_empty())
729        .map(|s| format!("\n# User Focus Instructions\n{}\n", s.trim()))
730        .unwrap_or_default();
731
732    format!(
733        "{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{}",
734        prepared.history_excerpt
735    )
736}
737
738/// Scrub orphan tool-call/tool-result pairs from the preserved tail so a
739/// forwarded unpaired block can't 400 a provider (Anthropic). Compaction keeps
740/// a recent tail verbatim; if the split inherits a pre-existing orphan, both
741/// directions must be repaired symmetrically:
742///
743///   * Forward (#71): an assistant `tool_use` whose `tool_result` never
744///     committed — e.g. a turn cancelled after the model emitted calls. Drop
745///     the orphaned calls (keeping the assistant's text) rather than fabricate
746///     results. A call with no `id` can't be paired, so it's treated as orphaned.
747///   * Reverse (#F64): a `tool_result` (role=Tool) whose `tool_use` id is no
748///     longer present among the retained messages — e.g. the assistant turn was
749///     archived while its result landed in the tail. Anthropic equally rejects a
750///     `tool_result` with no preceding `tool_use`, so drop the orphaned result.
751///
752/// When `preserve_pending_tail` is set (the run is resuming mid-tool after a
753/// context-limit retry / truncation recovery), a *trailing* assistant `tool_use`
754/// is genuinely pending execution rather than abandoned, so its calls are kept
755/// across the checkpoint and the resumed turn appends the awaited results (#F65).
756/// Only the final message qualifies: anything after an assistant `tool_use` (a
757/// tool result, a follow-up, a user cancel) means it is no longer pending. This
758/// is trigger-gated by the caller because a user cancel yields the same trailing
759/// shape but must still drop — there, the result will never arrive.
760/// Repair `tool_use`/`tool_result` pairing on a message list before it is sent
761/// to a provider (or seeded as a resumed prefix). Drops orphans in both
762/// directions: an assistant `tool_use` with no matching result, and a
763/// `tool_result` whose call is gone.
764///
765/// `preserve_pending_tail` is always `false` here: an outgoing request must
766/// never carry a trailing unanswered `tool_use` (providers 400 on it), and a
767/// cold-loaded prefix has no in-flight turn to append the awaited result — a
768/// preserved tail would be a permanent orphan. The compaction path calls
769/// [`drop_orphan_tool_calls`] directly with `true` for the truncation-recovery
770/// checkpoint, which *does* resume the pending call.
771pub(crate) fn normalize_history(messages: &mut Vec<ChatMessage>) {
772    drop_orphan_tool_calls(messages, false);
773}
774
775pub(crate) fn drop_orphan_tool_calls(messages: &mut Vec<ChatMessage>, preserve_pending_tail: bool) {
776    let pending_tail = if preserve_pending_tail
777        && messages.last().is_some_and(|m| {
778            m.role == MessageRole::Assistant && m.tool_calls.as_ref().is_some_and(|c| !c.is_empty())
779        }) {
780        Some(messages.len() - 1)
781    } else {
782        None
783    };
784
785    let answered: std::collections::HashSet<String> = messages
786        .iter()
787        .filter(|m| m.role == MessageRole::Tool)
788        .filter_map(|m| m.tool_call_id.clone())
789        .collect();
790
791    // Forward (#71): drop unanswered assistant `tool_use`, save a pending tail.
792    for (idx, m) in messages.iter_mut().enumerate() {
793        if Some(idx) == pending_tail {
794            continue;
795        }
796        let Some(calls) = m.tool_calls.as_mut() else {
797            continue;
798        };
799        calls.retain(|c| c.id.as_deref().is_some_and(|id| answered.contains(id)));
800        if calls.is_empty() {
801            m.tool_calls = None;
802        }
803        let kept: std::collections::HashSet<&str> = m
804            .tool_calls
805            .iter()
806            .flatten()
807            .filter_map(|call| call.id.as_deref())
808            .collect();
809        if let Some(continuation) = &mut m.provider_continuation {
810            continuation.retain_meta_function_calls(|call_id| kept.contains(call_id));
811        }
812    }
813
814    // Reverse (#F64): drop a `tool_result` whose `tool_use` id is no longer
815    // present among the assistant messages retained above (symmetric orphan).
816    let emitted: std::collections::HashSet<String> = messages
817        .iter()
818        .filter_map(|m| m.tool_calls.as_ref())
819        .flat_map(|calls| calls.iter())
820        .filter_map(|c| c.id.clone())
821        .collect();
822    messages.retain(|m| {
823        m.role != MessageRole::Tool
824            || m.tool_call_id
825                .as_deref()
826                .is_some_and(|id| emitted.contains(id))
827    });
828}
829
830fn tail_start_index(messages: &[ChatMessage], policy: CompactionPolicy) -> Option<usize> {
831    let mut user_turns = 0usize;
832    let mut start = None;
833    for (idx, msg) in messages.iter().enumerate().rev() {
834        if msg.role == MessageRole::User {
835            user_turns += 1;
836            start = Some(idx);
837            if user_turns >= policy.tail_turns {
838                break;
839            }
840        }
841    }
842    let mut start = start?;
843    while estimate_messages_tokens(&messages[start..]) > policy.tail_token_budget {
844        let next_user = messages
845            .iter()
846            .enumerate()
847            .skip(start + 1)
848            .find(|(_, msg)| msg.role == MessageRole::User)
849            .map(|(idx, _)| idx);
850        match next_user {
851            Some(idx) => start = idx,
852            None => break,
853        }
854    }
855    Some(start)
856}
857
858fn format_history_excerpt(
859    messages: &[ChatMessage],
860    policy: CompactionPolicy,
861    total_images: usize,
862    included_images: usize,
863) -> String {
864    let mut out = String::new();
865    if total_images > 0 {
866        out.push_str(&format!(
867            "\n[Visual context: {included_images} of {total_images} archived image attachment(s) supplied with this request; {} omitted by the input budget.]\n",
868            total_images.saturating_sub(included_images)
869        ));
870    }
871    for (idx, msg) in messages.iter().enumerate() {
872        let role = match msg.role {
873            MessageRole::User => "USER",
874            MessageRole::Assistant => "ASSISTANT",
875            MessageRole::System => "SYSTEM",
876            MessageRole::Tool => "TOOL",
877        };
878        out.push_str(&format!("\n\n--- MESSAGE {} [{}] ---\n", idx + 1, role));
879        if msg.kind != ChatMessageKind::Normal {
880            out.push_str(&format!("kind: {:?}\n", msg.kind));
881        }
882        if let Some(name) = &msg.tool_name {
883            out.push_str(&format!("tool_name: {}\n", name));
884        }
885        if let Some(id) = &msg.tool_call_id {
886            out.push_str(&format!("tool_call_id: {}\n", id));
887        }
888        if let Some(calls) = &msg.tool_calls {
889            for call in calls {
890                let mut arguments = call.function.arguments.clone();
891                crate::utils::redact_json(&mut arguments);
892                let arguments = truncate_middle(
893                    &arguments.to_string(),
894                    policy.tool_output_max_chars.saturating_mul(4),
895                );
896                out.push_str(&format!(
897                    "tool_call: id={} name={} arguments={}\n",
898                    call.id.as_deref().unwrap_or("<missing>"),
899                    call.function.name,
900                    arguments
901                ));
902            }
903        }
904        if let Some(images) = &msg.images
905            && !images.is_empty()
906        {
907            out.push_str(&format!(
908                "[{} image attachment(s) referenced above]\n",
909                images.len()
910            ));
911        }
912        for action in &msg.actions {
913            out.push_str(&format!(
914                "action: {}({}) duration={:?}\n",
915                action.action_type, action.target, action.duration_seconds
916            ));
917            if let Some(metadata) = &action.metadata {
918                out.push_str(&format!("action_metadata: {:?}\n", metadata));
919            }
920        }
921        let cap = if msg.role == MessageRole::Tool {
922            policy.tool_output_max_chars
923        } else {
924            policy.tool_output_max_chars.saturating_mul(4)
925        };
926        out.push_str(&truncate_middle(&msg.content, cap));
927    }
928    out
929}
930
931fn estimate_message_tokens(msg: &ChatMessage) -> usize {
932    let mut chars = msg.content.len();
933    chars = chars.saturating_add(format!("{:?}", msg.role).len());
934    chars = chars.saturating_add(msg.tool_name.as_deref().map(str::len).unwrap_or(0));
935    chars = chars.saturating_add(msg.tool_call_id.as_deref().map(str::len).unwrap_or(0));
936    if let Some(images) = &msg.images {
937        chars = chars.saturating_add(images.iter().map(String::len).sum::<usize>());
938    }
939    // Assistant tool calls carry the function name + a JSON arguments payload
940    // (often kilobytes for a file write or shell script). Omitting these made
941    // the estimate run systematically low for this tool-heavy agent, causing
942    // under-compaction and provider-side context overflows.
943    if let Some(tool_calls) = &msg.tool_calls {
944        for tc in tool_calls {
945            chars = chars.saturating_add(tc.function.name.len());
946            chars = chars.saturating_add(tc.function.arguments.to_string().len());
947            chars = chars.saturating_add(tc.id.as_deref().map(str::len).unwrap_or(0));
948        }
949    }
950    chars.div_ceil(4)
951}
952
953fn truncate_middle(text: &str, max_chars: usize) -> String {
954    if text.chars().count() <= max_chars {
955        return text.to_string();
956    }
957    if max_chars < 128 {
958        return text.chars().take(max_chars).collect();
959    }
960    let marker = "\n\n[... truncated during context compaction ...]\n\n";
961    let keep = max_chars.saturating_sub(marker.len());
962    let head = keep / 2;
963    let tail = keep.saturating_sub(head);
964    let start: String = text.chars().take(head).collect();
965    let end: String = text
966        .chars()
967        .rev()
968        .take(tail)
969        .collect::<Vec<_>>()
970        .into_iter()
971        .rev()
972        .collect();
973    format!("{start}{marker}{end}")
974}
975
976fn extract_tagged_summary(text: &str) -> Option<&str> {
977    let start_tag = "<summary>";
978    let end_tag = "</summary>";
979    let start = text.find(start_tag)? + start_tag.len();
980    let end = text[start..].find(end_tag)? + start;
981    Some(&text[start..end])
982}
983
984#[cfg(test)]
985mod tests {
986    use super::*;
987
988    fn request_with(messages: Vec<ChatMessage>) -> ChatRequest {
989        ChatRequest {
990            model_id: "ollama/test".to_string(),
991            messages,
992            system_prompt: "system".to_string(),
993            instructions: None,
994            reasoning: ReasoningLevel::Medium,
995            temperature: 0.7,
996            max_tokens: 4096,
997            tools: Vec::new(),
998            ollama_num_ctx: None,
999            ollama_allow_ram_offload: None,
1000            resolved_context_window: None,
1001            resolved_max_output: None,
1002            output_schema: None,
1003            suppress_auto_compact: false,
1004            suppressed_builtin_tools: Vec::new(),
1005        }
1006    }
1007
1008    #[test]
1009    fn classify_length_stop_discriminates_output_cap_from_context_full() {
1010        let usage = TokenUsage::provider(16_600, 4_000);
1011        // No usage → Unknown (legacy recovery path preserved).
1012        assert_eq!(
1013            classify_length_stop(None, Some(100_000), 4_000),
1014            LengthCause::Unknown
1015        );
1016        // Unknown window + usage → the per-response output cap (the normal
1017        // remote-provider case — the GLM-5.2 misdiagnosis this fixes).
1018        assert_eq!(
1019            classify_length_stop(Some(&usage), None, 4_000),
1020            LengthCause::OutputCapped
1021        );
1022        // Window with plenty of room → still the output cap.
1023        assert_eq!(
1024            classify_length_stop(Some(&usage), Some(1_000_000), 4_000),
1025            LengthCause::OutputCapped
1026        );
1027        // prompt + completion + reserve reaching the window → genuinely full.
1028        assert_eq!(
1029            classify_length_stop(Some(&usage), Some(24_000), 4_000),
1030            LengthCause::ContextFull
1031        );
1032    }
1033
1034    #[test]
1035    fn classify_length_stop_counts_cached_and_reasoning_tokens() {
1036        let usage = TokenUsage::provider(100, 100)
1037            .with_cached_input(700)
1038            .with_cache_creation(50)
1039            .with_reasoning_output(50);
1040        assert_eq!(usage.total_tokens(), 1_000);
1041        assert_eq!(
1042            classify_length_stop(Some(&usage), Some(1_100), 100),
1043            LengthCause::ContextFull
1044        );
1045    }
1046
1047    #[test]
1048    fn summary_and_verification_requests_copy_resolved_limits() {
1049        // The summarizer calls the same model — its request must inherit the
1050        // live-discovered limits or Anthropic AUTO would fall to the 8192
1051        // floor mid-compaction.
1052        let mut base = request_with(vec![ChatMessage::user("hello")]);
1053        base.resolved_context_window = Some(1_000_000);
1054        base.resolved_max_output = Some(128_000);
1055        let prepared = PreparedCompaction {
1056            archived_messages: vec![ChatMessage::user("old")],
1057            preserved_messages: vec![],
1058            previous_summary: None,
1059            history_excerpt: "excerpt".to_string(),
1060            summary_images: Vec::new(),
1061        };
1062        let policy = CompactionPolicy::default();
1063        let summary = build_summary_request(&base, &prepared, None, policy);
1064        assert_eq!(summary.resolved_context_window, Some(1_000_000));
1065        assert_eq!(summary.resolved_max_output, Some(128_000));
1066        let verify = build_verification_request(&base, &prepared, "draft", None, policy);
1067        assert_eq!(verify.resolved_context_window, Some(1_000_000));
1068        assert_eq!(verify.resolved_max_output, Some(128_000));
1069    }
1070
1071    #[test]
1072    fn response_reserve_is_reasoning_aware_on_auto() {
1073        let policy = CompactionPolicy::default();
1074        let mut req = request_with(vec![ChatMessage::user("hello")]);
1075
1076        // AUTO: the reserve scales with the reasoning level instead of
1077        // mirroring a send-cap that no longer exists.
1078        req.max_tokens = 0;
1079        req.reasoning = ReasoningLevel::None;
1080        let base = policy.response_reserve(&req);
1081        assert_eq!(base, policy.min_response_reserve_tokens);
1082        req.reasoning = ReasoningLevel::Max;
1083        let deep = policy.response_reserve(&req);
1084        assert!(deep > base, "a Max-reasoning turn must reserve more room");
1085        assert!(deep <= policy.max_response_reserve_tokens);
1086
1087        // An explicit cap is the best reserve estimate — honored (clamped).
1088        req.max_tokens = 12_000;
1089        assert_eq!(policy.response_reserve(&req), 12_000);
1090        req.max_tokens = 1_000_000;
1091        assert_eq!(
1092            policy.response_reserve(&req),
1093            policy.max_response_reserve_tokens
1094        );
1095    }
1096
1097    #[test]
1098    fn auto_compaction_triggers_by_percent() {
1099        let snapshot = ContextUsageSnapshot::from_estimate(
1100            super::super::state::PromptTokenBreakdown {
1101                system_tokens: 0,
1102                instructions_tokens: 0,
1103                message_tokens: 86,
1104                tool_schema_tokens: 0,
1105                image_count: 0,
1106                message_count: 2,
1107                tool_count: 0,
1108            },
1109            Some(100),
1110        );
1111        let req = request_with(vec![ChatMessage::user("hello")]);
1112        assert!(should_auto_compact(&snapshot, &req, CompactionPolicy::default()).is_ok());
1113    }
1114
1115    #[test]
1116    fn auto_compaction_pause_rides_the_request() {
1117        let snapshot = ContextUsageSnapshot::from_estimate(
1118            super::super::state::PromptTokenBreakdown {
1119                system_tokens: 0,
1120                instructions_tokens: 0,
1121                message_tokens: 86,
1122                tool_schema_tokens: 0,
1123                image_count: 0,
1124                message_count: 2,
1125                tool_count: 0,
1126            },
1127            Some(100),
1128        );
1129        let mut req = request_with(vec![ChatMessage::user("hello")]);
1130        req.suppress_auto_compact = true;
1131        assert_eq!(
1132            should_auto_compact(&snapshot, &req, CompactionPolicy::default()),
1133            Err(CompactionSkip::Suppressed)
1134        );
1135    }
1136
1137    #[test]
1138    fn boundary_fingerprint_matches_only_the_identical_message() {
1139        let message = ChatMessage::user("hello");
1140        let boundary = CompactionBoundary::from_message(&message);
1141        assert!(boundary.matches(&message));
1142
1143        let mut other_content = message.clone();
1144        other_content.content = "hello!".to_string();
1145        assert!(!boundary.matches(&other_content));
1146
1147        let mut other_kind = message.clone();
1148        other_kind.kind = ChatMessageKind::ContextCheckpoint;
1149        assert!(!boundary.matches(&other_kind));
1150
1151        let mut other_time = message.clone();
1152        other_time.timestamp += chrono::Duration::nanoseconds(1);
1153        assert!(!boundary.matches(&other_time));
1154    }
1155
1156    #[test]
1157    fn prepare_preserves_recent_two_user_turns() {
1158        let messages = vec![
1159            ChatMessage::user("one"),
1160            ChatMessage::assistant("one answer"),
1161            ChatMessage::user("two"),
1162            ChatMessage::assistant("two answer"),
1163            ChatMessage::user("three"),
1164        ];
1165        let request = CompactionRequest::manual(request_with(messages), None);
1166        let prepared = prepare_compaction(&request, Some(100_000)).expect("prepared");
1167        assert_eq!(prepared.archived_messages.len(), 2);
1168        assert_eq!(prepared.preserved_messages.len(), 3);
1169        assert_eq!(prepared.preserved_messages[0].content, "two");
1170    }
1171
1172    #[test]
1173    fn prepare_projects_redacted_tool_arguments_and_archived_images() {
1174        let mut old = ChatMessage::user("inspect the screenshot");
1175        old.images = Some(vec!["aGVsbG8=".to_string()]);
1176        let mut call = ChatMessage::assistant("");
1177        call.tool_calls = Some(vec![crate::models::tool_call::ToolCall {
1178            id: Some("call_1".to_string()),
1179            function: crate::models::tool_call::FunctionCall {
1180                name: "execute_command".to_string(),
1181                arguments: serde_json::json!({
1182                    "cmd": "cargo test --workspace",
1183                    "api_key": "opaque-secret-value"
1184                }),
1185            },
1186        }]);
1187        let messages = vec![
1188            old,
1189            call,
1190            ChatMessage::tool("call_1", "execute_command", "tests passed"),
1191            ChatMessage::user("second"),
1192            ChatMessage::assistant("second answer"),
1193            ChatMessage::user("third"),
1194        ];
1195        let request = CompactionRequest::manual(request_with(messages), None);
1196        let prepared = prepare_compaction(&request, Some(100_000)).expect("prepared");
1197        assert!(prepared.history_excerpt.contains("cargo test --workspace"));
1198        assert!(prepared.history_excerpt.contains("[REDACTED]"));
1199        assert!(!prepared.history_excerpt.contains("opaque-secret-value"));
1200        assert_eq!(prepared.summary_images, vec!["aGVsbG8=".to_string()]);
1201        let summary =
1202            build_summary_request(&request.chat, &prepared, None, CompactionPolicy::default());
1203        assert_eq!(
1204            summary.messages[0].images.as_deref(),
1205            Some(prepared.summary_images.as_slice())
1206        );
1207    }
1208
1209    #[test]
1210    fn complete_summary_request_fits_known_window() {
1211        let messages = vec![
1212            ChatMessage::user("old ".repeat(40_000)),
1213            ChatMessage::assistant("old answer ".repeat(20_000)),
1214            ChatMessage::user("second"),
1215            ChatMessage::assistant("second answer"),
1216            ChatMessage::user("third"),
1217        ];
1218        let request = CompactionRequest::manual(request_with(messages), Some("focus".repeat(500)));
1219        let window = 32_000;
1220        let prepared = prepare_compaction(&request, Some(window)).expect("prepared");
1221        let summary = build_summary_request(
1222            &request.chat,
1223            &prepared,
1224            request.instructions.as_deref(),
1225            request.policy,
1226        );
1227        let usage = crate::domain::estimate_context_usage_for_request(&summary, Some(window));
1228        assert!(usage.used_tokens.saturating_add(summary.max_tokens) <= window);
1229    }
1230
1231    #[test]
1232    fn complete_summary_request_with_images_fits_known_window() {
1233        let mut old = ChatMessage::user("old ".repeat(40_000));
1234        old.images = Some(vec!["i".repeat(40_000), "j".repeat(40_000)]);
1235        let messages = vec![
1236            old,
1237            ChatMessage::assistant("old answer ".repeat(20_000)),
1238            ChatMessage::user("second"),
1239            ChatMessage::assistant("second answer"),
1240            ChatMessage::user("third"),
1241        ];
1242        let request = CompactionRequest::manual(request_with(messages), Some("focus".repeat(500)));
1243        let window = 32_000;
1244        let prepared = prepare_compaction(&request, Some(window)).expect("prepared");
1245        let summary = build_summary_request(
1246            &request.chat,
1247            &prepared,
1248            request.instructions.as_deref(),
1249            request.policy,
1250        );
1251        assert!(
1252            !prepared.summary_images.is_empty(),
1253            "the newest image fits the budget and must be attached"
1254        );
1255        let usage = crate::domain::estimate_context_usage_for_request(&summary, Some(window));
1256        assert!(
1257            usage.used_tokens.saturating_add(summary.max_tokens) <= window,
1258            "used {} + max_tokens {} > window {}",
1259            usage.used_tokens,
1260            summary.max_tokens,
1261            window
1262        );
1263    }
1264
1265    #[test]
1266    fn image_budget_keeps_every_fitting_image_newest_first() {
1267        let mut old = ChatMessage::user("inspect");
1268        // Oldest image is tiny, newest exceeds the whole input budget. One
1269        // oversized recent screenshot must not evict older small diagrams
1270        // that fit — the older image still rides along, and the projection
1271        // reports the omission honestly.
1272        old.images = Some(vec!["a".repeat(400), "b".repeat(400_000)]);
1273        let messages = vec![
1274            old,
1275            ChatMessage::assistant("looked"),
1276            ChatMessage::user("second"),
1277            ChatMessage::assistant("second answer"),
1278            ChatMessage::user("third"),
1279        ];
1280        let request = CompactionRequest::manual(request_with(messages), None);
1281        let prepared = prepare_compaction(&request, None).expect("prepared");
1282        assert_eq!(prepared.summary_images, vec!["a".repeat(400)]);
1283        assert!(
1284            prepared
1285                .history_excerpt
1286                .contains("1 of 2 archived image attachment(s)")
1287        );
1288    }
1289
1290    #[test]
1291    fn summary_structure_requires_ordered_non_placeholder_sections() {
1292        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";
1293        assert!(validate_summary_structure(valid).is_ok());
1294        assert!(validate_summary_structure("## Goal\n- [single-sentence task summary]").is_err());
1295    }
1296
1297    #[test]
1298    fn summary_structure_tolerates_quoted_markdown_in_bodies() {
1299        // Checkpoints legitimately quote markdown — a `## `-prefixed line
1300        // inside a section body is content, not structure, and must not fail
1301        // the checkpoint closed.
1302        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";
1303        assert!(validate_summary_structure(with_quoted_heading).is_ok());
1304    }
1305
1306    fn tool_call(id: &str, name: &str) -> crate::models::tool_call::ToolCall {
1307        crate::models::tool_call::ToolCall {
1308            id: Some(id.to_string()),
1309            function: crate::models::tool_call::FunctionCall {
1310                name: name.to_string(),
1311                arguments: serde_json::json!({}),
1312            },
1313        }
1314    }
1315
1316    #[test]
1317    fn prepare_strips_orphan_tool_call_from_preserved_tail() {
1318        // A tail that inherits an assistant(tool_calls) with no matching result
1319        // (e.g. a cancelled tool turn) must not forward the unpaired tool_use (#71).
1320        let mut orphan = ChatMessage::assistant("calling a tool");
1321        orphan.tool_calls = Some(vec![tool_call("call_1", "do_thing")]);
1322        let messages = vec![
1323            ChatMessage::user("one"),
1324            ChatMessage::assistant("one answer"),
1325            ChatMessage::user("two"),
1326            orphan,
1327            ChatMessage::user("three"),
1328        ];
1329        let request = CompactionRequest::manual(request_with(messages), None);
1330        let prepared = prepare_compaction(&request, Some(100_000)).expect("prepared");
1331        let has_orphan = prepared
1332            .preserved_messages
1333            .iter()
1334            .any(|m| m.tool_calls.as_ref().is_some_and(|c| !c.is_empty()));
1335        assert!(
1336            !has_orphan,
1337            "orphan tool_use must be stripped from the tail"
1338        );
1339        // The message itself (its text) is preserved — only the calls are dropped.
1340        assert!(
1341            prepared
1342                .preserved_messages
1343                .iter()
1344                .any(|m| m.content == "calling a tool")
1345        );
1346    }
1347
1348    #[test]
1349    fn prepare_keeps_paired_tool_call_in_tail() {
1350        // The mirror case: a tool_call whose result is also in the tail survives.
1351        let mut asst = ChatMessage::assistant("calling");
1352        asst.tool_calls = Some(vec![tool_call("call_1", "do_thing")]);
1353        let messages = vec![
1354            ChatMessage::user("one"),
1355            ChatMessage::assistant("one answer"),
1356            ChatMessage::user("two"),
1357            asst,
1358            ChatMessage::tool("call_1", "do_thing", "ok"),
1359            ChatMessage::user("three"),
1360        ];
1361        let request = CompactionRequest::manual(request_with(messages), None);
1362        let prepared = prepare_compaction(&request, Some(100_000)).expect("prepared");
1363        let kept = prepared
1364            .preserved_messages
1365            .iter()
1366            .any(|m| m.tool_calls.as_ref().is_some_and(|c| !c.is_empty()));
1367        assert!(kept, "a tool_call paired with its result must be preserved");
1368    }
1369
1370    #[test]
1371    fn normalize_history_drops_orphan_assistant_tool_use() {
1372        let mut orphan = ChatMessage::assistant("calling a tool");
1373        orphan.tool_calls = Some(vec![tool_call("call_1", "do_thing")]);
1374        let mut messages = vec![ChatMessage::user("hi"), orphan];
1375        normalize_history(&mut messages);
1376        assert!(
1377            messages
1378                .iter()
1379                .all(|m| m.tool_calls.as_ref().is_none_or(|c| c.is_empty())),
1380            "dangling tool_use must be dropped"
1381        );
1382        assert!(
1383            messages.iter().any(|m| m.content == "calling a tool"),
1384            "the assistant text is preserved — only the unpaired call is removed"
1385        );
1386    }
1387
1388    #[test]
1389    fn normalize_history_drops_matching_meta_replay_function_call() {
1390        let mut orphan = ChatMessage::assistant("calling a tool");
1391        orphan.tool_calls = Some(vec![tool_call("call_1", "do_thing")]);
1392        orphan.provider_continuation = Some(crate::models::ProviderContinuation::MetaResponses {
1393            output: vec![crate::models::MetaResponseItem::from_wire(
1394                serde_json::json!({
1395                    "type": "function_call",
1396                    "call_id": "call_1",
1397                    "name": "do_thing",
1398                    "arguments": "{}"
1399                }),
1400            )],
1401        });
1402        let mut messages = vec![ChatMessage::user("hi"), orphan];
1403        normalize_history(&mut messages);
1404        let output = messages[1]
1405            .provider_continuation
1406            .as_ref()
1407            .and_then(crate::models::ProviderContinuation::meta_output)
1408            .unwrap();
1409        assert!(
1410            output.is_empty(),
1411            "orphan Meta function_call must also drop"
1412        );
1413    }
1414
1415    #[test]
1416    fn normalize_history_drops_orphan_tool_result() {
1417        let mut messages = vec![
1418            ChatMessage::user("hi"),
1419            ChatMessage::tool("call_ghost", "do_thing", "result with no call"),
1420        ];
1421        normalize_history(&mut messages);
1422        assert!(
1423            !messages.iter().any(|m| m.role == MessageRole::Tool),
1424            "a tool_result whose call is absent must be dropped"
1425        );
1426    }
1427
1428    #[test]
1429    fn normalize_history_keeps_well_paired_tool_calls() {
1430        let mut asst = ChatMessage::assistant("calling");
1431        asst.tool_calls = Some(vec![tool_call("call_1", "do_thing")]);
1432        let mut messages = vec![
1433            ChatMessage::user("hi"),
1434            asst,
1435            ChatMessage::tool("call_1", "do_thing", "ok"),
1436        ];
1437        let before = messages.len();
1438        normalize_history(&mut messages);
1439        assert_eq!(
1440            messages.len(),
1441            before,
1442            "a paired call+result survives intact"
1443        );
1444        assert!(
1445            messages[1]
1446                .tool_calls
1447                .as_ref()
1448                .is_some_and(|c| c.len() == 1)
1449        );
1450    }
1451
1452    #[test]
1453    fn normalize_history_drops_idless_tool_use() {
1454        let mut asst = ChatMessage::assistant("calling");
1455        asst.tool_calls = Some(vec![crate::models::tool_call::ToolCall {
1456            id: None,
1457            function: crate::models::tool_call::FunctionCall {
1458                name: "do_thing".into(),
1459                arguments: serde_json::json!({}),
1460            },
1461        }]);
1462        let mut messages = vec![asst];
1463        normalize_history(&mut messages);
1464        assert!(
1465            messages[0].tool_calls.as_ref().is_none_or(|c| c.is_empty()),
1466            "an id-less tool_use is inherently unpaired → dropped"
1467        );
1468    }
1469
1470    #[test]
1471    fn prepare_drops_reverse_orphan_tool_result_from_tail() {
1472        // The mirror of #71 (#F64): the assistant `tool_use` is archived (split
1473        // out of the tail) while its `tool_result` lands in the preserved tail.
1474        // A lone `tool_result` with no preceding `tool_use` 400s Anthropic, so it
1475        // must be dropped symmetrically.
1476        let mut asst = ChatMessage::assistant("calling");
1477        asst.tool_calls = Some(vec![tool_call("call_1", "do_thing")]);
1478        let messages = vec![
1479            ChatMessage::user("one"),
1480            asst,
1481            ChatMessage::user("two"),
1482            ChatMessage::tool("call_1", "do_thing", "result"),
1483            ChatMessage::user("three"),
1484        ];
1485        // Tail keeps the last two user turns ("two".., "three"), so the assistant
1486        // tool_use is archived but the tool_result survives into the tail.
1487        let request = CompactionRequest::manual(request_with(messages), None);
1488        let prepared = prepare_compaction(&request, Some(100_000)).expect("prepared");
1489        assert!(
1490            prepared
1491                .preserved_messages
1492                .iter()
1493                .all(|m| m.role != MessageRole::Tool),
1494            "an orphan tool_result whose tool_use was archived must be dropped"
1495        );
1496    }
1497
1498    #[test]
1499    fn prepare_keeps_pending_trailing_tool_use_on_retry() {
1500        // #F65: a context-limit retry / truncation recovery compacts mid-tool.
1501        // The trailing assistant tool_use is genuinely pending — the run resumes
1502        // and appends the result — so its calls must survive compaction.
1503        let mut pending = ChatMessage::assistant("calling a tool");
1504        pending.tool_calls = Some(vec![tool_call("call_9", "do_thing")]);
1505        let messages = vec![
1506            ChatMessage::user("one"),
1507            ChatMessage::assistant("a1"),
1508            ChatMessage::user("two"),
1509            ChatMessage::assistant("a2"),
1510            ChatMessage::user("three"),
1511            pending,
1512        ];
1513        let request =
1514            CompactionRequest::auto(request_with(messages), CompactionTrigger::ContextLimitRetry);
1515        let prepared = prepare_compaction(&request, Some(100_000)).expect("prepared");
1516        let last = prepared
1517            .preserved_messages
1518            .last()
1519            .expect("non-empty preserved tail");
1520        assert!(
1521            last.tool_calls
1522                .as_ref()
1523                .is_some_and(|c| c.iter().any(|call| call.id.as_deref() == Some("call_9"))),
1524            "a pending trailing tool_use must be preserved across a retry compaction"
1525        );
1526    }
1527
1528    #[test]
1529    fn prepare_drops_trailing_tool_use_on_manual_compaction() {
1530        // Same trailing shape, but a manual compaction is not a resume: the tool
1531        // is treated as abandoned/cancelled, so the unpaired call is still
1532        // scrubbed (#71) — only the assistant's text is kept.
1533        let mut pending = ChatMessage::assistant("calling a tool");
1534        pending.tool_calls = Some(vec![tool_call("call_9", "do_thing")]);
1535        let messages = vec![
1536            ChatMessage::user("one"),
1537            ChatMessage::assistant("a1"),
1538            ChatMessage::user("two"),
1539            ChatMessage::assistant("a2"),
1540            ChatMessage::user("three"),
1541            pending,
1542        ];
1543        let request = CompactionRequest::manual(request_with(messages), None);
1544        let prepared = prepare_compaction(&request, Some(100_000)).expect("prepared");
1545        assert!(
1546            !prepared
1547                .preserved_messages
1548                .iter()
1549                .any(|m| m.tool_calls.as_ref().is_some_and(|c| !c.is_empty())),
1550            "manual compaction must scrub the trailing orphan tool_use"
1551        );
1552        assert!(
1553            prepared
1554                .preserved_messages
1555                .iter()
1556                .any(|m| m.content == "calling a tool"),
1557            "the assistant text is kept even though the orphan call is dropped"
1558        );
1559    }
1560
1561    #[test]
1562    fn replacement_starts_with_checkpoint_and_ack() {
1563        let prepared = PreparedCompaction {
1564            archived_messages: vec![ChatMessage::user("old")],
1565            preserved_messages: vec![ChatMessage::user("new")],
1566            previous_summary: None,
1567            history_excerpt: "old".to_string(),
1568            summary_images: Vec::new(),
1569        };
1570        let record = CompactionRecord {
1571            id: "c1".to_string(),
1572            trigger: CompactionTrigger::Manual,
1573            created_at: Local::now(),
1574            before_tokens: 100,
1575            after_tokens: 25,
1576            archived_message_count: 1,
1577            preserved_message_count: 1,
1578            preserved_turn_count: 1,
1579            summary_tokens: 10,
1580            duration_secs: 1.0,
1581            review_status: CompactionReviewStatus::Reviewed,
1582            review_error: None,
1583            focus: None,
1584            archive_path: None,
1585        };
1586        let messages = build_replacement_messages("## Goal\n- continue", &prepared, &record);
1587        assert_eq!(messages[0].kind, ChatMessageKind::ContextCheckpoint);
1588        assert!(messages[0].content.contains(CHECKPOINT_MARKER));
1589        assert_eq!(messages[2].content, "new");
1590    }
1591
1592    #[test]
1593    fn replacement_metadata_records_review_status() {
1594        let prepared = PreparedCompaction {
1595            archived_messages: vec![ChatMessage::user("old")],
1596            preserved_messages: vec![ChatMessage::user("new")],
1597            previous_summary: None,
1598            history_excerpt: "old".to_string(),
1599            summary_images: Vec::new(),
1600        };
1601        let record = CompactionRecord {
1602            id: "c1".to_string(),
1603            trigger: CompactionTrigger::Manual,
1604            created_at: Local::now(),
1605            before_tokens: 100,
1606            after_tokens: 25,
1607            archived_message_count: 1,
1608            preserved_message_count: 1,
1609            preserved_turn_count: 1,
1610            summary_tokens: 10,
1611            duration_secs: 1.0,
1612            review_status: CompactionReviewStatus::DraftValidated,
1613            review_error: Some("provider overloaded".to_string()),
1614            focus: None,
1615            archive_path: None,
1616        };
1617        let messages = build_replacement_messages("## Goal\n- continue", &prepared, &record);
1618        let metadata = messages[0].metadata.as_ref().expect("metadata");
1619        assert_eq!(
1620            metadata.get("review_status").and_then(|v| v.as_str()),
1621            Some("draft_validated")
1622        );
1623        assert_eq!(
1624            metadata.get("review_error").and_then(|v| v.as_str()),
1625            Some("provider overloaded")
1626        );
1627        assert!(messages[1].content.contains("structurally validated draft"));
1628    }
1629}