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