Skip to main content

recursive/compact/
mod.rs

1//! Context compaction.
2//!
3//! Two layers:
4//!
5//! * [`Compactor`] — LLM-driven summarisation of older transcript messages
6//!   into a single system message. Disabled by default (threshold =
7//!   `usize::MAX`), enabled via `AgentRuntimeBuilder::compactor(...)`.
8//!
9//! * [`Microcompactor`] — no-LLM proactive pruning of old tool results by
10//!   count. Runs before the LLM-driven compactor each step so the transcript
11//!   may drop below the compaction threshold and the expensive LLM summary
12//!   is skipped entirely.
13
14pub mod micro;
15pub mod prompt;
16pub mod reinject;
17pub mod retry;
18
19pub use micro::{Microcompactor, MICROCOMPACT_PLACEHOLDER};
20pub use reinject::{
21    build_skill_reinjector_from_env, FileReinjector, PlanTodoReinjector, SkillReinjector,
22};
23pub use retry::{truncate_head_for_retry, MAX_PTL_RETRIES};
24
25/// Stop attempting proactive compaction after this many consecutive
26/// failures. Emergency compaction (`compact_on_overflow`) is exempt —
27/// it is the last-resort recovery for an already-failed turn.
28pub const MAX_CONSECUTIVE_COMPACT_FAILURES: u32 = 3;
29
30use crate::error::Result;
31use crate::llm::{ChatProvider, StructuredRequest, ToolSpec};
32use crate::message::Message;
33
34/// Configuration for LLM-driven transcript compaction.
35#[derive(Debug, Clone)]
36pub struct Compactor {
37    /// Character-count threshold above which compaction is triggered.
38    /// Defaults to `usize::MAX` (disabled).
39    ///
40    /// This estimate is reliable for English content (~4 chars/token) but
41    /// underestimates token density in CJK languages. When actual
42    /// `prompt_tokens` from the API are available, `threshold_prompt_tokens`
43    /// is checked first and takes priority.
44    pub threshold_chars: usize,
45    /// Token-count threshold: if the last turn's `prompt_tokens` from the
46    /// API response meets or exceeds this value, compaction is triggered.
47    ///
48    /// When `Some` and the last prompt_tokens is non-zero, this check takes
49    /// priority over `threshold_chars`. This eliminates the 4-char/token
50    /// assumption that fails for CJK content. Set to `None` to rely solely
51    /// on the char-based heuristic.
52    pub threshold_prompt_tokens: Option<u32>,
53    /// Number of most-recent messages to keep verbatim during compaction.
54    pub keep_recent_n: usize,
55}
56
57impl Default for Compactor {
58    fn default() -> Self {
59        Self {
60            threshold_chars: usize::MAX,
61            threshold_prompt_tokens: None,
62            keep_recent_n: 8,
63        }
64    }
65}
66
67impl Compactor {
68    /// Create a new compactor with the given threshold and default `keep_recent_n` (8).
69    pub fn new(threshold_chars: usize) -> Self {
70        Self {
71            threshold_chars,
72            threshold_prompt_tokens: None,
73            keep_recent_n: 8,
74        }
75    }
76
77    /// Set the token-count threshold for compaction.
78    ///
79    /// When the last turn's `prompt_tokens` (from the API response) meets or
80    /// exceeds this value, compaction is triggered instead of relying on the
81    /// character-count estimate. Prefer this for non-English workloads.
82    pub fn threshold_prompt_tokens(mut self, n: u32) -> Self {
83        self.threshold_prompt_tokens = Some(n);
84        self
85    }
86
87    /// Set the number of recent messages to preserve verbatim.
88    pub fn keep_recent_n(mut self, n: usize) -> Self {
89        self.keep_recent_n = n;
90        self
91    }
92
93    /// Estimate the prompt byte count of a transcript.
94    ///
95    /// This is a rough proxy for token count (≈4 bytes/token for English;
96    /// over-counts CJK ~3× because CJK chars are 3 UTF-8 bytes each). The
97    /// agent uses this to decide whether compaction is needed before the
98    /// next LLM call. It is intentionally byte-based and kept in bytes —
99    /// the char/byte threshold calibration depends on it.
100    /// Includes tool_calls arguments and reasoning_content which were previously
101    /// omitted, causing the threshold to be systematically underestimated.
102    pub fn estimate_bytes(transcript: &[Message]) -> usize {
103        transcript
104            .iter()
105            .map(|m| {
106                m.content.len()
107                    + m.tool_calls
108                        .iter()
109                        .map(|tc| tc.name.len() + tc.arguments.to_string().len() + 32)
110                        .sum::<usize>()
111                    + m.reasoning_content.as_deref().map_or(0, |r| r.len())
112            })
113            .sum()
114    }
115
116    /// Decide whether compaction should run, preferring the token-based
117    /// threshold (accurate, CJK-safe) when actual API `prompt_tokens` are
118    /// available, falling back to the byte estimate.
119    ///
120    /// `last_prompt_tokens` of `0` means "no API reading yet" (first step,
121    /// or a provider that never reports usage) — the byte estimate is used.
122    pub fn should_compact(&self, estimate_bytes: usize, last_prompt_tokens: u32) -> bool {
123        match (self.threshold_prompt_tokens, last_prompt_tokens) {
124            (Some(token_threshold), actual) if actual > 0 => actual >= token_threshold,
125            _ => estimate_bytes >= self.threshold_chars,
126        }
127    }
128
129    /// JSON schema for structured compaction output.
130    const COMPACT_SCHEMA: &'static str = r#"{"type":"object","properties":{"summary":{"type":"string","description":"1-3 paragraph summary of the conversation so far, preserving key decisions, file paths touched, and outcomes."},"kept_facts":{"type":"array","items":{"type":"string"},"description":"Discrete facts worth remembering across compaction (e.g. 'goal=add_X_to_Y', 'compaction happened at step N', 'tool X failed 3 times')."},"next_steps":{"type":"array","items":{"type":"string"},"description":"Outstanding TODOs the agent identified before compaction (each one a single-sentence imperative)."}},"required":["summary","kept_facts"]}"#;
131
132    /// Render a structured compaction result into the message format.
133    fn render_structured(
134        summary: &str,
135        kept_facts: &[String],
136        next_steps: &[String],
137        step: usize,
138    ) -> String {
139        let mut rendered = format!(
140            "[compacted: structured at step {step}]\n\nSummary: {summary}\n\nKey facts to remember:\n"
141        );
142        for fact in kept_facts {
143            rendered.push_str(&format!("- {fact}\n"));
144        }
145        if !next_steps.is_empty() {
146            rendered.push_str("\nOutstanding TODOs:\n");
147            for step in next_steps {
148                rendered.push_str(&format!("- {step}\n"));
149            }
150        }
151        rendered
152    }
153
154    /// Try structured compaction, returning the rendered string on success.
155    /// Returns None if the provider doesn't support it or the response is invalid.
156    async fn try_structured_compact(
157        &self,
158        provider: &dyn ChatProvider,
159        older_text: &str,
160        step: usize,
161    ) -> Option<String> {
162        let structured_prompt = format!(
163            "Summarize the following conversation. \
164             Preserve: file paths modified, key technical decisions, test \
165             outcomes, and any errors not yet resolved. Drop: file contents, \
166             repeated tool errors, exploratory dead-ends.\n\n\
167             Conversation to summarize:\n{older_text}"
168        );
169
170        let schema = match serde_json::from_str(Self::COMPACT_SCHEMA) {
171            Ok(v) => v,
172            Err(e) => {
173                tracing::error!(error = %e, "COMPACT_SCHEMA is invalid JSON, falling back");
174                return None;
175            }
176        };
177        let structured_req = StructuredRequest {
178            messages: vec![Message::user(structured_prompt)],
179            schema,
180            schema_name: "compaction_result".to_string(),
181        };
182
183        let json_val = match provider.complete_structured(structured_req).await {
184            Ok(v) => v,
185            Err(e) => {
186                tracing::info!(error = %e, "structured compaction not available, falling back to free-text");
187                return None;
188            }
189        };
190
191        let obj = match json_val.as_object() {
192            Some(o) => o,
193            None => {
194                tracing::warn!(
195                    "structured compaction returned non-object, falling back to free-text"
196                );
197                return None;
198            }
199        };
200
201        let summary = match obj.get("summary").and_then(|v| v.as_str()) {
202            Some(s) => s.to_string(),
203            None => {
204                tracing::warn!(
205                    "structured compaction missing 'summary' field, falling back to free-text"
206                );
207                return None;
208            }
209        };
210
211        let kept_facts: Vec<String> = obj
212            .get("kept_facts")
213            .and_then(|v| v.as_array())
214            .map(|arr| {
215                arr.iter()
216                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
217                    .collect()
218            })
219            .unwrap_or_default();
220
221        let next_steps: Vec<String> = obj
222            .get("next_steps")
223            .and_then(|v| v.as_array())
224            .map(|arr| {
225                arr.iter()
226                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
227                    .collect()
228            })
229            .unwrap_or_default();
230
231        Some(Self::render_structured(
232            &summary,
233            &kept_facts,
234            &next_steps,
235            step,
236        ))
237    }
238
239    /// Compute the safe split point for compaction: the index at which to
240    /// divide "older messages to summarize" from "recent messages to keep".
241    ///
242    /// The retained ("kept") segment must start with a User (or System)
243    /// message, because OpenAI/Anthropic require that the first non-System
244    /// message after the compaction summary is a User message.  Two cases
245    /// can violate this invariant:
246    ///
247    /// 1. The split point lands on a `Tool` result message — backing up
248    ///    preserves tool-call / tool-result pairs.
249    /// 2. The split point lands on an `Assistant` message that carries
250    ///    `tool_calls` — backing up avoids starting the kept segment with
251    ///    an Assistant-with-tool-calls, which would produce an invalid
252    ///    `[System(summary), Assistant(tool_calls), Tool, …]` sequence.
253    pub fn safe_split_point(transcript: &[Message], keep_n: usize) -> usize {
254        let mut split = transcript.len().saturating_sub(keep_n);
255        // Back up past Tool / Assistant-with-tool_calls to preserve pairing.
256        loop {
257            if split == 0 || split >= transcript.len() {
258                break;
259            }
260            let msg = &transcript[split];
261            let should_back_up = msg.role == crate::message::Role::Tool
262                || (msg.role == crate::message::Role::Assistant && !msg.tool_calls.is_empty());
263            if should_back_up {
264                split -= 1;
265            } else {
266                break;
267            }
268        }
269        // Advance past leading System messages — never compact the system
270        // prompt.  Goal 345: without this a compaction whose `keep_recent_n`
271        // would put the system message into `older` produces a garbage summary
272        // ("No conversation found to summarize").
273        while split < transcript.len() && transcript[split].role == crate::message::Role::System {
274            split += 1;
275        }
276        split
277    }
278
279    /// Apply compaction in-place to `transcript`, returning `(removed, summary_chars)`.
280    ///
281    /// Finds the correct split point (never splits inside a tool-call pair),
282    /// calls `compact()` to get the summary message, splices the transcript,
283    /// and returns how many messages were removed and the summary char count.
284    ///
285    /// Returns `None` when the transcript is too short to compact
286    /// (`< keep_recent_n + 2` messages).
287    /// Minimum number of non-System conversational messages (User or
288    /// Assistant) that must appear in the `older` slice for a compaction
289    /// to be worth running.  Fewer than this means the older portion is
290    /// essentially a system-prompt preamble and compacting it produces a
291    /// garbage summary ("No conversation found to summarize").
292    const MIN_CONVERSATIONAL_IN_OLDER: usize = 2;
293
294    /// Pure helper: the split point for a compaction and whether the `older`
295    /// slice is acceptable to summarise. Returns `(split, false)` when the
296    /// transcript is too short or the older slice is degenerate (fewer than
297    /// `MIN_CONVERSATIONAL_IN_OLDER` conversational messages). Single source
298    /// of truth shared by [`apply_to_transcript`] and [`would_compact`] so the
299    /// "will compaction actually fire?" decision cannot drift between them.
300    /// (Goal 345)
301    fn split_for_compaction(transcript: &[Message], keep_n: usize) -> (usize, bool) {
302        if transcript.len() < keep_n + 2 {
303            return (0, false);
304        }
305        let split = Self::safe_split_point(transcript, keep_n);
306        let acceptable = if split > 0 {
307            let conversational_count = transcript[..split]
308                .iter()
309                .filter(|m| {
310                    m.role == crate::message::Role::User
311                        || m.role == crate::message::Role::Assistant
312                })
313                .count();
314            conversational_count >= Self::MIN_CONVERSATIONAL_IN_OLDER
315        } else {
316            // split == 0: older slice is empty. Preserve the pre-existing
317            // behaviour (proceed) rather than tightening it here — the g331
318            // failure mode was split > 0 with only a system-prompt older, not
319            // the empty-older edge. Tightening split == 0 is out of scope and
320            // risks breaking tests that rely on compaction firing at the
321            // minimum-length boundary.
322            true
323        };
324        (split, acceptable)
325    }
326
327    /// Returns `true` iff [`apply_to_transcript`] would actually compact this
328    /// transcript (return `Ok(Some(..))`): long enough AND the older slice has
329    /// enough real conversation. Pure — no provider call, no mutation.
330    ///
331    /// Proactive-compaction callers should consult this **before** dispatching
332    /// `PreCompact` so that `PreCompact` and `PostCompact` stay balanced (both
333    /// fire when compaction runs, neither fires when it is rejected). Without
334    /// this guard the Goal 345 degenerate-slice rejection made `PreCompact`
335    /// fire without a matching `PostCompact`. (Goal 345)
336    pub fn would_compact(&self, transcript: &[Message]) -> bool {
337        Self::split_for_compaction(transcript, self.keep_recent_n).1
338    }
339
340    pub async fn apply_to_transcript(
341        &self,
342        provider: &dyn ChatProvider,
343        transcript: &mut Vec<Message>,
344        step: usize,
345    ) -> Result<Option<(usize, usize)>> {
346        // Goal 345: delegate the "is this worth compacting?" decision to
347        // `split_for_compaction` (shared with `would_compact`) so callers can
348        // pre-check without dispatching `PreCompact` into the void.
349        let (split, acceptable) = Self::split_for_compaction(transcript, self.keep_recent_n);
350        if !acceptable {
351            return Ok(None);
352        }
353
354        let summary_msg = self.compact(provider, transcript, step).await?;
355        let summary_chars = summary_msg.content.len();
356        let removed = split;
357        transcript.drain(..split);
358        transcript.insert(0, summary_msg);
359        Ok(Some((removed, summary_chars)))
360    }
361
362    /// Render a slice of messages into a single text block for the
363    /// summarisation prompt. Each message is wrapped in XML-style tags
364    /// with tool_call summaries appended.
365    fn render_for_summarize(messages: &[Message]) -> String {
366        messages
367            .iter()
368            .map(|m| {
369                let role_tag = match m.role {
370                    crate::message::Role::System => "system",
371                    crate::message::Role::User => "user",
372                    crate::message::Role::Assistant => "assistant",
373                    crate::message::Role::Tool => "tool",
374                };
375                let mut body = m.content.replace('<', "&lt;").replace('>', "&gt;");
376                if !m.tool_calls.is_empty() {
377                    let calls_summary: Vec<String> = m
378                        .tool_calls
379                        .iter()
380                        .map(|tc| format!("{}({})", tc.name, tc.arguments))
381                        .collect();
382                    body.push_str(&format!("\n[tool_calls: {}]", calls_summary.join(", ")));
383                }
384                format!("<{role_tag}>{body}</{role_tag}>")
385            })
386            .collect::<Vec<_>>()
387            .join("\n")
388    }
389
390    /// Ask the provider to summarise the given messages.
391    /// Tries structured output first, falls back to free-text completion.
392    async fn summarize(
393        &self,
394        provider: &dyn ChatProvider,
395        messages: &[Message],
396        step: usize,
397    ) -> Result<String> {
398        let older_text = Self::render_for_summarize(messages);
399
400        // Try structured output first
401        match self
402            .try_structured_compact(provider, &older_text, step)
403            .await
404        {
405            Some(rendered) => Ok(rendered),
406            None => {
407                // Fall back to free-text path with structured 9-section template
408                let summary_prompt = format!(
409                    "{}\n\nConversation to summarize:\n{}",
410                    crate::compact::prompt::FREE_TEXT_COMPACT_PROMPT,
411                    older_text,
412                );
413                let completion = provider
414                    .complete(&[Message::user(summary_prompt)], &[] as &[ToolSpec])
415                    .await?;
416                Ok(crate::compact::prompt::format_compact_summary(
417                    &completion.content,
418                ))
419            }
420        }
421    }
422
423    /// Compact the transcript: summarize older messages into a single system
424    /// message, keeping the last `keep_recent_n` messages verbatim.
425    ///
426    /// `step` is the current turn number and is embedded in the compaction
427    /// header for debuggability.
428    ///
429    /// Returns the summary `Message` that should replace the older portion.
430    /// The caller is responsible for splicing it into the transcript.
431    ///
432    /// If the summarisation call returns a context-window-exceeded error,
433    /// the oldest message groups are dropped (via [`truncate_head_for_retry`])
434    /// and the call is retried up to [`MAX_PTL_RETRIES`] times.
435    #[tracing::instrument(skip(self, provider, transcript))]
436    pub async fn compact(
437        &self,
438        provider: &dyn ChatProvider,
439        transcript: &[Message],
440        step: usize,
441    ) -> Result<Message> {
442        let split = Self::safe_split_point(transcript, self.keep_recent_n);
443        let older = &transcript[..split];
444        let _recent = &transcript[split..];
445
446        // Retry loop: if the summarisation call itself exceeds the context
447        // window, drop oldest message groups and try again.
448        let mut to_summarize: Vec<Message> = older.to_vec();
449        let mut ptl_attempts = 0_usize;
450
451        let summary = loop {
452            match self.summarize(provider, &to_summarize, step).await {
453                Ok(text) => break text,
454                Err(e) if crate::error::is_context_window_exceeded(&e) => {
455                    ptl_attempts += 1;
456                    if ptl_attempts > MAX_PTL_RETRIES {
457                        return Err(e);
458                    }
459                    let target = retry::estimate_target_from_error(&e);
460                    match retry::truncate_head_for_retry(&to_summarize, target) {
461                        Some(truncated) => {
462                            tracing::info!(
463                                target: "recursive::compact",
464                                ptl_attempts,
465                                original_len = to_summarize.len(),
466                                truncated_len = truncated.len(),
467                                "compaction PTL retry: dropped oldest groups"
468                            );
469                            to_summarize = truncated;
470                        }
471                        None => {
472                            // Cannot truncate further — propagate the error.
473                            return Err(e);
474                        }
475                    }
476                }
477                Err(e) => return Err(e),
478            }
479        };
480
481        let summary_chars = summary.len();
482
483        let header = format!(
484            "[compacted: {} messages → {} chars at step {step}]\n{}",
485            older.len(),
486            summary_chars,
487            summary
488        );
489
490        Ok(Message::system(header).with_compaction_summary())
491    }
492}
493
494#[cfg(test)]
495mod tests {
496    use super::*;
497    use crate::llm::{Completion, MockProvider};
498
499    #[tokio::test]
500    async fn compact_returns_system_message_with_summary() {
501        let provider = MockProvider::new(vec![Completion {
502            content: "Key decisions: added adder tool. Tests pass.".to_string(),
503            tool_calls: vec![],
504            finish_reason: Some("stop".to_string()),
505            usage: None,
506            reasoning_content: None,
507        }]);
508
509        let transcript = vec![
510            Message::system("You are a coding agent.".to_string()),
511            Message::user("Add an adder tool".to_string()),
512            Message::assistant("Let me create the tool.".to_string()),
513            Message::user("Done. Now test it.".to_string()),
514            Message::assistant("Tests pass.".to_string()),
515        ];
516
517        let compactor = Compactor::new(200).keep_recent_n(2);
518        let summary_msg = compactor.compact(&provider, &transcript, 0).await.unwrap();
519
520        assert_eq!(summary_msg.role, crate::message::Role::System);
521        assert!(
522            summary_msg.is_compaction_summary,
523            "compactor must mark summary message with the bit"
524        );
525        assert!(
526            summary_msg.content.contains("[compacted:"),
527            "compactor must still include the [compacted: header for debuggability"
528        );
529        assert!(summary_msg.content.contains("Key decisions:"));
530        assert!(summary_msg.content.contains("Tests pass."));
531    }
532
533    #[tokio::test]
534    async fn compact_preserves_recent_messages() {
535        let provider = MockProvider::new(vec![Completion {
536            content: "Summary of older messages.".to_string(),
537            tool_calls: vec![],
538            finish_reason: Some("stop".to_string()),
539            usage: None,
540            reasoning_content: None,
541        }]);
542
543        let transcript = vec![
544            Message::system("sys".to_string()),
545            Message::user("old goal".to_string()),
546            Message::assistant("old reply".to_string()),
547            Message::user("recent goal".to_string()),
548            Message::assistant("recent reply".to_string()),
549        ];
550
551        // keep_recent_n=2 should keep the last 2 messages verbatim
552        let compactor = Compactor::new(100).keep_recent_n(2);
553        let summary_msg = compactor.compact(&provider, &transcript, 5).await.unwrap();
554
555        assert!(summary_msg.content.contains("[compacted: 3 messages →"));
556        // The summary should mention the older messages
557        assert!(summary_msg.content.contains("Summary of older messages."));
558    }
559
560    #[tokio::test]
561    async fn compact_handles_empty_older_portion() {
562        let provider = MockProvider::new(vec![Completion {
563            content: "nothing to summarize".to_string(),
564            tool_calls: vec![],
565            finish_reason: Some("stop".to_string()),
566            usage: None,
567            reasoning_content: None,
568        }]);
569
570        let transcript = vec![Message::user("only message".to_string())];
571
572        // keep_recent_n=5 means all messages are "recent", none to compact
573        let compactor = Compactor::new(100).keep_recent_n(5);
574        let summary_msg = compactor.compact(&provider, &transcript, 0).await.unwrap();
575
576        // Should still produce a summary (even if older portion is empty-ish)
577        assert_eq!(summary_msg.role, crate::message::Role::System);
578        assert!(
579            summary_msg.is_compaction_summary,
580            "compactor must mark summary message with the bit"
581        );
582        assert!(
583            summary_msg.content.contains("[compacted:"),
584            "compactor must still include the [compacted: header for debuggability"
585        );
586    }
587
588    #[test]
589    fn estimate_bytes_sums_content_lengths() {
590        let transcript = vec![
591            Message::user("hello".to_string()),
592            Message::assistant("world".to_string()),
593        ];
594        assert_eq!(Compactor::estimate_bytes(&transcript), 10);
595    }
596
597    #[test]
598    fn default_threshold_is_max() {
599        let c = Compactor::default();
600        assert_eq!(c.threshold_chars, usize::MAX);
601        assert_eq!(c.threshold_prompt_tokens, None);
602        assert_eq!(c.keep_recent_n, 8);
603    }
604
605    #[test]
606    fn builder_methods_work() {
607        let c = Compactor::new(500).keep_recent_n(4);
608        assert_eq!(c.threshold_chars, 500);
609        assert_eq!(c.threshold_prompt_tokens, None);
610        assert_eq!(c.keep_recent_n, 4);
611    }
612
613    #[test]
614    fn threshold_prompt_tokens_setter_works() {
615        let c = Compactor::new(500_000).threshold_prompt_tokens(144_000);
616        assert_eq!(c.threshold_chars, 500_000);
617        assert_eq!(c.threshold_prompt_tokens, Some(144_000));
618        assert_eq!(c.keep_recent_n, 8);
619    }
620
621    // ========================================================================
622    // should_compact tests (Goal 330)
623    // ========================================================================
624
625    #[test]
626    fn should_compact_uses_token_threshold_when_available() {
627        let c = Compactor::new(usize::MAX).threshold_prompt_tokens(1000);
628        // At threshold → true
629        assert!(c.should_compact(0, 1000));
630        // Below threshold → false
631        assert!(!c.should_compact(0, 999));
632        // Far below → false
633        assert!(!c.should_compact(0, 1));
634    }
635
636    #[test]
637    fn should_compact_falls_back_to_chars_when_tokens_zero() {
638        let c = Compactor::new(500).threshold_prompt_tokens(1000);
639        // last_prompt_tokens = 0 means no reading yet → use char threshold
640        // 600 chars >= 500 → true
641        assert!(c.should_compact(600, 0));
642        // 400 chars < 500 → false
643        assert!(!c.should_compact(400, 0));
644    }
645
646    #[test]
647    fn should_compact_falls_back_to_chars_when_threshold_none() {
648        let c = Compactor::new(500);
649        assert_eq!(c.threshold_prompt_tokens, None);
650        // 600 chars >= 500 → true
651        assert!(c.should_compact(600, 42));
652        // 400 chars < 500 → false
653        assert!(!c.should_compact(400, 42));
654    }
655
656    #[test]
657    fn should_compact_zero_token_threshold_fires_on_zero() {
658        // threshold_prompt_tokens = Some(0) is a degenerate case:
659        // only match when last_prompt_tokens == 0, the predicate
660        // guard `actual > 0` prevents it, so it falls through to char.
661        let c = Compactor::new(usize::MAX).threshold_prompt_tokens(0);
662        // last_prompt_tokens = 0 → guard fails, falls to char threshold
663        // usize::MAX chars → 0 >= usize::MAX is false
664        assert!(!c.should_compact(0, 0));
665        // With actual > 0, guard is active: 1 >= 0 is true
666        assert!(c.should_compact(0, 1));
667    }
668
669    /// Regression test — cross-turn compaction must use `last_prompt_tokens`
670    /// (the single most-recent LLM call's value) not the accumulated sum over all
671    /// LLM calls in the turn.  Before the fix, `runtime.rs` passed
672    /// `turn_outcome.usage.prompt_tokens` (accumulated), which caused premature
673    /// compaction on multi-step turns even when the actual context was well below
674    /// the threshold.
675    ///
676    /// Scenario: deepseek-v4-flash (1 M context), threshold = 784 000 tokens.
677    /// A turn with 8 LLM calls each at ~100 K tokens:
678    ///   accumulated = 8 × 100_000 = 800_000  →  should_compact = TRUE  (OLD WRONG behaviour)
679    ///   last_call   =     100_000            →  should_compact = FALSE (CORRECT)
680    #[test]
681    fn cross_turn_compact_uses_last_not_accumulated_prompt_tokens() {
682        // deepseek-v4-flash threshold: (1_000_000 - 20_000) * 0.80 = 784_000
683        let threshold = 784_000u32;
684        let c = Compactor::new(usize::MAX).threshold_prompt_tokens(threshold);
685
686        let per_call_tokens: u32 = 100_000;
687        let llm_calls_in_turn: u32 = 8;
688        let accumulated_tokens = per_call_tokens * llm_calls_in_turn; // 800_000
689        let last_call_tokens = per_call_tokens; // 100_000
690
691        // Confirm that the accumulated value WOULD have triggered compaction
692        // (this is what the old code passed — it was wrong).
693        assert!(
694            c.should_compact(0, accumulated_tokens),
695            "accumulated tokens ({accumulated_tokens}) exceeds threshold \
696             ({threshold}): should_compact is true — shows why old code was wrong"
697        );
698
699        // With the correct last_call value, compaction must NOT fire.
700        // runtime.rs now passes TurnOutcome::last_prompt_tokens here.
701        assert!(
702            !c.should_compact(0, last_call_tokens),
703            "last_call tokens ({last_call_tokens}) is well below threshold \
704             ({threshold}): compaction must NOT fire"
705        );
706    }
707
708    // ========================================================================
709    // Structured compaction tests
710    // ========================================================================
711
712    #[tokio::test]
713    async fn compactor_structured_happy_path() {
714        let json = serde_json::json!({
715            "summary": "Added adder tool and verified tests pass.",
716            "kept_facts": [
717                "goal=add_adder_tool",
718                "tool adder created successfully",
719                "tests pass"
720            ],
721            "next_steps": [
722                "Add subtractor tool",
723                "Run integration tests"
724            ]
725        });
726        let provider = MockProvider::new(vec![]).with_structured_responses(vec![Ok(json)]);
727
728        let transcript = vec![
729            Message::system("You are a coding agent.".to_string()),
730            Message::user("Add an adder tool".to_string()),
731            Message::assistant("Let me create the tool.".to_string()),
732            Message::user("Done. Now test it.".to_string()),
733            Message::assistant("Tests pass.".to_string()),
734        ];
735
736        let compactor = Compactor::new(200).keep_recent_n(2);
737        let summary_msg = compactor.compact(&provider, &transcript, 3).await.unwrap();
738
739        assert_eq!(summary_msg.role, crate::message::Role::System);
740        // Should contain the structured rendering format with the step number
741        assert!(summary_msg
742            .content
743            .contains("[compacted: structured at step 3]"));
744        assert!(summary_msg.content.contains("Summary: Added adder tool"));
745        assert!(summary_msg.content.contains("Key facts to remember:"));
746        assert!(summary_msg.content.contains("- goal=add_adder_tool"));
747        assert!(summary_msg
748            .content
749            .contains("- tool adder created successfully"));
750        assert!(summary_msg.content.contains("- tests pass"));
751        assert!(summary_msg.content.contains("Outstanding TODOs:"));
752        assert!(summary_msg.content.contains("- Add subtractor tool"));
753        assert!(summary_msg.content.contains("- Run integration tests"));
754    }
755
756    #[tokio::test]
757    async fn compactor_falls_back_on_structured_error() {
758        // MockProvider with no structured responses configured -> returns error
759        let provider = MockProvider::new(vec![Completion {
760            content: "Free-text fallback summary.".to_string(),
761            tool_calls: vec![],
762            finish_reason: Some("stop".to_string()),
763            usage: None,
764            reasoning_content: None,
765        }]);
766
767        let transcript = vec![
768            Message::user("goal".to_string()),
769            Message::assistant("response".to_string()),
770        ];
771
772        let compactor = Compactor::new(100).keep_recent_n(1);
773        let summary_msg = compactor.compact(&provider, &transcript, 0).await.unwrap();
774
775        assert_eq!(summary_msg.role, crate::message::Role::System);
776        // Should have fallen back to free-text format
777        assert!(
778            summary_msg.is_compaction_summary,
779            "compactor must mark summary message with the bit"
780        );
781        assert!(
782            summary_msg.content.contains("[compacted:"),
783            "compactor must still include the [compacted: header for debuggability"
784        );
785        assert!(summary_msg.content.contains("Free-text fallback summary."));
786    }
787
788    #[tokio::test]
789    async fn compactor_structured_invalid_response_falls_back() {
790        // Return valid JSON but not matching the schema (missing 'summary')
791        let json = serde_json::json!({
792            "foo": "bar"
793        });
794        let provider = MockProvider::new(vec![Completion {
795            content: "Fallback after invalid structured response.".to_string(),
796            tool_calls: vec![],
797            finish_reason: Some("stop".to_string()),
798            usage: None,
799            reasoning_content: None,
800        }])
801        .with_structured_responses(vec![Ok(json)]);
802
803        let transcript = vec![
804            Message::user("goal".to_string()),
805            Message::assistant("response".to_string()),
806        ];
807
808        let compactor = Compactor::new(100).keep_recent_n(1);
809        let summary_msg = compactor.compact(&provider, &transcript, 0).await.unwrap();
810
811        assert_eq!(summary_msg.role, crate::message::Role::System);
812        // Should have fallen back to free-text format
813        assert!(
814            summary_msg.is_compaction_summary,
815            "compactor must mark summary message with the bit"
816        );
817        assert!(
818            summary_msg.content.contains("[compacted:"),
819            "compactor must still include the [compacted: header for debuggability"
820        );
821        assert!(summary_msg
822            .content
823            .contains("Fallback after invalid structured response."));
824    }
825
826    #[tokio::test]
827    async fn compact_freetext_fallback_uses_structured_prompt_and_formats() {
828        // MockProvider with no structured responses -> falls back to free-text path.
829        // The free-text fallback now uses the 9-section template and
830        // format_compact_summary post-processor. This test verifies that:
831        //   - The <analysis>...<analysis> block is stripped.
832        //   - The <summary>...</summary> is converted to "Summary:" header.
833        //   - The [compacted: header is still present from compact().
834        let provider = MockProvider::new(vec![Completion {
835            content: "<analysis>scratchpad thinking</analysis>\n\
836                       <summary>Added feature X. Tests pass.</summary>"
837                .to_string(),
838            tool_calls: vec![],
839            finish_reason: Some("stop".to_string()),
840            usage: None,
841            reasoning_content: None,
842        }]);
843
844        let transcript = vec![
845            Message::user("Add feature X".to_string()),
846            Message::assistant("Working on it.".to_string()),
847            Message::user("Test it.".to_string()),
848            Message::assistant("Tests pass.".to_string()),
849        ];
850
851        let compactor = Compactor::new(100).keep_recent_n(2);
852        let summary_msg = compactor.compact(&provider, &transcript, 0).await.unwrap();
853
854        assert_eq!(summary_msg.role, crate::message::Role::System);
855        // Must carry the [compacted: header from compact()
856        assert!(
857            summary_msg.content.contains("[compacted:"),
858            "compactor must include the [compacted: header, got: {}",
859            summary_msg.content
860        );
861        // The analysis block must have been stripped
862        assert!(
863            !summary_msg.content.contains("scratchpad thinking"),
864            "analysis scratchpad must be stripped"
865        );
866        // The summary tag must be converted to a "Summary:" header
867        assert!(
868            summary_msg.content.contains("Summary:"),
869            "summary must contain 'Summary:' header, got: {}",
870            summary_msg.content
871        );
872        // The actual summary content must be present
873        assert!(
874            summary_msg.content.contains("Added feature X"),
875            "summary content must be preserved, got: {}",
876            summary_msg.content
877        );
878        assert!(
879            summary_msg.content.contains("Tests pass."),
880            "summary content must be preserved, got: {}",
881            summary_msg.content
882        );
883    }
884
885    #[test]
886    fn safe_split_point_keep_n_zero_does_not_panic() {
887        // keep_n=0 means split = transcript.len(); the while loop must not
888        // index transcript[transcript.len()] — that would be out of bounds.
889        let msgs = vec![
890            Message::user("a".to_string()),
891            Message::assistant("b".to_string()),
892        ];
893        let split = Compactor::safe_split_point(&msgs, 0);
894        assert_eq!(split, msgs.len(), "keep_n=0 should return full length");
895    }
896
897    #[test]
898    fn safe_split_point_empty_transcript_does_not_panic() {
899        let split = Compactor::safe_split_point(&[], 0);
900        assert_eq!(split, 0);
901        let split = Compactor::safe_split_point(&[], 5);
902        assert_eq!(split, 0);
903    }
904
905    #[test]
906    fn safe_split_backs_up_past_tool_and_assistant_with_tool_calls() {
907        // Sequence: [user, asst+tc, tool_result, user, asst]  (indices 0..4)
908        // keep_n=3: initial split = 5-3 = 2 (Tool message)
909        //   → backs up to 1 (Assistant+tool_calls)
910        //   → backs up to 0 (loop breaks: split==0)
911        use crate::llm::ToolCall;
912        let asst_with_calls = Message::assistant_with_tool_calls(
913            "thinking".to_string(),
914            vec![ToolCall {
915                id: "call_1".into(),
916                name: "Read".into(),
917                arguments: serde_json::json!("{}"),
918            }],
919        );
920        let msgs = vec![
921            Message::user("start".to_string()),
922            asst_with_calls,
923            Message::tool_result("call_1", "file contents"),
924            Message::user("continue".to_string()),
925            Message::assistant("done".to_string()),
926        ];
927        let split = Compactor::safe_split_point(&msgs, 3);
928        assert_eq!(
929            split, 0,
930            "should back up past both Tool and Assistant-with-tool-calls to index 0"
931        );
932    }
933
934    #[test]
935    fn safe_split_backs_up_when_landing_directly_on_assistant_with_tool_calls() {
936        // Sequence: [user, asst+tc, user, asst]  (indices 0..3)
937        // keep_n=3: initial split = 4-3 = 1 (Assistant+tool_calls)
938        //   → backs up to 0 (loop breaks: split==0)
939        use crate::llm::ToolCall;
940        let asst_with_calls = Message::assistant_with_tool_calls(
941            "planning".to_string(),
942            vec![ToolCall {
943                id: "c1".into(),
944                name: "Glob".into(),
945                arguments: serde_json::json!("{}"),
946            }],
947        );
948        let msgs = vec![
949            Message::user("first question".to_string()),
950            asst_with_calls,
951            Message::user("follow-up".to_string()),
952            Message::assistant("answer".to_string()),
953        ];
954        let split = Compactor::safe_split_point(&msgs, 3);
955        assert_eq!(
956            split, 0,
957            "split landing directly on Assistant+tool_calls should retreat to 0"
958        );
959    }
960
961    #[test]
962    fn safe_split_no_backup_when_split_is_already_valid() {
963        // Sequence: [user, asst+tc, tool_result, user, asst]  (indices 0..4)
964        // keep_n=2: initial split = 5-2 = 3 (User message) → no backup needed
965        use crate::llm::ToolCall;
966        let asst_with_calls = Message::assistant_with_tool_calls(
967            "".to_string(),
968            vec![ToolCall {
969                id: "x".into(),
970                name: "Write".into(),
971                arguments: serde_json::json!("{}"),
972            }],
973        );
974        let msgs = vec![
975            Message::user("start".to_string()),
976            asst_with_calls,
977            Message::tool_result("x", "ok"),
978            Message::user("continue".to_string()),
979            Message::assistant("done".to_string()),
980        ];
981        let split = Compactor::safe_split_point(&msgs, 2);
982        assert_eq!(split, 3, "split at a User message should not retreat");
983    }
984
985    // ========================================================================
986    // estimate_bytes — tool_calls and reasoning_content coverage
987    // ========================================================================
988
989    #[test]
990    fn estimate_bytes_includes_tool_calls() {
991        use crate::llm::ToolCall;
992        // An assistant message with one tool call.
993        // name = "Read" (4 chars), arguments = "\"path\"" (6 chars), overhead = 32
994        let tool_call = ToolCall {
995            id: "c1".into(),
996            name: "Read".into(),
997            arguments: serde_json::json!("path"),
998        };
999        let msg = Message::assistant_with_tool_calls("think".to_string(), vec![tool_call]);
1000        // content=5, name=4, args=serde representation len, overhead=32
1001        let args_len = msg.tool_calls[0].arguments.to_string().len();
1002        let expected = 5 + (4 + args_len + 32);
1003        assert_eq!(
1004            Compactor::estimate_bytes(&[msg]),
1005            expected,
1006            "tool_calls contribution must be counted"
1007        );
1008    }
1009
1010    #[test]
1011    fn estimate_bytes_includes_reasoning_content() {
1012        let mut msg = Message::assistant("hello".to_string());
1013        msg.reasoning_content = Some("some reasoning".to_string());
1014        // content=5, reasoning=14
1015        assert_eq!(
1016            Compactor::estimate_bytes(&[msg]),
1017            5 + 14,
1018            "reasoning_content must be counted"
1019        );
1020    }
1021
1022    #[test]
1023    fn estimate_bytes_tool_calls_plus_reasoning_combined() {
1024        use crate::llm::ToolCall;
1025        let tool_call = ToolCall {
1026            id: "c2".into(),
1027            name: "Write".into(),
1028            arguments: serde_json::json!({"path": "x"}),
1029        };
1030        let mut msg = Message::assistant_with_tool_calls("".to_string(), vec![tool_call]);
1031        msg.reasoning_content = Some("reason".to_string());
1032        let args_len = msg.tool_calls[0].arguments.to_string().len();
1033        // content=0, tool_call: name=5, args=..., +32; reasoning=6
1034        let expected = (5 + args_len + 32) + 6;
1035        assert_eq!(Compactor::estimate_bytes(&[msg]), expected);
1036    }
1037
1038    // ========================================================================
1039    // apply_to_transcript — splice and return-value coverage
1040    // ========================================================================
1041
1042    #[tokio::test]
1043    async fn apply_to_transcript_splices_and_returns_counts() {
1044        let provider = MockProvider::new(vec![Completion {
1045            content: "summary of first three".to_string(),
1046            tool_calls: vec![],
1047            finish_reason: Some("stop".to_string()),
1048            usage: None,
1049            reasoning_content: None,
1050        }]);
1051
1052        let mut transcript = vec![
1053            Message::system("sys".to_string()),
1054            Message::user("msg1".to_string()),
1055            Message::assistant("rep1".to_string()),
1056            Message::user("msg2".to_string()),
1057            Message::assistant("rep2".to_string()),
1058            Message::user("msg3".to_string()),
1059        ];
1060
1061        // keep_recent_n=2 → split=4, compact first 4 msgs → removed=4
1062        let compactor = Compactor::new(50).keep_recent_n(2);
1063        let result = compactor
1064            .apply_to_transcript(&provider, &mut transcript, 5)
1065            .await
1066            .unwrap();
1067
1068        // Should have returned Some((removed, summary_chars))
1069        let (removed, summary_chars) =
1070            result.expect("should compact when transcript is long enough");
1071        assert!(removed > 0, "removed must be > 0 when compaction ran");
1072        assert!(summary_chars > 0, "summary_chars must be > 0");
1073
1074        // Transcript must start with the compaction summary
1075        assert_eq!(transcript[0].role, crate::message::Role::System);
1076        assert!(transcript[0].is_compaction_summary);
1077        // The 2 recent messages are preserved after the summary
1078        assert!(transcript.len() >= 3, "summary + at least 2 recent");
1079    }
1080
1081    #[tokio::test]
1082    async fn apply_to_transcript_too_short_returns_none() {
1083        // A provider that should never be called
1084        let provider = MockProvider::new(vec![]);
1085
1086        let mut transcript = vec![
1087            Message::user("only one".to_string()),
1088            Message::assistant("reply".to_string()),
1089        ];
1090
1091        // keep_recent_n=8 + 2 = 10 required, but transcript has only 2
1092        let compactor = Compactor::new(50).keep_recent_n(8);
1093        let result = compactor
1094            .apply_to_transcript(&provider, &mut transcript, 0)
1095            .await
1096            .unwrap();
1097
1098        assert!(result.is_none(), "transcript too short must return None");
1099        // Transcript must not be modified
1100        assert_eq!(transcript.len(), 2);
1101    }
1102
1103    #[tokio::test]
1104    async fn compact_includes_tool_calls_in_older_text() {
1105        // Verify that assistant messages carrying tool_calls contribute their
1106        // call summaries to the text sent to the LLM for summarization.
1107        // If the `!m.tool_calls.is_empty()` guard is inverted, the calls
1108        // are omitted (or spurious empty `[tool_calls: ]` is appended to
1109        // messages that have no calls), which breaks the compactor's ability
1110        // to preserve tool-invocation context across compaction boundaries.
1111        use crate::llm::{Completion, MockProvider, ToolCall};
1112        let provider = MockProvider::new(vec![Completion {
1113            content: "summary".to_string(),
1114            tool_calls: vec![],
1115            finish_reason: Some("stop".to_string()),
1116            usage: None,
1117            reasoning_content: None,
1118        }]);
1119
1120        let tool_call = ToolCall {
1121            id: "c1".into(),
1122            name: "Read".into(),
1123            arguments: serde_json::json!({"path": "src/lib.rs"}),
1124        };
1125        let asst_with_calls =
1126            Message::assistant_with_tool_calls("thinking".to_string(), vec![tool_call]);
1127        // Build transcript long enough that the tool-call pair ends up in
1128        // the "older" portion: [user, asst+tc, tool_result, user] are older,
1129        // [asst, user] are "recent" (keep_recent_n=2).
1130        // safe_split_point with keep_n=2 on 6 msgs: initial split=4
1131        // (transcript[4] = "asst" — no tool_calls, no Tool role) → no retreat.
1132        let transcript = vec![
1133            Message::user("start".to_string()),          // [0] older
1134            asst_with_calls,                             // [1] older  ← has tool_calls
1135            Message::tool_result("c1", "file contents"), // [2] older
1136            Message::user("continue".to_string()),       // [3] older
1137            Message::assistant("done".to_string()),      // [4] recent
1138            Message::user("next".to_string()),           // [5] recent
1139        ];
1140
1141        let compactor = Compactor::new(0).keep_recent_n(2);
1142        let _ = compactor.compact(&provider, &transcript, 0).await.unwrap();
1143
1144        let calls = provider.calls();
1145        assert!(!calls.is_empty(), "provider must have been called");
1146        // The first (and only) call is the summarization request.
1147        let prompt_msg = &calls[0][0];
1148        assert!(
1149            prompt_msg.content.contains("Read"),
1150            "older_text must include the tool call name 'Read'; got: {}",
1151            prompt_msg.content
1152        );
1153        assert!(
1154            prompt_msg.content.contains("[tool_calls:"),
1155            "older_text must include the [tool_calls: ...] marker; got: {}",
1156            prompt_msg.content
1157        );
1158    }
1159
1160    #[tokio::test]
1161    async fn apply_to_transcript_minimum_length_boundary() {
1162        // Goal 345: older must have ≥2 conversational messages.
1163        // At keep_n=1 with 4 messages, older = [sys, user, asst] → 2 conv.
1164        let provider = MockProvider::new(vec![Completion {
1165            content: "boundary summary".to_string(),
1166            tool_calls: vec![],
1167            finish_reason: Some("stop".to_string()),
1168            usage: None,
1169            reasoning_content: None,
1170        }]);
1171
1172        let keep_n = 1_usize;
1173        let mut transcript = vec![
1174            Message::system("s".to_string()),
1175            Message::user("u".to_string()),
1176            Message::assistant("a".to_string()),
1177            Message::user("u2".to_string()),
1178        ];
1179
1180        let compactor = Compactor::new(0).keep_recent_n(keep_n);
1181        let result = compactor
1182            .apply_to_transcript(&provider, &mut transcript, 1)
1183            .await
1184            .unwrap();
1185
1186        assert!(
1187            result.is_some(),
1188            "should compact when older has ≥2 conversational msgs"
1189        );
1190    }
1191
1192    // ========================================================================
1193    // render_structured — direct unit tests
1194    // ========================================================================
1195
1196    #[test]
1197    fn render_structured_with_next_steps_includes_todos_section() {
1198        let rendered = Compactor::render_structured(
1199            "summary text",
1200            &["fact A".into(), "fact B".into()],
1201            &["step 1".into()],
1202            5,
1203        );
1204        assert!(rendered.contains("[compacted: structured at step 5]"));
1205        assert!(rendered.contains("Summary: summary text"));
1206        assert!(rendered.contains("- fact A"));
1207        assert!(rendered.contains("- fact B"));
1208        assert!(
1209            rendered.contains("Outstanding TODOs:"),
1210            "non-empty next_steps must show TODOs section"
1211        );
1212        assert!(rendered.contains("- step 1"));
1213    }
1214
1215    #[test]
1216    fn render_structured_without_next_steps_omits_todos_section() {
1217        // Kills `delete !` at line 85: if next_steps.is_empty() (mutated from !next_steps.is_empty())
1218        // then the TODO block would appear even for empty next_steps.
1219        let rendered = Compactor::render_structured(
1220            "summary",
1221            &["fact".into()],
1222            &[], // empty next_steps
1223            1,
1224        );
1225        assert!(
1226            !rendered.contains("Outstanding TODOs:"),
1227            "empty next_steps must NOT include TODOs section (kills delete-! mutation)"
1228        );
1229    }
1230
1231    #[tokio::test]
1232    async fn apply_to_transcript_boundary_plus_not_times() {
1233        // Kills: `replace + with * in Compactor::apply_to_transcript`.
1234        // The threshold is `keep_recent_n + 2`, NOT `keep_recent_n * 2`.
1235        // With keep_recent_n=2, older=[sys, user, asst] → 2 conv.
1236        // Original `+`: 5 >= 3 → compaction fires
1237        // Mutant `*`:   5 >= 4 → early return misuse
1238        let provider = MockProvider::new(vec![Completion {
1239            content: "summary".to_string(),
1240            tool_calls: vec![],
1241            finish_reason: Some("stop".to_string()),
1242            usage: None,
1243            reasoning_content: None,
1244        }]);
1245
1246        let keep_n = 2_usize;
1247        // 5 messages: sys + 2 conv older + 2 kept
1248        let mut transcript = vec![
1249            Message::system("s0".to_string()),
1250            Message::user("u1".to_string()),
1251            Message::assistant("a1".to_string()),
1252            Message::user("u2".to_string()),
1253            Message::assistant("a2".to_string()),
1254        ];
1255
1256        let compactor = Compactor::new(0).keep_recent_n(keep_n);
1257        let result = compactor
1258            .apply_to_transcript(&provider, &mut transcript, 1)
1259            .await
1260            .unwrap();
1261
1262        assert!(
1263            result.is_some(),
1264            "must compact when older has ≥2 conversational msgs"
1265        );
1266    }
1267
1268    // ========================================================================
1269    // Goal 345: degenerate slice rejection and system-message preservation
1270    // ========================================================================
1271
1272    #[test]
1273    fn safe_split_point_keeps_system_message_in_kept() {
1274        // Scenario: a transcript where keep_recent_n would put the
1275        // system prompt into the older portion.  After the fix, the
1276        // split must advance past the system message so it stays
1277        // verbatim in `kept` — never summarised.
1278        let msgs = vec![
1279            Message::system("You are a coding agent. Be thorough.".to_string()),
1280            Message::user("Do task A".to_string()),
1281            Message::assistant("Working on A".to_string()),
1282            Message::user("Now do task B".to_string()),
1283            Message::assistant("Working on B".to_string()),
1284            Message::user("Now do task C".to_string()),
1285            Message::assistant("Working on C".to_string()),
1286        ];
1287        // keep_n=4: initial split = 7-4 = 3 (msgs[3] = "Now do task B")
1288        // That's a User message — no backup, no forward advance.
1289        let split = Compactor::safe_split_point(&msgs, 4);
1290        assert_eq!(split, 3, "valid split must not be disturbed");
1291    }
1292
1293    #[test]
1294    fn safe_split_point_advances_past_system_at_boundary() {
1295        // Scenario: keep_recent_n is large enough that the initial
1296        // split lands on a System message.  The forward pass must
1297        // advance past it so the system prompt stays in `kept`.
1298        let msgs = vec![
1299            Message::system("SYSTEM PROMPT — keep me verbatim".to_string()),
1300            Message::user("Hello".to_string()),
1301            Message::assistant("Hi!".to_string()),
1302        ];
1303        // keep_n=3: initial split = 3-3 = 0
1304        // split=0: backup loop breaks immediately.
1305        // Forward pass: msgs[0] is System → advance to 1.
1306        let split = Compactor::safe_split_point(&msgs, 3);
1307        assert_eq!(
1308            split, 1,
1309            "system message at split boundary must be advanced past"
1310        );
1311    }
1312
1313    #[tokio::test]
1314    async fn apply_to_transcript_rejects_system_only_older() {
1315        // The g331 failure mode: a long system prompt followed by a
1316        // few real messages, keep_recent_n covers almost everything,
1317        // older = [system] → summary says "No conversation found to
1318        // summarize".
1319        let provider = MockProvider::new(vec![]); // should never be called
1320
1321        let mut transcript = vec![
1322            Message::system(
1323                "VERY LONG SYSTEM PROMPT — AGENTS.md, CLAUDE.md, journal, skills...".to_string(),
1324            ),
1325            Message::user("Add a feature".to_string()),
1326            Message::assistant("I'll work on it.".to_string()),
1327            Message::user("Status?".to_string()),
1328            Message::assistant("Almost done.".to_string()),
1329            Message::user("Now test it.".to_string()),
1330            Message::assistant("Tests pass.".to_string()),
1331            Message::user("Commit.".to_string()),
1332            Message::assistant("Done.".to_string()),
1333        ];
1334        // 9 messages total, keep_recent_n=8
1335        // safe_split_point: initial split = 9-8 = 1 (msgs[1]="Add a feature")
1336        // That's User → no backup. Forward: not System → no advance.
1337        // older = transcript[..1] = [system]
1338        // conversational_count = 0 < MIN(2) → return None
1339        let compactor = Compactor::new(0).keep_recent_n(8);
1340        let result = compactor
1341            .apply_to_transcript(&provider, &mut transcript, 0)
1342            .await
1343            .unwrap();
1344
1345        assert!(
1346            result.is_none(),
1347            "system-only older (0 conversational msgs < 2 min) must be rejected"
1348        );
1349        // Transcript must not be modified
1350        assert_eq!(transcript.len(), 9, "transcript must be unchanged");
1351    }
1352
1353    #[tokio::test]
1354    async fn apply_to_transcript_compacts_normally_with_real_conversation() {
1355        // When older has ≥2 conversational messages, compaction runs
1356        // normally.
1357        let provider = MockProvider::new(vec![Completion {
1358            content: "We added a feature and tested it.".to_string(),
1359            tool_calls: vec![],
1360            finish_reason: Some("stop".to_string()),
1361            usage: None,
1362            reasoning_content: None,
1363        }]);
1364
1365        let mut transcript = vec![
1366            Message::system("System prompt".to_string()),
1367            Message::user("Add feature X".to_string()),
1368            Message::assistant("Working on X".to_string()),
1369            Message::user("Add feature Y".to_string()),
1370            Message::assistant("Working on Y".to_string()),
1371            Message::user("Test everything".to_string()),
1372            Message::assistant("All tests pass".to_string()),
1373        ];
1374        // keep_recent_n=2: older = transcript[..5] = [sys, user, asst, user, asst]
1375        // conversational_count = 4 (≥2) → compaction proceeds
1376        let compactor = Compactor::new(0).keep_recent_n(2);
1377        let result = compactor
1378            .apply_to_transcript(&provider, &mut transcript, 1)
1379            .await
1380            .unwrap();
1381
1382        let (removed, summary_chars) =
1383            result.expect("should compact when older has real conversation");
1384        assert!(removed > 0);
1385        assert!(summary_chars > 0);
1386        assert_eq!(transcript[0].role, crate::message::Role::System);
1387        assert!(transcript[0].is_compaction_summary);
1388    }
1389
1390    #[tokio::test]
1391    async fn apply_to_transcript_rejects_below_min_conversational() {
1392        // 1 conversational message in older (< MIN=2) → reject.
1393        let provider = MockProvider::new(vec![]); // should never be called
1394
1395        let mut transcript = vec![
1396            Message::system("System prompt".to_string()),
1397            Message::user("The only user message".to_string()),
1398            Message::assistant("Reply".to_string()),
1399            Message::user("Another user message".to_string()),
1400            Message::assistant("Another reply".to_string()),
1401            Message::user("Yet another".to_string()),
1402            Message::assistant("Final reply".to_string()),
1403        ];
1404        // 7 messages, keep_recent_n=5
1405        // safe_split_point: initial split = 7-5 = 2 (msgs[2]="Reply")
1406        // That's Asst w/o tool_calls → no backup. Forward: not System.
1407        // older = transcript[..2] = [system, user]
1408        // conversational_count = 1 < 2 → reject
1409        let compactor = Compactor::new(0).keep_recent_n(5);
1410        let result = compactor
1411            .apply_to_transcript(&provider, &mut transcript, 0)
1412            .await
1413            .unwrap();
1414
1415        assert!(
1416            result.is_none(),
1417            "older with 1 conversational msg (< 2 min) must be rejected"
1418        );
1419    }
1420
1421    #[tokio::test]
1422    async fn apply_to_transcript_accepts_exactly_min_conversational() {
1423        // Exactly 2 conversational messages in older (= MIN=2) → accept.
1424        let provider = MockProvider::new(vec![Completion {
1425            content: "Conversation about two messages.".to_string(),
1426            tool_calls: vec![],
1427            finish_reason: Some("stop".to_string()),
1428            usage: None,
1429            reasoning_content: None,
1430        }]);
1431
1432        let mut transcript = vec![
1433            Message::system("System prompt".to_string()),
1434            Message::user("Hello".to_string()),
1435            Message::assistant("Hi there!".to_string()),
1436            Message::user("How are you?".to_string()),
1437            Message::assistant("I'm well, thanks.".to_string()),
1438        ];
1439        // 5 messages, keep_recent_n=2
1440        // safe_split_point: initial split = 5-2 = 3 (msgs[3]="How are you?")
1441        // That's User → no backup. Forward: not System.
1442        // older = transcript[..3] = [sys, user("Hello"), asst("Hi there!")]
1443        // conversational_count = 2 = MIN → accepted
1444        let compactor = Compactor::new(0).keep_recent_n(2);
1445        let result = compactor
1446            .apply_to_transcript(&provider, &mut transcript, 1)
1447            .await
1448            .unwrap();
1449
1450        let (removed, _summary_chars) =
1451            result.expect("should compact when older has exactly 2 conversational msgs");
1452        assert!(removed > 0);
1453    }
1454
1455    // ========================================================================
1456    // Goal 337: PTL retry loop tests
1457    // ========================================================================
1458
1459    #[tokio::test]
1460    async fn compact_retries_on_ptl_then_succeeds() {
1461        // First summarization call returns PTL error, second succeeds.
1462        let provider = MockProvider::new(vec![Completion {
1463            content: "Second attempt summary.".to_string(),
1464            tool_calls: vec![],
1465            finish_reason: Some("stop".to_string()),
1466            usage: None,
1467            reasoning_content: None,
1468        }])
1469        .with_errors(vec![crate::error::Error::Llm {
1470            provider: "mock".into(),
1471            message: "HTTP 400: context_length_exceeded, prompt is too long".into(),
1472        }]);
1473
1474        let transcript = vec![
1475            Message::system("sys".to_string()),
1476            Message::user("msg1".to_string()),
1477            Message::assistant("rep1".to_string()),
1478            Message::user("msg2".to_string()),
1479            Message::assistant("rep2".to_string()),
1480            Message::user("msg3".to_string()),
1481            Message::assistant("rep3".to_string()),
1482            Message::user("msg4".to_string()),
1483            Message::assistant("rep4".to_string()),
1484            Message::user("msg5".to_string()),
1485            Message::assistant("rep5".to_string()),
1486        ];
1487
1488        let compactor = Compactor::new(0).keep_recent_n(2);
1489        let summary_msg = compactor.compact(&provider, &transcript, 0).await.unwrap();
1490
1491        assert_eq!(summary_msg.role, crate::message::Role::System);
1492        assert!(
1493            summary_msg.content.contains("Second attempt summary."),
1494            "should contain the retry's summary, got: {}",
1495            summary_msg.content
1496        );
1497
1498        // The provider should have been called twice (first error → retry).
1499        let calls = provider.calls();
1500        assert_eq!(
1501            calls.len(),
1502            2,
1503            "provider must be called twice (first error, then retry)"
1504        );
1505    }
1506
1507    #[tokio::test]
1508    async fn compact_gives_up_after_max_retries() {
1509        // Provider returns PTL error every time.
1510        let mut errors = Vec::new();
1511        for _ in 0..=MAX_PTL_RETRIES {
1512            errors.push(crate::error::Error::Llm {
1513                provider: "mock".into(),
1514                message: "HTTP 400: context_length_exceeded, prompt is too long".into(),
1515            });
1516        }
1517        // Add one more completion so total calls = MAX_PTL_RETRIES + 1.
1518        // The loop tries once + 3 retries = 4 total calls, all errors.
1519        let provider = MockProvider::new(vec![]).with_errors(errors);
1520
1521        let transcript = vec![
1522            Message::system("sys".to_string()),
1523            Message::user("long msg1".to_string()),
1524            Message::assistant("long rep1".to_string()),
1525            Message::user("long msg2".to_string()),
1526            Message::assistant("long rep2".to_string()),
1527        ];
1528
1529        let compactor = Compactor::new(0).keep_recent_n(1);
1530        let result = compactor.compact(&provider, &transcript, 0).await;
1531        assert!(
1532            result.is_err(),
1533            "should return error after exhausting retries"
1534        );
1535        let err = result.unwrap_err();
1536        assert!(
1537            err.to_string().contains("context_length_exceeded"),
1538            "error should contain the PTL signal"
1539        );
1540    }
1541
1542    #[tokio::test]
1543    async fn compact_non_ptl_error_propagates_immediately() {
1544        // Non-PTL error — should NOT retry.
1545        let provider = MockProvider::new(vec![]).with_errors(vec![crate::error::Error::Llm {
1546            provider: "mock".into(),
1547            message: "HTTP 400: invalid request".into(),
1548        }]);
1549
1550        let transcript = vec![
1551            Message::system("sys".to_string()),
1552            Message::user("msg1".to_string()),
1553            Message::assistant("rep1".to_string()),
1554        ];
1555
1556        let compactor = Compactor::new(0).keep_recent_n(1);
1557        let result = compactor.compact(&provider, &transcript, 0).await;
1558        assert!(
1559            result.is_err(),
1560            "non-PTL error should propagate immediately"
1561        );
1562
1563        // Provider should have been called only once (no retry).
1564        let calls = provider.calls();
1565        assert_eq!(calls.len(), 1, "non-PTL error must not trigger retry");
1566    }
1567
1568    // ========================================================================
1569    // Goal 341: boundary-preserving compaction validation
1570    //
1571    // Validates that compaction never modifies the recent tail messages —
1572    // they pass through byte-identical. Proves that splice reordering alone
1573    // cannot preserve the prefix cache (the summary is new content preceding
1574    // the recent region either way). The real cache-preserving lever is
1575    // microcompact (goals 332/333).
1576    // ========================================================================
1577
1578    /// Validation helper: verify the recent-region messages are byte-identical
1579    /// after applying the compaction splice.
1580    ///
1581    /// Computes the split point, serialises the expected recent messages (the
1582    /// transcript slice from `split..`), applies `drain(..split)` +
1583    /// `insert(0, summary)` (the same splice `apply_to_transcript` uses),
1584    /// then asserts the messages at indices `1..` are byte-identical (via
1585    /// `serde_json` serialisation) to the pre-compaction recent region.
1586    ///
1587    /// This proves compaction never touches the recent tail — the only thing
1588    /// that changes is what precedes the recent region (the summary vs. the
1589    /// original older messages).
1590    async fn validate_boundary_preserving(
1591        transcript: &[Message],
1592        keep_n: usize,
1593        summary_content: &str,
1594    ) {
1595        let split = Compactor::safe_split_point(transcript, keep_n);
1596
1597        // Serialize expected recent messages before any mutation.
1598        let expected_recent_json: Vec<String> = transcript[split..]
1599            .iter()
1600            .map(|m| serde_json::to_string(m).unwrap())
1601            .collect();
1602
1603        // Apply the same splice that `apply_to_transcript` uses:
1604        // drain older, insert summary at head.
1605        let mut compacted = transcript.to_vec();
1606        let summary_msg = Message::system(format!(
1607            "[compacted: test summary at step 1]\n{}",
1608            summary_content
1609        ))
1610        .with_compaction_summary();
1611        compacted.drain(..split);
1612        compacted.insert(0, summary_msg);
1613
1614        // After splice, recent messages are at compacted[1..] (after the summary).
1615        let recent_after_json: Vec<String> = compacted[1..]
1616            .iter()
1617            .map(|m| serde_json::to_string(m).unwrap())
1618            .collect();
1619
1620        assert_eq!(
1621            recent_after_json, expected_recent_json,
1622            "recent region must be byte-identical after compaction splice"
1623        );
1624    }
1625
1626    #[tokio::test]
1627    async fn boundary_preserving_recent_region_is_byte_identical() {
1628        // Build a transcript with a clear split: 4 older messages + 2 recent.
1629        let transcript = vec![
1630            Message::system("System prompt.".to_string()),
1631            Message::user("Old goal: add feature X".to_string()),
1632            Message::assistant("Working on feature X.".to_string()),
1633            Message::user("Old goal: test it.".to_string()),
1634            // ── split point (keep_n=2) ──
1635            Message::assistant("Recent: I fixed a test.".to_string()),
1636            Message::user("Recent: Let's commit.".to_string()),
1637        ];
1638
1639        validate_boundary_preserving(
1640            &transcript,
1641            2, // keep_recent_n
1642            "Summary of the old goals: added feature X, tests pass.",
1643        )
1644        .await;
1645    }
1646
1647    #[tokio::test]
1648    async fn boundary_preserving_with_tool_calls_in_older() {
1649        // Scenario: older portion includes tool-call pairs. The split point
1650        // backs up past Tool/Assistant+tool_calls, so the recent region
1651        // boundary may shift. Verify the recent region is still byte-identical.
1652        use crate::llm::ToolCall;
1653
1654        let tool_call = ToolCall {
1655            id: "c1".into(),
1656            name: "Read".into(),
1657            arguments: serde_json::json!({"path": "src/lib.rs"}),
1658        };
1659        let transcript = vec![
1660            Message::system("sys".to_string()),
1661            Message::user("Read the file".to_string()),
1662            Message::assistant_with_tool_calls("reading".to_string(), vec![tool_call]),
1663            Message::tool_result("c1", "file contents here"),
1664            // ── split point (keep_n=2, after backup) ──
1665            Message::user("Recent: now edit".to_string()),
1666            Message::assistant("Recent: done".to_string()),
1667        ];
1668
1669        validate_boundary_preserving(
1670            &transcript,
1671            2, // keep_recent_n
1672            "Summary of read-file steps.",
1673        )
1674        .await;
1675    }
1676
1677    #[tokio::test]
1678    async fn mock_provider_accepts_post_compact_transcript() {
1679        // Feed a post-compaction transcript through MockProvider and verify
1680        // the first non-system message is Role::User (provider ordering
1681        // invariant). This confirms the current splice yields a valid
1682        // sequence — no mid-transcript System-message rejection risk.
1683        //
1684        // Two completions are needed: the first is consumed by
1685        // `apply_to_transcript`'s internal summarization call; the second
1686        // by the explicit `complete()` below.
1687        let provider = MockProvider::new(vec![
1688            Completion {
1689                content: "Summary: added feature, tests pass.".to_string(),
1690                tool_calls: vec![],
1691                finish_reason: Some("stop".to_string()),
1692                usage: None,
1693                reasoning_content: None,
1694            },
1695            Completion {
1696                content: "This is a response to the post-compact transcript.".to_string(),
1697                tool_calls: vec![],
1698                finish_reason: Some("stop".to_string()),
1699                usage: None,
1700                reasoning_content: None,
1701            },
1702        ]);
1703
1704        let mut transcript = vec![
1705            Message::system("System prompt.".to_string()),
1706            Message::user("Goal: add feature".to_string()),
1707            Message::assistant("Working on it.".to_string()),
1708            Message::user("Test it.".to_string()),
1709            Message::assistant("Tests pass.".to_string()),
1710            Message::user("Recent: commit.".to_string()),
1711            Message::assistant("Recent: done.".to_string()),
1712        ];
1713
1714        // Apply compaction: this produces transcript = [summary, recent...]
1715        let compactor = Compactor::new(0).keep_recent_n(2);
1716        let result = compactor
1717            .apply_to_transcript(&provider, &mut transcript, 5)
1718            .await
1719            .unwrap();
1720        assert!(
1721            result.is_some(),
1722            "compaction must succeed for the test transcript"
1723        );
1724
1725        // The post-compact transcript should have: [System(summary), User("Recent: commit."), Assistant("Recent: done.")]
1726        // Feed it through the mock provider and verify:
1727        // 1. The provider accepts it (no error).
1728        // 2. The first non-system message is Role::User.
1729        let completion = provider.complete(&transcript, &[]).await;
1730        assert!(
1731            completion.is_ok(),
1732            "mock provider must accept post-compaction transcript"
1733        );
1734
1735        // Check the messages that were sent to the provider:
1736        let calls = provider.calls();
1737        assert!(
1738            !calls.is_empty(),
1739            "provider should have been called via complete()"
1740        );
1741        let sent_messages = &calls[calls.len() - 1]; // Last call = our complete() above.
1742
1743        // Find first non-system message and verify it's User.
1744        let first_non_system = sent_messages
1745            .iter()
1746            .find(|m| m.role != crate::message::Role::System);
1747        assert!(
1748            first_non_system.is_some(),
1749            "post-compact transcript must contain at least one non-System message"
1750        );
1751        assert_eq!(
1752            first_non_system.unwrap().role,
1753            crate::message::Role::User,
1754            "first non-system message must be User (provider ordering invariant)"
1755        );
1756
1757        // Verify no message has role `Tool` without a preceding
1758        // Assistant-with-tool_calls (basic transcript validity).
1759        for (i, msg) in sent_messages.iter().enumerate() {
1760            if msg.role == crate::message::Role::Tool {
1761                // Ensure preceding message is an Assistant with matching tool_calls.
1762                if i > 0 {
1763                    let prev = &sent_messages[i - 1];
1764                    assert_eq!(
1765                        prev.role,
1766                        crate::message::Role::Assistant,
1767                        "Tool message at index {i} must be preceded by Assistant"
1768                    );
1769                }
1770            }
1771        }
1772    }
1773
1774    #[tokio::test]
1775    async fn mock_provider_accepts_compact_with_system_summary_user_ordering() {
1776        // Edge case: compaction just happened, the transcript is:
1777        // [System(summary), System(original prompt), User(msg), ...]
1778        // Verify that after skipping all leading System messages, the first
1779        // non-System message is still User — no mid-stream System insertion
1780        // breaks the provider's ordering invariant.
1781        let provider = MockProvider::new(vec![Completion {
1782            content: "provider accepted the sequence".to_string(),
1783            tool_calls: vec![],
1784            finish_reason: Some("stop".to_string()),
1785            usage: None,
1786            reasoning_content: None,
1787        }]);
1788
1789        // Simulate a post-compact transcript with multiple leading System msgs.
1790        let transcript = vec![
1791            Message::system("[compacted: summary at step 5] Summary text.")
1792                .with_compaction_summary(),
1793            Message::system("Original system prompt.".to_string()),
1794            Message::user("Post-compact user message.".to_string()),
1795            Message::assistant("Post-compact assistant response.".to_string()),
1796        ];
1797
1798        let completion = provider.complete(&transcript, &[]).await;
1799        assert!(
1800            completion.is_ok(),
1801            "mock provider must accept transcript with multiple leading System messages"
1802        );
1803
1804        let calls = provider.calls();
1805        let sent = calls.last().expect("provider must have been called");
1806        let first_non_system = sent.iter().find(|m| m.role != crate::message::Role::System);
1807        assert!(
1808            first_non_system.is_some(),
1809            "transcript must have a non-System message"
1810        );
1811        assert_eq!(
1812            first_non_system.unwrap().role,
1813            crate::message::Role::User,
1814            "first non-System must be User even with multiple leading System messages"
1815        );
1816    }
1817}